Compare commits

...

34 Commits

Author SHA1 Message Date
陈大猫
4373a8ce14 Merge pull request #339 from binaricat/feat/toolbar-tooltips
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
ui: add styled tooltips to terminal and SFTP toolbar buttons
2026-03-14 01:51:15 +08:00
bincxz
007fe47310 ui: add styled tooltips to terminal and SFTP toolbar buttons
Replace native title attributes with Radix UI Tooltip components for
a consistent, styled tooltip experience across both toolbars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:47:46 +08:00
陈大猫
9109fc2f6e Merge pull request #338 from binaricat/feat/default-dark-theme
feat: default theme to dark for new users
2026-03-14 01:42:30 +08:00
bincxz
961f79d3d8 feat: change default theme from system to dark for new users
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:41:54 +08:00
陈大猫
494fc27454 Merge pull request #337 from binaricat/fix/memory-leaks-and-cpu-optimization
fix: resolve memory leaks and reduce unnecessary CPU consumption
2026-03-14 01:38:36 +08:00
bincxz
a85324c9fb fix: resolve memory leaks and reduce unnecessary CPU consumption
- Fix onSelectionChange listener leak in Terminal.tsx (missing dispose on cleanup)
- Debounce window resize handler in TopTabs.tsx to prevent IPC storm
- Use .once() for SSH/SFTP/PortForward connection lifecycle events (ready/error/timeout/close)
  to prevent listener accumulation across sessions
- Clean up sessionEncodings/sessionDecoders maps in all error paths in sshBridge
- Use .once() for execCommand() connection events (creates new conn per call)
- Remove duplicate requestAnimationFrame in useSftpPaneVirtualList
- Capture and dispose OSC 7 parser handler in createXTermRuntime

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:36:12 +08:00
陈大猫
860739bb97 Merge pull request #336 from binaricat/feat/side-panel-position-toggle
feat: add toggle to move side panel between left and right
2026-03-14 01:14:39 +08:00
bincxz
a6494bfb78 feat: add toggle button to move side panel between left and right
Add a position toggle button next to the close button in the side panel
header. The position preference is persisted in localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:11:16 +08:00
bincxz
1fa11c2c2d ui: show spinning icon during terminal connection progress
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:51:47 +08:00
陈大猫
35b8990a9c Merge pull request #335 from binaricat/fix/terminal-block-char-gaps
fix: enable customGlyphs to eliminate gaps between block characters
2026-03-14 00:39:47 +08:00
bincxz
67536c9424 fix: enable customGlyphs to eliminate gaps between block characters
Enable xterm.js customGlyphs option so box-drawing and block characters
are rendered by canvas instead of font glyphs, eliminating visible gaps.

Closes #331

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:39:29 +08:00
陈大猫
4dbbb96e4d Merge pull request #334 from binaricat/fix/webdav-self-signed-cert
feat: allow ignoring certificate errors for WebDAV connections
2026-03-14 00:35:19 +08:00
bincxz
5cb8b348b3 fix: handle allowInsecure in WebDAVAdapter fallback path
- Add httpsAgent with rejectUnauthorized:false in WebDAVAdapter.createClient()
  so the fallback (non-bridge) path also respects the allowInsecure option
- Use explicit ternary for allowInsecure config serialization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:32:54 +08:00
bincxz
06efcfe384 feat: add option to ignore certificate errors for WebDAV connections
Allow users to bypass TLS certificate verification for WebDAV endpoints
using self-signed certificates, which is common for LAN NAS devices
(Synology, FNAS, Unraid, etc.).

Closes #332

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:28:23 +08:00
陈大猫
4877c934fa feat: move Scripts and Theme to side panel sub-tabs (#333)
* feat: move Scripts and Theme from toolbar popups to side panel sub-tabs

Migrate Scripts (snippet library) and Theme customization from toolbar
popover/modal dialogs into the left side panel alongside SFTP. The panel
header now shows three tab buttons (SFTP / Scripts / Theme) so users can
switch between sub-panels without losing SFTP connections.

- Add ScriptsSidePanel with package hierarchy, breadcrumb nav and search
- Add ThemeSidePanel adapted from ThemeCustomizeModal (no preview pane)
- Generalize TerminalLayer state from sftpOpenTabs to sidePanelOpenTabs
- Simplify TerminalToolbar by removing inline popover and modal rendering
- Clicking the already-active tab button is a no-op; only X closes panel
- Theme/font changes apply in real-time to the actual terminal behind

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review findings for side panel migration

- Clean up sftpInitialLocationForTab on panel close
- Remove unused handleCloseSidePanel from deps array
- Re-focus terminal after snippet execution from side panel
- Use props directly in ThemeSidePanel instead of mirrored local state
- Use ?? instead of || for falsy-safe theme/font/size defaults
- Extract isFocusedHostLocal into memoized value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:21:34 +08:00
陈大猫
c542520dee feat: SFTP sidebar polish - workspace caching, toolbar overflow, terminal cwd navigation
## Summary
- Add SFTP side panel with workspace-level connection caching for instant switching between terminal endpoints
- Responsive toolbar with overflow menu that collapses action buttons when panel is narrow, prioritizing breadcrumb path display
- Silent terminal CWD detection via separate SSH exec channel (no visible commands in terminal)
- Extract SftpTransferQueue as reusable component with i18n support
- Remove passphrase from port forwarding credentials (decrypted at load time)
- Add compressed upload support to uploadEntriesDirect
- Fix various eslint warnings and code quality issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:39:56 +08:00
陈大猫
0b61d10953 Merge pull request #329 from MiracleLau/fix-portforward-no-passphrase-given
fix: no passphrase given error on port forwarding launch
2026-03-13 18:45:02 +08:00
MiracleLau
347361bc7b fix: complete incomplete parameters for startTunnel 2026-03-13 17:02:15 +08:00
MiracleLau
746c336ee1 fix: no passphrase given error on port forwarding launch 2026-03-13 16:43:22 +08:00
陈大猫
6373762399 Merge pull request #327 from binaricat/feat/tab-redesign-os-icons
feat: redesign tab bar with OS/distro icons
2026-03-13 15:08:47 +08:00
陈大猫
27b8d4a410 Merge pull request #328 from yuzifu/fix-host-group
fix: show all nodes in the Group field of host details.
2026-03-13 15:07:06 +08:00
yuzifu
27773c58db fix: show all nodes in the Group field of host details. 2026-03-13 14:59:06 +08:00
bincxz
ecb48e89a5 feat: redesign tab bar to Windows Terminal style with OS/distro icons
- Redesign tabs from rounded rectangle + accent border to flat-bottom
  Windows Terminal style with top accent line indicator
- Show OS/distro icons with brand background colors in session tabs
- Add OS-specific icons (macOS/Windows/Linux) for local terminal tabs
  with auto-detection via navigator.userAgent
- Add SVG assets for macOS, Windows, and Linux logos
- Give Vaults tab a distinct style (rounded, semi-transparent bg,
  no accent line) to differentiate from session tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:54:00 +08:00
陈大猫
d609d8edb3 Merge pull request #326 from binaricat/fix/sftp-modal-upload-race-condition
fix: prevent SFTP modal drag-upload from targeting stale directory
2026-03-13 14:13:37 +08:00
bincxz
5f91fbbab8 fix: prevent SFTP modal drag-upload from targeting stale directory
When reopening the SFTP modal via drag-and-drop, the session effect's
initialization IIFE runs async (ensureSftp + listSftp ~0.5s). During
this window, dependency changes (e.g. loadFiles recreation from
files.length change by the layout effect clearing stale cache) can
re-trigger the session effect. Since initializedRef is already true,
the effect falls through to loadFiles(currentPath) with the OLD path.
If this loadFiles resolves before the IIFE, loading transitions to
false prematurely, causing the auto-upload to snapshot the stale
currentPathRef and upload to the wrong directory.

Add an initializingRef flag that is set when the initialization IIFE
starts and cleared in its finally block. The fallthrough loadFiles
call is skipped while initializingRef is true, ensuring only the
IIFE's completion triggers the loading transition that the auto-upload
effect relies on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:06:26 +08:00
陈大猫
89c3c7f83a Merge pull request #325 from binaricat/fix/mac-tray-update-restart
fix: destroy system tray before quitAndInstall on macOS
2026-03-13 13:36:52 +08:00
bincxz
ee391bcc32 fix: destroy system tray before quitAndInstall on macOS
On macOS, the system tray keeps the app process alive after all windows
are closed, preventing quitAndInstall from completing the restart.
Clean up the tray and its panel window before calling quitAndInstall so
the app can exit cleanly and the installer can proceed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:32:44 +08:00
陈大猫
26fd5023f5 Merge pull request #324 from binaricat/fix-sync-knowhosts
fix: known hosts sync not work
2026-03-13 13:27:09 +08:00
bincxz
49543abcff Merge main into fix-sync-knowhosts and resolve conflicts
Resolve conflict in useAutoSync.ts by integrating getEffectiveKnownHosts
into the refactored getSyncSnapshot function, avoiding duplication in
getDataHash which now delegates to getSyncSnapshot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:02:35 +08:00
陈大猫
6bab971de8 Merge pull request #323 from binaricat/codex/fix-auto-sync-overlap
Fix overlapping auto-sync retries
2026-03-13 11:44:26 +08:00
bincxz
392a57f95b Fix overlapping auto-sync handling 2026-03-13 11:38:17 +08:00
yuzifu
85e3e8b26f fix: known hosts sync not work 2026-03-13 11:30:29 +08:00
陈大猫
9747498833 Merge pull request #321 from yuzifu/fix-hosts-count
fix: show hosts count in the group
2026-03-13 11:02:59 +08:00
yuzifu
520e2c3f9d fix: show hosts count in the group 2026-03-13 10:47:58 +08:00
55 changed files with 3815 additions and 1063 deletions

19
App.tsx
View File

@@ -180,6 +180,12 @@ function App({ settings }: { settings: SettingsState }) {
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
@@ -370,7 +376,7 @@ function App({ settings }: { settings: SettingsState }) {
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
[keys]
);
@@ -439,7 +445,7 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
@@ -1201,6 +1207,7 @@ function App({ settings }: { settings: SettingsState }) {
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}
@@ -1274,6 +1281,7 @@ function App({ settings }: { settings: SettingsState }) {
keys={keys}
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
sessions={sessions}
workspaces={workspaces}
knownHosts={knownHosts}
@@ -1306,6 +1314,13 @@ function App({ settings }: { settings: SettingsState }) {
onSplitSession={splitSession}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -34,6 +34,7 @@ const en: Messages = {
'common.advanced': 'Advanced',
'common.left': 'Left',
'common.right': 'Right',
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.az': 'A-z',
@@ -595,7 +596,11 @@ const en: Messages = {
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
@@ -1121,6 +1126,7 @@ const en: Messages = {
'cloudSync.webdav.password': 'Password',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': 'Show secret',
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
'cloudSync.webdav.validation.token': 'Token is required.',

View File

@@ -22,6 +22,7 @@ const zhCN: Messages = {
'common.use': '使用',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.az': 'A-z',
'sort.za': 'Z-a',
@@ -433,7 +434,11 @@ const zhCN: Messages = {
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.transfers': '传输',
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
@@ -802,6 +807,7 @@ const zhCN: Messages = {
'cloudSync.webdav.password': '密码',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': '显示密钥',
'cloudSync.webdav.allowInsecure': '允许不安全的连接(忽略证书错误)',
'cloudSync.webdav.validation.endpoint': '请输入有效的 WebDAV 端点。',
'cloudSync.webdav.validation.credentials': '请输入用户名和密码。',
'cloudSync.webdav.validation.token': '请输入 Token。',

View File

@@ -0,0 +1,52 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
interface SharedRemoteHostCacheEntry {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
updatedAt: number;
}
const SHARED_REMOTE_HOST_CACHE_TTL_MS = 60_000;
const sharedRemoteHostCache = new Map<string, SharedRemoteHostCacheEntry>();
/**
* Build a cache key that includes connection details so that the same host ID
* with different session-time overrides (port, protocol) uses separate entries.
*/
export const buildCacheKey = (
hostId: string,
hostname?: string,
port?: number,
protocol?: string,
sftpSudo?: boolean,
username?: string,
): string => {
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}`;
};
export const getSharedRemoteHostCache = (
cacheKey: string,
): SharedRemoteHostCacheEntry | null => {
const entry = sharedRemoteHostCache.get(cacheKey);
if (!entry) return null;
if (Date.now() - entry.updatedAt > SHARED_REMOTE_HOST_CACHE_TTL_MS) {
sharedRemoteHostCache.delete(cacheKey);
return null;
}
return entry;
};
export const setSharedRemoteHostCache = (
cacheKey: string,
entry: Omit<SharedRemoteHostCacheEntry, "updatedAt">,
): void => {
sharedRemoteHostCache.set(cacheKey, {
...entry,
updatedAt: Date.now(),
});
};

View File

@@ -59,4 +59,5 @@ export interface SftpStateOptions {
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
}

View File

@@ -5,6 +5,7 @@ import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncodin
import type { SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
interface UseSftpConnectionsParams {
hosts: Host[];
@@ -24,14 +25,16 @@ interface UseSftpConnectionsParams {
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
autoConnectLocalOnMount?: boolean;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -55,22 +58,24 @@ export const useSftpConnections = ({
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => {
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) {
if (!sideTabs.activeTabId || options?.forceNewTab) {
const newPane = createEmptyPane();
activeTabId = newPane.id;
setTabs((prev) => ({
@@ -89,6 +94,14 @@ export const useSftpConnections = ({
const connectRequestId = navSeqRef.current[side];
lastConnectedHostRef.current[side] = host;
// Store the cache key for this connection so pane actions can look it up
// by connectionId instead of relying on the per-side lastConnectedHostRef.
if (host !== "local") {
connectionCacheKeyMapRef.current.set(
connectionId,
buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
);
}
const currentPane = getActivePane(side);
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
@@ -96,18 +109,22 @@ export const useSftpConnections = ({
const filenameEncoding: SftpFilenameEncoding =
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
// When forceNewTab is set, we're preserving the old tab for instant switching —
// don't close its SFTP session or clear its cache.
if (!options?.forceNewTab) {
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
@@ -162,22 +179,33 @@ export const useSftpConnections = ({
}));
}
} else {
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
const sharedHostCache =
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
? sharedHostCacheCandidate
: null;
const cachedStartPath = sharedHostCache?.path ?? "/";
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
hostLabel: host.label,
isLocal: false,
status: "connecting",
currentPath: "/",
currentPath: cachedStartPath,
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
// Always show loading while connecting — even with cached files.
// The cached file list is shown as a preview, but the pane stays
// non-interactive until the SFTP session is actually established.
loading: true,
reconnecting: prev.reconnecting,
error: null,
files: prev.reconnecting ? prev.files : [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
@@ -238,72 +266,137 @@ export const useSftpConnections = ({
sftpSessionsRef.current.set(connectionId, sftpId);
let startPath = "/";
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
break;
let startPath = sharedHostCache?.path ?? "/";
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
// Fall through to /root check
}
if (startPath === "/") {
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) {
startPath = `/home/${credentials.username}`;
homeDir = startPath;
}
} catch {
// Fall through to /root check
}
if (startPath === "/") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
}
}
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
const provisionalCacheKey = sharedHostCache
? makeCacheKey(connectionId, startPath, filenameEncoding)
: null;
if (sharedHostCache && provisionalCacheKey) {
dirCacheRef.current.set(provisionalCacheKey, {
files: sharedHostCache.files,
timestamp: Date.now(),
});
}
let files: SftpFileEntry[] = [];
try {
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
} catch {
// Cached path may be stale (deleted, permissions changed).
// Remove the provisional cache entry so phantom files don't resurface.
if (provisionalCacheKey) {
dirCacheRef.current.delete(provisionalCacheKey);
}
// Fall back to homeDir, then "/", chaining attempts.
let fallbackSucceeded = false;
if (sharedHostCache && startPath !== homeDir) {
try {
startPath = homeDir;
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// homeDir also failed, try root
}
}
if (!fallbackSucceeded && startPath !== "/") {
try {
startPath = "/";
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// root also failed
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
}
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
setSharedRemoteHostCache(hostCacheKey, {
path: startPath,
homeDir,
files,
filenameEncoding,
});
reconnectingRef.current[side] = false;
@@ -314,7 +407,7 @@ export const useSftpConnections = ({
...prev.connection,
status: "connected",
currentPath: startPath,
homeDir: startPath,
homeDir,
}
: null,
files,
@@ -356,13 +449,17 @@ export const useSftpConnections = ({
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
if (
autoConnectLocalOnMount &&
!initialConnectDoneRef.current &&
leftTabs.tabs.length === 0
) {
initialConnectDoneRef.current = true;
setTimeout(() => {
connect("left", "local");
}, 0);
}
}, [connect, leftTabs.tabs.length]);
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {

View File

@@ -7,11 +7,13 @@ import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
// Re-export UploadResult for external usage
export type { UploadResult };
@@ -20,6 +22,8 @@ interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
@@ -38,10 +42,16 @@ interface SftpExternalOperationsResult {
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string }
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
@@ -53,6 +63,8 @@ export const useSftpExternalOperations = (
getActivePane,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
clearDirCacheEntry,
useCompressedUpload = false,
addExternalUpload,
updateExternalUpload,
@@ -63,6 +75,10 @@ export const useSftpExternalOperations = (
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
const pane = getActivePane(side);
@@ -324,6 +340,7 @@ export const useSftpExternalOperations = (
pane.filenameEncoding,
);
watchId = result.watchId;
activeFileWatchCountRef.current += 1;
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
@@ -337,7 +354,9 @@ export const useSftpExternalOperations = (
// Create upload callbacks that translate to TransferTask updates
const createUploadCallbacks = useCallback((
connectionId: string,
targetPath: string
targetPath: string,
targetHostId?: string,
targetConnectionKey?: string,
): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
@@ -349,6 +368,8 @@ export const useSftpExternalOperations = (
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
@@ -374,6 +395,8 @@ export const useSftpExternalOperations = (
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
@@ -505,7 +528,12 @@ export const useSftpExternalOperations = (
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
const callbacks = createUploadCallbacks(
pane.connection.id,
pane.connection.currentPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
try {
const results = await uploadFromDataTransfer(
@@ -532,6 +560,7 @@ export const useSftpExternalOperations = (
}
},
[
connectionCacheKeyMapRef,
getActivePane,
refresh,
sftpSessionsRef,
@@ -541,6 +570,90 @@ export const useSftpExternalOperations = (
],
);
const uploadExternalEntries = useCallback(
async (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string },
): Promise<UploadResult[]> => {
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 sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const controller = new UploadController();
uploadControllerRef.current = controller;
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
const directUploadBridge: UploadBridge = {
...createUploadBridge,
};
try {
const results = await uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: directUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller,
);
// Refresh the current directory and invalidate the upload target's
// cache entry. If the user navigated away during the upload, the
// invalidation ensures returning to the target path triggers a fresh
// listing instead of serving stale cached data.
const livePane = getActivePane(side);
if (livePane?.connection) {
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
await refresh(side);
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload,
],
);
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
if (controller) {
@@ -566,7 +679,9 @@ export const useSftpExternalOperations = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
};
};

View File

@@ -4,8 +4,10 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
@@ -15,6 +17,7 @@ interface UseSftpPaneActionsParams {
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
@@ -50,6 +53,7 @@ interface UseSftpPaneActionsResult {
}
export const useSftpPaneActions = ({
hosts,
getActivePane,
updateTab,
updateActiveTab,
@@ -59,6 +63,7 @@ export const useSftpPaneActions = ({
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
@@ -68,6 +73,31 @@ export const useSftpPaneActions = ({
isSessionError,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
// Build the shared cache key for the active pane. Prefer the last connected
// host (which includes session-time overrides), fall back to the vault hosts list.
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const getActivePaneCacheKey = useCallback((side: "left" | "right", hostId: string, connectionId?: string): string => {
// Prefer the per-connection cache key — it's set at connect time and
// correctly identifies the endpoint even when multiple tabs share the
// same hostId with different session-time overrides.
if (connectionId) {
const perConnKey = connectionCacheKeyMapRef.current.get(connectionId);
if (perConnKey) return perConnKey;
}
// Fallback: lastConnectedHostRef (per-side, may be stale for multi-tab)
const connHost = lastConnectedHostRef.current[side];
if (connHost && connHost !== "local" && connHost.id === hostId) {
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username);
}
// Fall back to vault host
const host = hostsRef.current.find(h => h.id === hostId);
if (host) {
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
}
return hostId;
}, [connectionCacheKeyMapRef, lastConnectedHostRef]);
// Track the latest navigation request ID per tab, so we can distinguish
// whether a superseded request was superseded by the same tab or a different tab.
const tabNavSeqRef = useRef(new Map<string, number>());
@@ -134,6 +164,19 @@ export const useSftpPaneActions = ({
error: null,
selectedFiles: new Set(),
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
// shared cache is a best-effort optimization and hostId uniquely
// identifies the connection in the common case. Session-time
// overrides create separate connections with distinct cache keys
// at the connect() layer.
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files: cached.files,
filenameEncoding: pane.filenameEncoding,
});
}
return;
}
@@ -258,6 +301,14 @@ export const useSftpPaneActions = ({
loading: false,
selectedFiles: new Set(),
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files,
filenameEncoding: pane.filenameEncoding,
});
}
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
@@ -295,6 +346,7 @@ export const useSftpPaneActions = ({
},
[
getActivePane,
getActivePaneCacheKey,
updateTab,
leftTabsRef,
rightTabsRef,

View File

@@ -18,6 +18,7 @@ import {
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { toast } from '../../components/ui/toast';
interface AutoSyncConfig {
@@ -52,12 +53,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const isInitializedRef = useRef(false);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
// If port-forwarding hook state is still [] (async init in progress),
// fall back to localStorage to avoid uploading an empty array that
// overwrites the cloud snapshot.
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
@@ -72,6 +69,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}));
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
return {
hosts: config.hosts,
keys: config.keys,
@@ -80,40 +80,31 @@ export const useAutoSync = (config: AutoSyncConfig) => {
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
knownHosts: effectiveKnownHosts,
};
}, [
config.hosts,
config.keys,
config.identities,
config.snippets,
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
]);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
return {
...getSyncSnapshot(),
syncedAt: Date.now(),
};
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
}, [getSyncSnapshot]);
// Create a hash of current data for comparison
const getDataHash = useCallback(() => {
// Same fallback as buildPayload
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const data = {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
};
return JSON.stringify(data);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
return JSON.stringify(getSyncSnapshot());
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
@@ -130,6 +121,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.noProvider'));
}
if (syncing) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
return;
}
throw new Error(t('sync.autoSync.alreadySyncing'));
}
@@ -151,6 +146,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = getDataHash();
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
@@ -169,7 +165,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
lastSyncedDataRef.current = getDataHash();
lastSyncedDataRef.current = dataHash;
} catch (error) {
if (trigger === 'manual') {
throw error;
@@ -236,6 +232,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (currentHash === lastSyncedDataRef.current) {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing) {
return;
}
// Clear existing timeout
if (syncTimeoutRef.current) {
@@ -253,7 +255,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, getDataHash, syncNow]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow]);
// Check remote version on startup/unlock
useEffect(() => {

View File

@@ -17,7 +17,7 @@ import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: { id: string; privateKey: string }[];
keys: { id: string; privateKey: string; passphrase: string }[];
}
/**
@@ -30,7 +30,7 @@ export const usePortForwardingAutoStart = ({
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
// Keep refs in sync
useEffect(() => {

View File

@@ -63,7 +63,7 @@ export interface UsePortForwardingStateResult {
startTunnel: (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
keys: { id: string; privateKey: string; passphrase: string }[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
@@ -377,7 +377,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
keys: { id: string; privateKey: string; passphrase: string }[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,

View File

@@ -39,7 +39,7 @@ import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'system';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
/** Resolve the current OS color scheme preference. */
const getSystemPreference = (): 'light' | 'dark' =>

View File

@@ -101,12 +101,26 @@ export const useSftpState = (
}
}, []);
const clearDirCacheEntry = useCallback((connectionId: string, path: string) => {
// Remove all encoding variants of this path from the cache
for (const key of dirCacheRef.current.keys()) {
if (key.startsWith(`${connectionId}::`) && key.endsWith(`::${path}`)) {
dirCacheRef.current.delete(key);
}
}
}, []);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
right: false,
});
// Map connectionId → cache key, set at connect time so each tab's
// navigateTo can use the correct cache key even when multiple tabs
// share the same hostId with different session-time overrides.
const connectionCacheKeyMapRef = useRef<Map<string, string>>(new Map());
// Store last connected host info for reconnection
const lastConnectedHostRef = useRef<{
left: Host | "local" | null;
@@ -149,10 +163,12 @@ export const useSftpState = (
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane: createPane,
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
});
const {
@@ -173,6 +189,7 @@ export const useSftpState = (
renameFile,
changePermissions,
} = useSftpPaneActions({
hosts,
getActivePane,
updateTab,
updateActiveTab,
@@ -182,6 +199,7 @@ export const useSftpState = (
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
@@ -249,12 +267,16 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
} = useSftpExternalOperations({
getActivePane,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
clearDirCacheEntry,
useCompressedUpload: options?.useCompressedUpload,
addExternalUpload,
updateExternalUpload,
@@ -298,6 +320,7 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
startTransfer,
@@ -344,6 +367,7 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
startTransfer,
@@ -396,6 +420,8 @@ export const useSftpState = (
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),
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
methodsRef.current.uploadExternalEntries(...args),
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
@@ -407,7 +433,8 @@ export const useSftpState = (
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
}), []); // Empty deps - these wrappers never change
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
// Return object with stable method references but reactive state
// State changes will cause re-renders, but method references stay stable

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a string value to localStorage.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists
* @param validate - Optional function to validate stored value; returns fallback if invalid
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredString = <T extends string = string>(
storageKey: string,
fallback: T,
validate?: (value: string) => value is T,
) => {
const [value, setValue] = useState<T>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored === null) return fallback;
if (validate) return validate(stored) ? stored : fallback;
return stored as T;
});
useEffect(() => {
localStorageAdapter.writeString(storageKey, value);
}, [storageKey, value]);
return [value, setValue] as const;
};

View File

@@ -731,6 +731,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const [webdavPassword, setWebdavPassword] = useState('');
const [webdavToken, setWebdavToken] = useState('');
const [showWebdavSecret, setShowWebdavSecret] = useState(false);
const [webdavAllowInsecure, setWebdavAllowInsecure] = useState(false);
const [webdavError, setWebdavError] = useState<string | null>(null);
const [webdavErrorDetail, setWebdavErrorDetail] = useState<string | null>(null);
const [isSavingWebdav, setIsSavingWebdav] = useState(false);
@@ -853,6 +854,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
setWebdavUsername(config?.username || '');
setWebdavPassword(config?.password || '');
setWebdavToken(config?.token || '');
setWebdavAllowInsecure(config?.allowInsecure || false);
setShowWebdavSecret(false);
setWebdavError(null);
setWebdavErrorDetail(null);
@@ -903,6 +905,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
username: webdavAuthType === 'token' ? undefined : webdavUsername.trim(),
password: webdavAuthType === 'token' ? undefined : webdavPassword,
token: webdavAuthType === 'token' ? webdavToken.trim() : undefined,
allowInsecure: webdavAllowInsecure ? true : undefined,
};
setIsSavingWebdav(true);
@@ -1337,6 +1340,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
{t('cloudSync.webdav.showSecret')}
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={webdavAllowInsecure}
onChange={(e) => setWebdavAllowInsecure(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.webdav.allowInsecure')}
</label>
{webdavError && (
<p className="text-sm text-red-500">{webdavError}</p>
)}

View File

@@ -18,6 +18,10 @@ export const DISTRO_LOGOS: Record<string, string> = {
oracle: "/distro/oracle.svg",
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
// OS-level logos (used by local terminal tab icons)
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
linux: "/distro/linux.svg",
};
export const DISTRO_COLORS: Record<string, string> = {
@@ -34,6 +38,10 @@ export const DISTRO_COLORS: Record<string, string> = {
oracle: "bg-[#C74634]",
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
// OS-level colors
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",
linux: "bg-[#333333]",
default: "bg-slate-600",
};

View File

@@ -130,7 +130,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {

View File

@@ -24,7 +24,6 @@ import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActi
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
import { toast } from "./ui/toast";
import { Dialog, DialogContent } from "./ui/dialog";
interface SFTPModalProps {
host: Host;
@@ -649,10 +648,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
};
if (!open) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
<>
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
<SftpModalHeader
onClose={handleClose}
t={t}
host={host}
credentials={credentials}
@@ -753,7 +755,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
/>
</DialogContent>
</div>
<SftpModalDialogs
t={t}
@@ -808,7 +810,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,221 @@
/**
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
*
* Shows snippets organized by package hierarchy with breadcrumb navigation.
* Clicking a snippet executes it in the focused terminal session.
*/
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Snippet } from '../types';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (command: string) => void;
isVisible?: boolean;
}
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
onSnippetClick,
isVisible = true,
}) => {
const { t } = useI18n();
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const displayedPackages = useMemo(() => {
if (!selectedPackage) {
const absolutePaths = packages.filter(p => p.startsWith('/'));
const relativePaths = packages.filter(p => !p.startsWith('/'));
const results: { name: string; path: string; count: number }[] = [];
const relativeRoots = relativePaths
.map((p) => p.split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(relativeRoots)).forEach((name: string) => {
const path: string = name;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name, path, count });
});
const absoluteRoots = absolutePaths
.map((p) => {
const cleanPath = p.substring(1);
return cleanPath.split('/')[0];
})
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
const path: string = `/${name}`;
const displayName: string = `/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name: displayName, path, count });
});
return results;
}
const prefix = selectedPackage + '/';
const children = packages
.filter((p) => p.startsWith(prefix))
.map((p) => p.replace(prefix, '').split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
return Array.from(new Set(children)).map((name) => {
const path = `${selectedPackage}/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
return { name, path, count };
});
}, [packages, selectedPackage, snippets]);
const displayedSnippets = useMemo(() => {
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
if (search.trim()) {
const s = search.toLowerCase();
result = result.filter(sn =>
sn.label.toLowerCase().includes(s) ||
sn.command.toLowerCase().includes(s)
);
}
return result;
}, [snippets, selectedPackage, search]);
// Also filter packages by search when at root level
const filteredPackages = useMemo(() => {
if (!search.trim()) return displayedPackages;
const s = search.toLowerCase();
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
}, [displayedPackages, search]);
const breadcrumb = useMemo(() => {
if (!selectedPackage) return [];
const isAbsolute = selectedPackage.startsWith('/');
const parts = selectedPackage.split('/').filter(Boolean);
return parts.map((name, idx) => {
const pathSegments = parts.slice(0, idx + 1);
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
return { name, path };
});
}, [selectedPackage]);
const handleSnippetClick = useCallback((command: string) => {
onSnippetClick(command);
}, [onSnippetClick]);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Search */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('snippets.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
</div>
{/* Breadcrumb */}
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
<button
className={cn(
"hover:text-primary transition-colors truncate",
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
)}
onClick={() => setSelectedPackage(null)}
>
{t('terminal.toolbar.library')}
</button>
{breadcrumb.map((b) => (
<React.Fragment key={b.path}>
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
<button
className="text-muted-foreground hover:text-primary transition-colors truncate"
onClick={() => setSelectedPackage(b.path)}
>
{b.name}
</button>
</React.Fragment>
))}
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="py-1">
{!hasAnyContent && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
)}
{/* Packages */}
{filteredPackages.map((pkg) => (
<button
key={pkg.path}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
>
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Package size={12} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{pkg.name}</div>
<div className="text-[10px] text-muted-foreground">
{t('snippets.package.count', { count: pkg.count })}
</div>
</div>
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
</button>
))}
{/* Snippets */}
{displayedSnippets.map((s) => (
<button
key={s.id}
onClick={() => handleSnippetClick(s.command)}
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
>
<span className="text-xs font-medium truncate">{s.label}</span>
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
{s.command}
</span>
</button>
))}
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
)}
</div>
</ScrollArea>
</div>
);
};
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';

View File

@@ -0,0 +1,618 @@
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
* Reuses SftpView's components (SftpPaneView, SftpContextProvider, etc.)
* to provide a unified SFTP experience. Renders a single pane (left side only).
*
* IMPORTANT: Does NOT use the global activeTabStore to avoid conflicts with
* the main SftpView tab. Instead manages pane visibility internally.
*
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types";
import type { TransferTask } from "../types";
import { toast } from "./ui/toast";
import { DistroAvatar } from "./DistroAvatar";
import { SftpPaneView } from "./sftp/SftpPaneView";
import { SftpOverlays } from "./sftp/SftpOverlays";
import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
interface SftpSidePanelProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
initialLocation?: { hostId: string; path: string } | null;
showWorkspaceHostHeader?: boolean;
isVisible?: boolean;
renderOverlays?: boolean;
pendingUpload?: {
requestId: string;
hostId: string;
connectionKey: string;
targetPath?: string;
entries: DropEntry[];
} | null;
onPendingUploadHandled?: (requestId: string) => void;
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
keys,
identities,
updateHosts,
activeHost,
initialLocation,
showWorkspaceHostHeader = false,
isVisible = true,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
}) => {
const { t } = useI18n();
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
toast.error(t('sftp.autoSync.error', { error: payload.error }));
logger.error("[SFTP] File auto-sync failed", payload);
},
}), [t]);
const sftpOptions = useMemo(() => ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
const {
leftCallbacks,
rightCallbacks,
dragCallbacks,
draggedFiles,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
const {
leftPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
// Auto-connect when activeHost changes.
// Uses sftpRef to avoid re-triggering on every sftp state change.
const connectedKeyRef = useRef<string | null>(null);
// Store the Host object used for the current connection so the header
// can show session-time overrides even during deferred host switches.
const connectedHostObjRef = useRef<Host | null>(null);
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
const handledPendingUploadIdRef = useRef<string | null>(null);
// Maps tab IDs to the connectionKey used to create them, so we can
// correctly identify tabs when the same host ID has different overrides.
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
const pendingConnectionKeyRef = useRef<string | null>(null);
const prevIsVisibleRef = useRef(isVisible);
// Reset location guard when the panel is reopened so the terminal cwd
// is re-applied even if it matches the previous session's path.
useEffect(() => {
if (isVisible && !prevIsVisibleRef.current) {
lastAppliedInitialLocationKeyRef.current = null;
}
prevIsVisibleRef.current = isVisible;
}, [isVisible]);
// Navigate SFTP to the terminal's current working directory
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd();
if (cwd) {
sftpRef.current.navigateTo("left", cwd);
}
}, [onGetTerminalCwd]);
// Track whether there's active work that should block connection switching.
// Computed outside the effect so it can be in the dependency array.
const hasActiveTransfers = useMemo(
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
[sftp.transfers],
);
// Block host-following while any connection-sensitive UI or operation
// is active: text editor, permissions dialog, file-opener dialog, or
// auto-synced external file watches.
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
useEffect(() => {
if (!activeHost) return;
const s = sftpRef.current;
// Serial terminals don't support SFTP — disconnect any existing
// connection (remote or local) so the panel doesn't remain bound to
// a previous host.
const proto = activeHost.protocol;
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
// Serial terminals don't support SFTP. Just clear the tracked
// connection key so switching back to a remote terminal will
// trigger auto-connect. Don't disconnect existing tabs — they
// may be reused when focus returns.
connectedKeyRef.current = null;
return;
}
// Local terminals connect to the local file browser
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
if (hasActiveWork) return;
const leftConn = s.leftPane.connection;
if (leftConn?.isLocal) {
// Already connected locally
connectedKeyRef.current = "local";
return;
}
// Check for an existing local tab to reuse
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
tab.connection?.isLocal && tab.connection.status === "connected",
);
if (existingLocalTab) {
s.selectTab("left", existingLocalTab.id);
connectedKeyRef.current = "local";
return;
}
connectedKeyRef.current = "local";
// Preserve existing remote tab when switching to local
const needsNewTab = !!(leftConn && leftConn.status === "connected");
if (needsNewTab) {
s.connect("left", "local", { forceNewTab: true });
} else if (leftConn) {
// Await disconnect before connecting locally to avoid the async
// disconnect wiping out the fresh local connection.
void s.disconnect("left").then(() => s.connect("left", "local"));
} else {
s.connect("left", "local");
}
return;
}
// Build a connection key that accounts for session-time overrides
// (same host ID may have different port/protocol in different workspace panes).
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
if (connectedKeyRef.current === connectionKey) return;
// Don't switch connections while transfers or editor are active
if (hasActiveWork) return;
logger.info("[SftpSidePanel] Auto-connect triggered", {
hostId: activeHost.id,
hostLabel: activeHost.label,
protocol: activeHost.protocol,
hostname: activeHost.hostname,
});
// Check if an existing SFTP tab matches this exact endpoint.
// We track which connectionKey was used to create each tab so that
// tabs for the same host ID with different session-time overrides
// (port/protocol) are not incorrectly reused.
const tabs = s.leftTabs.tabs;
const existingTab = tabs.find((tab) => {
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
// Don't reuse errored tabs — they need a fresh connection
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
});
if (existingTab) {
s.selectTab("left", existingTab.id);
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
return;
}
// Create a new tab when there's already an active connection to a different
// host, so the previous tab is preserved for instant switching on focus change.
const currentConn = s.leftPane.connection;
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
// Store the pending key so the effect below can map it once the tab is created
pendingConnectionKeyRef.current = connectionKey;
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
// Track the active tab's connectionKey after connect() creates or reuses it.
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
useEffect(() => {
const activeTabId = sftp.leftTabs.activeTabId;
if (activeTabId && pendingConnectionKeyRef.current) {
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
pendingConnectionKeyRef.current = null;
}
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.
// Also reset the file-watch counter — watches are bound to the SFTP session,
// so they stop when the session disconnects.
useEffect(() => {
const connection = sftp.leftPane.connection;
if (!connection || connection.status === "error" || connection.status === "disconnected") {
connectedKeyRef.current = null;
if (sftp.activeFileWatchCountRef) {
sftp.activeFileWatchCountRef.current = 0;
}
}
}, [sftp.leftPane.connection, sftp.leftPane.connection?.status, sftp.activeFileWatchCountRef]);
useEffect(() => {
if (!activeHost || !initialLocation) return;
if (initialLocation.hostId !== activeHost.id || !initialLocation.path) return;
const activePane = sftpRef.current.leftPane;
const connection = activePane.connection;
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
// Include full endpoint key so that same-hostId sessions with
// different overrides each get their initial location applied.
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
if (connection.currentPath === initialLocation.path) {
lastAppliedInitialLocationKeyRef.current = locationKey;
return;
}
lastAppliedInitialLocationKeyRef.current = locationKey;
sftpRef.current.navigateTo("left", initialLocation.path);
}, [
activeHost,
initialLocation,
sftp.leftPane,
]);
useEffect(() => {
if (!pendingUpload || !activeHost) return;
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
if (pendingUpload.hostId !== activeHost.id) return;
const activePane = sftp.leftPane;
const connection = activePane.connection;
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
handledPendingUploadIdRef.current = pendingUpload.requestId;
const runUpload = async () => {
try {
const results = await sftpRef.current.uploadExternalEntries("left", pendingUpload.entries, {
targetPath: pendingUpload.targetPath,
});
if (results.some((result) => result.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failCount = results.filter((result) => !result.success && !result.cancelled).length;
const successCount = results.filter((result) => result.success).length;
if (failCount === 0) {
const message =
successCount === 1
? `${t("sftp.upload")}: ${results[0]?.fileName ?? ""}`
: `${t("sftp.uploadFiles")}: ${successCount}`;
toast.success(message, "SFTP");
} else {
const failedFiles = results.filter((result) => !result.success && !result.cancelled);
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
"SFTP",
);
});
}
} catch (error) {
logger.error("[SftpSidePanel] Failed to upload dropped files:", error);
handledPendingUploadIdRef.current = null;
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
return;
} finally {
onPendingUploadHandled?.(pendingUpload.requestId);
}
};
void runUpload();
}, [
activeHost,
onPendingUploadHandled,
pendingUpload,
sftp.leftPane,
t,
]);
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
[sftp.transfers],
);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
const connection = sftpRef.current.leftPane.connection;
if (!connection || connection.isLocal) return;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[],
);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
const connection = sftp.leftPane.connection;
if (!connection || connection.isLocal) return false;
if (task.targetHostId) {
if (connection.hostId !== task.targetHostId) return false;
// If the transfer recorded a full endpoint key, use it to
// distinguish same-hostId uploads with different session overrides.
if (task.targetConnectionKey) {
return connectedKeyRef.current === task.targetConnectionKey;
}
return true;
}
return connection.id === task.targetConnectionId;
},
[sftp.leftPane.connection],
);
// When the auto-connect effect defers a switch (active transfers or open
// editor), the panel still operates on the current connection, not
// activeHost. Use the connected host for the header so the label matches
// what browse/edit/delete actions actually target.
const displayHost = useMemo(() => {
const conn = sftp.leftPane.connection;
if (conn && !conn.isLocal) {
// Prefer the stored Host object from connect time — it preserves
// session-time overrides that the vault host may lack.
if (connectedHostObjRef.current && connectedHostObjRef.current.id === conn.hostId) {
return connectedHostObjRef.current;
}
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
}
return activeHost;
}, [sftp.leftPane.connection, hosts, activeHost]);
// Determine the active pane to render (without using global activeTabStore)
const activeLeftPaneId = sftp.leftTabs.activeTabId;
return (
<SftpContextProvider
hosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
>
<div
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<DistroAvatar
host={displayHost}
fallback={displayHost.label.slice(0, 2).toUpperCase()}
size="sm"
className="h-5 w-5 rounded-sm shrink-0"
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
>
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
</div>
</div>
)}
{/* File browser pane - render only the active pane */}
<div className="relative flex-1 min-h-0">
{leftPanes.map((pane, idx) => {
// Manage visibility locally instead of via activeTabStore
const isActive = activeLeftPaneId
? pane.id === activeLeftPaneId
: idx === 0;
if (!isActive) return null;
return (
<div key={pane.id} className="absolute inset-0 z-10">
<SftpPaneView
side="left"
pane={pane}
showHeader
showEmptyHeader
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
/>
</div>
);
})}
</div>
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
/>
</div>
{renderOverlays && (
<SftpOverlays
hosts={hosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}
hostSearchRight={hostSearchRight}
setShowHostPickerLeft={setShowHostPickerLeft}
setShowHostPickerRight={setShowHostPickerRight}
setHostSearchLeft={setHostSearchLeft}
setHostSearchRight={setHostSearchRight}
handleHostSelectLeft={handleHostSelectLeft}
handleHostSelectRight={handleHostSelectRight}
permissionsState={permissionsState}
setPermissionsState={setPermissionsState}
showTextEditor={showTextEditor}
setShowTextEditor={setShowTextEditor}
textEditorTarget={textEditorTarget}
setTextEditorTarget={setTextEditorTarget}
textEditorContent={textEditorContent}
setTextEditorContent={setTextEditorContent}
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}
setFileOpenerTarget={setFileOpenerTarget}
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
t={t}
/>
)}
</SftpContextProvider>
);
};
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&
prev.activeHost === next.activeHost &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.renderOverlays === next.renderOverlays &&
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path;
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
SftpSidePanel.displayName = "SftpSidePanel";

View File

@@ -5,7 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
// flushSync removed - no longer needed
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -28,7 +28,7 @@ import {
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { toast } from "./ui/toast";
@@ -119,9 +119,6 @@ interface TerminalProps {
sessionId: string;
startupCommand?: string;
serialConfig?: SerialConfig;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
hotkeyScheme?: "disabled" | "mac" | "pc";
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
@@ -141,6 +138,14 @@ interface TerminalProps {
) => void;
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
onOpenSftp?: (
host: Host,
initialPath?: string,
pendingUploadEntries?: DropEntry[],
sourceSessionId?: string,
) => void;
onOpenScripts?: () => void;
onOpenTheme?: () => void;
isBroadcastEnabled?: boolean;
onToggleBroadcast?: () => void;
onToggleComposeBar?: () => void;
@@ -180,9 +185,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
startupCommand,
serialConfig,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
hotkeyScheme = "disabled",
keyBindings = [],
onHotkeyAction,
@@ -197,6 +199,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandExecuted,
onSplitHorizontal,
onSplitVertical,
onOpenSftp,
onOpenScripts,
onOpenTheme,
isBroadcastEnabled,
onToggleBroadcast,
onToggleComposeBar,
@@ -228,6 +233,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
useEffect(() => {
if (xtermRuntimeRef.current) {
@@ -279,7 +285,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
// isScriptsOpen state removed - scripts now handled by side panel
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
@@ -288,7 +294,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
@@ -304,7 +309,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Drag and drop state
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
@@ -642,12 +647,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth, host.protocol, host.hostname]);
const safeFit = () => {
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
if (options?.requireVisible && !isVisibleRef.current) return;
const container = containerRef.current;
if (!container) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) {
// Terminal is hidden — invalidate the cached size so that when it
// becomes visible again, a non-forced fit won't be suppressed by a
// stale size match (e.g. after font metrics changed while hidden).
lastFittedSizeRef.current = null;
return;
}
if (!options?.force) {
const lastSize = lastFittedSizeRef.current;
if (lastSize && lastSize.width === width && lastSize.height === height) {
return;
}
}
const runFit = () => {
try {
lastFittedSizeRef.current = { width, height };
fitAddon.fit();
} catch (err) {
logger.warn("Fit failed", err);
@@ -721,7 +748,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
setTimeout(() => safeFit(), 50);
setTimeout(() => safeFit({ force: true }), 50);
}
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
@@ -739,14 +766,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
selectionBackground: effectiveTheme.colors.selection,
};
setTimeout(() => safeFit(), 50);
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (!isVisible) return;
const timer = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
if (pendingOutputScrollRef.current) {
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
@@ -828,17 +855,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
if (isResizing) return;
if (isResizing || !isVisibleRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 250);
});
@@ -853,7 +880,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (prevIsResizingRef.current && !isResizing && isVisible) {
const timer = setTimeout(() => {
safeFit();
safeFit({ force: true, requireVisible: true });
}, 100);
return () => clearTimeout(timer);
}
@@ -863,7 +890,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 100);
return () => clearTimeout(timer);
}, [inWorkspace, isVisible]);
@@ -903,7 +930,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
term.onSelectionChange(onSelectionChange);
const disposable = term.onSelectionChange(onSelectionChange);
return () => disposable.dispose();
}, [terminalSettings?.copyOnSelect]);
// Track whether the terminal application has enabled mouse tracking
@@ -965,11 +993,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (!isVisibleRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 250);
};
@@ -1001,18 +1030,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
scrollOnPasteRef,
});
const handleSnippetClick = (cmd: string) => {
if (sessionRef.current) {
const payload = `${cmd}\r`;
terminalBackend.writeToSession(sessionRef.current, payload);
scrollToBottomAfterProgrammaticInput(payload);
setIsScriptsOpen(false);
termRef.current?.focus();
return;
}
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
if (sessionRef.current) {
@@ -1021,30 +1038,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
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
}
}
onOpenSftp(host, initialPath, undefined, sessionId);
return;
}
// Fallback: toggle internal SFTP state (shouldn't happen with new architecture)
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);
};
@@ -1176,27 +1191,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.focus();
}
} else {
// Remote terminal: Trigger SFTP upload
// Get current working directory for SFTP initial path
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
// Remote terminal: Trigger SFTP upload via parent
if (onOpenSftp) {
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
}
} catch {
// Silently fail and open SFTP without initial path
}
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
setPendingUploadEntries(dropEntries);
// Use flushSync to ensure sftpInitialPath is updated synchronously
// before setShowSFTP(true) triggers the modal open
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
}
} catch (error) {
logger.error("Failed to handle file drop", error);
@@ -1207,18 +1216,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const renderControls = (opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
snippets={snippets}
host={host}
defaultThemeId={terminalTheme.id}
defaultFontFamilyId={fontFamilyId}
defaultFontSize={fontSize}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={handleOpenSFTP}
onSnippetClick={handleSnippetClick}
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenTheme={onOpenTheme ?? (() => {})}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
onClose={() => onCloseSession?.(sessionId)}
@@ -1652,7 +1653,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
<div
className="h-full flex-1 min-w-0 transition-all duration-300 relative overflow-hidden pt-8"
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
style={{ backgroundColor: effectiveTheme.colors.background }}
>
<div
@@ -1742,78 +1743,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
themeColors={effectiveTheme.colors}
/>
)}
<SFTPModal
host={host}
credentials={(() => {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
// Build proxy config if present
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
// Build jump hosts array if host chain is configured
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => allHosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
username: resolvedAuth.username,
hostname: host.hostname,
port: host.port,
password: resolvedAuth.password,
privateKey: resolvedAuth.key?.privateKey,
certificate: resolvedAuth.key?.certificate,
passphrase: resolvedAuth.passphrase,
publicKey: resolvedAuth.key?.publicKey,
keyId: resolvedAuth.keyId,
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
legacyAlgorithms: host.legacyAlgorithms,
};
})()}
open={showSFTP && status === "connected"}
onClose={() => {
setShowSFTP(false);
setPendingUploadEntries([]);
}}
initialPath={sftpInitialPath}
initialEntriesToUpload={pendingUploadEntries}
onUpdateHost={onUpdateHost}
/>
</div>
</TerminalContextMenu>
);

View File

@@ -1,4 +1,4 @@
import { Circle, LayoutGrid, Server } from 'lucide-react';
import { Circle, FolderTree, LayoutGrid, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId } from '../application/state/activeTabStore';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
@@ -6,15 +6,23 @@ import { collectSessionIds } from '../domain/workspace';
import { SplitDirection } from '../domain/workspace';
import { KeyBinding, TerminalSettings } from '../domain/models';
import { cn } from '../lib/utils';
import { useStoredString } from '../application/state/useStoredString';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
type SidePanelTab = 'sftp' | 'scripts' | 'theme';
type WorkspaceRect = { x: number; y: number; w: number; h: number };
type SplitHint = {
@@ -33,11 +41,34 @@ type ResizerHandle = {
splitArea: { w: number; h: number };
};
type PendingSftpUpload = {
requestId: string;
hostId: string;
/** Full connection identity (id:hostname:port:protocol) for session-override awareness */
connectionKey: string;
targetPath?: string;
entries: DropEntry[];
};
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
let changed = false;
const next = new Map<string, T>();
for (const [id, value] of source) {
if (validIds.has(id)) {
next.set(id, value);
} else {
changed = true;
}
}
return changed ? next : source;
};
interface TerminalLayerProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
snippetPackages: string[];
sessions: TerminalSession[];
workspaces: Workspace[];
knownHosts?: KnownHost[];
@@ -69,6 +100,14 @@ interface TerminalLayerProps {
// Broadcast mode
isBroadcastEnabled?: (workspaceId: string) => boolean;
onToggleBroadcast?: (workspaceId: string) => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
}
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
@@ -76,6 +115,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
keys,
identities,
snippets,
snippetPackages,
sessions,
workspaces,
knownHosts = [],
@@ -106,6 +146,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSplitSession,
isBroadcastEnabled,
onToggleBroadcast,
updateHosts,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
editorWordWrap,
setEditorWordWrap,
}) => {
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
@@ -184,6 +231,161 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Workspace-level compose bar state
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const activeTabIdRef = useRef(activeTabId);
activeTabIdRef.current = activeTabId;
const activeWorkspaceRef = useRef(activeWorkspace);
activeWorkspaceRef.current = activeWorkspace;
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
const [sidePanelWidth, setSidePanelWidth] = useState(320);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
const sftpResizingRef = useRef(false);
const sidePanelOpenTabsRef = useRef(sidePanelOpenTabs);
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
// Whether side panel is open for the currently active tab and which sub-panel
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
// Legacy compatibility helpers for SFTP-specific logic
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
// The host to pass to the SFTP panel - stored when the user opens SFTP
const [sftpHostForTab, setSftpHostForTab] = useState<Map<string, Host>>(new Map());
const [sftpInitialLocationForTab, setSftpInitialLocationForTab] = useState<
Map<string, { hostId: string; path: string }>
>(new Map());
const [sftpPendingUploadsForTab, setSftpPendingUploadsForTab] = useState<
Map<string, PendingSftpUpload>
>(new Map());
const sftpHostForTabRef = useRef(sftpHostForTab);
sftpHostForTabRef.current = sftpHostForTab;
const handleToggleWorkspaceComposeBar = useCallback(() => {
setIsComposeBarOpen(prev => !prev);
}, []);
const handleOpenSftp = useCallback((host: Host, initialPath?: string, pendingUploadEntries?: DropEntry[], sourceSessionId?: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
// When SFTP is opened from a non-focused workspace pane (toolbar click
// or drag-drop), switch focus first so the SFTP panel binds to the
// correct host.
if (sourceSessionId) {
const ws = activeWorkspaceRef.current;
if (ws && ws.focusedSessionId !== sourceSessionId) {
onSetWorkspaceFocusedSessionRef.current?.(ws.id, sourceSessionId);
}
}
const currentPanel = sidePanelOpenTabsRef.current.get(tabId);
const isOpen = currentPanel === 'sftp';
const currentHost = sftpHostForTabRef.current.get(tabId);
const shouldKeepOpen = !!pendingUploadEntries?.length;
// Compare full endpoint identity so that session-time overrides
// (different port/protocol for the same host ID) trigger a switch
// instead of toggling the panel closed.
const isSameEndpoint = currentHost
&& currentHost.id === host.id
&& currentHost.hostname === host.hostname
&& currentHost.port === host.port
&& currentHost.protocol === host.protocol
&& currentHost.username === host.username
&& currentHost.sftpSudo === host.sftpSudo;
const isClosing = !shouldKeepOpen && isOpen && isSameEndpoint;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
if (isClosing) {
next.delete(tabId);
} else {
next.set(tabId, 'sftp');
}
return next;
});
// Store or remove the host for this tab.
// Removing on close unmounts the panel so SFTP sessions are cleaned up.
setSftpHostForTab(prev => {
const next = new Map(prev);
if (isClosing) {
next.delete(tabId);
} else {
next.set(tabId, host);
}
return next;
});
setSftpInitialLocationForTab(prev => {
const next = new Map(prev);
if (initialPath) {
next.set(tabId, { hostId: host.id, path: initialPath });
} else {
next.delete(tabId);
}
return next;
});
setSftpPendingUploadsForTab(prev => {
const next = new Map(prev);
if (isClosing || !pendingUploadEntries?.length) {
// Clear any stale pending upload on close or when opening without new files
next.delete(tabId);
} else {
next.set(tabId, {
requestId: crypto.randomUUID(),
hostId: host.id,
connectionKey: buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
targetPath: initialPath,
entries: pendingUploadEntries,
});
}
return next;
});
}, []);
const handlePendingUploadHandled = useCallback((tabId: string, requestId: string) => {
setSftpPendingUploadsForTab(prev => {
const current = prev.get(tabId);
if (!current || current.requestId !== requestId) {
return prev;
}
const next = new Map(prev);
next.delete(tabId);
return next;
});
}, []);
// Side panel resize handler
const handleSidePanelResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
sftpResizingRef.current = true;
const startX = e.clientX;
const startWidth = sidePanelWidth;
const onMouseMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
const newWidth = Math.max(200, Math.min(600, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
setSidePanelWidth(newWidth);
};
const onMouseUp = () => {
sftpResizingRef.current = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, [sidePanelWidth, sidePanelPosition]);
// Pre-compute host lookup map for O(1) access
const hostMap = useMemo(() => {
@@ -226,6 +428,89 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return map;
}, [sessions, hostMap]);
const validTerminalTabIds = useMemo(() => {
const ids = new Set<string>();
for (const session of sessions) ids.add(session.id);
for (const workspace of workspaces) ids.add(workspace.id);
return ids;
}, [sessions, workspaces]);
const onSplitSessionRef = useRef(onSplitSession);
onSplitSessionRef.current = onSplitSession;
const splitHorizontalHandlersRef = useRef<Map<string, () => void>>(new Map());
const splitVerticalHandlersRef = useRef<Map<string, () => void>>(new Map());
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
for (const [id] of splitHorizontalHandlersRef.current) {
if (!validSessionIds.has(id)) {
splitHorizontalHandlersRef.current.delete(id);
}
}
for (const [id] of splitVerticalHandlersRef.current) {
if (!validSessionIds.has(id)) {
splitVerticalHandlersRef.current.delete(id);
}
}
for (const session of sessions) {
if (!splitHorizontalHandlersRef.current.has(session.id)) {
splitHorizontalHandlersRef.current.set(session.id, () => {
onSplitSessionRef.current?.(session.id, 'horizontal');
});
}
if (!splitVerticalHandlersRef.current.has(session.id)) {
splitVerticalHandlersRef.current.set(session.id, () => {
onSplitSessionRef.current?.(session.id, 'vertical');
});
}
}
}, [sessions]);
const onToggleWorkspaceViewModeRef = useRef(onToggleWorkspaceViewMode);
onToggleWorkspaceViewModeRef.current = onToggleWorkspaceViewMode;
const workspaceFocusHandlersRef = useRef<Map<string, () => void>>(new Map());
const onToggleBroadcastRef = useRef(onToggleBroadcast);
onToggleBroadcastRef.current = onToggleBroadcast;
const workspaceBroadcastHandlersRef = useRef<Map<string, () => void>>(new Map());
useEffect(() => {
const validWorkspaceIds = new Set(workspaces.map((workspace) => workspace.id));
for (const [id] of workspaceFocusHandlersRef.current) {
if (!validWorkspaceIds.has(id)) {
workspaceFocusHandlersRef.current.delete(id);
}
}
for (const [id] of workspaceBroadcastHandlersRef.current) {
if (!validWorkspaceIds.has(id)) {
workspaceBroadcastHandlersRef.current.delete(id);
}
}
for (const workspace of workspaces) {
if (!workspaceFocusHandlersRef.current.has(workspace.id)) {
workspaceFocusHandlersRef.current.set(workspace.id, () => {
onToggleWorkspaceViewModeRef.current?.(workspace.id);
});
}
if (!workspaceBroadcastHandlersRef.current.has(workspace.id)) {
workspaceBroadcastHandlersRef.current.set(workspace.id, () => {
onToggleBroadcastRef.current?.(workspace.id);
});
}
}
}, [workspaces]);
useEffect(() => {
setSidePanelOpenTabs(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
}, [validTerminalTabIds]);
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
if (!workspace) return {} as Record<string, WorkspaceRect>;
const wTotal = size?.width || 1;
@@ -435,6 +720,192 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusMode = activeWorkspace?.viewMode === 'focus';
const focusedSessionId = activeWorkspace?.focusedSessionId;
// Resolve the SFTP host for the current tab.
// Uses the stored host from when the user opened SFTP, but updates when
// the focused session changes in workspace mode.
const sftpActiveHost = useMemo((): Host | null => {
if (!isSftpOpenForCurrentTab || !activeTabId) return null;
// For workspace: follow focus
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
}
// For solo session: use stored host (from when SFTP was opened)
return sftpHostForTab.get(activeTabId) ?? null;
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, focusedSessionId, sessionHostsMap, sftpHostForTab]);
// Keep sftpHostForTab in sync with focus changes in workspace mode
// so that the toggle check uses the currently displayed host.
useEffect(() => {
if (!activeTabId || !sftpActiveHost) return;
if (sidePanelOpenTabs.get(activeTabId) !== 'sftp') return;
const stored = sftpHostForTab.get(activeTabId);
if (stored?.id === sftpActiveHost.id
&& stored?.hostname === sftpActiveHost.hostname
&& stored?.port === sftpActiveHost.port
&& stored?.protocol === sftpActiveHost.protocol) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(activeTabId, sftpActiveHost);
return next;
});
}, [activeTabId, sftpActiveHost, sidePanelOpenTabs, sftpHostForTab]);
const mountedSftpTabIds = useMemo(
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
);
// Get the focused terminal's current working directory
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return null;
try {
const result = await terminalBackend.getSessionPwd(sessionId);
return result.success && result.cwd ? result.cwd : null;
} catch {
return null;
}
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
// Close the entire side panel for the current tab
const handleCloseSidePanel = useCallback(() => {
if (!activeTabId) return;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
// Always clean up SFTP state (it may be mounted in the background
// while scripts/theme tab was active)
setSftpHostForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setSftpPendingUploadsForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
setSftpInitialLocationForTab(prev => {
const next = new Map(prev);
next.delete(activeTabId);
return next;
});
}, [activeTabId]);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
if (!activeTabId) return;
const currentPanel = sidePanelOpenTabsRef.current.get(activeTabId);
// If already on this tab, do nothing — user must click X to close
if (currentPanel === tab) return;
// If switching to SFTP and no host is stored yet, resolve it
if (tab === 'sftp' && !sftpHostForTabRef.current.has(activeTabId)) {
let host: Host | null = null;
if (activeWorkspace && focusedSessionId) {
host = sessionHostsMap.get(focusedSessionId) ?? null;
} else if (activeSession) {
host = sessionHostsMap.get(activeSession.id) ?? null;
}
if (!host) return;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(activeTabId, host);
return next;
});
}
// Note: When switching away from SFTP, we keep the SFTP host state
// so the SftpSidePanel stays mounted (hidden) and preserves connections.
// SFTP state is only cleaned up when the panel is fully closed.
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(activeTabId, tab);
return next;
});
}, [activeTabId, activeWorkspace, focusedSessionId, activeSession, sessionHostsMap]);
// Toggle SFTP from activity bar header
const handleToggleSftpFromBar = useCallback(() => {
handleSwitchSidePanelTab('sftp');
}, [handleSwitchSidePanelTab]);
// Open scripts side panel (called from Terminal toolbar)
const handleOpenScripts = useCallback(() => {
handleSwitchSidePanelTab('scripts');
}, [handleSwitchSidePanelTab]);
// Open theme side panel (called from Terminal toolbar)
const handleOpenTheme = useCallback(() => {
handleSwitchSidePanelTab('theme');
}, [handleSwitchSidePanelTab]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return;
const payload = `${command}\r`;
terminalBackend.writeToSession(sessionId, payload);
// Re-focus the terminal so the user can interact immediately
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
// Resolve theme change handler for the focused session
const focusedHost = useMemo((): Host | null => {
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? null;
}
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? null;
}
return null;
}, [activeWorkspace, focusedSessionId, activeSession, sessionHostsMap]);
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
if (isFocusedHostLocal) {
onUpdateTerminalThemeId?.(themeId);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, theme: themeId });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontSize: newFontSize });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
// Current theme/font/size for the focused session (for ThemeSidePanel)
const focusedThemeId = focusedHost?.theme ?? terminalTheme.id;
const focusedFontFamilyId = focusedHost?.fontFamily ?? terminalFontFamilyId;
const focusedFontSize = focusedHost?.fontSize ?? fontSize;
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
@@ -620,48 +1091,209 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
className="absolute inset-0 bg-background flex flex-col"
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
>
<div className="flex-1 flex min-h-0 relative">
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0) && (
<>
<div
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
className={cn(
"flex-shrink-0 h-full relative z-20",
)}
>
{isSidePanelOpenForCurrentTab && (
<div
className={cn(
"absolute top-0 h-full w-2 cursor-ew-resize z-30",
sidePanelPosition === 'left' ? "right-[-3px]" : "left-[-3px]",
)}
onMouseDown={handleSidePanelResizeStart}
/>
)}
<div
className={cn(
"h-full flex flex-col overflow-hidden",
!isSidePanelOpenForCurrentTab && "pointer-events-none",
)}
>
{isSidePanelOpenForCurrentTab && (
<div className="flex h-8 items-center px-1.5 py-0.5 flex-shrink-0 gap-0.5">
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0",
activeSidePanelTab === 'sftp'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
onClick={handleToggleSftpFromBar}
title="SFTP"
>
<FolderTree size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0",
activeSidePanelTab === 'scripts'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
onClick={handleOpenScripts}
title="Scripts"
>
<Zap size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0",
activeSidePanelTab === 'theme'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
onClick={handleOpenTheme}
title="Theme"
>
<Palette size={14} />
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0 text-muted-foreground",
"hover:bg-transparent hover:text-foreground",
)}
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
>
{sidePanelPosition === 'left' ? <PanelRight size={14} /> : <PanelLeft size={14} />}
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 rounded-md p-0 text-muted-foreground",
"hover:bg-transparent hover:text-foreground",
)}
onClick={handleCloseSidePanel}
title="Close panel"
>
<X size={14} />
</Button>
</div>
)}
<div className="flex-1 min-h-0 relative">
{/* SFTP sub-panel */}
{mountedSftpTabIds.map((tabId) => {
const isVisibleSftpPanel = activeTabId === tabId && activeSidePanelTab === 'sftp';
return (
<SftpSidePanel
key={tabId}
hosts={hosts}
keys={keys}
identities={identities}
updateHosts={updateHosts}
activeHost={isVisibleSftpPanel ? sftpActiveHost : null}
initialLocation={
isVisibleSftpPanel
? (sftpInitialLocationForTab.get(tabId) ?? null)
: null
}
showWorkspaceHostHeader={isVisibleSftpPanel && !!activeWorkspace}
isVisible={isVisibleSftpPanel}
renderOverlays={isVisibleSftpPanel}
pendingUpload={sftpPendingUploadsForTab.get(tabId) ?? null}
onPendingUploadHandled={(requestId) => handlePendingUploadHandled(tabId, requestId)}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={isVisibleSftpPanel ? sftpAutoSync : false}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
/>
);
})}
{/* Scripts sub-panel */}
{activeSidePanelTab === 'scripts' && (
<div className="absolute inset-0 z-10">
<ScriptsSidePanel
snippets={snippets}
packages={snippetPackages}
onSnippetClick={handleSnippetClickForFocusedSession}
/>
</div>
)}
{/* Theme sub-panel */}
{activeSidePanelTab === 'theme' && (
<div className="absolute inset-0 z-10">
<ThemeSidePanel
currentThemeId={focusedThemeId}
currentFontFamilyId={focusedFontFamilyId}
currentFontSize={focusedFontSize}
onThemeChange={handleThemeChangeForFocusedSession}
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
/>
</div>
)}
</div>
</div>
</div>
</>
)}
{/* Focus mode sidebar */}
{isFocusMode && renderFocusModeSidebar()}
{draggingSessionId && !isFocusMode && (
<div
ref={workspaceOverlayRef}
className="absolute inset-0 z-30"
onDragOver={(e) => {
if (isFocusMode) return;
if (!e.dataTransfer.types.includes('session-id')) return;
e.preventDefault();
e.stopPropagation();
const hint = computeSplitHint(e);
setDropHint(hint);
}}
onDragLeave={(e) => {
if (!e.dataTransfer.types.includes('session-id')) return;
setDropHint(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
handleWorkspaceDrop(e);
}}
>
{dropHint && (
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
style={{
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
}}
/>
</div>
)}
</div>
)}
<div ref={workspaceInnerRef} className={cn("absolute overflow-hidden", isFocusMode ? "left-56 right-0 top-0 bottom-0" : "inset-0")}>
<div ref={workspaceInnerRef} className="overflow-hidden relative flex-1">
{draggingSessionId && !isFocusMode && (
<div
ref={workspaceOverlayRef}
className="absolute inset-0 z-30"
onDragOver={(e) => {
if (isFocusMode) return;
if (!e.dataTransfer.types.includes('session-id')) return;
e.preventDefault();
e.stopPropagation();
const hint = computeSplitHint(e);
setDropHint(hint);
}}
onDragLeave={(e) => {
if (!e.dataTransfer.types.includes('session-id')) return;
setDropHint(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
handleWorkspaceDrop(e);
}}
>
{dropHint && (
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
style={{
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
}}
/>
</div>
)}
</div>
)}
{sessions.map(session => {
// Use pre-computed host to avoid creating new objects on every render
const host = sessionHostsMap.get(session.id)!;
@@ -694,6 +1326,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Check if this pane is the focused one in the workspace
const isFocusedPane = inActiveWorkspace && !isFocusMode && session.id === focusedSessionId;
const workspaceFocusHandler = activeWorkspace
? workspaceFocusHandlersRef.current.get(activeWorkspace.id)
: undefined;
const workspaceBroadcastHandler = activeWorkspace
? workspaceBroadcastHandlersRef.current.get(activeWorkspace.id)
: undefined;
const splitHorizontalHandler = splitHorizontalHandlersRef.current.get(session.id);
const splitVerticalHandler = splitVerticalHandlersRef.current.get(session.id);
return (
<div
@@ -733,12 +1373,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionId={session.id}
startupCommand={session.startupCommand}
serialConfig={session.serialConfig}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onHotkeyAction={onHotkeyAction}
onOpenSftp={handleOpenSftp}
onOpenScripts={handleOpenScripts}
onOpenTheme={handleOpenTheme}
onCloseSession={handleCloseSession}
onStatusChange={handleStatusChange}
onSessionExit={handleSessionExit}
@@ -747,12 +1387,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateHost={handleUpdateHost}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleCommandExecuted}
onExpandToFocus={inActiveWorkspace && !isFocusMode && activeWorkspace ? () => onToggleWorkspaceViewMode?.(activeWorkspace.id) : undefined}
onSplitHorizontal={onSplitSession ? () => onSplitSession(session.id, 'horizontal') : undefined}
onSplitVertical={onSplitSession ? () => onSplitSession(session.id, 'vertical') : undefined}
onExpandToFocus={inActiveWorkspace && !isFocusMode ? workspaceFocusHandler : undefined}
onSplitHorizontal={onSplitSession ? splitHorizontalHandler : undefined}
onSplitVertical={onSplitSession ? splitVerticalHandler : undefined}
isBroadcastEnabled={inActiveWorkspace && activeWorkspace ? isBroadcastEnabled?.(activeWorkspace.id) : false}
onToggleBroadcast={inActiveWorkspace && activeWorkspace ? () => onToggleBroadcast?.(activeWorkspace.id) : undefined}
onToggleComposeBar={inActiveWorkspace ? () => setIsComposeBarOpen(prev => !prev) : undefined}
onToggleBroadcast={inActiveWorkspace ? workspaceBroadcastHandler : undefined}
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
/>
@@ -843,6 +1483,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.sessions === next.sessions &&
prev.workspaces === next.workspaces &&
prev.draggingSessionId === next.draggingSessionId &&
@@ -851,11 +1492,18 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onUpdateHost === next.onUpdateHost &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession
prev.onSplitSession === next.onSplitSession &&
prev.identities === next.identities
);
};

View File

@@ -1,11 +1,13 @@
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, X } from 'lucide-react';
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { normalizeDistroId } from '../domain/host';
import { cn } from '../lib/utils';
import { TerminalSession, Workspace } from '../types';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
@@ -16,6 +18,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
interface TopTabsProps {
theme: 'dark' | 'light';
hosts: Host[];
sessions: TerminalSession[];
orphanSessions: TerminalSession[];
workspaces: Workspace[];
@@ -38,6 +41,82 @@ interface TopTabsProps {
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
}
// Detect local OS for local terminal tab icons
const localOsId = (() => {
if (typeof navigator === 'undefined') return 'linux';
const ua = navigator.userAgent;
if (/Mac/i.test(ua)) return 'macos';
if (/Win/i.test(ua)) return 'windows';
return 'linux';
})();
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
const iconSize = "h-2.5 w-2.5";
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
// Serial protocol → USB icon
if (protocol === 'serial' || host?.protocol === 'serial') {
return (
<div className={cn(boxBase, "bg-amber-500/15 text-amber-500")}>
<Usb className={iconSize} />
</div>
);
}
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
const logo = DISTRO_LOGOS[localOsId];
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
if (logo) {
return (
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={localOsId}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
);
}
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<TerminalSquare className={iconSize} />
</div>
);
}
// Try distro logo with brand background color
if (host) {
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
const logo = DISTRO_LOGOS[distro];
if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
return (
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={host.distro || host.os}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
);
}
}
// Fallback: generic server icon for remote, terminal for unknown
if (host && host.protocol !== 'local') {
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<Server className={iconSize} />
</div>
);
}
return <TerminalSquare className={fallbackIcon} />;
});
SessionTabIcon.displayName = 'SessionTabIcon';
const sessionStatusDot = (status: TerminalSession['status']) => {
const tone = status === 'connected'
? "bg-emerald-400"
@@ -56,12 +135,19 @@ const WindowControls: React.FC = memo(() => {
// Check initial maximized state
fetchIsMaximized().then(v => setIsMaximized(!!v));
// Listen for window resize to update maximized state
// Listen for window resize to update maximized state (debounced to avoid IPC storm)
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const handleResize = () => {
fetchIsMaximized().then(v => setIsMaximized(!!v));
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fetchIsMaximized().then(v => setIsMaximized(!!v));
}, 200);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (resizeTimer) clearTimeout(resizeTimer);
};
}, [fetchIsMaximized]);
const handleMinimize = () => {
@@ -78,17 +164,17 @@ const WindowControls: React.FC = memo(() => {
};
return (
<div className="flex items-center app-drag">
<div className="flex items-center app-drag h-full">
<button
onClick={handleMinimize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
@@ -101,7 +187,7 @@ const WindowControls: React.FC = memo(() => {
</button>
<button
onClick={handleClose}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
title="Close"
>
<X size={16} />
@@ -113,6 +199,7 @@ WindowControls.displayName = 'WindowControls';
const TopTabsInner: React.FC<TopTabsProps> = ({
theme,
hosts,
sessions,
orphanSessions,
workspaces,
@@ -235,6 +322,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return map;
}, [logViews]);
const hostMap = useMemo(() => {
const map = new Map<string, Host>();
for (const h of hosts) map.set(h.id, h);
return map;
}, [hosts]);
// Pre-compute session counts per workspace for O(1) access
const workspacePaneCounts = useMemo(() => {
const counts = new Map<string, number>();
@@ -376,26 +469,29 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
activeTabId === session.id ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
activeTabId === session.id
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(activeTabId === session.id ? { borderColor: 'hsl(var(--accent))' } : {})
}}
style={shiftStyle}
>
{/* Active tab top accent line */}
{activeTabId === session.id && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<TerminalSquare size={14} className={cn("shrink-0", activeTabId === session.id ? "text-accent" : "text-muted-foreground")} />
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
</div>
@@ -445,23 +541,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(isActive ? { borderColor: 'hsl(var(--accent))' } : {})
}}
style={shiftStyle}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
@@ -495,12 +594,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
data-tab-id={logView.id}
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-colors duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isActive ? { borderColor: 'hsl(var(--accent))' } : {}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
<span className="truncate">
@@ -536,36 +640,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
className="relative w-full bg-secondary border-b border-border/60 app-drag"
className="relative w-full bg-secondary app-drag"
style={dragRegionNoSelect}
onDoubleClick={handleTitleBarDoubleClick}
>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className="h-8 flex items-center gap-2 app-drag"
className="h-9 flex items-end gap-0 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
<div
onClick={() => onSelectTab('vault')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isVaultActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isVaultActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isVaultActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
<Shield size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isSftpActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isSftpActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isSftpActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
<Folder size={14} /> SFTP
</div>
</div>
@@ -594,7 +703,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Scrollable container */}
<div
ref={tabsContainerRef}
className="flex items-center gap-2 overflow-x-auto scrollbar-none app-drag max-w-full"
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{renderOrderedTabs()}
@@ -603,7 +712,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
@@ -611,7 +720,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Button>
)}
{/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-6 app-drag flex-shrink-0" style={dragRegionStyle} />
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
</div>
{/* Right fade mask */}
@@ -628,7 +737,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
onClick={onOpenQuickSwitcher}
title="More tabs"
>
@@ -637,7 +746,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
)}
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag" style={dragRegionStyle}>
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
<Bell size={16} />
</Button>
@@ -653,9 +762,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Button>
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <WindowControls />}
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
</div>
</div>
);
@@ -665,6 +774,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
return (
prev.theme === next.theme &&
prev.hosts === next.hosts &&
prev.sessions === next.sessions &&
prev.orphanSessions === next.orphanSessions &&
prev.workspaces === next.workspaces &&

View File

@@ -152,7 +152,7 @@ const TrayPanelContent: React.FC = () => {
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
[keys],
);

View File

@@ -715,6 +715,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return root;
}, [hosts, customGroups]);
// Generate all possible group paths from the tree (including all intermediate nodes)
const allGroupPaths = useMemo(() => {
const paths = new Set<string>();
const traverse = (nodes: Record<string, GroupNode>) => {
Object.values(nodes).forEach((node) => {
if (node.path) {
paths.add(node.path);
}
if (node.children) {
traverse(node.children);
}
});
};
// Traverse the tree
traverse(buildGroupTree);
return Array.from(paths).sort();
}, [buildGroupTree]);
const findGroupNode = (path: string | null): GroupNode | null => {
if (!path)
@@ -879,6 +899,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return root;
}, [treeViewHosts, customGroups]);
// Helper function to recursively count all hosts in a node and its children
const countAllHostsInNode = (node: GroupNode): number => {
let count = node.hosts.length;
if (node.children) {
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
}
return count;
};
// Create tree view specific group tree that excludes ungrouped hosts
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
return (Object.values(buildTreeViewGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name));
@@ -1718,7 +1749,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: node.hosts.length })}
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
</div>
</div>
</div>
@@ -2270,12 +2301,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
initialData={editingHost}
availableKeys={keys}
identities={identities}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
allHosts={hosts}
@@ -2310,12 +2336,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
groups={allGroupPaths}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),

View File

@@ -5,6 +5,7 @@ import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload"
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
@@ -44,7 +45,10 @@ export default function SettingsSyncTab(props: {
}));
}
}
return buildSyncPayload(vault, effectiveRules);
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
}, [vault, portForwardingRules]);
const onApplyPayload = useCallback(

View File

@@ -1,11 +1,10 @@
import React, { useEffect, useState } from "react";
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload, X } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
@@ -55,6 +54,7 @@ interface SftpModalHeaderProps {
onToggleShowHiddenFiles: () => void;
onUpdateHost?: (host: Host) => void;
onNavigateToBookmark?: (path: string) => void;
onClose?: () => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
@@ -97,6 +97,7 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
onToggleShowHiddenFiles,
onUpdateHost,
onNavigateToBookmark,
onClose,
}) => {
// Delay tooltip activation to prevent flickering when modal opens
const [tooltipsReady, setTooltipsReady] = useState(false);
@@ -126,24 +127,35 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
return (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
size="sm"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
<div className="text-sm font-semibold">
{host.label}
</DialogTitle>
</div>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
</DialogHeader>
</div>
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">

View File

@@ -89,6 +89,7 @@ export const useSftpModalSession = ({
const sftpIdRef = useRef<string | null>(null);
const closingPromiseRef = useRef<Promise<void> | null>(null);
const initializedRef = useRef(false);
const initializingRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const localHomeRef = useRef<string | null>(null);
@@ -316,92 +317,109 @@ export const useSftpModalSession = ({
if (open) {
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
initializedRef.current = true;
initializingRef.current = true;
lastInitialPathRef.current = initialPath;
onClearSelection();
setLoading(true);
if (isLocalSession) {
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
} finally {
setLoading(false);
initializingRef.current = false;
}
})();
return;
}
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
}
}
} finally {
initializingRef.current = false;
}
})();
return;
}
void loadFiles(currentPath);
// Skip redundant loadFiles while async initialization is still in flight.
// Without this guard, dependency changes (e.g. loadFiles recreation from
// files.length change) can re-trigger this effect and call loadFiles with
// the stale currentPath before the initialization IIFE has resolved and
// updated currentPathRef — causing uploads to target the wrong directory.
if (!initializingRef.current) {
void loadFiles(currentPath);
}
} else {
loadSeqRef.current += 1;
initializedRef.current = false;
initializingRef.current = false;
}
}, [
closeSftpSession,

View File

@@ -2,10 +2,10 @@ import React from "react";
import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import { Button } from "../ui/button";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog, SftpTransferItem } from "./index";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
import { SftpTransferQueue } from "./SftpTransferQueue";
type SftpState = ReturnType<typeof useSftpState>;
@@ -13,6 +13,7 @@ interface SftpOverlaysProps {
hosts: Host[];
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
showTransferQueue?: boolean;
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
@@ -46,6 +47,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
hosts,
sftp,
visibleTransfers,
showTransferQueue = true,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
@@ -98,49 +100,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
onSelectHost={handleHostSelectRight}
/>
{/* Transfer status area - shows folder uploads and file transfers */}
{sftp.transfers.length > 0 && (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
<span className="font-medium">
Transfers
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({sftp.activeTransfersCount} active)
</span>
)}
</span>
{sftp.transfers.some(
(t) => t.status === "completed" || t.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={sftp.clearCompletedTransfers}
>
Clear completed
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
// External uploads use a different cancel mechanism
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
/>
))}
</div>
</div>
{showTransferQueue && (
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} />
)}
<SftpConflictDialog

View File

@@ -1,9 +1,11 @@
import React from "react";
import { Bookmark, Check, ChevronLeft, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
import { cn } from "../../lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
import { SftpBreadcrumb } from "./index";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
@@ -12,7 +14,6 @@ import type { SftpBookmark } from "../../domain/models";
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onNavigateUp: () => void;
onNavigateTo: (path: string) => void;
onSetFilter: (value: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
@@ -49,12 +50,17 @@ interface SftpPaneToolbarProps {
onDeleteBookmark: (id: string) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
}
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
// bookmark ~20px, padding ~16px. Collapse early so the breadcrumb
// always gets at least ~200px of space.
const COLLAPSE_WIDTH = 400;
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
t,
pane,
onNavigateUp,
onNavigateTo,
onSetFilter,
onSetFilenameEncoding,
@@ -90,308 +96,486 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
onDeleteBookmark,
showHiddenFiles,
onToggleShowHiddenFiles,
}) => (
<>
{/* Toolbar - always visible when connected */}
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onNavigateUp}
title={t("sftp.goUp")}
>
<ChevronLeft size={12} />
</Button>
onGoToTerminalCwd,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
)}
// Observe the overall toolbar width to decide whether to collapse action buttons
useEffect(() => {
const el = outerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setCollapsed(entry.contentRect.width < COLLAPSE_WIDTH);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
{/* Bookmark button with dropdown */}
<Popover>
<PopoverTrigger asChild>
const handleNewFolder = useCallback(() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}, [setNewFolderName, setShowNewFolderDialog]);
const handleNewFile = useCallback(() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}, [getNextUntitledName, pane.files, setNewFileName, setFileNameError, setShowNewFileDialog]);
const handleToggleFilter = useCallback(() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}, [showFilterBar, setShowFilterBar, filterInputRef]);
const isRemote = !pane.connection?.isLocal;
// Buttons that always remain visible (not collapsed)
const pinnedButtons = (
<>
{onGoToTerminalCwd && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
title={isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
onClick={(e) => {
// If not bookmarked, toggle directly instead of opening popover
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
e.preventDefault();
onToggleBookmark();
}
}}
className="h-6 w-6"
onClick={onGoToTerminalCwd}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
<TerminalSquare size={14} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="p-2 border-b border-border/40">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start text-xs h-7"
onClick={onToggleBookmark}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</Button>
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
title={bm.path}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</div>
) : (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</TooltipTrigger>
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={handleToggleFilter}
>
<Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.filter")}</TooltipContent>
</Tooltip>
</>
);
// Collapsible action buttons (shown inline when space allows)
const collapsibleButtons = (
<>
{isRemote && (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="end">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNewFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNewFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.refresh")}</TooltipContent>
</Tooltip>
</>
);
<div className="ml-auto flex items-center gap-0.5">
{!pane.connection?.isLocal && (
<Popover>
<PopoverTrigger asChild>
// Overflow dropdown menu items (same collapsible actions as menu items)
const overflowMenuItems = (
<div className="flex flex-col min-w-[140px]">
{isRemote && (
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left">
<Languages size={14} className="shrink-0" />
{t("sftp.encoding.label")}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start" side="right">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={handleNewFolder}
>
<FolderPlus size={14} className="shrink-0" />
{t("sftp.newFolder")}
</button>
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={handleNewFile}
>
<FilePlus size={14} className="shrink-0" />
{t("sftp.newFile")}
</button>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
showHiddenFiles && "text-primary",
)}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} className="shrink-0" /> : <Eye size={14} className="shrink-0" />}
{t("settings.sftp.showHiddenFiles")}
</button>
<button
className="flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn("shrink-0", (pane.loading || pane.reconnecting) && "animate-spin")}
/>
{t("common.refresh")}
</button>
</div>
);
return (
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
{/* Toolbar - always visible when connected */}
<div ref={outerRef} className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
)}
{/* Bookmark button with dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
onClick={(e) => {
// If not bookmarked, toggle directly instead of opening popover
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
e.preventDefault();
onToggleBookmark();
}
}}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-64 p-0" align="start">
<div className="p-2 border-b border-border/40">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start text-xs h-7"
onClick={onToggleBookmark}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</Button>
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
title={bm.path}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</div>
) : (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</PopoverContent>
</Popover>
{/* Action buttons area - observed for overflow */}
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{collapsed ? (
<>
{pinnedButtons}
<Dropdown>
<Tooltip>
<TooltipTrigger asChild>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<MoreHorizontal size={14} />
</Button>
</DropdownTrigger>
</TooltipTrigger>
<TooltipContent>{t("common.more")}</TooltipContent>
</Tooltip>
<DropdownContent align="end">
{overflowMenuItems}
</DropdownContent>
</Dropdown>
</>
) : (
<>
{pinnedButtons}
{collapsibleButtons}
</>
)}
</div>
</div>
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={t("sftp.encoding.label")}
>
<Languages size={14} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="end">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}}
title={t("sftp.newFolder")}
>
<FolderPlus size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}
title={t("sftp.newFile")}
>
<FilePlus size={14} />
</Button>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
title={t("settings.sftp.showHiddenFiles")}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}}
title={t("sftp.filter")}
>
<Search size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
title={t("common.refresh")}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</div>
</div>
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
}}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.close")}</TooltipContent>
</Tooltip>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
setShowFilterBar(false);
}}
title={t("common.close")}
>
<X size={14} />
</Button>
</div>
)}
</>
);
)}
</TooltipProvider>
);
};

View File

@@ -58,6 +58,7 @@ interface SftpPaneViewProps {
showHeader?: boolean;
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
@@ -66,6 +67,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showHeader = true,
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
}) => {
const isActive = true;
@@ -299,7 +301,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<SftpPaneToolbar
t={t}
pane={pane}
onNavigateUp={callbacks.onNavigateUp}
onNavigateTo={callbacks.onNavigateTo}
onSetFilter={callbacks.onSetFilter}
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
@@ -335,6 +336,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onDeleteBookmark={deleteBookmark}
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
/>
<SftpPaneFileList

View File

@@ -12,6 +12,7 @@ import {
XCircle,
} from 'lucide-react';
import React, { memo } from 'react';
import { getParentPath } from '../../application/state/sftp/utils';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
@@ -22,9 +23,18 @@ interface SftpTransferItemProps {
onCancel: () => void;
onRetry: () => void;
onDismiss: () => void;
canRevealTarget?: boolean;
onRevealTarget?: () => void;
}
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel, onRetry, onDismiss }) => {
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
task,
onCancel,
onRetry,
onDismiss,
canRevealTarget = false,
onRevealTarget,
}) => {
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Calculate remaining time from backend-reported sliding-window speed
@@ -49,33 +59,43 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
: '';
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
<div className="h-6 w-6 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={14} className="animate-spin text-primary" />}
const details = (
<>
<div className="h-5 w-5 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={12} className="animate-spin text-primary" />}
{task.status === 'pending' && (task.isDirectory
? <FolderUp size={14} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={14} className="text-muted-foreground animate-bounce" />
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />
)}
{task.status === 'completed' && <CheckCircle2 size={14} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={14} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={14} className="text-muted-foreground" />}
{task.status === 'completed' && <CheckCircle2 size={12} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={12} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={12} className="text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm truncate font-medium">{task.fileName}</span>
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && speedFormatted && (
<span className="text-xs text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && remainingFormatted && (
<span className="text-xs text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
</div>
<div
className={cn(
"text-[9px] mt-0.5 truncate",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
title={targetDirectoryPath}
>
{targetDirectoryPath}
</div>
{(task.status === 'transferring' || task.status === 'pending') && (
<div className="flex items-center gap-2 mt-1.5">
<div className="flex-1 h-2 bg-secondary/80 rounded-full overflow-hidden">
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-secondary/80 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full relative overflow-hidden",
@@ -100,39 +120,56 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
)}
</div>
</div>
<span className="text-[11px] text-muted-foreground shrink-0 min-w-[40px] text-right font-mono">
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
{task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`}
</span>
</div>
)}
{task.status === 'transferring' && bytesDisplay && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
<div className="text-[9px] text-muted-foreground mt-0.5 font-mono">
{bytesDisplay}
</div>
)}
{task.status === 'completed' && bytesDisplay && (
<div className="text-[10px] text-green-600 mt-0.5">
<div className="text-[9px] text-green-600 mt-0.5">
Completed - {bytesDisplay}
</div>
)}
{task.status === 'failed' && task.error && (
<span className="text-xs text-destructive">{task.error}</span>
<span className="text-[10px] text-destructive">{task.error}</span>
)}
</div>
</>
);
return (
<div className="flex items-center gap-2.5 px-3 py-2 bg-background/60 border-t border-border/40 backdrop-blur-sm">
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex flex-1 min-w-0 items-center gap-2.5 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
title="Open transfer destination"
>
{details}
</button>
) : (
details
)}
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRetry} title="Retry">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onDismiss} title="Dismiss">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
)}
@@ -158,6 +195,8 @@ const arePropsEqual = (
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
if (prev.targetPath !== next.targetPath) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
// For transferring status, allow frequent re-renders for smooth progress bar
if (next.status === 'transferring') {

View File

@@ -0,0 +1,79 @@
import React from "react";
import { Button } from "../ui/button";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { useSftpState } from "../../application/state/useSftpState";
import type { TransferTask } from "../../types";
import { SftpTransferItem } from "./SftpTransferItem";
type SftpState = ReturnType<typeof useSftpState>;
interface SftpTransferQueueProps {
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
}
export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
sftp,
visibleTransfers,
canRevealTransferTarget,
onRevealTransferTarget,
}) => {
const { t } = useI18n();
if (sftp.transfers.length === 0) {
return null;
}
return (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/40">
<span className="font-medium">
{t("sftp.transfers")}
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({t("sftp.transfers.active", { count: sftp.activeTransfersCount })})
</span>
)}
</span>
{sftp.transfers.some(
(tr) => tr.status === "completed" || tr.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[11px]"
onClick={sftp.clearCompletedTransfers}
>
{t("sftp.transfers.clearCompleted")}
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
onRevealTarget={
onRevealTransferTarget
? () => {
void onRevealTransferTarget(task);
}
: undefined
}
/>
))}
</div>
</div>
);
};

View File

@@ -30,14 +30,12 @@ export const useSftpPaneVirtualList = ({
if (!container || !isActive) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf1 = window.requestAnimationFrame(update);
const raf2 = window.requestAnimationFrame(update);
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
window.cancelAnimationFrame(raf);
};
}, [isActive, sortedDisplayFiles.length]);

View File

@@ -119,6 +119,10 @@ export const useSftpViewFileOps = ({
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
/** Host ID at the time the file was opened, to prevent saving to wrong host.
* Uses hostId (not connectionId) because auto-reconnect after a transient
* disconnect generates a fresh connectionId for the same endpoint. */
hostId?: string;
} | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
@@ -148,7 +152,7 @@ export const useSftpViewFileOps = ({
try {
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath });
setTextEditorTarget({ file, side, fullPath, hostId: pane.connection.hostId });
const content = await sftpRef.current.readTextFile(side, fullPath);
@@ -242,6 +246,19 @@ export const useSftpViewFileOps = ({
async (content: string) => {
if (!textEditorTarget) return;
// Verify the SFTP connection hasn't switched to a different host.
// We check hostId (not connectionId) because auto-reconnect after a
// transient disconnect generates a fresh connectionId for the same
// endpoint. The auto-connect effect in SftpSidePanel blocks
// host-switching while the editor is open, so a hostId mismatch here
// reliably indicates a genuinely different endpoint.
const currentPane = textEditorTarget.side === "left"
? sftpRef.current.leftPane
: sftpRef.current.rightPane;
if (textEditorTarget.hostId && currentPane.connection?.hostId !== textEditorTarget.hostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
await sftpRef.current.writeTextFile(
textEditorTarget.side,
textEditorTarget.fullPath,

View File

@@ -2,7 +2,7 @@
* Terminal Connection Dialog
* Full connection overlay with host info, progress indicator, and auth/progress content
*/
import { User } from 'lucide-react';
import { Loader2, TerminalSquare, User } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
@@ -154,7 +154,11 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
)}>
{'>_'}
{isConnecting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<TerminalSquare size={14} />
)}
</div>
</div>
</div>

View File

@@ -5,28 +5,19 @@
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Snippet, Host } from '../../types';
import { Host } from '../../types';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import { ScrollArea } from '../ui/scroll-area';
import ThemeCustomizeModal from './ThemeCustomizeModal';
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
export interface TerminalToolbarProps {
status: 'connecting' | 'connected' | 'disconnected';
snippets: Snippet[];
host?: Host;
defaultThemeId: string;
defaultFontFamilyId: string;
defaultFontSize: number;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
isScriptsOpen: boolean;
setIsScriptsOpen: (open: boolean) => void;
onOpenSFTP: () => void;
onSnippetClick: (command: string) => void;
onOpenScripts: () => void;
onOpenTheme: () => void;
onUpdateHost?: (host: Host) => void;
showClose?: boolean;
onClose?: () => void;
@@ -43,18 +34,10 @@ export interface TerminalToolbarProps {
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
status,
snippets,
host,
defaultThemeId,
defaultFontFamilyId,
defaultFontSize,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
isScriptsOpen,
setIsScriptsOpen,
onOpenSFTP,
onSnippetClick,
onOpenScripts,
onOpenTheme,
onUpdateHost,
showClose,
onClose,
@@ -66,7 +49,6 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSetTerminalEncoding,
}) => {
const { t } = useI18n();
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
@@ -75,69 +57,45 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
const hidesSftp = isLocalTerminal || isSerialTerminal;
const currentThemeId = host?.theme || defaultThemeId;
const currentFontFamilyId = host?.fontFamily || defaultFontFamilyId;
const currentFontSize = host?.fontSize || defaultFontSize;
const handleThemeChange = (themeId: string) => {
if (isLocalTerminal) {
onUpdateTerminalThemeId?.(themeId);
return;
}
if (host && onUpdateHost) {
onUpdateHost({ ...host, theme: themeId });
}
};
const handleFontFamilyChange = (fontFamilyId: string) => {
if (isLocalTerminal) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (host && onUpdateHost) {
onUpdateHost({ ...host, fontFamily: fontFamilyId });
}
};
const handleFontSizeChange = (fontSize: number) => {
if (isLocalTerminal) {
onUpdateTerminalFontSize?.(fontSize);
return;
}
if (host && onUpdateHost) {
onUpdateHost({ ...host, fontSize });
}
};
return (
<>
<TooltipProvider delayDuration={500} skipDelayDuration={100} disableHoverableContent>
{!hidesSftp && (
<Button
variant="secondary"
size="icon"
className={buttonBase}
disabled={status !== 'connected'}
title={status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<FolderInput size={12} />
</Button>
)}
{isSSHSession && onSetTerminalEncoding && (
<Popover>
<PopoverTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.encoding")}
aria-label={t("terminal.toolbar.encoding")}
disabled={status !== 'connected'}
aria-label={t("terminal.toolbar.openSftp")}
onClick={onOpenSFTP}
>
<Languages size={12} />
<FolderInput size={12} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.openSftp") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
)}
{isSSHSession && onSetTerminalEncoding && (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.encoding")}
>
<Languages size={12} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.encoding")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["utf-8", "gb18030"] as const).map((enc) => (
<PopoverClose asChild key={enc}>
@@ -163,57 +121,35 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</Popover>
)}
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
<PopoverTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.scripts")}
aria-label={t("terminal.toolbar.scripts")}
onClick={onOpenScripts}
>
<Zap size={12} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="px-3 py-2 text-[10px] uppercase text-muted-foreground font-semibold bg-muted/30 border-b">
{t("terminal.toolbar.library")}
</div>
<ScrollArea className="h-64">
<div className="py-1">
{snippets.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground italic">
{t("terminal.toolbar.noSnippets")}
</div>
) : (
snippets.map((s) => (
<button
key={s.id}
onClick={() => onSnippetClick(s.command)}
className="w-full text-left px-3 py-2 text-xs hover:bg-accent transition-colors flex flex-col gap-0.5"
>
<span className="font-medium">{s.label}</span>
<span className="text-muted-foreground truncate font-mono text-[10px]">
{s.command}
</span>
</button>
))
)}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.scripts")}</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.terminalSettings")}
aria-label={t("terminal.toolbar.terminalSettings")}
onClick={() => setThemeModalOpen(true)}
>
<Palette size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.terminalSettings")}
onClick={onOpenTheme}
>
<Palette size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.terminalSettings")}</TooltipContent>
</Tooltip>
<HostKeywordHighlightPopover
host={host}
@@ -223,59 +159,57 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
buttonClassName={buttonBase}
/>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.composeBar")}
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
>
<TextCursorInput size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
>
<TextCursorInput size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.composeBar")}</TooltipContent>
</Tooltip>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.searchTerminal")}
aria-label={t("terminal.toolbar.searchTerminal")}
aria-pressed={isSearchOpen}
onClick={onToggleSearch}
>
<Search size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
aria-label={t("terminal.toolbar.searchTerminal")}
aria-pressed={isSearchOpen}
onClick={onToggleSearch}
>
<Search size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.searchTerminal")}</TooltipContent>
</Tooltip>
{showClose && onClose && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[color:var(--terminal-toolbar-fg)] hover:bg-transparent"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t("terminal.toolbar.closeSession")}
>
<X size={11} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-[color:var(--terminal-toolbar-fg)] hover:bg-transparent"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X size={11} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.closeSession")}</TooltipContent>
</Tooltip>
)}
<ThemeCustomizeModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
currentThemeId={currentThemeId}
currentFontFamilyId={currentFontFamilyId}
currentFontSize={currentFontSize}
onThemeChange={handleThemeChange}
onFontFamilyChange={handleFontFamilyChange}
onFontSizeChange={handleFontSizeChange}
onSave={() => {
// Trigger any necessary updates
}}
/>
</>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,430 @@
/**
* ThemeSidePanel - Theme/Font customization panel for the terminal side panel
*
* Adapted from ThemeCustomizeModal's left panel content.
* No preview - the actual terminal behind serves as a live preview.
* Changes apply in real-time.
*/
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
import { CustomThemeModal } from './CustomThemeModal';
import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models';
import { ScrollArea } from '../ui/scroll-area';
type TabType = 'theme' | 'font' | 'custom';
// Memoized theme item component
const ThemeItem = memo(({
theme,
isSelected,
onSelect,
onEdit,
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
onEdit?: (id: string) => void;
}) => (
<div
role="button"
tabIndex={0}
onClick={() => onSelect(theme.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors group cursor-pointer',
isSelected
? 'bg-accent/50'
: 'hover:bg-accent/50'
)}
>
{/* Color swatch */}
<div
className="w-6 h-6 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-0.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-0.5 w-1.5 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">
{theme.type}
{theme.isCustom && ' • custom'}
</div>
</div>
{onEdit && (
<div
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
>
<Pencil size={10} />
</div>
)}
{isSelected && !onEdit && (
<Check size={12} className="text-primary flex-shrink-0" />
)}
</div>
));
ThemeItem.displayName = 'ThemeItem';
// Memoized font item component
const FontItem = memo(({
font,
isSelected,
onSelect
}: {
font: TerminalFont;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(font.id)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
isSelected
? 'bg-accent/50'
: 'hover:bg-accent/50'
)}
>
<div className="flex-1 min-w-0">
<div
className="text-xs font-medium truncate"
style={{ fontFamily: font.family }}
>
{font.name}
</div>
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
</div>
{isSelected && (
<Check size={12} className="text-primary flex-shrink-0" />
)}
</button>
));
FontItem.displayName = 'FontItem';
interface ThemeSidePanelProps {
currentThemeId: string;
currentFontFamilyId: string;
currentFontSize: number;
onThemeChange: (themeId: string) => void;
onFontFamilyChange: (fontFamilyId: string) => void;
onFontSizeChange: (fontSize: number) => void;
isVisible?: boolean;
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
currentThemeId,
currentFontFamilyId,
currentFontSize,
onThemeChange,
onFontFamilyChange,
onFontSizeChange,
isVisible = true,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
const [isNewTheme, setIsNewTheme] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const allThemes = useMemo(
() => [...TERMINAL_THEMES, ...customThemes],
[customThemes]
);
const handleThemeSelect = useCallback((themeId: string) => {
setEditingTheme(null);
onThemeChange(themeId);
}, [onThemeChange]);
const handleFontSelect = useCallback((fontId: string) => {
onFontFamilyChange(fontId);
}, [onFontFamilyChange]);
const handleFontSizeChange = useCallback((delta: number) => {
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, currentFontSize + delta));
onFontSizeChange(newSize);
}, [currentFontSize, onFontSizeChange]);
const handleNewTheme = useCallback(() => {
const base = allThemes.find(t => t.id === currentThemeId) || TERMINAL_THEMES[0];
const newTheme: TerminalTheme = {
...base,
id: `custom-${Date.now()}`,
name: `${base.name} (Custom)`,
isCustom: true,
colors: { ...base.colors },
};
setEditingTheme(newTheme);
setIsNewTheme(true);
}, [currentThemeId, allThemes]);
const handleImportFile = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
const parsed = parseItermcolors(xml, name);
if (parsed) {
addTheme(parsed);
onThemeChange(parsed.id);
setActiveTab('theme');
} else {
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file.');
}
};
reader.readAsText(file);
e.target.value = '';
}, [addTheme, onThemeChange, t]);
const handleEditTheme = useCallback((themeId: string) => {
const theme = customThemes.find(t => t.id === themeId);
if (theme) {
setEditingTheme({ ...theme, colors: { ...theme.colors } });
setIsNewTheme(false);
}
}, [customThemes]);
const handleEditorDelete = useCallback((themeId: string) => {
deleteTheme(themeId);
if (currentThemeId === themeId) {
onThemeChange(TERMINAL_THEMES[0].id);
}
setEditingTheme(null);
setIsNewTheme(false);
}, [deleteTheme, currentThemeId, onThemeChange]);
if (!isVisible) return null;
const builtinThemes = TERMINAL_THEMES;
return (
<>
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Tab Bar */}
<div className="flex p-1.5 gap-0.5 shrink-0 border-b border-border/50">
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'theme'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
<button
onClick={() => setActiveTab('font')}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'font'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Type size={12} />
{t('terminal.themeModal.tab.font')}
</button>
<button
onClick={() => setActiveTab('custom')}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'custom'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
</button>
</div>
{/* List Content */}
<ScrollArea className="flex-1 min-h-0">
<div className="py-1">
{activeTab === 'theme' && (
<div>
{builtinThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id && !editingTheme}
onSelect={handleThemeSelect}
/>
))}
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id && !editingTheme}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
{activeTab === 'font' && (
<div>
{availableFonts.map(font => (
<FontItem
key={font.id}
font={font}
isSelected={currentFontFamilyId === font.id}
onSelect={handleFontSelect}
/>
))}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
<div>
<button
onClick={handleNewTheme}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-primary/10 text-primary shrink-0">
<Plus size={12} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
</div>
</button>
<button
onClick={handleImportFile}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
<Download size={12} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept=".itermcolors"
onChange={handleFileSelected}
className="hidden"
/>
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={currentThemeId === theme.id}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
</div>
</ScrollArea>
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t border-border/50 shrink-0">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
{t('terminal.themeModal.fontSize')}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
<button
onClick={() => handleFontSizeChange(-1)}
disabled={currentFontSize <= MIN_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Minus size={12} />
</button>
<div className="flex items-baseline gap-1">
<span className="text-lg font-bold text-foreground tabular-nums">{currentFontSize}</span>
<span className="text-[9px] text-muted-foreground">px</span>
</div>
<button
onClick={() => handleFontSizeChange(1)}
disabled={currentFontSize >= MAX_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Plus size={12} />
</button>
</div>
</div>
)}
{/* Current selection info */}
<div className="px-2.5 py-1.5 border-t border-border/50 shrink-0">
<div className="text-[9px] text-muted-foreground truncate">
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px
</div>
</div>
</div>
{/* Custom Theme Editor Modal */}
{editingTheme && (
<CustomThemeModal
open={!!editingTheme}
theme={editingTheme}
isNew={isNewTheme}
onSave={(theme) => {
if (isNewTheme) {
addTheme(theme);
onThemeChange(theme.id);
} else {
updateTheme(theme.id, theme);
if (currentThemeId === theme.id) {
onThemeChange(theme.id);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}}
onDelete={isNewTheme ? undefined : handleEditorDelete}
onCancel={() => { setEditingTheme(null); setIsNewTheme(false); }}
/>
)}
</>
);
};
export const ThemeSidePanel = memo(ThemeSidePanelInner);
ThemeSidePanel.displayName = 'ThemeSidePanel';

View File

@@ -590,7 +590,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
term.parser.registerOscHandler(7, (data) => {
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
try {
// data is the content after "7;" - typically "file://hostname/path"
if (data.startsWith('file://')) {
@@ -638,6 +638,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
osc7Disposable.dispose();
try {
term.dispose();
} catch (err) {

View File

@@ -664,6 +664,10 @@ export interface TransferTask {
targetPath: string;
sourceConnectionId: string;
targetConnectionId: string;
targetHostId?: string;
/** Full endpoint key (hostId:hostname:port:protocol) for distinguishing
* same-hostId uploads with different session-time overrides. */
targetConnectionKey?: string;
direction: TransferDirection;
status: TransferStatus;
totalBytes: number;

View File

@@ -52,6 +52,7 @@ export interface WebDAVConfig {
username?: string;
password?: string;
token?: string;
allowInsecure?: boolean;
}
export interface S3Config {

View File

@@ -302,6 +302,18 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:update:install", () => {
const updater = getAutoUpdater();
if (!updater) return;
// On macOS, the system tray keeps the app process alive even after all
// windows are closed, which prevents quitAndInstall from completing.
// Destroy the tray (and its panel window) before quitting so the app
// can exit cleanly and the installer can proceed.
try {
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
globalShortcutBridge.cleanup();
} catch {
// ignore — bridge may not be available
}
updater.quitAndInstall(false, true);
});

View File

@@ -1,4 +1,5 @@
const { createClient, AuthType } = require("webdav");
const https = require("https");
const {
S3Client,
HeadObjectCommand,
@@ -50,6 +51,10 @@ const buildError = (message, details) => {
const buildWebdavClient = (config) => {
if (!config) throw new Error("Missing WebDAV config");
const endpoint = normalizeEndpoint(config.endpoint);
const extraOpts = {};
if (config.allowInsecure) {
extraOpts.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
if (config.authType === "token") {
return createClient(endpoint, {
authType: AuthType.Token,
@@ -57,6 +62,7 @@ const buildWebdavClient = (config) => {
access_token: config.token || "",
token_type: "Bearer",
},
...extraOpts,
});
}
if (config.authType === "digest") {
@@ -64,12 +70,14 @@ const buildWebdavClient = (config) => {
authType: AuthType.Digest,
username: config.username || "",
password: config.password || "",
...extraOpts,
});
}
return createClient(endpoint, {
authType: AuthType.Password,
username: config.username || "",
password: config.password || "",
...extraOpts,
});
};

View File

@@ -104,7 +104,7 @@ async function startPortForward(event, payload) {
// can reject if the tunnel was killed during SSH handshake.
let settled = false;
conn.on('ready', () => {
conn.once('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
if (type === 'local') {
@@ -297,7 +297,7 @@ async function startPortForward(event, payload) {
}
});
conn.on('error', (err) => {
conn.once('error', (err) => {
console.error(`[PortForward] SSH error:`, err.message);
sendStatus('error', err.message);
portForwardingTunnels.delete(tunnelId);
@@ -305,7 +305,7 @@ async function startPortForward(event, payload) {
reject(err);
});
conn.on('close', () => {
conn.once('close', () => {
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
const tunnel = portForwardingTunnels.get(tunnelId);
// Capture the cancelled flag BEFORE cleanup deletes the entry.

View File

@@ -518,7 +518,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
conn.once('ready', () => {
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
resolve();
});
@@ -531,7 +531,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
reject(err);
});
conn.on('timeout', () => {
conn.once('timeout', () => {
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});

View File

@@ -416,17 +416,17 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
// Connect this hop
await new Promise((resolve, reject) => {
conn.on('ready', () => {
conn.once('ready', () => {
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
resolve();
});
conn.on('error', (err) => {
conn.once('error', (err) => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} error:`, err.message);
sendProgress(i + 1, totalHops + 1, hopLabel, 'error');
reject(err);
});
conn.on('timeout', () => {
conn.once('timeout', () => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
});
@@ -920,7 +920,7 @@ async function startSSHSession(event, options) {
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
conn.on("ready", () => {
conn.once("ready", () => {
console.log(`${logPrefix} ${options.hostname} ready`);
// Cache the successful auth method
@@ -1063,28 +1063,34 @@ async function startSSHSession(event, options) {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
}
reject(err);
});
conn.on("timeout", () => {
conn.once("timeout", () => {
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
}
reject(err);
});
conn.on("close", () => {
conn.once("close", () => {
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch { }
}
@@ -1203,7 +1209,7 @@ async function execCommand(event, payload) {
}, timeoutMs);
conn
.on("ready", () => {
.once("ready", () => {
conn.exec(payload.command, (err, stream) => {
if (err) {
clearTimeout(timer);
@@ -1227,13 +1233,13 @@ async function execCommand(event, payload) {
});
});
})
.on("error", (err) => {
.once("error", (err) => {
if (settled) return;
clearTimeout(timer);
settled = true;
reject(err);
})
.on("end", () => {
.once("end", () => {
if (settled) return;
clearTimeout(timer);
settled = true;
@@ -1440,67 +1446,46 @@ async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const session = sessions.get(sessionId);
if (!session || !session.stream || !session.conn) {
if (!session || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
// Completely silent: uses a separate exec channel, nothing is printed
// in the interactive terminal. The exec channel and the interactive
// shell are both children of the same per-connection sshd process,
// so we find the shell as a sibling via $PPID.
return new Promise((resolve) => {
const stream = session.stream;
const marker = `__PWD_${Date.now()}__`;
const timeout = setTimeout(() => {
stream.removeListener('data', onData);
const timer = setTimeout(() => {
resolve({ success: false, error: 'Timeout getting pwd' });
}, 3000);
}, 5000);
let buffer = '';
// Find the interactive shell's cwd silently via a separate exec channel.
// Both the exec channel and the interactive shell share the same sshd
// parent ($PPID). We exclude our own PID ($$) to avoid reading our own cwd.
const cmd = `p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$p" ] && readlink /proc/$p/cwd 2>/dev/null && exit 0; p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$p" ] && readlink /proc/$p/cwd 2>/dev/null && exit 0; eval echo "~"`;
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' });
}
}
session.conn.exec(cmd, (err, stream) => {
if (err) {
clearTimeout(timer);
log('[getSessionPwd] exec error:', err.message);
resolve({ success: false, error: err.message });
return;
}
};
stream.on('data', onData);
// Send pwd command with short unique markers
// Using 'S' and 'E' as suffixes to make markers shorter
// After the command, send ANSI escape sequences to clear the output lines:
// \x1b[1A = move cursor up 1 line, \x1b[2K = clear entire line
// Clear 4 lines: the command echo, START marker, pwd output, and END marker
const clearLines = '\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K\\x1b[1A\\x1b[2K';
stream.write(` echo '${marker}S' && pwd && echo '${marker}E' && printf '${clearLines}'\n`);
let out = '';
let errOut = '';
stream.on('data', (d) => { out += d.toString(); });
stream.stderr?.on('data', (d) => { errOut += d.toString(); });
stream.on('close', (code) => {
clearTimeout(timer);
const path = out.trim();
log('[getSessionPwd]', { stdout: path, stderr: errOut.trim(), exitCode: code });
if (path && path.startsWith('/')) {
resolve({ success: true, cwd: path });
} else {
resolve({ success: false, error: 'Could not determine cwd' });
}
});
});
});
}

1
global.d.ts vendored
View File

@@ -109,6 +109,7 @@ declare global {
username: string;
password?: string;
privateKey?: string;
passphrase?: string;
}
interface PortForwardResult {

View File

@@ -29,8 +29,9 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Disabling it improves performance by 15-20%
allowTransparency: false,
// Custom glyphs require additional memory and processing
customGlyphs: false,
// Custom glyphs: xterm.js draws box/block characters on canvas
// instead of using font glyphs, eliminating gaps between cells
customGlyphs: true,
// Font rendering settings
letterSpacing: 0,

View File

@@ -147,6 +147,12 @@ export class WebDAVAdapter {
}
private createClient(config: WebDAVConfig): WebDAVClient {
const extraOpts: Record<string, unknown> = {};
if (config.allowInsecure && typeof globalThis.process !== 'undefined') {
const https = require('https');
extraOpts.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
if (config.authType === 'token') {
return createClient(config.endpoint, {
authType: AuthType.Token,
@@ -154,6 +160,7 @@ export class WebDAVAdapter {
access_token: config.token || '',
token_type: 'Bearer',
},
...extraOpts,
});
}
@@ -162,6 +169,7 @@ export class WebDAVAdapter {
authType: AuthType.Digest,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}
@@ -169,6 +177,7 @@ export class WebDAVAdapter {
authType: AuthType.Password,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}

View File

@@ -357,7 +357,7 @@ export const reconcileWithBackend = async (): Promise<{
export const startPortForward = async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
keys: { id: string; privateKey: string; passphrase: string }[],
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
enableReconnect = false
): Promise<{ success: boolean; error?: string }> => {
@@ -376,12 +376,14 @@ export const startPortForward = async (
// Generate a unique tunnel ID
const tunnelId = `pf-${rule.id}-${Date.now()}`;
// Get the private key if using key auth
// Get the private key and passphrase if using key auth
let privateKey: string | undefined;
let passphrase: string | undefined;
if (host.identityFileId) {
const key = keys.find(k => k.id === host.identityFileId);
if (key) {
privateKey = key.privateKey;
passphrase = key.passphrase;
}
}
@@ -429,6 +431,7 @@ export const startPortForward = async (
username: host.username,
password: host.password,
privateKey,
passphrase,
});
if (!result.success) {

View File

@@ -0,0 +1,25 @@
import type { KnownHost } from '../domain/models';
import { STORAGE_KEY_KNOWN_HOSTS } from './config/storageKeys';
import { localStorageAdapter } from './persistence/localStorageAdapter';
/**
* Get effective knownHosts for sync payload.
*
* If the hook/state knownHosts is empty but localStorage has data,
* read from localStorage to avoid uploading an empty array that
* overwrites the cloud snapshot.
*/
export function getEffectiveKnownHosts(
knownHostsFromState: KnownHost[] | undefined,
): KnownHost[] | undefined {
if (knownHostsFromState && knownHostsFromState.length > 0) {
return knownHostsFromState;
}
const stored = localStorageAdapter.read<KnownHost[]>(STORAGE_KEY_KNOWN_HOSTS);
if (stored && Array.isArray(stored) && stored.length > 0) {
return stored;
}
return knownHostsFromState;
}

View File

@@ -917,13 +917,59 @@ export async function uploadEntriesDirect(
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
if (controller) {
controller.reset();
controller.setBridge(bridge);
}
if (entries.length === 0) {
return [];
}
// Support compressed folder uploads (same logic as uploadFromDataTransfer)
if (useCompressedUpload && !isLocal && sftpId) {
const rootFolders = detectRootFolders(entries);
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
if (folderEntries.length > 0) {
try {
const compressedResults = await uploadFoldersCompressed(folderEntries, targetPath, sftpId, callbacks, controller);
const failedFolders = compressedResults.filter(result =>
!result.success && result.error === "Compressed upload not supported - fallback needed"
);
const successfulFolders = compressedResults.filter(result =>
result.success || result.error !== "Compressed upload not supported - fallback needed"
);
let fallbackResults: UploadResult[] = [];
if (failedFolders.length > 0) {
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
const failedFolderEntries = entries.filter(entry => {
const topFolder = entry.relativePath.split('/')[0];
return failedFolderNames.has(topFolder);
});
if (failedFolderEntries.length > 0) {
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
let standaloneResults: UploadResult[] = [];
if (standaloneFileEntries.length > 0) {
const standaloneEntries = standaloneFileEntries.flatMap(([, e]) => e);
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
} catch {
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
}
}
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
/**

1
public/distro/linux.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

1
public/distro/macos.svg Normal file
View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>macOS</title><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/></svg>

After

Width:  |  Height:  |  Size: 651 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Windows</title><path d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623"/></svg>

After

Width:  |  Height:  |  Size: 204 B