Compare commits

..

7 Commits

Author SHA1 Message Date
bincxz
0826bbb435 style: use Netcatty logo in OAuth callback page
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Replace the generic terminal SVG icon with the actual Netcatty brand
logo (blue rounded-rect with terminal + cat tail motif).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:49:25 +08:00
bincxz
ec87eb593e fix: show spinner and connecting text during cloud sync connection
Replace yellow pulsing dot with a spinning Loader2 icon when cloud
provider is in connecting state. Also show "Connecting..." text
instead of "Not connected" during the connection attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:44:03 +08:00
bincxz
ecbd50dde4 fix: use accent color for active tab indicator instead of foreground
The top indicator line on active tabs (sessions, logview, vaults, SFTP)
was hardcoded to foreground color (white), making it always white
regardless of the system accent color setting. Changed all 4 tab
indicator lines to use --top-tabs-accent / --accent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:31:20 +08:00
bincxz
4dd7640452 fix: allow auto encoding through same-host fast path
The encoding guard was rejecting "auto" which is the default encoding
for nearly all connections, making same-host optimization never trigger.

Frontend now allows "auto" through. Backend resolves "auto" to the
actual session encoding via resolveEncodingForRequest and only proceeds
with exec cp when the resolved encoding is UTF-8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:25:36 +08:00
陈大猫
0b08521e63 perf: optimize same-host SFTP transfer with remote cp command (#564)
* perf: optimize same-host SFTP transfer with remote cp command

When both panels are connected to the same remote host, use SSH exec
`cp -a` instead of downloading to local temp then re-uploading. This
eliminates 2x bandwidth usage and reduces latency for same-host transfers.

Closes #561

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: optimize same-host directory transfer with single cp -ra command

For same-host directory transfers, use a single `cp -ra` command via SSH
exec instead of recursively walking the directory and copying files one
by one. This makes directory copies nearly instant on the remote server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use endpoint cache key for same-host detection and guard non-UTF-8 paths

Address two code review issues:

1. Compare per-connection cache keys (hostname+port+protocol+sudo+username)
   instead of just hostId for same-host detection. This prevents false
   positives when the same hostId has different session-time overrides.

2. Restrict exec-based cp paths to UTF-8 compatible encodings only.
   Non-UTF-8 encodings (e.g. gb18030) need encodePathForSession which
   shell exec cannot use — fall back to download+upload for those cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: directory cp semantics, cancellation, and auto encoding guard

1. Use `cp -ra source/. target/` instead of `cp -ra source target` to
   copy directory contents into target, preserving merge semantics when
   the target directory already exists (avoids extra nesting level).

2. Check cancellation state before and after sameHostCopyDirectory call
   so cancelled transfers don't finalize as completed.

3. Exclude 'auto' from exec-safe encodings since auto can resolve to
   non-UTF-8 (e.g. gb18030) at the session level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: wire cancellation into same-host copy paths

1. Single file cp -a: check transfer.cancelled before and after
   execSshCommand so cancelled transfers don't proceed as success.

2. Directory cp -ra: accept transferId, register in activeTransfers
   so cancelTransfer can flag it, and check cancelled state at each
   async boundary. Cleanup via finally block.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: abort remote cp process on transfer cancellation

Add execSshCommandCancellable() that wires the SSH exec stream into
transfer.abort, so cancelTransfer can close the stream and kill the
remote cp process immediately instead of waiting for it to finish.

Used in both single-file (cp -a) and directory (cp -ra) same-host paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: close exec stream immediately if cancelled before callback fires

Check transfer.cancelled at the start of the exec callback and close
the stream right away, preventing the remote cp from running when
cancellation happened between the exec() call and callback delivery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fallback to download+upload when remote cp is unavailable

On non-POSIX remotes (e.g. Windows SSH servers) where cp is absent,
same-host optimization now gracefully falls back to the existing
download+upload transfer path instead of failing the transfer.

- Single file: try cp -a first, fall back to temp file on non-zero exit
- Directory: sameHostCopyDirectory returns { success: false } instead of
  throwing, frontend falls back to recursive transferDirectory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: cache cp unavailability to avoid repeated exec failures

Track sftpIds where remote cp failed in cpUnavailableSet so subsequent
file transfers in the same session skip the exec attempt and go directly
to download+upload, avoiding per-file exec round-trip overhead on
non-POSIX remotes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip transferFile for directories already handled by same-host copy

Add !task.isDirectory guard to the else branch so successful
sameHostCopyDirectory doesn't also trigger a redundant transferFile
call that would duplicate data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dereference symlinks in same-host copy to match SFTP behavior

Use cp -aL instead of cp -a so symlinks are dereferenced (copied as
file contents), matching the existing SFTP download+upload flow which
always transfers resolved file data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert: remove -L flag from same-host cp to avoid recursing symlinked dirs

Revert cp -aL back to cp -a. The -L flag dereferences all symlinks
including symlinked directories, which can unexpectedly recurse into
large unrelated directory trees. Using cp -a preserves symlinks as-is,
which is safer and consistent with how the transfer UI treats symlink
directories as non-recursive entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: refine cp unavailability caching and remove dead import

1. Only cache sftpId in cpUnavailableSet on exit code 127 (command not
   found). Other failures (permission denied, disk full) are transient
   or path-specific and should not disable cp for the entire session.

2. Check cpUnavailableSet at the top of sameHostCopyDirectory to skip
   exec attempt on known non-POSIX remotes. Also cache 127 exits from
   directory copies.

3. Remove unused execSshCommand import from transferBridge (replaced by
   local execSshCommandCancellable) and revert its export from sftpBridge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:21:58 +08:00
陈大猫
59e768c447 fix: prevent key file path from overflowing panel (#551) (#567)
* fix: prevent key file path from overflowing host details panel

Add min-w-0 to flex containers and flex items displaying key file
paths. Without this, flex items default to min-width: auto which
prevents truncate from working and causes long file paths (e.g.
from the file picker) to blow out the panel width.

Closes #551

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add overflow-hidden to AsidePanel to prevent content overflow

The root cause of key file paths overflowing the panel was the
AsidePanel container itself lacking overflow-hidden. Even though
inner elements had min-w-0 and truncate, the absolute-positioned
panel div allowed content to visually escape its bounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add overflow-hidden to credentials Card and key path row

Ensure truncation works by adding overflow-hidden at multiple
levels: the Port & Credentials Card container and each key file
path flex row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use w-0 flex-1 to force key file path truncation

min-w-0 alone is insufficient in nested flex layouts. Setting w-0
with flex-1 forces the element to start at zero width and only grow
to fill available space, guaranteeing truncation works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:17:04 +08:00
陈大猫
6a37b8bbc6 fix: use system browser for OAuth flows (#563) (#565) 2026-03-29 12:43:21 +08:00
12 changed files with 309 additions and 86 deletions

View File

@@ -22,6 +22,7 @@ interface UseSftpTransfersParams {
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
clearCacheForConnection: (connectionId: string) => void;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
@@ -78,6 +79,7 @@ export const useSftpTransfers = ({
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -209,6 +211,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
@@ -228,6 +231,7 @@ export const useSftpTransfers = ({
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
sameHost: sameHost || undefined,
};
let lastProgressUpdate = 0;
@@ -343,6 +347,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
symlinkDepth = 0,
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
) => {
@@ -433,6 +438,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
isSymlink ? symlinkDepth + 1 : symlinkDepth,
followSymlinks,
);
@@ -496,6 +502,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
);
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
@@ -571,6 +578,22 @@ export const useSftpTransfers = ({
? null
: sftpSessionsRef.current.get(targetPane.connection!.id);
// Detect same-host: both sides connected to the same remote endpoint.
// Use per-connection cache keys (hostname+port+protocol+sudo+username) instead of
// just hostId, because the same hostId can have different session-time overrides.
const sourceCacheKey = sourcePane.connection?.id
? connectionCacheKeyMapRef.current.get(sourcePane.connection.id)
: undefined;
const targetCacheKey = targetPane.connection?.id
? connectionCacheKeyMapRef.current.get(targetPane.connection.id)
: undefined;
const sameHost = !!(
sourceSftpId && targetSftpId &&
!sourcePane.connection?.isLocal && !targetPane.connection?.isLocal &&
sourceCacheKey && targetCacheKey &&
sourceCacheKey === targetCacheKey
);
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
const sourceSide = targetSide === "left" ? "right" : "left";
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
@@ -718,7 +741,34 @@ export const useSftpTransfers = ({
let dirPartialFailure = false;
if (task.isDirectory) {
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
// "auto" is allowed here — the backend resolves it to the actual encoding
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
const encodingSafeForExec =
(!sourceEncoding || sourceEncoding === "utf-8" || sourceEncoding === "auto") &&
(!targetEncoding || targetEncoding === "utf-8" || targetEncoding === "auto");
// Try same-host directory optimization first; falls back to recursive transfer
// if remote cp is unavailable (e.g. Windows SSH servers).
let dirHandledBySameHost = false;
if (task.isDirectory && sameHost && encodingSafeForExec && sourceSftpId) {
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
const result = await netcattyBridge.require().sameHostCopyDirectory!(
sourceSftpId,
task.sourcePath,
task.targetPath,
sourceEncoding,
task.id,
);
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
dirHandledBySameHost = result.success;
}
if (task.isDirectory && !dirHandledBySameHost) {
// For directory transfers, parent task uses:
// totalBytes = total file count (discovered async)
// transferredBytes = completed file count (incremented by child completions)
@@ -746,12 +796,13 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
);
if (dirErrors > 0) {
dirPartialFailure = true;
}
} else {
} else if (!task.isDirectory) {
await transferFile(
task,
sourceSftpId,
@@ -761,6 +812,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
);
}
@@ -1247,6 +1299,7 @@ export const useSftpTransfers = ({
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);

View File

@@ -81,6 +81,7 @@ export interface CloudSyncHook {
code: string,
redirectUri: string
) => Promise<void>;
cancelOAuthConnect: () => void;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
@@ -265,34 +266,30 @@ export const useCloudSync = (): CloudSyncHook => {
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Use system browser to avoid white-screen issues in popup windows (#563)
// Race: if browser launch fails, surface the error immediately
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -314,34 +311,29 @@ export const useCloudSync = (): CloudSyncHook => {
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Use system browser to avoid white-screen issues in popup windows (#563)
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -372,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.connectConfigProvider('s3', config);
}, []);
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
}, []);
// ========== Settings ==========
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
@@ -469,6 +466,7 @@ export const useCloudSync = (): CloudSyncHook => {
connectWebDAV,
connectS3,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
resetProviderStatus,

View File

@@ -289,6 +289,7 @@ export const useSftpState = (
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,

View File

@@ -102,11 +102,14 @@ interface StatusDotProps {
}
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
if (status === 'connecting') {
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
}
const colors = {
connected: 'bg-green-500',
syncing: 'bg-blue-500 animate-pulse',
error: 'bg-red-500',
connecting: 'bg-yellow-500 animate-pulse',
disconnected: 'bg-muted-foreground/50',
};
@@ -279,6 +282,7 @@ interface ProviderCardProps {
disabled?: boolean; // Disable connect button when another provider is connected
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
}
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
disabled,
onEdit,
onConnect,
onCancelConnect,
onDisconnect,
onSync,
}) => {
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
{error}
</p>
) : (
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
</p>
)}
</div>
@@ -408,6 +415,16 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
<CloudOff size={14} />
</Button>
</>
) : isConnecting && onCancelConnect ? (
<Button
size="sm"
variant="outline"
onClick={onCancelConnect}
className="gap-1"
>
<X size={14} />
{t('common.cancel')}
</Button>
) : (
<Button
size="sm"
@@ -1088,6 +1105,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
/>
@@ -1104,6 +1122,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
/>

View File

@@ -739,7 +739,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
@@ -984,9 +984,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Button
@@ -1179,10 +1179,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 min-w-0">
<input
type="text"
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
value={newKeyFilePath}
onChange={(e) => setNewKeyFilePath(e.target.value)}

View File

@@ -826,7 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP

View File

@@ -218,7 +218,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>
@@ -253,7 +253,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
return (
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>

View File

@@ -105,7 +105,6 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
.logo {
width: 36px;
height: 36px;
color: hsl(var(--accent));
}
.brand {
font-size: 16px;
@@ -162,14 +161,17 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
<body>
<div class="shell">
<div class="header">
<svg class="logo" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.12" />
<path
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
stroke="currentColor" stroke-width="2" />
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<svg class="logo" viewBox="0 0 56 56" aria-hidden="true">
<rect x="0" y="0" width="56" height="56" rx="12" fill="#2F7BFF"/>
<rect x="10" y="13" width="36" height="24" rx="4" fill="#FFFFFF" stroke="#1D4FCF" stroke-opacity="0.12"/>
<rect x="10" y="13" width="36" height="5" rx="4" fill="#E6EEFF"/>
<circle cx="14" cy="15.5" r="1" fill="#1E4FD1"/>
<circle cx="18" cy="15.5" r="1" fill="#1E4FD1" opacity="0.7"/>
<circle cx="22" cy="15.5" r="1" fill="#1E4FD1" opacity="0.5"/>
<path d="M16 28 L20 26 L16 24" stroke="#1E4FD1" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 30 H30" stroke="#1E4FD1" stroke-width="1.6" stroke-linecap="round"/>
<path d="M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47" stroke="white" fill="none" stroke-width="3.2" stroke-linecap="round"/>
<rect x="34" y="44" width="6" height="5" rx="1" fill="white" stroke="#1E4FD1"/>
</svg>
<div>
<div class="brand">Netcatty</div>
@@ -279,9 +281,8 @@ function startOAuthCallback(expectedState) {
res.end(
renderOAuthPage({
title: "Authorization Complete",
message: "You are signed in and ready to sync.",
message: "You are signed in and ready to sync. You can close this tab now.",
status: "success",
autoClose: true,
})
);

View File

@@ -1858,4 +1858,5 @@ module.exports = {
renameSftp,
statSftp,
chmodSftp,
resolveEncodingForRequest,
};

View File

@@ -6,7 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel, resolveEncodingForRequest } = require("./sftpBridge.cjs");
/**
* Safely ensure a local directory exists.
@@ -50,6 +50,9 @@ let sftpClients = null;
// Active transfers storage
const activeTransfers = new Map();
const isolatedDownloadChannelPools = new WeakMap();
// Cache sftpIds where remote cp is known to be unavailable, so we skip
// repeated failed exec attempts for each file in a multi-file transfer.
const cpUnavailableSet = new Set();
/**
* Initialize the transfer bridge with dependencies
@@ -58,6 +61,46 @@ function init(deps) {
sftpClients = deps.sftpClients;
}
/**
* Execute an SSH command with cancellation support.
* Registers an abort hook on the transfer object that closes the exec stream,
* which sends SIGHUP to the remote process.
*/
function execSshCommandCancellable(sshClient, command, transfer) {
return new Promise((resolve, reject) => {
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
sshClient.exec(command, (err, stream) => {
if (err) return reject(err);
// If cancelled between exec() call and callback, kill immediately
if (transfer.cancelled) {
try { stream.close(); } catch { }
return reject(new Error('Transfer cancelled'));
}
let stdout = '';
let stderr = '';
// Wire abort: closing the stream kills the remote process
const prevAbort = transfer.abort;
transfer.abort = () => {
try { stream.close(); } catch { }
if (typeof prevAbort === 'function') prevAbort();
};
stream.on('close', (code) => {
transfer.abort = prevAbort; // restore
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
resolve({ stdout, stderr, code });
});
stream.on('data', (data) => { stdout += data.toString(); });
stream.stderr.on('data', (data) => { stderr += data.toString(); });
});
});
}
async function openIsolatedSftpChannel(client) {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") return null;
@@ -475,6 +518,7 @@ async function startTransfer(event, payload, onProgress) {
totalBytes,
sourceEncoding,
targetEncoding,
sameHost,
} = payload;
const sender = event.sender;
@@ -674,34 +718,73 @@ async function startTransfer(event, payload, onProgress) {
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
// Try same-host optimization first: remote cp via SSH exec.
// Falls back to download+upload if cp is unavailable (e.g. Windows SSH servers).
let sameHostDone = false;
const resolvedSourceEnc = sourceSftpId ? resolveEncodingForRequest(sourceSftpId, sourceEncoding) : sourceEncoding;
const resolvedTargetEnc = targetSftpId ? resolveEncodingForRequest(targetSftpId, targetEncoding) : targetEncoding;
if (sameHost
&& (!resolvedSourceEnc || resolvedSourceEnc === 'utf-8')
&& (!resolvedTargetEnc || resolvedTargetEnc === 'utf-8')
&& !cpUnavailableSet.has(sourceSftpId)) {
const srcClient = sftpClients.get(sourceSftpId);
const sshClient = srcClient?.client;
if (sshClient && typeof sshClient.exec === 'function') {
try {
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sourceSftpId, dir, targetEncoding || sourceEncoding); } catch { }
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 0) {
sendProgress(fileSize, fileSize);
sameHostDone = true;
} else if (result.code === 127) {
// Exit 127 = command not found — cache to skip future attempts
cpUnavailableSet.add(sourceSftpId);
}
// Other non-zero exits (permission denied, disk full, etc.)
// fall through to download+upload without caching
} catch (cpErr) {
// If cancelled, re-throw; otherwise fall back to download+upload
if (transfer.cancelled) throw cpErr;
}
}
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
if (!sameHostDone) {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
try { await fs.promises.unlink(tempPath); } catch { }
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
try { await fs.promises.unlink(tempPath); } catch { }
}
} else {
throw new Error("Invalid transfer configuration");
@@ -749,12 +832,73 @@ async function cancelTransfer(event, payload) {
return { success: true };
}
/**
* Same-host directory copy: uses a single `cp -ra` command on the remote server
* instead of recursively transferring files one by one.
*/
async function sameHostCopyDirectory(event, payload) {
const { sftpId, sourcePath, targetPath, encoding, transferId } = payload;
// Register in activeTransfers so cancelTransfer can flag it
const transfer = { cancelled: false };
if (transferId) {
activeTransfers.set(transferId, transfer);
}
try {
if (cpUnavailableSet.has(sftpId)) return { success: false };
const client = sftpClients.get(sftpId);
if (!client) return { success: false };
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== 'function') {
return { success: false };
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
// Ensure target directory itself exists (not just its parent),
// so cp copies contents into it rather than creating a nested subdirectory.
const targetDir = targetPath.replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sftpId, targetDir, encoding); } catch { }
// Use "source/." to copy directory *contents* into target, preserving merge
// semantics consistent with the recursive per-file transfer path.
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -ra '${escapedSource}/.' '${escapedTarget}/'`;
try {
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 127) {
cpUnavailableSet.add(sftpId);
return { success: false };
}
if (result.code !== 0) {
return { success: false };
}
} catch (cpErr) {
if (transfer.cancelled) throw cpErr;
return { success: false };
}
return { success: true };
} finally {
if (transferId) {
activeTransfers.delete(transferId);
}
}
}
/**
* Register IPC handlers for transfer operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:transfer:start", startTransfer);
ipcMain.handle("netcatty:transfer:cancel", cancelTransfer);
ipcMain.handle("netcatty:transfer:same-host-copy-dir", sameHostCopyDirectory);
}
module.exports = {
@@ -762,4 +906,5 @@ module.exports = {
registerHandlers,
startTransfer,
cancelTransfer,
sameHostCopyDirectory,
};

View File

@@ -741,6 +741,9 @@ const api = {
cleanupTransferListeners(transferId);
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
},
sameHostCopyDirectory: async (sftpId, sourcePath, targetPath, encoding, transferId) => {
return ipcRenderer.invoke("netcatty:transfer:same-host-copy-dir", { sftpId, sourcePath, targetPath, encoding, transferId });
},
// Compressed folder upload
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
const { compressionId } = options;

2
global.d.ts vendored
View File

@@ -346,6 +346,7 @@ declare global {
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
sameHostCopyDirectory?(sftpId: string, sourcePath: string, targetPath: string, encoding?: SftpFilenameEncoding, transferId?: string): Promise<{ success: boolean }>;
// Compressed folder upload
startCompressedUpload?(
@@ -383,6 +384,7 @@ declare global {
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
sameHost?: boolean;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,