Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e50a087a07 | ||
|
|
5839c00b67 | ||
|
|
f5cb590e0c | ||
|
|
237b4404dc | ||
|
|
1c10076866 | ||
|
|
eb80b8f60c | ||
|
|
f38515d383 | ||
|
|
64a1b8de3e | ||
|
|
c1eb19a739 | ||
|
|
7342b4a872 | ||
|
|
db682d7857 | ||
|
|
c6491b71c9 | ||
|
|
8667d0d535 | ||
|
|
2bcb081486 | ||
|
|
fefda0015e | ||
|
|
5fc5471685 | ||
|
|
4601372ce6 | ||
|
|
6491ab38bc | ||
|
|
6476bc95df | ||
|
|
7ef1059f7b | ||
|
|
fd78fc7baa | ||
|
|
5787a6ac6a | ||
|
|
787760d02c | ||
|
|
1b2c3e30a2 | ||
|
|
ae7495baf9 | ||
|
|
2bcea8386f | ||
|
|
be7d29f45e | ||
|
|
4a762097ee | ||
|
|
c91cf1d2f8 | ||
|
|
0a43220057 | ||
|
|
288ea06c04 | ||
|
|
9ca7e39748 | ||
|
|
1cbbb61afa | ||
|
|
cf352502f8 | ||
|
|
72d270580f | ||
|
|
f0cfcbc560 | ||
|
|
f8262a64ab | ||
|
|
a24e27586a | ||
|
|
ca24d3861c | ||
|
|
eb3b99b164 | ||
|
|
681f4cb3df | ||
|
|
6fae312981 | ||
|
|
ed199eae8c | ||
|
|
e38af76bfd | ||
|
|
1726917db0 | ||
|
|
1712762305 | ||
|
|
5d75f1acd4 | ||
|
|
18b77f9a87 | ||
|
|
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 | ||
|
|
cfaee48553 | ||
|
|
1f05fe3efa | ||
|
|
e9c3b82c16 | ||
|
|
83fce70b20 | ||
|
|
d36c8bcbea | ||
|
|
5346752994 | ||
|
|
d267c4b6fc | ||
|
|
1a1da02e92 | ||
|
|
1adcffa7a8 | ||
|
|
7a2bedc4f4 | ||
|
|
5e753334ed | ||
|
|
a488bc466b | ||
|
|
2748cd5363 | ||
|
|
033165561d | ||
|
|
8e514f1008 | ||
|
|
0acd39603f | ||
|
|
4bdb0bbbf7 | ||
|
|
6b2c58f8f0 | ||
|
|
c0199c43cf | ||
|
|
7940b9a0a7 | ||
|
|
920914e3ee |
140
.github/workflows/build.yml
vendored
140
.github/workflows/build.yml
vendored
@@ -13,12 +13,18 @@ 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
|
||||
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 +56,16 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
- name: Build package
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:mac
|
||||
|
||||
- name: Build package (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
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
|
||||
@@ -83,10 +76,117 @@ jobs:
|
||||
release/*.tar.gz
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Dedicated job for Linux x64 — builds inside Debian Bullseye (GLIBC 2.31)
|
||||
# to ensure native modules (node-pty, ssh2) compile correctly and to
|
||||
# maintain GLIBC compatibility with older distros.
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-linux-x64
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
|
||||
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
|
||||
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
|
||||
build-linux-arm64:
|
||||
name: build-linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-linux-arm64
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
needs: [build, build-linux-x64, build-linux-arm64]
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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',
|
||||
@@ -122,6 +134,7 @@ const en: Messages = {
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
'tray.quit': 'Quit Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Collapse sidebar',
|
||||
@@ -217,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',
|
||||
@@ -323,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',
|
||||
@@ -389,6 +406,8 @@ const en: Messages = {
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -510,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',
|
||||
@@ -778,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',
|
||||
@@ -893,8 +919,16 @@ 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.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
@@ -913,6 +947,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',
|
||||
@@ -949,6 +987,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...',
|
||||
@@ -964,10 +1006,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': '会话日志',
|
||||
@@ -107,6 +118,7 @@ const zhCN: Messages = {
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
'tray.quit': '退出 Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起侧边栏',
|
||||
@@ -190,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} 分钟前',
|
||||
@@ -256,6 +269,8 @@ const zhCN: Messages = {
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -352,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': '大小',
|
||||
@@ -493,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': '直连',
|
||||
@@ -579,8 +601,16 @@ 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.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
@@ -599,6 +629,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': '已挂载磁盘',
|
||||
@@ -635,6 +669,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': '已断开',
|
||||
@@ -651,10 +689,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':
|
||||
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
|
||||
@@ -813,6 +891,7 @@ const zhCN: Messages = {
|
||||
'common.import': '导入',
|
||||
'common.generate': '生成',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可选',
|
||||
'common.selectPlaceholder': '请选择...',
|
||||
@@ -1021,6 +1100,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': '输出时自动滚动',
|
||||
@@ -1083,6 +1165,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 };
|
||||
};
|
||||
@@ -4,31 +4,16 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a fatal error that should stop the entire upload.
|
||||
* This includes session errors AND target directory deletion errors.
|
||||
*/
|
||||
export const isFatalUploadError = (errorMessage: string): boolean => {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
// Session-related errors
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("connection") ||
|
||||
msg.includes("disconnected") ||
|
||||
// Target directory was deleted during upload
|
||||
msg.includes("no such file") ||
|
||||
msg.includes("enoent") ||
|
||||
msg.includes("does not exist") ||
|
||||
msg.includes("write stream error") ||
|
||||
// Directory was removed
|
||||
msg.includes("directory not found") ||
|
||||
msg.includes("not a directory")
|
||||
msg.includes("session lost") ||
|
||||
msg.includes("channel not ready") ||
|
||||
msg.includes("readdir is not a function") ||
|
||||
msg.includes("channel closed") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("client disconnected") ||
|
||||
msg.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -132,6 +132,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -158,15 +159,23 @@ export const useSftpTransfers = ({
|
||||
total: number,
|
||||
speed: number,
|
||||
) => {
|
||||
// Bubble up streaming progress to parent (for directory transfers)
|
||||
onStreamProgress?.(transferred, total, speed);
|
||||
|
||||
setTransfers((prev) =>
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -249,6 +258,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -270,6 +280,9 @@ export const useSftpTransfers = ({
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
// Track bytes completed so far in this directory (including subdirectories)
|
||||
let completedBytesInDir = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
@@ -290,6 +303,13 @@ export const useSftpTransfers = ({
|
||||
};
|
||||
|
||||
if (file.type === "directory") {
|
||||
// For subdirectories, create a nested progress tracker
|
||||
let subDirCompletedBytes = 0;
|
||||
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
|
||||
subDirCompletedBytes = subCompleted;
|
||||
// Report to parent: our completed + subdirectory's (completed + in-progress)
|
||||
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
|
||||
};
|
||||
await transferDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
@@ -299,8 +319,14 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
onSubDirChildProgress,
|
||||
);
|
||||
completedBytesInDir += subDirCompletedBytes;
|
||||
} else {
|
||||
// For files, report streaming progress
|
||||
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
|
||||
onChildProgress?.(completedBytesInDir, transferred, total, speed);
|
||||
};
|
||||
await transferFile(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
@@ -310,7 +336,12 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
onFileStreamProgress,
|
||||
);
|
||||
// After file completes, add its bytes to completed total
|
||||
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
completedBytesInDir += childSize;
|
||||
onChildProgress?.(completedBytesInDir, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -393,7 +424,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
if (!hasStreamingTransfer || task.isDirectory) {
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
@@ -481,6 +512,24 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
if (task.isDirectory) {
|
||||
// Track real progress for directory transfers:
|
||||
// completedBytes = sum of all finished child files
|
||||
// + currentFileTransferred = in-progress bytes of the currently transferring file
|
||||
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
|
||||
const totalProgress = completedBytes + currentFileTransferred;
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id || t.status === "cancelled") return t;
|
||||
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: Math.max(t.transferredBytes, totalProgress),
|
||||
totalBytes: newTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
await transferDirectory(
|
||||
task,
|
||||
sourceSftpId,
|
||||
@@ -490,6 +539,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
onChildProgress,
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
@@ -590,14 +640,14 @@ export const useSftpTransfers = ({
|
||||
async (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
|
||||
const targetPane = getActivePane(targetSide);
|
||||
|
||||
@@ -633,11 +683,11 @@ export const useSftpTransfers = ({
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
|
||||
if (stat) fileSize = stat.size;
|
||||
} else if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
fullPath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) fileSize = stat.size;
|
||||
}
|
||||
} catch {
|
||||
@@ -773,7 +823,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(
|
||||
|
||||
@@ -78,6 +78,12 @@ export const useTerminalBackend = () => {
|
||||
bridge?.closeSession?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
|
||||
return bridge.setSessionEncoding(sessionId, encoding);
|
||||
}, []);
|
||||
|
||||
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
|
||||
@@ -148,6 +154,7 @@ export const useTerminalBackend = () => {
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onChainProgress,
|
||||
|
||||
@@ -12,6 +12,11 @@ export const useTrayPanelBackend = () => {
|
||||
await bridge?.openMainWindow?.();
|
||||
}, []);
|
||||
|
||||
const quitApp = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.quitApp?.();
|
||||
}, []);
|
||||
|
||||
const jumpToSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
|
||||
@@ -57,6 +62,7 @@ export const useTrayPanelBackend = () => {
|
||||
return {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -29,6 +29,14 @@ import {
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -99,20 +107,47 @@ export const useVaultState = () => {
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
// persists if its version still matches the latest.
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
// event bumps the counter; the async decrypt callback only applies state if
|
||||
// its sequence still matches, preventing stale decrypts from overwriting
|
||||
// newer data when multiple events arrive in quick succession.
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
@@ -271,7 +306,11 @@ export const useVaultState = () => {
|
||||
// Add to hosts using functional update
|
||||
setHosts((prevHosts) => {
|
||||
const updated = [...prevHosts, sanitizeHost(newHost)];
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(updated).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -279,82 +318,120 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
const sanitized = savedHosts.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
setKeys(migratedKeys);
|
||||
// Persist migrated keys
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedIdentities) setIdentities(savedIdentities);
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
};
|
||||
|
||||
init();
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -367,7 +444,17 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_HOSTS) {
|
||||
const next = safeParse<Host[]>(event.newValue) ?? [];
|
||||
setHosts(next.map(sanitizeHost));
|
||||
// Bump write version to invalidate any in-flight encrypt from this
|
||||
// window — the cross-window data is newer and must not be overwritten.
|
||||
++hostsWriteVersion.current;
|
||||
const seq = ++hostsReadSeq.current;
|
||||
const writeAtStart = hostsWriteVersion.current;
|
||||
decryptHosts(next).then((dec) => {
|
||||
// Discard if a newer storage event arrived OR a local write occurred
|
||||
// during the decrypt (writeVersion would have advanced).
|
||||
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
|
||||
setHosts(dec.map(sanitizeHost));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,13 +467,25 @@ export const useVaultState = () => {
|
||||
if (!record || isLegacyUnsupportedKey(record)) continue;
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
setKeys(migratedKeys);
|
||||
++keysWriteVersion.current;
|
||||
const seq = ++keysReadSeq.current;
|
||||
const writeAtStart = keysWriteVersion.current;
|
||||
decryptKeys(migratedKeys).then((dec) => {
|
||||
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
|
||||
setKeys(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_IDENTITIES) {
|
||||
const next = safeParse<Identity[]>(event.newValue) ?? [];
|
||||
setIdentities(next);
|
||||
++identitiesWriteVersion.current;
|
||||
const seq = ++identitiesReadSeq.current;
|
||||
const writeAtStart = identitiesWriteVersion.current;
|
||||
decryptIdentities(next).then((dec) => {
|
||||
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
|
||||
setIdentities(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,7 +541,11 @@ export const useVaultState = () => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, distro: normalized } : h,
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(next).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
@@ -32,6 +32,9 @@ interface HostTreeViewProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -53,6 +56,9 @@ interface TreeNodeProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
@@ -74,6 +80,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
@@ -215,9 +224,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
@@ -230,6 +242,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
@@ -247,6 +262,9 @@ interface HostTreeItemProps {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
@@ -258,6 +276,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
@@ -270,18 +291,40 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode && toggleHostSelection) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
onConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4" />
|
||||
{isMultiSelectMode && (
|
||||
<div className="mr-2 flex-shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHostSelection?.(host.id);
|
||||
}}>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
@@ -351,6 +394,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -471,6 +517,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -486,6 +535,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -391,9 +403,40 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
listSftp: listSftpWithEncoding,
|
||||
deleteLocalFile,
|
||||
});
|
||||
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 +481,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 +495,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 +569,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 +685,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onNavigateToBookmark={(path) => setCurrentPath(path)}
|
||||
/>
|
||||
|
||||
<SftpModalFileList
|
||||
@@ -718,6 +763,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
|
||||
@@ -260,7 +266,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
|
||||
|
||||
|
||||
@@ -290,6 +296,13 @@ 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 [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
|
||||
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
|
||||
return 'utf-8';
|
||||
});
|
||||
const terminalEncodingRef = useRef(terminalEncoding);
|
||||
terminalEncodingRef.current = terminalEncoding;
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -344,13 +357,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 +433,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setProgressLogs,
|
||||
setProgressValue,
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
// Sync terminal encoding to SSH backend before first data arrives
|
||||
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
@@ -679,6 +704,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 +901,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) => {
|
||||
@@ -892,6 +922,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (showSFTP) {
|
||||
@@ -1094,6 +1131,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onClose={() => onCloseSession?.(sessionId)}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
|
||||
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
|
||||
terminalEncoding={terminalEncoding}
|
||||
onSetTerminalEncoding={handleSetTerminalEncoding}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1122,7 +1163,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 +1343,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 +1631,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 +1709,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 +1719,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>
|
||||
|
||||
@@ -543,8 +543,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-8 px-3 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
|
||||
className="h-8 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
|
||||
>
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
|
||||
@@ -654,8 +654,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <WindowControls />}
|
||||
{/* Small drag shim to the right edge */}
|
||||
<div className="w-2 h-8 app-drag flex-shrink-0" />
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
|
||||
@@ -109,6 +109,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
const {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
@@ -200,8 +201,12 @@ const TrayPanelContent: React.FC = () => {
|
||||
void openMainWindow();
|
||||
}, [openMainWindow]);
|
||||
|
||||
const handleQuit = useCallback(() => {
|
||||
void quitApp();
|
||||
}, [quitApp]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden">
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
@@ -225,7 +230,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-3 text-sm">
|
||||
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{jumpableSessions.length > 0 && (() => {
|
||||
// Group sessions by workspace
|
||||
@@ -378,6 +383,17 @@ const TrayPanelContent: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quit button at the bottom */}
|
||||
<div className="px-3 py-2 border-t border-border/60">
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={handleQuit}
|
||||
>
|
||||
<Power size={14} />
|
||||
<span>{t("tray.quit")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1857,6 +1857,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
@@ -2000,11 +2003,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -2136,11 +2138,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -329,18 +329,26 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
) : (
|
||||
<>
|
||||
{isNavigableDirectory && (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
handleNavigate(
|
||||
currentPath === "/"
|
||||
? `/${file.name}`
|
||||
: `${currentPath}/${file.name}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
{!isLocalSession && (
|
||||
<ContextMenuItem onClick={() => handleDownload(file)}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isDownloadableFile && (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Host, RemoteFile } from "../../../types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isSessionError } from "../../../application/state/sftp/errors";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalSessionParams {
|
||||
@@ -20,6 +21,7 @@ interface UseSftpModalSessionParams {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
@@ -39,6 +41,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 +58,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>;
|
||||
@@ -75,11 +79,14 @@ export const useSftpModalSession = ({
|
||||
getHomeDir,
|
||||
onClearSelection,
|
||||
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [currentPath, setCurrentPathState] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [sessionVersion, setSessionVersion] = useState(0);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
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 +100,19 @@ export const useSftpModalSession = ({
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
const setCurrentPath = useCallback((path: string) => {
|
||||
currentPathRef.current = path;
|
||||
setCurrentPathState(path);
|
||||
}, []);
|
||||
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 +129,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,34 +152,48 @@ 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]);
|
||||
})();
|
||||
|
||||
const isSessionError = useCallback((err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("client disconnected")
|
||||
);
|
||||
}, []);
|
||||
closingPromiseRef.current = currentClosePromise;
|
||||
await currentClosePromise;
|
||||
}, [bumpSessionVersion, closeSftp, isLocalSession]);
|
||||
|
||||
// Use shared session-error classifier from errors.ts
|
||||
|
||||
const handleSessionError = useCallback(async () => {
|
||||
if (reconnectingRef.current) return;
|
||||
@@ -169,17 +204,31 @@ 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 ensureSftp();
|
||||
await closeSftpSession();
|
||||
const newSftpId = await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
|
||||
// Auto-reload current directory after successful reconnect
|
||||
try {
|
||||
const reloadPath = currentPathRef.current;
|
||||
const reloadRequestId = loadSeqRef.current;
|
||||
const list = await listSftp(newSftpId, reloadPath);
|
||||
if (
|
||||
reloadRequestId !== loadSeqRef.current ||
|
||||
currentPathRef.current !== reloadPath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClearSelection();
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Reload failed — UI still shows old data, user can manually refresh
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -195,7 +244,7 @@ export const useSftpModalSession = ({
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftp, ensureSftp, t]);
|
||||
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
@@ -248,7 +297,7 @@ export const useSftpModalSession = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -351,7 +400,6 @@ export const useSftpModalSession = ({
|
||||
void loadFiles(currentPath);
|
||||
} else {
|
||||
loadSeqRef.current += 1;
|
||||
void closeSftpSession();
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [
|
||||
@@ -367,6 +415,7 @@ export const useSftpModalSession = ({
|
||||
loadFiles,
|
||||
onClearSelection,
|
||||
open,
|
||||
setCurrentPath,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -384,6 +433,7 @@ export const useSftpModalSession = ({
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
|
||||
@@ -40,6 +40,8 @@ interface UseSftpModalTransfersParams {
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
readLocalFile: (path: string) => Promise<ArrayBuffer>;
|
||||
readSftp: (sftpId: string, path: string) => Promise<string>;
|
||||
listSftp?: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
deleteLocalFile?: (path: string) => Promise<void>;
|
||||
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftpBinaryWithProgress: (
|
||||
sftpId: string,
|
||||
@@ -113,6 +115,8 @@ export const useSftpModalTransfers = ({
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload = false,
|
||||
listSftp,
|
||||
deleteLocalFile,
|
||||
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
@@ -127,6 +131,9 @@ export const useSftpModalTransfers = ({
|
||||
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
|
||||
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Track active child transfer IDs for directory downloads (parentId -> childId)
|
||||
const activeChildTransferIdsRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Create upload bridge that adapts the modal's functions to the service interface
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
return {
|
||||
@@ -157,7 +164,7 @@ export const useSftpModalTransfers = ({
|
||||
onComplete || (() => { }),
|
||||
onError || (() => { })
|
||||
);
|
||||
|
||||
|
||||
// Check if this transfer was cancelled
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
@@ -251,13 +258,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,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -311,8 +327,8 @@ export const useSftpModalTransfers = ({
|
||||
const [folderName, phase] = newName.split('|');
|
||||
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
|
||||
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
|
||||
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
|
||||
: t('sftp.upload.phase.compressed');
|
||||
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
|
||||
: t('sftp.upload.phase.compressed');
|
||||
displayName = `${folderName} (${phaseLabel})`;
|
||||
}
|
||||
setUploadTasks(prev =>
|
||||
@@ -401,12 +417,236 @@ export const useSftpModalTransfers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files, use streaming download with save dialog
|
||||
// For remote SFTP files/directories, use streaming download with save dialog
|
||||
if (!showSaveDialog || !startStreamTransfer) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a directory download
|
||||
const isDirectory = file.type === 'directory' || (file.type === 'symlink' && file.linkTarget === 'directory');
|
||||
|
||||
if (isDirectory) {
|
||||
// For directories, download recursively
|
||||
if (!listSftp) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path (the saved "file" becomes the folder path)
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) return;
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Track the currently active child transfer ID for cancellation
|
||||
let activeChildTransferId: string | null = null;
|
||||
const setActiveChild = (childId: string | null) => {
|
||||
activeChildTransferId = childId;
|
||||
if (childId) {
|
||||
activeChildTransferIdsRef.current.set(transferId, childId);
|
||||
} else {
|
||||
activeChildTransferIdsRef.current.delete(transferId);
|
||||
}
|
||||
};
|
||||
|
||||
// Create download task for progress display
|
||||
const downloadTask: TransferTask = {
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
direction: "download",
|
||||
isDirectory: true,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, downloadTask]);
|
||||
|
||||
try {
|
||||
// Safely create target directory.
|
||||
// showSaveDialog "Replace" may leave a file (not directory) at the path,
|
||||
// so we remove it first — ONLY in this explicit overwrite context.
|
||||
try {
|
||||
await createUploadBridge.mkdirLocal(targetPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
|
||||
if (isEEXIST && deleteLocalFile) {
|
||||
// Path exists as a file (from save dialog replace), remove it and retry
|
||||
await deleteLocalFile(targetPath);
|
||||
await createUploadBridge.mkdirLocal(targetPath);
|
||||
} else {
|
||||
throw mkdirErr;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively download directory contents
|
||||
let completedBytes = 0;
|
||||
// Track visited remote paths to prevent symlink cycles
|
||||
const visitedPaths = new Set<string>();
|
||||
// Max symlink-directory nesting depth to prevent cycles (only applies to symlinks)
|
||||
const MAX_SYMLINK_DEPTH = 32;
|
||||
|
||||
const downloadDir = async (remotePath: string, localPath: string, symlinkDepth = 0): Promise<void> => {
|
||||
// Prevent revisiting the same path
|
||||
if (visitedPaths.has(remotePath)) return;
|
||||
visitedPaths.add(remotePath);
|
||||
// Check if transfer was cancelled
|
||||
if (cancelledTransferIdsRef.current.has(transferId)) {
|
||||
throw new Error('Transfer cancelled');
|
||||
}
|
||||
|
||||
const entries = await listSftp(sftpId, remotePath);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === '..' || entry.name === '.') continue;
|
||||
|
||||
// Check cancellation between files
|
||||
if (cancelledTransferIdsRef.current.has(transferId)) {
|
||||
// Cancel the active child transfer if any
|
||||
if (activeChildTransferId && cancelTransfer) {
|
||||
try { await cancelTransfer(activeChildTransferId); } catch { /* ignore */ }
|
||||
}
|
||||
throw new Error('Transfer cancelled');
|
||||
}
|
||||
|
||||
const remoteEntryPath = joinPath(remotePath, entry.name);
|
||||
const localEntryPath = `${localPath}/${entry.name}`;
|
||||
|
||||
const isRealDir = entry.type === 'directory';
|
||||
const isSymlinkDir = entry.type === 'symlink' && entry.linkTarget === 'directory';
|
||||
if (isRealDir || isSymlinkDir) {
|
||||
// Only symlink directories can form cycles; enforce depth limit for them
|
||||
if (isSymlinkDir && symlinkDepth >= MAX_SYMLINK_DEPTH) {
|
||||
throw new Error('Maximum symlink directory depth exceeded (possible symlink cycle)');
|
||||
}
|
||||
try {
|
||||
await createUploadBridge.mkdirLocal(localEntryPath);
|
||||
} catch (mkdirErr: unknown) {
|
||||
// Only ignore EEXIST (directory already exists), propagate other errors
|
||||
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
|
||||
if (!isEEXIST) throw mkdirErr;
|
||||
}
|
||||
await downloadDir(remoteEntryPath, localEntryPath, isSymlinkDir ? symlinkDepth + 1 : symlinkDepth);
|
||||
} else {
|
||||
// Download individual file
|
||||
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeChildTransferId = childTransferId;
|
||||
setActiveChild(childTransferId);
|
||||
const entrySize = typeof entry.size === 'number' ? entry.size : parseInt(String(entry.size), 10) || 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
startStreamTransfer(
|
||||
{
|
||||
transferId: childTransferId,
|
||||
sourcePath: remoteEntryPath,
|
||||
targetPath: localEntryPath,
|
||||
sourceType: 'sftp',
|
||||
targetType: 'local',
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: entrySize,
|
||||
},
|
||||
// onProgress - update parent task
|
||||
(transferred, total, speed) => {
|
||||
if (cancelledTransferIdsRef.current.has(transferId)) {
|
||||
// Actively cancel the in-flight child transfer
|
||||
if (cancelTransfer) {
|
||||
cancelTransfer(childTransferId).catch(() => { /* ignore */ });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const totalProgress = completedBytes + transferred;
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: Math.max(task.transferredBytes, totalProgress),
|
||||
totalBytes: Math.max(task.totalBytes, totalProgress, completedBytes + total),
|
||||
progress: (() => {
|
||||
const effectiveTotal = Math.max(task.totalBytes, completedBytes + total);
|
||||
if (effectiveTotal <= 0) return task.progress;
|
||||
const percent = (totalProgress / effectiveTotal) * 100;
|
||||
return Math.max(task.progress, Math.min(percent, 99));
|
||||
})(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
// onComplete
|
||||
() => {
|
||||
completedBytes += entrySize;
|
||||
setActiveChild(null);
|
||||
resolve();
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
setActiveChild(null);
|
||||
reject(new Error(error));
|
||||
}
|
||||
).then((result) => {
|
||||
// Handle resolved result with error (e.g. cancellation)
|
||||
if (result === undefined) {
|
||||
setActiveChild(null);
|
||||
reject(new Error('Stream transfer unavailable'));
|
||||
} else if (result?.error) {
|
||||
setActiveChild(null);
|
||||
reject(new Error(result.error));
|
||||
}
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await downloadDir(fullPath, targetPath);
|
||||
|
||||
// Mark as completed
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
transferredBytes: completedBytes,
|
||||
totalBytes: completedBytes,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : t("sftp.error.downloadFailed");
|
||||
const isCancelError = errorMsg.includes('cancelled') || errorMsg.includes('canceled')
|
||||
|| cancelledTransferIdsRef.current.has(transferId);
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
status: isCancelError ? "cancelled" as const : "failed" as const,
|
||||
speed: 0,
|
||||
error: isCancelError ? undefined : errorMsg,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
if (!isCancelError) {
|
||||
toast.error(errorMsg, "SFTP");
|
||||
}
|
||||
} finally {
|
||||
cancelledTransferIdsRef.current.delete(transferId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
@@ -452,12 +692,20 @@ export const useSftpModalTransfers = ({
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
progress: total > 0 ? Math.round((transferred / total) * 100) : 0,
|
||||
speed,
|
||||
}
|
||||
...task,
|
||||
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 +715,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
|
||||
)
|
||||
);
|
||||
@@ -546,7 +800,7 @@ export const useSftpModalTransfers = ({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t, listSftp, createUploadBridge, deleteLocalFile, cancelledTransferIdsRef, cancelTransfer],
|
||||
);
|
||||
|
||||
|
||||
@@ -763,13 +1017,27 @@ export const useSftpModalTransfers = ({
|
||||
if (!task) return;
|
||||
|
||||
if (task.direction === "download") {
|
||||
// For download tasks, cancel only this specific transfer
|
||||
// For download tasks, cancel the specific transfer
|
||||
// Add to cancelled set so recursive downloads can check
|
||||
cancelledTransferIdsRef.current.add(taskId);
|
||||
|
||||
if (cancelTransfer) {
|
||||
try {
|
||||
// Cancel the parent task ID (works for single-file downloads)
|
||||
await cancelTransfer(taskId);
|
||||
} catch {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
// Also cancel the active child transfer for directory downloads
|
||||
const activeChildId = activeChildTransferIdsRef.current.get(taskId);
|
||||
if (activeChildId) {
|
||||
try {
|
||||
await cancelTransfer(activeChildId);
|
||||
} catch {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
activeChildTransferIdsRef.current.delete(taskId);
|
||||
}
|
||||
}
|
||||
// Mark task as cancelled
|
||||
setUploadTasks(prev =>
|
||||
|
||||
@@ -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,13 @@ interface SftpPaneToolbarProps {
|
||||
setFileNameError: (value: string | null) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
setNewFolderName: (value: string) => 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 +80,12 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
setFileNameError,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
setNewFolderName,
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
onToggleBookmark,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
}) => (
|
||||
<>
|
||||
{/* Toolbar - always visible when connected */}
|
||||
@@ -154,27 +168,120 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
<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"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setShowNewFolderDialog(true)}
|
||||
onClick={() => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
}}
|
||||
title={t("sftp.newFolder")}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
|
||||
@@ -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,8 @@ 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";
|
||||
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -84,6 +88,32 @@ 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 remoteBookmarks = useSftpBookmarks({
|
||||
host: currentHost,
|
||||
currentPath: pane.connection?.currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
const localBookmarks = useLocalSftpBookmarks({
|
||||
currentPath: pane.connection?.currentPath,
|
||||
});
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
|
||||
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
|
||||
files: pane.files,
|
||||
filter: pane.filter,
|
||||
@@ -201,7 +231,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
() => ({
|
||||
onRename: (fileName: string) => openRenameDialog(fileName),
|
||||
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
|
||||
onNewFolder: () => setShowNewFolderDialog(true),
|
||||
onNewFolder: () => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
},
|
||||
onNewFile: () => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
@@ -216,6 +249,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
pane.files,
|
||||
setFileNameError,
|
||||
setNewFileName,
|
||||
setNewFolderName,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
],
|
||||
@@ -293,6 +327,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setFileNameError={setFileNameError}
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
setNewFolderName={setNewFolderName}
|
||||
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
|
||||
|
||||
73
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
73
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
// ── Shared external store so every hook instance sees the same bookmarks ──
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
const isRoot = currentPath === "/" || /^[A-Za-z]:\\?$/.test(currentPath);
|
||||
const label = isRoot
|
||||
? currentPath
|
||||
: currentPath.split(/[\\/]/).filter(Boolean).pop() || currentPath;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path: currentPath,
|
||||
label,
|
||||
};
|
||||
setBookmarks((prev) => [...prev, newBookmark]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
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,12 +2,13 @@
|
||||
* 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 { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Snippet, Host } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import ThemeCustomizeModal from './ThemeCustomizeModal';
|
||||
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
|
||||
@@ -32,6 +33,12 @@ export interface TerminalToolbarProps {
|
||||
// Search functionality
|
||||
isSearchOpen?: boolean;
|
||||
onToggleSearch?: () => void;
|
||||
// Compose bar
|
||||
isComposeBarOpen?: boolean;
|
||||
onToggleComposeBar?: () => void;
|
||||
// Terminal encoding
|
||||
terminalEncoding?: 'utf-8' | 'gb18030';
|
||||
onSetTerminalEncoding?: (encoding: 'utf-8' | 'gb18030') => void;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
@@ -53,6 +60,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onClose,
|
||||
isSearchOpen,
|
||||
onToggleSearch,
|
||||
isComposeBarOpen,
|
||||
onToggleComposeBar,
|
||||
terminalEncoding,
|
||||
onSetTerminalEncoding,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
@@ -61,6 +72,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const currentThemeId = host?.theme || defaultThemeId;
|
||||
@@ -113,6 +125,44 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.encoding")}
|
||||
aria-label={t("terminal.toolbar.encoding")}
|
||||
>
|
||||
<Languages size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
terminalEncoding === enc && "font-medium"
|
||||
)}
|
||||
onClick={() => onSetTerminalEncoding(enc)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
terminalEncoding === enc ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -173,6 +223,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,7 +89,9 @@ export type TerminalSessionStartersContext = {
|
||||
setProgressLogs: Dispatch<SetStateAction<string[]>>;
|
||||
setProgressValue: Dispatch<SetStateAction<number>>;
|
||||
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
|
||||
t?: (key: string) => string;
|
||||
|
||||
onSessionAttached?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
@@ -123,6 +129,7 @@ const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
ctx.sessionRef.current = id;
|
||||
ctx.onSessionAttached?.(id);
|
||||
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
let data = chunk;
|
||||
@@ -183,9 +190,9 @@ const runDistroDetection = async (
|
||||
timeout: 8000,
|
||||
});
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/ID=([\\w\\-]+)/i);
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
const distro = idMatch
|
||||
? idMatch[1].replace(/"/g, "")
|
||||
? idMatch[1]
|
||||
: (data.split(/\s+/)[0] || "").toLowerCase();
|
||||
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
|
||||
} catch (err) {
|
||||
@@ -194,6 +201,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 +240,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 +259,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 +279,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 +310,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 +400,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 +418,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';
|
||||
|
||||
|
||||
85
electron/bridges/credentialBridge.cjs
Normal file
85
electron/bridges/credentialBridge.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Credential Bridge - Field-level encryption for sensitive data at rest
|
||||
*
|
||||
* Uses Electron's safeStorage API to encrypt individual sensitive fields
|
||||
* (passwords, tokens, private keys) before they are persisted to localStorage.
|
||||
*
|
||||
* Sentinel prefix "enc:v1:" on encrypted values enables:
|
||||
* - Detection of already-encrypted vs plaintext (migration)
|
||||
* - No double-encryption
|
||||
* - Future re-keying with enc:v2: etc.
|
||||
*
|
||||
* When safeStorage is unavailable (e.g. Linux without libsecret), all values
|
||||
* pass through unmodified so the app still works.
|
||||
*/
|
||||
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
|
||||
let safeStorage = null;
|
||||
|
||||
/**
|
||||
* Register IPC handlers for credential encryption/decryption
|
||||
* @param {Electron.IpcMain} ipcMain
|
||||
* @param {typeof Electron} electronModule
|
||||
*/
|
||||
function registerHandlers(ipcMain, electronModule) {
|
||||
safeStorage = electronModule?.safeStorage ?? null;
|
||||
|
||||
ipcMain.handle("netcatty:credentials:available", () => {
|
||||
return Boolean(safeStorage?.isEncryptionAvailable?.());
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:encrypt", (_event, plaintext) => {
|
||||
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
||||
return plaintext ?? "";
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
return plaintext;
|
||||
}
|
||||
// If value looks like it might already be encrypted, verify by attempting
|
||||
// to decode and decrypt. If it succeeds the value is genuinely encrypted
|
||||
// and we return it as-is; if it fails, the prefix was a coincidence and
|
||||
// we proceed to encrypt the raw plaintext.
|
||||
if (plaintext.startsWith(ENC_PREFIX)) {
|
||||
try {
|
||||
const base64 = plaintext.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
safeStorage.decryptString(buf); // throws on invalid ciphertext
|
||||
return plaintext; // verified — already encrypted
|
||||
} catch {
|
||||
// Not valid ciphertext — fall through to encrypt
|
||||
}
|
||||
}
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(plaintext);
|
||||
return ENC_PREFIX + encrypted.toString("base64");
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] encrypt failed, returning plaintext:", err?.message || err);
|
||||
return plaintext;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:decrypt", (_event, value) => {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return value ?? "";
|
||||
}
|
||||
// Not encrypted — pass through (supports migration from plaintext)
|
||||
if (!value.startsWith(ENC_PREFIX)) {
|
||||
return value;
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
// Cannot decrypt without safeStorage; return raw value
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const base64 = value.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
return safeStorage.decryptString(buf);
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] decrypt failed:", err?.message || err);
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerHandlers };
|
||||
@@ -691,6 +691,13 @@ function registerHandlers(ipcMain) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:quitApp", async () => {
|
||||
const { app } = electronModule;
|
||||
closeToTray = false;
|
||||
app.quit();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log("[GlobalShortcut] IPC handlers registered");
|
||||
}
|
||||
|
||||
@@ -700,6 +707,20 @@ function registerHandlers(ipcMain) {
|
||||
function cleanup() {
|
||||
unregisterGlobalHotkey();
|
||||
destroyTray();
|
||||
|
||||
if (trayPanelRefreshTimer) {
|
||||
clearInterval(trayPanelRefreshTimer);
|
||||
trayPanelRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
|
||||
try {
|
||||
trayPanelWindow.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
trayPanelWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -55,10 +55,10 @@ async function listLocalDir(event, payload) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
// fs.promises.stat follows symlinks, so we get the target's stats
|
||||
const stat = await fs.promises.stat(fullPath);
|
||||
|
||||
|
||||
let type;
|
||||
let linkTarget = null;
|
||||
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
// This is a symlink - mark it as such and record the target type
|
||||
type = "symlink";
|
||||
@@ -69,10 +69,10 @@ async function listLocalDir(event, payload) {
|
||||
} else {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type,
|
||||
@@ -201,7 +201,7 @@ async function getSystemInfo() {
|
||||
async function readKnownHosts() {
|
||||
const homeDir = os.homedir();
|
||||
const knownHostsPaths = [];
|
||||
|
||||
|
||||
if (process.platform === "win32") {
|
||||
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
|
||||
knownHostsPaths.push(path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"));
|
||||
@@ -212,9 +212,9 @@ async function readKnownHosts() {
|
||||
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
|
||||
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
|
||||
}
|
||||
|
||||
|
||||
let combinedContent = "";
|
||||
|
||||
|
||||
for (const knownHostsPath of knownHostsPaths) {
|
||||
try {
|
||||
if (fs.existsSync(knownHostsPath)) {
|
||||
@@ -227,7 +227,7 @@ async function readKnownHosts() {
|
||||
console.warn(`Failed to read known_hosts from ${knownHostsPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return combinedContent || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -127,7 +127,102 @@ const encodePathForSession = (sftpId, inputPath, requestedEncoding) => {
|
||||
return encodePath(inputPath, encoding);
|
||||
};
|
||||
|
||||
const getSftpChannel = (client) => client?.sftp || client?.client?.sftp;
|
||||
const hasSftpChannelApi = (value) =>
|
||||
!!value &&
|
||||
typeof value.readdir === "function" &&
|
||||
typeof value.stat === "function" &&
|
||||
typeof value.mkdir === "function" &&
|
||||
typeof value.unlink === "function";
|
||||
|
||||
const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
|
||||
const tryOpenSftpChannel = (client) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
reject(new Error("SFTP channel open timed out"));
|
||||
}, SFTP_CHANNEL_OPEN_TIMEOUT_MS);
|
||||
try {
|
||||
sshClient.sftp((err, sftp) => {
|
||||
clearTimeout(timer);
|
||||
if (settled) {
|
||||
// Timeout already fired — close the orphaned channel to prevent leaks
|
||||
try { sftp?.end?.(); } catch { }
|
||||
return;
|
||||
}
|
||||
if (err) return reject(err);
|
||||
resolve(sftp || null);
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
const getSftpChannel = async (client) => {
|
||||
if (!client) return null;
|
||||
|
||||
if (hasSftpChannelApi(client.sftp)) {
|
||||
return client.sftp;
|
||||
}
|
||||
|
||||
// sudo sessions must keep using the sudo-bootstrapped SFTP wrapper.
|
||||
// Reopening with sshClient.sftp() would silently downgrade permissions.
|
||||
if (client.__netcattySudoMode) {
|
||||
console.warn("[SFTP] Sudo SFTP channel is unavailable; automatic recovery is disabled for sudo sessions. Please reconnect.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do not treat ssh2's "client.sftp" method as a channel object.
|
||||
// Re-open a fresh channel when the cached channel is stale.
|
||||
if (!client.client || typeof client.client.sftp !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deduplicate per-client: avoid concurrent channel re-open attempts
|
||||
if (client._reopeningPromise) {
|
||||
try {
|
||||
return await client._reopeningPromise;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
client._reopeningPromise = (async () => {
|
||||
try {
|
||||
const reopened = await tryOpenSftpChannel(client);
|
||||
if (hasSftpChannelApi(reopened)) {
|
||||
client.sftp = reopened;
|
||||
return reopened;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to recover SFTP channel", err?.message || String(err));
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await client._reopeningPromise;
|
||||
} finally {
|
||||
client._reopeningPromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
const requireSftpChannel = async (client) => {
|
||||
const sftp = await getSftpChannel(client);
|
||||
if (!sftp) {
|
||||
throw new Error("SFTP session lost. Please reconnect.");
|
||||
}
|
||||
return sftp;
|
||||
};
|
||||
|
||||
const statAsync = (sftp, targetPath) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -241,19 +336,57 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
|
||||
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
|
||||
if (encoding === "utf-8") {
|
||||
await requireSftpChannel(client);
|
||||
const encodedPath = encodePath(dirPath, encoding);
|
||||
await client.mkdir(encodedPath, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) throw new Error("SFTP channel not ready");
|
||||
const sftp = await requireSftpChannel(client);
|
||||
|
||||
const normalizedPath = await normalizeRemotePathString(client, dirPath);
|
||||
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
|
||||
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 +440,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 +844,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 +950,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
|
||||
@@ -865,10 +986,7 @@ async function listSftp(event, payload) {
|
||||
const pathEncoding = resolveEncodingForRequest(payload.sftpId, requestedEncoding);
|
||||
const encodedPath = encodePath(basePath, pathEncoding);
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) {
|
||||
throw new Error("SFTP channel not ready");
|
||||
}
|
||||
const sftp = await requireSftpChannel(client);
|
||||
|
||||
let list;
|
||||
try {
|
||||
@@ -989,6 +1107,7 @@ async function readSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const buffer = await client.get(encodedPath);
|
||||
@@ -1002,6 +1121,7 @@ async function readSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const buffer = await client.get(encodedPath);
|
||||
@@ -1016,6 +1136,7 @@ async function writeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.put(Buffer.from(payload.content, "utf-8"), encodedPath);
|
||||
@@ -1029,6 +1150,7 @@ async function writeSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.put(Buffer.from(payload.content), encodedPath);
|
||||
@@ -1045,6 +1167,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const { sftpId, path: remotePath, content, transferId } = payload;
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(remotePath, encoding);
|
||||
|
||||
@@ -1279,6 +1402,7 @@ async function deleteSftp(event, payload) {
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
|
||||
if (encoding === "utf-8") {
|
||||
await requireSftpChannel(client);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const stat = await client.stat(encodedPath);
|
||||
if (stat.isDirectory) {
|
||||
@@ -1316,8 +1440,7 @@ async function deleteSftp(event, payload) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) throw new Error("SFTP channel not ready");
|
||||
const sftp = await requireSftpChannel(client);
|
||||
const normalizedPath = await normalizeRemotePathString(client, payload.path);
|
||||
await removeRemotePathInternal(sftp, normalizedPath, encoding);
|
||||
return true;
|
||||
@@ -1330,6 +1453,7 @@ async function renameSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedOldPath = encodePath(payload.oldPath, encoding);
|
||||
const encodedNewPath = encodePath(payload.newPath, encoding);
|
||||
@@ -1344,6 +1468,7 @@ async function statSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const stat = await client.stat(encodedPath);
|
||||
@@ -1363,6 +1488,7 @@ async function chmodSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.chmod(encodedPath, parseInt(payload.mode, 8));
|
||||
@@ -1400,6 +1526,7 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
getSftpClients,
|
||||
requireSftpChannel,
|
||||
encodePathForSession,
|
||||
ensureRemoteDirForSession,
|
||||
openSftp,
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for DEK-Info header (legacy PEM encryption indicator)
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
@@ -43,7 +43,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
@@ -61,132 +61,132 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
|
||||
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
const hasExplicitPassword = !!password;
|
||||
const hasExplicitAgent = !!agent;
|
||||
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
|
||||
|
||||
|
||||
// Determine if this is a password-only or key-only connection
|
||||
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
// - No explicit auth is configured (pure fallback mode)
|
||||
// When user configured key/password, system agent should only be used AFTER as fallback
|
||||
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
|
||||
|
||||
|
||||
// Determine effective agent
|
||||
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
|
||||
|
||||
|
||||
// Determine effective privateKey (user-provided takes priority)
|
||||
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
|
||||
|
||||
|
||||
// Determine fallback keys (keys to try after user's primary auth fails)
|
||||
// - If user provided a key: all default keys are fallbacks
|
||||
// - If no explicit auth: first default key is primary, rest are fallbacks
|
||||
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
: defaultKeys;
|
||||
|
||||
|
||||
// Check if we need dynamic handler (have fallback options)
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
(isPasswordOnly && defaultKeys.length > 0);
|
||||
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
@@ -194,15 +194,15 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
|
||||
|
||||
return {
|
||||
authHandler: authMethods,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: effectiveAgent,
|
||||
usedDefaultKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Build comprehensive authMethods array with all auth options
|
||||
// Order depends on what user explicitly configured:
|
||||
// - Password-only: password -> agent -> default keys -> keyboard-interactive
|
||||
@@ -210,144 +210,132 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
|
||||
// - No explicit auth: agent -> default keys -> keyboard-interactive
|
||||
const authMethods = [];
|
||||
|
||||
|
||||
if (isPasswordOnly) {
|
||||
// Password-only: password first, then fallbacks
|
||||
// Password-only: respect user's explicit choice, no key/agent fallback
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
|
||||
// Add agent and default keys AFTER password as fallback
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
for (const keyInfo of defaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (isKeyOnly) {
|
||||
// Key-only: user key first, then password (if any), then agent/default keys as fallback
|
||||
|
||||
|
||||
// 1. User-provided key first
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
id: "publickey-user"
|
||||
});
|
||||
|
||||
|
||||
// 2. Password (if configured alongside key)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
|
||||
// 3. System agent as fallback (AFTER user's key)
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
|
||||
|
||||
|
||||
// 1. Agent (user-provided or system)
|
||||
if (effectiveAgent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
|
||||
// 2. User-provided key
|
||||
if (privateKey) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
id: "publickey-user"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 3. Password (if configured)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 5. If no user key provided, add first default key at the beginning (after agent)
|
||||
if (!privateKey && defaultKeys.length > 0) {
|
||||
const insertIndex = effectiveAgent ? 1 : 0;
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Keyboard-interactive as last resort
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
return callback({
|
||||
@@ -355,107 +343,107 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
|
||||
// even if effectiveAgent is null (for fallback scenarios)
|
||||
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
|
||||
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: returnAgent,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrases for encrypted default keys
|
||||
* Shows a modal for each encrypted key and collects passphrases
|
||||
@@ -466,16 +454,16 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
return { keys: [], cancelled: false };
|
||||
}
|
||||
|
||||
|
||||
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
|
||||
|
||||
|
||||
const unlockedKeys = [];
|
||||
let wasCancelled = false;
|
||||
|
||||
|
||||
for (const keyInfo of encryptedKeys) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
@@ -483,27 +471,27 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
keyInfo.keyName,
|
||||
hostname
|
||||
);
|
||||
|
||||
|
||||
// Handle different response types
|
||||
if (!result) {
|
||||
// Timeout or error - continue with next key
|
||||
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (result.cancelled) {
|
||||
// User clicked Cancel - stop the entire flow
|
||||
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
|
||||
wasCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (result.skipped) {
|
||||
// User clicked Skip - continue with next key
|
||||
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (result.passphrase) {
|
||||
// User provided passphrase
|
||||
unlockedKeys.push({
|
||||
@@ -514,19 +502,19 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { keys: unlockedKeys, cancelled: wasCancelled };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -183,6 +227,31 @@ let electronModule = null;
|
||||
// Cache persists until auth failure, then cleared to retry all methods
|
||||
const authMethodCache = new Map();
|
||||
|
||||
// Per-session terminal encoding (default: utf-8)
|
||||
const sessionEncodings = new Map();
|
||||
// Per-session stateful iconv decoders (keyed by sessionId, value: { stdout, stderr })
|
||||
const sessionDecoders = new Map();
|
||||
const iconv = require("iconv-lite");
|
||||
|
||||
function getSessionDecoder(sessionId, stream) {
|
||||
let decoders = sessionDecoders.get(sessionId);
|
||||
if (!decoders) {
|
||||
decoders = { stdout: null, stderr: null };
|
||||
sessionDecoders.set(sessionId, decoders);
|
||||
}
|
||||
if (!decoders[stream]) {
|
||||
const enc = sessionEncodings.get(sessionId) || "utf-8";
|
||||
decoders[stream] = iconv.getDecoder(enc);
|
||||
}
|
||||
return decoders[stream];
|
||||
}
|
||||
|
||||
function resetSessionDecoders(sessionId) {
|
||||
const enc = sessionEncodings.get(sessionId) || "utf-8";
|
||||
const decoders = { stdout: iconv.getDecoder(enc), stderr: iconv.getDecoder(enc) };
|
||||
sessionDecoders.set(sessionId, decoders);
|
||||
}
|
||||
|
||||
function getAuthCacheKey(username, hostname, port) {
|
||||
return `${username}@${hostname}:${port || 22}`;
|
||||
}
|
||||
@@ -277,22 +346,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 +519,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
|
||||
@@ -553,9 +592,14 @@ async function startSSHSession(event, options) {
|
||||
// If no primary auth method configured, try ssh-agent first, then ALL default keys
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
// First, try to use ssh-agent if available (this is what regular SSH does)
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
: process.env.SSH_AUTH_SOCK;
|
||||
let sshAgentSocket;
|
||||
if (process.platform === "win32") {
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check", agentStatus);
|
||||
sshAgentSocket = agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
|
||||
} else {
|
||||
sshAgentSocket = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
@@ -582,14 +626,23 @@ async function startSSHSession(event, options) {
|
||||
|
||||
// Agent forwarding
|
||||
if (options.agentForwarding) {
|
||||
connectOpts.agentForward = true;
|
||||
if (!connectOpts.agent) {
|
||||
if (process.platform === "win32") {
|
||||
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check (agentForwarding)", agentStatus);
|
||||
if (agentStatus.running) {
|
||||
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
} else {
|
||||
connectOpts.agent = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
}
|
||||
// Only enable forwarding when an agent is actually available
|
||||
if (connectOpts.agent) {
|
||||
connectOpts.agentForward = true;
|
||||
} else {
|
||||
log("Agent forwarding requested but no agent available, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
// Build authentication handler with fallback support
|
||||
@@ -948,11 +1001,13 @@ async function startSSHSession(event, options) {
|
||||
};
|
||||
|
||||
stream.on("data", (data) => {
|
||||
bufferData(data.toString("utf8"));
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
bufferData(decoder.write(data));
|
||||
});
|
||||
|
||||
stream.stderr?.on("data", (data) => {
|
||||
bufferData(data.toString("utf8"));
|
||||
const decoder = getSessionDecoder(sessionId, "stderr");
|
||||
bufferData(decoder.write(data));
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
@@ -964,12 +1019,19 @@ async function startSSHSession(event, options) {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-seed encoding from host charset if it's a GB variant
|
||||
if (options.charset && /^gb/i.test(String(options.charset).trim())) {
|
||||
sessionEncodings.set(sessionId, "gb18030");
|
||||
}
|
||||
|
||||
// Run startup command if specified
|
||||
if (options.startupCommand) {
|
||||
setTimeout(() => {
|
||||
@@ -1311,7 +1373,9 @@ async function startSSHSessionWrapper(event, options) {
|
||||
if (isAuthError) {
|
||||
// Check if there are encrypted default keys we haven't tried yet
|
||||
// Only offer retry if no unlocked keys were provided in this attempt
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const hasJumpHosts = options.jumpHosts && options.jumpHosts.length > 0;
|
||||
const isPasswordOnly = !hasJumpHosts && !options.agentForwarding && !!options.password && !options.privateKey && !options.certificate;
|
||||
if (!isPasswordOnly && (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0)) {
|
||||
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
@@ -1470,8 +1534,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 +1589,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 +1637,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 +1819,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)
|
||||
@@ -1758,6 +1836,24 @@ async function getServerStats(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terminal encoding for an active SSH session
|
||||
*/
|
||||
async function setSessionEncoding(_event, { sessionId, encoding }) {
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session || !session.stream) {
|
||||
return { ok: false, encoding: encoding || "utf-8" };
|
||||
}
|
||||
const enc = String(encoding || "utf-8").toLowerCase();
|
||||
if (!iconv.encodingExists(enc)) {
|
||||
return { ok: false, encoding: enc };
|
||||
}
|
||||
sessionEncodings.set(sessionId, enc);
|
||||
// Reset stateful decoders so new data uses the updated encoding
|
||||
resetSessionDecoders(sessionId);
|
||||
return { ok: true, encoding: enc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for SSH operations
|
||||
*/
|
||||
@@ -1767,6 +1863,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:ssh:stats", getServerStats);
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
ipcMain.handle("netcatty:ssh:setEncoding", setSessionEncoding);
|
||||
ipcMain.handle("netcatty:ssh:check-agent", async () => {
|
||||
return await checkWindowsSshAgent();
|
||||
});
|
||||
|
||||
@@ -6,11 +6,24 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession } = require("./sftpBridge.cjs");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = 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,107 @@ 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) {
|
||||
await requireSftpChannel(client);
|
||||
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 +141,107 @@ 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) {
|
||||
await requireSftpChannel(client);
|
||||
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 +249,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 +284,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);
|
||||
@@ -224,29 +406,26 @@ async function startTransfer(event, payload, onProgress) {
|
||||
} else if (sourceType === 'sftp') {
|
||||
const client = sftpClients.get(sourceSftpId);
|
||||
if (!client) throw new Error("Source SFTP session not found");
|
||||
await requireSftpChannel(client);
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const stat = await client.stat(encodedSourcePath);
|
||||
fileSize = stat.size;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +433,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 +454,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 +463,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 +487,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 +533,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;
|
||||
|
||||
@@ -438,15 +519,15 @@ function attachOAuthLoadingOverlay(win) {
|
||||
`;
|
||||
|
||||
win.webContents.on("did-start-loading", () => {
|
||||
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => {});
|
||||
win.webContents.executeJavaScript(injectOverlayScript, true).catch(() => { });
|
||||
});
|
||||
|
||||
win.webContents.on("did-stop-loading", () => {
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
|
||||
});
|
||||
|
||||
win.webContents.on("did-fail-load", () => {
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => {});
|
||||
win.webContents.executeJavaScript(removeOverlayScript, true).catch(() => { });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -543,12 +624,12 @@ 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
|
||||
electronApp = app;
|
||||
|
||||
|
||||
const osTheme = nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
||||
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
|
||||
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
|
||||
@@ -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", () => {
|
||||
@@ -665,6 +775,7 @@ async function createWindow(electronModule, options) {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowStateSync(state);
|
||||
closeSettingsWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -732,36 +843,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);
|
||||
@@ -779,7 +872,7 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html");
|
||||
|
||||
|
||||
onRegisterBridge?.(win);
|
||||
return win;
|
||||
}
|
||||
@@ -788,15 +881,15 @@ 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
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.focus();
|
||||
return settingsWindow;
|
||||
}
|
||||
|
||||
|
||||
const osTheme = electronModule?.nativeTheme?.shouldUseDarkColors ? "dark" : "light";
|
||||
const effectiveTheme = currentTheme === "dark" || currentTheme === "light" ? currentTheme : osTheme;
|
||||
const frontendBackground = resolveFrontendBackgroundColor(electronDir || __dirname, effectiveTheme);
|
||||
@@ -830,6 +923,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);
|
||||
@@ -863,7 +1002,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
// Load the settings page
|
||||
const settingsPath = '/#/settings';
|
||||
|
||||
|
||||
if (isDev) {
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
@@ -876,7 +1015,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
@@ -1050,19 +1189,19 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
const template = [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: tMenu(language, "edit"),
|
||||
@@ -1101,7 +1240,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -241,6 +242,15 @@ function focusMainWindow() {
|
||||
const win = wins && wins.length ? wins[0] : null;
|
||||
if (!win) return false;
|
||||
|
||||
// Check if the webContents has crashed or been destroyed
|
||||
try {
|
||||
if (win.webContents?.isCrashed?.()) {
|
||||
console.warn('[Main] Main window webContents has crashed, destroying window');
|
||||
win.destroy();
|
||||
return false;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (win.isMinimized && win.isMinimized()) win.restore();
|
||||
} catch {}
|
||||
@@ -279,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 {
|
||||
@@ -393,6 +404,7 @@ const registerBridges = (win) => {
|
||||
sessionLogsBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
globalShortcutBridge.registerHandlers(ipcMain);
|
||||
credentialBridge.registerHandlers(ipcMain, electronModule);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -633,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 };
|
||||
}
|
||||
@@ -685,100 +697,130 @@ function showStartupError(err) {
|
||||
}
|
||||
}
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(() => {
|
||||
registerAppProtocol();
|
||||
|
||||
// Set dock icon on macOS
|
||||
if (isMac && appIcon && app.dock?.setIcon) {
|
||||
try {
|
||||
app.dock.setIcon(appIcon);
|
||||
} catch (err) {
|
||||
console.warn("Failed to set dock icon", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = windowManager.buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow();
|
||||
const settingsWin = windowManager.getSettingsWindow();
|
||||
const isPrimary = win === mainWin || win === settingsWin;
|
||||
if (!isPrimary) {
|
||||
win.setMenuBarVisibility(false);
|
||||
win.autoHideMenuBar = true;
|
||||
win.setMenu(null);
|
||||
if (appIcon && win.setIcon) win.setIcon(appIcon);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Create the main window
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
try {
|
||||
app.quit();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Re-create window on macOS dock click
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
// Ensure single-instance behavior — must run before app.whenReady() so
|
||||
// the second instance never attempts to register the app:// protocol or
|
||||
// create a BrowserWindow (which would fail with ERR_FAILED).
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (!focusMainWindow()) {
|
||||
// Window is missing or crashed — try to recreate it
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
console.error("[Main] Failed to recreate window on second-instance:", err);
|
||||
showStartupError(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure single-instance behavior focuses existing window
|
||||
try {
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
focusMainWindow();
|
||||
// Application lifecycle
|
||||
app.whenReady().then(() => {
|
||||
registerAppProtocol();
|
||||
|
||||
// Set dock icon on macOS
|
||||
if (isMac && appIcon && app.dock?.setIcon) {
|
||||
try {
|
||||
app.dock.setIcon(appIcon);
|
||||
} catch (err) {
|
||||
console.warn("Failed to set dock icon", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = windowManager.buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow();
|
||||
const settingsWin = windowManager.getSettingsWindow();
|
||||
const isPrimary = win === mainWin || win === settingsWin;
|
||||
if (!isPrimary) {
|
||||
win.setMenuBarVisibility(false);
|
||||
win.autoHideMenuBar = true;
|
||||
win.setMenu(null);
|
||||
if (appIcon && win.setIcon) win.setIcon(appIcon);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Cleanup on all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
// Create the main window
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
try {
|
||||
app.quit();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// 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);
|
||||
showStartupError(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
console.warn("Error during terminal cleanup:", err);
|
||||
}
|
||||
try {
|
||||
portForwardingBridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT to prevent zombie processes
|
||||
for (const sig of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(sig, () => {
|
||||
console.log(`[Main] Received ${sig}, quitting…`);
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
console.warn("Error during terminal cleanup:", err);
|
||||
}
|
||||
try {
|
||||
portForwardingBridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
module.exports = {
|
||||
|
||||
@@ -376,6 +376,8 @@ const api = {
|
||||
closeSession: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:close", { sessionId });
|
||||
},
|
||||
setSessionEncoding: (sessionId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
@@ -801,6 +803,7 @@ const api = {
|
||||
// Tray panel window
|
||||
hideTrayPanel: () => ipcRenderer.invoke("netcatty:trayPanel:hide"),
|
||||
openMainWindow: () => ipcRenderer.invoke("netcatty:trayPanel:openMainWindow"),
|
||||
quitApp: () => ipcRenderer.invoke("netcatty:trayPanel:quitApp"),
|
||||
jumpToSessionFromTrayPanel: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
|
||||
connectToHostFromTrayPanel: (hostId) =>
|
||||
@@ -844,8 +847,59 @@ const api = {
|
||||
readClipboardText: async () => {
|
||||
return ipcRenderer.invoke("netcatty:clipboard:readText");
|
||||
},
|
||||
|
||||
// Credential encryption (field-level safeStorage)
|
||||
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
|
||||
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
|
||||
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
|
||||
};
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
11
global.d.ts
vendored
11
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;
|
||||
@@ -225,6 +229,7 @@ declare global {
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
@@ -582,6 +587,11 @@ declare global {
|
||||
getPathForFile?(file: File): string | undefined;
|
||||
readClipboardText?(): Promise<string>;
|
||||
|
||||
// Credential encryption (field-level safeStorage for sensitive data at rest)
|
||||
credentialsAvailable?(): Promise<boolean>;
|
||||
credentialsEncrypt?(plaintext: string): Promise<string>;
|
||||
credentialsDecrypt?(value: string): Promise<string>;
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
@@ -610,6 +620,7 @@ declare global {
|
||||
|
||||
hideTrayPanel?(): Promise<{ success: boolean }>;
|
||||
openMainWindow?(): Promise<{ success: boolean }>;
|
||||
quitApp?(): Promise<{ success: boolean }>;
|
||||
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
|
||||
onTrayPanelCloseRequest?(callback: () => void): () => void;
|
||||
|
||||
@@ -41,6 +41,9 @@ export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_v
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
|
||||
// SFTP Local Bookmarks
|
||||
export const STORAGE_KEY_SFTP_LOCAL_BOOKMARKS = 'netcatty_sftp_local_bookmarks_v1';
|
||||
|
||||
// SFTP Settings
|
||||
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
@@ -64,3 +67,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;
|
||||
}
|
||||
}
|
||||
184
infrastructure/persistence/secureFieldAdapter.ts
Normal file
184
infrastructure/persistence/secureFieldAdapter.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Secure Field Adapter — Renderer-side helpers for field-level encryption
|
||||
*
|
||||
* Encrypts / decrypts individual sensitive fields within domain models before
|
||||
* they are written to (or after they are read from) localStorage.
|
||||
*
|
||||
* The heavy lifting is done by Electron's safeStorage via the credential
|
||||
* bridge IPC. When the bridge is unavailable (web fallback, tests) every
|
||||
* function degrades to a no-op — values pass through unmodified.
|
||||
*/
|
||||
|
||||
import type { Host, Identity, SSHKey } from "../../domain/models";
|
||||
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
|
||||
import { netcattyBridge } from "../services/netcattyBridge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const bridge = () => netcattyBridge.get();
|
||||
|
||||
export async function encryptField(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) return value;
|
||||
const b = bridge();
|
||||
if (!b?.credentialsEncrypt) return value;
|
||||
return b.credentialsEncrypt(value);
|
||||
}
|
||||
|
||||
export async function decryptField(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) return value;
|
||||
const b = bridge();
|
||||
if (!b?.credentialsDecrypt) return value;
|
||||
return b.credentialsDecrypt(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptHostSecrets(host: Host): Promise<Host> {
|
||||
const out = { ...host };
|
||||
out.password = await encryptField(out.password);
|
||||
out.telnetPassword = await encryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptHostSecrets(host: Host): Promise<Host> {
|
||||
const out = { ...host };
|
||||
out.password = await decryptField(out.password);
|
||||
out.telnetPassword = await decryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await decryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSHKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
||||
const out = { ...key };
|
||||
out.passphrase = await encryptField(out.passphrase);
|
||||
out.privateKey = (await encryptField(out.privateKey)) ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
||||
const out = { ...key };
|
||||
out.passphrase = await decryptField(out.passphrase);
|
||||
out.privateKey = (await decryptField(out.privateKey)) ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
||||
const out = { ...identity };
|
||||
out.password = await encryptField(out.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
||||
const out = { ...identity };
|
||||
out.password = await decryptField(out.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Connection (Cloud Sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
||||
const out = { ...conn };
|
||||
|
||||
if (out.tokens) {
|
||||
const t = { ...out.tokens };
|
||||
t.accessToken = (await encryptField(t.accessToken)) ?? "";
|
||||
t.refreshToken = await encryptField(t.refreshToken);
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
// WebDAV — use authType (required field unique to WebDAVConfig) as discriminator
|
||||
// so that token-auth configs (which may lack a password key after JSON round-trip)
|
||||
// still get their token field encrypted.
|
||||
if ("authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await encryptField(c.password);
|
||||
c.token = await encryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
// S3
|
||||
if ("secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await encryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await encryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
||||
const out = { ...conn };
|
||||
|
||||
if (out.tokens) {
|
||||
const t = { ...out.tokens };
|
||||
t.accessToken = (await decryptField(t.accessToken)) ?? "";
|
||||
t.refreshToken = await decryptField(t.refreshToken);
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
if ("authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await decryptField(c.password);
|
||||
c.token = await decryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
if ("secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await decryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await decryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function encryptHosts(hosts: Host[]): Promise<Host[]> {
|
||||
return Promise.all(hosts.map(encryptHostSecrets));
|
||||
}
|
||||
|
||||
export function decryptHosts(hosts: Host[]): Promise<Host[]> {
|
||||
return Promise.all(hosts.map(decryptHostSecrets));
|
||||
}
|
||||
|
||||
export function encryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
||||
return Promise.all(keys.map(encryptKeySecrets));
|
||||
}
|
||||
|
||||
export function decryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
||||
return Promise.all(keys.map(decryptKeySecrets));
|
||||
}
|
||||
|
||||
export function encryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
||||
return Promise.all(identities.map(encryptIdentitySecrets));
|
||||
}
|
||||
|
||||
export function decryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
||||
return Promise.all(identities.map(decryptIdentitySecrets));
|
||||
}
|
||||
@@ -38,6 +38,10 @@ import { createAdapter, type CloudAdapter } from './adapters';
|
||||
import type { GitHubAdapter } from './adapters/GitHubAdapter';
|
||||
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
|
||||
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
|
||||
import {
|
||||
decryptProviderSecrets,
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
|
||||
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
|
||||
@@ -79,11 +83,25 @@ export class CloudSyncManager {
|
||||
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private masterPassword: string | null = null; // In memory only!
|
||||
private hasStorageListener = false;
|
||||
// Per-provider sequence counters for async decrypt callbacks (startup,
|
||||
// cross-window storage events). Bumped by any state mutation so stale
|
||||
// decrypt results are discarded.
|
||||
private providerDecryptSeq: Record<CloudProvider, number> = {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
};
|
||||
// Per-provider write sequence counters for saveProviderConnection.
|
||||
// Only bumped when a new save is initiated, so status-only updates
|
||||
// (which don't persist) cannot discard an in-flight encrypted write.
|
||||
private providerWriteSeq: Record<CloudProvider, number> = {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadInitialState();
|
||||
this.stateSnapshot = { ...this.state };
|
||||
this.setupCrossWindowSync();
|
||||
// Decrypt provider secrets asynchronously after initial load
|
||||
this.initProviderDecryption();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -167,11 +185,41 @@ export class CloudSyncManager {
|
||||
} as ProviderConnection;
|
||||
}
|
||||
|
||||
private saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): void {
|
||||
/**
|
||||
* Asynchronously decrypt provider connection secrets after initial load.
|
||||
* Runs once at construction; decrypted tokens replace the encrypted ones
|
||||
* in-memory so adapters can use them.
|
||||
*/
|
||||
private async initProviderDecryption(): Promise<void> {
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const p of providers) {
|
||||
try {
|
||||
const conn = this.state.providers[p];
|
||||
if (conn.tokens || conn.config) {
|
||||
const seq = ++this.providerDecryptSeq[p];
|
||||
const decrypted = await decryptProviderSecrets(conn);
|
||||
// Only apply if no newer update has occurred during the async gap
|
||||
if (seq === this.providerDecryptSeq[p]) {
|
||||
this.state.providers[p] = decrypted;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Decryption failure is non-fatal; the adapter will fail on use
|
||||
}
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
private async saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): Promise<void> {
|
||||
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
||||
// Don't persist sensitive tokens directly - use safeStorage in production
|
||||
const { tokens, ...safeData } = connection;
|
||||
this.saveToStorage(key, { ...safeData, tokens }); // In production, encrypt tokens/config
|
||||
// Use write-specific counter so status-only updates cannot discard
|
||||
// an in-flight encrypted write that must be persisted.
|
||||
const seq = ++this.providerWriteSeq[provider];
|
||||
const encrypted = await encryptProviderSecrets(connection);
|
||||
// Only persist if no newer save has started during the async gap
|
||||
if (seq === this.providerWriteSeq[provider]) {
|
||||
this.saveToStorage(key, encrypted);
|
||||
}
|
||||
}
|
||||
|
||||
private loadFromStorage<T>(key: string): T | null {
|
||||
@@ -292,48 +340,61 @@ export class CloudSyncManager {
|
||||
};
|
||||
const provider = providerByKey[key];
|
||||
if (provider) {
|
||||
const prev = this.state.providers[provider];
|
||||
const next = this.loadProviderConnection(provider);
|
||||
const rawNext = this.loadProviderConnection(provider);
|
||||
const seq = ++this.providerDecryptSeq[provider];
|
||||
// Also bump write seq so any in-flight save from this window for the
|
||||
// same provider is discarded — the cross-window data is newer.
|
||||
++this.providerWriteSeq[provider];
|
||||
|
||||
const preserveTransientStatus =
|
||||
prev.status === 'connecting' || prev.status === 'syncing';
|
||||
// Decrypt secrets asynchronously, then update state.
|
||||
// Use sequence counter to discard stale results when multiple events
|
||||
// for the same provider arrive in quick succession.
|
||||
decryptProviderSecrets(rawNext).then((next) => {
|
||||
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
|
||||
|
||||
this.state.providers[provider] = {
|
||||
...next,
|
||||
status: preserveTransientStatus ? prev.status : next.status,
|
||||
error: preserveTransientStatus ? prev.error : next.error,
|
||||
};
|
||||
const prev = this.state.providers[provider];
|
||||
const preserveTransientStatus =
|
||||
prev.status === 'connecting' || prev.status === 'syncing';
|
||||
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!nextTokens && !nextConfig) {
|
||||
if (adapter) {
|
||||
this.state.providers[provider] = {
|
||||
...next,
|
||||
status: preserveTransientStatus ? prev.status : next.status,
|
||||
error: preserveTransientStatus ? prev.error : next.error,
|
||||
};
|
||||
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!nextTokens && !nextConfig) {
|
||||
if (adapter) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenChanged =
|
||||
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
||||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
||||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
||||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
||||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
||||
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
||||
|
||||
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
||||
|
||||
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenChanged =
|
||||
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
||||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
||||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
||||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
||||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
||||
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
||||
|
||||
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
||||
|
||||
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
}).catch(() => {
|
||||
// Decryption failure in cross-window handler is non-fatal
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -637,6 +698,7 @@ export class CloudSyncManager {
|
||||
try {
|
||||
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
|
||||
|
||||
++this.providerDecryptSeq.github;
|
||||
this.state.providers.github = {
|
||||
...this.state.providers.github,
|
||||
status: 'connected',
|
||||
@@ -650,7 +712,7 @@ export class CloudSyncManager {
|
||||
this.state.providers.github.resourceId = resourceId;
|
||||
}
|
||||
|
||||
this.saveProviderConnection('github', this.state.providers.github);
|
||||
await this.saveProviderConnection('github', this.state.providers.github);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -689,6 +751,7 @@ export class CloudSyncManager {
|
||||
account = odAdapter.accountInfo;
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status: 'connected',
|
||||
@@ -702,7 +765,7 @@ export class CloudSyncManager {
|
||||
this.state.providers[provider].resourceId = resourceId;
|
||||
}
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -729,6 +792,7 @@ export class CloudSyncManager {
|
||||
const resourceId = await adapter.initializeSync();
|
||||
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'connected',
|
||||
@@ -737,7 +801,7 @@ export class CloudSyncManager {
|
||||
resourceId: resourceId || undefined,
|
||||
};
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -759,12 +823,13 @@ export class CloudSyncManager {
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'disconnected',
|
||||
};
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -773,6 +838,8 @@ export class CloudSyncManager {
|
||||
status: ProviderConnection['status'],
|
||||
error?: string
|
||||
): void {
|
||||
// Bump sequence to invalidate any in-flight async decrypt for this provider
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status,
|
||||
@@ -842,11 +909,14 @@ export class CloudSyncManager {
|
||||
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.remoteVersion = syncedFile.meta.version;
|
||||
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
|
||||
// Invalidate any pending provider decrypt so it cannot overwrite
|
||||
// the lastSync/lastSyncVersion we are about to set.
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
|
||||
|
||||
this.saveSyncConfig();
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange();
|
||||
|
||||
// Add to sync history
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -1008,6 +1008,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1653,7 +1654,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1675,7 +1675,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1692,7 +1691,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1707,7 +1705,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5673,6 +5670,7 @@
|
||||
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
@@ -5702,6 +5700,7 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -5980,7 +5979,8 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
@@ -6012,6 +6012,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6044,6 +6045,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6376,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"
|
||||
}
|
||||
},
|
||||
@@ -6516,6 +6518,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7123,8 +7126,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7364,6 +7366,7 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7689,7 +7692,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7710,7 +7712,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7928,6 +7929,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10167,7 +10169,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -10180,6 +10181,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -10826,7 +10828,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10844,7 +10845,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10957,6 +10957,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10966,6 +10967,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11833,7 +11835,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11898,7 +11899,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -11967,6 +11967,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12091,6 +12092,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12293,6 +12295,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12386,6 +12389,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12648,6 +12652,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -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,5 +1,114 @@
|
||||
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..c311d3a 100644
|
||||
index 9f33c02..9751164 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
@@ -117,6 +117,20 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
|
||||
@@ -23,7 +132,70 @@ index 9f33c02..c311d3a 100644
|
||||
const fakeStderr = {
|
||||
readable: false,
|
||||
writable: false,
|
||||
@@ -339,7 +351,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
|
||||
this._writeReqid = -1;
|
||||
this._requests = {};
|
||||
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
|
||||
+ this._preambleSkipped = false; // Track if we've found the start of SFTP binary data
|
||||
+ this._preambleBuf = null; // Buffer for partial preamble data across frames
|
||||
this._maxOutPktLen = 34000;
|
||||
this._maxReadLen =
|
||||
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
|
||||
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
|
||||
this.emit('end');
|
||||
return;
|
||||
}
|
||||
+
|
||||
+ // Skip non-SFTP preamble data (e.g. MOTD/banner text from misconfigured servers)
|
||||
+ // Only applies to client mode; server mode expects SSH_FXP_INIT directly.
|
||||
+ if (!this._preambleSkipped) {
|
||||
+ if (this.server) {
|
||||
+ // Server mode: no preamble skipping, proceed to normal parsing
|
||||
+ this._preambleSkipped = true;
|
||||
+ } else {
|
||||
+ // Concatenate with any previously buffered partial data
|
||||
+ if (this._preambleBuf) {
|
||||
+ data = Buffer.concat([this._preambleBuf, data]);
|
||||
+ this._preambleBuf = null;
|
||||
+ }
|
||||
+
|
||||
+ // Look for the start of a valid SFTP packet in the data.
|
||||
+ // The first SFTP packet from the server is SSH_FXP_VERSION (type=2).
|
||||
+ // Format: uint32 length, byte type=0x02, uint32 version, ...
|
||||
+ // The length should be >= 5 (1 byte type + 4 bytes version).
|
||||
+ let found = -1;
|
||||
+ for (let i = 0; i <= data.length - 5; i++) {
|
||||
+ const len = (data[i] << 24) | (data[i+1] << 16) | (data[i+2] << 8) | data[i+3];
|
||||
+ if (len >= 5 && len <= this._maxInPktLen && data[i+4] === 0x02) {
|
||||
+ found = i;
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ if (found === -1) {
|
||||
+ // No valid SFTP packet header found yet.
|
||||
+ // Keep up to the last 4 bytes in case a valid header spans this and the
|
||||
+ // next chunk (the uint32 length could be split across frames).
|
||||
+ const keep = Math.min(data.length, 4);
|
||||
+ this._preambleBuf = Buffer.from(data.slice(data.length - keep));
|
||||
+ this._debug && this._debug(
|
||||
+ 'SFTP: Skipping non-SFTP preamble data (' + data.length + ' bytes, buffered last ' + keep + ')'
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+ if (found > 0) {
|
||||
+ this._debug && this._debug(
|
||||
+ 'SFTP: Skipped ' + found + ' bytes of non-SFTP preamble data'
|
||||
+ );
|
||||
+ data = data.slice(found);
|
||||
+ }
|
||||
+ this._preambleSkipped = true;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/*
|
||||
uint32 length
|
||||
byte type
|
||||
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 pflags
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -32,7 +204,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
|
||||
|
||||
@@ -349,7 +361,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -41,7 +213,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
writeUInt32BE(buf, attrsFlags, p += 4);
|
||||
if (attrsLen) {
|
||||
@@ -734,7 +746,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string filename
|
||||
*/
|
||||
@@ -50,7 +222,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
|
||||
|
||||
@@ -744,7 +756,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, fnameLen, p);
|
||||
@@ -59,7 +231,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -762,8 +774,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -70,7 +242,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
|
||||
|
||||
@@ -773,9 +785,9 @@ class SFTP extends EventEmitter {
|
||||
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, oldLen, p);
|
||||
@@ -82,7 +254,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -806,7 +818,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -91,7 +263,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -816,7 +828,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -100,7 +272,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -844,7 +856,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -109,7 +281,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -854,7 +866,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -118,7 +290,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -987,7 +999,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -127,7 +299,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -997,7 +1009,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -136,7 +308,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1014,7 +1026,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -145,7 +317,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1024,7 +1036,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -154,7 +326,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1041,7 +1053,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -163,7 +335,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1051,7 +1063,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -172,7 +344,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1080,7 +1092,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -181,7 +353,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -1090,7 +1102,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -190,7 +362,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -1205,7 +1217,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -199,7 +371,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1215,7 +1227,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -208,7 +380,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1243,8 +1255,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
|
||||
string linkpath
|
||||
string targetpath
|
||||
*/
|
||||
@@ -219,7 +391,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
|
||||
|
||||
@@ -1256,14 +1268,14 @@ class SFTP extends EventEmitter {
|
||||
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
|
||||
if (this._isOpenSSH) {
|
||||
// OpenSSH has linkpath and targetpath positions switched
|
||||
writeUInt32BE(buf, targetLen, p);
|
||||
@@ -238,7 +410,7 @@ index 9f33c02..c311d3a 100644
|
||||
}
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
@@ -1281,7 +1293,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -247,7 +419,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1291,7 +1303,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -256,7 +428,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1325,8 +1337,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -267,7 +439,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1337,11 +1349,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 24, p);
|
||||
@@ -282,7 +454,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1364,7 +1376,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
|
||||
string "statvfs@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -291,7 +463,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
|
||||
|
||||
@@ -1374,9 +1386,9 @@ class SFTP extends EventEmitter {
|
||||
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 19, p);
|
||||
@@ -303,7 +475,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
|
||||
|
||||
@@ -1411,7 +1423,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -312,7 +484,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, handleLen, p += 20);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1437,8 +1449,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -323,7 +495,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1449,11 +1461,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -338,7 +510,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1488,7 +1500,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 17, p);
|
||||
@@ -347,7 +519,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, handleLen, p += 17);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1524,7 +1536,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -356,7 +528,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
|
||||
@@ -1535,10 +1547,10 @@ class SFTP extends EventEmitter {
|
||||
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -369,7 +541,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
@@ -1573,7 +1585,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
|
||||
string "expand-path@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -378,7 +550,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
|
||||
|
||||
@@ -1583,10 +1595,10 @@ class SFTP extends EventEmitter {
|
||||
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 23, p);
|
||||
@@ -391,7 +563,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1653,7 +1665,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 9, p);
|
||||
p += 4;
|
||||
@@ -400,7 +572,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += 9;
|
||||
|
||||
writeUInt32BE(buf, srcHandle.length, p);
|
||||
@@ -1708,7 +1720,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
|
||||
string username
|
||||
*/
|
||||
let p = 0;
|
||||
@@ -409,7 +581,7 @@ index 9f33c02..c311d3a 100644
|
||||
const buf = Buffer.allocUnsafe(
|
||||
4 + 1
|
||||
+ 4
|
||||
@@ -1728,12 +1740,12 @@ class SFTP extends EventEmitter {
|
||||
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 14, p);
|
||||
p += 4;
|
||||
@@ -424,7 +596,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += usernameLen;
|
||||
|
||||
this._requests[reqid] = {
|
||||
@@ -1806,7 +1818,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 30, p);
|
||||
p += 4;
|
||||
@@ -433,7 +605,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += 30;
|
||||
|
||||
writeUInt32BE(buf, 4 * uids.length, p);
|
||||
@@ -1871,7 +1883,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
message || (message = '');
|
||||
|
||||
@@ -442,7 +614,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
|
||||
|
||||
@@ -1884,7 +1896,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, msgLen, p += 4);
|
||||
p += 4;
|
||||
if (msgLen) {
|
||||
@@ -451,7 +623,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += msgLen;
|
||||
}
|
||||
|
||||
@@ -1913,7 +1925,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
|
||||
const dataLen = (
|
||||
isBuffer
|
||||
? data.length
|
||||
@@ -460,7 +632,7 @@ index 9f33c02..c311d3a 100644
|
||||
);
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
|
||||
@@ -1927,7 +1939,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
|
||||
if (isBuffer)
|
||||
buf.set(data, p += 4);
|
||||
else if (isUTF8)
|
||||
@@ -469,7 +641,7 @@ index 9f33c02..c311d3a 100644
|
||||
else
|
||||
buf.write(data, p += 4, dataLen, encoding);
|
||||
}
|
||||
@@ -1959,13 +1971,13 @@ class SFTP extends EventEmitter {
|
||||
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -485,7 +657,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
if (typeof name.attrs === 'object' && name.attrs !== null) {
|
||||
nameAttrs = attrsToBytes(name.attrs);
|
||||
@@ -2011,11 +2023,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -499,7 +671,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2026,11 +2038,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.longname
|
||||
);
|
||||
@@ -513,7 +685,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2749,7 +2761,7 @@ function requestLimits(sftp, cb) {
|
||||
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 18, p);
|
||||
@@ -522,7 +694,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
|
||||
|
||||
@@ -2953,18 +2965,28 @@ const CLIENT_HANDLERS = {
|
||||
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
|
||||
// spec not specifying an encoding because the specs for newer
|
||||
// versions of the protocol all explicitly specify UTF-8 for
|
||||
// filenames
|
||||
|
||||
Reference in New Issue
Block a user