Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af074c5704 | ||
|
|
c60afdd8fe | ||
|
|
a1d05ca5b3 | ||
|
|
327ca3806a | ||
|
|
2f71dd3927 | ||
|
|
3844edd49f | ||
|
|
8f97a7e81d | ||
|
|
5daf1f0d6f | ||
|
|
b1a5b92ce4 | ||
|
|
c99a70831a | ||
|
|
4b0468b0d2 | ||
|
|
f32078f270 | ||
|
|
a525c073b9 | ||
|
|
afceb92a55 | ||
|
|
4822894efb | ||
|
|
d9b51c3a50 | ||
|
|
15b1dba558 | ||
|
|
fd6b3930c1 | ||
|
|
53cb160a6e | ||
|
|
bb590f140d | ||
|
|
945992b80e | ||
|
|
b8de9ce2b6 | ||
|
|
2c7bce31d4 | ||
|
|
004a5f18de | ||
|
|
731d57d355 | ||
|
|
8c6ff1a6a4 | ||
|
|
f7630b3574 | ||
|
|
76bfe26561 | ||
|
|
7079ea66aa | ||
|
|
6562351955 | ||
|
|
986fdda008 | ||
|
|
af2dc66113 | ||
|
|
cca4a3a37e | ||
|
|
75ec050c31 | ||
|
|
db604e4c41 | ||
|
|
05c48b3d28 | ||
|
|
3bb98c9c27 | ||
|
|
7f4dcce3cb | ||
|
|
766451d9bb | ||
|
|
6f5a2181b2 | ||
|
|
297adbb818 | ||
|
|
13eeb2cf6d | ||
|
|
e9ad65fef6 | ||
|
|
ddb6b5af1e | ||
|
|
c1171d4c7b | ||
|
|
21daccf6ed | ||
|
|
2eed15b4b2 | ||
|
|
de7fdfc4b4 | ||
|
|
709ed12259 | ||
|
|
0826bbb435 | ||
|
|
ec87eb593e | ||
|
|
ecbd50dde4 | ||
|
|
4dd7640452 | ||
|
|
0b08521e63 | ||
|
|
59e768c447 | ||
|
|
6a37b8bbc6 |
@@ -699,6 +699,9 @@ const en: Messages = {
|
||||
'sftp.deleteConfirm.single': 'Delete "{name}"?',
|
||||
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
|
||||
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
|
||||
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
|
||||
'sftp.deleteConfirm.host': 'Host',
|
||||
'sftp.deleteConfirm.path': 'Path',
|
||||
'sftp.error.loadFailed': 'Failed to load directory',
|
||||
'sftp.error.downloadFailed': 'Download failed',
|
||||
'sftp.error.uploadFailed': 'Upload failed',
|
||||
@@ -1684,6 +1687,17 @@ const en: Messages = {
|
||||
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
|
||||
'ai.claude.check': 'Check',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
|
||||
'ai.copilot.detecting': 'Detecting...',
|
||||
'ai.copilot.detected': 'Detected',
|
||||
'ai.copilot.notFound': 'Not found',
|
||||
'ai.copilot.path': 'Path:',
|
||||
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
|
||||
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
|
||||
'ai.copilot.check': 'Check',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': 'Default Agent',
|
||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||
|
||||
@@ -507,6 +507,9 @@ const zhCN: Messages = {
|
||||
'sftp.deleteConfirm.single': '删除 "{name}"?',
|
||||
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
|
||||
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
|
||||
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
|
||||
'sftp.deleteConfirm.host': '主机',
|
||||
'sftp.deleteConfirm.path': '路径',
|
||||
'sftp.error.loadFailed': '加载目录失败',
|
||||
'sftp.error.downloadFailed': '下载失败',
|
||||
'sftp.error.uploadFailed': '上传失败',
|
||||
@@ -1691,6 +1694,17 @@ const zhCN: Messages = {
|
||||
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
|
||||
'ai.claude.check': '检查',
|
||||
|
||||
// AI GitHub Copilot CLI
|
||||
'ai.copilot.title': 'GitHub Copilot CLI',
|
||||
'ai.copilot.description': '通过 ACP over stdio(`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.copilot.detecting': '检测中...',
|
||||
'ai.copilot.detected': '已检测到',
|
||||
'ai.copilot.notFound': '未找到',
|
||||
'ai.copilot.path': '路径:',
|
||||
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
|
||||
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
|
||||
'ai.copilot.check': '检查',
|
||||
|
||||
// AI Default Agent
|
||||
'ai.defaultAgent': '默认 Agent',
|
||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||
|
||||
@@ -28,6 +28,7 @@ interface UseSftpPaneActionsParams {
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
isSessionError: (err: unknown) => boolean;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
dirCacheTtlMs: number;
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ export const useSftpPaneActions = ({
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs,
|
||||
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||||
const normalizePathForCompare = useCallback((path: string): string => {
|
||||
@@ -465,6 +467,10 @@ export const useSftpPaneActions = ({
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
updateActiveTab(side, (prev) => {
|
||||
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
|
||||
if (newSelection.has(fileName)) {
|
||||
@@ -475,11 +481,15 @@ export const useSftpPaneActions = ({
|
||||
return { ...prev, selectedFiles: newSelection };
|
||||
});
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const rangeSelect = useCallback(
|
||||
(side: "left" | "right", fileNames: string[]) => {
|
||||
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||||
if (activeTabId) {
|
||||
clearSelectionsExcept({ side, tabId: activeTabId });
|
||||
}
|
||||
const newSelection = new Set<string>();
|
||||
for (const name of fileNames) {
|
||||
if (name && name !== "..") {
|
||||
@@ -489,7 +499,7 @@ export const useSftpPaneActions = ({
|
||||
|
||||
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||||
},
|
||||
[updateActiveTab],
|
||||
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||||
);
|
||||
|
||||
const clearSelection = useCallback((side: "left" | "right") => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SftpTabsState {
|
||||
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;
|
||||
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||||
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
|
||||
addTab: (side: "left" | "right") => string;
|
||||
closeTab: (side: "left" | "right", tabId: string) => void;
|
||||
@@ -34,6 +35,8 @@ interface SftpTabsState {
|
||||
getActiveTabId: (side: "left" | "right") => string | null;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION = new Set<string>();
|
||||
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
@@ -95,6 +98,31 @@ export const useSftpTabsState = ({
|
||||
[updateTab],
|
||||
);
|
||||
|
||||
const clearSelectionsExcept = useCallback(
|
||||
(target: { side: "left" | "right"; tabId: string } | null) => {
|
||||
const clearSideSelections = (
|
||||
prev: SftpSideTabs,
|
||||
side: "left" | "right",
|
||||
): SftpSideTabs => {
|
||||
let changed = false;
|
||||
const tabs = prev.tabs.map((tab) => {
|
||||
const shouldKeepSelection =
|
||||
target?.side === side && target.tabId === tab.id;
|
||||
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
|
||||
return tab;
|
||||
}
|
||||
changed = true;
|
||||
return { ...tab, selectedFiles: EMPTY_SELECTION };
|
||||
});
|
||||
return changed ? { ...prev, tabs } : prev;
|
||||
};
|
||||
|
||||
setLeftTabs((prev) => clearSideSelections(prev, "left"));
|
||||
setRightTabs((prev) => clearSideSelections(prev, "right"));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setTabShowHiddenFiles = useCallback(
|
||||
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
|
||||
updateTab(side, tabId, (prev) => {
|
||||
@@ -258,6 +286,7 @@ export const useSftpTabsState = ({
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
|
||||
@@ -22,6 +22,7 @@ interface UseSftpTransfersParams {
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
@@ -78,6 +79,7 @@ export const useSftpTransfers = ({
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
@@ -209,6 +211,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
@@ -228,6 +231,7 @@ export const useSftpTransfers = ({
|
||||
totalBytes: task.totalBytes || undefined,
|
||||
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
|
||||
targetEncoding: targetIsLocal ? undefined : targetEncoding,
|
||||
sameHost: sameHost || undefined,
|
||||
};
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
@@ -343,6 +347,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
symlinkDepth = 0,
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
) => {
|
||||
@@ -433,6 +438,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
isSymlink ? symlinkDepth + 1 : symlinkDepth,
|
||||
followSymlinks,
|
||||
);
|
||||
@@ -496,6 +502,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
);
|
||||
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
@@ -571,6 +578,22 @@ export const useSftpTransfers = ({
|
||||
? null
|
||||
: sftpSessionsRef.current.get(targetPane.connection!.id);
|
||||
|
||||
// Detect same-host: both sides connected to the same remote endpoint.
|
||||
// Use per-connection cache keys (hostname+port+protocol+sudo+username) instead of
|
||||
// just hostId, because the same hostId can have different session-time overrides.
|
||||
const sourceCacheKey = sourcePane.connection?.id
|
||||
? connectionCacheKeyMapRef.current.get(sourcePane.connection.id)
|
||||
: undefined;
|
||||
const targetCacheKey = targetPane.connection?.id
|
||||
? connectionCacheKeyMapRef.current.get(targetPane.connection.id)
|
||||
: undefined;
|
||||
const sameHost = !!(
|
||||
sourceSftpId && targetSftpId &&
|
||||
!sourcePane.connection?.isLocal && !targetPane.connection?.isLocal &&
|
||||
sourceCacheKey && targetCacheKey &&
|
||||
sourceCacheKey === targetCacheKey
|
||||
);
|
||||
|
||||
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
|
||||
const sourceSide = targetSide === "left" ? "right" : "left";
|
||||
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
|
||||
@@ -718,7 +741,34 @@ export const useSftpTransfers = ({
|
||||
|
||||
let dirPartialFailure = false;
|
||||
|
||||
if (task.isDirectory) {
|
||||
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
|
||||
// "auto" is allowed here — the backend resolves it to the actual encoding
|
||||
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
|
||||
const encodingSafeForExec =
|
||||
(!sourceEncoding || sourceEncoding === "utf-8" || sourceEncoding === "auto") &&
|
||||
(!targetEncoding || targetEncoding === "utf-8" || targetEncoding === "auto");
|
||||
|
||||
// Try same-host directory optimization first; falls back to recursive transfer
|
||||
// if remote cp is unavailable (e.g. Windows SSH servers).
|
||||
let dirHandledBySameHost = false;
|
||||
if (task.isDirectory && sameHost && encodingSafeForExec && sourceSftpId) {
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const result = await netcattyBridge.require().sameHostCopyDirectory!(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
task.targetPath,
|
||||
sourceEncoding,
|
||||
task.id,
|
||||
);
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
dirHandledBySameHost = result.success;
|
||||
}
|
||||
|
||||
if (task.isDirectory && !dirHandledBySameHost) {
|
||||
// For directory transfers, parent task uses:
|
||||
// totalBytes = total file count (discovered async)
|
||||
// transferredBytes = completed file count (incremented by child completions)
|
||||
@@ -746,12 +796,13 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
);
|
||||
|
||||
if (dirErrors > 0) {
|
||||
dirPartialFailure = true;
|
||||
}
|
||||
} else {
|
||||
} else if (!task.isDirectory) {
|
||||
await transferFile(
|
||||
task,
|
||||
sourceSftpId,
|
||||
@@ -761,6 +812,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1247,6 +1299,7 @@ export const useSftpTransfers = ({
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface CloudSyncHook {
|
||||
code: string,
|
||||
redirectUri: string
|
||||
) => Promise<void>;
|
||||
cancelOAuthConnect: () => void;
|
||||
disconnectProvider: (provider: CloudProvider) => Promise<void>;
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
@@ -265,34 +266,30 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
// Start callback server and open browser
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
// Race: if browser launch fails, surface the error immediately
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('google', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,34 +311,29 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
|
||||
const expectedState = adapter?.getPKCEState?.() || undefined;
|
||||
|
||||
// Start callback server and open browser
|
||||
// Start callback server and open system browser
|
||||
const callbackPromise = startCallback(expectedState);
|
||||
|
||||
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
|
||||
let popup: Window | null = null;
|
||||
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const openTimer = setTimeout(() => {
|
||||
popup = window.open(data.url, "_blank", "width=600,height=700");
|
||||
// Poll for popup closure — if user closes it, cancel the OAuth flow
|
||||
if (popup) {
|
||||
popupPollTimer = setInterval(() => {
|
||||
if (popup?.closed) {
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 100);
|
||||
// Use system browser to avoid white-screen issues in popup windows (#563)
|
||||
let openTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const browserPromise = new Promise<never>((_resolve, reject) => {
|
||||
openTimer = setTimeout(async () => {
|
||||
try {
|
||||
await bridge?.openExternal(data.url);
|
||||
} catch (err) {
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
try {
|
||||
// Wait for callback
|
||||
const { code } = await callbackPromise;
|
||||
const { code } = await Promise.race([callbackPromise, browserPromise]);
|
||||
|
||||
// Complete auth with the received code
|
||||
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
|
||||
} finally {
|
||||
clearTimeout(openTimer);
|
||||
if (popupPollTimer) clearInterval(popupPollTimer);
|
||||
if (openTimer) clearTimeout(openTimer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
await manager.connectConfigProvider('s3', config);
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelOAuthCallback?.();
|
||||
}, []);
|
||||
|
||||
// ========== Settings ==========
|
||||
|
||||
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
|
||||
@@ -469,6 +466,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectWebDAV,
|
||||
connectS3,
|
||||
completePKCEAuth,
|
||||
cancelOAuthConnect,
|
||||
disconnectProvider,
|
||||
resetProviderStatus,
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
updateTab,
|
||||
updateActiveTab,
|
||||
clearSelectionsExcept,
|
||||
setTabShowHiddenFiles,
|
||||
addTab,
|
||||
closeTab,
|
||||
@@ -235,6 +236,7 @@ export const useSftpState = (
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
clearSelectionsExcept,
|
||||
dirCacheTtlMs: DIR_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
@@ -289,6 +291,7 @@ export const useSftpState = (
|
||||
refresh,
|
||||
clearCacheForConnection,
|
||||
sftpSessionsRef,
|
||||
connectionCacheKeyMapRef,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
@@ -338,6 +341,7 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
@@ -391,6 +395,7 @@ export const useSftpState = (
|
||||
toggleSelection,
|
||||
rangeSelect,
|
||||
clearSelection,
|
||||
clearSelectionsExcept,
|
||||
selectAll,
|
||||
setFilter,
|
||||
setFilenameEncoding,
|
||||
@@ -447,6 +452,8 @@ export const useSftpState = (
|
||||
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
|
||||
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
|
||||
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
|
||||
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
|
||||
methodsRef.current.clearSelectionsExcept(...args),
|
||||
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
|
||||
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
|
||||
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AgentModelPreset,
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
AISessionScope,
|
||||
@@ -43,6 +44,20 @@ import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGa
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
|
||||
if (!agent) return false;
|
||||
const tokens = [
|
||||
agent.id,
|
||||
agent.name,
|
||||
agent.icon,
|
||||
agent.command,
|
||||
agent.acpCommand,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
|
||||
return tokens.some((token) => token.includes('copilot'));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
// -------------------------------------------------------------------
|
||||
@@ -425,15 +440,62 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const providerDisplayName = activeProvider?.name ?? '';
|
||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
||||
|
||||
// Agent model presets for the current external agent
|
||||
const currentAgentConfig = useMemo(
|
||||
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
|
||||
[currentAgentId, externalAgents],
|
||||
);
|
||||
const isCopilotExternalAgent = useMemo(
|
||||
() => isCopilotAgentConfig(currentAgentConfig),
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
// Ref to read agentModelMap inside the effect without re-triggering it
|
||||
// when setAgentModel updates the map (avoids double ACP spawn).
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentConfig?.acpCommand) return;
|
||||
if (!isCopilotExternalAgent) return;
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiAcpListModels) return;
|
||||
|
||||
let cancelled = false;
|
||||
void bridge.aiAcpListModels(
|
||||
currentAgentConfig.acpCommand,
|
||||
currentAgentConfig.acpArgs || [],
|
||||
undefined,
|
||||
undefined,
|
||||
`models_${currentAgentId}`,
|
||||
).then((result) => {
|
||||
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
|
||||
const knownModelIds = new Set(result.models.map((model) => model.id));
|
||||
setRuntimeAgentModelPresets((prev) => ({
|
||||
...prev,
|
||||
[currentAgentId]: result.models ?? [],
|
||||
}));
|
||||
const storedModelId = agentModelMapRef.current[currentAgentId];
|
||||
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
|
||||
setAgentModel(currentAgentId, result.currentModelId);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
|
||||
|
||||
const agentModelPresets = useMemo(
|
||||
() => getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentConfig?.command],
|
||||
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
|
||||
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
|
||||
);
|
||||
|
||||
// Per-agent model: recall last selection or use first preset as default
|
||||
@@ -593,7 +655,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
|
||||
@@ -102,11 +102,14 @@ interface StatusDotProps {
|
||||
}
|
||||
|
||||
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
|
||||
if (status === 'connecting') {
|
||||
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
|
||||
}
|
||||
|
||||
const colors = {
|
||||
connected: 'bg-green-500',
|
||||
syncing: 'bg-blue-500 animate-pulse',
|
||||
error: 'bg-red-500',
|
||||
connecting: 'bg-yellow-500 animate-pulse',
|
||||
disconnected: 'bg-muted-foreground/50',
|
||||
};
|
||||
|
||||
@@ -279,6 +282,7 @@ interface ProviderCardProps {
|
||||
disabled?: boolean; // Disable connect button when another provider is connected
|
||||
onEdit?: () => void;
|
||||
onConnect: () => void;
|
||||
onCancelConnect?: () => void;
|
||||
onDisconnect: () => void;
|
||||
onSync: () => void;
|
||||
}
|
||||
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
disabled,
|
||||
onEdit,
|
||||
onConnect,
|
||||
onCancelConnect,
|
||||
onDisconnect,
|
||||
onSync,
|
||||
}) => {
|
||||
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -408,6 +415,16 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
<CloudOff size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : isConnecting && onCancelConnect ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCancelConnect}
|
||||
className="gap-1"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1088,6 +1105,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.google.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
|
||||
onConnect={handleConnectGoogle}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('google')}
|
||||
onSync={() => handleSync('google')}
|
||||
/>
|
||||
@@ -1104,6 +1122,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
error={sync.providers.onedrive.error}
|
||||
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
|
||||
onConnect={handleConnectOneDrive}
|
||||
onCancelConnect={sync.cancelOAuthConnect}
|
||||
onDisconnect={() => sync.disconnectProvider('onedrive')}
|
||||
onSync={() => handleSync('onedrive')}
|
||||
/>
|
||||
|
||||
@@ -65,8 +65,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
|
||||
// Size variants - all use rounded corners for consistency
|
||||
const sizeClasses = {
|
||||
sm: "h-6 w-6 rounded-md",
|
||||
md: "h-11 w-11 rounded-xl",
|
||||
sm: "h-6 w-6 rounded",
|
||||
md: "h-11 w-11 rounded-lg",
|
||||
lg: "h-14 w-14 rounded-xl",
|
||||
};
|
||||
const iconSizes = {
|
||||
@@ -98,7 +98,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
containerClass,
|
||||
"flex items-center justify-center border border-border/40 overflow-hidden",
|
||||
"flex items-center justify-center overflow-hidden",
|
||||
bg,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -739,7 +739,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
@@ -984,9 +984,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Button
|
||||
@@ -1179,10 +1179,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
|
||||
@@ -19,6 +19,12 @@ import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
|
||||
interface SelectHostPanelProps {
|
||||
hosts: Host[];
|
||||
@@ -198,6 +204,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
|
||||
@@ -271,7 +278,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Breadcrumbs */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -301,20 +308,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
)}
|
||||
{groupsWithCounts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
|
||||
<div className="space-y-1">
|
||||
{groupsWithCounts.map((group) => (
|
||||
<div
|
||||
key={group.path}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
onClick={() => setCurrentPath(group.path)}
|
||||
>
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
|
||||
<LayoutGrid size={18} />
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
|
||||
<LayoutGrid size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-[13px] font-medium truncate">{group.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: group.count })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,18 +334,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
{/* Hosts Section */}
|
||||
{filteredHosts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
|
||||
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
|
||||
<div className="space-y-1">
|
||||
{filteredHosts.map((host) => {
|
||||
const isSelected = selectedHostIds.includes(host.id);
|
||||
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
|
||||
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
|
||||
isSelected
|
||||
? "bg-muted border border-border"
|
||||
? "bg-muted"
|
||||
: "hover:bg-muted/70",
|
||||
)}
|
||||
onClick={() => onSelect(host)}
|
||||
@@ -346,16 +354,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.os[0].toUpperCase()}
|
||||
className="h-10 w-10"
|
||||
className="h-8 w-8 rounded-md"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{host.username}@{host.hostname}:{host.port || 22}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[13px] font-medium truncate">
|
||||
{host.label}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{host.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{connectionStr}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
<p>{connectionStr}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={16} className="text-primary" />
|
||||
<Check size={14} className="text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -413,6 +437,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
@@ -130,12 +131,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
const panelRootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
||||
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
||||
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive: isVisible && hasPaneFocus,
|
||||
});
|
||||
|
||||
@@ -149,10 +152,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
||||
}, []);
|
||||
|
||||
const syncFocusedSelection = useCallback((tabId: string | null) => {
|
||||
if (tabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
|
||||
return;
|
||||
}
|
||||
keepOnlyPaneSelections(sftpRef.current, null);
|
||||
}, []);
|
||||
|
||||
const handlePaneFocus = useCallback(() => {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
}, []);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
}, [syncFocusedSelection]);
|
||||
|
||||
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
||||
// activeTabStore is a global singleton shared with SftpView.
|
||||
@@ -161,19 +173,30 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
}, [isVisible]);
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
const elementTarget = target instanceof Element ? target : null;
|
||||
const isPortalInteraction = !!elementTarget?.closest(
|
||||
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
|
||||
);
|
||||
if (isPortalInteraction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panelRootRef.current?.contains(target)) {
|
||||
sftpFocusStore.setFocusedSide("left");
|
||||
setHasPaneFocus(true);
|
||||
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
||||
} else {
|
||||
setHasPaneFocus(false);
|
||||
syncFocusedSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +204,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, [isVisible]);
|
||||
}, [isVisible, syncFocusedSelection]);
|
||||
|
||||
const {
|
||||
leftCallbacks,
|
||||
@@ -599,10 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={isVisible && hasPaneFocus}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
showEmptyHeader
|
||||
forceActive
|
||||
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
|
||||
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
||||
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
@@ -79,6 +81,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const dialogActionScopeIdRef = useRef("sftp-main-view");
|
||||
|
||||
useInstantThemeSwitch(rootRef);
|
||||
|
||||
@@ -132,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId: dialogActionScopeIdRef.current,
|
||||
isActive,
|
||||
});
|
||||
|
||||
@@ -139,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
|
||||
// Clear the opposite side's selection so file operations only affect the focused pane
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
|
||||
const prevSide = sftpFocusStore.getFocusedSide();
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
if (prevSide !== side) {
|
||||
if (targetTabId) {
|
||||
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
|
||||
} else {
|
||||
// Focus side changed — clear other panes but keep the newly focused pane intact.
|
||||
keepOnlyActivePaneSelections(sftpRef.current, side);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
|
||||
@@ -255,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
handleHostSelectRight,
|
||||
} = useSftpViewTabs({ sftp, sftpRef });
|
||||
|
||||
const handleAddTabLeftWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabLeft();
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handleAddTabLeft, handlePaneFocus]);
|
||||
|
||||
const handleAddTabRightWithFocus = useCallback(() => {
|
||||
const tabId = handleAddTabRight();
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handleAddTabRight, handlePaneFocus]);
|
||||
|
||||
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabLeft(tabId);
|
||||
handlePaneFocus("left", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabLeft]);
|
||||
|
||||
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
|
||||
handleSelectTabRight(tabId);
|
||||
handlePaneFocus("right", tabId);
|
||||
}, [handlePaneFocus, handleSelectTabRight]);
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
@@ -295,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpTabBar
|
||||
tabs={leftTabsInfo}
|
||||
side="left"
|
||||
onSelectTab={handleSelectTabLeft}
|
||||
onSelectTab={handleSelectTabLeftWithFocus}
|
||||
onCloseTab={handleCloseTabLeft}
|
||||
onAddTab={handleAddTabLeft}
|
||||
onAddTab={handleAddTabLeftWithFocus}
|
||||
onReorderTabs={handleReorderTabsLeft}
|
||||
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
||||
/>
|
||||
@@ -313,6 +347,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpPaneView
|
||||
side="left"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "left"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
@@ -354,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpTabBar
|
||||
tabs={rightTabsInfo}
|
||||
side="right"
|
||||
onSelectTab={handleSelectTabRight}
|
||||
onSelectTab={handleSelectTabRightWithFocus}
|
||||
onCloseTab={handleCloseTabRight}
|
||||
onAddTab={handleAddTabRight}
|
||||
onAddTab={handleAddTabRightWithFocus}
|
||||
onReorderTabs={handleReorderTabsRight}
|
||||
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
||||
/>
|
||||
@@ -372,6 +407,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
<SftpPaneView
|
||||
side="right"
|
||||
pane={pane}
|
||||
dialogActionScopeId={dialogActionScopeIdRef.current}
|
||||
isPaneFocused={focusedSide === "right"}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
showHeader
|
||||
|
||||
@@ -1963,6 +1963,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
|
||||
@@ -826,7 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{isSftpActive && (
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
|
||||
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
|
||||
/>
|
||||
)}
|
||||
<Folder size={14} /> SFTP
|
||||
|
||||
@@ -11,6 +11,7 @@ type AgentLike = {
|
||||
|
||||
type AgentIconKey =
|
||||
| 'catty'
|
||||
| 'copilot'
|
||||
| 'openai'
|
||||
| 'claude'
|
||||
| 'anthropic'
|
||||
@@ -20,7 +21,7 @@ type AgentIconKey =
|
||||
| 'openrouter'
|
||||
| 'zed'
|
||||
| 'atom'
|
||||
| 'terminal'
|
||||
| 'terminal'
|
||||
| 'plus';
|
||||
|
||||
type AgentIconVisual = {
|
||||
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
|
||||
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
|
||||
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
|
||||
},
|
||||
copilot: {
|
||||
src: '/ai/agents/copilot.svg',
|
||||
badgeClassName: 'border-zinc-300 bg-white',
|
||||
imageClassName: 'object-contain brightness-0',
|
||||
},
|
||||
openai: {
|
||||
src: '/ai/providers/openai.svg',
|
||||
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
|
||||
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
|
||||
if (tokens.some((token) => token.includes('claude'))) {
|
||||
return 'claude';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('copilot'))) {
|
||||
return 'copilot';
|
||||
}
|
||||
if (tokens.some((token) => token.includes('anthropic'))) {
|
||||
return 'anthropic';
|
||||
}
|
||||
@@ -160,7 +169,8 @@ export const AgentIconBadge: React.FC<{
|
||||
variant?: 'plain' | 'badge';
|
||||
className?: string;
|
||||
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
|
||||
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
|
||||
const iconKey = getAgentIconKey(agent);
|
||||
const visual = AGENT_ICON_VISUALS[iconKey];
|
||||
const badgeSize =
|
||||
size === 'xs'
|
||||
? 'h-4 w-4 rounded-sm'
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
isSettingsManagedDiscoveredAgent,
|
||||
matchesManagedAgentConfig,
|
||||
} from '../../infrastructure/ai/managedAgents';
|
||||
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
||||
import AgentIconBadge from './AgentIconBadge';
|
||||
import {
|
||||
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
const unconfiguredDiscovered = useMemo(
|
||||
() =>
|
||||
discoveredAgents.filter(
|
||||
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
|
||||
(da) => {
|
||||
if (isSettingsManagedDiscoveredAgent(da)) {
|
||||
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
|
||||
}
|
||||
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
|
||||
},
|
||||
),
|
||||
[discoveredAgents, externalAgents],
|
||||
);
|
||||
|
||||
@@ -238,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
</MessageResponse>
|
||||
)}
|
||||
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls?.map((tc) => {
|
||||
{/* Pending tool calls from the *last* assistant message are rendered
|
||||
after all tool-result messages (see below) for chronological order.
|
||||
Unresolved tool calls from earlier or cancelled messages are shown
|
||||
inline — as interrupted, or with approval controls if still pending. */}
|
||||
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id),
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
@@ -249,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
isInterrupted={!isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
@@ -290,6 +293,33 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pending tool calls from the last assistant message — rendered here
|
||||
(after all tool-result messages) so they appear at the bottom. */}
|
||||
{lastAssistantMessage?.toolCalls?.filter((tc) =>
|
||||
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
|
||||
).map((tc) => {
|
||||
const isPending = pendingApprovals.has(tc.id);
|
||||
const resolved = resolvedApprovals.get(tc.id);
|
||||
const approvalStatus = isPending
|
||||
? 'pending' as const
|
||||
: resolved === true
|
||||
? 'approved' as const
|
||||
: resolved === false
|
||||
? 'denied' as const
|
||||
: undefined;
|
||||
return (
|
||||
<ToolCall
|
||||
key={tc.id}
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
|
||||
approvalStatus={approvalStatus}
|
||||
onApprove={() => handleApprove(tc.id)}
|
||||
onReject={() => handleReject(tc.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
|
||||
{Array.from(pendingApprovals.entries())
|
||||
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
|
||||
|
||||
@@ -112,6 +112,13 @@ export interface PanelBridge extends NetcattyBridge {
|
||||
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
|
||||
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
|
||||
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
|
||||
aiAcpListModels?: (
|
||||
acpCommand: string,
|
||||
acpArgs?: string[],
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
chatSessionId?: string,
|
||||
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||
[key: string]: ((...args: unknown[]) => unknown) | undefined;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
@@ -228,7 +228,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
|
||||
>
|
||||
{selectedHost ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
|
||||
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
|
||||
<span>{selectedHost.label}</span>
|
||||
<Check size={14} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - SafetySettings
|
||||
*/
|
||||
import { Bot, Globe } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AIProviderId,
|
||||
@@ -16,8 +16,12 @@ import type {
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
} from "../../../infrastructure/ai/types";
|
||||
import {
|
||||
getManagedAgentStoredPath,
|
||||
matchesManagedAgentConfig,
|
||||
type ManagedAgentKey,
|
||||
} from "../../../infrastructure/ai/managedAgents";
|
||||
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
|
||||
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Select, SettingRow } from "../settings-ui";
|
||||
@@ -38,6 +42,7 @@ import { ProviderCard } from "./ai/ProviderCard";
|
||||
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
|
||||
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
|
||||
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
|
||||
import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
|
||||
@@ -70,6 +75,54 @@ interface SettingsAITabProps {
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}
|
||||
|
||||
function areExternalAgentListsEqual(
|
||||
left: ExternalAgentConfig[],
|
||||
right: ExternalAgentConfig[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
|
||||
}
|
||||
|
||||
function buildManagedAgentState(
|
||||
prevAgents: ExternalAgentConfig[],
|
||||
defaultAgentId: string,
|
||||
agentKey: ManagedAgentKey,
|
||||
pathInfo: AgentPathInfo | null,
|
||||
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
|
||||
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
|
||||
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
|
||||
|
||||
if (!pathInfo?.available || !pathInfo.path) {
|
||||
return {
|
||||
agents: storedPath ? prevAgents : otherAgents,
|
||||
defaultAgentId: storedPath
|
||||
? defaultAgentId
|
||||
: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? "catty"
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
const nextManagedAgent: ExternalAgentConfig = {
|
||||
...existingManaged,
|
||||
...defaults,
|
||||
id: managedId,
|
||||
command: pathInfo.path,
|
||||
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
|
||||
};
|
||||
|
||||
return {
|
||||
agents: [...otherAgents, nextManagedAgent],
|
||||
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
|
||||
? managedId
|
||||
: defaultAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Tab Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,58 +166,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [claudeCustomPath, setClaudeCustomPath] = useState("");
|
||||
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
|
||||
const initialManagedPathsRef = useRef<{
|
||||
codex: string;
|
||||
claude: string;
|
||||
copilot: string;
|
||||
} | null>(null);
|
||||
if (!initialManagedPathsRef.current) {
|
||||
initialManagedPathsRef.current = {
|
||||
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
|
||||
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
|
||||
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
discoveredAgents,
|
||||
isDiscovering,
|
||||
enableAgent,
|
||||
} = useAgentDiscovery(externalAgents, setExternalAgents);
|
||||
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
|
||||
const [copilotCustomPath, setCopilotCustomPath] = useState("");
|
||||
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
||||
|
||||
// Derive path info from discovery results
|
||||
useEffect(() => {
|
||||
if (isDiscovering) return;
|
||||
// Ref to read current defaultAgentId without adding it as a dependency.
|
||||
const defaultAgentIdRef = useRef(defaultAgentId);
|
||||
defaultAgentIdRef.current = defaultAgentId;
|
||||
|
||||
const codex = discoveredAgents.find((a) => a.command === "codex");
|
||||
setCodexPathInfo(
|
||||
codex
|
||||
? { path: codex.path, version: codex.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
|
||||
const claude = discoveredAgents.find((a) => a.command === "claude");
|
||||
setClaudePathInfo(
|
||||
claude
|
||||
? { path: claude.path, version: claude.version, available: true }
|
||||
: { path: null, version: null, available: false },
|
||||
);
|
||||
}, [isDiscovering, discoveredAgents]);
|
||||
|
||||
// Auto-register discovered agents in externalAgents
|
||||
useEffect(() => {
|
||||
if (isDiscovering || discoveredAgents.length === 0) return;
|
||||
|
||||
setExternalAgents((prev) => {
|
||||
const agentsToRegister: ExternalAgentConfig[] = [];
|
||||
|
||||
for (const da of discoveredAgents) {
|
||||
if (da.command !== "codex" && da.command !== "claude") continue;
|
||||
const agentId = `discovered_${da.command}`;
|
||||
if (prev.some((ea) => ea.id === agentId)) continue;
|
||||
agentsToRegister.push(enableAgent(da));
|
||||
}
|
||||
|
||||
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
|
||||
});
|
||||
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
|
||||
const resolveAgentPath = useCallback(async (
|
||||
agentKey: ManagedAgentKey,
|
||||
customPath = "",
|
||||
) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiResolveCli) return;
|
||||
if (!bridge?.aiResolveCli) return null;
|
||||
|
||||
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
|
||||
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
|
||||
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
|
||||
const setInfo = agentKey === "codex"
|
||||
? setCodexPathInfo
|
||||
: agentKey === "claude"
|
||||
? setClaudePathInfo
|
||||
: setCopilotPathInfo;
|
||||
const setResolving = agentKey === "codex"
|
||||
? setIsResolvingCodex
|
||||
: agentKey === "claude"
|
||||
? setIsResolvingClaude
|
||||
: setIsResolvingCopilot;
|
||||
|
||||
setResolving(true);
|
||||
try {
|
||||
@@ -174,32 +213,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
});
|
||||
setInfo(result);
|
||||
|
||||
// Register/update in externalAgents if valid
|
||||
if (result.available && result.path) {
|
||||
const agentId = `discovered_${agentKey}`;
|
||||
const defaults = AGENT_DEFAULTS[agentKey];
|
||||
setExternalAgents((prev) => {
|
||||
const idx = prev.findIndex((a) => a.id === agentId);
|
||||
const config: ExternalAgentConfig = {
|
||||
id: agentId,
|
||||
command: result.path!,
|
||||
enabled: true,
|
||||
...defaults,
|
||||
};
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...updated[idx], command: result.path! };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, config];
|
||||
});
|
||||
// Consolidate managed agent entries using the callback form of
|
||||
// setExternalAgents so we never depend on externalAgents directly.
|
||||
// All three agents resolve concurrently on mount — React runs
|
||||
// state updater callbacks sequentially, so updating the ref inside
|
||||
// ensures later calls see earlier defaultAgentId changes.
|
||||
let nextDefaultId: string | null = null;
|
||||
setExternalAgents((prev) => {
|
||||
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
|
||||
if (state.defaultAgentId !== defaultAgentIdRef.current) {
|
||||
nextDefaultId = state.defaultAgentId;
|
||||
defaultAgentIdRef.current = state.defaultAgentId;
|
||||
}
|
||||
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
|
||||
});
|
||||
if (nextDefaultId !== null) {
|
||||
setDefaultAgentId(nextDefaultId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error("Path resolution failed:", err);
|
||||
return null;
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
|
||||
}, [setExternalAgents, setDefaultAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
|
||||
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
|
||||
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
|
||||
}, [resolveAgentPath]);
|
||||
|
||||
// Validate a custom path for an agent
|
||||
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
|
||||
const customPath = agentKey === "codex"
|
||||
? codexCustomPath
|
||||
: agentKey === "claude"
|
||||
? claudeCustomPath
|
||||
: copilotCustomPath;
|
||||
await resolveAgentPath(agentKey, customPath);
|
||||
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
|
||||
|
||||
// Add a new provider from preset
|
||||
const handleAddProvider = useCallback(
|
||||
@@ -457,7 +512,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<CodexConnectionCard
|
||||
pathInfo={codexPathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingCodex}
|
||||
isResolvingPath={isResolvingCodex}
|
||||
customPath={codexCustomPath}
|
||||
onCustomPathChange={setCodexCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("codex")}
|
||||
@@ -483,13 +538,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
|
||||
<ClaudeCodeCard
|
||||
pathInfo={claudePathInfo}
|
||||
isResolvingPath={isDiscovering || isResolvingClaude}
|
||||
isResolvingPath={isResolvingClaude}
|
||||
customPath={claudeCustomPath}
|
||||
onCustomPathChange={setClaudeCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("claude")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- GitHub Copilot CLI Section -- */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
|
||||
</div>
|
||||
|
||||
<CopilotCliCard
|
||||
pathInfo={copilotPathInfo}
|
||||
isResolvingPath={isResolvingCopilot}
|
||||
customPath={copilotCustomPath}
|
||||
onCustomPathChange={setCopilotCustomPath}
|
||||
onRecheckPath={() => void handleCheckCustomPath("copilot")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -- Default Agent Section -- */}
|
||||
{agentOptions.length > 1 && (
|
||||
<div className="space-y-4">
|
||||
@@ -507,7 +578,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
value={defaultAgentId}
|
||||
options={agentOptions}
|
||||
onChange={setDefaultAgentId}
|
||||
className="w-48"
|
||||
className="w-64"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
87
components/settings/tabs/ai/CopilotCliCard.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { AgentPathInfo } from "./types";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
|
||||
export const CopilotCliCard: React.FC<{
|
||||
pathInfo: AgentPathInfo | null;
|
||||
isResolvingPath: boolean;
|
||||
customPath: string;
|
||||
onCustomPathChange: (path: string) => void;
|
||||
onRecheckPath: () => void;
|
||||
}> = ({
|
||||
pathInfo,
|
||||
isResolvingPath,
|
||||
customPath,
|
||||
onCustomPathChange,
|
||||
onRecheckPath,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const found = pathInfo?.available;
|
||||
|
||||
const statusText = isResolvingPath
|
||||
? t('ai.copilot.detecting')
|
||||
: found
|
||||
? t('ai.copilot.detected')
|
||||
: t('ai.copilot.notFound');
|
||||
|
||||
const statusClassName = isResolvingPath
|
||||
? "text-muted-foreground"
|
||||
: found
|
||||
? "text-emerald-500"
|
||||
: "text-amber-500";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIconBadge providerId="copilot" size="sm" />
|
||||
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-5">
|
||||
{t('ai.copilot.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{found ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : !isResolvingPath ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-amber-500">
|
||||
{t('ai.copilot.notFoundHint')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.copilot.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
{t('ai.copilot.check')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"object-contain brightness-0 invert",
|
||||
"object-contain",
|
||||
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
|
||||
size === "sm" ? "w-3 h-3" : "w-4 h-4",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
|
||||
export { AddProviderDropdown } from "./AddProviderDropdown";
|
||||
export { CodexConnectionCard } from "./CodexConnectionCard";
|
||||
export { ClaudeCodeCard } from "./ClaudeCodeCard";
|
||||
export { CopilotCliCard } from "./CopilotCliCard";
|
||||
export { SafetySettings } from "./SafetySettings";
|
||||
|
||||
@@ -82,6 +82,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
|
||||
acpCommand: "claude-agent-acp",
|
||||
acpArgs: [],
|
||||
},
|
||||
copilot: {
|
||||
name: "GitHub Copilot CLI",
|
||||
args: ["-p", "{prompt}"],
|
||||
icon: "copilot",
|
||||
acpCommand: "copilot",
|
||||
acpArgs: ["--acp", "--stdio"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
|
||||
// Provider icon helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SettingsIconId = AIProviderId | "claude";
|
||||
export type SettingsIconId = AIProviderId | "claude" | "copilot";
|
||||
|
||||
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
|
||||
openai: "/ai/providers/openai.svg",
|
||||
anthropic: "/ai/providers/anthropic.svg",
|
||||
claude: "/ai/agents/claude.svg",
|
||||
copilot: "/ai/agents/copilot.svg",
|
||||
google: "/ai/providers/google.svg",
|
||||
ollama: "/ai/providers/ollama.svg",
|
||||
openrouter: "/ai/providers/openrouter.svg",
|
||||
@@ -124,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
|
||||
openai: "bg-emerald-600",
|
||||
anthropic: "bg-orange-600",
|
||||
claude: "bg-orange-600",
|
||||
copilot: "border border-zinc-300 bg-white",
|
||||
google: "bg-blue-600",
|
||||
ollama: "bg-purple-600",
|
||||
openrouter: "bg-pink-600",
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface SftpTransferSource {
|
||||
export interface SftpPaneCallbacks {
|
||||
onConnect: (host: Host | "local") => void;
|
||||
onDisconnect: () => void;
|
||||
onPrepareSelection: () => void;
|
||||
onNavigateTo: (path: string) => void;
|
||||
onNavigateUp: () => void;
|
||||
onRefresh: () => void;
|
||||
|
||||
@@ -76,8 +76,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleOpen}
|
||||
className={cn(
|
||||
"px-4 py-2 items-center cursor-pointer text-sm hover:bg-accent/50",
|
||||
isSelectionVisible && "bg-accent text-accent-foreground",
|
||||
"px-4 py-2 items-center cursor-pointer text-sm",
|
||||
isSelectionVisible
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "hover:bg-accent/50",
|
||||
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
|
||||
)}
|
||||
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
|
||||
@@ -130,6 +132,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
|
||||
if (prev.index !== next.index) return false;
|
||||
if (prev.isSelected !== next.isSelected) return false;
|
||||
// Only re-render for showSelectionHighlight changes when the row is actually selected
|
||||
if (prev.isSelected && prev.showSelectionHighlight !== next.showSelectionHighlight) return false;
|
||||
if (prev.isDragOver !== next.isDragOver) return false;
|
||||
if (prev.columnWidths.name !== next.columnWidths.name) return false;
|
||||
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
|
||||
import { SftpHostPicker } from "./index";
|
||||
import type { Host } from "../../types";
|
||||
|
||||
interface SftpPaneDialogsProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
hostLabel?: string;
|
||||
currentPath?: string;
|
||||
// New folder
|
||||
showNewFolderDialog: boolean;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
@@ -61,8 +64,15 @@ interface SftpPaneDialogsProps {
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
|
||||
label ? (
|
||||
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
|
||||
) : null;
|
||||
|
||||
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
t,
|
||||
hostLabel,
|
||||
currentPath,
|
||||
showNewFolderDialog,
|
||||
setShowNewFolderDialog,
|
||||
newFolderName,
|
||||
@@ -100,12 +110,36 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
setHostSearch,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}) => (
|
||||
}) => {
|
||||
const isSingleDeleteTarget = deleteTargets.length === 1;
|
||||
const deletePath = (() => {
|
||||
if (isSingleDeleteTarget) {
|
||||
return deleteTargets[0];
|
||||
}
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) return uniquePaths[0];
|
||||
if (uniquePaths.length > 1) return "Multiple locations";
|
||||
return currentPath;
|
||||
})();
|
||||
const showDeleteList = deleteTargets.length > 1;
|
||||
const deleteListItems = (() => {
|
||||
if (!showDeleteList) return [];
|
||||
|
||||
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
|
||||
if (uniquePaths.length === 1) {
|
||||
return deleteTargets.map((target) => getFileName(target) || target);
|
||||
}
|
||||
return deleteTargets;
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dialogs */}
|
||||
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -148,6 +182,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -192,6 +227,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
|
||||
@@ -217,6 +253,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<HostHint label={hostLabel} />
|
||||
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
@@ -258,19 +295,39 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sftp.deleteConfirm.desc")}
|
||||
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteTargets.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="space-y-3">
|
||||
{hostLabel || deletePath ? (
|
||||
<div className="text-xs text-muted-foreground space-y-1.5">
|
||||
{hostLabel ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
|
||||
<span className="break-all">{hostLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{deletePath ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
|
||||
<span className="break-all">{deletePath}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
{showDeleteList ? (
|
||||
<div className="max-h-32 overflow-auto text-sm space-y-1">
|
||||
{deleteListItems.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -310,4 +367,5 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ import { getParentPath, joinPath } from '../../application/state/sftp/utils';
|
||||
import { buildSftpColumnTemplate, filterHiddenFiles, formatBytes, formatDate, getFileIcon, isNavigableDirectory, sortSftpEntries, type ColumnWidths, type SortField, type SortOrder } from './utils';
|
||||
import type { SftpTransferSource } from './SftpContext';
|
||||
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
|
||||
import { sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
@@ -55,6 +55,7 @@ type NodeDescriptor =
|
||||
interface SftpPaneTreeViewProps {
|
||||
pane: SftpPane;
|
||||
side: 'left' | 'right';
|
||||
onPrepareSelection: () => void;
|
||||
onLoadChildren: (path: string) => Promise<SftpFileEntry[]>;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onNavigateUp: () => void;
|
||||
@@ -126,8 +127,10 @@ const TreeNode = React.memo<TreeNodeProps>(({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid items-center gap-x-1 px-2 cursor-pointer select-none hover:bg-accent/50 text-sm',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
'grid items-center gap-x-1 px-2 cursor-pointer select-none text-sm',
|
||||
isSelected
|
||||
? 'bg-accent text-accent-foreground hover:bg-accent'
|
||||
: 'hover:bg-accent/50',
|
||||
isDragOver && 'ring-2 ring-primary/50 ring-inset bg-primary/10',
|
||||
)}
|
||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||
@@ -257,6 +260,7 @@ interface ContextTarget {
|
||||
export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
pane,
|
||||
side,
|
||||
onPrepareSelection,
|
||||
onLoadChildren,
|
||||
onMoveEntriesToPath,
|
||||
onNavigateUp,
|
||||
@@ -368,12 +372,21 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
const [rootEntries, setRootEntries] = useState<SftpFileEntry[]>(pane.files ?? []);
|
||||
const [resolvedRootPath, setResolvedRootPath] = useState(pane.connection?.currentPath ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPaths.size === 0) {
|
||||
lastClickedPathRef.current = null;
|
||||
sftpKeyboardSelectionStore.clear(pane.id);
|
||||
}
|
||||
}, [pane.id, selectedPaths.size]);
|
||||
|
||||
const onOpenEntryRef = useRef(onOpenEntry);
|
||||
onOpenEntryRef.current = onOpenEntry;
|
||||
const onNavigateUpRef = useRef(onNavigateUp);
|
||||
onNavigateUpRef.current = onNavigateUp;
|
||||
const onNavigateToRef = useRef(onNavigateTo);
|
||||
onNavigateToRef.current = onNavigateTo;
|
||||
const onPrepareSelectionRef = useRef(onPrepareSelection);
|
||||
onPrepareSelectionRef.current = onPrepareSelection;
|
||||
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
|
||||
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
|
||||
const onDragStartRef = useRef(onDragStart);
|
||||
@@ -508,6 +521,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
invalidateTreeCache();
|
||||
dispatchTreePaths({ type: 'RESET' });
|
||||
sftpTreeSelectionStore.clearSelection(pane.id);
|
||||
sftpKeyboardSelectionStore.clear(pane.id);
|
||||
lastClickedPathRef.current = null;
|
||||
}
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.id, invalidateTreeCache]);
|
||||
@@ -556,11 +570,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
focusTreeContainer();
|
||||
|
||||
const state = treeSelectionStateRef.current;
|
||||
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
|
||||
const nextSelection: string[] = (() => {
|
||||
if (e.shiftKey && lastClickedPathRef.current) {
|
||||
const items = state.visibleItems;
|
||||
const lastIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? -1;
|
||||
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
|
||||
if (lastIdx !== -1 && currentIdx !== -1) {
|
||||
const parentPath = getParentPath(entryPath);
|
||||
const start = Math.min(lastIdx, currentIdx);
|
||||
@@ -582,7 +596,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
return [entryPath];
|
||||
})();
|
||||
|
||||
onPrepareSelectionRef.current();
|
||||
sftpTreeSelectionStore.setSelection(pane.id, nextSelection);
|
||||
if (currentIdx !== -1) {
|
||||
if (e.shiftKey && lastClickedPathRef.current) {
|
||||
const anchorIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? currentIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, currentIdx);
|
||||
} else {
|
||||
sftpKeyboardSelectionStore.set(pane.id, currentIdx, currentIdx);
|
||||
}
|
||||
}
|
||||
|
||||
lastClickedPathRef.current = entryPath;
|
||||
}, [focusTreeContainer, pane.id]);
|
||||
@@ -610,23 +633,33 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
|
||||
const delta = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const currentSelected = [...selectedPathsRef.current];
|
||||
let currentIdx = -1;
|
||||
if (currentSelected.length === 1) {
|
||||
currentIdx = state.visibleIndexByPath.get(currentSelected[0]) ?? -1;
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
if (currentSelected.length === 0) {
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else {
|
||||
const focusPath = items[focusIdx]?.path;
|
||||
if (!focusPath || !state.selectedPaths.has(focusPath)) {
|
||||
focusIdx = state.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
}
|
||||
|
||||
let nextIdx = currentIdx + delta;
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= items.length) nextIdx = items.length - 1;
|
||||
|
||||
onPrepareSelectionRef.current();
|
||||
if (e.shiftKey && currentSelected.length > 0) {
|
||||
const anchorIdx = currentIdx >= 0 ? currentIdx : 0;
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
const paths = items.slice(start, end + 1).map((item) => item.path);
|
||||
sftpTreeSelectionStore.setSelection(pane.id, paths);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
|
||||
lastClickedPathRef.current = items[nextIdx].path;
|
||||
|
||||
@@ -67,25 +67,31 @@ SftpPaneWrapper.displayName = "SftpPaneWrapper";
|
||||
interface SftpPaneViewProps {
|
||||
side: "left" | "right";
|
||||
pane: SftpPane;
|
||||
dialogActionScopeId: string;
|
||||
isPaneFocused: boolean;
|
||||
sftpDefaultViewMode: 'list' | 'tree';
|
||||
showHeader?: boolean;
|
||||
showEmptyHeader?: boolean;
|
||||
onToggleShowHiddenFiles?: () => void;
|
||||
onGoToTerminalCwd?: () => void;
|
||||
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
|
||||
forceActive?: boolean;
|
||||
}
|
||||
|
||||
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
side,
|
||||
pane,
|
||||
dialogActionScopeId,
|
||||
isPaneFocused,
|
||||
sftpDefaultViewMode,
|
||||
showHeader = true,
|
||||
showEmptyHeader = true,
|
||||
onToggleShowHiddenFiles,
|
||||
onGoToTerminalCwd,
|
||||
forceActive,
|
||||
}) => {
|
||||
const isActive = true;
|
||||
const activeTabId = useActiveTabId(side);
|
||||
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
|
||||
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
@@ -354,7 +360,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
useSftpDialogActionHandler(side, dialogActionHandlers);
|
||||
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
|
||||
|
||||
const handleSortWithTransition = (field: typeof sortField) => {
|
||||
startTransition(() => handleSort(field));
|
||||
@@ -495,6 +501,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
<SftpPaneTreeView
|
||||
pane={pane}
|
||||
side={side}
|
||||
onPrepareSelection={callbacks.onPrepareSelection}
|
||||
onLoadChildren={callbacks.onListDirectory}
|
||||
onMoveEntriesToPath={handleMoveEntriesToPath}
|
||||
onNavigateUp={callbacks.onNavigateUp}
|
||||
@@ -573,6 +580,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
|
||||
<SftpPaneDialogs
|
||||
t={t}
|
||||
hostLabel={pane.connection?.hostLabel}
|
||||
currentPath={pane.connection?.currentPath}
|
||||
showNewFolderDialog={showNewFolderDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
newFolderName={newFolderName}
|
||||
@@ -621,6 +630,7 @@ const sftpPaneViewAreEqual = (
|
||||
): boolean => {
|
||||
if (prev.pane !== next.pane) return false;
|
||||
if (prev.side !== next.side) return false;
|
||||
if (prev.dialogActionScopeId !== next.dialogActionScopeId) return false;
|
||||
if (prev.isPaneFocused !== next.isPaneFocused) return false;
|
||||
if (prev.showHeader !== next.showHeader) return false;
|
||||
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
|
||||
|
||||
@@ -214,6 +214,22 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
[onCloseTab],
|
||||
);
|
||||
|
||||
const handleSelectTabClick = useCallback(
|
||||
(e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
onSelectTab(tabId);
|
||||
},
|
||||
[onSelectTab],
|
||||
);
|
||||
|
||||
const handleAddTabClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAddTab();
|
||||
},
|
||||
[onAddTab],
|
||||
);
|
||||
|
||||
// Cross-pane drag handlers
|
||||
const handleCrossPaneDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
@@ -302,7 +318,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
@@ -379,7 +395,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
{/* Add tab button */}
|
||||
<button
|
||||
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
|
||||
onClick={onAddTab}
|
||||
onClick={handleAddTabClick}
|
||||
title={t("sftp.tabs.addTab")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
@@ -418,4 +434,3 @@ const sftpTabBarAreEqual = (
|
||||
|
||||
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
|
||||
SftpTabBar.displayName = "SftpTabBar";
|
||||
|
||||
|
||||
37
components/sftp/hooks/selectionScope.ts
Normal file
37
components/sftp/hooks/selectionScope.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
|
||||
export interface SftpSelectionTarget {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
export const keepOnlyPaneSelections = (
|
||||
sftp: SftpStateApi,
|
||||
target: SftpSelectionTarget | null,
|
||||
) => {
|
||||
sftp.clearSelectionsExcept(target);
|
||||
const paneIds = [
|
||||
...sftp.leftTabs.tabs.map((tab) => tab.id),
|
||||
...sftp.rightTabs.tabs.map((tab) => tab.id),
|
||||
];
|
||||
for (const paneId of paneIds) {
|
||||
if (target?.tabId === paneId) continue;
|
||||
sftpTreeSelectionStore.clearSelection(paneId);
|
||||
}
|
||||
};
|
||||
|
||||
export const keepOnlyActivePaneSelections = (
|
||||
sftp: SftpStateApi,
|
||||
side: "left" | "right",
|
||||
): SftpSelectionTarget | null => {
|
||||
const tabId = sftp.getActiveTabId(side);
|
||||
if (!tabId) {
|
||||
keepOnlyPaneSelections(sftp, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = { side, tabId } as const;
|
||||
keepOnlyPaneSelections(sftp, target);
|
||||
return target;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null
|
||||
interface SftpDialogAction {
|
||||
type: SftpDialogActionType;
|
||||
targetSide: SftpFocusedSide;
|
||||
targetScopeId: string;
|
||||
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
|
||||
timestamp: number; // To distinguish different triggers of the same action
|
||||
}
|
||||
@@ -37,13 +38,14 @@ export const sftpDialogActionStore = {
|
||||
/**
|
||||
* Trigger a dialog action
|
||||
*/
|
||||
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
|
||||
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
|
||||
if (!type) {
|
||||
dialogAction = null;
|
||||
} else {
|
||||
dialogAction = {
|
||||
type,
|
||||
targetSide: sftpFocusStore.getFocusedSide(),
|
||||
targetScopeId,
|
||||
targetFiles,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
@@ -82,17 +84,19 @@ export const useSftpDialogAction = (): SftpDialogAction | null => {
|
||||
*/
|
||||
export const useSftpDialogActionHandler = (
|
||||
side: SftpFocusedSide,
|
||||
scopeId: string,
|
||||
handlers: {
|
||||
onRename?: (fileName: string) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
onNewFile?: () => void;
|
||||
}
|
||||
},
|
||||
isActive = true
|
||||
) => {
|
||||
const action = useSftpDialogAction();
|
||||
|
||||
useEffect(() => {
|
||||
if (!action || action.targetSide !== side) return;
|
||||
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
|
||||
|
||||
// Handle the action and clear it
|
||||
switch (action.type) {
|
||||
@@ -116,5 +120,5 @@ export const useSftpDialogActionHandler = (
|
||||
|
||||
// Clear the action after handling
|
||||
sftpDialogActionStore.clear();
|
||||
}, [action, side, handlers]);
|
||||
}, [action, side, scopeId, handlers, isActive]);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { sftpFocusStore } from "./useSftpFocusedPane";
|
||||
import { sftpDialogActionStore } from "./useSftpDialogAction";
|
||||
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
|
||||
import { sftpListOrderStore } from "./useSftpListOrderStore";
|
||||
import { keepOnlyPaneSelections } from "./selectionScope";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
@@ -72,13 +73,15 @@ export const sftpTreeEnterStore = {
|
||||
// indices per pane so Shift+Arrow extends correctly.
|
||||
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
|
||||
|
||||
function getKbSelection(paneId: string) {
|
||||
return _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 };
|
||||
}
|
||||
|
||||
function setKbSelection(paneId: string, anchor: number, focus: number) {
|
||||
_kbSelectionState.set(paneId, { anchor, focus });
|
||||
}
|
||||
export const sftpKeyboardSelectionStore = {
|
||||
get: (paneId: string) => _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 },
|
||||
set: (paneId: string, anchor: number, focus: number) => {
|
||||
_kbSelectionState.set(paneId, { anchor, focus });
|
||||
},
|
||||
clear: (paneId: string) => {
|
||||
_kbSelectionState.delete(paneId);
|
||||
},
|
||||
};
|
||||
|
||||
// Basic navigation keys that work even when custom hotkeys are disabled.
|
||||
const BASIC_NAV_KEYS: Record<string, string> = {
|
||||
@@ -90,6 +93,7 @@ interface UseSftpKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
dialogActionScopeId: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -115,6 +119,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
dialogActionScopeId,
|
||||
isActive,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -134,6 +139,12 @@ export const useSftpKeyboardShortcuts = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip when a dialog or overlay is open to prevent SFTP shortcuts from
|
||||
// firing while interacting with unrelated dialogs (e.g. settings, confirm).
|
||||
if (document.querySelector('[role="dialog"][data-state="open"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Arrow Up/Down: move selection ────────────────────────────────
|
||||
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const sftp = sftpRef.current;
|
||||
@@ -155,29 +166,35 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
// Resolve current focus position from tracked state, falling back
|
||||
// to the actual selection when out of sync (e.g. after mouse click).
|
||||
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
const currentSelected = Array.from(pane.selectedFiles) as string[];
|
||||
// If the tracked focus doesn't match the actual selection, re-sync
|
||||
if (currentSelected.length >= 1 && !currentSelected.includes(listItems[focusIdx])) {
|
||||
if (currentSelected.length === 0) {
|
||||
// No selection: start from before the list so the first arrow press lands on item 0.
|
||||
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else if (!currentSelected.includes(listItems[focusIdx])) {
|
||||
// Tracked focus doesn't match actual selection, re-sync
|
||||
focusIdx = listItems.indexOf(currentSelected[currentSelected.length - 1]);
|
||||
if (focusIdx < 0) focusIdx = 0;
|
||||
anchorIdx = focusIdx;
|
||||
setKbSelection(pane.id, anchorIdx, focusIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= listItems.length) nextIdx = listItems.length - 1;
|
||||
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
if (e.shiftKey) {
|
||||
// Shift+Arrow: extend range from anchor to new focus
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
sftp.rangeSelect(focusedSide, listItems.slice(start, end + 1));
|
||||
setKbSelection(pane.id, anchorIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
|
||||
setKbSelection(pane.id, nextIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -191,26 +208,35 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const currentSelected = [...treeState.selectedPaths];
|
||||
|
||||
// Use tracked state, re-sync if needed
|
||||
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
|
||||
if (currentSelected.length >= 1 && items[focusIdx]?.path !== currentSelected[currentSelected.length - 1]) {
|
||||
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
setKbSelection(pane.id, anchorIdx, focusIdx);
|
||||
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
|
||||
if (currentSelected.length === 0) {
|
||||
// No selection: start from before the list so the first arrow press lands on item 0.
|
||||
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
|
||||
anchorIdx = e.shiftKey ? 0 : -1;
|
||||
focusIdx = -1;
|
||||
} else {
|
||||
const focusPath = items[focusIdx]?.path;
|
||||
if (!focusPath || !treeState.selectedPaths.has(focusPath)) {
|
||||
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
|
||||
anchorIdx = focusIdx;
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
|
||||
}
|
||||
}
|
||||
|
||||
let nextIdx = focusIdx + delta;
|
||||
if (nextIdx < 0) nextIdx = 0;
|
||||
if (nextIdx >= items.length) nextIdx = items.length - 1;
|
||||
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
if (e.shiftKey) {
|
||||
const start = Math.min(anchorIdx, nextIdx);
|
||||
const end = Math.max(anchorIdx, nextIdx);
|
||||
const paths = items.slice(start, end + 1).map(item => item.path);
|
||||
sftpTreeSelectionStore.setSelection(pane.id, paths);
|
||||
setKbSelection(pane.id, anchorIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
|
||||
} else {
|
||||
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
|
||||
setKbSelection(pane.id, nextIdx, nextIdx);
|
||||
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -350,8 +376,10 @@ export const useSftpKeyboardShortcuts = ({
|
||||
if (!clipboard || clipboard.files.length === 0) return;
|
||||
|
||||
// Use startTransfer to paste files from source to current pane
|
||||
// The transfer direction is determined by clipboard sourceSide and current focusedSide
|
||||
if (clipboard.sourceSide !== focusedSide) {
|
||||
// Allow paste when source and target are different connections, even on the same side
|
||||
const isSameConnection = clipboard.sourceSide === focusedSide
|
||||
&& clipboard.sourceConnectionId === pane.connection.id;
|
||||
if (!isSameConnection) {
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
|
||||
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
|
||||
|
||||
@@ -439,6 +467,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
case "sftpSelectAll": {
|
||||
if (treeSelectionState.visibleItems.length > 0) {
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
sftpTreeSelectionStore.selectAllVisible(pane.id);
|
||||
break;
|
||||
}
|
||||
@@ -458,33 +487,38 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const allFileNames = visibleFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
|
||||
sftp.rangeSelect(focusedSide, allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
if (treeActionSelection.length === 1) {
|
||||
sftpDialogActionStore.trigger("rename", [treeActionSelection[0].path]);
|
||||
sftpDialogActionStore.trigger("rename", dialogActionScopeId, [treeActionSelection[0].path]);
|
||||
break;
|
||||
}
|
||||
|
||||
// Trigger rename for the first selected file
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length !== 1) return;
|
||||
sftpDialogActionStore.trigger("rename", selectedFiles);
|
||||
sftpDialogActionStore.trigger("rename", dialogActionScopeId, selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
if (treeActionSelection.length > 0) {
|
||||
sftpDialogActionStore.trigger("delete", treeActionSelection.map((entry) => entry.path));
|
||||
sftpDialogActionStore.trigger(
|
||||
"delete",
|
||||
dialogActionScopeId,
|
||||
treeActionSelection.map((entry) => entry.path),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Delete selected files
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
sftpDialogActionStore.trigger("delete", selectedFiles);
|
||||
sftpDialogActionStore.trigger("delete", dialogActionScopeId, selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -496,7 +530,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
sftpDialogActionStore.trigger("newFolder");
|
||||
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -559,7 +593,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { isNavigableDirectory } from "../index";
|
||||
@@ -67,6 +67,12 @@ export const useSftpPaneDragAndSelect = ({
|
||||
const onUploadRef = useRef(onUploadExternalFiles);
|
||||
onUploadRef.current = onUploadExternalFiles;
|
||||
|
||||
useEffect(() => {
|
||||
if (pane.selectedFiles.size === 0) {
|
||||
lastSelectedIndexRef.current = null;
|
||||
}
|
||||
}, [pane.selectedFiles.size]);
|
||||
|
||||
const getSamePaneDragPaths = useCallback((): string[] | null => {
|
||||
const dragged = draggedFilesRef.current;
|
||||
if (!dragged || dragged.length === 0) return null;
|
||||
|
||||
@@ -99,6 +99,17 @@ export const sftpTreeSelectionStore = {
|
||||
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
|
||||
},
|
||||
|
||||
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
|
||||
const keep = new Set(paneIdsToKeep ?? []);
|
||||
Array.from(paneStates.keys()).forEach((paneId) => {
|
||||
if (keep.has(paneId)) return;
|
||||
setPaneState(paneId, (state) => {
|
||||
if (state.selectedPaths.size === 0) return state;
|
||||
return { ...state, selectedPaths: EMPTY_PATHS };
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
selectAllVisible: (paneId: string) => {
|
||||
setPaneState(paneId, (state) => ({
|
||||
...state,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
|
||||
import { keepOnlyActivePaneSelections } from "./selectionScope";
|
||||
|
||||
interface UseSftpViewPaneActionsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -14,6 +15,8 @@ interface UseSftpViewPaneActionsResult {
|
||||
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
|
||||
onDisconnectLeft: () => void;
|
||||
onDisconnectRight: () => void;
|
||||
onPrepareSelectionLeft: () => void;
|
||||
onPrepareSelectionRight: () => void;
|
||||
onNavigateToLeft: (path: string) => void;
|
||||
onNavigateToRight: (path: string) => void;
|
||||
onNavigateUpLeft: () => void;
|
||||
@@ -126,6 +129,12 @@ export const useSftpViewPaneActions = ({
|
||||
);
|
||||
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
|
||||
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
|
||||
const onPrepareSelectionLeft = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "left");
|
||||
}, [sftpRef]);
|
||||
const onPrepareSelectionRight = useCallback(() => {
|
||||
keepOnlyActivePaneSelections(sftpRef.current, "right");
|
||||
}, [sftpRef]);
|
||||
const onNavigateToLeft = useCallback(
|
||||
(path: string) => sftpRef.current.navigateTo("left", path),
|
||||
[sftpRef],
|
||||
@@ -151,20 +160,32 @@ export const useSftpViewPaneActions = ({
|
||||
[sftpRef],
|
||||
);
|
||||
const onToggleSelectionLeft = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
|
||||
[sftpRef],
|
||||
(name: string, multi: boolean) => {
|
||||
onPrepareSelectionLeft();
|
||||
sftpRef.current.toggleSelection("left", name, multi);
|
||||
},
|
||||
[onPrepareSelectionLeft, sftpRef],
|
||||
);
|
||||
const onToggleSelectionRight = useCallback(
|
||||
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
|
||||
[sftpRef],
|
||||
(name: string, multi: boolean) => {
|
||||
onPrepareSelectionRight();
|
||||
sftpRef.current.toggleSelection("right", name, multi);
|
||||
},
|
||||
[onPrepareSelectionRight, sftpRef],
|
||||
);
|
||||
const onRangeSelectLeft = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
|
||||
[sftpRef],
|
||||
(fileNames: string[]) => {
|
||||
onPrepareSelectionLeft();
|
||||
sftpRef.current.rangeSelect("left", fileNames);
|
||||
},
|
||||
[onPrepareSelectionLeft, sftpRef],
|
||||
);
|
||||
const onRangeSelectRight = useCallback(
|
||||
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
|
||||
[sftpRef],
|
||||
(fileNames: string[]) => {
|
||||
onPrepareSelectionRight();
|
||||
sftpRef.current.rangeSelect("right", fileNames);
|
||||
},
|
||||
[onPrepareSelectionRight, sftpRef],
|
||||
);
|
||||
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
|
||||
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
|
||||
@@ -266,6 +287,8 @@ export const useSftpViewPaneActions = ({
|
||||
onConnectRight,
|
||||
onDisconnectLeft,
|
||||
onDisconnectRight,
|
||||
onPrepareSelectionLeft,
|
||||
onPrepareSelectionRight,
|
||||
onNavigateToLeft,
|
||||
onNavigateToRight,
|
||||
onNavigateUpLeft,
|
||||
|
||||
@@ -140,6 +140,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectLeft,
|
||||
onDisconnect: paneActions.onDisconnectLeft,
|
||||
onPrepareSelection: paneActions.onPrepareSelectionLeft,
|
||||
onNavigateTo: paneActions.onNavigateToLeft,
|
||||
onNavigateUp: paneActions.onNavigateUpLeft,
|
||||
onRefresh: paneActions.onRefreshLeft,
|
||||
@@ -176,6 +177,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
() => ({
|
||||
onConnect: paneActions.onConnectRight,
|
||||
onDisconnect: paneActions.onDisconnectRight,
|
||||
onPrepareSelection: paneActions.onPrepareSelectionRight,
|
||||
onNavigateTo: paneActions.onNavigateToRight,
|
||||
onNavigateUp: paneActions.onNavigateUpRight,
|
||||
onRefresh: paneActions.onRefreshRight,
|
||||
|
||||
@@ -21,8 +21,8 @@ interface UseSftpViewTabsResult {
|
||||
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleAddTabLeft: () => void;
|
||||
handleAddTabRight: () => void;
|
||||
handleAddTabLeft: () => string;
|
||||
handleAddTabRight: () => string;
|
||||
handleCloseTabLeft: (tabId: string) => void;
|
||||
handleCloseTabRight: (tabId: string) => void;
|
||||
handleSelectTabLeft: (tabId: string) => void;
|
||||
@@ -42,13 +42,15 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
|
||||
const [hostSearchRight, setHostSearchRight] = useState("");
|
||||
|
||||
const handleAddTabLeft = useCallback(() => {
|
||||
sftpRef.current.addTab("left");
|
||||
const tabId = sftpRef.current.addTab("left");
|
||||
setShowHostPickerLeft(true);
|
||||
return tabId;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleAddTabRight = useCallback(() => {
|
||||
sftpRef.current.addTab("right");
|
||||
const tabId = sftpRef.current.addTab("right");
|
||||
setShowHostPickerRight(true);
|
||||
return tabId;
|
||||
}, [sftpRef]);
|
||||
|
||||
const handleCloseTabLeft = useCallback((tabId: string) => {
|
||||
|
||||
@@ -48,6 +48,8 @@ interface AutocompletePopupProps {
|
||||
onRequestReposition?: () => void;
|
||||
/** Offset from top of container to terminal content area (toolbar + search bar) */
|
||||
searchBarOffset?: number;
|
||||
/** Called when user clicks outside the popup to dismiss it */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
|
||||
@@ -105,7 +107,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
containerRef,
|
||||
onRequestReposition,
|
||||
searchBarOffset: _searchBarOffset = 30,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
@@ -148,6 +152,18 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
};
|
||||
}, [containerRef, onRequestReposition, visible]);
|
||||
|
||||
// Dismiss popup when clicking outside
|
||||
useEffect(() => {
|
||||
if (!visible || !onDismiss) return;
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [visible, onDismiss]);
|
||||
|
||||
if (!visible || suggestions.length === 0) return null;
|
||||
|
||||
const bg = themeColors?.background ?? "#1e1e2e";
|
||||
@@ -217,6 +233,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${clampedLeft}px`,
|
||||
|
||||
@@ -218,7 +218,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
|
||||
return (
|
||||
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
className
|
||||
)}>
|
||||
@@ -253,7 +253,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
|
||||
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
|
||||
width,
|
||||
className
|
||||
)}>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
|
||||
const https = require("node:https");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const { URL } = require("node:url");
|
||||
const { spawn, execFileSync } = require("node:child_process");
|
||||
const { existsSync } = require("node:fs");
|
||||
const fs = require("node:fs");
|
||||
const { existsSync } = fs;
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
|
||||
@@ -60,7 +62,6 @@ const acpProviders = new Map();
|
||||
const acpActiveStreams = new Map();
|
||||
const acpRequestSessions = new Map();
|
||||
const acpPendingCancelRequests = new Set();
|
||||
const acpForceProviderReset = new Set();
|
||||
const acpChatRuns = new Map();
|
||||
|
||||
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
||||
@@ -141,21 +142,39 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
|
||||
}
|
||||
|
||||
function cleanupAcpProvider(chatSessionId) {
|
||||
// Clean up temporary COPILOT_HOME directory regardless of whether a
|
||||
// provider entry exists — prepareCopilotHome may have succeeded before
|
||||
// provider creation failed.
|
||||
try {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
|
||||
if (existsSync(tempCopilotHome)) {
|
||||
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
|
||||
const entry = acpProviders.get(chatSessionId);
|
||||
if (!entry) return;
|
||||
const rootPid = entry.provider?.model?.agentProcess?.pid;
|
||||
cleanupAcpProviderInstance(entry.provider, chatSessionId);
|
||||
acpProviders.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function cleanupAcpProviderInstance(provider, chatSessionId = "transient") {
|
||||
if (!provider) return;
|
||||
const rootPid = provider?.model?.agentProcess?.pid;
|
||||
const childPids = getChildProcessTreePids(rootPid);
|
||||
try {
|
||||
if (typeof entry.provider.forceCleanup === "function") {
|
||||
entry.provider.forceCleanup();
|
||||
} else if (typeof entry.provider.cleanup === "function") {
|
||||
entry.provider.cleanup();
|
||||
if (typeof provider.forceCleanup === "function") {
|
||||
provider.forceCleanup();
|
||||
} else if (typeof provider.cleanup === "function") {
|
||||
provider.cleanup();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
|
||||
}
|
||||
killTrackedProcessTree(rootPid, childPids);
|
||||
acpProviders.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function isActiveAcpRun(chatSessionId, requestId) {
|
||||
@@ -163,9 +182,10 @@ function isActiveAcpRun(chatSessionId, requestId) {
|
||||
return Boolean(activeRun && activeRun.requestId === requestId);
|
||||
}
|
||||
|
||||
function isUnsupportedLoadSessionError(err) {
|
||||
function shouldRetryFreshSession(err) {
|
||||
const message = String(err?.message || err || "").toLowerCase();
|
||||
return message.includes("method not found") && message.includes("session/load");
|
||||
return (message.includes("method not found") && message.includes("session/load"))
|
||||
|| (message.includes("resource not found") && message.includes("session") && message.includes("not found"));
|
||||
}
|
||||
|
||||
function getChildProcessTreePids(rootPid) {
|
||||
@@ -302,6 +322,127 @@ function _validateSenderImpl(event, allowSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMcpServersForDebug(mcpServers) {
|
||||
if (!Array.isArray(mcpServers)) return [];
|
||||
return mcpServers.map((server) => ({
|
||||
name: server?.name || "",
|
||||
type: server?.type || "",
|
||||
command: server?.command || "",
|
||||
args: Array.isArray(server?.args) ? server.args : [],
|
||||
hasEnv: Array.isArray(server?.env) ? server.env.length > 0 : false,
|
||||
url: server?.url || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function logAcpDebug(agentLabel, message, details) {
|
||||
const prefix = `[ACP DEBUG][${agentLabel}]`;
|
||||
if (details === undefined) {
|
||||
console.log(prefix, message);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
console.log(prefix, message, JSON.stringify(details));
|
||||
} catch {
|
||||
console.log(prefix, message, details);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAgentCommandName(command) {
|
||||
if (typeof command !== "string" || !command) return "";
|
||||
return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/i, "");
|
||||
}
|
||||
|
||||
function matchesAgentCommand(command, expectedName) {
|
||||
if (typeof command !== "string" || typeof expectedName !== "string") return false;
|
||||
if (command.toLowerCase() === expectedName.toLowerCase()) return true;
|
||||
return normalizeAgentCommandName(command) === normalizeAgentCommandName(expectedName);
|
||||
}
|
||||
|
||||
function envPairsToObject(entries) {
|
||||
if (!Array.isArray(entries)) return {};
|
||||
const result = {};
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry.name !== "string") continue;
|
||||
result[entry.name] = entry.value == null ? "" : String(entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapMcpServerToCopilotConfig(server) {
|
||||
if (!server || typeof server !== "object" || !server.name) return null;
|
||||
|
||||
if (server.type === "stdio" || server.type === "local") {
|
||||
return {
|
||||
type: "local",
|
||||
command: server.command || "",
|
||||
args: Array.isArray(server.args) ? server.args : [],
|
||||
env: envPairsToObject(server.env),
|
||||
tools: ["*"],
|
||||
};
|
||||
}
|
||||
|
||||
if (server.type === "http" || server.type === "sse") {
|
||||
return {
|
||||
type: server.type,
|
||||
url: server.url || "",
|
||||
headers: envPairsToObject(server.headers),
|
||||
tools: ["*"],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCopilotHome(shellEnv, mcpServers, chatSessionId) {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const homeDir = shellEnv.HOME || process.env.HOME || process.env.USERPROFILE || "";
|
||||
const realCopilotHome = shellEnv.COPILOT_HOME || path.join(homeDir, ".copilot");
|
||||
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
|
||||
|
||||
try {
|
||||
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup failures; mkdir/copy below will surface real issues if any.
|
||||
}
|
||||
|
||||
fs.mkdirSync(tempCopilotHome, { recursive: true });
|
||||
|
||||
if (realCopilotHome && existsSync(realCopilotHome)) {
|
||||
fs.cpSync(realCopilotHome, tempCopilotHome, { recursive: true });
|
||||
}
|
||||
|
||||
const configPath = path.join(tempCopilotHome, "mcp-config.json");
|
||||
const baseConfig = safeReadJson(configPath) || { mcpServers: {} };
|
||||
const mergedServers = { ...(baseConfig.mcpServers || {}) };
|
||||
|
||||
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
|
||||
const mapped = mapMcpServerToCopilotConfig(server);
|
||||
if (!mapped) continue;
|
||||
mergedServers[server.name] = mapped;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ ...baseConfig, mcpServers: mergedServers }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
return {
|
||||
copilotHome: tempCopilotHome,
|
||||
configPath,
|
||||
serverNames: Object.keys(mergedServers),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a streaming HTTP request and forward SSE events back to renderer
|
||||
*/
|
||||
@@ -1253,6 +1394,15 @@ function registerHandlers(ipcMain) {
|
||||
args: ["exec", "--full-auto", "--json", "{prompt}"],
|
||||
resolveAcp: resolveCodexAcpBinaryPath,
|
||||
},
|
||||
{
|
||||
command: "copilot",
|
||||
name: "GitHub Copilot CLI",
|
||||
icon: "copilot",
|
||||
description: "GitHub's coding agent CLI",
|
||||
acpCommand: "copilot",
|
||||
acpArgs: ["--acp", "--stdio"],
|
||||
args: ["-p", "{prompt}"],
|
||||
},
|
||||
];
|
||||
|
||||
const shellEnv = await getShellEnv();
|
||||
@@ -1309,6 +1459,7 @@ function registerHandlers(ipcMain) {
|
||||
const { resolveAcp: _unused, ...agentInfo } = agent;
|
||||
agents.push({
|
||||
...agentInfo,
|
||||
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
|
||||
path: resolvedPath,
|
||||
version,
|
||||
available: true,
|
||||
@@ -1327,7 +1478,9 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
if (customPath) {
|
||||
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
|
||||
resolvedPath = normalizeCliPathForPlatform(customPath);
|
||||
// Fall back to PATH search if the stored path no longer exists
|
||||
// (e.g. CLI reinstalled to a different location).
|
||||
resolvedPath = normalizeCliPathForPlatform(customPath) || resolveCliFromPath(command, shellEnv);
|
||||
} else {
|
||||
resolvedPath = resolveCliFromPath(command, shellEnv);
|
||||
}
|
||||
@@ -1521,6 +1674,7 @@ function registerHandlers(ipcMain) {
|
||||
const ALLOWED_AGENT_COMMANDS = new Set([
|
||||
"claude", "claude-agent-acp",
|
||||
"codex", "codex-acp",
|
||||
"copilot",
|
||||
]);
|
||||
|
||||
// Spawn an external agent process
|
||||
@@ -1730,6 +1884,102 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// ── ACP (Agent Client Protocol) streaming ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
|
||||
let provider = null;
|
||||
let copilotConfigInfo = null;
|
||||
try {
|
||||
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
|
||||
const shellEnv = await getShellEnv();
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
|
||||
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
||||
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
|
||||
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
|
||||
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||
|
||||
const agentEnv = { ...shellEnv };
|
||||
if (apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
|
||||
if (isCopilotAgent) {
|
||||
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
|
||||
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
|
||||
}
|
||||
|
||||
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const resolvedCommand = isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: claudeAcp
|
||||
? claudeAcp.command
|
||||
: acpCommand;
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
|
||||
provider = createACPProvider({
|
||||
command: resolvedCommand,
|
||||
args: resolvedArgs,
|
||||
env: agentEnv,
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const sessionInfo = await provider.initSession();
|
||||
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
|
||||
? sessionInfo.models.availableModels
|
||||
: [];
|
||||
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Fetched session models", {
|
||||
chatSessionId: chatSessionId || null,
|
||||
currentModelId: sessionInfo?.models?.currentModelId || null,
|
||||
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
|
||||
copilotHome: copilotConfigInfo?.copilotHome || null,
|
||||
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
currentModelId: sessionInfo?.models?.currentModelId || null,
|
||||
models: availableModels.map((modelInfo) => ({
|
||||
id: modelInfo?.modelId,
|
||||
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
|
||||
description: modelInfo?.description || undefined,
|
||||
})).filter((modelInfo) => Boolean(modelInfo.id)),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to list models:", err?.message || err);
|
||||
return { ok: false, error: err?.message || String(err) };
|
||||
} finally {
|
||||
try {
|
||||
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");
|
||||
} catch {
|
||||
// Ignore cleanup failures for transient model-discovery providers.
|
||||
}
|
||||
// Clean up transient COPILOT_HOME created for model listing
|
||||
if (copilotConfigInfo?.copilotHome) {
|
||||
try {
|
||||
fs.rmSync(copilotConfigInfo.copilotHome, { recursive: true, force: true });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
@@ -1771,8 +2021,10 @@ function registerHandlers(ipcMain) {
|
||||
const shellEnv = await getShellEnv();
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = acpCommand === "codex-acp";
|
||||
const isClaudeAgent = acpCommand === "claude-agent-acp";
|
||||
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
|
||||
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
||||
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
|
||||
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
|
||||
|
||||
// Resolve API key from providerId (decrypted in main process only)
|
||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||
@@ -1811,6 +2063,13 @@ function registerHandlers(ipcMain) {
|
||||
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
|
||||
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
|
||||
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
|
||||
chatSessionId,
|
||||
scopedIds,
|
||||
injectedServer: summarizeMcpServersForDebug([netcattyMcpConfig])[0],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
|
||||
}
|
||||
@@ -1821,9 +2080,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
const currentPermissionMode = mcpServerBridge.getPermissionMode();
|
||||
let providerEntry = acpProviders.get(chatSessionId);
|
||||
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
|
||||
const shouldReuseProvider = Boolean(
|
||||
!shouldForceProviderReset &&
|
||||
providerEntry &&
|
||||
providerEntry.acpCommand === acpCommand &&
|
||||
providerEntry.cwd === sessionCwd &&
|
||||
@@ -1840,6 +2097,11 @@ function registerHandlers(ipcMain) {
|
||||
if (apiKey) {
|
||||
agentEnv.CODEX_API_KEY = apiKey;
|
||||
}
|
||||
let copilotConfigInfo = null;
|
||||
if (isCopilotAgent) {
|
||||
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
|
||||
}
|
||||
|
||||
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
|
||||
const resolvedCommand = isCodexAgent
|
||||
@@ -1850,6 +2112,7 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedArgs = claudeAcp
|
||||
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [];
|
||||
const sessionMcpServers = isCopilotAgent ? [] : mcpSnapshot.mcpServers;
|
||||
|
||||
const provider = createACPProvider({
|
||||
command: resolvedCommand,
|
||||
@@ -1857,15 +2120,31 @@ function registerHandlers(ipcMain) {
|
||||
env: agentEnv,
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: mcpSnapshot.mcpServers,
|
||||
mcpServers: sessionMcpServers,
|
||||
},
|
||||
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
persistSession: true,
|
||||
});
|
||||
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Creating ACP provider", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
cwd: sessionCwd,
|
||||
resolvedCommand,
|
||||
resolvedArgs,
|
||||
sessionMcpServers: summarizeMcpServersForDebug(sessionMcpServers),
|
||||
copilotHome: copilotConfigInfo?.copilotHome || null,
|
||||
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
|
||||
copilotMcpServerNames: copilotConfigInfo?.serverNames || [],
|
||||
});
|
||||
}
|
||||
|
||||
providerEntry = {
|
||||
provider,
|
||||
acpCommand,
|
||||
@@ -1877,15 +2156,21 @@ function registerHandlers(ipcMain) {
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
}
|
||||
acpForceProviderReset.delete(chatSessionId);
|
||||
|
||||
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
try {
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "ACP session initialized", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||
toolNames: Object.keys(providerEntry.provider.tools || {}),
|
||||
});
|
||||
}
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
} catch (err) {
|
||||
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
||||
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
|
||||
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1901,13 +2186,22 @@ function registerHandlers(ipcMain) {
|
||||
args: fallbackClaudeAcp
|
||||
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
||||
: acpArgs || [],
|
||||
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
env: (() => {
|
||||
const fallbackEnv = apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv };
|
||||
if (isCopilotAgent) {
|
||||
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
|
||||
}
|
||||
return fallbackEnv;
|
||||
})(),
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: mcpSnapshot.mcpServers,
|
||||
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: isCopilotAgent
|
||||
? { authMethodId: "copilot-login" }
|
||||
: {}),
|
||||
persistSession: true,
|
||||
});
|
||||
@@ -1924,6 +2218,14 @@ function registerHandlers(ipcMain) {
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||
toolNames: Object.keys(providerEntry.provider.tools || {}),
|
||||
});
|
||||
}
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
}
|
||||
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
|
||||
@@ -2042,6 +2344,9 @@ function registerHandlers(ipcMain) {
|
||||
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call") {
|
||||
hasContent = true;
|
||||
}
|
||||
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
|
||||
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
|
||||
}
|
||||
safeSend(event.sender, "netcatty:ai:acp:event", {
|
||||
requestId,
|
||||
event: serialized,
|
||||
@@ -2057,6 +2362,13 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// If stream completed with zero content, likely an auth or connection issue
|
||||
if (!hasContent && !abortController.signal.aborted) {
|
||||
if (isCopilotAgent) {
|
||||
logAcpDebug(agentLabel, "Stream completed with no content", {
|
||||
requestId,
|
||||
chatSessionId,
|
||||
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||
});
|
||||
}
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -2095,9 +2407,6 @@ function registerHandlers(ipcMain) {
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
const activeRun = acpChatRuns.get(chatSessionId);
|
||||
if (activeRun?.requestId === requestId) {
|
||||
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
acpChatRuns.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
@@ -2127,10 +2436,6 @@ function registerHandlers(ipcMain) {
|
||||
acpPendingCancelRequests.add(effectiveRequestId);
|
||||
cancelled = true;
|
||||
}
|
||||
if (effectiveChatSessionId) {
|
||||
acpForceProviderReset.add(effectiveChatSessionId);
|
||||
cleanupAcpProvider(effectiveChatSessionId);
|
||||
}
|
||||
// Preserve the ACP provider session on stop so the next user message can
|
||||
// continue within the same persisted conversation context. Full provider
|
||||
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
||||
@@ -2143,7 +2448,6 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
|
||||
acpForceProviderReset.delete(chatSessionId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
||||
return { ok: true };
|
||||
|
||||
@@ -380,6 +380,7 @@ async function handleMessage(socket, line) {
|
||||
if (!socket.destroyed) socket.write(response);
|
||||
return;
|
||||
}
|
||||
console.warn("[MCP Bridge] auth/verify failed or unexpected first method", method);
|
||||
// Wrong token or wrong method — reject and close
|
||||
const response = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
@@ -629,6 +630,22 @@ function handleExec(params) {
|
||||
|
||||
// ── MCP Server Config Builder ──
|
||||
|
||||
function resolveMcpServerRuntimeCommand() {
|
||||
const runtimeCommand = process.execPath;
|
||||
const runtimeEnv = [];
|
||||
|
||||
if (runtimeCommand && existsSync(runtimeCommand)) {
|
||||
const basename = path.basename(runtimeCommand).toLowerCase();
|
||||
const isNodeBinary = basename === "node" || basename.startsWith("node.");
|
||||
if (!isNodeBinary) {
|
||||
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
|
||||
}
|
||||
return { command: runtimeCommand, env: runtimeEnv };
|
||||
}
|
||||
|
||||
return { command: "node", env: runtimeEnv };
|
||||
}
|
||||
|
||||
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
|
||||
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
|
||||
@@ -638,8 +655,10 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
const runtimePath = toUnpackedAsarPath(
|
||||
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
|
||||
);
|
||||
const runtime = resolveMcpServerRuntimeCommand();
|
||||
|
||||
const env = [
|
||||
...runtime.env,
|
||||
{ name: "NETCATTY_MCP_PORT", value: String(port) },
|
||||
];
|
||||
|
||||
@@ -664,7 +683,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
return {
|
||||
name: "netcatty-remote-hosts",
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
command: runtime.command,
|
||||
args: [runtimePath],
|
||||
env,
|
||||
};
|
||||
|
||||
@@ -105,7 +105,6 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
.brand {
|
||||
font-size: 16px;
|
||||
@@ -162,14 +161,17 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="header">
|
||||
<svg class="logo" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.12" />
|
||||
<path
|
||||
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
|
||||
stroke="currentColor" stroke-width="2" />
|
||||
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<svg class="logo" viewBox="0 0 56 56" aria-hidden="true">
|
||||
<rect x="0" y="0" width="56" height="56" rx="12" fill="#2F7BFF"/>
|
||||
<rect x="10" y="13" width="36" height="24" rx="4" fill="#FFFFFF" stroke="#1D4FCF" stroke-opacity="0.12"/>
|
||||
<rect x="10" y="13" width="36" height="5" rx="4" fill="#E6EEFF"/>
|
||||
<circle cx="14" cy="15.5" r="1" fill="#1E4FD1"/>
|
||||
<circle cx="18" cy="15.5" r="1" fill="#1E4FD1" opacity="0.7"/>
|
||||
<circle cx="22" cy="15.5" r="1" fill="#1E4FD1" opacity="0.5"/>
|
||||
<path d="M16 28 L20 26 L16 24" stroke="#1E4FD1" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 30 H30" stroke="#1E4FD1" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<path d="M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47" stroke="white" fill="none" stroke-width="3.2" stroke-linecap="round"/>
|
||||
<rect x="34" y="44" width="6" height="5" rx="1" fill="white" stroke="#1E4FD1"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="brand">Netcatty</div>
|
||||
@@ -279,9 +281,8 @@ function startOAuthCallback(expectedState) {
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
title: "Authorization Complete",
|
||||
message: "You are signed in and ready to sync.",
|
||||
message: "You are signed in and ready to sync. You can close this tab now.",
|
||||
status: "success",
|
||||
autoClose: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -1858,4 +1858,5 @@ module.exports = {
|
||||
renameSftp,
|
||||
statSftp,
|
||||
chmodSftp,
|
||||
resolveEncodingForRequest,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel, resolveEncodingForRequest } = require("./sftpBridge.cjs");
|
||||
|
||||
/**
|
||||
* Safely ensure a local directory exists.
|
||||
@@ -50,6 +50,9 @@ let sftpClients = null;
|
||||
// Active transfers storage
|
||||
const activeTransfers = new Map();
|
||||
const isolatedDownloadChannelPools = new WeakMap();
|
||||
// Cache sftpIds where remote cp is known to be unavailable, so we skip
|
||||
// repeated failed exec attempts for each file in a multi-file transfer.
|
||||
const cpUnavailableSet = new Set();
|
||||
|
||||
/**
|
||||
* Initialize the transfer bridge with dependencies
|
||||
@@ -58,6 +61,46 @@ function init(deps) {
|
||||
sftpClients = deps.sftpClients;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an SSH command with cancellation support.
|
||||
* Registers an abort hook on the transfer object that closes the exec stream,
|
||||
* which sends SIGHUP to the remote process.
|
||||
*/
|
||||
function execSshCommandCancellable(sshClient, command, transfer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
|
||||
|
||||
sshClient.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// If cancelled between exec() call and callback, kill immediately
|
||||
if (transfer.cancelled) {
|
||||
try { stream.close(); } catch { }
|
||||
return reject(new Error('Transfer cancelled'));
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
// Wire abort: closing the stream kills the remote process
|
||||
const prevAbort = transfer.abort;
|
||||
transfer.abort = () => {
|
||||
try { stream.close(); } catch { }
|
||||
if (typeof prevAbort === 'function') prevAbort();
|
||||
};
|
||||
|
||||
stream.on('close', (code) => {
|
||||
transfer.abort = prevAbort; // restore
|
||||
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
|
||||
resolve({ stdout, stderr, code });
|
||||
});
|
||||
|
||||
stream.on('data', (data) => { stdout += data.toString(); });
|
||||
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client) {
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") return null;
|
||||
@@ -475,6 +518,7 @@ async function startTransfer(event, payload, onProgress) {
|
||||
totalBytes,
|
||||
sourceEncoding,
|
||||
targetEncoding,
|
||||
sameHost,
|
||||
} = payload;
|
||||
const sender = event.sender;
|
||||
|
||||
@@ -674,34 +718,73 @@ async function startTransfer(event, payload, onProgress) {
|
||||
});
|
||||
|
||||
} else if (sourceType === 'sftp' && targetType === 'sftp') {
|
||||
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
|
||||
// Try same-host optimization first: remote cp via SSH exec.
|
||||
// Falls back to download+upload if cp is unavailable (e.g. Windows SSH servers).
|
||||
let sameHostDone = false;
|
||||
const resolvedSourceEnc = sourceSftpId ? resolveEncodingForRequest(sourceSftpId, sourceEncoding) : sourceEncoding;
|
||||
const resolvedTargetEnc = targetSftpId ? resolveEncodingForRequest(targetSftpId, targetEncoding) : targetEncoding;
|
||||
if (sameHost
|
||||
&& (!resolvedSourceEnc || resolvedSourceEnc === 'utf-8')
|
||||
&& (!resolvedTargetEnc || resolvedTargetEnc === 'utf-8')
|
||||
&& !cpUnavailableSet.has(sourceSftpId)) {
|
||||
const srcClient = sftpClients.get(sourceSftpId);
|
||||
const sshClient = srcClient?.client;
|
||||
if (sshClient && typeof sshClient.exec === 'function') {
|
||||
try {
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(sourceSftpId, dir, targetEncoding || sourceEncoding); } catch { }
|
||||
|
||||
const sourceClient = sftpClients.get(sourceSftpId);
|
||||
const targetClient = sftpClients.get(targetSftpId);
|
||||
if (!sourceClient) throw new Error("Source SFTP session not found");
|
||||
if (!targetClient) throw new Error("Target SFTP session not found");
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
|
||||
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const downloadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
throw new Error('Transfer cancelled');
|
||||
const result = await execSshCommandCancellable(sshClient, command, transfer);
|
||||
if (result.code === 0) {
|
||||
sendProgress(fileSize, fileSize);
|
||||
sameHostDone = true;
|
||||
} else if (result.code === 127) {
|
||||
// Exit 127 = command not found — cache to skip future attempts
|
||||
cpUnavailableSet.add(sourceSftpId);
|
||||
}
|
||||
// Other non-zero exits (permission denied, disk full, etc.)
|
||||
// fall through to download+upload without caching
|
||||
} catch (cpErr) {
|
||||
// If cancelled, re-throw; otherwise fall back to download+upload
|
||||
if (transfer.cancelled) throw cpErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
|
||||
if (!sameHostDone) {
|
||||
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
|
||||
|
||||
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
|
||||
const uploadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
|
||||
const sourceClient = sftpClients.get(sourceSftpId);
|
||||
const targetClient = sftpClients.get(targetSftpId);
|
||||
if (!sourceClient) throw new Error("Source SFTP session not found");
|
||||
if (!targetClient) throw new Error("Target SFTP session not found");
|
||||
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const downloadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
throw new Error('Transfer cancelled');
|
||||
}
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
|
||||
|
||||
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
|
||||
const uploadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
|
||||
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error("Invalid transfer configuration");
|
||||
@@ -749,12 +832,73 @@ async function cancelTransfer(event, payload) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-host directory copy: uses a single `cp -ra` command on the remote server
|
||||
* instead of recursively transferring files one by one.
|
||||
*/
|
||||
async function sameHostCopyDirectory(event, payload) {
|
||||
const { sftpId, sourcePath, targetPath, encoding, transferId } = payload;
|
||||
|
||||
// Register in activeTransfers so cancelTransfer can flag it
|
||||
const transfer = { cancelled: false };
|
||||
if (transferId) {
|
||||
activeTransfers.set(transferId, transfer);
|
||||
}
|
||||
|
||||
try {
|
||||
if (cpUnavailableSet.has(sftpId)) return { success: false };
|
||||
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) return { success: false };
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient || typeof sshClient.exec !== 'function') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
|
||||
// Ensure target directory itself exists (not just its parent),
|
||||
// so cp copies contents into it rather than creating a nested subdirectory.
|
||||
const targetDir = targetPath.replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(sftpId, targetDir, encoding); } catch { }
|
||||
|
||||
// Use "source/." to copy directory *contents* into target, preserving merge
|
||||
// semantics consistent with the recursive per-file transfer path.
|
||||
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\\''");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\\''");
|
||||
const command = `cp -ra '${escapedSource}/.' '${escapedTarget}/'`;
|
||||
|
||||
try {
|
||||
const result = await execSshCommandCancellable(sshClient, command, transfer);
|
||||
if (result.code === 127) {
|
||||
cpUnavailableSet.add(sftpId);
|
||||
return { success: false };
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
return { success: false };
|
||||
}
|
||||
} catch (cpErr) {
|
||||
if (transfer.cancelled) throw cpErr;
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} finally {
|
||||
if (transferId) {
|
||||
activeTransfers.delete(transferId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for transfer operations
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:transfer:start", startTransfer);
|
||||
ipcMain.handle("netcatty:transfer:cancel", cancelTransfer);
|
||||
ipcMain.handle("netcatty:transfer:same-host-copy-dir", sameHostCopyDirectory);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -762,4 +906,5 @@ module.exports = {
|
||||
registerHandlers,
|
||||
startTransfer,
|
||||
cancelTransfer,
|
||||
sameHostCopyDirectory,
|
||||
};
|
||||
|
||||
@@ -741,6 +741,9 @@ const api = {
|
||||
cleanupTransferListeners(transferId);
|
||||
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
|
||||
},
|
||||
sameHostCopyDirectory: async (sftpId, sourcePath, targetPath, encoding, transferId) => {
|
||||
return ipcRenderer.invoke("netcatty:transfer:same-host-copy-dir", { sftpId, sourcePath, targetPath, encoding, transferId });
|
||||
},
|
||||
// Compressed folder upload
|
||||
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
|
||||
const { compressionId } = options;
|
||||
@@ -1204,6 +1207,9 @@ const api = {
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
|
||||
},
|
||||
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
|
||||
},
|
||||
aiAcpCancel: async (requestId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
|
||||
},
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -346,6 +346,7 @@ declare global {
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
sameHostCopyDirectory?(sftpId: string, sourcePath: string, targetPath: string, encoding?: SftpFilenameEncoding, transferId?: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Compressed folder upload
|
||||
startCompressedUpload?(
|
||||
@@ -383,6 +384,7 @@ declare global {
|
||||
totalBytes?: number;
|
||||
sourceEncoding?: SftpFilenameEncoding;
|
||||
targetEncoding?: SftpFilenameEncoding;
|
||||
sameHost?: boolean;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
|
||||
69
infrastructure/ai/managedAgents.ts
Normal file
69
infrastructure/ai/managedAgents.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
|
||||
|
||||
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
|
||||
|
||||
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
|
||||
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
|
||||
claude: { commandNames: ['claude', 'claude-agent-acp'], acpCommand: 'claude-agent-acp' },
|
||||
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
|
||||
};
|
||||
|
||||
function getCommandBasename(command: string | undefined): string {
|
||||
const normalized = String(command || '').trim();
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split(/[\\/]/);
|
||||
return (parts.pop() || '').toLowerCase();
|
||||
}
|
||||
|
||||
function isPathLikeCommand(command: string | undefined): boolean {
|
||||
const normalized = String(command || '').trim();
|
||||
return normalized.includes('/') || normalized.includes('\\');
|
||||
}
|
||||
|
||||
function matchesPrimaryCliBasename(command: string | undefined, agentKey: ManagedAgentKey): boolean {
|
||||
const basename = getCommandBasename(command);
|
||||
return basename === agentKey || basename.startsWith(`${agentKey}.`);
|
||||
}
|
||||
|
||||
export function isSettingsManagedDiscoveredAgent(
|
||||
agent: Pick<DiscoveredAgent, 'command'>,
|
||||
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
|
||||
return agent.command === 'codex' || agent.command === 'claude' || agent.command === 'copilot';
|
||||
}
|
||||
|
||||
export function matchesManagedAgentConfig(
|
||||
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
|
||||
agentKey: ManagedAgentKey,
|
||||
): boolean {
|
||||
const meta = MANAGED_AGENT_META[agentKey];
|
||||
const basename = getCommandBasename(agent.command);
|
||||
return (
|
||||
agent.id === `discovered_${agentKey}` ||
|
||||
agent.acpCommand === meta.acpCommand ||
|
||||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
|
||||
);
|
||||
}
|
||||
|
||||
export function getManagedAgentStoredPath(
|
||||
agents: ExternalAgentConfig[],
|
||||
agentKey: ManagedAgentKey,
|
||||
): string | null {
|
||||
const managedId = `discovered_${agentKey}`;
|
||||
const preferredAgent = agents.find(
|
||||
(agent) =>
|
||||
agent.id === managedId &&
|
||||
isPathLikeCommand(agent.command) &&
|
||||
matchesPrimaryCliBasename(agent.command, agentKey),
|
||||
);
|
||||
if (preferredAgent) {
|
||||
return preferredAgent.command;
|
||||
}
|
||||
|
||||
const fallbackAgent = agents.find(
|
||||
(agent) =>
|
||||
matchesManagedAgentConfig(agent, agentKey) &&
|
||||
isPathLikeCommand(agent.command) &&
|
||||
matchesPrimaryCliBasename(agent.command, agentKey),
|
||||
);
|
||||
return fallbackAgent?.command ?? null;
|
||||
}
|
||||
1
public/ai/agents/copilot.svg
Normal file
1
public/ai/agents/copilot.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><path d="M95.667 67.954C92.225 73.933 72.24 88.04 47.997 88.04 23.754 88.04 3.769 73.933.328 67.954c-.216-.375-.307-.796-.328-1.226V55.661c.019-.371.089-.736.226-1.081 1.489-3.738 5.386-9.166 10.417-10.623.667-1.712 1.655-4.215 2.576-6.062-.154-1.414-.208-2.872-.208-4.345 0-5.322 1.128-9.99 4.527-13.466 1.587-1.623 3.557-2.869 5.893-3.805 5.595-4.545 13.563-8.369 24.48-8.369s19.057 3.824 24.652 8.369c2.337.936 4.306 2.182 5.894 3.805 3.399 3.476 4.527 8.144 4.527 13.466 0 1.473-.054 2.931-.208 4.345.921 1.847 1.909 4.35 2.576 6.062 5.03 1.457 8.928 6.885 10.417 10.623.163.41.231.848.231 1.289v10.644c0 .504-.081 1.004-.333 1.441ZM48.686 43.993l-.3.001-1.077-.001c-.423.709-.894 1.39-1.418 2.035-3.078 3.787-7.672 5.964-14.026 5.964-6.897 0-11.952-1.435-15.123-5.032a7.886 7.886 0 0 1-.342-.419l-.39.419v26.326c5.737 3.118 18.05 8.713 31.987 8.713 13.938 0 26.251-5.595 31.988-8.713V46.96l-.39-.419s-.132.181-.342.419c-3.171 3.597-8.226 5.032-15.123 5.032-6.354 0-10.949-2.177-14.026-5.964a17.178 17.178 0 0 1-1.418-2.034h-.066l.066-.001Zm-3.94-11.733c.17-1.326.251-2.513.253-3.573v-.084c-.005-3.077-.678-5.079-1.752-6.308-1.365-1.562-4.184-2.758-10.127-2.115-6.021.652-9.386 2.146-11.294 4.098-1.847 1.889-2.818 4.715-2.818 9.272 0 4.842.698 7.703 2.232 9.443 1.459 1.655 4.332 3.001 10.625 3.001 4.837 0 7.603-1.573 9.371-3.749 1.899-2.336 2.967-5.759 3.51-9.985Zm6.503 0c.543 4.226 1.611 7.649 3.51 9.985 1.768 2.176 4.533 3.749 9.371 3.749 6.292 0 9.165-1.346 10.624-3.001 1.535-1.74 2.232-4.601 2.232-9.443 0-4.557-.97-7.383-2.817-9.272-1.908-1.952-5.274-3.446-11.294-4.098-5.943-.643-8.763.553-10.127 2.115-1.074 1.229-1.747 3.231-1.752 6.308v.084c.002 1.06.083 2.247.253 3.573Zm-2.563 11.734h.066l-.066-.001v.001Z"></path><path d="M38.5 55.75a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Zm19 0a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
Reference in New Issue
Block a user