Compare commits

...

32 Commits

Author SHA1 Message Date
bincxz
adb2bc9403 Rename onOpenFile prop to _onOpenFile and tidy formatting
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
Rename the unused `onOpenFile` callback to `_onOpenFile` to suppress lint warnings and clarify that it is intentionally unused.
Remove extraneous blank lines and adjust string concatenation in upload success messages for cleaner code formatting.

Rename unused onOpenFile prop and clean formatting

Renames the unused `onOpenFile` callback to `_onOpenFile` to silence lint warnings and make its intentional non‑use explicit.
Removes stray blank lines and streamlines string concatenation in upload success messages, improving code readability and consistency.
2026-01-12 23:08:54 +08:00
bincxz
7a6ed660fb Clears stray output lines after pwd command
Adds ANSI escape sequences to the pwd request so that the command echo, start/end markers, and the pwd output are removed from the terminal view. This prevents leftover lines from cluttering the SSH session display, resulting in a cleaner UI.
2026-01-12 23:00:35 +08:00
bincxz
035b22b467 Add stop‑bits warning, binary SFTP read support
- Introduces a user warning for 1.5 stop‑bits, which may be unsupported on some Windows devices, and updates both English and Chinese locales.
- Extends serial port validation to recognize Windows UNC COM paths and displays the new warning when 1.5 stop‑bits are selected.
- Improves SFTP modal behavior by re‑initializing when the initial path changes, using a ref to track the previous path.
- Refactors terminal logic to obtain the current working directory via stream markers, applies `flushSync` to commit the path before opening the SFTP modal, ensuring correct initialization.
- Adds a binary SFTP read API (`readSftpBinary`) on the Electron side and exposes it through the preload script, enabling proper handling of binary files.
- Updates local Claude settings to allow linting via `npm run lint:*`.
- Minor code clean‑ups and whitespace fixes across several bridge modules.
2026-01-12 22:58:44 +08:00
陈大猫
1bce2c9808 Merge pull request #73 from binaricat/copilot/add-ftp-auto-sync-feature
feat: Sync SFTP to terminal's current working directory
2026-01-12 22:12:04 +08:00
copilot-swe-agent[bot]
ca2d699e55 feat: Add terminal folder sync to SFTP - open SFTP at terminal's current directory
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 14:03:58 +00:00
copilot-swe-agent[bot]
6907fb54c8 Initial plan 2026-01-12 13:57:16 +00:00
陈大猫
4bae2517fe Merge pull request #72 from binaricat/copilot/fix-sftp-download-issue
Fix SFTP context menu mislabel: "Download" → "Open" for files
2026-01-12 21:05:26 +08:00
copilot-swe-agent[bot]
da4936ff22 Add Download to local functionality to SFTP context menu
- Added onDownloadFile callback to SftpPaneCallbacks interface
- Implemented handleDownloadFileForSide that reads file as binary
  and triggers browser download
- Added Download menu item after Edit in file context menu
- Now both Open (打开) and Download (下载) functions exist

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 13:03:35 +00:00
copilot-swe-agent[bot]
2223ec34f0 Fix SFTP context menu: change "Download" label to "Open" for files
The first context menu item for files incorrectly displayed "Download" (下载)
but actually performed an "Open" action. This fixes the mislabeling by:
- Changing the label from "Download" to "Open"
- Changing the icon from Download to ExternalLink
- Removing the duplicate "Open" menu item
- Updating the renderRow dependency array

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 12:49:49 +00:00
copilot-swe-agent[bot]
ca1423051d Initial plan 2026-01-12 12:44:53 +00:00
陈大猫
ca8b36c7d5 Merge pull request #71 from binaricat/copilot/fix-group-switching-issue
Fix group switching not saving when host already has a group
2026-01-12 19:35:42 +08:00
copilot-swe-agent[bot]
b96eaf2aca Fix group switching bug: sync groupInputValue when Combobox value changes
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 11:22:11 +00:00
copilot-swe-agent[bot]
663fe88b2e Initial plan 2026-01-12 11:14:42 +00:00
陈大猫
42da477425 Merge pull request #68 from binaricat/copilot/add-sftp-drag-and-drop
Add external file drag-and-drop to SFTP views
2026-01-12 19:13:34 +08:00
copilot-swe-agent[bot]
474a13e4f9 polish: Final code review improvements
- Simplify success/failure message logic (check failCount === 0)
- Improve eslint comment to list all stable dependencies
- Clarify progress API fallback condition with explicit checks
- Add comment explaining fallback behavior

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:44:49 +00:00
copilot-swe-agent[bot]
3c5e12cc8b refactor: Improve code clarity per review feedback
- Use template literals instead of string concatenation for i18n
- Add inline comments to clarify undefined parameters
- Remove empty comment block
- Simplify toast message construction

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:43:30 +00:00
copilot-swe-agent[bot]
c2a01d83d7 fix: Properly handle writeSftpBinaryWithProgress return value
- Check result.success property instead of undefined
- Add fallback to writeSftpBinary if progress API fails
- Improve code clarity by checking method existence upfront

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:41:58 +00:00
copilot-swe-agent[bot]
dcc3b6fce7 fix: Correct upload progress API fallback logic and add eslint comment
- Fix writeSftpBinaryWithProgress return value check (undefined vs boolean)
- Add eslint-disable comment for sftpRef dependency (follows existing pattern)
- Simplify progress API fallback condition for clarity

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:40:37 +00:00
copilot-swe-agent[bot]
fb43b53f33 refactor: Address code review feedback
- Extract shared upload logic into handleUploadExternalFilesForSide helper
- Simplify SFTP upload fallback logic for better readability
- Remove code duplication between left and right upload handlers

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:38:24 +00:00
copilot-swe-agent[bot]
b90c29f56a feat: Add external file drag-and-drop support to SftpView
- Add uploadExternalFiles method to useSftpState for handling OS file drops
- Detect external file drag events in SftpView (e.dataTransfer.types includes 'Files')
- Handle external file drops separately from internal pane-to-pane transfers
- Upload dropped files to local or remote filesystems using existing backend methods
- Display success/error toasts for upload operations
- Support drag-and-drop to any active pane/tab

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 08:36:17 +00:00
copilot-swe-agent[bot]
37092826f3 Initial plan 2026-01-12 08:29:10 +00:00
陈大猫
30b809a8f6 Merge pull request #67 from binaricat/copilot/add-sftp-show-hidden-files-option
Add SFTP show hidden files setting
2026-01-12 15:47:52 +08:00
copilot-swe-agent[bot]
989a1aa3d7 Support Windows hidden files using file attribute, remove dotfile filtering
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:40:35 +00:00
copilot-swe-agent[bot]
9e5c5f826f Update hidden files documentation to clarify Unix/Linux dotfile convention
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:28:22 +00:00
copilot-swe-agent[bot]
74e0249797 Refactor: extract hidden file filtering into shared utility
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:16:59 +00:00
copilot-swe-agent[bot]
d89d6d3959 Add SFTP show hidden files setting
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 07:13:41 +00:00
copilot-swe-agent[bot]
66680d585f Initial plan 2026-01-12 07:00:55 +00:00
陈大猫
57dd2fb48b Merge pull request #65 from binaricat/copilot/fix-windows-serial-connection-issue
Fix Windows serial port validation to accept COM ports
2026-01-12 14:59:28 +08:00
copilot-swe-agent[bot]
6d973f9bc8 Fix Windows serial port validation to accept COM ports
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-12 06:26:00 +00:00
copilot-swe-agent[bot]
425647eeda Initial plan 2026-01-12 06:21:00 +00:00
陈大猫
9109aec4ab Merge pull request #64 from Weihong-Liu/feature/dmg-repair-helper
Add DMG background and repair helper app
2026-01-12 14:01:45 +08:00
Puppet
6d5283173a Add DMG background and repair helper app 2026-01-12 13:45:38 +08:00
27 changed files with 696 additions and 130 deletions

View File

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

View File

@@ -542,6 +542,12 @@ const en: Messages = {
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',
@@ -1061,11 +1067,12 @@ const en: Messages = {
'serial.field.baudRate': 'Baud Rate',
'serial.field.dataBits': 'Data Bits',
'serial.field.stopBits': 'Stop Bits',
'serial.field.stopBits15Warning': '1.5 stop bits may not be supported on all Windows devices',
'serial.field.parity': 'Parity',
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
'serial.type.custom': 'Custom',

View File

@@ -774,6 +774,12 @@ const zhCN: Messages = {
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
@@ -1050,11 +1056,12 @@ const zhCN: Messages = {
'serial.field.baudRate': '波特率',
'serial.field.dataBits': '数据位',
'serial.field.stopBits': '停止位',
'serial.field.stopBits15Warning': '1.5 停止位在 Windows 下可能不被所有设备支持',
'serial.field.parity': '校验位',
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
'serial.type.custom': '自定义',

View File

@@ -18,6 +18,7 @@ STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -41,6 +42,7 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -167,6 +169,10 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_SYNC;
});
const [sftpShowHiddenFiles, setSftpShowHiddenFiles] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -398,11 +404,18 @@ export const useSettingsState = () => {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -465,6 +478,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -575,6 +594,8 @@ export const useSettingsState = () => {
setSftpDoubleClickBehavior,
sftpAutoSync,
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
availableFonts,
};
};

View File

@@ -676,6 +676,7 @@ export const useSftpState = (
lastModified: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden, // Windows hidden attribute
};
});
},
@@ -2724,6 +2725,89 @@ export const useSftpState = (
[getActivePane],
);
// Upload external files dropped from OS
const uploadExternalFiles = useCallback(
async (side: "left" | "right", files: FileList) => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const results: { fileName: string; success: boolean; error?: string }[] = [];
for (const file of Array.from(files)) {
const targetPath = joinPath(pane.connection.currentPath, file.name);
try {
const arrayBuffer = await file.arrayBuffer();
if (pane.connection.isLocal) {
// Upload to local filesystem
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(targetPath, arrayBuffer);
} else {
// Upload to remote via SFTP
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
// Try progress API first, fallback to basic binary write
if (bridge.writeSftpBinaryWithProgress) {
const result = await bridge.writeSftpBinaryWithProgress(
sftpId,
targetPath,
arrayBuffer,
crypto.randomUUID(),
// Progress callbacks not needed for simple drag-drop upload
undefined, // onProgress
undefined, // onComplete
undefined, // onError
);
// Check if progress API explicitly reported failure
// If result is undefined/null or success is false, fallback to basic API
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, targetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
}
} else if (bridge.writeSftpBinary) {
// Progress API not available, use basic API
await bridge.writeSftpBinary(sftpId, targetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
}
results.push({ fileName: file.name, success: true });
} catch (error) {
logger.error(`Failed to upload ${file.name}:`, error);
results.push({
fileName: file.name,
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
// Refresh the file list to show new files
await refresh(side);
return results;
},
[getActivePane, refresh],
);
// Select an application from system file picker
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
@@ -2767,6 +2851,7 @@ export const useSftpState = (
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
selectApplication,
startTransfer,
cancelTransfer,
@@ -2804,6 +2889,7 @@ export const useSftpState = (
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
selectApplication,
startTransfer,
cancelTransfer,
@@ -2844,6 +2930,7 @@ export const useSftpState = (
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),

View File

@@ -522,12 +522,16 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Combobox
options={groupOptions}
value={form.group || ""}
onValueChange={(val) => update("group", val)}
onValueChange={(val) => {
update("group", val);
setGroupInputValue(val);
}}
placeholder={t("hostDetails.group.placeholder")}
allowCreate={true}
onCreateNew={(val) => {
onCreateGroup?.(val);
update("group", val);
setGroupInputValue(val);
}}
createText="Create Group"
triggerClassName="flex-1 h-10"

View File

@@ -48,6 +48,7 @@ import { logger } from "../lib/logger";
import { getFileExtension, isKnownBinaryFile, FileOpenerType, SystemAppInfo } from "../lib/sftpFileUtils";
import { cn } from "../lib/utils";
import { Host, RemoteFile } from "../types";
import { filterHiddenFiles } from "./sftp";
import { DistroAvatar } from "./DistroAvatar";
import FileOpenerDialog from "./FileOpenerDialog";
import TextEditorModal from "./TextEditorModal";
@@ -256,6 +257,8 @@ interface SFTPModalProps {
};
open: boolean;
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
}
// Sort configuration
@@ -280,6 +283,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
credentials,
open,
onClose,
initialPath,
}) => {
const {
openSftp,
@@ -304,7 +308,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
downloadSftpToTempAndOpen,
} = useSftpBackend();
const { t, resolvedLocale } = useI18n();
const { sftpAutoSync } = useSettingsState();
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
@@ -316,6 +320,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const inputRef = useRef<HTMLInputElement>(null);
const sftpIdRef = useRef<string | null>(null);
const initializedRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const navigatingRef = useRef(false);
const lastSelectedIndexRef = useRef<number | null>(null);
const localHomeRef = useRef<string | null>(null);
@@ -612,8 +617,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
useEffect(() => {
if (open) {
if (!initializedRef.current) {
// Check if we need to reinitialize (either first time or initialPath changed)
const needsReinit = !initializedRef.current || initialPath !== lastInitialPathRef.current;
if (needsReinit) {
initializedRef.current = true;
lastInitialPathRef.current = initialPath;
if (isLocalSession) {
void (async () => {
let home = localHomeRef.current;
@@ -628,7 +637,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
loadFiles(startPath);
})();
} else {
// For remote sessions, load home directory directly
// For remote sessions, try initialPath first, then fall back to home directory
void (async () => {
const username = credentials.username || 'root';
// Root user's home is /root, other users' home is /home/username
@@ -637,6 +646,26 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Set loading state immediately for better UX
setLoading(true);
// If initialPath is provided, try to use it first
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
setSelectedFiles(new Set());
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return; // Successfully opened at initialPath
} catch {
// initialPath not accessible, fall back to home directory
logger.warn(`[SFTP] Initial path ${initialPath} not accessible, falling back to home`);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath);
@@ -679,7 +708,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
void closeSftpSession();
initializedRef.current = false;
}
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t]);
}, [open, currentPath, loadFiles, closeSftpSession, getHomeDir, isLocalSession, credentials.username, ensureSftp, listSftp, host.id, t, initialPath]);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
@@ -1263,9 +1292,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
// Check if we're at root
const atRoot = isRootPath(currentPath);
if (atRoot) return files;
if (atRoot) return visibleFiles;
// Add ".." parent directory entry at the top (only if not at root)
const parentEntry: RemoteFile = {
@@ -1274,8 +1306,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
size: "--",
lastModified: undefined,
};
return [parentEntry, ...files.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath]);
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPath, sftpShowHiddenFiles]);
// Sorted files
const sortedFiles = useMemo(() => {

View File

@@ -114,9 +114,16 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// Validate: port path must start with /dev/
const isPortValid = selectedPort.trim().startsWith('/dev/');
// 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);
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 (
@@ -236,6 +243,11 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</option>
))}
</select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>

View File

@@ -59,6 +59,7 @@ import { Label } from "./ui/label";
// Import extracted components
import {
ColumnWidths,
filterHiddenFiles,
isNavigableDirectory,
SftpBreadcrumb,
SftpConflictDialog,
@@ -100,6 +101,7 @@ import {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpShowHiddenFiles,
useActiveTabId,
activeTabStore,
type SftpPaneCallbacks,
@@ -162,6 +164,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const showHiddenFiles = useSftpShowHiddenFiles();
// Destructure for easier use
const {
@@ -182,8 +185,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onReceiveFromOtherPane,
onEditPermissions,
onEditFile,
onOpenFile,
onOpenFileWith,
onDownloadFile,
onUploadExternalFiles,
} = callbacks;
// 渲染追踪 - 只追踪数据 props回调来自 context引用稳定
@@ -255,11 +259,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const filteredFiles = useMemo(() => {
const term = pane.filter.trim().toLowerCase();
if (!term) return pane.files;
return pane.files.filter(
// Filter hidden files using utility function
let files = filterHiddenFiles(pane.files, showHiddenFiles);
// Apply text filter
if (!term) return files;
return files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [pane.files, pane.filter]);
}, [pane.files, pane.filter, showHiddenFiles]);
// Path suggestions
const pathSuggestions = useMemo(() => {
@@ -593,6 +602,18 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
// Drag handlers
const handlePaneDragOver = (e: React.DragEvent) => {
// Check if this is external file drag (from OS)
const hasFiles = e.dataTransfer.types.includes('Files');
// If it's external files, always allow drop
if (hasFiles) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
return;
}
// Otherwise, check if it's internal drag from other pane
if (!draggedFiles || draggedFiles[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
@@ -607,11 +628,23 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setDragOverEntry(null);
};
const handlePaneDrop = (e: React.DragEvent) => {
const handlePaneDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
// Check if this is external file drop (from OS)
const droppedFiles = e.dataTransfer.files;
if (droppedFiles && droppedFiles.length > 0) {
// Handle external file upload using the callback
if (onUploadExternalFiles) {
await onUploadExternalFiles(droppedFiles);
}
return;
}
// Otherwise, handle internal drag from other pane
if (!draggedFiles || draggedFiles[0]?.side === side) return;
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
@@ -814,18 +847,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
</>
) : (
<>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
</>
)}
</ContextMenuItem>
{/* File operations - only for files, not directories */}
{!isNavigableDirectory(entry) && onOpenFile && (
<ContextMenuItem onClick={() => onOpenFile(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
@@ -838,6 +864,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onDownloadFile && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
@@ -901,10 +933,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowOpen,
handleRowSelect,
onCopyToOtherPane,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onOpenFile,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
@@ -1480,8 +1512,8 @@ interface SftpViewProps {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const { sftpDoubleClickBehavior, sftpAutoSync } = useSettingsState();
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -1494,7 +1526,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
logger.error("[SFTP] File auto-sync failed", payload);
},
}), [t]);
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
// Store sftp in a ref so callbacks can access the latest instance
@@ -1505,7 +1537,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
// Store behavior setting in ref for stable callbacks
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
// Store auto-sync setting in ref for stable callbacks
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
@@ -1882,6 +1914,100 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
[handleOpenFileWithForSide],
);
// Handle external file upload from OS drag-and-drop (shared logic)
// Uses sftpRef.current internally, so dependencies are stable.
// toast and logger are globally stable, t is the only real dependency.
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", files: FileList) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, files);
const failCount = results.filter(r => !r.success).length;
if (failCount === 0) {
// All files uploaded successfully
const successCount = results.length;
const message = successCount === 1
? `${t('sftp.upload')}: ${results[0].fileName}`
: `${t('sftp.uploadFiles')}: ${successCount}`;
toast.success(message, "SFTP");
} else {
// Some or all files failed
const failedFiles = results.filter(r => !r.success);
failedFiles.forEach(failed => {
const errorMsg = failed.error ? ` - ${failed.error}` : '';
toast.error(
`${t('sftp.error.uploadFailed')}: ${failed.fileName}${errorMsg}`,
"SFTP"
);
});
}
} catch (error) {
logger.error("[SftpView] Failed to upload external files:", error);
toast.error(
error instanceof Error ? error.message : t('sftp.error.uploadFailed'),
"SFTP"
);
}
},
[t],
);
const handleUploadExternalFilesLeft = useCallback(
(files: FileList) => handleUploadExternalFilesForSide("left", files),
[handleUploadExternalFilesForSide],
);
const handleUploadExternalFilesRight = useCallback(
(files: FileList) => handleUploadExternalFilesForSide("right", files),
[handleUploadExternalFilesForSide],
);
// Download file to local filesystem (browser download)
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
// Read the file as binary
const content = await sftpRef.current.readBinaryFile(side, fullPath);
// Create blob and trigger browser download
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${t('sftp.context.download')}: ${file.name}`, "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
toast.error(
e instanceof Error ? e.message : t('sftp.error.downloadFailed'),
"SFTP"
);
}
},
[t],
);
const handleDownloadFileLeft = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
[handleDownloadFileForSide],
);
const handleDownloadFileRight = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
[handleDownloadFileForSide],
);
// Custom handleOpenEntry callbacks that check the double-click behavior setting
const handleOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => {
@@ -1959,6 +2085,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileLeft,
onOpenFile: handleOpenFileLeft,
onOpenFileWith: handleOpenFileWithLeft,
onDownloadFile: handleDownloadFileLeft,
onUploadExternalFiles: handleUploadExternalFilesLeft,
}),
[],
);
@@ -1984,6 +2112,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
onEditFile: handleEditFileRight,
onOpenFile: handleOpenFileRight,
onOpenFileWith: handleOpenFileWithRight,
onDownloadFile: handleDownloadFileRight,
onUploadExternalFiles: handleUploadExternalFilesRight,
}),
[],
);
@@ -2124,6 +2254,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
showHiddenFiles={sftpShowHiddenFiles}
>
<div
className={cn(

View File

@@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -181,6 +182,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
@@ -732,6 +734,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (showSFTP) {
setShowSFTP(false);
return;
}
// Try to get the current working directory from the terminal session
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail and open SFTP without initial path
}
}
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
};
const handleCancelConnect = () => {
setIsCancelling(true);
auth.setNeedsAuth(false);
@@ -810,7 +840,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={() => setShowSFTP((v) => !v)}
onOpenSFTP={handleOpenSFTP}
onSnippetClick={handleSnippetClick}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
@@ -1053,6 +1083,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})()}
open={showSFTP && status === "connected"}
onClose={() => setShowSFTP(false)}
initialPath={sftpInitialPath}
/>
</div>
</TerminalContextMenu>

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 } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -173,6 +173,46 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Show hidden files section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.showHiddenFiles')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.desc')}
</p>
<button
onClick={() => setSftpShowHiddenFiles(!sftpShowHiddenFiles)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpShowHiddenFiles
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpShowHiddenFiles
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpShowHiddenFiles && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.showHiddenFiles.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.showHiddenFiles.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -31,6 +31,9 @@ export interface SftpPaneCallbacks {
onEditFile?: (entry: SftpFileEntry) => void;
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
// External file upload
onUploadExternalFiles?: (files: FileList) => Promise<void>;
}
export interface SftpDragCallbacks {
@@ -91,6 +94,9 @@ export interface SftpContextValue {
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
// Settings
showHiddenFiles: boolean;
}
const SftpContext = createContext<SftpContextValue | null>(null);
@@ -124,12 +130,19 @@ export const useSftpHosts = () => {
return context.hosts;
};
// Hook to get showHiddenFiles setting
export const useSftpShowHiddenFiles = (): boolean => {
const context = useSftpContext();
return context.showHiddenFiles;
};
interface SftpContextProviderProps {
hosts: Host[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
showHiddenFiles: boolean;
children: React.ReactNode;
}
@@ -139,6 +152,7 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
@@ -150,8 +164,9 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
}),
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;

View File

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

View File

@@ -187,3 +187,33 @@ export interface ColumnWidths {
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === 'directory' || (entry.type === 'symlink' && entry.linkTarget === 'directory');
};
/**
* Check if a file is hidden on Windows
* Only applies to local Windows filesystem where the hidden attribute is set
* The ".." parent directory entry is never considered hidden
*
* Note: On Unix/Linux, there's no system-level hidden file concept.
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
*/
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
if (file.name === "..") return false;
return file.hidden === true;
};
/**
* Filter files based on Windows hidden file visibility setting
* Only filters files with the Windows hidden attribute set
* Always preserves ".." parent directory entry
*
* This setting only affects local Windows filesystem browsing.
* On Unix/Linux systems and remote SFTP connections, all files are shown
* because there's no system-level hidden file concept (dotfiles are just a convention).
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isWindowsHiddenFile(f));
};

View File

@@ -473,6 +473,7 @@ export interface RemoteFile {
lastModified: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export type WorkspaceNode =
@@ -512,6 +513,7 @@ export interface SftpFileEntry {
owner?: string;
group?: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export interface SftpConnection {

View File

@@ -42,6 +42,27 @@
"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": [
{

View File

@@ -6,14 +6,35 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { execSync } = require("node:child_process");
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
*/
function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
try {
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
return false;
}
}
/**
* List files in a local directory
* Properly handles symlinks by resolving their target type
* On Windows, also detects hidden files using the hidden attribute
*/
async function listLocalDir(event, payload) {
const dirPath = payload.path;
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const isWindows = process.platform === "win32";
// Stat entries in parallel with a small concurrency limit.
// Serial stats can be very slow on Windows for large dirs.
@@ -45,12 +66,16 @@ async function listLocalDir(event, payload) {
type = "file";
}
// Check for Windows hidden attribute
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
type,
linkTarget,
size: `${stat.size} bytes`,
lastModified: stat.mtime.toISOString(),
hidden,
};
} catch (err) {
// Handle broken symlinks - lstat doesn't follow symlinks
@@ -61,12 +86,14 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",
linkTarget: null, // Broken link - target unknown
size: `${lstat.size} bytes`,
lastModified: lstat.mtime.toISOString(),
hidden,
};
return;
}

View File

@@ -466,11 +466,23 @@ async function listSftp(event, payload) {
async function readSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
return buffer.toString();
}
/**
* Read file as binary (returns ArrayBuffer for binary files like images)
*/
async function readSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
const buffer = await client.get(payload.path);
// Convert Node.js Buffer to ArrayBuffer
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}
/**
* Write file content
*/
@@ -649,6 +661,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:open", openSftp);
ipcMain.handle("netcatty:sftp:list", listSftp);
ipcMain.handle("netcatty:sftp:read", readSftp);
ipcMain.handle("netcatty:sftp:readBinary", readSftpBinary);
ipcMain.handle("netcatty:sftp:write", writeSftp);
ipcMain.handle("netcatty:sftp:writeBinaryWithProgress", writeSftpBinaryWithProgress);
ipcMain.handle("netcatty:sftp:close", closeSftp);
@@ -673,6 +686,7 @@ module.exports = {
openSftp,
listSftp,
readSftp,
readSftpBinary,
writeSftp,
writeSftpBinaryWithProgress,
closeSftp,

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,50 +800,74 @@ async function startSSHSessionWrapper(event, options) {
/**
* Get current working directory from an active SSH session
* This sends 'pwd' to the shell and captures the output
* This sends 'pwd' to the existing shell stream and captures the output
* using unique markers to identify the command output boundaries
*/
async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const session = sessions.get(sessionId);
if (!session || !session.stream || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
return new Promise((resolve) => {
const conn = session.conn;
const stream = session.stream;
const marker = `__PWD_${Date.now()}__`;
const timeout = setTimeout(() => {
stream.removeListener('data', onData);
resolve({ success: false, error: 'Timeout getting pwd' });
}, 3000);
// Use exec on the existing connection to run pwd
conn.exec('pwd', (err, stream) => {
if (err) {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
return;
}
let stdout = '';
stream.on('data', (data) => {
stdout += data.toString();
});
stream.on('close', () => {
clearTimeout(timeout);
const cwd = stdout.trim().split(/\r?\n/).pop()?.trim();
if (cwd && cwd.startsWith('/')) {
resolve({ success: true, cwd });
} else {
resolve({ success: false, error: 'Invalid pwd output' });
let buffer = '';
const onData = (data) => {
const str = data.toString();
buffer += str;
// We need to find the ACTUAL output markers, not the command echo
// The command echo looks like: echo '__PWD_xxx__S' && pwd && echo '__PWD_xxx__E'
// The actual output looks like: __PWD_xxx__S\n/path/to/dir\n__PWD_xxx__E
//
// We look for the marker at the START of a line (after newline) to avoid the echo
const startMarkerRegex = new RegExp(`(?:^|[\\r\\n])${marker}S[\\r\\n]+`);
const endMarkerRegex = new RegExp(`[\\r\\n]${marker}E(?:[\\r\\n]|$)`);
const startMatch = buffer.match(startMarkerRegex);
const endMatch = buffer.match(endMarkerRegex);
if (startMatch && endMatch) {
const startIdx = buffer.indexOf(startMatch[0]) + startMatch[0].length;
const endIdx = buffer.indexOf(endMatch[0]);
if (startIdx <= endIdx) {
clearTimeout(timeout);
stream.removeListener('data', onData);
const pwdOutput = buffer.slice(startIdx, endIdx).trim();
console.log('[getSessionPwd] pwdOutput:', JSON.stringify(pwdOutput));
// The pwd output should be a valid absolute path
if (pwdOutput && pwdOutput.startsWith('/')) {
console.log('[getSessionPwd] Success, cwd:', pwdOutput);
resolve({ success: true, cwd: pwdOutput });
} else {
console.log('[getSessionPwd] Failed - invalid path:', pwdOutput);
resolve({ success: false, error: 'Invalid pwd output' });
}
}
});
stream.on('error', (err) => {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
});
});
}
};
stream.on('data', onData);
// Send pwd command with short unique markers
// Using 'S' and 'E' as suffixes to make markers shorter
// After the command, send ANSI escape sequences to clear the output lines:
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
});
}

View File

@@ -295,6 +295,9 @@ const api = {
readSftp: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:read", { sftpId, path });
},
readSftpBinary: async (sftpId, path) => {
return ipcRenderer.invoke("netcatty:sftp:readBinary", { sftpId, path });
},
writeSftp: async (sftpId, path, content) => {
return ipcRenderer.invoke("netcatty:sftp:write", { sftpId, path, content });
},

View File

@@ -41,6 +41,7 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
// SFTP Settings
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

BIN
public/dmg-background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

BIN
public/dmg-fix-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 KiB

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Fix Quarantine</string>
<key>CFBundleExecutable</key>
<string>FixQuarantine</string>
<key>CFBundleIdentifier</key>
<string>com.netcatty.fixquarantine</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Fix Quarantine</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>CFBundleIconFile</key>
<string>FixQuarantine.icns</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
</dict>
</plist>

View File

@@ -0,0 +1,17 @@
#!/bin/bash
set -e
APP_PATH="/Applications/Netcatty.app"
if [ ! -d "$APP_PATH" ]; then
/usr/bin/osascript <<'EOF'
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
EOF
exit 1
fi
/usr/bin/osascript <<'EOF'
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
EOF
open "$APP_PATH"

10
scripts/gen_icns.sh Normal file
View File

@@ -0,0 +1,10 @@
# 1) 准备一张 1024x1024 PNG例如放在 public/dmg-fix-icon.png
# 2) 生成 iconset 并转 icns
ICONSET="scripts/fixquarantine.iconset"
mkdir -p "$ICONSET"
for size in 16 32 128 256 512; do
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
rm -rf $ICONSET