Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fecbb94fb | ||
|
|
6bd3968e04 | ||
|
|
528cda1f70 | ||
|
|
29bde31989 | ||
|
|
b12a2171e7 | ||
|
|
ffcd94e216 | ||
|
|
9b77fc9e3b | ||
|
|
9e57f2eb90 | ||
|
|
8cf6e9243d | ||
|
|
7a32aa0743 | ||
|
|
a1d0ce02fe | ||
|
|
adb2bc9403 | ||
|
|
7a6ed660fb | ||
|
|
035b22b467 | ||
|
|
1bce2c9808 | ||
|
|
ca2d699e55 | ||
|
|
6907fb54c8 | ||
|
|
4bae2517fe | ||
|
|
da4936ff22 | ||
|
|
2223ec34f0 | ||
|
|
ca1423051d | ||
|
|
ca8b36c7d5 | ||
|
|
b96eaf2aca | ||
|
|
663fe88b2e | ||
|
|
42da477425 | ||
|
|
474a13e4f9 | ||
|
|
3c5e12cc8b | ||
|
|
c2a01d83d7 | ||
|
|
dcc3b6fce7 | ||
|
|
fb43b53f33 | ||
|
|
b90c29f56a | ||
|
|
37092826f3 | ||
|
|
30b809a8f6 | ||
|
|
989a1aa3d7 | ||
|
|
9e5c5f826f | ||
|
|
74e0249797 | ||
|
|
d89d6d3959 | ||
|
|
66680d585f | ||
|
|
57dd2fb48b | ||
|
|
6d973f9bc8 | ||
|
|
425647eeda | ||
|
|
9109aec4ab | ||
|
|
6d5283173a |
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)"
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
41
App.tsx
41
App.tsx
@@ -20,7 +20,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, TerminalTheme } from './types';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
import type { SftpView as SftpViewComponent } from './components/SftpView';
|
||||
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
|
||||
@@ -619,6 +619,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
addConnectionLog({
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
addConnectionLog({
|
||||
@@ -635,6 +654,24 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectToHost(host);
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
addConnectionLog({
|
||||
hostId: '',
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
username: username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createSerialSession(config);
|
||||
}, [addConnectionLog, createSerialSession]);
|
||||
|
||||
// Handle terminal data capture when session exits
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
@@ -776,7 +813,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
onConnectSerial={createSerialSession}
|
||||
onConnectSerial={handleConnectSerial}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onConnect={handleConnectToHost}
|
||||
onUpdateHosts={updateHosts}
|
||||
|
||||
@@ -525,7 +525,7 @@ const en: Messages = {
|
||||
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
|
||||
'settings.sftpFileAssociations.remove': 'Remove',
|
||||
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
|
||||
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
|
||||
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
|
||||
@@ -533,7 +533,7 @@ const en: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
|
||||
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': 'Auto-sync to remote',
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
@@ -542,6 +542,12 @@ const en: Messages = {
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
@@ -648,6 +654,12 @@ const en: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet Password',
|
||||
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
|
||||
'hostDetails.telnet.add': 'Add Telnet Protocol',
|
||||
'hostDetails.tags': 'Tags',
|
||||
'hostDetails.group': 'Group',
|
||||
'hostDetails.selectGroup': 'Select Group',
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': 'Edit Host',
|
||||
@@ -1061,11 +1073,12 @@ const en: Messages = {
|
||||
'serial.field.baudRate': 'Baud Rate',
|
||||
'serial.field.dataBits': 'Data Bits',
|
||||
'serial.field.stopBits': 'Stop Bits',
|
||||
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
|
||||
'serial.field.parity': 'Parity',
|
||||
'serial.field.flowControl': 'Flow Control',
|
||||
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
|
||||
'serial.field.customPort': 'Custom Port Path',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
|
||||
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
|
||||
'serial.type.hardware': 'Hardware',
|
||||
'serial.type.pseudo': 'Pseudo Terminal',
|
||||
'serial.type.custom': 'Custom',
|
||||
@@ -1082,6 +1095,15 @@ const en: Messages = {
|
||||
'serial.field.lineMode': 'Line Mode',
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.connectionError': 'Failed to connect to serial port',
|
||||
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
|
||||
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
|
||||
'serial.field.customBaudRate': 'Using custom baud rate',
|
||||
'serial.field.saveConfig': 'Save Configuration',
|
||||
'serial.field.saveConfigDesc': 'Save this serial configuration to hosts for quick access',
|
||||
'serial.field.configLabel': 'Configuration Name',
|
||||
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
|
||||
'serial.connectAndSave': 'Connect & Save',
|
||||
'serial.edit.title': 'Serial Port Settings',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -401,6 +401,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.telnet.password': 'Telnet 密码',
|
||||
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
|
||||
'hostDetails.telnet.add': '添加 Telnet 协议',
|
||||
'hostDetails.tags': '标签',
|
||||
'hostDetails.group': '分组',
|
||||
'hostDetails.selectGroup': '选择分组',
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
|
||||
// Host form (legacy modal)
|
||||
'hostForm.title.edit': '编辑主机',
|
||||
@@ -757,7 +763,7 @@ const zhCN: Messages = {
|
||||
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
|
||||
'settings.sftpFileAssociations.remove': '移除',
|
||||
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
|
||||
|
||||
|
||||
// Settings > SFTP Behavior
|
||||
'settings.sftp.doubleClickBehavior': '双击行为',
|
||||
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
|
||||
@@ -765,7 +771,7 @@ const zhCN: Messages = {
|
||||
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
|
||||
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
|
||||
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
|
||||
|
||||
|
||||
// Settings > SFTP Auto Sync
|
||||
'settings.sftp.autoSync': '自动同步到远程',
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
@@ -774,6 +780,12 @@ const zhCN: Messages = {
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
@@ -1050,11 +1062,12 @@ const zhCN: Messages = {
|
||||
'serial.field.baudRate': '波特率',
|
||||
'serial.field.dataBits': '数据位',
|
||||
'serial.field.stopBits': '停止位',
|
||||
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
|
||||
'serial.field.parity': '校验位',
|
||||
'serial.field.flowControl': '流控制',
|
||||
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
|
||||
'serial.field.customPort': '自定义串口路径',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
|
||||
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
|
||||
'serial.type.hardware': '硬件',
|
||||
'serial.type.pseudo': '虚拟终端',
|
||||
'serial.type.custom': '自定义',
|
||||
@@ -1071,6 +1084,15 @@ const zhCN: Messages = {
|
||||
'serial.field.lineMode': '行模式',
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.connectionError': '连接串口失败',
|
||||
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
|
||||
'serial.field.baudRateEmpty': '输入自定义波特率',
|
||||
'serial.field.customBaudRate': '使用自定义波特率',
|
||||
'serial.field.saveConfig': '保存配置',
|
||||
'serial.field.saveConfigDesc': '将此串口配置保存到主机列表以便快速访问',
|
||||
'serial.field.configLabel': '配置名称',
|
||||
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
|
||||
'serial.connectAndSave': '连接并保存',
|
||||
'serial.edit.title': '串口设置',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -72,6 +72,37 @@ export const useSessionState = () => {
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
// Handle serial hosts specially - use createSerialSession for them
|
||||
if (host.protocol === 'serial') {
|
||||
// Use stored serialConfig or construct from host data
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
const newSession: TerminalSession = {
|
||||
id: sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
|
||||
@@ -18,6 +18,7 @@ STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -41,6 +42,7 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
: 'pc';
|
||||
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
|
||||
const readStoredString = (key: string): string | null => {
|
||||
const raw = localStorageAdapter.readString(key);
|
||||
@@ -167,6 +169,10 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
|
||||
});
|
||||
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
|
||||
});
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
@@ -398,11 +404,18 @@ export const useSettingsState = () => {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -465,6 +478,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -575,6 +594,8 @@ export const useSettingsState = () => {
|
||||
setSftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
setSftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
availableFonts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -676,6 +676,7 @@ export const useSftpState = (
|
||||
lastModified: new Date(f.lastModified).getTime(),
|
||||
lastModifiedFormatted: f.lastModified,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
hidden: f.hidden, // Windows hidden attribute
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -2724,6 +2725,89 @@ export const useSftpState = (
|
||||
[getActivePane],
|
||||
);
|
||||
|
||||
// Upload external files dropped from OS
|
||||
const uploadExternalFiles = useCallback(
|
||||
async (side: "left" | "right", files: FileList) => {
|
||||
const pane = getActivePane(side);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("No active connection");
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Bridge not available");
|
||||
}
|
||||
|
||||
const results: { fileName: string; success: boolean; error?: string }[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
const targetPath = joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
// Upload to local filesystem
|
||||
if (!bridge.writeLocalFile) {
|
||||
throw new Error("writeLocalFile not available");
|
||||
}
|
||||
await bridge.writeLocalFile(targetPath, arrayBuffer);
|
||||
} else {
|
||||
// Upload to remote via SFTP
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// Try progress API first, fallback to basic binary write
|
||||
if (bridge.writeSftpBinaryWithProgress) {
|
||||
const result = await bridge.writeSftpBinaryWithProgress(
|
||||
sftpId,
|
||||
targetPath,
|
||||
arrayBuffer,
|
||||
crypto.randomUUID(),
|
||||
// Progress callbacks not needed for simple drag-drop upload
|
||||
undefined, // onProgress
|
||||
undefined, // onComplete
|
||||
undefined, // onError
|
||||
);
|
||||
|
||||
// Check if progress API explicitly reported failure
|
||||
// If result is undefined/null or success is false, fallback to basic API
|
||||
if (!result || result.success === false) {
|
||||
if (bridge.writeSftpBinary) {
|
||||
await bridge.writeSftpBinary(sftpId, targetPath, arrayBuffer);
|
||||
} else {
|
||||
throw new Error("Upload failed and no fallback method available");
|
||||
}
|
||||
}
|
||||
} else if (bridge.writeSftpBinary) {
|
||||
// Progress API not available, use basic API
|
||||
await bridge.writeSftpBinary(sftpId, targetPath, arrayBuffer);
|
||||
} else {
|
||||
throw new Error("No SFTP write method available");
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ fileName: file.name, success: true });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upload ${file.name}:`, error);
|
||||
results.push({
|
||||
fileName: file.name,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the file list to show new files
|
||||
await refresh(side);
|
||||
|
||||
return results;
|
||||
},
|
||||
[getActivePane, refresh],
|
||||
);
|
||||
|
||||
// Select an application from system file picker
|
||||
const selectApplication = useCallback(
|
||||
async (): Promise<{ path: string; name: string } | null> => {
|
||||
@@ -2767,6 +2851,7 @@ export const useSftpState = (
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
cancelTransfer,
|
||||
@@ -2804,6 +2889,7 @@ export const useSftpState = (
|
||||
readBinaryFile,
|
||||
writeTextFile,
|
||||
downloadToTempAndOpen,
|
||||
uploadExternalFiles,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
cancelTransfer,
|
||||
@@ -2844,6 +2930,7 @@ export const useSftpState = (
|
||||
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
|
||||
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
|
||||
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
|
||||
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
|
||||
selectApplication: () => methodsRef.current.selectApplication(),
|
||||
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
|
||||
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Server,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Usb,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo } from "react";
|
||||
@@ -63,6 +64,7 @@ interface LogItemProps {
|
||||
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
||||
const isSerial = log.protocol === "serial";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -92,14 +94,14 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
||||
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
||||
)}>
|
||||
{isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
|
||||
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Server } from "lucide-react";
|
||||
import { Server, Usb } from "lucide-react";
|
||||
import React, { memo } from "react";
|
||||
import { normalizeDistroId } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -69,6 +69,21 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
const containerClass = sizeClasses[size];
|
||||
const iconSize = iconSizes[size];
|
||||
|
||||
// Show USB icon for serial hosts
|
||||
if (host.protocol === 'serial') {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center bg-amber-500/15 text-amber-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Usb className={iconSize} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logo && !errored) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -522,12 +522,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={form.group || ""}
|
||||
onValueChange={(val) => update("group", val)}
|
||||
onValueChange={(val) => {
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
placeholder={t("hostDetails.group.placeholder")}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
onCreateGroup?.(val);
|
||||
update("group", val);
|
||||
setGroupInputValue(val);
|
||||
}}
|
||||
createText="Create Group"
|
||||
triggerClassName="flex-1 h-10"
|
||||
|
||||
@@ -48,6 +48,7 @@ import { logger } from "../lib/logger";
|
||||
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, RemoteFile } from "../types";
|
||||
import { filterHiddenFiles } from "./sftp";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import FileOpenerDialog from "./FileOpenerDialog";
|
||||
import TextEditorModal from "./TextEditorModal";
|
||||
@@ -256,6 +257,8 @@ interface SFTPModalProps {
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
// Sort configuration
|
||||
@@ -280,6 +283,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
credentials,
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
@@ -304,7 +308,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
downloadSftpToTempAndOpen,
|
||||
} = useSftpBackend();
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const { sftpAutoSync } = useSettingsState();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
@@ -316,6 +320,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const navigatingRef = useRef(false);
|
||||
const lastSelectedIndexRef = useRef<number | null>(null);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
@@ -612,8 +617,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (!initializedRef.current) {
|
||||
// Check if we need to reinitialize (either first time or initialPath changed)
|
||||
const needsReinit = !initializedRef.current || initialPath !== lastInitialPathRef.current;
|
||||
|
||||
if (needsReinit) {
|
||||
initializedRef.current = true;
|
||||
lastInitialPathRef.current = initialPath;
|
||||
if (isLocalSession) {
|
||||
void (async () => {
|
||||
let home = localHomeRef.current;
|
||||
@@ -628,7 +637,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
loadFiles(startPath);
|
||||
})();
|
||||
} else {
|
||||
// For remote sessions, load home directory directly
|
||||
// For remote sessions, try initialPath first, then fall back to home directory
|
||||
void (async () => {
|
||||
const username = credentials.username || 'root';
|
||||
// Root user's home is /root, other users' home is /home/username
|
||||
@@ -637,6 +646,26 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Set loading state immediately for better UX
|
||||
setLoading(true);
|
||||
|
||||
// If initialPath is provided, try to use it first
|
||||
if (initialPath) {
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, initialPath);
|
||||
setCurrentPath(initialPath);
|
||||
setFiles(list);
|
||||
setSelectedFiles(new Set());
|
||||
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setLoading(false);
|
||||
return; // Successfully opened at initialPath
|
||||
} catch {
|
||||
// initialPath not accessible, fall back to home directory
|
||||
logger.warn(`[SFTP] Initial path ${initialPath} not accessible, falling back to home`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const sftpId = await ensureSftp();
|
||||
const list = await listSftp(sftpId, homePath);
|
||||
@@ -679,7 +708,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
void closeSftpSession();
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t]);
|
||||
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t, initialPath]);
|
||||
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
// Prevent double navigation (e.g., from double-click race condition)
|
||||
@@ -1263,9 +1292,12 @@ 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);
|
||||
|
||||
// Check if we're at root
|
||||
const atRoot = isRootPath(currentPath);
|
||||
if (atRoot) return files;
|
||||
if (atRoot) return visibleFiles;
|
||||
|
||||
// Add ".." parent directory entry at the top (only if not at root)
|
||||
const parentEntry: RemoteFile = {
|
||||
@@ -1274,8 +1306,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
size: "--",
|
||||
lastModified: undefined,
|
||||
};
|
||||
return [parentEntry, ...files.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPath]);
|
||||
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPath, sftpShowHiddenFiles]);
|
||||
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* Serial Port Connect Modal
|
||||
* Allows users to configure and connect to a serial port
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Usb } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
@@ -35,6 +36,7 @@ interface SerialConnectModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
@@ -47,6 +49,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
@@ -63,6 +66,10 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
|
||||
// Save configuration state
|
||||
const [saveConfig, setSaveConfig] = useState(false);
|
||||
const [configLabel, setConfigLabel] = useState('');
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
@@ -87,6 +94,14 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Generate a default label when port is selected
|
||||
useEffect(() => {
|
||||
if (selectedPort && !configLabel) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
setConfigLabel(`Serial: ${portName}`);
|
||||
}
|
||||
}, [selectedPort, configLabel]);
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
@@ -101,6 +116,26 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
lineMode,
|
||||
};
|
||||
|
||||
// Save as host if checkbox is checked and onSaveHost is provided
|
||||
if (saveConfig && onSaveHost) {
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const host: Host = {
|
||||
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: configLabel.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
// For serial hosts, port field stores baud rate as a numeric identifier.
|
||||
// The full configuration is stored in serialConfig for actual connection.
|
||||
port: baudRate,
|
||||
username: '',
|
||||
os: 'linux',
|
||||
tags: ['serial'],
|
||||
protocol: 'serial',
|
||||
createdAt: Date.now(),
|
||||
serialConfig: config, // Store full serial configuration for connection
|
||||
};
|
||||
onSaveHost(host);
|
||||
}
|
||||
|
||||
onConnect(config);
|
||||
onClose();
|
||||
};
|
||||
@@ -114,9 +149,17 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Validate: port path must start with /dev/
|
||||
const isPortValid = selectedPort.trim().startsWith('/dev/');
|
||||
const isBaudRateValid = BAUD_RATES.includes(baudRate);
|
||||
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
// Allow custom baud rates as long as they are positive integers
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
return (
|
||||
@@ -171,18 +214,28 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<select
|
||||
id="baud-rate"
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(parseInt(e.target.value, 10))}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{BAUD_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
@@ -236,6 +289,11 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,6 +371,40 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Save Configuration */}
|
||||
{onSaveHost && (
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="save-config" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.saveConfig')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.saveConfigDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-config"
|
||||
checked={saveConfig}
|
||||
onChange={(e) => setSaveConfig(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
{saveConfig && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="config-label"
|
||||
value={configLabel}
|
||||
onChange={(e) => setConfigLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -320,8 +412,12 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!isValid}>
|
||||
<Cpu size={14} className="mr-2" />
|
||||
{t('common.connect')}
|
||||
{saveConfig ? (
|
||||
<Save size={14} className="mr-2" />
|
||||
) : (
|
||||
<Cpu size={14} className="mr-2" />
|
||||
)}
|
||||
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
415
components/SerialHostDetailsPanel.tsx
Normal file
415
components/SerialHostDetailsPanel.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Serial Host Details Panel
|
||||
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
|
||||
*/
|
||||
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
} from './ui/aside-panel';
|
||||
|
||||
interface SerialPort {
|
||||
path: string;
|
||||
manufacturer: string;
|
||||
serialNumber: string;
|
||||
vendorId: string;
|
||||
productId: string;
|
||||
pnpId: string;
|
||||
type?: 'hardware' | 'pseudo' | 'custom';
|
||||
}
|
||||
|
||||
interface SerialHostDetailsPanelProps {
|
||||
initialData: Host;
|
||||
allTags?: string[];
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
|
||||
const DATA_BITS: Array<5 | 6 | 7 | 8> = [5, 6, 7, 8];
|
||||
const STOP_BITS: Array<1 | 1.5 | 2> = [1, 1.5, 2];
|
||||
const PARITY_OPTIONS: SerialParity[] = ['none', 'even', 'odd', 'mark', 'space'];
|
||||
const FLOW_CONTROL_OPTIONS: SerialFlowControl[] = ['none', 'xon/xoff', 'rts/cts'];
|
||||
|
||||
export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
initialData,
|
||||
allTags = [],
|
||||
groups = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const [ports, setPorts] = useState<SerialPort[]>([]);
|
||||
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [label, setLabel] = useState(initialData.label);
|
||||
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
|
||||
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
|
||||
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
|
||||
const [parity, setParity] = useState<SerialParity>(initialData.serialConfig?.parity || 'none');
|
||||
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
|
||||
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
|
||||
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
|
||||
const loadPorts = useCallback(async () => {
|
||||
setIsLoadingPorts(true);
|
||||
try {
|
||||
const result = await terminalBackend.listSerialPorts();
|
||||
setPorts(result);
|
||||
} catch (err) {
|
||||
console.error('[Serial] Failed to list ports:', err);
|
||||
} finally {
|
||||
setIsLoadingPorts(false);
|
||||
}
|
||||
}, [terminalBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPorts();
|
||||
}, [loadPorts]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedPort) return;
|
||||
|
||||
const config: SerialConfig = {
|
||||
path: selectedPort,
|
||||
baudRate,
|
||||
dataBits,
|
||||
stopBits,
|
||||
parity,
|
||||
flowControl,
|
||||
localEcho,
|
||||
lineMode,
|
||||
};
|
||||
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
const updatedHost: Host = {
|
||||
...initialData,
|
||||
label: label.trim() || `Serial: ${portName}`,
|
||||
hostname: selectedPort,
|
||||
port: baudRate,
|
||||
tags,
|
||||
group,
|
||||
serialConfig: config,
|
||||
};
|
||||
|
||||
onSave(updatedHost);
|
||||
};
|
||||
|
||||
// Convert ports to Combobox options
|
||||
const portOptions: ComboboxOption[] = useMemo(() => {
|
||||
return ports.map((port) => ({
|
||||
value: port.path,
|
||||
label: port.path,
|
||||
sublabel: port.manufacturer || undefined,
|
||||
}));
|
||||
}, [ports]);
|
||||
|
||||
// Tag options for MultiCombobox
|
||||
const tagOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allUniqueTags = new Set([...allTags, ...tags]);
|
||||
return Array.from(allUniqueTags).map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
}));
|
||||
}, [allTags, tags]);
|
||||
|
||||
// Group options for Combobox
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allGroups = new Set(groups);
|
||||
if (group && !allGroups.has(group)) {
|
||||
allGroups.add(group);
|
||||
}
|
||||
return Array.from(allGroups).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}));
|
||||
}, [groups, group]);
|
||||
|
||||
// Validation
|
||||
const trimmedPort = selectedPort.trim();
|
||||
const isPortValid =
|
||||
trimmedPort.startsWith('/dev/') ||
|
||||
/^COM\d+$/i.test(trimmedPort) ||
|
||||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
|
||||
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('serial.edit.title')}
|
||||
subtitle={initialData.label}
|
||||
className="z-40"
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serial-label">{t('serial.field.configLabel')}</Label>
|
||||
<Input
|
||||
id="serial-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('serial.field.configLabelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Serial Port */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="serial-port">{t('serial.field.port')}</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadPorts}
|
||||
disabled={isLoadingPorts}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<Combobox
|
||||
options={portOptions}
|
||||
value={selectedPort}
|
||||
onValueChange={setSelectedPort}
|
||||
placeholder={t('serial.field.selectPort')}
|
||||
emptyText={t('serial.noPorts')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<Usb size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
{!isPortValid && selectedPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('serial.field.customPortPlaceholder')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baud Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
|
||||
<Combobox
|
||||
options={BAUD_RATES.map((rate) => ({
|
||||
value: String(rate),
|
||||
label: String(rate),
|
||||
}))}
|
||||
value={String(baudRate)}
|
||||
onValueChange={(val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
setBaudRate(parsed);
|
||||
}
|
||||
}}
|
||||
placeholder={t('serial.field.baudRatePlaceholder')}
|
||||
emptyText={t('serial.field.baudRateEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
/>
|
||||
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.customBaudRate')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Tag size={14} />
|
||||
{t('hostDetails.tags')}
|
||||
</Label>
|
||||
<MultiCombobox
|
||||
options={tagOptions}
|
||||
values={tags}
|
||||
onValuesChange={setTags}
|
||||
placeholder={t('hostDetails.addTag')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createTag')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('hostDetails.group')}</Label>
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={group}
|
||||
onValueChange={setGroup}
|
||||
placeholder={t('hostDetails.selectGroup')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createGroup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between h-9 px-0 hover:bg-transparent"
|
||||
>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('common.advanced')}
|
||||
</span>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* Data Bits */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value as SerialParity)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
<div className="space-y-3 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.localEcho')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.localEchoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="local-echo"
|
||||
checked={localEcho}
|
||||
onChange={(e) => setLocalEcho(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="line-mode" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.lineMode')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.lineModeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="line-mode"
|
||||
checked={lineMode}
|
||||
onChange={(e) => setLineMode(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AsidePanelContent>
|
||||
|
||||
<AsidePanelFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isValid} className="flex-1">
|
||||
<Save size={14} className="mr-2" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default SerialHostDetailsPanel;
|
||||
@@ -59,6 +59,7 @@ import { Label } from "./ui/label";
|
||||
// Import extracted components
|
||||
import {
|
||||
ColumnWidths,
|
||||
filterHiddenFiles,
|
||||
isNavigableDirectory,
|
||||
SftpBreadcrumb,
|
||||
SftpConflictDialog,
|
||||
@@ -100,6 +101,7 @@ import {
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
useActiveTabId,
|
||||
activeTabStore,
|
||||
type SftpPaneCallbacks,
|
||||
@@ -162,6 +164,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
const hosts = useSftpHosts();
|
||||
const showHiddenFiles = useSftpShowHiddenFiles();
|
||||
|
||||
// Destructure for easier use
|
||||
const {
|
||||
@@ -182,8 +185,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onReceiveFromOtherPane,
|
||||
onEditPermissions,
|
||||
onEditFile,
|
||||
onOpenFile,
|
||||
onOpenFileWith,
|
||||
onDownloadFile,
|
||||
onUploadExternalFiles,
|
||||
} = callbacks;
|
||||
|
||||
// 渲染追踪 - 只追踪数据 props(回调来自 context,引用稳定)
|
||||
@@ -255,11 +259,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
if (!term) return pane.files;
|
||||
return pane.files.filter(
|
||||
|
||||
// Filter hidden files using utility function
|
||||
let files = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
|
||||
// Apply text filter
|
||||
if (!term) return files;
|
||||
return files.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, [pane.files, pane.filter]);
|
||||
}, [pane.files, pane.filter, showHiddenFiles]);
|
||||
|
||||
// Path suggestions
|
||||
const pathSuggestions = useMemo(() => {
|
||||
@@ -593,6 +602,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
// Drag handlers
|
||||
const handlePaneDragOver = (e: React.DragEvent) => {
|
||||
// Check if this is external file drag (from OS)
|
||||
const hasFiles = e.dataTransfer.types.includes('Files');
|
||||
|
||||
// If it's external files, always allow drop
|
||||
if (hasFiles) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOverPane(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, check if it's internal drag from other pane
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
@@ -607,11 +628,23 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setDragOverEntry(null);
|
||||
};
|
||||
|
||||
const handlePaneDrop = (e: React.DragEvent) => {
|
||||
const handlePaneDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOverPane(false);
|
||||
setDragOverEntry(null);
|
||||
|
||||
// Check if this is external file drop (from OS)
|
||||
const droppedFiles = e.dataTransfer.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) {
|
||||
// Handle external file upload using the callback
|
||||
if (onUploadExternalFiles) {
|
||||
await onUploadExternalFiles(droppedFiles);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, handle internal drag from other pane
|
||||
if (!draggedFiles || draggedFiles[0]?.side === side) return;
|
||||
onReceiveFromOtherPane(
|
||||
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
|
||||
@@ -814,18 +847,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.open")}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
{/* File operations - only for files, not directories */}
|
||||
{!isNavigableDirectory(entry) && onOpenFile && (
|
||||
<ContextMenuItem onClick={() => onOpenFile(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.open")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onOpenFileWith && (
|
||||
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
|
||||
<ExternalLink size={14} className="mr-2" />{" "}
|
||||
@@ -838,6 +864,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
{t("sftp.context.edit")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{!isNavigableDirectory(entry) && onDownloadFile && (
|
||||
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
|
||||
<Download size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.download")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -901,10 +933,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handleRowOpen,
|
||||
handleRowSelect,
|
||||
onCopyToOtherPane,
|
||||
onDownloadFile,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
onOpenFile,
|
||||
onOpenFileWith,
|
||||
onRefresh,
|
||||
openDeleteConfirm,
|
||||
@@ -1480,8 +1512,8 @@ interface SftpViewProps {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync } = useSettingsState();
|
||||
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
@@ -1494,7 +1526,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
logger.error("[SFTP] File auto-sync failed", payload);
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
@@ -1505,7 +1537,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
// Store behavior setting in ref for stable callbacks
|
||||
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
||||
behaviorRef.current = sftpDoubleClickBehavior;
|
||||
|
||||
|
||||
// Store auto-sync setting in ref for stable callbacks
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
@@ -1882,6 +1914,100 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
[handleOpenFileWithForSide],
|
||||
);
|
||||
|
||||
// Handle external file upload from OS drag-and-drop (shared logic)
|
||||
// Uses sftpRef.current internally, so dependencies are stable.
|
||||
// toast and logger are globally stable, t is the only real dependency.
|
||||
const handleUploadExternalFilesForSide = useCallback(
|
||||
async (side: "left" | "right", files: FileList) => {
|
||||
try {
|
||||
const results = await sftpRef.current.uploadExternalFiles(side, files);
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
|
||||
if (failCount === 0) {
|
||||
// All files uploaded successfully
|
||||
const successCount = results.length;
|
||||
const message = successCount === 1
|
||||
? `${t('sftp.upload')}: ${results[0].fileName}`
|
||||
: `${t('sftp.uploadFiles')}: ${successCount}`;
|
||||
toast.success(message, "SFTP");
|
||||
} else {
|
||||
// Some or all files failed
|
||||
const failedFiles = results.filter(r => !r.success);
|
||||
failedFiles.forEach(failed => {
|
||||
const errorMsg = failed.error ? ` - ${failed.error}` : '';
|
||||
toast.error(
|
||||
`${t('sftp.error.uploadFailed')}: ${failed.fileName}${errorMsg}`,
|
||||
"SFTP"
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[SftpView] Failed to upload external files:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('sftp.error.uploadFailed'),
|
||||
"SFTP"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesLeft = useCallback(
|
||||
(files: FileList) => handleUploadExternalFilesForSide("left", files),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
const handleUploadExternalFilesRight = useCallback(
|
||||
(files: FileList) => handleUploadExternalFilesForSide("right", files),
|
||||
[handleUploadExternalFilesForSide],
|
||||
);
|
||||
|
||||
// Download file to local filesystem (browser download)
|
||||
const handleDownloadFileForSide = useCallback(
|
||||
async (side: "left" | "right", file: SftpFileEntry) => {
|
||||
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
|
||||
if (!pane.connection) return;
|
||||
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
// Read the file as binary
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
|
||||
// Create blob and trigger browser download
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`${t('sftp.context.download')}: ${file.name}`, "SFTP");
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('sftp.error.downloadFailed'),
|
||||
"SFTP"
|
||||
);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleDownloadFileLeft = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
const handleDownloadFileRight = useCallback(
|
||||
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
|
||||
[handleDownloadFileForSide],
|
||||
);
|
||||
|
||||
// Custom handleOpenEntry callbacks that check the double-click behavior setting
|
||||
const handleOpenEntryLeft = useCallback(
|
||||
(entry: SftpFileEntry) => {
|
||||
@@ -1959,6 +2085,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onEditFile: handleEditFileLeft,
|
||||
onOpenFile: handleOpenFileLeft,
|
||||
onOpenFileWith: handleOpenFileWithLeft,
|
||||
onDownloadFile: handleDownloadFileLeft,
|
||||
onUploadExternalFiles: handleUploadExternalFilesLeft,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1984,6 +2112,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onEditFile: handleEditFileRight,
|
||||
onOpenFile: handleOpenFileRight,
|
||||
onOpenFileWith: handleOpenFileWithRight,
|
||||
onDownloadFile: handleDownloadFileRight,
|
||||
onUploadExternalFiles: handleUploadExternalFilesRight,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -2124,6 +2254,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Maximize2, Radio } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -181,6 +182,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [showSFTP, setShowSFTP] = useState(false);
|
||||
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
|
||||
const [progressValue, setProgressValue] = useState(15);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
|
||||
@@ -732,6 +734,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (showSFTP) {
|
||||
setShowSFTP(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get the current working directory from the terminal session
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
}
|
||||
|
||||
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
|
||||
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
};
|
||||
|
||||
const handleCancelConnect = () => {
|
||||
setIsCancelling(true);
|
||||
auth.setNeedsAuth(false);
|
||||
@@ -810,7 +840,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
isScriptsOpen={isScriptsOpen}
|
||||
setIsScriptsOpen={setIsScriptsOpen}
|
||||
onOpenSFTP={() => setShowSFTP((v) => !v)}
|
||||
onOpenSFTP={handleOpenSFTP}
|
||||
onSnippetClick={handleSnippetClick}
|
||||
onUpdateHost={onUpdateHost}
|
||||
showClose={opts?.showClose}
|
||||
@@ -1053,6 +1083,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
onClose={() => setShowSFTP(false)}
|
||||
initialPath={sftpInitialPath}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
|
||||
@@ -50,6 +50,7 @@ import PortForwarding from "./PortForwardingNew";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -1361,7 +1362,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
|
||||
{currentSection === "hosts" && isHostPanelOpen && (
|
||||
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
|
||||
<HostDetailsPanel
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
@@ -1396,6 +1397,31 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Serial Host Details Panel - for editing serial port hosts */}
|
||||
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol === 'serial' && (
|
||||
<SerialHostDetailsPanel
|
||||
initialData={editingHost}
|
||||
allTags={allTags}
|
||||
groups={Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={isNewFolderOpen} onOpenChange={(open) => {
|
||||
setIsNewFolderOpen(open);
|
||||
if (!open) {
|
||||
@@ -1532,6 +1558,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onConnectSerial(config);
|
||||
}
|
||||
}}
|
||||
onSaveHost={(host) => {
|
||||
onUpdateHosts([...hosts, host]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ const getOpenerLabel = (
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
@@ -173,6 +173,46 @@ export default function SettingsFileAssociationsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show hidden files section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpShowHiddenFiles
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpShowHiddenFiles
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpShowHiddenFiles && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.showHiddenFiles.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.showHiddenFiles.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface SftpPaneCallbacks {
|
||||
onEditFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFile?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
|
||||
// External file upload
|
||||
onUploadExternalFiles?: (files: FileList) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SftpDragCallbacks {
|
||||
@@ -91,6 +94,9 @@ export interface SftpContextValue {
|
||||
// Callbacks for each side
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
|
||||
// Settings
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const SftpContext = createContext<SftpContextValue | null>(null);
|
||||
@@ -124,12 +130,19 @@ export const useSftpHosts = () => {
|
||||
return context.hosts;
|
||||
};
|
||||
|
||||
// Hook to get showHiddenFiles setting
|
||||
export const useSftpShowHiddenFiles = (): boolean => {
|
||||
const context = useSftpContext();
|
||||
return context.showHiddenFiles;
|
||||
};
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
rightCallbacks: SftpPaneCallbacks;
|
||||
showHiddenFiles: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -139,6 +152,7 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
children,
|
||||
}) => {
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
@@ -150,8 +164,9 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
}),
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes,formatDate,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
activeTabStore,
|
||||
|
||||
@@ -187,3 +187,33 @@ export interface ColumnWidths {
|
||||
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
|
||||
if (file.name === "..") return false;
|
||||
return file.hidden === 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).
|
||||
*/
|
||||
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
|
||||
files: T[],
|
||||
showHiddenFiles: boolean
|
||||
): T[] => {
|
||||
if (showHiddenFiles) return files;
|
||||
return files.filter((f) => !isWindowsHiddenFile(f));
|
||||
};
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface Host {
|
||||
telnetEnabled?: boolean; // Is Telnet enabled for this host
|
||||
telnetUsername?: string; // Telnet-specific username
|
||||
telnetPassword?: string; // Telnet-specific password
|
||||
// Serial-specific configuration (for protocol='serial' hosts)
|
||||
serialConfig?: SerialConfig;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -473,6 +475,7 @@ export interface RemoteFile {
|
||||
lastModified: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
|
||||
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
|
||||
}
|
||||
|
||||
export type WorkspaceNode =
|
||||
@@ -512,6 +515,7 @@ export interface SftpFileEntry {
|
||||
owner?: string;
|
||||
group?: string;
|
||||
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
|
||||
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
|
||||
}
|
||||
|
||||
export interface SftpConnection {
|
||||
@@ -615,7 +619,7 @@ export interface ConnectionLog {
|
||||
hostLabel: string; // Display label (e.g., 'Local Terminal' or host label)
|
||||
hostname: string; // Target hostname or 'localhost'
|
||||
username: string; // SSH username or system username
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh';
|
||||
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'serial';
|
||||
startTime: number; // Connection start timestamp
|
||||
endTime?: number; // Connection end timestamp (undefined if still active)
|
||||
localUsername: string; // System username of the local user
|
||||
|
||||
109
electron-builder.config.cjs
Normal file
109
electron-builder.config.cjs
Normal file
@@ -0,0 +1,109 @@
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
*/
|
||||
module.exports = {
|
||||
appId: 'com.netcatty.app',
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
},
|
||||
files: [
|
||||
'dist/**/*',
|
||||
'electron/**/*',
|
||||
'!electron/.dev-config.json',
|
||||
'public/**/*',
|
||||
'node_modules/**/*'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*'
|
||||
],
|
||||
mac: {
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: ['arm64', 'x64']
|
||||
},
|
||||
{
|
||||
target: 'zip',
|
||||
arch: ['arm64', 'x64']
|
||||
}
|
||||
],
|
||||
category: 'public.app-category.developer-tools',
|
||||
hardenedRuntime: false,
|
||||
gatekeeperAssess: false,
|
||||
entitlements: 'electron/entitlements.mac.plist',
|
||||
entitlementsInherit: 'electron/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
NSCameraUsageDescription: 'Netcatty may use the camera for video calls',
|
||||
NSMicrophoneUsageDescription: 'Netcatty may use the microphone for audio',
|
||||
NSLocalNetworkUsageDescription: 'Netcatty needs local network access for SSH connections'
|
||||
}
|
||||
},
|
||||
dmg: {
|
||||
title: '${productName}',
|
||||
background: 'public/dmg-background.jpg',
|
||||
iconSize: 100,
|
||||
iconTextSize: 12,
|
||||
window: {
|
||||
width: 672,
|
||||
height: 500
|
||||
},
|
||||
contents: [
|
||||
{ x: 150, y: 158 },
|
||||
{ x: 550, y: 158, type: 'link', path: '/Applications' },
|
||||
{
|
||||
x: 350,
|
||||
y: 330,
|
||||
type: 'file',
|
||||
// Use absolute path resolved at build time
|
||||
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
|
||||
name: '已损坏修复.app'
|
||||
}
|
||||
]
|
||||
},
|
||||
win: {
|
||||
target: [
|
||||
{
|
||||
target: 'nsis',
|
||||
arch: ['x64']
|
||||
},
|
||||
{
|
||||
target: 'dir',
|
||||
arch: ['x64']
|
||||
}
|
||||
]
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
perMachine: false,
|
||||
allowElevation: true,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
shortcutName: 'Netcatty'
|
||||
},
|
||||
linux: {
|
||||
target: [
|
||||
{
|
||||
target: 'AppImage',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'deb',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'rpm',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
],
|
||||
category: 'Development'
|
||||
}
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.netcatty.app",
|
||||
"productName": "Netcatty",
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "public/icon.png",
|
||||
"directories": {
|
||||
"buildResources": "build",
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"!electron/.dev-config.json",
|
||||
"public/**/*",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/node-pty/**/*",
|
||||
"node_modules/ssh2/**/*",
|
||||
"node_modules/cpu-features/**/*"
|
||||
],
|
||||
"mac": {
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["arm64", "x64"]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": ["arm64", "x64"]
|
||||
}
|
||||
],
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSCameraUsageDescription": "Netcatty may use the camera for video calls",
|
||||
"NSMicrophoneUsageDescription": "Netcatty may use the microphone for audio",
|
||||
"NSLocalNetworkUsageDescription": "Netcatty needs local network access for SSH connections"
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "Netcatty"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": ["x64", "arm64"]
|
||||
},
|
||||
{
|
||||
"target": "rpm",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"category": "Development"
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,35 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
*/
|
||||
function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
try {
|
||||
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a local directory
|
||||
* Properly handles symlinks by resolving their target type
|
||||
* On Windows, also detects hidden files using the hidden attribute
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Stat entries in parallel with a small concurrency limit.
|
||||
// Serial stats can be very slow on Windows for large dirs.
|
||||
@@ -45,12 +66,16 @@ async function listLocalDir(event, payload) {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
type,
|
||||
linkTarget,
|
||||
size: `${stat.size} bytes`,
|
||||
lastModified: stat.mtime.toISOString(),
|
||||
hidden,
|
||||
};
|
||||
} catch (err) {
|
||||
// Handle broken symlinks - lstat doesn't follow symlinks
|
||||
@@ -61,12 +86,14 @@ async function listLocalDir(event, payload) {
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
linkTarget: null, // Broken link - target unknown
|
||||
size: `${lstat.size} bytes`,
|
||||
lastModified: lstat.mtime.toISOString(),
|
||||
hidden,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -466,11 +466,23 @@ async function listSftp(event, payload) {
|
||||
async function readSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
|
||||
const buffer = await client.get(payload.path);
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file as binary (returns ArrayBuffer for binary files like images)
|
||||
*/
|
||||
async function readSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const buffer = await client.get(payload.path);
|
||||
// Convert Node.js Buffer to ArrayBuffer
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write file content
|
||||
*/
|
||||
@@ -649,6 +661,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sftp:open", openSftp);
|
||||
ipcMain.handle("netcatty:sftp:list", listSftp);
|
||||
ipcMain.handle("netcatty:sftp:read", readSftp);
|
||||
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
|
||||
ipcMain.handle("netcatty:sftp:write", writeSftp);
|
||||
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
|
||||
ipcMain.handle("netcatty:sftp:close", closeSftp);
|
||||
@@ -673,6 +686,7 @@ module.exports = {
|
||||
openSftp,
|
||||
listSftp,
|
||||
readSftp,
|
||||
readSftpBinary,
|
||||
writeSftp,
|
||||
writeSftpBinaryWithProgress,
|
||||
closeSftp,
|
||||
|
||||
@@ -13,7 +13,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
|
||||
const log = (msg, data) => {
|
||||
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("[SSH]", msg, data || "");
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
}
|
||||
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
|
||||
socket.write(connectRequest);
|
||||
|
||||
|
||||
let response = '';
|
||||
const onData = (data) => {
|
||||
response += data.toString();
|
||||
@@ -87,7 +87,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
// SOCKS5 greeting
|
||||
const authMethods = proxy.username && proxy.password ? [0x00, 0x02] : [0x00];
|
||||
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
|
||||
|
||||
|
||||
let step = 'greeting';
|
||||
const onData = (data) => {
|
||||
if (step === 'greeting') {
|
||||
@@ -144,7 +144,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const sendConnectRequest = () => {
|
||||
// SOCKS5 connect request
|
||||
const hostBuf = Buffer.from(targetHost);
|
||||
@@ -155,7 +155,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
]);
|
||||
socket.write(request);
|
||||
};
|
||||
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
socket.on('error', reject);
|
||||
@@ -172,27 +172,27 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
const totalHops = jumpHosts.length;
|
||||
|
||||
|
||||
// Connect through each jump host
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jump = jumpHosts[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === jumpHosts.length - 1;
|
||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
||||
|
||||
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
||||
|
||||
|
||||
const conn = new SSHClient();
|
||||
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
host: jump.hostname,
|
||||
@@ -211,7 +211,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
@@ -241,7 +241,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
@@ -254,7 +254,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
}
|
||||
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.on('ready', () => {
|
||||
@@ -274,9 +274,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
|
||||
connections.push(conn);
|
||||
|
||||
|
||||
// Determine next target
|
||||
let nextHost, nextPort;
|
||||
if (isLast) {
|
||||
@@ -289,7 +289,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
nextHost = nextJump.hostname;
|
||||
nextPort = nextJump.port || 22;
|
||||
}
|
||||
|
||||
|
||||
// Create forward stream to next hop
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Forwarding from ${hopLabel} to ${nextHost}:${nextPort}...`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'forwarding');
|
||||
@@ -305,17 +305,17 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Return the final forwarded stream and all connections for cleanup
|
||||
return {
|
||||
socket: currentSocket,
|
||||
return {
|
||||
socket: currentSocket,
|
||||
connections,
|
||||
sendProgress
|
||||
sendProgress
|
||||
};
|
||||
} catch (err) {
|
||||
// Cleanup on error
|
||||
for (const conn of connections) {
|
||||
try { conn.end(); } catch {}
|
||||
try { conn.end(); } catch { }
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -332,7 +332,7 @@ async function startSSHSession(event, options) {
|
||||
const cols = options.cols || 80;
|
||||
const rows = options.rows || 24;
|
||||
const sender = event.sender;
|
||||
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
@@ -343,13 +343,13 @@ async function startSSHSession(event, options) {
|
||||
const conn = new SSHClient();
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
|
||||
|
||||
// Determine if we have jump hosts
|
||||
const jumpHosts = options.jumpHosts || [];
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!options.proxy;
|
||||
const totalHops = jumpHosts.length + 1; // +1 for final target
|
||||
|
||||
|
||||
// Build base connection options for final target
|
||||
const connectOpts = {
|
||||
host: options.hostname,
|
||||
@@ -382,7 +382,7 @@ async function startSSHSession(event, options) {
|
||||
hasPassword: !!options.password,
|
||||
hasEffectivePassphrase: !!effectivePassphrase,
|
||||
});
|
||||
|
||||
|
||||
log("Auth configuration", {
|
||||
hasCertificate,
|
||||
keySource: options.keySource,
|
||||
@@ -437,25 +437,25 @@ async function startSSHSession(event, options) {
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
event,
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
|
||||
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connecting');
|
||||
} else if (hasProxy) {
|
||||
sendProgress(1, 1, options.hostname, 'connecting');
|
||||
connectionSocket = await createProxySocket(
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.proxy,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
);
|
||||
connectOpts.sock = connectionSocket;
|
||||
@@ -470,7 +470,7 @@ async function startSSHSession(event, options) {
|
||||
if (hasJumpHosts || hasProxy) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connected');
|
||||
}
|
||||
|
||||
|
||||
conn.shell(
|
||||
{
|
||||
term: "xterm-256color",
|
||||
@@ -478,7 +478,7 @@ async function startSSHSession(event, options) {
|
||||
rows,
|
||||
},
|
||||
{
|
||||
env: {
|
||||
env: {
|
||||
LANG: resolveLangFromCharset(options.charset),
|
||||
COLORTERM: "truecolor",
|
||||
...(options.env || {}),
|
||||
@@ -488,7 +488,7 @@ async function startSSHSession(event, options) {
|
||||
if (err) {
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
@@ -507,7 +507,7 @@ async function startSSHSession(event, options) {
|
||||
let flushTimeout = null;
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
|
||||
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
const contents = event.sender;
|
||||
@@ -516,7 +516,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
// Immediate flush for large chunks
|
||||
@@ -551,7 +551,7 @@ async function startSSHSession(event, options) {
|
||||
sessions.delete(sessionId);
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -569,28 +569,28 @@ async function startSSHSession(event, options) {
|
||||
|
||||
conn.on("error", (err) => {
|
||||
const contents = event.sender;
|
||||
|
||||
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.message?.toLowerCase().includes('password') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.message?.toLowerCase().includes('password') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
// Use log instead of error for auth failures (normal fallback scenario)
|
||||
if (isAuthError) {
|
||||
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
|
||||
safeSend(contents, "netcatty:auth:failed", {
|
||||
sessionId,
|
||||
safeSend(contents, "netcatty:auth:failed", {
|
||||
sessionId,
|
||||
error: err.message,
|
||||
hostname: options.hostname
|
||||
hostname: options.hostname
|
||||
});
|
||||
} else {
|
||||
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
|
||||
}
|
||||
|
||||
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
@@ -602,7 +602,7 @@ async function startSSHSession(event, options) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
@@ -612,7 +612,7 @@ async function startSSHSession(event, options) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -731,11 +731,11 @@ async function execCommand(event, payload) {
|
||||
*/
|
||||
async function generateKeyPair(event, options) {
|
||||
const { type, bits, comment } = options;
|
||||
|
||||
|
||||
try {
|
||||
let keyType;
|
||||
let keyBits = bits;
|
||||
|
||||
|
||||
switch (type) {
|
||||
case 'ED25519':
|
||||
keyType = 'ed25519';
|
||||
@@ -751,15 +751,15 @@ async function generateKeyPair(event, options) {
|
||||
keyBits = bits || 4096;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
const result = sshUtils.generateKeyPairSync(keyType, {
|
||||
bits: keyBits,
|
||||
comment: comment || 'netcatty-generated-key',
|
||||
});
|
||||
|
||||
|
||||
const privateKey = result.private;
|
||||
const publicKey = result.public;
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
privateKey,
|
||||
@@ -783,9 +783,9 @@ async function startSSHSessionWrapper(event, options) {
|
||||
return await startSSHSession(event, options);
|
||||
} catch (err) {
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
err.message?.toLowerCase().includes('auth') ||
|
||||
err.level === 'client-authentication';
|
||||
|
||||
if (isAuthError) {
|
||||
// Re-throw with a clean error to avoid Electron printing full stack trace
|
||||
// The frontend will handle this as a normal auth failure for fallback
|
||||
@@ -800,50 +800,74 @@ async function startSSHSessionWrapper(event, options) {
|
||||
|
||||
/**
|
||||
* Get current working directory from an active SSH session
|
||||
* This sends 'pwd' to the shell and captures the output
|
||||
* This sends 'pwd' to the existing shell stream and captures the output
|
||||
* using unique markers to identify the command output boundaries
|
||||
*/
|
||||
async function getSessionPwd(event, payload) {
|
||||
const { sessionId } = payload;
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
|
||||
if (!session || !session.stream || !session.conn) {
|
||||
return { success: false, error: 'Session not found or not connected' };
|
||||
}
|
||||
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const conn = session.conn;
|
||||
const stream = session.stream;
|
||||
const marker = `__PWD_${Date.now()}__`;
|
||||
const timeout = setTimeout(() => {
|
||||
stream.removeListener('data', onData);
|
||||
resolve({ success: false, error: 'Timeout getting pwd' });
|
||||
}, 3000);
|
||||
|
||||
// Use exec on the existing connection to run pwd
|
||||
conn.exec('pwd', (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
stream.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
const cwd = stdout.trim().split(/\r?\n/).pop()?.trim();
|
||||
if (cwd && cwd.startsWith('/')) {
|
||||
resolve({ success: true, cwd });
|
||||
} else {
|
||||
resolve({ success: false, error: 'Invalid pwd output' });
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const onData = (data) => {
|
||||
const str = data.toString();
|
||||
buffer += str;
|
||||
|
||||
// We need to find the ACTUAL output markers, not the command echo
|
||||
// The command echo looks like: echo '__PWD_xxx__S' && pwd && echo '__PWD_xxx__E'
|
||||
// The actual output looks like: __PWD_xxx__S\n/path/to/dir\n__PWD_xxx__E
|
||||
//
|
||||
// We look for the marker at the START of a line (after newline) to avoid the echo
|
||||
const startMarkerRegex = new RegExp(`(?:^|[\\r\\n])${marker}S[\\r\\n]+`);
|
||||
const endMarkerRegex = new RegExp(`[\\r\\n]${marker}E(?:[\\r\\n]|$)`);
|
||||
|
||||
const startMatch = buffer.match(startMarkerRegex);
|
||||
const endMatch = buffer.match(endMarkerRegex);
|
||||
|
||||
if (startMatch && endMatch) {
|
||||
const startIdx = buffer.indexOf(startMatch[0]) + startMatch[0].length;
|
||||
const endIdx = buffer.indexOf(endMatch[0]);
|
||||
|
||||
if (startIdx <= endIdx) {
|
||||
clearTimeout(timeout);
|
||||
stream.removeListener('data', onData);
|
||||
|
||||
const pwdOutput = buffer.slice(startIdx, endIdx).trim();
|
||||
console.log('[getSessionPwd] pwdOutput:', JSON.stringify(pwdOutput));
|
||||
|
||||
// The pwd output should be a valid absolute path
|
||||
if (pwdOutput && pwdOutput.startsWith('/')) {
|
||||
console.log('[getSessionPwd] Success, cwd:', pwdOutput);
|
||||
resolve({ success: true, cwd: pwdOutput });
|
||||
} else {
|
||||
console.log('[getSessionPwd] Failed - invalid path:', pwdOutput);
|
||||
resolve({ success: false, error: 'Invalid pwd output' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
stream.on('data', onData);
|
||||
|
||||
// Send pwd command with short unique markers
|
||||
// Using 'S' and 'E' as suffixes to make markers shorter
|
||||
// After the command, send ANSI escape sequences to clear the output lines:
|
||||
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
|
||||
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
|
||||
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
|
||||
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,9 @@ const api = {
|
||||
readSftp: async (sftpId, path) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path });
|
||||
},
|
||||
readSftpBinary: async (sftpId, path) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path });
|
||||
},
|
||||
writeSftp: async (sftpId, path, content) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content });
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
|
||||
// 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';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
|
||||
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
|
||||
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';
|
||||
|
||||
2191
package-lock.json
generated
2191
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -15,11 +15,11 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node electron/launch.cjs",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --dir --publish=never",
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --win --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.json --linux --publish=never",
|
||||
"pack": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --publish=never",
|
||||
"pack:dir": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --dir --publish=never",
|
||||
"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",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"lint": "eslint .",
|
||||
@@ -77,4 +77,4 @@
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/dmg-background.jpg
Normal file
BIN
public/dmg-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
BIN
public/dmg-fix-icon.png
Normal file
BIN
public/dmg-fix-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 727 KiB |
28
scripts/FixQuarantine.app/Contents/Info.plist
Normal file
28
scripts/FixQuarantine.app/Contents/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>FixQuarantine</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.netcatty.fixquarantine</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>FixQuarantine.icns</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
</dict>
|
||||
</plist>
|
||||
17
scripts/FixQuarantine.app/Contents/MacOS/FixQuarantine
Executable file
17
scripts/FixQuarantine.app/Contents/MacOS/FixQuarantine
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
APP_PATH="/Applications/Netcatty.app"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
/usr/bin/osascript <<'EOF'
|
||||
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
/usr/bin/osascript <<'EOF'
|
||||
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
|
||||
EOF
|
||||
|
||||
open "$APP_PATH"
|
||||
BIN
scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
Normal file
BIN
scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
Normal file
Binary file not shown.
10
scripts/gen_icns.sh
Normal file
10
scripts/gen_icns.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
# 1) 准备一张 1024x1024 PNG,例如放在 public/dmg-fix-icon.png
|
||||
# 2) 生成 iconset 并转 icns
|
||||
ICONSET="scripts/fixquarantine.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
|
||||
rm -rf $ICONSET
|
||||
162
to-do.md
162
to-do.md
@@ -1,162 +0,0 @@
|
||||
# Netcatty Feature TODO List
|
||||
|
||||
项目地址: https://github.com/binaricat/Netcatty
|
||||
|
||||
## 功能需求清单
|
||||
|
||||
### 1. GB18030编码支持 🔤
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 支持操作文件名为GB18030编码的文件
|
||||
- 实现动态编码切换,无需断开重连即可生效
|
||||
- 解决目前市面上工具需要重新连接才能应用编码设置的问题
|
||||
|
||||
**技术要点**:
|
||||
- SFTP文件列表的编码转换
|
||||
- 文件名编码自动检测/手动切换
|
||||
- 保持连接状态下的编码切换
|
||||
|
||||
---
|
||||
|
||||
### 2. SFTP的sudo提权支持 🔐
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 普通用户通过SFTP操作文件时支持sudo提权
|
||||
- 两种可选实现方式:
|
||||
- **方式A (WinSCP式)**: 要求服务器端配置sudo免密码
|
||||
- **方式B (HexHub式)**: 使用保存的密码自动完成sudo鉴权 ⭐ 推荐
|
||||
|
||||
**技术要点**:
|
||||
- 研究HexHub的实现原理
|
||||
- 密码安全存储
|
||||
- sudo命令的SFTP封装
|
||||
- 权限提升的UI交互设计
|
||||
|
||||
---
|
||||
|
||||
### 3. trzsz协议支持 📁
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 集成trzsz文件传输协议
|
||||
- 参考项目: https://github.com/trzsz/trzsz
|
||||
- 解决electerm和tabby现有实现中的稳定性问题
|
||||
|
||||
**已知问题**:
|
||||
- electerm和tabby支持trzsz但偶尔无法正常收发文件
|
||||
- 具体bug现象待补充
|
||||
|
||||
**技术要点**:
|
||||
- trzsz协议完整实现
|
||||
- 文件传输的错误处理和重试机制
|
||||
- 传输进度显示
|
||||
- 大文件传输稳定性测试
|
||||
|
||||
---
|
||||
|
||||
### 4. 终端性能优化 ⚡
|
||||
**优先级**: 高
|
||||
|
||||
**需求描述**:
|
||||
- 解决基于xtermjs的终端在大量滚屏时的性能问题
|
||||
- 确保高速输出场景下键盘输入的实时响应
|
||||
|
||||
**核心问题**:
|
||||
- 大量刷屏时`Ctrl+C`信号发不出去
|
||||
- tmux切换窗口命令无响应
|
||||
- 输入延迟严重
|
||||
|
||||
**技术要点**:
|
||||
- 终端渲染性能优化
|
||||
- 输入处理与渲染分离
|
||||
- 虚拟滚动/缓冲区管理
|
||||
- 输入队列优先级处理
|
||||
- 压力测试场景设计
|
||||
|
||||
---
|
||||
|
||||
### 5. X11 Forwarding支持 🖥️
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 支持X11图形界面转发
|
||||
- 能够在SSH连接中运行远程图形应用程序
|
||||
|
||||
**技术要点**:
|
||||
- X11转发的SSH配置
|
||||
- 本地X Server集成或推荐
|
||||
- 跨平台兼容性(Windows/macOS/Linux)
|
||||
- 连接配置UI
|
||||
|
||||
---
|
||||
|
||||
### 6. Terminal到SFTP目录定位 🎯
|
||||
**优先级**: 中
|
||||
|
||||
**需求描述**:
|
||||
- 在Terminal界面时,点击右上角按钮
|
||||
- 自动切换到SFTP视图并定位到当前工作目录
|
||||
- 实现Terminal和SFTP之间的上下文联动
|
||||
|
||||
**已知问题**:
|
||||
- 之前尝试实现但未成功
|
||||
|
||||
**技术要点**:
|
||||
- 获取当前shell的工作目录(`pwd`命令)
|
||||
- Terminal和SFTP视图的状态同步
|
||||
- 异步目录切换的UI反馈
|
||||
- 处理特殊路径(软链接、权限不足等)
|
||||
|
||||
**实现思路**:
|
||||
1. 通过发送`pwd`命令获取当前目录
|
||||
2. 解析命令输出结果
|
||||
3. 触发SFTP视图切换
|
||||
4. 异步加载目标目录内容
|
||||
|
||||
---
|
||||
|
||||
## 开发注意事项 ⚠️
|
||||
|
||||
### 质量要求
|
||||
- 充分的单元测试和集成测试
|
||||
- 避免"按下葫芦起了瓢"的问题
|
||||
- 每个功能都要有完整的测试用例
|
||||
|
||||
### 性能考虑
|
||||
- 避免频繁的AI token消耗
|
||||
- 代码review和人工测试相结合
|
||||
- 建立性能基准测试
|
||||
|
||||
### 用户体验
|
||||
- 这些都是"可以没有但有了方便很多"的功能
|
||||
- 注重细节和边界情况处理
|
||||
- 提供清晰的错误提示和操作引导
|
||||
|
||||
---
|
||||
|
||||
## 实现优先级建议
|
||||
|
||||
### Phase 1 - 核心功能完善
|
||||
- [ ] GB18030编码支持
|
||||
- [ ] 终端性能优化
|
||||
- [ ] Terminal到SFTP目录定位
|
||||
|
||||
### Phase 2 - 高级特性
|
||||
- [ ] SFTP的sudo提权支持
|
||||
- [ ] trzsz协议支持
|
||||
|
||||
### Phase 3 - 扩展功能
|
||||
- [ ] X11 Forwarding支持
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
- trzsz项目: https://github.com/trzsz/trzsz
|
||||
- 竞品分析: WinSCP, HexHub, electerm, tabby
|
||||
- 技术栈: xtermjs (需要性能优化方案)
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-01-09
|
||||
Reference in New Issue
Block a user