Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec04334a21 | ||
|
|
57e3641ec5 | ||
|
|
8258ad6e95 |
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)"
|
||||
"Bash(npx tsc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,12 +542,6 @@ const en: Messages = {
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
@@ -1067,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',
|
||||
|
||||
@@ -774,12 +774,6 @@ const zhCN: Messages = {
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
@@ -1056,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': '自定义',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,7 +316,6 @@ 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);
|
||||
@@ -617,12 +612,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
|
||||
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;
|
||||
@@ -637,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
|
||||
@@ -646,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);
|
||||
@@ -708,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)
|
||||
@@ -1292,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 = {
|
||||
@@ -1306,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(() => {
|
||||
|
||||
@@ -114,16 +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);
|
||||
// Validate: port path must start with /dev/
|
||||
const isPortValid = selectedPort.trim().startsWith('/dev/');
|
||||
const isBaudRateValid = BAUD_RATES.includes(baudRate);
|
||||
|
||||
// Check if using 1.5 stop bits (limited Windows support)
|
||||
const isStopBits15 = stopBits === 1.5;
|
||||
const isValid = isPortValid && isBaudRateValid;
|
||||
|
||||
return (
|
||||
@@ -243,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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -1512,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 }) => {
|
||||
@@ -1526,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
|
||||
@@ -1537,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;
|
||||
@@ -1914,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) => {
|
||||
@@ -2085,8 +1959,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onEditFile: handleEditFileLeft,
|
||||
onOpenFile: handleOpenFileLeft,
|
||||
onOpenFileWith: handleOpenFileWithLeft,
|
||||
onDownloadFile: handleDownloadFileLeft,
|
||||
onUploadExternalFiles: handleUploadExternalFilesLeft,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -2112,8 +1984,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
onEditFile: handleEditFileRight,
|
||||
onOpenFile: handleOpenFileRight,
|
||||
onOpenFileWith: handleOpenFileWithRight,
|
||||
onDownloadFile: handleDownloadFileRight,
|
||||
onUploadExternalFiles: handleUploadExternalFilesRight,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -2254,7 +2124,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
rightCallbacks={rightCallbacks}
|
||||
showHiddenFiles={sftpShowHiddenFiles}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -473,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 =
|
||||
@@ -513,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 {
|
||||
|
||||
@@ -42,27 +42,6 @@
|
||||
"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",
|
||||
"path": "scripts/FixQuarantine.app",
|
||||
"name": "已损坏修复.app"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 727 KiB |
@@ -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>
|
||||
@@ -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"
|
||||
Binary file not shown.
@@ -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
162
to-do.md
Normal 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
|
||||
Reference in New Issue
Block a user