Compare commits

..

3 Commits

Author SHA1 Message Date
TachibanaLolo
ec04334a21 Merge branch 'binaricat:main' into main
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-01-09 22:03:02 +08:00
TachibanaLolo
57e3641ec5 docs: add Netcatty feature todo list 2026-01-09 22:02:34 +08:00
TachibanaLolo
8258ad6e95 Merge pull request #1 from AkarinServer/feature/linux-build-support
feat: add linux build support (x64/arm64)
2026-01-08 23:22:16 +08:00
37 changed files with 1574 additions and 2660 deletions

View File

@@ -1,8 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)"
"Bash(npx tsc:*)"
]
}
}

41
App.tsx
View File

@@ -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, SerialConfig, TerminalTheme } from './types';
import { ConnectionLog, Host, HostProtocol, 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,25 +619,6 @@ 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({
@@ -654,24 +635,6 @@ 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 });
@@ -813,7 +776,7 @@ function App({ settings }: { settings: SettingsState }) {
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
onConnectSerial={handleConnectSerial}
onConnectSerial={createSerialSession}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
onUpdateHosts={updateHosts}

View File

@@ -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,18 +542,6 @@ const en: Messages = {
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
'sftp.reconnected': 'Connection restored',
'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.',
// 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',
@@ -660,12 +648,6 @@ 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',
@@ -1079,12 +1061,11 @@ 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 or COM1',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',
@@ -1101,15 +1082,6 @@ 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;

View File

@@ -401,12 +401,6 @@ 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': '编辑主机',
@@ -763,7 +757,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 视图中双击文件时的操作',
@@ -771,7 +765,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': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
@@ -780,18 +774,6 @@ const zhCN: Messages = {
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
'sftp.reconnected': '连接已恢复',
'sftp.error.reconnectFailed': '重连失败,请重试。',
// 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': '选择主题',
@@ -1068,12 +1050,11 @@ 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 或 COM1',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
'serial.type.custom': '自定义',
@@ -1090,15 +1071,6 @@ 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;

View File

@@ -72,37 +72,6 @@ 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,

View File

@@ -18,7 +18,6 @@ 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';
@@ -42,7 +41,6 @@ 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);
@@ -169,10 +167,6 @@ 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) => {
@@ -404,18 +398,11 @@ 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, sftpShowHiddenFiles]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -478,12 +465,6 @@ 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 => {
@@ -594,8 +575,6 @@ export const useSettingsState = () => {
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
availableFonts,
};
};

View File

@@ -676,7 +676,6 @@ 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
};
});
},
@@ -2725,89 +2724,6 @@ 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> => {
@@ -2851,7 +2767,6 @@ export const useSftpState = (
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
selectApplication,
startTransfer,
cancelTransfer,
@@ -2889,7 +2804,6 @@ export const useSftpState = (
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
selectApplication,
startTransfer,
cancelTransfer,
@@ -2930,7 +2844,6 @@ 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),

View File

@@ -4,7 +4,6 @@ import {
Server,
Terminal,
Trash2,
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo } from "react";
@@ -64,7 +63,6 @@ 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
@@ -94,14 +92,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",
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server 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" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
{isLocal ? "local" : `${log.protocol}, ${log.username}`}
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Server, Usb } from "lucide-react";
import { Server } from "lucide-react";
import React, { memo } from "react";
import { normalizeDistroId } from "../domain/host";
import { cn } from "../lib/utils";
@@ -69,21 +69,6 @@ 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

View File

@@ -522,16 +522,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Combobox
options={groupOptions}
value={form.group || ""}
onValueChange={(val) => {
update("group", val);
setGroupInputValue(val);
}}
onValueChange={(val) => update("group", 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"

View File

@@ -48,7 +48,6 @@ 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";
@@ -257,8 +256,6 @@ interface SFTPModalProps {
};
open: boolean;
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
}
// Sort configuration
@@ -283,7 +280,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials,
open,
onClose,
initialPath,
}) => {
const {
openSftp,
@@ -308,7 +304,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
downloadSftpToTempAndOpen,
} = useSftpBackend();
const { t, resolvedLocale } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { sftpAutoSync } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
@@ -320,17 +316,10 @@ 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);
// Reconnect state
const [reconnecting, setReconnecting] = useState(false);
const reconnectingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const MAX_RECONNECT_ATTEMPTS = 3;
// Rename dialog state
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
@@ -542,40 +531,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
openSftp,
]);
// Check if an error indicates a stale/lost SFTP session
const isSessionError = useCallback((err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset") ||
msg.includes("eof")
);
}, []);
// Handle session error - triggers auto-reconnect
const handleSessionError = useCallback(() => {
if (reconnectingRef.current) return; // Prevent duplicate reconnect attempts
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
setReconnecting(false);
reconnectingRef.current = false;
return;
}
// Clear stale session reference
sftpIdRef.current = null;
// Set reconnecting state
reconnectingRef.current = true;
reconnectAttemptsRef.current++;
setReconnecting(true);
}, [t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
const requestId = ++loadSeqRef.current;
@@ -609,14 +564,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setSelectedFiles(new Set());
} catch (e) {
if (loadSeqRef.current !== requestId) return;
// Check if this is a session error that can trigger auto-reconnect
if (!isLocalSession && isSessionError(e) && files.length > 0) {
logger.info("[SFTP] Session lost, attempting to reconnect...");
handleSessionError();
return;
}
logger.error("Failed to load files", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
@@ -629,7 +576,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length],
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t],
);
useLayoutEffect(() => {
@@ -663,79 +610,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
};
}, [closeSftpSession]);
// Auto-reconnect effect
useEffect(() => {
if (!reconnecting || !reconnectingRef.current || isLocalSession) return;
const attemptReconnect = async () => {
// Small delay before reconnecting
await new Promise((resolve) => setTimeout(resolve, 1000));
if (!reconnectingRef.current) return; // May have been cancelled
try {
// Re-establish SFTP connection
const sftpId = await openSftp({
sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname,
username: credentials.username || "root",
port: credentials.port || 22,
password: credentials.password,
privateKey: credentials.privateKey,
certificate: credentials.certificate,
passphrase: credentials.passphrase,
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
});
sftpIdRef.current = sftpId;
// Refresh current directory
const list = await listSftp(sftpId, currentPath);
dirCacheRef.current.set(`${host.id}::${currentPath}`, {
files: list,
timestamp: Date.now(),
});
setFiles(list);
setSelectedFiles(new Set());
// Reconnect successful
reconnectingRef.current = false;
reconnectAttemptsRef.current = 0;
setReconnecting(false);
toast.success(t("sftp.reconnected"), "SFTP");
} catch (e) {
logger.error("[SFTP] Reconnect failed", e);
// Check if we can retry
if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
// Trigger another attempt
sftpIdRef.current = null;
reconnectingRef.current = false; // Reset to allow handleSessionError to work
handleSessionError();
} else {
// Max retries reached
reconnectingRef.current = false;
setReconnecting(false);
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
}
} finally {
setLoading(false);
}
};
attemptReconnect();
}, [reconnecting, isLocalSession, host.id, credentials, openSftp, listSftp, currentPath, t, handleSessionError]);
useEffect(() => {
if (open) {
// Check if we need to reinitialize (either first time or initialPath changed)
const needsReinit = !initializedRef.current || initialPath !== lastInitialPathRef.current;
if (needsReinit) {
if (!initializedRef.current) {
initializedRef.current = true;
lastInitialPathRef.current = initialPath;
if (isLocalSession) {
void (async () => {
let home = localHomeRef.current;
@@ -750,7 +628,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
loadFiles(startPath);
})();
} else {
// For remote sessions, try initialPath first, then fall back to home directory
// For remote sessions, load home directory directly
void (async () => {
const username = credentials.username || 'root';
// Root user's home is /root, other users' home is /home/username
@@ -759,26 +637,6 @@ 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);
@@ -821,7 +679,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
void closeSftpSession();
initializedRef.current = false;
}
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t, initialPath]);
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t]);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
@@ -1405,12 +1263,9 @@ 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 visibleFiles;
if (atRoot) return files;
// Add ".." parent directory entry at the top (only if not at root)
const parentEntry: RemoteFile = {
@@ -1419,8 +1274,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
size: "--",
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath, sftpShowHiddenFiles]);
return [parentEntry, ...files.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath]);
// Sorted files
const sortedFiles = useMemo(() => {
@@ -1816,7 +1671,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
className="h-7 w-7"
onClick={() => loadFiles(currentPath, { force: true })}
>
<RefreshCw size={14} className={cn((loading || reconnecting) && "animate-spin")} />
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
</Button>
{/* Editable Breadcrumbs */}
@@ -2010,19 +1865,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
</div>
)}
{/* Reconnecting overlay */}
{reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
</div>
</div>
</div>
)}
{files.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />

View File

@@ -2,11 +2,11 @@
* Serial Port Connect Modal
* Allows users to configure and connect to a serial port
*/
import { ChevronDown, ChevronUp, Cpu, RefreshCw, Save, Usb } from 'lucide-react';
import { ChevronDown, ChevronUp, Cpu, RefreshCw, 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 type { SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
@@ -18,7 +18,6 @@ import {
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
@@ -36,7 +35,6 @@ 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];
@@ -49,7 +47,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
open,
onClose,
onConnect,
onSaveHost,
}) => {
const { t } = useI18n();
const [ports, setPorts] = useState<SerialPort[]>([]);
@@ -66,10 +63,6 @@ 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 () => {
@@ -94,14 +87,6 @@ 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;
@@ -116,26 +101,6 @@ 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();
};
@@ -149,17 +114,9 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// 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;
// Validate: port path must start with /dev/
const isPortValid = selectedPort.trim().startsWith('/dev/');
const isBaudRateValid = BAUD_RATES.includes(baudRate);
const isValid = isPortValid && isBaudRateValid;
return (
@@ -214,28 +171,18 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{/* 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>
)}
<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>
</div>
{/* Advanced Options */}
@@ -289,11 +236,6 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</option>
))}
</select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
@@ -371,40 +313,6 @@ 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>
@@ -412,12 +320,8 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{t('common.cancel')}
</Button>
<Button onClick={handleConnect} disabled={!isValid}>
{saveConfig ? (
<Save size={14} className="mr-2" />
) : (
<Cpu size={14} className="mr-2" />
)}
{saveConfig ? t('serial.connectAndSave') : t('common.connect')}
<Cpu size={14} className="mr-2" />
{t('common.connect')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,415 +0,0 @@
/**
* 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;

View File

@@ -59,7 +59,6 @@ import { Label } from "./ui/label";
// Import extracted components
import {
ColumnWidths,
filterHiddenFiles,
isNavigableDirectory,
SftpBreadcrumb,
SftpConflictDialog,
@@ -101,7 +100,6 @@ import {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
activeTabStore,
type SftpPaneCallbacks,
@@ -164,7 +162,6 @@ 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 {
@@ -185,9 +182,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onReceiveFromOtherPane,
onEditPermissions,
onEditFile,
onOpenFile,
onOpenFileWith,
onDownloadFile,
onUploadExternalFiles,
} = callbacks;
// 渲染追踪 - 只追踪数据 props回调来自 context引用稳定
@@ -259,16 +255,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const filteredFiles = useMemo(() => {
const term = pane.filter.trim().toLowerCase();
// Filter hidden files using utility function
let files = filterHiddenFiles(pane.files, showHiddenFiles);
// Apply text filter
if (!term) return files;
return files.filter(
if (!term) return pane.files;
return pane.files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [pane.files, pane.filter, showHiddenFiles]);
}, [pane.files, pane.filter]);
// Path suggestions
const pathSuggestions = useMemo(() => {
@@ -602,18 +593,6 @@ 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";
@@ -628,23 +607,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setDragOverEntry(null);
};
const handlePaneDrop = async (e: React.DragEvent) => {
const handlePaneDrop = (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 })),
@@ -847,11 +814,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</>
) : (
<>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</>
)}
</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" />{" "}
@@ -864,12 +838,6 @@ 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={() => {
@@ -933,10 +901,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowOpen,
handleRowSelect,
onCopyToOtherPane,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onOpenFile,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
@@ -1291,7 +1259,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : pane.error && !pane.reconnecting ? (
) : pane.error ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{pane.error}</span>
@@ -1341,25 +1309,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
{pane.loading && sortedDisplayFiles.length > 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
{pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 size={32} className="animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
</div>
</div>
</div>
)}
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
@@ -1525,8 +1480,8 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, sftpAutoSync } = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -1539,7 +1494,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
@@ -1550,7 +1505,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;
@@ -1927,100 +1882,6 @@ 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) => {
@@ -2098,8 +1959,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileLeft,
onOpenFile: handleOpenFileLeft,
onOpenFileWith: handleOpenFileWithLeft,
onDownloadFile: handleDownloadFileLeft,
onUploadExternalFiles: handleUploadExternalFilesLeft,
}),
[],
);
@@ -2125,8 +1984,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileRight,
onOpenFile: handleOpenFileRight,
onOpenFileWith: handleOpenFileWithRight,
onDownloadFile: handleDownloadFileRight,
onUploadExternalFiles: handleUploadExternalFilesRight,
}),
[],
);
@@ -2267,7 +2124,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
showHiddenFiles={sftpShowHiddenFiles}
>
<div
className={cn(

View File

@@ -5,7 +5,6 @@ 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";
@@ -182,7 +181,6 @@ 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);
@@ -734,34 +732,6 @@ 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);
@@ -840,7 +810,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={handleOpenSFTP}
onOpenSFTP={() => setShowSFTP((v) => !v)}
onSnippetClick={handleSnippetClick}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
@@ -1083,7 +1053,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})()}
open={showSFTP && status === "connected"}
onClose={() => setShowSFTP(false)}
initialPath={sftpInitialPath}
/>
</div>
</TerminalContextMenu>

View File

@@ -50,7 +50,6 @@ 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";
@@ -1362,7 +1361,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
{currentSection === "hosts" && isHostPanelOpen && (
<HostDetailsPanel
initialData={editingHost}
availableKeys={keys}
@@ -1397,31 +1396,6 @@ 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) {
@@ -1558,9 +1532,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnectSerial(config);
}
}}
onSaveHost={(host) => {
onUpdateHosts([...hosts, host]);
}}
/>
</div>
);

View File

@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -173,46 +173,6 @@ 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')} />

View File

@@ -31,9 +31,6 @@ 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 {
@@ -94,9 +91,6 @@ export interface SftpContextValue {
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
// Settings
showHiddenFiles: boolean;
}
const SftpContext = createContext<SftpContextValue | null>(null);
@@ -130,19 +124,12 @@ 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;
}
@@ -152,7 +139,6 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
@@ -164,9 +150,8 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
}),
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;

View File

@@ -7,7 +7,7 @@
// Utilities
export {
formatBytes,formatDate,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidths,type SortField,
type SortOrder
} from './utils';
@@ -18,7 +18,6 @@ export {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
useIsPaneActive,
activeTabStore,

View File

@@ -187,33 +187,3 @@ 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));
};

View File

@@ -88,8 +88,6 @@ 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';
@@ -475,7 +473,6 @@ 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 =
@@ -515,7 +512,6 @@ 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 {
@@ -619,7 +615,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' | 'serial';
protocol: 'ssh' | 'telnet' | 'local' | 'mosh';
startTime: number; // Connection start timestamp
endTime?: number; // Connection end timestamp (undefined if still active)
localUsername: string; // System username of the local user

View File

@@ -1,110 +0,0 @@
/* global __dirname */
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'
}
};

83
electron-builder.json Normal file
View File

@@ -0,0 +1,83 @@
{
"$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"
}
}

View File

@@ -6,35 +6,14 @@
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.
@@ -66,16 +45,12 @@ 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
@@ -86,14 +61,12 @@ 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;
}

View File

@@ -466,23 +466,11 @@ 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
*/
@@ -661,7 +649,6 @@ 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);
@@ -686,7 +673,6 @@ module.exports = {
openSftp,
listSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinaryWithProgress,
closeSftp,

View File

@@ -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,74 +800,50 @@ async function startSSHSessionWrapper(event, options) {
/**
* Get current working directory from an active SSH session
* This sends 'pwd' to the existing shell stream and captures the output
* using unique markers to identify the command output boundaries
* This sends 'pwd' to the shell and captures the output
*/
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 stream = session.stream;
const marker = `__PWD_${Date.now()}__`;
const conn = session.conn;
const timeout = setTimeout(() => {
stream.removeListener('data', onData);
resolve({ success: false, error: 'Timeout getting pwd' });
}, 3000);
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' });
}
}
// 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;
}
};
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`);
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' });
}
});
stream.on('error', (err) => {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
});
});
});
}

View File

@@ -295,9 +295,6 @@ 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 });
},

View File

@@ -41,7 +41,6 @@ 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';

2200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.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",
"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",
"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"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 KiB

View File

@@ -1,28 +0,0 @@
<?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>

View File

@@ -1,17 +0,0 @@
#!/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"

View File

@@ -1,10 +0,0 @@
# 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 Normal file
View File

@@ -0,0 +1,162 @@
# 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