mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6e1b4a57 | ||
|
|
c6c3f27363 | ||
|
|
9e0a598765 |
No files matched your search
@@ -141,7 +141,6 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
|||||||
### 📁 SFTP + 内蔵エディタ
|
### 📁 SFTP + 内蔵エディタ
|
||||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||||
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
||||||
- **リモート圧縮** — ファイルやフォルダーを1つ以上選び、右クリックの **Compress...** から、表示された保存先に名前を指定して `.tar.gz` または `.zip` アーカイブを作成できます。サーバーには選択した形式に対応する `tar`/`gzip` または `zip` が必要です。
|
|
||||||
|
|
||||||
### 🎨 パーソナライズ
|
### 🎨 パーソナライズ
|
||||||
- **カスタムテーマ** — UI の見た目を好みに調整
|
- **カスタムテーマ** — UI の見た目を好みに調整
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
|||||||
### 📁 SFTP + Built-in Editor
|
### 📁 SFTP + Built-in Editor
|
||||||
- **File workflows** — drag & drop uploads/downloads
|
- **File workflows** — drag & drop uploads/downloads
|
||||||
- **Edit in place** — built-in editor for quick changes
|
- **Edit in place** — built-in editor for quick changes
|
||||||
- **Create remote archives** — select files or folders, right-click **Compress...**, and create a named `.tar.gz` or `.zip` archive in the displayed destination directory; the server needs `tar`/`gzip` or `zip` for the chosen format.
|
|
||||||
|
|
||||||
### 🎨 Personalization
|
### 🎨 Personalization
|
||||||
- **Custom themes** — tune the app appearance to your taste
|
- **Custom themes** — tune the app appearance to your taste
|
||||||
|
|||||||
@@ -141,7 +141,6 @@
|
|||||||
### 📁 SFTP + 内置编辑器
|
### 📁 SFTP + 内置编辑器
|
||||||
- **文件工作流** —— 拖拽上传/下载更直观
|
- **文件工作流** —— 拖拽上传/下载更直观
|
||||||
- **就地编辑** —— 内置编辑器快速修改文件
|
- **就地编辑** —— 内置编辑器快速修改文件
|
||||||
- **远程压缩** —— 单选或多选文件、文件夹,右键选择“压缩…”,在对话框显示的保存目录生成指定名称的 `.tar.gz` 或 `.zip` 压缩包;服务器需安装对应的 `tar`/`gzip` 或 `zip`。
|
|
||||||
|
|
||||||
### 🎨 个性化
|
### 🎨 个性化
|
||||||
- **自定义主题** —— 按喜好调整应用外观
|
- **自定义主题** —— 按喜好调整应用外观
|
||||||
|
|||||||
@@ -141,7 +141,6 @@
|
|||||||
### 📁 SFTP + 內建編輯器
|
### 📁 SFTP + 內建編輯器
|
||||||
- **檔案工作流** —— 拖放上傳/下載
|
- **檔案工作流** —— 拖放上傳/下載
|
||||||
- **就地編輯** —— 內建編輯器,快速修改檔案
|
- **就地編輯** —— 內建編輯器,快速修改檔案
|
||||||
- **遠端壓縮** —— 單選或多選檔案、資料夾,按右鍵選擇「壓縮…」,在對話方塊顯示的儲存目錄建立指定名稱的 `.tar.gz` 或 `.zip` 壓縮檔;伺服器需安裝對應的 `tar`/`gzip` 或 `zip`。
|
|
||||||
|
|
||||||
### 🎨 個人化
|
### 🎨 個人化
|
||||||
- **自訂主題** —— 依你的喜好調整外觀
|
- **自訂主題** —— 依你的喜好調整外觀
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
|||||||
assert.equal(moveCalls, 0);
|
assert.equal(moveCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Escape leaves magnification unchanged and reaches the terminal', () => {
|
test('Escape restores magnification after transient dialogs are closed', () => {
|
||||||
let restores = 0;
|
let restores = 0;
|
||||||
let prevented = false;
|
let prevented = false;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -405,29 +405,30 @@ test('Escape leaves magnification unchanged and reaches the terminal', () => {
|
|||||||
},
|
},
|
||||||
}), event);
|
}), event);
|
||||||
|
|
||||||
assert.equal(restores, 0);
|
assert.equal(restores, 1);
|
||||||
assert.equal(prevented, false);
|
assert.equal(prevented, true);
|
||||||
assert.equal(stopped, false);
|
assert.equal(stopped, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Escape closes the quick switcher', () => {
|
test('consumed Escape does not restore magnification', () => {
|
||||||
let open = true;
|
let restores = 0;
|
||||||
handleEscapeKeyDownImpl(() => ({
|
handleEscapeKeyDownImpl(() => ({
|
||||||
isQuickSwitcherOpen: open,
|
isQuickSwitcherOpen: false,
|
||||||
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
|
setIsQuickSwitcherOpen: () => {},
|
||||||
}), { key: 'Escape', defaultPrevented: false } as KeyboardEvent);
|
terminalPaneMagnificationRef: {
|
||||||
|
current: {
|
||||||
assert.equal(open, false);
|
getState: () => 'focused',
|
||||||
});
|
focus: () => false,
|
||||||
|
restore: () => {
|
||||||
test('consumed Escape leaves the quick switcher open', () => {
|
restores += 1;
|
||||||
let open = true;
|
return true;
|
||||||
handleEscapeKeyDownImpl(() => ({
|
},
|
||||||
isQuickSwitcherOpen: open,
|
toggle: () => false,
|
||||||
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
|
},
|
||||||
|
},
|
||||||
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
|
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
|
||||||
|
|
||||||
assert.equal(open, true);
|
assert.equal(restores, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('close tab hotkey routes native plugin view tabs through their owner', () => {
|
test('close tab hotkey routes native plugin view tabs through their owner', () => {
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
|
|||||||
const {
|
const {
|
||||||
isQuickSwitcherOpen,
|
isQuickSwitcherOpen,
|
||||||
setIsQuickSwitcherOpen,
|
setIsQuickSwitcherOpen,
|
||||||
|
sftpPaneMagnificationRef,
|
||||||
|
terminalPaneMagnificationRef,
|
||||||
} = getCtx();
|
} = getCtx();
|
||||||
{
|
{
|
||||||
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
||||||
@@ -372,6 +374,13 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
|
|||||||
setIsQuickSwitcherOpen(false);
|
setIsQuickSwitcherOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (getAvailablePaneMagnificationController([
|
||||||
|
sftpPaneMagnificationRef?.current,
|
||||||
|
terminalPaneMagnificationRef?.current,
|
||||||
|
])?.restore()) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { PluginContributionHost } from '../../components/plugins/PluginContribut
|
|||||||
import type { TerminalSession, Workspace } from '../../types';
|
import type { TerminalSession, Workspace } from '../../types';
|
||||||
import { useActiveTabId } from '../state/activeTabStore';
|
import { useActiveTabId } from '../state/activeTabStore';
|
||||||
import { resolveActivePluginKeybindingContext } from '../state/pluginContributionContexts';
|
import { resolveActivePluginKeybindingContext } from '../state/pluginContributionContexts';
|
||||||
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
|
|
||||||
import { getPersistedAiSessionsSnapshot } from '../state/aiStateSnapshots';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leaf host for plugin keybindings so AppView does not subscribe to activeTabId.
|
* Leaf host for plugin keybindings so AppView does not subscribe to activeTabId.
|
||||||
@@ -25,19 +23,11 @@ export function AppPluginKeybindingHost({
|
|||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
}) {
|
}) {
|
||||||
const activeTabId = useActiveTabId();
|
const activeTabId = useActiveTabId();
|
||||||
const keybindingContext = useMemo(() => {
|
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
|
||||||
// AI chat recency drives the workspace anchor fallback (#4098 phase 2).
|
activeTabId,
|
||||||
// Read without subscribing: keybinding context is best-effort and refreshes
|
sessions,
|
||||||
// on every tab/session/workspaces change that matters here.
|
workspaces,
|
||||||
const live = getAISessionsSnapshot().sessions;
|
}), [activeTabId, sessions, workspaces]);
|
||||||
const aiSessions = live.length > 0 ? live : getPersistedAiSessionsSnapshot();
|
|
||||||
return resolveActivePluginKeybindingContext({
|
|
||||||
activeTabId,
|
|
||||||
sessions,
|
|
||||||
workspaces,
|
|
||||||
aiSessions,
|
|
||||||
});
|
|
||||||
}, [activeTabId, sessions, workspaces]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PluginContributionHost
|
<PluginContributionHost
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import test from 'node:test';
|
|||||||
|
|
||||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||||
|
|
||||||
test('quick-switcher Escape handling stays in the bubble phase', () => {
|
test('terminal Escape restoration runs before xterm can stop propagation', () => {
|
||||||
const effectStart = source.indexOf('const onKeyDown = (e: KeyboardEvent) => {\n _handleEscapeKeyDown(e);');
|
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
|
||||||
const effectEnd = source.indexOf('}, [appLockLocked]);', effectStart);
|
const effectSource = source.slice(effectStart, effectStart + 900);
|
||||||
const effectSource = source.slice(effectStart, effectEnd);
|
|
||||||
|
|
||||||
assert.notEqual(effectStart, -1);
|
assert.notEqual(effectStart, -1);
|
||||||
assert.match(effectSource, /window\.addEventListener\('keydown', onKeyDown\)/);
|
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
|
||||||
assert.match(effectSource, /window\.removeEventListener\('keydown', onKeyDown\)/);
|
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||||
assert.doesNotMatch(source, /onCaptureKeyDown/);
|
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||||
});
|
});
|
||||||
@@ -58,9 +58,7 @@ import {
|
|||||||
} from '../../domain/terminalAppearance';
|
} from '../../domain/terminalAppearance';
|
||||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||||
import { collectSessionIds } from '../../domain/workspace';
|
import { collectSessionIds } from '../../domain/workspace';
|
||||||
import { resolveAgentHostOpenWorkspace } from '../../domain/agentHostWorkspace';
|
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||||
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
|
|
||||||
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
|
||||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||||
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||||
@@ -615,7 +613,7 @@ export function AppSideEffects() {
|
|||||||
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
|
||||||
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
|
||||||
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
|
||||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
|
||||||
|
|
||||||
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
|
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
|
||||||
// dedicated transfer resume so quick-connect rows do not break tray connect.
|
// dedicated transfer resume so quick-connect rows do not break tray connect.
|
||||||
@@ -1235,11 +1233,19 @@ export function AppSideEffects() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (appLockLocked) return;
|
if (appLockLocked) return;
|
||||||
|
const onCaptureKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
const target = e.target;
|
||||||
|
if (!(target instanceof HTMLElement) || !target.closest('.xterm')) return;
|
||||||
|
_handleEscapeKeyDown(e);
|
||||||
|
};
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
_handleEscapeKeyDown(e);
|
_handleEscapeKeyDown(e);
|
||||||
};
|
};
|
||||||
|
window.addEventListener('keydown', onCaptureKeyDown, true);
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onCaptureKeyDown, true);
|
||||||
window.removeEventListener('keydown', onKeyDown);
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
};
|
};
|
||||||
}, [appLockLocked]);
|
}, [appLockLocked]);
|
||||||
@@ -1499,21 +1505,8 @@ export function AppSideEffects() {
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
}, [handleConnectToHost, renameSessionInline]);
|
}, [handleConnectToHost, renameSessionInline]);
|
||||||
|
|
||||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string; chatSessionId?: string }) => {
|
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
|
||||||
// A resumed history chat can run in a panel other than its saved binding.
|
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
|
||||||
// Prefer that turn's captured scope, never the window's current focus.
|
|
||||||
// Outside a running turn, retain the saved chat binding as the fallback.
|
|
||||||
const chat = options?.chatSessionId
|
|
||||||
? getAISessionsSnapshot().sessions.find((session) => session.id === options.chatSessionId)
|
|
||||||
: undefined;
|
|
||||||
const turnScope = options?.chatSessionId
|
|
||||||
? peekAgentRuntimeCleanup()?.getActiveScope?.(options.chatSessionId)
|
|
||||||
: undefined;
|
|
||||||
const target = resolveAgentHostOpenWorkspace(options?.workspaceId, turnScope ? { scope: turnScope } : chat, {
|
|
||||||
activeTabId: activeTabStore.getActiveTabId(),
|
|
||||||
workspaceIds: workspaces.map(workspace => workspace.id),
|
|
||||||
terminalSessions: sessionsRef.current,
|
|
||||||
});
|
|
||||||
if (target.error) return { ok: false as const, error: target.error };
|
if (target.error) return { ok: false as const, error: target.error };
|
||||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, moveWorkspacePane,
|
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||||
@@ -279,7 +279,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||||
setWorkspaceFocusedSession, setWorkspaceAnchorChat, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||||
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
|
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
|
||||||
@@ -774,14 +774,12 @@ function AppViewInner({ domains }: AppViewProps) {
|
|||||||
onTerminalDataCapture={handleTerminalDataCapture}
|
onTerminalDataCapture={handleTerminalDataCapture}
|
||||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||||
onMoveWorkspacePane={moveWorkspacePane}
|
|
||||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||||
onUpdateSplitSizes={updateSplitSizes}
|
onUpdateSplitSizes={updateSplitSizes}
|
||||||
onSetDraggingSessionId={setDraggingSessionId}
|
onSetDraggingSessionId={setDraggingSessionId}
|
||||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||||
onSetWorkspaceAnchorChat={setWorkspaceAnchorChat}
|
|
||||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||||
onReorderTabs={reorderWorkTabs}
|
onReorderTabs={reorderWorkTabs}
|
||||||
onCopySession={copySessionWithCurrentShell}
|
onCopySession={copySessionWithCurrentShell}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { registerHooks } from 'node:module';
|
|
||||||
import test from 'node:test';
|
|
||||||
import React from 'react';
|
|
||||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
|
||||||
import {
|
|
||||||
EMPTY_SESSION_SNAPSHOT,
|
|
||||||
getSessionSnapshotActions,
|
|
||||||
publishSessionSnapshot,
|
|
||||||
} from '../state/sessionSnapshotStore';
|
|
||||||
import { getAppShellPropsSnapshot } from './appShellPropsStore';
|
|
||||||
import { registerAppHandlers } from './appHandlersBridge';
|
|
||||||
|
|
||||||
// Keep both production publishers and the store between them real. Only the
|
|
||||||
// state owner and unrelated theme side effects are replaced with fixtures.
|
|
||||||
const sessionFixtureUrl = `data:text/javascript,${encodeURIComponent(`
|
|
||||||
let session;
|
|
||||||
export function setSession(value) { session = value; }
|
|
||||||
export function useSessionState() { return session; }
|
|
||||||
`)}`;
|
|
||||||
const themeFixtureUrl = `data:text/javascript,${encodeURIComponent(`
|
|
||||||
const noop = () => {};
|
|
||||||
const theme = {};
|
|
||||||
const runtime = {
|
|
||||||
globalAppearance: theme, accentedGlobalAppearance: theme,
|
|
||||||
clearIntent: noop, settleManualIntent: noop, pickTheme: noop,
|
|
||||||
resolveFocusedAppearance: noop, currentTerminalTheme: theme,
|
|
||||||
};
|
|
||||||
export function useThemeRuntime() { return runtime; }
|
|
||||||
export function useTerminalAppearanceInjection() {}
|
|
||||||
`)}`;
|
|
||||||
registerHooks({
|
|
||||||
resolve(specifier, context, nextResolve) {
|
|
||||||
if (specifier === '../../state/useSessionState' && context.parentURL?.endsWith('/SessionPublisher.tsx')) {
|
|
||||||
return { url: sessionFixtureUrl, shortCircuit: true };
|
|
||||||
}
|
|
||||||
if (specifier === '../../state/useThemeRuntime' && context.parentURL?.endsWith('/TerminalHost.tsx')) {
|
|
||||||
return { url: themeFixtureUrl, shortCircuit: true };
|
|
||||||
}
|
|
||||||
return nextResolve(specifier, context);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { setSession } = await import(sessionFixtureUrl);
|
|
||||||
const { SessionPublisher } = await import('./publishers/SessionPublisher');
|
|
||||||
const { TerminalHost } = await import('./hosts/TerminalHost');
|
|
||||||
|
|
||||||
test('workspace actions reach the terminal shell through SessionPublisher and TerminalHost', async (t) => {
|
|
||||||
const previousActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
|
||||||
let renderer: ReactTestRenderer | undefined;
|
|
||||||
t.after(async () => {
|
|
||||||
await act(async () => renderer?.unmount());
|
|
||||||
registerAppHandlers(null);
|
|
||||||
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
|
|
||||||
if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment);
|
|
||||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
const calls: unknown[][] = [];
|
|
||||||
const moveWorkspacePane = (...args: unknown[]) => calls.push(['move', ...args]);
|
|
||||||
const setWorkspaceAnchorChat = (...args: unknown[]) => calls.push(['anchor', ...args]);
|
|
||||||
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane, setWorkspaceAnchorChat });
|
|
||||||
registerAppHandlers({});
|
|
||||||
const renderTree = () => React.createElement(
|
|
||||||
SessionPublisher,
|
|
||||||
{ persistSessionRestore: false },
|
|
||||||
React.createElement(TerminalHost),
|
|
||||||
);
|
|
||||||
await act(async () => { renderer = create(renderTree()); });
|
|
||||||
|
|
||||||
assert.equal(getSessionSnapshotActions()?.moveWorkspacePane, moveWorkspacePane);
|
|
||||||
assert.equal(getSessionSnapshotActions()?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
|
|
||||||
const terminal = getAppShellPropsSnapshot().domains?.terminal;
|
|
||||||
assert.equal(terminal?.moveWorkspacePane, moveWorkspacePane);
|
|
||||||
assert.equal(terminal?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
|
|
||||||
const hint = { direction: 'horizontal', position: 'top', targetSessionId: 'pane-b' };
|
|
||||||
(terminal?.moveWorkspacePane as typeof moveWorkspacePane)('workspace', 'pane-a', hint);
|
|
||||||
(terminal?.setWorkspaceAnchorChat as typeof setWorkspaceAnchorChat)('workspace', 'chat');
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
['move', 'workspace', 'pane-a', hint],
|
|
||||||
['anchor', 'workspace', 'chat'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// A changed state-owner callback must not be trapped in a stale memoized
|
|
||||||
// action bag even when all session/workspace snapshots stay identical.
|
|
||||||
const replacementMove = (...args: unknown[]) => calls.push(['replacement-move', ...args]);
|
|
||||||
const replacementAnchor = (...args: unknown[]) => calls.push(['replacement-anchor', ...args]);
|
|
||||||
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane: replacementMove, setWorkspaceAnchorChat: replacementAnchor });
|
|
||||||
await act(async () => renderer!.update(renderTree()));
|
|
||||||
assert.equal(getAppShellPropsSnapshot().domains?.terminal.moveWorkspacePane, replacementMove);
|
|
||||||
assert.equal(getAppShellPropsSnapshot().domains?.terminal.setWorkspaceAnchorChat, replacementAnchor);
|
|
||||||
});
|
|
||||||
@@ -170,7 +170,6 @@ export function TerminalHost() {
|
|||||||
if (!handlers) return null;
|
if (!handlers) return null;
|
||||||
return {
|
return {
|
||||||
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
|
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
|
||||||
moveWorkspacePane: sessionActions?.moveWorkspacePane,
|
|
||||||
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
|
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
|
||||||
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
|
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
|
||||||
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
|
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
|
||||||
@@ -228,7 +227,6 @@ export function TerminalHost() {
|
|||||||
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
|
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
|
||||||
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
|
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
|
||||||
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
|
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
|
||||||
setWorkspaceAnchorChat: sessionActions?.setWorkspaceAnchorChat,
|
|
||||||
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
|
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
|
||||||
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
|
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
|
||||||
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
|
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
|||||||
resetWorkspaceRename,
|
resetWorkspaceRename,
|
||||||
removeSessionFromWorkspace,
|
removeSessionFromWorkspace,
|
||||||
setWorkspaceFocusedSession,
|
setWorkspaceFocusedSession,
|
||||||
setWorkspaceAnchorChat,
|
|
||||||
toggleWorkspaceViewMode,
|
toggleWorkspaceViewMode,
|
||||||
createLocalTerminal,
|
createLocalTerminal,
|
||||||
createSerialSession,
|
createSerialSession,
|
||||||
@@ -59,7 +58,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
|||||||
createWorkspaceWithHosts,
|
createWorkspaceWithHosts,
|
||||||
createWorkspaceFromSessions,
|
createWorkspaceFromSessions,
|
||||||
addSessionToWorkspace,
|
addSessionToWorkspace,
|
||||||
moveWorkspacePane,
|
|
||||||
appendHostToWorkspace,
|
appendHostToWorkspace,
|
||||||
appendHostToWorkspaceConfirmed,
|
appendHostToWorkspaceConfirmed,
|
||||||
appendLocalTerminalToWorkspace,
|
appendLocalTerminalToWorkspace,
|
||||||
@@ -135,7 +133,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
|||||||
resetWorkspaceRename,
|
resetWorkspaceRename,
|
||||||
removeSessionFromWorkspace,
|
removeSessionFromWorkspace,
|
||||||
setWorkspaceFocusedSession,
|
setWorkspaceFocusedSession,
|
||||||
setWorkspaceAnchorChat,
|
|
||||||
toggleWorkspaceViewMode,
|
toggleWorkspaceViewMode,
|
||||||
createLocalTerminal,
|
createLocalTerminal,
|
||||||
createSerialSession,
|
createSerialSession,
|
||||||
@@ -146,7 +143,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
|||||||
createWorkspaceWithHosts,
|
createWorkspaceWithHosts,
|
||||||
createWorkspaceFromSessions,
|
createWorkspaceFromSessions,
|
||||||
addSessionToWorkspace,
|
addSessionToWorkspace,
|
||||||
moveWorkspacePane,
|
|
||||||
appendHostToWorkspace,
|
appendHostToWorkspace,
|
||||||
appendHostToWorkspaceConfirmed,
|
appendHostToWorkspaceConfirmed,
|
||||||
appendLocalTerminalToWorkspace,
|
appendLocalTerminalToWorkspace,
|
||||||
@@ -176,7 +172,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
|||||||
updateSessionCodingCliProvider,
|
updateSessionCodingCliProvider,
|
||||||
}), [
|
}), [
|
||||||
addSessionToWorkspace,
|
addSessionToWorkspace,
|
||||||
moveWorkspacePane,
|
|
||||||
appendHostToWorkspace,
|
appendHostToWorkspace,
|
||||||
appendHostToWorkspaceConfirmed,
|
appendHostToWorkspaceConfirmed,
|
||||||
appendLocalTerminalToWorkspace,
|
appendLocalTerminalToWorkspace,
|
||||||
@@ -213,7 +208,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
|||||||
setActiveTabId,
|
setActiveTabId,
|
||||||
setDraggingSessionId,
|
setDraggingSessionId,
|
||||||
setWorkspaceFocusedSession,
|
setWorkspaceFocusedSession,
|
||||||
setWorkspaceAnchorChat,
|
|
||||||
splitSession,
|
splitSession,
|
||||||
startSessionRename,
|
startSessionRename,
|
||||||
startWorkspaceRename,
|
startWorkspaceRename,
|
||||||
|
|||||||
@@ -720,8 +720,6 @@ export const enAiMessages: Messages = {
|
|||||||
|
|
||||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||||
'terminal.layer.addTerminal': 'Add Terminal',
|
'terminal.layer.addTerminal': 'Add Terminal',
|
||||||
'terminal.layer.emptyWorkspace': 'No terminals in this workspace',
|
|
||||||
'terminal.layer.emptyWorkspaceHint': 'Add a local terminal or connect to a host to get started.',
|
|
||||||
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
'terminal.layer.switchToSplitView': 'Switch to Split View',
|
||||||
'terminal.layer.sftp': 'SFTP',
|
'terminal.layer.sftp': 'SFTP',
|
||||||
'terminal.layer.scripts': 'Scripts',
|
'terminal.layer.scripts': 'Scripts',
|
||||||
|
|||||||
@@ -316,26 +316,7 @@ export const enVaultMessages: Messages = {
|
|||||||
'sftp.extract.extracting': 'Extracting {fileName}...',
|
'sftp.extract.extracting': 'Extracting {fileName}...',
|
||||||
'sftp.extract.success': 'Extracted {fileName}',
|
'sftp.extract.success': 'Extracted {fileName}',
|
||||||
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
|
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
|
||||||
'sftp.archive.action': 'Compress...',
|
|
||||||
'sftp.archive.title': 'Compress files',
|
|
||||||
'sftp.archive.description': 'Create an archive on the server from {count} selected items.',
|
|
||||||
'sftp.archive.name': 'Archive name',
|
|
||||||
'sftp.archive.format': 'Format',
|
|
||||||
'sftp.archive.destination': 'Save in',
|
|
||||||
'sftp.archive.create': 'Compress',
|
|
||||||
'sftp.archive.creating': 'Compressing...',
|
|
||||||
'sftp.archive.overwriteTitle': 'Replace existing archive?',
|
|
||||||
'sftp.archive.overwriteDescription': 'An archive named "{name}" already exists. Confirm to replace it with the new archive.',
|
|
||||||
'sftp.archive.overwrite': 'Replace and compress',
|
|
||||||
'sftp.archive.failed': 'Failed to create archive',
|
|
||||||
'sftp.archive.invalidSelection': 'Cannot compress this selection',
|
|
||||||
'sftp.archive.invalidName': 'Enter a valid file name without a directory path.',
|
|
||||||
'sftp.archive.invalidExtension': 'The file extension must match the selected format.',
|
|
||||||
'sftp.archive.connectionChanged': 'The connection has changed. Select the files again.',
|
|
||||||
'sftp.archive.unavailable': 'Archive creation is unavailable for this connection.',
|
|
||||||
'sftp.archive.success': 'Created {fileName}',
|
|
||||||
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
'sftp.context.copyToOtherPane': 'Copy to other pane',
|
||||||
'sftp.context.duplicate': 'Duplicate',
|
|
||||||
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
||||||
'sftp.copyCurrentPath': 'Copy current path',
|
'sftp.copyCurrentPath': 'Copy current path',
|
||||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||||
|
|||||||
@@ -720,8 +720,6 @@ export const esAiMessages: Messages = {
|
|||||||
|
|
||||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||||
'terminal.layer.addTerminal': 'Agregar terminal',
|
'terminal.layer.addTerminal': 'Agregar terminal',
|
||||||
'terminal.layer.emptyWorkspace': 'No hay terminales en este espacio de trabajo',
|
|
||||||
'terminal.layer.emptyWorkspaceHint': 'Agrega un terminal local o conecta un host para comenzar.',
|
|
||||||
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
|
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
|
||||||
'terminal.layer.sftp': 'SFTP',
|
'terminal.layer.sftp': 'SFTP',
|
||||||
'terminal.layer.scripts': 'Scripts',
|
'terminal.layer.scripts': 'Scripts',
|
||||||
|
|||||||
@@ -316,26 +316,7 @@ export const esVaultMessages: Messages = {
|
|||||||
'sftp.extract.extracting': 'Extrayendo {fileName}...',
|
'sftp.extract.extracting': 'Extrayendo {fileName}...',
|
||||||
'sftp.extract.success': 'Se extrajo {fileName}',
|
'sftp.extract.success': 'Se extrajo {fileName}',
|
||||||
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
|
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
|
||||||
'sftp.archive.action': 'Comprimir...',
|
|
||||||
'sftp.archive.title': 'Comprimir archivos',
|
|
||||||
'sftp.archive.description': 'Crea un archivo comprimido en el servidor con los {count} elementos seleccionados.',
|
|
||||||
'sftp.archive.name': 'Nombre del archivo comprimido',
|
|
||||||
'sftp.archive.format': 'Formato',
|
|
||||||
'sftp.archive.destination': 'Guardar en',
|
|
||||||
'sftp.archive.create': 'Comprimir',
|
|
||||||
'sftp.archive.creating': 'Comprimiendo...',
|
|
||||||
'sftp.archive.overwriteTitle': '¿Reemplazar el archivo comprimido existente?',
|
|
||||||
'sftp.archive.overwriteDescription': 'Ya existe un archivo comprimido llamado "{name}". Confirma para reemplazarlo por el nuevo.',
|
|
||||||
'sftp.archive.overwrite': 'Reemplazar y comprimir',
|
|
||||||
'sftp.archive.failed': 'No se pudo crear el archivo comprimido',
|
|
||||||
'sftp.archive.invalidSelection': 'No se puede comprimir esta selección',
|
|
||||||
'sftp.archive.invalidName': 'Ingresa un nombre de archivo válido sin una ruta de directorio.',
|
|
||||||
'sftp.archive.invalidExtension': 'La extensión del archivo debe coincidir con el formato seleccionado.',
|
|
||||||
'sftp.archive.connectionChanged': 'La conexión cambió. Vuelve a seleccionar los archivos.',
|
|
||||||
'sftp.archive.unavailable': 'No se pueden crear archivos comprimidos en esta conexión.',
|
|
||||||
'sftp.archive.success': 'Se creó {fileName}',
|
|
||||||
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
|
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
|
||||||
'sftp.context.duplicate': 'Duplicar',
|
|
||||||
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
|
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
|
||||||
'sftp.copyCurrentPath': 'Copiar ruta actual',
|
'sftp.copyCurrentPath': 'Copiar ruta actual',
|
||||||
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
|
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
|
||||||
|
|||||||
@@ -659,8 +659,6 @@ export const ruAiMessages: Messages = {
|
|||||||
|
|
||||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||||
'terminal.layer.addTerminal': 'Добавить терминал',
|
'terminal.layer.addTerminal': 'Добавить терминал',
|
||||||
'terminal.layer.emptyWorkspace': 'В этом рабочем пространстве пока нет терминалов',
|
|
||||||
'terminal.layer.emptyWorkspaceHint': 'Добавьте локальный терминал или подключитесь к хосту, чтобы начать.',
|
|
||||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||||
'terminal.layer.sftp': 'SFTP',
|
'terminal.layer.sftp': 'SFTP',
|
||||||
'terminal.layer.scripts': 'Скрипты',
|
'terminal.layer.scripts': 'Скрипты',
|
||||||
|
|||||||
@@ -360,26 +360,7 @@ export const ruVaultMessages: Messages = {
|
|||||||
'sftp.extract.extracting': 'Распаковка {fileName}...',
|
'sftp.extract.extracting': 'Распаковка {fileName}...',
|
||||||
'sftp.extract.success': 'Распакован {fileName}',
|
'sftp.extract.success': 'Распакован {fileName}',
|
||||||
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
|
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
|
||||||
'sftp.archive.action': 'Сжать...',
|
|
||||||
'sftp.archive.title': 'Сжать файлы',
|
|
||||||
'sftp.archive.description': 'Создать архив на сервере из выбранных объектов: {count}.',
|
|
||||||
'sftp.archive.name': 'Имя архива',
|
|
||||||
'sftp.archive.format': 'Формат',
|
|
||||||
'sftp.archive.destination': 'Сохранить в',
|
|
||||||
'sftp.archive.create': 'Сжать',
|
|
||||||
'sftp.archive.creating': 'Сжатие...',
|
|
||||||
'sftp.archive.overwriteTitle': 'Заменить существующий архив?',
|
|
||||||
'sftp.archive.overwriteDescription': 'Архив с именем «{name}» уже существует. Подтвердите его замену новым архивом.',
|
|
||||||
'sftp.archive.overwrite': 'Заменить и сжать',
|
|
||||||
'sftp.archive.failed': 'Не удалось создать архив',
|
|
||||||
'sftp.archive.invalidSelection': 'Невозможно сжать выбранные объекты',
|
|
||||||
'sftp.archive.invalidName': 'Введите допустимое имя файла без пути к каталогу.',
|
|
||||||
'sftp.archive.invalidExtension': 'Расширение файла должно соответствовать выбранному формату.',
|
|
||||||
'sftp.archive.connectionChanged': 'Подключение изменилось. Выберите файлы заново.',
|
|
||||||
'sftp.archive.unavailable': 'Создание архивов недоступно для этого подключения.',
|
|
||||||
'sftp.archive.success': 'Создан архив {fileName}',
|
|
||||||
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
|
||||||
'sftp.context.duplicate': 'Дублировать',
|
|
||||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||||
|
|||||||
@@ -720,8 +720,6 @@ export const zhCNAiMessages: Messages = {
|
|||||||
|
|
||||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||||
'terminal.layer.addTerminal': '添加终端',
|
'terminal.layer.addTerminal': '添加终端',
|
||||||
'terminal.layer.emptyWorkspace': '工作区中还没有终端',
|
|
||||||
'terminal.layer.emptyWorkspaceHint': '添加本地终端或连接主机,开始使用这个工作区。',
|
|
||||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||||
'terminal.layer.sftp': '文件传输',
|
'terminal.layer.sftp': '文件传输',
|
||||||
'terminal.layer.scripts': '脚本',
|
'terminal.layer.scripts': '脚本',
|
||||||
|
|||||||
@@ -1053,26 +1053,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
|||||||
'sftp.extract.extracting': '正在解压 {fileName}...',
|
'sftp.extract.extracting': '正在解压 {fileName}...',
|
||||||
'sftp.extract.success': '已解压 {fileName}',
|
'sftp.extract.success': '已解压 {fileName}',
|
||||||
'sftp.extract.error': '解压 {fileName} 失败:{error}',
|
'sftp.extract.error': '解压 {fileName} 失败:{error}',
|
||||||
'sftp.archive.action': '压缩…',
|
|
||||||
'sftp.archive.title': '压缩文件',
|
|
||||||
'sftp.archive.description': '将 {count} 个选中项目打包到服务器上的压缩包。',
|
|
||||||
'sftp.archive.name': '压缩包名称',
|
|
||||||
'sftp.archive.format': '格式',
|
|
||||||
'sftp.archive.destination': '保存目录',
|
|
||||||
'sftp.archive.create': '压缩',
|
|
||||||
'sftp.archive.creating': '正在压缩…',
|
|
||||||
'sftp.archive.overwriteTitle': '覆盖现有压缩包?',
|
|
||||||
'sftp.archive.overwriteDescription': '“{name}”已存在。确认后将用新的压缩包替换它。',
|
|
||||||
'sftp.archive.overwrite': '覆盖并压缩',
|
|
||||||
'sftp.archive.failed': '压缩失败',
|
|
||||||
'sftp.archive.invalidSelection': '无法压缩此选区',
|
|
||||||
'sftp.archive.invalidName': '请输入有效的文件名,不要包含目录路径。',
|
|
||||||
'sftp.archive.invalidExtension': '文件扩展名必须与所选格式一致。',
|
|
||||||
'sftp.archive.connectionChanged': '连接已切换,请重新选择文件。',
|
|
||||||
'sftp.archive.unavailable': '当前连接不支持创建压缩包。',
|
|
||||||
'sftp.archive.success': '已创建 {fileName}',
|
|
||||||
'sftp.context.copyToOtherPane': '复制到另一侧',
|
'sftp.context.copyToOtherPane': '复制到另一侧',
|
||||||
'sftp.context.duplicate': '创建副本',
|
|
||||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||||
'sftp.copyCurrentPath': '复制当前路径',
|
'sftp.copyCurrentPath': '复制当前路径',
|
||||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||||
|
|||||||
@@ -710,8 +710,6 @@ export const zhTWAiMessages: Messages = {
|
|||||||
|
|
||||||
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
|
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
|
||||||
'terminal.layer.addTerminal': '新增終端',
|
'terminal.layer.addTerminal': '新增終端',
|
||||||
'terminal.layer.emptyWorkspace': '工作區中還沒有終端',
|
|
||||||
'terminal.layer.emptyWorkspaceHint': '新增本機終端或連線至主機,開始使用這個工作區。',
|
|
||||||
'terminal.layer.switchToSplitView': '切換到分割檢視',
|
'terminal.layer.switchToSplitView': '切換到分割檢視',
|
||||||
'terminal.layer.sftp': '檔案傳輸',
|
'terminal.layer.sftp': '檔案傳輸',
|
||||||
'terminal.layer.scripts': '指令碼',
|
'terminal.layer.scripts': '指令碼',
|
||||||
|
|||||||
@@ -1055,26 +1055,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
|||||||
'sftp.extract.extracting': '正在解壓 {fileName}...',
|
'sftp.extract.extracting': '正在解壓 {fileName}...',
|
||||||
'sftp.extract.success': '已解壓 {fileName}',
|
'sftp.extract.success': '已解壓 {fileName}',
|
||||||
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
|
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
|
||||||
'sftp.archive.action': '壓縮…',
|
|
||||||
'sftp.archive.title': '壓縮檔案',
|
|
||||||
'sftp.archive.description': '將 {count} 個選取項目打包成伺服器上的壓縮檔。',
|
|
||||||
'sftp.archive.name': '壓縮檔名稱',
|
|
||||||
'sftp.archive.format': '格式',
|
|
||||||
'sftp.archive.destination': '儲存目錄',
|
|
||||||
'sftp.archive.create': '壓縮',
|
|
||||||
'sftp.archive.creating': '正在壓縮…',
|
|
||||||
'sftp.archive.overwriteTitle': '覆寫現有壓縮檔?',
|
|
||||||
'sftp.archive.overwriteDescription': '「{name}」已存在。確認後將以新的壓縮檔取代它。',
|
|
||||||
'sftp.archive.overwrite': '覆寫並壓縮',
|
|
||||||
'sftp.archive.failed': '壓縮失敗',
|
|
||||||
'sftp.archive.invalidSelection': '無法壓縮此選取範圍',
|
|
||||||
'sftp.archive.invalidName': '請輸入有效的檔案名稱,不要包含目錄路徑。',
|
|
||||||
'sftp.archive.invalidExtension': '副檔名必須與所選格式一致。',
|
|
||||||
'sftp.archive.connectionChanged': '連線已切換,請重新選取檔案。',
|
|
||||||
'sftp.archive.unavailable': '目前連線不支援建立壓縮檔。',
|
|
||||||
'sftp.archive.success': '已建立 {fileName}',
|
|
||||||
'sftp.context.copyToOtherPane': '複製到另一側',
|
'sftp.context.copyToOtherPane': '複製到另一側',
|
||||||
'sftp.context.duplicate': '建立副本',
|
|
||||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||||
'sftp.copyCurrentPath': '複製目前路徑',
|
'sftp.copyCurrentPath': '複製目前路徑',
|
||||||
'sftp.copyCurrentPath.success': '已複製目前路徑',
|
'sftp.copyCurrentPath.success': '已複製目前路徑',
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function isSuccessfulApplyResult(result: unknown): boolean {
|
|||||||
* the DB before collection. Ordinary last-known collect snapshots are never
|
* the DB before collection. Ordinary last-known collect snapshots are never
|
||||||
* re-applied, so local settings edits made after the last collect are kept.
|
* re-applied, so local settings edits made after the last collect are kept.
|
||||||
*/
|
*/
|
||||||
export async function collectPluginSyncSidecarsFromHost(options: {
|
export async function collectPluginSyncSidecarsFromHost(options?: {
|
||||||
/**
|
/**
|
||||||
* When true, never fall back to last-known cache. Returns null if the host is
|
* When true, never fall back to last-known cache. Returns null if the host is
|
||||||
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
|
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
|
||||||
|
|||||||
@@ -75,95 +75,6 @@ test('resolves the focused workspace session for global keybindings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses the anchor chat owner when focus is missing (#4098 phase 2)', () => {
|
|
||||||
const splitWorkspace: Workspace = {
|
|
||||||
id: 'workspace-1',
|
|
||||||
title: 'Workspace',
|
|
||||||
anchorChatId: 'chat-2',
|
|
||||||
root: {
|
|
||||||
id: 'split-1',
|
|
||||||
type: 'split',
|
|
||||||
direction: 'vertical',
|
|
||||||
children: [
|
|
||||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
|
||||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
|
|
||||||
],
|
|
||||||
sizes: [1, 1],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const chatOwnerSessions: TerminalSession[] = [
|
|
||||||
...sessions,
|
|
||||||
{
|
|
||||||
id: 'session-3',
|
|
||||||
hostId: 'host-3',
|
|
||||||
hostLabel: 'Host 3',
|
|
||||||
username: 'root',
|
|
||||||
hostname: 'example.org',
|
|
||||||
status: 'connected',
|
|
||||||
protocol: 'ssh',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
resolveActivePluginKeybindingContext({
|
|
||||||
activeTabId: 'workspace-1',
|
|
||||||
sessions: chatOwnerSessions,
|
|
||||||
workspaces: [splitWorkspace],
|
|
||||||
aiSessions: [
|
|
||||||
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 2 },
|
|
||||||
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
|
|
||||||
],
|
|
||||||
})['terminal.sessionId'],
|
|
||||||
// anchorChatId's chat is terminal-scoped to session-3, which wins even
|
|
||||||
// though session-1's chat is older and session-1 is the first member.
|
|
||||||
'session-3',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to the most-recently-active member when there is no anchor (#4098 phase 2)', () => {
|
|
||||||
const splitWorkspace: Workspace = {
|
|
||||||
id: 'workspace-1',
|
|
||||||
title: 'Workspace',
|
|
||||||
root: {
|
|
||||||
id: 'split-1',
|
|
||||||
type: 'split',
|
|
||||||
direction: 'vertical',
|
|
||||||
children: [
|
|
||||||
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
|
|
||||||
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
|
|
||||||
],
|
|
||||||
sizes: [1, 1],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const chatOwnerSessions: TerminalSession[] = [
|
|
||||||
...sessions,
|
|
||||||
{
|
|
||||||
id: 'session-3',
|
|
||||||
hostId: 'host-3',
|
|
||||||
hostLabel: 'Host 3',
|
|
||||||
username: 'root',
|
|
||||||
hostname: 'example.org',
|
|
||||||
status: 'connected',
|
|
||||||
protocol: 'ssh',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
resolveActivePluginKeybindingContext({
|
|
||||||
activeTabId: 'workspace-1',
|
|
||||||
sessions: chatOwnerSessions,
|
|
||||||
workspaces: [splitWorkspace],
|
|
||||||
aiSessions: [
|
|
||||||
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
|
|
||||||
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 9 },
|
|
||||||
],
|
|
||||||
})['terminal.sessionId'],
|
|
||||||
'session-3',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolves standalone sessions and fails closed for non-terminal tabs', () => {
|
test('resolves standalone sessions and fails closed for non-terminal tabs', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
|
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
|
||||||
@@ -178,18 +89,3 @@ test('resolves standalone sessions and fails closed for non-terminal tabs', () =
|
|||||||
'netcatty.activeTabId': 'vault',
|
'netcatty.activeTabId': 'vault',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('empty workspace does not target the detached owner of its anchored chat', () => {
|
|
||||||
const context = resolveActivePluginKeybindingContext({
|
|
||||||
activeTabId: 'workspace-1',
|
|
||||||
sessions: sessions.map((session) => ({ ...session, workspaceId: undefined })),
|
|
||||||
workspaces: [{ ...workspaces[0], focusedSessionId: undefined, anchorChatId: 'chat-1', root: {
|
|
||||||
id: 'empty', type: 'split', direction: 'vertical', children: [], sizes: [],
|
|
||||||
} }],
|
|
||||||
aiSessions: [{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 }],
|
|
||||||
});
|
|
||||||
assert.equal(context['terminal.sessionId'], undefined);
|
|
||||||
assert.equal(context['host.id'], undefined);
|
|
||||||
assert.equal(context['workspace.id'], 'workspace-1');
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { collectSessionIds } from '../../domain/workspace';
|
import { collectSessionIds } from '../../domain/workspace';
|
||||||
import { resolveMostRecentlyActiveMemberTerminalId } from '../../domain/aiSessionRecency';
|
|
||||||
import type { AIChatRecencyEntry } from '../../domain/aiSessionRecency';
|
|
||||||
import type { TerminalSession, Workspace } from '../../types';
|
import type { TerminalSession, Workspace } from '../../types';
|
||||||
|
|
||||||
export type PluginContributionContextValue = string | boolean | number | null;
|
export type PluginContributionContextValue = string | boolean | number | null;
|
||||||
@@ -46,17 +44,14 @@ export function resolveActivePluginKeybindingContext({
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
sessions,
|
sessions,
|
||||||
workspaces,
|
workspaces,
|
||||||
aiSessions,
|
|
||||||
}: {
|
}: {
|
||||||
activeTabId: string;
|
activeTabId: string;
|
||||||
sessions: readonly TerminalSession[];
|
sessions: readonly TerminalSession[];
|
||||||
workspaces: readonly Workspace[];
|
workspaces: readonly Workspace[];
|
||||||
/** AI chat snapshot: resolves the workspace anchor and recency fallback (#4098 phase 2). */
|
|
||||||
aiSessions?: readonly AIChatRecencyEntry[];
|
|
||||||
}): PluginContributionContext {
|
}): PluginContributionContext {
|
||||||
const workspace = workspaces.find((candidate) => candidate.id === activeTabId);
|
const workspace = workspaces.find((candidate) => candidate.id === activeTabId);
|
||||||
const activeSessionId = workspace
|
const activeSessionId = workspace
|
||||||
? resolveWorkspaceActiveTerminalId(workspace, aiSessions)
|
? (workspace.focusedSessionId ?? collectSessionIds(workspace.root)[0])
|
||||||
: sessions.some((candidate) => candidate.id === activeTabId)
|
: sessions.some((candidate) => candidate.id === activeTabId)
|
||||||
? activeTabId
|
? activeTabId
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -77,32 +72,3 @@ export function resolveActivePluginKeybindingContext({
|
|||||||
'netcatty.activeTabId': activeTabId,
|
'netcatty.activeTabId': activeTabId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue #4098 phase 2: the keybinding context's workspace session resolves by
|
|
||||||
* id, never positionally — focused member first, then the terminal member that
|
|
||||||
* owns the anchored chat, then the most-recently-active member by chat recency.
|
|
||||||
*/
|
|
||||||
function resolveWorkspaceActiveTerminalId(
|
|
||||||
workspace: Workspace,
|
|
||||||
aiSessions: readonly AIChatRecencyEntry[] | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
if (workspace.focusedSessionId && collectSessionIds(workspace.root).includes(workspace.focusedSessionId)) {
|
|
||||||
return workspace.focusedSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberTerminalIds = collectSessionIds(workspace.root);
|
|
||||||
if (memberTerminalIds.length === 1) {
|
|
||||||
// A single-member workspace has exactly one candidate — not a positional
|
|
||||||
// pick among many.
|
|
||||||
return memberTerminalIds[0];
|
|
||||||
}
|
|
||||||
const anchorChat = workspace.anchorChatId
|
|
||||||
? aiSessions?.find((session) => session.id === workspace.anchorChatId && session.scope?.type === 'terminal')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const anchorOwner = anchorChat?.scope.targetId;
|
|
||||||
return (anchorOwner && memberTerminalIds.includes(anchorOwner) ? anchorOwner : undefined)
|
|
||||||
?? resolveMostRecentlyActiveMemberTerminalId({ memberTerminalIds, aiSessions })
|
|
||||||
?? undefined;
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,6 @@ export type SessionSnapshotActions = Pick<
|
|||||||
| 'resetWorkspaceRename'
|
| 'resetWorkspaceRename'
|
||||||
| 'removeSessionFromWorkspace'
|
| 'removeSessionFromWorkspace'
|
||||||
| 'setWorkspaceFocusedSession'
|
| 'setWorkspaceFocusedSession'
|
||||||
| 'setWorkspaceAnchorChat'
|
|
||||||
| 'toggleWorkspaceViewMode'
|
| 'toggleWorkspaceViewMode'
|
||||||
// Terminal / chrome mutators previously read only via App session runtime.
|
// Terminal / chrome mutators previously read only via App session runtime.
|
||||||
| 'createLocalTerminal'
|
| 'createLocalTerminal'
|
||||||
@@ -57,7 +56,6 @@ export type SessionSnapshotActions = Pick<
|
|||||||
| 'createWorkspaceWithHosts'
|
| 'createWorkspaceWithHosts'
|
||||||
| 'createWorkspaceFromSessions'
|
| 'createWorkspaceFromSessions'
|
||||||
| 'addSessionToWorkspace'
|
| 'addSessionToWorkspace'
|
||||||
| 'moveWorkspacePane'
|
|
||||||
| 'appendHostToWorkspace'
|
| 'appendHostToWorkspace'
|
||||||
| 'appendHostToWorkspaceConfirmed'
|
| 'appendHostToWorkspaceConfirmed'
|
||||||
| 'appendLocalTerminalToWorkspace'
|
| 'appendLocalTerminalToWorkspace'
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||||
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
|
|
||||||
import {
|
import {
|
||||||
applyCloseSessionToSessions,
|
applyCloseSessionToSessions,
|
||||||
closeSessionsState,
|
closeSessionsState,
|
||||||
closeSessionWorkspaceLayoutState,
|
closeSessionWorkspaceLayoutState,
|
||||||
detachSessionFromWorkspaceState,
|
detachSessionFromWorkspaceState,
|
||||||
|
replaceDissolvedWorkspaceTabOrder,
|
||||||
resolveActiveTabAfterCloseSession,
|
resolveActiveTabAfterCloseSession,
|
||||||
} from "./sessionWorkspaceDetach";
|
} from "./sessionWorkspaceDetach";
|
||||||
|
|
||||||
@@ -14,19 +14,11 @@ const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
|||||||
id,
|
id,
|
||||||
hostId: id,
|
hostId: id,
|
||||||
hostLabel: id,
|
hostLabel: id,
|
||||||
username: "user",
|
|
||||||
hostname: id,
|
|
||||||
status: "connected",
|
status: "connected",
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chat = (id: string, targetId: string, updatedAt: number): AIChatRecencyEntry => ({
|
const workspace = (sessionIds: string[]): Workspace => ({
|
||||||
id,
|
|
||||||
scope: { type: "terminal", targetId },
|
|
||||||
updatedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Workspace => ({
|
|
||||||
id: "ws-1",
|
id: "ws-1",
|
||||||
title: "Workspace",
|
title: "Workspace",
|
||||||
focusedSessionId: sessionIds[0],
|
focusedSessionId: sessionIds[0],
|
||||||
@@ -44,17 +36,9 @@ const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Wo
|
|||||||
})),
|
})),
|
||||||
sizes: sessionIds.map(() => 1),
|
sizes: sessionIds.map(() => 1),
|
||||||
},
|
},
|
||||||
...overrides,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootPaneSessionIds = (workspace: Workspace): string[] => (
|
test("detach dissolves the original workspace when one session remains", () => {
|
||||||
workspace.root.type === "pane"
|
|
||||||
? [workspace.root.sessionId]
|
|
||||||
: workspace.root.children.map((child) => child.type === "pane" ? child.sessionId : "")
|
|
||||||
.filter((id): id is string => Boolean(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
test("detaching to one member keeps the workspace alive (#4098 phase 2)", () => {
|
|
||||||
const result = detachSessionFromWorkspaceState({
|
const result = detachSessionFromWorkspaceState({
|
||||||
sessions: [session("s1"), session("s2")],
|
sessions: [session("s1"), session("s2")],
|
||||||
workspaces: [workspace(["s1", "s2"])],
|
workspaces: [workspace(["s1", "s2"])],
|
||||||
@@ -65,126 +49,149 @@ test("detaching to one member keeps the workspace alive (#4098 phase 2)", () =>
|
|||||||
assert.equal(result.activeTabId, "s1");
|
assert.equal(result.activeTabId, "s1");
|
||||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||||
["s1", undefined],
|
["s1", undefined],
|
||||||
// The survivor keeps its workspace binding — no orphan promotion.
|
["s2", undefined],
|
||||||
["s2", "ws-1"],
|
|
||||||
]);
|
]);
|
||||||
assert.equal(result.workspaces.length, 1);
|
assert.equal(result.workspaces.length, 0);
|
||||||
assert.equal(result.workspaces[0].id, "ws-1");
|
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||||
assert.deepEqual(rootPaneSessionIds(result.workspaces[0]), ["s2"]);
|
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("detaching the last member keeps the workspace with an empty root (#4098 phase 2)", () => {
|
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||||
const result = detachSessionFromWorkspaceState({
|
const result = detachSessionFromWorkspaceState({
|
||||||
sessions: [session("s1")],
|
sessions: [session("s1"), session("s2"), session("s3")],
|
||||||
workspaces: [workspace(["s1"])],
|
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||||
sessionId: "s1",
|
sessionId: "s2",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.changed, true);
|
assert.equal(result.changed, true);
|
||||||
assert.equal(result.workspaces.length, 1);
|
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||||
assert.equal(result.workspaces[0].id, "ws-1");
|
["s1", "ws-1"],
|
||||||
assert.equal(result.workspaces[0].root.type, "split");
|
["s2", undefined],
|
||||||
assert.deepEqual(
|
["s3", "ws-1"],
|
||||||
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
|
]);
|
||||||
[],
|
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||||
);
|
|
||||||
assert.equal(result.workspaces[0].focusedSessionId, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closing one member keeps the workspace and its surviving member bound (#4098 phase 2)", () => {
|
|
||||||
const result = closeSessionsState({
|
|
||||||
sessions: [session("s1"), session("s2")],
|
|
||||||
workspaces: [workspace(["s1", "s2"])],
|
|
||||||
sessionIds: ["s1"],
|
|
||||||
currentActiveTabId: "ws-1",
|
|
||||||
tabOrder: ["ws-1"],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.workspaces.length, 1);
|
|
||||||
assert.equal(result.workspaces[0].id, "ws-1");
|
|
||||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [["s2", "ws-1"]]);
|
|
||||||
// The workspace tab is still live, so the active tab does not move.
|
|
||||||
assert.equal(result.activeTabId, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("member loss hands focus to the most-recently-active survivor, not sessions[0]", () => {
|
|
||||||
const result = closeSessionWorkspaceLayoutState(
|
|
||||||
[workspace(["s1", "s2", "s3"])],
|
|
||||||
"ws-1",
|
|
||||||
"s1",
|
|
||||||
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.workspaces.length, 1);
|
|
||||||
// remainingSessionIds[0] is s2, but s3's chat is the most recent.
|
|
||||||
assert.equal(result.workspaces[0].focusedSessionId, "s3");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reordering focus order does not change the survivor retarget", () => {
|
|
||||||
const result = closeSessionWorkspaceLayoutState(
|
|
||||||
[workspace(["s1", "s2", "s3"], { focusSessionOrder: ["s3", "s2", "s1"] })],
|
|
||||||
"ws-1",
|
|
||||||
"s1",
|
|
||||||
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.workspaces[0].focusedSessionId, "s3");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closing a non-focused member keeps the focused member", () => {
|
|
||||||
const result = closeSessionWorkspaceLayoutState(
|
|
||||||
[workspace(["s1", "s2", "s3"])],
|
|
||||||
"ws-1",
|
|
||||||
"s3",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.workspaces.length, 1);
|
|
||||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2"]);
|
assert.deepEqual(
|
||||||
|
result.workspaces[0].root.type === "split"
|
||||||
|
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||||
|
: [],
|
||||||
|
["s1", "s3"],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with no AI recency signal focus stays on a surviving terminal", () => {
|
test("dissolved workspace replacement preserves its tab position", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||||
|
["log-1", "s1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||||||
|
["s1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dissolved workspace replacement is idempotent", () => {
|
||||||
|
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||||||
|
once,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single remaining session preserves dissolved workspace tab position", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||||||
|
["log-1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||||||
|
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||||||
|
|
||||||
|
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||||
|
assert.equal(result.lastRemainingSessionId, "s2");
|
||||||
|
assert.deepEqual(result.workspaces, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
replaceDissolvedWorkspaceTabOrder(
|
||||||
|
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||||||
|
result.dissolvedWorkspaceId,
|
||||||
|
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||||||
|
),
|
||||||
|
["log-1", "s2", "session-3"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing the focused session hands focus to a remaining session (#3046)", () => {
|
||||||
const result = closeSessionWorkspaceLayoutState(
|
const result = closeSessionWorkspaceLayoutState(
|
||||||
[workspace(["s1", "s2"])],
|
[workspace(["s1", "s2", "s3", "s4"])],
|
||||||
"ws-1",
|
"ws-1",
|
||||||
"s1",
|
"s1",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.workspaces.length, 1);
|
assert.equal(result.workspaces.length, 1);
|
||||||
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
||||||
|
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closing ALL members keeps the workspace alive with an empty root (#4098 phase 2)", () => {
|
test("closing a non-focused session keeps the focused session", () => {
|
||||||
const result = closeSessionsState({
|
const result = closeSessionWorkspaceLayoutState(
|
||||||
sessions: [session("s1"), session("s2")],
|
[workspace(["s1", "s2", "s3", "s4"])],
|
||||||
workspaces: [workspace(["s1", "s2"])],
|
"ws-1",
|
||||||
sessionIds: ["s1", "s2"],
|
"s3",
|
||||||
currentActiveTabId: "ws-1",
|
|
||||||
tabOrder: ["ws-1"],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(result.sessions, []);
|
|
||||||
assert.equal(result.workspaces.length, 1);
|
|
||||||
assert.equal(result.workspaces[0].id, "ws-1");
|
|
||||||
assert.equal(result.workspaces[0].root.type, "split");
|
|
||||||
assert.deepEqual(
|
|
||||||
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
// The workspace tab survives, so the active tab is left alone.
|
|
||||||
assert.equal(result.activeTabId, undefined);
|
assert.equal(result.workspaces.length, 1);
|
||||||
|
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||||
|
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("closing a solo workspace member still replaces the closed tab's activity via fallbacks", () => {
|
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||||
|
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||||
|
[workspace(["s1", "s2"])],
|
||||||
|
"ws-1",
|
||||||
|
"s1",
|
||||||
|
);
|
||||||
|
const nextSessions = applyCloseSessionToSessions(
|
||||||
|
[session("s1"), session("s2")],
|
||||||
|
"s1",
|
||||||
|
layoutResult,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
|
||||||
|
["s2", undefined],
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
resolveActiveTabAfterCloseSession({
|
||||||
|
currentActiveTabId: "ws-1",
|
||||||
|
closedSessionId: "s1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
layoutResult,
|
||||||
|
remainingSessions: nextSessions,
|
||||||
|
preferredTabId: "s-outside",
|
||||||
|
}),
|
||||||
|
"s2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing the last workspace session does not invent a surviving terminal", () => {
|
||||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||||
[workspace(["s1"])],
|
[workspace(["s1"])],
|
||||||
"ws-1",
|
"ws-1",
|
||||||
"s1",
|
"s1",
|
||||||
);
|
);
|
||||||
const nextSessions = applyCloseSessionToSessions([session("s1")], "s1");
|
const nextSessions = applyCloseSessionToSessions(
|
||||||
|
[session("s1")],
|
||||||
|
"s1",
|
||||||
|
layoutResult,
|
||||||
|
);
|
||||||
|
|
||||||
assert.deepEqual(nextSessions, []);
|
assert.deepEqual(nextSessions, []);
|
||||||
// The workspace tab survives, so an active workspace tab needs no fallback.
|
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
|
||||||
|
assert.equal(layoutResult.lastRemainingSessionId, undefined);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveActiveTabAfterCloseSession({
|
resolveActiveTabAfterCloseSession({
|
||||||
currentActiveTabId: "ws-1",
|
currentActiveTabId: "ws-1",
|
||||||
@@ -193,7 +200,25 @@ test("closing a solo workspace member still replaces the closed tab's activity v
|
|||||||
layoutResult,
|
layoutResult,
|
||||||
remainingSessions: nextSessions,
|
remainingSessions: nextSessions,
|
||||||
}),
|
}),
|
||||||
null,
|
"vault",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing the last visible solo tab does not fall back to a hidden MCP session", () => {
|
||||||
|
const visibleSession: TerminalSession = { ...session("s1", undefined) };
|
||||||
|
const hiddenSession: TerminalSession = { ...session("s2", undefined), hiddenFromTabs: true };
|
||||||
|
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s1");
|
||||||
|
const remainingSessions = [hiddenSession];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveActiveTabAfterCloseSession({
|
||||||
|
currentActiveTabId: visibleSession.id,
|
||||||
|
closedSessionId: "s1",
|
||||||
|
workspaceId: undefined,
|
||||||
|
layoutResult,
|
||||||
|
remainingSessions,
|
||||||
|
}),
|
||||||
|
"vault",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||||
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
|
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||||
import { resolveMostRecentlyActiveMemberTerminalId } from "../../domain/aiSessionRecency";
|
|
||||||
import { collectSessionIds, createEmptyWorkspaceRoot, pruneWorkspaceNode } from "../../domain/workspace";
|
|
||||||
|
|
||||||
export type DetachSessionFromWorkspaceStateResult = {
|
export type DetachSessionFromWorkspaceStateResult = {
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
sessions: TerminalSession[];
|
sessions: TerminalSession[];
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
activeTabId?: string;
|
activeTabId?: string;
|
||||||
|
dissolvedWorkspaceId?: string;
|
||||||
|
replacementTabIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CloseSessionWorkspaceLayoutResult = {
|
export type CloseSessionWorkspaceLayoutResult = {
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
|
removedWorkspaceId?: string;
|
||||||
|
dissolvedWorkspaceId?: string;
|
||||||
|
lastRemainingSessionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CloseSessionsStateResult = {
|
export type CloseSessionsStateResult = {
|
||||||
@@ -25,65 +28,82 @@ type DetachSessionFromWorkspaceStateOptions = {
|
|||||||
sessions: TerminalSession[];
|
sessions: TerminalSession[];
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/**
|
|
||||||
* AI chat store snapshot used to rank surviving members by chat recency.
|
|
||||||
* Without a recency signal, terminal display still needs a live member.
|
|
||||||
*/
|
|
||||||
aiSessions?: readonly AIChatRecencyEntry[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export function replaceDissolvedWorkspaceTabOrder(
|
||||||
* Resolve the member that should receive focus (or an anchor retarget) after a
|
tabOrder: readonly string[],
|
||||||
* member loss. Prefers the AI-chat recency winner; falls back to the still-
|
workspaceId: string | undefined,
|
||||||
* valid preferred (focused) member because that is explicit user intent, and
|
replacementTabIds: readonly string[] | undefined,
|
||||||
* falls back to a surviving pane so focus mode never hides all terminals.
|
): string[] {
|
||||||
*/
|
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||||
const resolveSurvivingFocusMemberId = (
|
|
||||||
remainingSessionIds: readonly string[],
|
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||||
previousFocusedSessionId: string | undefined,
|
tabId && list.indexOf(tabId) === index
|
||||||
aiSessions: readonly AIChatRecencyEntry[] | undefined,
|
));
|
||||||
): string | undefined => {
|
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||||
if (previousFocusedSessionId && remainingSessionIds.includes(previousFocusedSessionId)) {
|
|
||||||
return previousFocusedSessionId;
|
if (!tabOrder.includes(workspaceId)) {
|
||||||
|
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||||
|
return hasAllReplacementIds ? [...tabOrder] : [
|
||||||
|
...tabOrder,
|
||||||
|
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return resolveMostRecentlyActiveMemberTerminalId({
|
|
||||||
memberTerminalIds: remainingSessionIds,
|
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||||
aiSessions,
|
let inserted = false;
|
||||||
}) ?? remainingSessionIds[0];
|
const nextOrder: string[] = [];
|
||||||
};
|
|
||||||
|
for (const tabId of tabOrder) {
|
||||||
|
if (tabId === workspaceId) {
|
||||||
|
if (!inserted) {
|
||||||
|
nextOrder.push(...uniqueReplacementIds);
|
||||||
|
inserted = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!replacementIdSet.has(tabId)) {
|
||||||
|
nextOrder.push(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextOrder;
|
||||||
|
}
|
||||||
|
|
||||||
export function closeSessionWorkspaceLayoutState(
|
export function closeSessionWorkspaceLayoutState(
|
||||||
workspaces: readonly Workspace[],
|
workspaces: readonly Workspace[],
|
||||||
workspaceId: string | undefined,
|
workspaceId: string | undefined,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
aiSessions?: readonly AIChatRecencyEntry[],
|
|
||||||
): CloseSessionWorkspaceLayoutResult {
|
): CloseSessionWorkspaceLayoutResult {
|
||||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||||
|
|
||||||
|
let removedWorkspaceId: string | undefined;
|
||||||
|
let dissolvedWorkspaceId: string | undefined;
|
||||||
|
let lastRemainingSessionId: string | undefined;
|
||||||
const nextWorkspaces = workspaces
|
const nextWorkspaces = workspaces
|
||||||
.map((workspace) => {
|
.map((workspace) => {
|
||||||
if (workspace.id !== workspaceId) return workspace;
|
if (workspace.id !== workspaceId) return workspace;
|
||||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||||
if (!prunedRoot) {
|
if (!prunedRoot) {
|
||||||
// Issue #4098 phase 2: the last member closed, but the workspace is a
|
removedWorkspaceId = workspace.id;
|
||||||
// first-class entity — it survives with an empty root so its id (and
|
return null;
|
||||||
// the AI chats anchored to it) survive restart and member churn.
|
|
||||||
return {
|
|
||||||
...workspace,
|
|
||||||
root: createEmptyWorkspaceRoot(),
|
|
||||||
focusedSessionId: undefined,
|
|
||||||
focusSessionOrder: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||||
// Closing the focused session hands focus to the most-recently-active
|
if (remainingSessionIds.length === 1) {
|
||||||
// surviving member when AI chat recency is available.
|
dissolvedWorkspaceId = workspace.id;
|
||||||
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
|
lastRemainingSessionId = remainingSessionIds[0];
|
||||||
remainingSessionIds,
|
return null;
|
||||||
workspace.focusedSessionId,
|
}
|
||||||
aiSessions,
|
|
||||||
);
|
// Closing the focused session must hand focus to a remaining one, or a
|
||||||
|
// focus-mode workspace is left with a dangling focusedSessionId: no pane
|
||||||
|
// qualifies as visible and the continuously rendered hidden panes all
|
||||||
|
// paint on top of each other (#3046).
|
||||||
|
const nextFocusedSessionId =
|
||||||
|
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
|
||||||
|
? remainingSessionIds[0]
|
||||||
|
: workspace.focusedSessionId;
|
||||||
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
|
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
|
||||||
(candidateId) => remainingSessionIds.includes(candidateId),
|
(candidateId) => remainingSessionIds.includes(candidateId),
|
||||||
);
|
);
|
||||||
@@ -94,23 +114,36 @@ export function closeSessionWorkspaceLayoutState(
|
|||||||
focusedSessionId: nextFocusedSessionId,
|
focusedSessionId: nextFocusedSessionId,
|
||||||
focusSessionOrder: nextFocusSessionOrder,
|
focusSessionOrder: nextFocusSessionOrder,
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaces: nextWorkspaces,
|
workspaces: nextWorkspaces,
|
||||||
|
removedWorkspaceId,
|
||||||
|
dissolvedWorkspaceId,
|
||||||
|
lastRemainingSessionId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a close-session action to the session list using the layout result.
|
* Apply a close-session action to the session list using the layout result.
|
||||||
* Since issue #4098 phase 2 workspaces survive member loss (no dissolve), the
|
* When a 2-pane workspace dissolves to one terminal, that remaining session
|
||||||
* remaining members keep their workspace binding.
|
* must become an orphan tab — not stay bound to a deleted workspaceId.
|
||||||
*/
|
*/
|
||||||
export function applyCloseSessionToSessions(
|
export function applyCloseSessionToSessions(
|
||||||
sessions: readonly TerminalSession[],
|
sessions: readonly TerminalSession[],
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
|
||||||
): TerminalSession[] {
|
): TerminalSession[] {
|
||||||
return sessions.filter((session) => session.id !== sessionId);
|
const remaining = sessions.filter((session) => session.id !== sessionId);
|
||||||
|
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
|
||||||
|
if (!lastRemainingSessionId) return remaining;
|
||||||
|
|
||||||
|
return remaining.map((session) => (
|
||||||
|
session.id === lastRemainingSessionId
|
||||||
|
? { ...session, workspaceId: undefined }
|
||||||
|
: session
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPreferredTabAvailableAfterClose(
|
export function isPreferredTabAvailableAfterClose(
|
||||||
@@ -128,6 +161,12 @@ export function isPreferredTabAvailableAfterClose(
|
|||||||
} {
|
} {
|
||||||
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
|
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
|
||||||
if (!preferredTabId || preferredTabId === closedSessionId) return false;
|
if (!preferredTabId || preferredTabId === closedSessionId) return false;
|
||||||
|
if (
|
||||||
|
preferredTabId === layoutResult.dissolvedWorkspaceId
|
||||||
|
|| preferredTabId === layoutResult.removedWorkspaceId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
|
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
|
||||||
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
|
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
|
||||||
return remainingSessions.some((session) => (
|
return remainingSessions.some((session) => (
|
||||||
@@ -140,6 +179,7 @@ export function isPreferredTabAvailableAfterClose(
|
|||||||
export function resolveActiveTabAfterCloseSession({
|
export function resolveActiveTabAfterCloseSession({
|
||||||
currentActiveTabId,
|
currentActiveTabId,
|
||||||
closedSessionId,
|
closedSessionId,
|
||||||
|
workspaceId,
|
||||||
layoutResult,
|
layoutResult,
|
||||||
remainingSessions,
|
remainingSessions,
|
||||||
preferredTabId,
|
preferredTabId,
|
||||||
@@ -162,14 +202,18 @@ export function resolveActiveTabAfterCloseSession({
|
|||||||
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
|
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
|
||||||
? preferredInput.preferredTabId
|
? preferredInput.preferredTabId
|
||||||
: undefined;
|
: undefined;
|
||||||
const fallback = preferredFallback
|
const fallback = layoutResult.lastRemainingSessionId
|
||||||
|
?? preferredFallback
|
||||||
?? fallbackWorkspace?.id
|
?? fallbackWorkspace?.id
|
||||||
?? fallbackSolo?.id
|
?? fallbackSolo?.id
|
||||||
?? "vault";
|
?? "vault";
|
||||||
|
|
||||||
// Workspaces survive member loss (#4098 phase 2), so only a closed session
|
if (
|
||||||
// tab that was active needs a replacement.
|
currentActiveTabId === closedSessionId
|
||||||
if (currentActiveTabId === closedSessionId) {
|
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|
||||||
|
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|
||||||
|
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
|
||||||
|
) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +227,6 @@ export function closeSessionsState({
|
|||||||
currentActiveTabId,
|
currentActiveTabId,
|
||||||
preferredTabId,
|
preferredTabId,
|
||||||
tabOrder,
|
tabOrder,
|
||||||
aiSessions,
|
|
||||||
}: {
|
}: {
|
||||||
sessions: readonly TerminalSession[];
|
sessions: readonly TerminalSession[];
|
||||||
workspaces: readonly Workspace[];
|
workspaces: readonly Workspace[];
|
||||||
@@ -191,7 +234,6 @@ export function closeSessionsState({
|
|||||||
currentActiveTabId: string | null;
|
currentActiveTabId: string | null;
|
||||||
preferredTabId?: string | null;
|
preferredTabId?: string | null;
|
||||||
tabOrder: readonly string[];
|
tabOrder: readonly string[];
|
||||||
aiSessions?: readonly AIChatRecencyEntry[];
|
|
||||||
}): CloseSessionsStateResult {
|
}): CloseSessionsStateResult {
|
||||||
let nextSessions = [...sessions];
|
let nextSessions = [...sessions];
|
||||||
let nextWorkspaces = [...workspaces];
|
let nextWorkspaces = [...workspaces];
|
||||||
@@ -208,10 +250,17 @@ export function closeSessionsState({
|
|||||||
nextWorkspaces,
|
nextWorkspaces,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
sessionId,
|
sessionId,
|
||||||
aiSessions,
|
|
||||||
);
|
);
|
||||||
nextWorkspaces = layoutResult.workspaces;
|
nextWorkspaces = layoutResult.workspaces;
|
||||||
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId);
|
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
|
||||||
|
|
||||||
|
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
|
||||||
|
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
|
||||||
|
nextTabOrder,
|
||||||
|
layoutResult.dissolvedWorkspaceId,
|
||||||
|
[layoutResult.lastRemainingSessionId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
|
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
|
||||||
currentActiveTabId: nextActiveTabId,
|
currentActiveTabId: nextActiveTabId,
|
||||||
@@ -239,7 +288,6 @@ export function detachSessionFromWorkspaceState({
|
|||||||
sessions,
|
sessions,
|
||||||
workspaces,
|
workspaces,
|
||||||
sessionId,
|
sessionId,
|
||||||
aiSessions,
|
|
||||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||||
if (!session?.workspaceId) {
|
if (!session?.workspaceId) {
|
||||||
@@ -253,36 +301,40 @@ export function detachSessionFromWorkspaceState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||||
const nextSessions = sessions.map((candidate) => (
|
let nextSessions = sessions.map((candidate) => (
|
||||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||||
));
|
));
|
||||||
|
|
||||||
if (!prunedRoot) {
|
if (!prunedRoot) {
|
||||||
// Last member dragged out: the workspace entity survives with an empty
|
|
||||||
// root (#4098 phase 2) so its id and anchored AI chats stay stable.
|
|
||||||
return {
|
return {
|
||||||
changed: true,
|
changed: true,
|
||||||
sessions: nextSessions,
|
sessions: nextSessions,
|
||||||
workspaces: workspaces.map((workspace) => (
|
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||||
workspace.id === workspaceId
|
|
||||||
? {
|
|
||||||
...workspace,
|
|
||||||
root: createEmptyWorkspaceRoot(),
|
|
||||||
focusedSessionId: undefined,
|
|
||||||
focusSessionOrder: [],
|
|
||||||
}
|
|
||||||
: workspace
|
|
||||||
)),
|
|
||||||
activeTabId: sessionId,
|
activeTabId: sessionId,
|
||||||
|
dissolvedWorkspaceId: workspaceId,
|
||||||
|
replacementTabIds: [sessionId],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||||
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
|
if (remainingSessionIds.length === 1) {
|
||||||
remainingSessionIds,
|
nextSessions = nextSessions.map((candidate) => (
|
||||||
targetWorkspace.focusedSessionId,
|
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||||
aiSessions,
|
));
|
||||||
);
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
sessions: nextSessions,
|
||||||
|
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||||
|
activeTabId: sessionId,
|
||||||
|
dissolvedWorkspaceId: workspaceId,
|
||||||
|
replacementTabIds: [sessionId, ...remainingSessionIds],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
|
||||||
|
? targetWorkspace.focusedSessionId
|
||||||
|
: remainingSessionIds[0];
|
||||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||||
.filter((candidateId, index, list) => (
|
.filter((candidateId, index, list) => (
|
||||||
candidateId !== sessionId &&
|
candidateId !== sessionId &&
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import type { SftpPane } from "./types";
|
|
||||||
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
|
|
||||||
import { createSftpArchiveForPane } from "./createSftpArchive.ts";
|
|
||||||
|
|
||||||
function setup() {
|
|
||||||
let pane = {
|
|
||||||
id: "pane-a", connection: { id: "connection-a", isLocal: false }, filenameEncoding: "gb18030",
|
|
||||||
} as SftpPane;
|
|
||||||
const calls: unknown[][] = [];
|
|
||||||
const refreshes: string[] = [];
|
|
||||||
const params = {
|
|
||||||
getPane: () => pane,
|
|
||||||
target: { paneId: "pane-a", connectionId: "connection-a" },
|
|
||||||
options: { directory: "/srv", paths: ["中文", "logs"], archiveName: "backup.zip", format: "zip" as const },
|
|
||||||
getSftpIdForConnection: (id: string) => id === "connection-a" ? "sftp-a" : undefined,
|
|
||||||
bridge: {
|
|
||||||
createSftpArchive: async (...args: unknown[]): Promise<SftpArchiveCreationResult> => {
|
|
||||||
calls.push(args);
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
refresh: async (id: string) => { refreshes.push(id); },
|
|
||||||
t: (key: string) => key,
|
|
||||||
};
|
|
||||||
return { params, calls, refreshes, setPane: (value: SftpPane) => { pane = value; } };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("archive creation passes selected paths and encoding to the captured remote session", async () => {
|
|
||||||
const { params, calls, refreshes } = setup();
|
|
||||||
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
|
|
||||||
assert.deepEqual(calls, [["sftp-a", params.options, "gb18030"]]);
|
|
||||||
assert.deepEqual(refreshes, ["pane-a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a switched or disconnected pane cannot compress files on another host", async () => {
|
|
||||||
for (const change of [
|
|
||||||
{ id: "pane-b" },
|
|
||||||
{ connection: { id: "connection-b", isLocal: false } },
|
|
||||||
{ connection: null },
|
|
||||||
]) {
|
|
||||||
const { params, calls, setPane } = setup();
|
|
||||||
setPane({ ...params.getPane(), ...change } as SftpPane);
|
|
||||||
await assert.rejects(createSftpArchiveForPane(params), /connectionChanged/);
|
|
||||||
assert.equal(calls.length, 0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("archive creation requires a remote session and a supported bridge", async () => {
|
|
||||||
const { params, calls, setPane } = setup();
|
|
||||||
await assert.rejects(createSftpArchiveForPane({ ...params, bridge: undefined }), /unavailable/);
|
|
||||||
await assert.rejects(createSftpArchiveForPane({ ...params, getSftpIdForConnection: () => undefined }), /unavailable/);
|
|
||||||
setPane({ ...params.getPane(), connection: { id: "connection-a", isLocal: true } } as SftpPane);
|
|
||||||
await assert.rejects(createSftpArchiveForPane(params), /unavailable/);
|
|
||||||
assert.equal(calls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("existing archives are returned for confirmation without retry or refresh", async () => {
|
|
||||||
const { params, refreshes } = setup();
|
|
||||||
const conflict = { success: false, code: "EEXIST", error: "Already exists" };
|
|
||||||
params.bridge.createSftpArchive = async () => conflict;
|
|
||||||
assert.equal(await createSftpArchiveForPane(params), conflict);
|
|
||||||
assert.deepEqual(refreshes, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("finishing after a tab switch does not refresh the new connection", async () => {
|
|
||||||
const { params, refreshes, setPane } = setup();
|
|
||||||
params.bridge.createSftpArchive = async () => {
|
|
||||||
setPane({ ...params.getPane(), id: "pane-b" } as SftpPane);
|
|
||||||
return { success: true };
|
|
||||||
};
|
|
||||||
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
|
|
||||||
assert.deepEqual(refreshes, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failed operation is not refreshed or converted to success", async () => {
|
|
||||||
const { params, refreshes } = setup();
|
|
||||||
params.bridge.createSftpArchive = async () => { throw new Error("zip is not installed"); };
|
|
||||||
await assert.rejects(createSftpArchiveForPane(params), /zip is not installed/);
|
|
||||||
assert.deepEqual(refreshes, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a listing error after publication does not invite a second archive creation", async () => {
|
|
||||||
const { params, calls } = setup();
|
|
||||||
params.refresh = async () => { throw new Error("Disconnected after publication"); };
|
|
||||||
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type {
|
|
||||||
SftpArchiveCreationOptions,
|
|
||||||
SftpArchiveCreationResult,
|
|
||||||
SftpArchiveCreationTarget,
|
|
||||||
} from "../../../domain/sftpArchiveCreation";
|
|
||||||
import type { SftpPane } from "./types";
|
|
||||||
import { logger } from "../../../lib/logger";
|
|
||||||
|
|
||||||
interface CreateSftpArchiveParams {
|
|
||||||
getPane: () => Pick<SftpPane, "id" | "connection" | "filenameEncoding">;
|
|
||||||
target: SftpArchiveCreationTarget;
|
|
||||||
options: SftpArchiveCreationOptions;
|
|
||||||
bridge: Pick<NetcattyBridge, "createSftpArchive"> | undefined;
|
|
||||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
|
||||||
refresh: (paneId: string) => Promise<void>;
|
|
||||||
t: (key: string) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bind the selected files to the pane/connection that opened the dialog. */
|
|
||||||
export async function createSftpArchiveForPane({
|
|
||||||
getPane, target, options, bridge, getSftpIdForConnection, refresh, t,
|
|
||||||
}: CreateSftpArchiveParams): Promise<SftpArchiveCreationResult> {
|
|
||||||
const pane = getPane();
|
|
||||||
if (pane.id !== target.paneId || pane.connection?.id !== target.connectionId) {
|
|
||||||
throw new Error(t("sftp.archive.connectionChanged"));
|
|
||||||
}
|
|
||||||
const sftpId = getSftpIdForConnection?.(target.connectionId);
|
|
||||||
if (pane.connection.isLocal || !sftpId || !bridge?.createSftpArchive) {
|
|
||||||
throw new Error(t("sftp.archive.unavailable"));
|
|
||||||
}
|
|
||||||
const result = await bridge.createSftpArchive(sftpId, options, pane.filenameEncoding);
|
|
||||||
if (!result.success) return result;
|
|
||||||
|
|
||||||
// A tab switch during compression must not refresh an unrelated connection.
|
|
||||||
const current = getPane();
|
|
||||||
if (current.id === target.paneId && current.connection?.id === target.connectionId) {
|
|
||||||
try {
|
|
||||||
await refresh(target.paneId);
|
|
||||||
} catch (error) {
|
|
||||||
// The archive has already been published. A listing failure must not
|
|
||||||
// invite the user to repeat a successful operation (or overwrite it).
|
|
||||||
logger.warn("[SFTP] Archive created but directory refresh failed:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
/** @test issue #4132 — a one-click duplicate copies next to the source without a conflict prompt */
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import React from "react";
|
|
||||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
|
||||||
import { useSftpTransfers } from "./useSftpTransfers";
|
|
||||||
import { createEmptyPane } from "./types";
|
|
||||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
|
||||||
|
|
||||||
test("duplicate transfers resolve same-name conflicts to (copy) without prompting", async () => {
|
|
||||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-entries-"));
|
|
||||||
const ownerId = `duplicate-entries-${crypto.randomUUID()}`;
|
|
||||||
const previousWindow = globalThis.window;
|
|
||||||
const previousStorage = globalThis.localStorage;
|
|
||||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
|
||||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
|
||||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
let renderer: ReactTestRenderer | undefined;
|
|
||||||
let ops!: ReturnType<typeof useSftpTransfers>;
|
|
||||||
// A duplicate has to cover both files and folders: the conflict type key
|
|
||||||
// differs between the two and the seeded defaults must match both.
|
|
||||||
const fileNames = ["report.txt"];
|
|
||||||
const folderNames = ["photos"];
|
|
||||||
for (const name of fileNames) {
|
|
||||||
await fs.writeFile(path.join(dir, name), "original data");
|
|
||||||
}
|
|
||||||
for (const name of folderNames) {
|
|
||||||
await fs.mkdir(path.join(dir, name));
|
|
||||||
await fs.writeFile(path.join(dir, name, "nested.txt"), "nested data");
|
|
||||||
}
|
|
||||||
const pane = createEmptyPane("duplicate-pane");
|
|
||||||
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
|
|
||||||
const statPath = async (value: string) => {
|
|
||||||
try {
|
|
||||||
const stat = await fs.lstat(value);
|
|
||||||
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
|
|
||||||
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
|
|
||||||
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
|
|
||||||
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
|
|
||||||
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
|
|
||||||
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
|
|
||||||
await fs.copyFile(options.sourcePath, options.targetPath);
|
|
||||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
} };
|
|
||||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
|
|
||||||
function Probe() {
|
|
||||||
ops = useSftpTransfers({
|
|
||||||
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
|
|
||||||
updateTab: () => undefined, refresh: async () => undefined,
|
|
||||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
|
||||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
|
||||||
listRemoteFiles: async () => [],
|
|
||||||
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
|
|
||||||
const stat = await fs.lstat(path.join(value, name));
|
|
||||||
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const waitForSettledRoots = async (expected: number) => {
|
|
||||||
await act(async () => {
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
|
||||||
if (roots.length === expected && roots.every(row => ["completed", "failed"].includes(row.status))) return;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
|
||||||
const sources = [...fileNames, ...folderNames].map(name => ({ name, isDirectory: folderNames.includes(name) }));
|
|
||||||
await act(async () => {
|
|
||||||
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
|
|
||||||
});
|
|
||||||
// The duplicate option must resolve conflicts by itself; a prompt here
|
|
||||||
// would defeat the one-click action the issue asks for.
|
|
||||||
assert.deepEqual(ops.conflicts, []);
|
|
||||||
await waitForSettledRoots(sources.length);
|
|
||||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
|
||||||
assert.equal(roots.length, sources.length, JSON.stringify(roots.map(row => row.fileName)));
|
|
||||||
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
|
|
||||||
for (const name of fileNames) {
|
|
||||||
assert.equal(await fs.readFile(path.join(dir, name), "utf8"), "original data");
|
|
||||||
}
|
|
||||||
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
|
|
||||||
assert.equal(await fs.readFile(path.join(dir, "photos (copy)", "nested.txt"), "utf8"), "nested data");
|
|
||||||
|
|
||||||
// A second duplicate must pick the next free name instead of colliding
|
|
||||||
// with the copy created by the first run.
|
|
||||||
await act(async () => {
|
|
||||||
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
|
|
||||||
});
|
|
||||||
assert.deepEqual(ops.conflicts, []);
|
|
||||||
await waitForSettledRoots(sources.length * 2);
|
|
||||||
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
|
|
||||||
assert.equal(await fs.readFile(path.join(dir, "photos (copy 2)", "nested.txt"), "utf8"), "nested data");
|
|
||||||
|
|
||||||
// The tree's ".." navigation row must never be duplicated: it would copy
|
|
||||||
// an ancestor into itself.
|
|
||||||
const tasksBefore = sftpTransferCenterStore.getOwnerTasks(ownerId).length;
|
|
||||||
let navigationResults: unknown;
|
|
||||||
await act(async () => {
|
|
||||||
navigationResults = await ops.startTransfer(
|
|
||||||
[{ name: "..", isDirectory: true }],
|
|
||||||
"left",
|
|
||||||
"left",
|
|
||||||
{ sourcePath: dir, targetPath: dir, duplicate: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
assert.deepEqual(navigationResults, []);
|
|
||||||
assert.equal(sftpTransferCenterStore.getOwnerTasks(ownerId).length, tasksBefore);
|
|
||||||
assert.deepEqual(ops.conflicts, []);
|
|
||||||
} finally {
|
|
||||||
await act(async () => { renderer?.unmount(); });
|
|
||||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
|
||||||
(globalThis as { window?: unknown }).window = previousWindow;
|
|
||||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
|
||||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/** @test issue #4132 — a duplicate must not reuse a destination an earlier copy still holds */
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import React from "react";
|
|
||||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
|
||||||
import { useSftpTransfers } from "./useSftpTransfers";
|
|
||||||
import { createEmptyPane } from "./types";
|
|
||||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
|
||||||
|
|
||||||
test("duplicate names around a destination an in-flight copy has not created yet", async () => {
|
|
||||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-inflight-"));
|
|
||||||
const ownerId = `duplicate-inflight-${crypto.randomUUID()}`;
|
|
||||||
const previousWindow = globalThis.window;
|
|
||||||
const previousStorage = globalThis.localStorage;
|
|
||||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
|
||||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
|
||||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
let renderer: ReactTestRenderer | undefined;
|
|
||||||
let ops!: ReturnType<typeof useSftpTransfers>;
|
|
||||||
let releaseFirst: (() => void) | undefined;
|
|
||||||
const firstCopyGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
|
|
||||||
let streamStarts = 0;
|
|
||||||
await fs.writeFile(path.join(dir, "report.txt"), "original data");
|
|
||||||
const pane = createEmptyPane("duplicate-inflight-pane");
|
|
||||||
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
|
|
||||||
const statPath = async (value: string) => {
|
|
||||||
try {
|
|
||||||
const stat = await fs.lstat(value);
|
|
||||||
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
|
|
||||||
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
|
|
||||||
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
|
|
||||||
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
|
|
||||||
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
|
|
||||||
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
|
|
||||||
streamStarts += 1;
|
|
||||||
// Hold the first copy before it writes its destination: the second
|
|
||||||
// duplicate then sees no "(copy)" on disk even though one is reserved.
|
|
||||||
if (streamStarts === 1) await firstCopyGate;
|
|
||||||
await fs.copyFile(options.sourcePath, options.targetPath);
|
|
||||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
} };
|
|
||||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
|
|
||||||
function Probe() {
|
|
||||||
ops = useSftpTransfers({
|
|
||||||
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
|
|
||||||
updateTab: () => undefined, refresh: async () => undefined,
|
|
||||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
|
||||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
|
||||||
listRemoteFiles: async () => [],
|
|
||||||
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
|
|
||||||
const stat = await fs.lstat(path.join(value, name));
|
|
||||||
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const sources = [{ name: "report.txt", isDirectory: false }];
|
|
||||||
const options = { sourcePath: dir, targetPath: dir, duplicate: true };
|
|
||||||
try {
|
|
||||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
|
||||||
// First duplicate starts copying and blocks before writing "(copy)".
|
|
||||||
let firstRun: ReturnType<typeof ops.startTransfer> | undefined;
|
|
||||||
await act(async () => {
|
|
||||||
firstRun = ops.startTransfer(sources, "left", "left", options);
|
|
||||||
const deadline = Date.now() + 3000;
|
|
||||||
while (streamStarts === 0 && Date.now() < deadline) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
assert.equal(streamStarts, 1, "the first copy should be in flight");
|
|
||||||
|
|
||||||
// Second duplicate runs while "(copy)" is still absent from the disk.
|
|
||||||
await act(async () => { await ops.startTransfer(sources, "left", "left", options); });
|
|
||||||
assert.equal(ops.conflicts.length, 0, "duplicate must not prompt for a reserved name");
|
|
||||||
releaseFirst?.();
|
|
||||||
await act(async () => { await firstRun; });
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
|
||||||
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) return;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 20));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
|
||||||
assert.deepEqual(roots.map(row => row.fileName).sort(), ["report (copy 2).txt", "report (copy).txt"]);
|
|
||||||
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
|
|
||||||
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
|
|
||||||
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
|
|
||||||
} finally {
|
|
||||||
releaseFirst?.();
|
|
||||||
await act(async () => { renderer?.unmount(); });
|
|
||||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
|
||||||
(globalThis as { window?: unknown }).window = previousWindow;
|
|
||||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
|
||||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -61,11 +61,10 @@ export function useSftpTransferConflictOps() {
|
|||||||
|
|
||||||
const getDuplicateTarget = useCallback(
|
const getDuplicateTarget = useCallback(
|
||||||
async (
|
async (
|
||||||
task: Pick<TransferTask, "fileName" | "isDirectory" | "targetPath">,
|
task: TransferTask,
|
||||||
targetPane: SftpPane,
|
targetPane: SftpPane,
|
||||||
targetSftpId: string | null,
|
targetSftpId: string | null,
|
||||||
targetEncoding: SftpFilenameEncoding,
|
targetEncoding: SftpFilenameEncoding,
|
||||||
isTargetTaken?: (targetPath: string) => boolean,
|
|
||||||
) => {
|
) => {
|
||||||
const parentPath = getParentPath(task.targetPath);
|
const parentPath = getParentPath(task.targetPath);
|
||||||
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
|
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
|
||||||
@@ -74,9 +73,6 @@ export function useSftpTransferConflictOps() {
|
|||||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||||
const fileName = `${baseName}${suffix}${ext}`;
|
const fileName = `${baseName}${suffix}${ext}`;
|
||||||
const targetPath = joinPath(parentPath, fileName);
|
const targetPath = joinPath(parentPath, fileName);
|
||||||
// A queued/paused duplicate may not have created its destination yet,
|
|
||||||
// so the filesystem alone would hand out the same name twice.
|
|
||||||
if (isTargetTaken?.(targetPath)) continue;
|
|
||||||
// Unsupported LSTAT must propagate — do not treat as a free name.
|
// Unsupported LSTAT must propagate — do not treat as a free name.
|
||||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||||
if (!existing) return { fileName, targetPath };
|
if (!existing) return { fileName, targetPath };
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test, { after } from "node:test";
|
|
||||||
import React from "react";
|
|
||||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
|
||||||
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
|
|
||||||
import { useSftpArchiveDialog } from "./useSftpArchiveDialog";
|
|
||||||
|
|
||||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
|
||||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
|
||||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
|
||||||
|
|
||||||
type Params = Parameters<typeof useSftpArchiveDialog>[0];
|
|
||||||
|
|
||||||
async function mount(overrides: Partial<Params> = {}) {
|
|
||||||
let params: Params = {
|
|
||||||
paneId: "pane-1", connectionId: "conn-1", isLocal: false, isActive: true,
|
|
||||||
t: (key) => key, onCreateArchive: async () => ({ success: true }), onCreated: () => {},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
let current!: ReturnType<typeof useSftpArchiveDialog>;
|
|
||||||
function Probe() {
|
|
||||||
current = useSftpArchiveDialog(params);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let renderer!: ReactTestRenderer;
|
|
||||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
|
||||||
return {
|
|
||||||
get value() { return current; },
|
|
||||||
async update(changes: Partial<Params>) {
|
|
||||||
params = { ...params, ...changes };
|
|
||||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
|
||||||
},
|
|
||||||
async unmount() { await act(async () => { renderer.unmount(); }); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("archive dialog snapshots nested selections, format, and connection identity", async (t) => {
|
|
||||||
const requests: unknown[] = [];
|
|
||||||
const reloaded: string[] = [];
|
|
||||||
const harness = await mount({
|
|
||||||
onCreateArchive: async (options, target) => { requests.push({ options, target }); return { success: true }; },
|
|
||||||
onCreated: (directory) => { reloaded.push(directory); },
|
|
||||||
});
|
|
||||||
t.after(() => harness.unmount());
|
|
||||||
await act(async () => { harness.value.open(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/site/config/app.json"]); });
|
|
||||||
assert.deepEqual(harness.value.state?.paths, ["assets", "config/app.json"]);
|
|
||||||
assert.equal(harness.value.state?.directory, "/srv/site");
|
|
||||||
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); harness.value.setFormat("zip"); });
|
|
||||||
assert.equal(harness.value.state?.archiveName, "backup.zip");
|
|
||||||
await act(async () => { await harness.value.submit(); });
|
|
||||||
assert.deepEqual(requests, [{
|
|
||||||
options: { directory: "/srv/site", paths: ["assets", "config/app.json"], archiveName: "backup.zip", format: "zip", overwrite: false },
|
|
||||||
target: { paneId: "pane-1", connectionId: "conn-1" },
|
|
||||||
}]);
|
|
||||||
assert.deepEqual(reloaded, ["/srv/site"]);
|
|
||||||
assert.equal(harness.value.state, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("archive dialog requires a separate overwrite decision and revokes it on edits", async (t) => {
|
|
||||||
const overwriteFlags: Array<boolean | undefined> = [];
|
|
||||||
const harness = await mount({ onCreateArchive: async (options) => {
|
|
||||||
overwriteFlags.push(options.overwrite);
|
|
||||||
return options.overwrite ? { success: true } : { success: false, code: "EEXIST" };
|
|
||||||
} });
|
|
||||||
t.after(() => harness.unmount());
|
|
||||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
|
||||||
await act(async () => { await harness.value.submit(true); });
|
|
||||||
assert.deepEqual(overwriteFlags, [], "overwrite is unavailable before a collision");
|
|
||||||
await act(async () => { await harness.value.submit(); });
|
|
||||||
assert.equal(harness.value.state?.confirmOverwrite, true);
|
|
||||||
await act(async () => { await harness.value.submit(); });
|
|
||||||
assert.deepEqual(overwriteFlags, [false], "Enter must not confirm overwrite");
|
|
||||||
await act(async () => { harness.value.setArchiveName("other.tar.gz"); });
|
|
||||||
assert.equal(harness.value.state?.confirmOverwrite, false);
|
|
||||||
await act(async () => { await harness.value.submit(true); });
|
|
||||||
assert.deepEqual(overwriteFlags, [false]);
|
|
||||||
await act(async () => { await harness.value.submit(); });
|
|
||||||
await act(async () => { await harness.value.submit(true); });
|
|
||||||
assert.deepEqual(overwriteFlags, [false, false, true]);
|
|
||||||
assert.equal(harness.value.state, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pending archive creation blocks duplicate submits, edits, and dismissal", async (t) => {
|
|
||||||
let finish!: (result: SftpArchiveCreationResult) => void;
|
|
||||||
let requests = 0;
|
|
||||||
const harness = await mount({ onCreateArchive: async () => {
|
|
||||||
requests += 1;
|
|
||||||
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
|
|
||||||
} });
|
|
||||||
t.after(() => harness.unmount());
|
|
||||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
|
||||||
let pending!: Promise<void>;
|
|
||||||
await act(async () => {
|
|
||||||
pending = harness.value.submit();
|
|
||||||
void harness.value.submit();
|
|
||||||
harness.value.close();
|
|
||||||
harness.value.setArchiveName("changed.tar.gz");
|
|
||||||
harness.value.open(["/different"]);
|
|
||||||
});
|
|
||||||
assert.equal(requests, 1);
|
|
||||||
assert.equal(harness.value.state?.busy, true);
|
|
||||||
assert.equal(harness.value.state?.archiveName, "site.tar.gz");
|
|
||||||
await act(async () => { finish({ success: true }); await pending; });
|
|
||||||
assert.equal(harness.value.state, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching connections dismisses the old dialog and ignores its late result", async (t) => {
|
|
||||||
let finish!: (result: SftpArchiveCreationResult) => void;
|
|
||||||
const reloaded: string[] = [];
|
|
||||||
let requests = 0;
|
|
||||||
const harness = await mount({
|
|
||||||
onCreateArchive: async () => {
|
|
||||||
requests += 1;
|
|
||||||
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
|
|
||||||
},
|
|
||||||
onCreated: (directory) => { reloaded.push(directory); },
|
|
||||||
});
|
|
||||||
t.after(() => harness.unmount());
|
|
||||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
|
||||||
let pending!: Promise<void>;
|
|
||||||
await act(async () => { pending = harness.value.submit(); });
|
|
||||||
await harness.update({ connectionId: "conn-2" });
|
|
||||||
assert.equal(harness.value.state, null);
|
|
||||||
await act(async () => { harness.value.open(["/srv/new-site"]); });
|
|
||||||
await act(async () => { finish({ success: false, code: "EEXIST" }); await pending; });
|
|
||||||
assert.equal(requests, 1);
|
|
||||||
assert.equal(harness.value.state?.archiveName, "new-site.tar.gz");
|
|
||||||
assert.equal(harness.value.state?.confirmOverwrite, false);
|
|
||||||
assert.deepEqual(reloaded, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tab switches invalidate the old form even when its connection stays mounted", async (t) => {
|
|
||||||
let requests = 0;
|
|
||||||
const harness = await mount({ onCreateArchive: async () => { requests += 1; return { success: true }; } });
|
|
||||||
t.after(() => harness.unmount());
|
|
||||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
|
||||||
const staleSubmit = harness.value.submit;
|
|
||||||
await harness.update({ isActive: false });
|
|
||||||
await act(async () => { await staleSubmit(); });
|
|
||||||
assert.equal(harness.value.state, null);
|
|
||||||
assert.equal(requests, 0);
|
|
||||||
await harness.update({ isActive: true, isLocal: true });
|
|
||||||
await act(async () => { harness.value.open(["/srv/site"]); });
|
|
||||||
assert.equal(harness.value.state, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("invalid names do not invoke the backend; failures keep a retryable form", async (t) => {
|
|
||||||
let requests = 0;
|
|
||||||
const harness = await mount({ onCreateArchive: async () => { requests += 1; throw new Error("zip is not installed"); } });
|
|
||||||
t.after(() => harness.unmount());
|
|
||||||
await act(async () => { harness.value.open(["/srv/site"]); harness.value.setArchiveName("../backup.tar.gz"); });
|
|
||||||
await act(async () => { await harness.value.submit(); });
|
|
||||||
assert.equal(requests, 0);
|
|
||||||
assert.equal(harness.value.state?.error, "sftp.archive.invalidName");
|
|
||||||
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); });
|
|
||||||
await act(async () => { await harness.value.submit(); });
|
|
||||||
assert.equal(requests, 1);
|
|
||||||
assert.equal(harness.value.state?.busy, false);
|
|
||||||
assert.equal(harness.value.state?.error, "zip is not installed");
|
|
||||||
});
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
prepareSftpArchiveSelection,
|
|
||||||
validateSftpArchiveName,
|
|
||||||
withSftpArchiveExtension,
|
|
||||||
type SftpArchiveCreationOptions,
|
|
||||||
type SftpArchiveCreationResult,
|
|
||||||
type SftpArchiveCreationTarget,
|
|
||||||
type SftpArchiveFormat,
|
|
||||||
} from "../../../domain/sftpArchiveCreation";
|
|
||||||
|
|
||||||
export type SftpArchiveTarget = SftpArchiveCreationTarget;
|
|
||||||
|
|
||||||
export interface SftpArchiveDialogState {
|
|
||||||
target: SftpArchiveTarget;
|
|
||||||
directory: string;
|
|
||||||
paths: string[];
|
|
||||||
archiveName: string;
|
|
||||||
format: SftpArchiveFormat;
|
|
||||||
busy: boolean;
|
|
||||||
error: string | null;
|
|
||||||
confirmOverwrite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseSftpArchiveDialogParams {
|
|
||||||
paneId: string;
|
|
||||||
connectionId?: string;
|
|
||||||
isLocal: boolean;
|
|
||||||
isActive: boolean;
|
|
||||||
t: (key: string, params?: Record<string, unknown>) => string;
|
|
||||||
onCreateArchive?: (
|
|
||||||
options: SftpArchiveCreationOptions,
|
|
||||||
target: SftpArchiveTarget,
|
|
||||||
) => Promise<SftpArchiveCreationResult>;
|
|
||||||
onCreated: (directory: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSftpArchiveDialog(params: UseSftpArchiveDialogParams) {
|
|
||||||
const [state, setState] = useState<SftpArchiveDialogState | null>(null);
|
|
||||||
const stateRef = useRef(state);
|
|
||||||
const paramsRef = useRef(params);
|
|
||||||
paramsRef.current = params;
|
|
||||||
const generation = useRef(0);
|
|
||||||
|
|
||||||
const update = useCallback((next: SftpArchiveDialogState | null) => {
|
|
||||||
stateRef.current = next;
|
|
||||||
setState(next);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const matchesTarget = useCallback((target: SftpArchiveTarget) => {
|
|
||||||
const current = paramsRef.current;
|
|
||||||
return current.isActive && !current.isLocal
|
|
||||||
&& current.paneId === target.paneId
|
|
||||||
&& current.connectionId === target.connectionId;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// A dialog belongs to the connection and pane on which it was opened. A tab
|
|
||||||
// switch may leave its component mounted, so visibility is part of identity.
|
|
||||||
useEffect(() => {
|
|
||||||
if (stateRef.current && !matchesTarget(stateRef.current.target)) {
|
|
||||||
generation.current += 1;
|
|
||||||
update(null);
|
|
||||||
}
|
|
||||||
}, [params.paneId, params.connectionId, params.isLocal, params.isActive, matchesTarget, update]);
|
|
||||||
|
|
||||||
useEffect(() => () => { generation.current += 1; }, []);
|
|
||||||
|
|
||||||
const open = useCallback((sourcePaths: string[]) => {
|
|
||||||
const current = paramsRef.current;
|
|
||||||
if (!current.connectionId || current.isLocal || !current.isActive
|
|
||||||
|| !current.onCreateArchive || stateRef.current?.busy) return;
|
|
||||||
generation.current += 1;
|
|
||||||
const target = { paneId: current.paneId, connectionId: current.connectionId };
|
|
||||||
try {
|
|
||||||
const selection = prepareSftpArchiveSelection(sourcePaths);
|
|
||||||
update({
|
|
||||||
target,
|
|
||||||
directory: selection.directory,
|
|
||||||
paths: selection.paths,
|
|
||||||
archiveName: withSftpArchiveExtension(selection.defaultName, "tar.gz"),
|
|
||||||
format: "tar.gz",
|
|
||||||
busy: false,
|
|
||||||
error: null,
|
|
||||||
confirmOverwrite: false,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
update({
|
|
||||||
target, directory: "", paths: [], archiveName: "", format: "tar.gz",
|
|
||||||
busy: false, error: current.t("sftp.archive.invalidSelection"), confirmOverwrite: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [update]);
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
|
||||||
if (stateRef.current?.busy) return;
|
|
||||||
generation.current += 1;
|
|
||||||
update(null);
|
|
||||||
}, [update]);
|
|
||||||
|
|
||||||
const setArchiveName = useCallback((archiveName: string) => {
|
|
||||||
const current = stateRef.current;
|
|
||||||
if (!current || current.busy) return;
|
|
||||||
update({ ...current, archiveName, error: null, confirmOverwrite: false });
|
|
||||||
}, [update]);
|
|
||||||
|
|
||||||
const setFormat = useCallback((format: SftpArchiveFormat) => {
|
|
||||||
const current = stateRef.current;
|
|
||||||
if (!current || current.busy) return;
|
|
||||||
update({
|
|
||||||
...current, format,
|
|
||||||
archiveName: withSftpArchiveExtension(current.archiveName, format),
|
|
||||||
error: null, confirmOverwrite: false,
|
|
||||||
});
|
|
||||||
}, [update]);
|
|
||||||
|
|
||||||
const submit = useCallback(async (overwrite = false) => {
|
|
||||||
const current = stateRef.current;
|
|
||||||
const { onCreateArchive, t } = paramsRef.current;
|
|
||||||
if (!current || current.busy || !onCreateArchive || !matchesTarget(current.target)
|
|
||||||
|| current.paths.length === 0 || (overwrite && !current.confirmOverwrite)) return;
|
|
||||||
// Enter in the name field must not implicitly confirm replacement.
|
|
||||||
if (current.confirmOverwrite && !overwrite) return;
|
|
||||||
const validationError = validateSftpArchiveName(current.archiveName, current.format);
|
|
||||||
if (validationError) {
|
|
||||||
update({ ...current, error: t(validationError) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const requestGeneration = generation.current;
|
|
||||||
update({ ...current, busy: true, error: null });
|
|
||||||
try {
|
|
||||||
const result = await onCreateArchive({
|
|
||||||
directory: current.directory,
|
|
||||||
paths: current.paths,
|
|
||||||
archiveName: current.archiveName,
|
|
||||||
format: current.format,
|
|
||||||
overwrite,
|
|
||||||
}, current.target);
|
|
||||||
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
|
|
||||||
if (!result.success) {
|
|
||||||
update({
|
|
||||||
...current, busy: false,
|
|
||||||
confirmOverwrite: result.code === "EEXIST",
|
|
||||||
error: result.code === "EEXIST" ? null : result.error || t("sftp.archive.failed"),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
update(null);
|
|
||||||
paramsRef.current.onCreated(current.directory);
|
|
||||||
} catch (error) {
|
|
||||||
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
|
|
||||||
update({
|
|
||||||
...current, busy: false, confirmOverwrite: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [matchesTarget, update]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: state && matchesTarget(state.target) ? state : null,
|
|
||||||
open, close, setArchiveName, setFormat, submit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1326,13 +1326,6 @@ export const useSftpTransfers = ({
|
|||||||
sourcePath?: string;
|
sourcePath?: string;
|
||||||
sourceConnectionId?: string;
|
sourceConnectionId?: string;
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
/**
|
|
||||||
* Create copies next to the originals instead of prompting for the
|
|
||||||
* same-name conflict. Each source name is resolved to the next free
|
|
||||||
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
|
|
||||||
* Used by the SFTP context menu's Duplicate action.
|
|
||||||
*/
|
|
||||||
duplicate?: boolean;
|
|
||||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -1436,10 +1429,6 @@ export const useSftpTransfers = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of sourceFiles) {
|
for (const file of sourceFiles) {
|
||||||
// "." / ".." are navigation rows, not real entries. Duplicating one
|
|
||||||
// would resolve to an ancestor (e.g. "/") and could walk the copy into
|
|
||||||
// its own output.
|
|
||||||
if (options?.duplicate && (file.name === "." || file.name === "..")) continue;
|
|
||||||
const direction: TransferDirection =
|
const direction: TransferDirection =
|
||||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||||
? "upload"
|
? "upload"
|
||||||
@@ -1472,36 +1461,8 @@ export const useSftpTransfers = ({
|
|||||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||||
|
|
||||||
const nextSourcePath = joinPath(sourcePath, file.name);
|
const nextSourcePath = joinPath(sourcePath, file.name);
|
||||||
|
const nextTargetPath = joinPath(targetPath, file.name);
|
||||||
const targetIsLocal = targetPane.connection!.isLocal;
|
const targetIsLocal = targetPane.connection!.isLocal;
|
||||||
// A duplicate targets the folder the item already lives in, so the
|
|
||||||
// same-name conflict is guaranteed. Resolve the "(copy)" name up front
|
|
||||||
// instead of routing through the conflict dialog: the one-click action
|
|
||||||
// must not prompt, and resolving it inside processTransfer would
|
|
||||||
// re-enter the same task while it is still marked in flight.
|
|
||||||
const duplicateTarget = options?.duplicate
|
|
||||||
? await getDuplicateTarget(
|
|
||||||
{ fileName: file.name, isDirectory: file.isDirectory, targetPath: joinPath(targetPath, file.name) },
|
|
||||||
targetPane,
|
|
||||||
targetIsLocal ? null : (sftpSessionsRef.current.get(targetPane.connection!.id) ?? null),
|
|
||||||
targetIsLocal ? "auto" : (targetPane.filenameEncoding || "auto"),
|
|
||||||
// A queued or paused copy has not created its destination on disk
|
|
||||||
// yet, so the next duplicate would pick the same name and surface
|
|
||||||
// a conflict dialog. Reserve active destinations while naming.
|
|
||||||
(candidatePath) => Boolean(findActivePathConflict(
|
|
||||||
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
|
|
||||||
{
|
|
||||||
id: "",
|
|
||||||
targetPath: candidatePath,
|
|
||||||
targetConnectionId: targetIsLocal ? "local" : targetPane.connection!.id,
|
|
||||||
targetHostId: targetIsLocal ? undefined : targetPane.connection!.hostId,
|
|
||||||
targetHostLabel: targetIsLocal ? "Local" : targetPane.connection!.hostLabel,
|
|
||||||
isDirectory: file.isDirectory,
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const nextFileName = duplicateTarget?.fileName ?? file.name;
|
|
||||||
const nextTargetPath = duplicateTarget?.targetPath ?? joinPath(targetPath, file.name);
|
|
||||||
const pathTaken = findActivePathConflict(
|
const pathTaken = findActivePathConflict(
|
||||||
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
|
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
|
||||||
{
|
{
|
||||||
@@ -1520,7 +1481,7 @@ export const useSftpTransfers = ({
|
|||||||
const attentionTask: TransferTask = {
|
const attentionTask: TransferTask = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
batchId,
|
batchId,
|
||||||
fileName: nextFileName,
|
fileName: file.name,
|
||||||
originalFileName: file.name,
|
originalFileName: file.name,
|
||||||
sourcePath: nextSourcePath,
|
sourcePath: nextSourcePath,
|
||||||
targetPath: nextTargetPath,
|
targetPath: nextTargetPath,
|
||||||
@@ -1561,7 +1522,7 @@ export const useSftpTransfers = ({
|
|||||||
newTasks.push({
|
newTasks.push({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
batchId,
|
batchId,
|
||||||
fileName: nextFileName,
|
fileName: file.name,
|
||||||
originalFileName: file.name,
|
originalFileName: file.name,
|
||||||
sourcePath: nextSourcePath,
|
sourcePath: nextSourcePath,
|
||||||
targetPath: nextTargetPath,
|
targetPath: nextTargetPath,
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ export interface UseSftpTransfersResult {
|
|||||||
sourcePath?: string;
|
sourcePath?: string;
|
||||||
sourceConnectionId?: string;
|
sourceConnectionId?: string;
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
/**
|
|
||||||
* Create copies next to the originals instead of prompting for the
|
|
||||||
* same-name conflict. Each source name is resolved to the next free
|
|
||||||
* `name (copy)`, `name (copy 2)`, ... before the transfer starts.
|
|
||||||
*/
|
|
||||||
duplicate?: boolean;
|
|
||||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||||
},
|
},
|
||||||
) => Promise<TransferResult[]>;
|
) => Promise<TransferResult[]>;
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/** Install isolated browser storage for hook tests, including Node without Web Storage. */
|
|
||||||
export function installTestLocalStorage(): () => void {
|
|
||||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
const storage: Storage = {
|
|
||||||
get length() { return values.size; },
|
|
||||||
clear: () => values.clear(),
|
|
||||||
getItem: key => values.get(String(key)) ?? null,
|
|
||||||
key: index => [...values.keys()][index] ?? null,
|
|
||||||
removeItem: key => { values.delete(String(key)); },
|
|
||||||
setItem: (key, value) => { values.set(String(key), String(value)); },
|
|
||||||
};
|
|
||||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
|
|
||||||
return () => {
|
|
||||||
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
|
|
||||||
else Reflect.deleteProperty(globalThis, 'localStorage');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -127,8 +127,6 @@ export interface SendToCattyContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SendToExternalContext {
|
export interface SendToExternalContext {
|
||||||
scopeType?: 'terminal' | 'workspace' | 'global';
|
|
||||||
scopeTargetId?: string;
|
|
||||||
existingSessionId?: string;
|
existingSessionId?: string;
|
||||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
|
|||||||
@@ -1597,7 +1597,6 @@ export function useAIState() {
|
|||||||
previousMemberTerminalIds: readonly string[];
|
previousMemberTerminalIds: readonly string[];
|
||||||
currentMemberTerminalIds: readonly string[];
|
currentMemberTerminalIds: readonly string[];
|
||||||
preferredTerminalId?: string | null;
|
preferredTerminalId?: string | null;
|
||||||
activeTargetIds?: ReadonlySet<string>;
|
|
||||||
}) => {
|
}) => {
|
||||||
const currentMap =
|
const currentMap =
|
||||||
latestAIActiveSessionMapSnapshot
|
latestAIActiveSessionMapSnapshot
|
||||||
@@ -1613,7 +1612,6 @@ export function useAIState() {
|
|||||||
previousMemberTerminalIds: input.previousMemberTerminalIds,
|
previousMemberTerminalIds: input.previousMemberTerminalIds,
|
||||||
currentMemberTerminalIds: input.currentMemberTerminalIds,
|
currentMemberTerminalIds: input.currentMemberTerminalIds,
|
||||||
preferredTerminalId: input.preferredTerminalId,
|
preferredTerminalId: input.preferredTerminalId,
|
||||||
activeTargetIds: input.activeTargetIds,
|
|
||||||
});
|
});
|
||||||
if (!result.changed) return;
|
if (!result.changed) return;
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import React from "react";
|
|||||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||||
|
|
||||||
import { useSessionState } from "./useSessionState.ts";
|
import { useSessionState } from "./useSessionState.ts";
|
||||||
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
|
|
||||||
|
|
||||||
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
|
|
||||||
|
|
||||||
type SessionState = ReturnType<typeof useSessionState>;
|
type SessionState = ReturnType<typeof useSessionState>;
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import React from "react";
|
|||||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||||
|
|
||||||
import { useSessionState } from "./useSessionState.ts";
|
import { useSessionState } from "./useSessionState.ts";
|
||||||
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
|
|
||||||
|
|
||||||
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
|
|
||||||
|
|
||||||
type SessionState = ReturnType<typeof useSessionState>;
|
type SessionState = ReturnType<typeof useSessionState>;
|
||||||
|
|
||||||
@@ -89,15 +86,6 @@ test("global broadcast availability follows visible orphan session count", async
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
current().removeSessionFromWorkspace(second.id);
|
current().removeSessionFromWorkspace(second.id);
|
||||||
});
|
});
|
||||||
// The one-member workspace stays intact; only the detached terminal is
|
|
||||||
// an orphan, so global broadcast remains unavailable until another opens.
|
|
||||||
assert.equal(current().workspaces.length, 1);
|
|
||||||
assert.equal(current().sessions.find(session => session.id === first.id)?.workspaceId, current().workspaces[0].id);
|
|
||||||
assert.deepEqual(current().orphanSessions.map(session => session.id), [second.id]);
|
|
||||||
assert.equal(current().canUseGlobalBroadcast, false);
|
|
||||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
|
||||||
await act(async () => { current().createLocalTerminal({ shellName: "04" }); });
|
|
||||||
assert.equal(current().orphanSessions.length, 2);
|
|
||||||
assert.equal(current().canUseGlobalBroadcast, true);
|
assert.equal(current().canUseGlobalBroadcast, true);
|
||||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import type { Workspace } from '../../domain/models';
|
|
||||||
import { collectSessionIds } from '../../domain/workspace';
|
|
||||||
import { moveWorkspacePaneInWorkspaces } from './useSessionState';
|
|
||||||
|
|
||||||
// Issue #4037: moving a member pane must rearrange only the tree shape.
|
|
||||||
// Session ids, workspace id, anchorChatId (AI binding), focus fields and tab
|
|
||||||
// order all live outside the touched surface, and these tests pin that.
|
|
||||||
|
|
||||||
const workspace = (id = 'ws-1'): Workspace => ({
|
|
||||||
id,
|
|
||||||
title: 'Workspace',
|
|
||||||
focusedSessionId: 's1',
|
|
||||||
focusSessionOrder: ['s1', 's2', 's3'],
|
|
||||||
anchorChatId: 'chat-anchor-1',
|
|
||||||
root: {
|
|
||||||
id: 'split-1',
|
|
||||||
type: 'split',
|
|
||||||
direction: 'vertical',
|
|
||||||
children: [
|
|
||||||
{ id: 'pane-1', type: 'pane', sessionId: 's1' },
|
|
||||||
{ id: 'pane-2', type: 'pane', sessionId: 's2' },
|
|
||||||
{ id: 'pane-3', type: 'pane', sessionId: 's3' },
|
|
||||||
],
|
|
||||||
sizes: [1, 1, 1],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moveWorkspacePaneInWorkspaces rearranges the tree and preserves every identity field', () => {
|
|
||||||
const ws = workspace();
|
|
||||||
const next = moveWorkspacePaneInWorkspaces([ws], 'ws-1', 's3', {
|
|
||||||
direction: 'vertical',
|
|
||||||
position: 'left',
|
|
||||||
targetSessionId: 's1',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(next.length, 1);
|
|
||||||
const moved = next[0];
|
|
||||||
assert.equal(moved.id, ws.id, 'workspace id (AI binding key) unchanged');
|
|
||||||
assert.equal(moved.title, ws.title);
|
|
||||||
assert.equal(moved.anchorChatId, 'chat-anchor-1', 'anchorChatId unchanged');
|
|
||||||
assert.equal(moved.focusedSessionId, 's1', 'focus stays on the same session id');
|
|
||||||
assert.deepEqual(moved.focusSessionOrder, ['s1', 's2', 's3'], 'tab/focus order unchanged');
|
|
||||||
assert.deepEqual(collectSessionIds(moved.root), ['s3', 's1', 's2']);
|
|
||||||
assert.notEqual(moved.root, ws.root, 'the tree shape actually changed');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moveWorkspacePaneInWorkspaces leaves other workspaces and no-op cases untouched', () => {
|
|
||||||
const ws1 = workspace('ws-1');
|
|
||||||
const ws2 = workspace('ws-2');
|
|
||||||
|
|
||||||
// Same-reference no-op: pane dropped onto itself.
|
|
||||||
const selfDrop = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 's2', {
|
|
||||||
direction: 'vertical',
|
|
||||||
position: 'left',
|
|
||||||
targetSessionId: 's2',
|
|
||||||
});
|
|
||||||
assert.equal(selfDrop[0], ws1, 'no-op workspace keeps its reference');
|
|
||||||
assert.equal(selfDrop[1], ws2);
|
|
||||||
|
|
||||||
// Unknown session / foreign workspace id: nothing moves.
|
|
||||||
const ghost = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 'ghost', {
|
|
||||||
direction: 'vertical',
|
|
||||||
position: 'left',
|
|
||||||
targetSessionId: 's1',
|
|
||||||
});
|
|
||||||
assert.equal(ghost[0], ws1);
|
|
||||||
assert.equal(ghost[1], ws2);
|
|
||||||
|
|
||||||
const foreign = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-other', 's1', {
|
|
||||||
direction: 'vertical',
|
|
||||||
position: 'left',
|
|
||||||
targetSessionId: 's2',
|
|
||||||
});
|
|
||||||
assert.equal(foreign[0], ws1);
|
|
||||||
assert.equal(foreign[1], ws2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moveWorkspacePaneInWorkspaces only rewrites the targeted workspace entry', () => {
|
|
||||||
const ws1 = workspace('ws-1');
|
|
||||||
const ws2 = workspace('ws-2');
|
|
||||||
const next = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-2', 's2', {
|
|
||||||
direction: 'vertical',
|
|
||||||
position: 'right',
|
|
||||||
targetSessionId: 's3',
|
|
||||||
});
|
|
||||||
assert.equal(next[0], ws1, 'sibling workspaces keep their references');
|
|
||||||
assert.notEqual(next[1], ws2);
|
|
||||||
assert.deepEqual(collectSessionIds(next[1].root), ['s1', 's3', 's2']);
|
|
||||||
});
|
|
||||||
@@ -19,7 +19,6 @@ createWorkspaceFromSessionIds,
|
|||||||
FocusDirection,
|
FocusDirection,
|
||||||
getNextFocusSessionId,
|
getNextFocusSessionId,
|
||||||
insertPaneIntoWorkspace,
|
insertPaneIntoWorkspace,
|
||||||
movePaneInWorkspace,
|
|
||||||
pruneWorkspaceNode,
|
pruneWorkspaceNode,
|
||||||
reorderWorkspaceFocusSessionOrder,
|
reorderWorkspaceFocusSessionOrder,
|
||||||
setWorkspaceFocusedSessionId,
|
setWorkspaceFocusedSessionId,
|
||||||
@@ -43,9 +42,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
closeSessionsState,
|
closeSessionsState,
|
||||||
detachSessionFromWorkspaceState,
|
detachSessionFromWorkspaceState,
|
||||||
|
replaceDissolvedWorkspaceTabOrder,
|
||||||
} from './sessionWorkspaceDetach';
|
} from './sessionWorkspaceDetach';
|
||||||
import { getAISessionsSnapshot } from './aiSessionsStore';
|
|
||||||
import { getPersistedAiSessionsSnapshot } from './aiStateSnapshots';
|
|
||||||
import {
|
import {
|
||||||
createCopiedTerminalSessionClone,
|
createCopiedTerminalSessionClone,
|
||||||
createSplitTerminalSessionClone,
|
createSplitTerminalSessionClone,
|
||||||
@@ -124,26 +122,6 @@ export function appendWorkspaceRootPaneIfMissing(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue #4037: rearrange an existing member pane within its own workspace.
|
|
||||||
* Only `workspaces[].root` shape changes — session ids, tab order, focus
|
|
||||||
* fields and every AI binding (anchorChatId / activeSessionIdMap scopes) are
|
|
||||||
* untouched, because the moved session keeps its id and its workspaceId.
|
|
||||||
*/
|
|
||||||
export function moveWorkspacePaneInWorkspaces(
|
|
||||||
workspaces: Workspace[],
|
|
||||||
workspaceId: string,
|
|
||||||
sessionId: string,
|
|
||||||
hint: SplitHint,
|
|
||||||
): Workspace[] {
|
|
||||||
return workspaces.map(ws => {
|
|
||||||
if (ws.id !== workspaceId) return ws;
|
|
||||||
if (!collectSessionIds(ws.root).includes(sessionId)) return ws;
|
|
||||||
const nextRoot = movePaneInWorkspace(ws.root, sessionId, hint);
|
|
||||||
return nextRoot === ws.root ? ws : { ...ws, root: nextRoot };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function insertCopiedTabOrderIdOnce(
|
export function insertCopiedTabOrderIdOnce(
|
||||||
prevTabOrder: string[],
|
prevTabOrder: string[],
|
||||||
sourceTabId: string,
|
sourceTabId: string,
|
||||||
@@ -637,11 +615,6 @@ export const useSessionState = ({
|
|||||||
currentActiveTabId: activeTabStore.getActiveTabId(),
|
currentActiveTabId: activeTabStore.getActiveTabId(),
|
||||||
preferredTabId: activeTabStore.getPreviousActiveTabId(),
|
preferredTabId: activeTabStore.getPreviousActiveTabId(),
|
||||||
tabOrder: tabOrderRef.current,
|
tabOrder: tabOrderRef.current,
|
||||||
// AI chat recency decides which surviving member receives focus/anchor
|
|
||||||
// after member loss (#4098 phase 2); hot store first, persisted fallback.
|
|
||||||
aiSessions: getAISessionsSnapshot().sessions.length > 0
|
|
||||||
? getAISessionsSnapshot().sessions
|
|
||||||
: getPersistedAiSessionsSnapshot(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setWorkspaces(result.workspaces);
|
setWorkspaces(result.workspaces);
|
||||||
@@ -849,20 +822,6 @@ export const useSessionState = ({
|
|||||||
});
|
});
|
||||||
}, [setActiveTabId]);
|
}, [setActiveTabId]);
|
||||||
|
|
||||||
// Issue #4037: drop an existing member pane back into its own workspace to
|
|
||||||
// rearrange it. Deliberately touches nothing but the pane tree: no session
|
|
||||||
// fields, no tab order, no focus changes (focusedSessionId keeps pointing
|
|
||||||
// at the same session id, so focus visually follows the moved pane), and
|
|
||||||
// no AI state — the anchorChatId lives on the workspace, which keeps its id.
|
|
||||||
const moveWorkspacePane = useCallback((
|
|
||||||
workspaceId: string,
|
|
||||||
sessionId: string,
|
|
||||||
hint: SplitHint
|
|
||||||
) => {
|
|
||||||
if (!hint) return;
|
|
||||||
setWorkspaces(prev => moveWorkspacePaneInWorkspaces(prev, workspaceId, sessionId, hint));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Merge two tabs into one workspace. When one of them already belongs to a
|
// Merge two tabs into one workspace. When one of them already belongs to a
|
||||||
// workspace, that workspace (and its id) is reused instead of dissolving
|
// workspace, that workspace (and its id) is reused instead of dissolving
|
||||||
// and recreating: the id is what workspace-scoped AI chats bind to, so
|
// and recreating: the id is what workspace-scoped AI chats bind to, so
|
||||||
@@ -1147,25 +1106,6 @@ export const useSessionState = ({
|
|||||||
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
|
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to.
|
|
||||||
// Written by the AI side panel when a chat becomes active in the workspace
|
|
||||||
// scope; consumed by the non-positional fallbacks and persisted to the
|
|
||||||
// session restore snapshot.
|
|
||||||
const setWorkspaceAnchorChat = useCallback((workspaceId: string, chatId: string | null) => {
|
|
||||||
setWorkspaces(prev => {
|
|
||||||
const index = prev.findIndex(ws => ws.id === workspaceId);
|
|
||||||
if (index < 0) return prev;
|
|
||||||
if (prev[index].anchorChatId === (chatId ?? undefined)) return prev;
|
|
||||||
const next = prev.slice();
|
|
||||||
next[index] = chatId
|
|
||||||
? { ...prev[index], anchorChatId: chatId }
|
|
||||||
: Object.fromEntries(
|
|
||||||
Object.entries(prev[index]).filter(([key]) => key !== 'anchorChatId'),
|
|
||||||
) as Workspace;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reorderWorkspaceSessions = useCallback((
|
const reorderWorkspaceSessions = useCallback((
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
draggedSessionId: string,
|
draggedSessionId: string,
|
||||||
@@ -1495,16 +1435,18 @@ export const useSessionState = ({
|
|||||||
sessions: sessionsRef.current,
|
sessions: sessionsRef.current,
|
||||||
workspaces: workspacesRef.current,
|
workspaces: workspacesRef.current,
|
||||||
sessionId,
|
sessionId,
|
||||||
aiSessions: getAISessionsSnapshot().sessions.length > 0
|
|
||||||
? getAISessionsSnapshot().sessions
|
|
||||||
: getPersistedAiSessionsSnapshot(),
|
|
||||||
});
|
});
|
||||||
if (!result.changed) return;
|
if (!result.changed) return;
|
||||||
|
|
||||||
setWorkspaces(result.workspaces);
|
setWorkspaces(result.workspaces);
|
||||||
setSessions(result.sessions);
|
setSessions(result.sessions);
|
||||||
setTabOrder((prevTabOrder) => {
|
setTabOrder((prevTabOrder) => {
|
||||||
if (!tabInsertionTarget) return prevTabOrder;
|
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||||
|
prevTabOrder,
|
||||||
|
result.dissolvedWorkspaceId,
|
||||||
|
result.replacementTabIds,
|
||||||
|
);
|
||||||
|
if (!tabInsertionTarget) return replacedOrder;
|
||||||
|
|
||||||
const allTabIds = [
|
const allTabIds = [
|
||||||
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
|
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
|
||||||
@@ -1513,7 +1455,7 @@ export const useSessionState = ({
|
|||||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||||
];
|
];
|
||||||
return reorderWorkTabIds(
|
return reorderWorkTabIds(
|
||||||
prevTabOrder,
|
replacedOrder,
|
||||||
allTabIds,
|
allTabIds,
|
||||||
sessionId,
|
sessionId,
|
||||||
tabInsertionTarget.tabId,
|
tabInsertionTarget.tabId,
|
||||||
@@ -1573,7 +1515,6 @@ export const useSessionState = ({
|
|||||||
createWorkspaceFromTargets,
|
createWorkspaceFromTargets,
|
||||||
createWorkspaceFromSessions,
|
createWorkspaceFromSessions,
|
||||||
addSessionToWorkspace,
|
addSessionToWorkspace,
|
||||||
moveWorkspacePane,
|
|
||||||
removeSessionFromWorkspace,
|
removeSessionFromWorkspace,
|
||||||
appendHostToWorkspace,
|
appendHostToWorkspace,
|
||||||
appendHostToWorkspaceConfirmed,
|
appendHostToWorkspaceConfirmed,
|
||||||
@@ -1582,7 +1523,6 @@ export const useSessionState = ({
|
|||||||
splitSession,
|
splitSession,
|
||||||
toggleWorkspaceViewMode,
|
toggleWorkspaceViewMode,
|
||||||
setWorkspaceFocusedSession,
|
setWorkspaceFocusedSession,
|
||||||
setWorkspaceAnchorChat,
|
|
||||||
reorderWorkspaceSessions,
|
reorderWorkspaceSessions,
|
||||||
moveFocusInWorkspace,
|
moveFocusInWorkspace,
|
||||||
runSnippet,
|
runSnippet,
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ import React from "react";
|
|||||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||||
|
|
||||||
import { useSessionState } from "./useSessionState.ts";
|
import { useSessionState } from "./useSessionState.ts";
|
||||||
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
|
|
||||||
import { collectSessionIds } from "../../domain/workspace.ts";
|
|
||||||
|
|
||||||
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
|
|
||||||
|
|
||||||
type SessionState = ReturnType<typeof useSessionState>;
|
type SessionState = ReturnType<typeof useSessionState>;
|
||||||
|
|
||||||
@@ -125,15 +121,6 @@ test("merged tab titles follow the workspace lifecycle until the user renames it
|
|||||||
assert.equal(workspace(workspaceId).title, "Pinned");
|
assert.equal(workspace(workspaceId).title, "Pinned");
|
||||||
assert.equal(workspace(workspaceId).generatedTitle, false);
|
assert.equal(workspace(workspaceId).generatedTitle, false);
|
||||||
|
|
||||||
await act(async () => { current().closeSession(firstSessionId); });
|
|
||||||
assert.equal(workspace(workspaceId).title, "Pinned");
|
|
||||||
assert.deepEqual(collectSessionIds(workspace(workspaceId).root), [secondSessionId]);
|
|
||||||
assert.equal(current().sessions.find(session => session.id === secondSessionId)?.workspaceId, workspaceId);
|
|
||||||
await act(async () => { current().closeSession(secondSessionId); });
|
|
||||||
assert.equal(workspace(workspaceId).title, "Pinned");
|
|
||||||
assert.equal(workspace(workspaceId).generatedTitle, false);
|
|
||||||
assert.deepEqual(collectSessionIds(workspace(workspaceId).root), []);
|
|
||||||
|
|
||||||
let leftBaseSessionId = "";
|
let leftBaseSessionId = "";
|
||||||
let leftJoiningSessionId = "";
|
let leftJoiningSessionId = "";
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import {
|
|||||||
tryBeginSendForKey,
|
tryBeginSendForKey,
|
||||||
} from './ai/draftSendGate';
|
} from './ai/draftSendGate';
|
||||||
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||||
import { latestAIActiveSessionMapSnapshot, latestAISessionsSnapshot } from '../application/state/aiStateSnapshots';
|
|
||||||
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
||||||
import {
|
import {
|
||||||
buildPromptWithTerminalSelectionAttachments,
|
buildPromptWithTerminalSelectionAttachments,
|
||||||
@@ -317,7 +316,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
|||||||
scopeHostIds,
|
scopeHostIds,
|
||||||
scopeLabel,
|
scopeLabel,
|
||||||
focusedSessionId,
|
focusedSessionId,
|
||||||
onSetWorkspaceAnchorChat,
|
|
||||||
workspaces = [],
|
workspaces = [],
|
||||||
terminalSessions = [],
|
terminalSessions = [],
|
||||||
liveScopeTargetIds,
|
liveScopeTargetIds,
|
||||||
@@ -446,16 +444,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
|||||||
preferredTerminalId: focusedSessionId,
|
preferredTerminalId: focusedSessionId,
|
||||||
visibleSessionIds: visibleHistorySessionIds,
|
visibleSessionIds: visibleHistorySessionIds,
|
||||||
});
|
});
|
||||||
// Issue #4098 phase 2: this workspace's anchored chat — used by the panel
|
|
||||||
// view resolver before any positional fallback could apply.
|
|
||||||
const workspaceAnchorChatId = scopeType === 'workspace'
|
|
||||||
? workspaces.find((workspace) => workspace.id === scopeTargetId)?.anchorChatId
|
|
||||||
: undefined;
|
|
||||||
// getScopedHistorySessions orders by scope match first, recency second.
|
|
||||||
const preferredHistorySessionId = historySessions[0]?.id ?? null;
|
|
||||||
const normalizedPanelView = useMemo<AIPanelView>(
|
const normalizedPanelView = useMemo<AIPanelView>(
|
||||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId),
|
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId],
|
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||||
);
|
);
|
||||||
const activeSession = useMemo(
|
const activeSession = useMemo(
|
||||||
() => resolveDisplayedSession(normalizedPanelView, historySessions),
|
() => resolveDisplayedSession(normalizedPanelView, historySessions),
|
||||||
@@ -650,21 +641,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
|||||||
}, { initialDelayMs: 250 });
|
}, { initialDelayMs: 250 });
|
||||||
}, [isVisible, terminalSessions, activeSessionId]);
|
}, [isVisible, terminalSessions, activeSessionId]);
|
||||||
|
|
||||||
// Workspace maintenance can retarget an inherited chat earlier in this same
|
|
||||||
// passive-effect flush. Before clearing a selection, recheck its membership
|
|
||||||
// against the synchronous snapshot instead of this render's old history.
|
|
||||||
const isSessionVisibleInCurrentScope = useCallback((sessionId: string) => (
|
|
||||||
getScopedHistorySessions(
|
|
||||||
latestAISessionsSnapshot ?? sessions,
|
|
||||||
scopeType,
|
|
||||||
scopeTargetId,
|
|
||||||
scopeHostIds,
|
|
||||||
activeTerminalSessionIds,
|
|
||||||
workspaceMemberTerminalIds,
|
|
||||||
latestAIActiveSessionMapSnapshot ?? activeSessionIdMap,
|
|
||||||
).some((session) => session.id === sessionId)
|
|
||||||
), [sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds, workspaceMemberTerminalIds, activeSessionIdMap]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return;
|
if (!isVisible) return;
|
||||||
// Predicate must match normalizePanelView's list (scoped history), not the
|
// Predicate must match normalizePanelView's list (scoped history), not the
|
||||||
@@ -672,23 +648,16 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
|||||||
if (!shouldForceDraftViewSync(
|
if (!shouldForceDraftViewSync(
|
||||||
explicitPanelView,
|
explicitPanelView,
|
||||||
normalizedPanelView,
|
normalizedPanelView,
|
||||||
isSessionVisibleInCurrentScope,
|
(sessionId) => historySessions.some((session) => session.id === sessionId),
|
||||||
)) {
|
)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showDraftView(scopeKey);
|
showDraftView(scopeKey);
|
||||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, isSessionVisibleInCurrentScope, showDraftView]);
|
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeSession) return;
|
if (!activeSession) return;
|
||||||
|
|
||||||
// Issue #4098 phase 2: a chat becoming active in the workspace scope pins
|
|
||||||
// the workspace's anchorChatId so member loss / restart resolve by id,
|
|
||||||
// never by position.
|
|
||||||
if (scopeType === 'workspace' && scopeTargetId && isVisible) {
|
|
||||||
onSetWorkspaceAnchorChat?.(scopeTargetId, activeSession.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
|
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
|
||||||
setActiveSessionId(activeSession.id);
|
setActiveSessionId(activeSession.id);
|
||||||
}
|
}
|
||||||
@@ -696,24 +665,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
|||||||
activeSession,
|
activeSession,
|
||||||
activeSessionIdMap,
|
activeSessionIdMap,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
scopeType,
|
|
||||||
scopeTargetId,
|
|
||||||
isVisible,
|
isVisible,
|
||||||
onSetWorkspaceAnchorChat,
|
|
||||||
setActiveSessionId,
|
setActiveSessionId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return;
|
if (!isVisible) return;
|
||||||
if (normalizedPanelView.mode !== 'draft') return;
|
if (normalizedPanelView.mode !== 'draft') return;
|
||||||
// Keep a selection that maintenance just made displayable, but always
|
// Keep ownership while an explicit session is still displayable in this
|
||||||
// honor an explicit New Chat draft. Out-of-scope / missing ids still clear.
|
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
|
||||||
const selectedSessionId = explicitPanelView?.mode === 'session'
|
|
||||||
? explicitPanelView.sessionId : persistedSessionId;
|
|
||||||
if (
|
if (
|
||||||
explicitPanelView?.mode !== 'draft'
|
explicitPanelView?.mode === 'session'
|
||||||
&& selectedSessionId
|
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
|
||||||
&& isSessionVisibleInCurrentScope(selectedSessionId)
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -723,7 +686,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
|||||||
isVisible,
|
isVisible,
|
||||||
normalizedPanelView.mode,
|
normalizedPanelView.mode,
|
||||||
explicitPanelView,
|
explicitPanelView,
|
||||||
isSessionVisibleInCurrentScope,
|
historySessions,
|
||||||
persistedSessionId,
|
persistedSessionId,
|
||||||
setActiveSessionId,
|
setActiveSessionId,
|
||||||
]);
|
]);
|
||||||
@@ -1825,8 +1788,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
|||||||
try {
|
try {
|
||||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||||
await sendToExternalAgent(sessionId, assistantMsgId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
await sendToExternalAgent(sessionId, assistantMsgId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
||||||
scopeType,
|
|
||||||
scopeTargetId,
|
|
||||||
existingSessionId: existingExternalSessionId,
|
existingSessionId: existingExternalSessionId,
|
||||||
updateExternalSessionId: updateSessionExternalSessionId,
|
updateExternalSessionId: updateSessionExternalSessionId,
|
||||||
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||||
|
|||||||
@@ -109,10 +109,8 @@ export interface AIChatSidePanelProps {
|
|||||||
scopeLabel?: string;
|
scopeLabel?: string;
|
||||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||||
focusedSessionId?: string;
|
focusedSessionId?: string;
|
||||||
/** Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to. */
|
/** Live workspaces, used to resolve workspace titles for `/chats`. */
|
||||||
onSetWorkspaceAnchorChat?: (workspaceId: string, chatId: string | null) => void;
|
workspaces?: Array<{ id: string; title: string }>;
|
||||||
/** Live workspaces (with their anchor chat), used for scope titles and anchor resolution. */
|
|
||||||
workspaces?: Array<{ id: string; title: string; anchorChatId?: string }>;
|
|
||||||
/** All currently live terminal (and workspace) scope target ids. */
|
/** All currently live terminal (and workspace) scope target ids. */
|
||||||
liveScopeTargetIds?: Set<string>;
|
liveScopeTargetIds?: Set<string>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import type { Identity, Host } from "../types";
|
|
||||||
|
|
||||||
async function withWizard(
|
|
||||||
run: (fixture: Awaited<ReturnType<typeof mountWizard>>) => Promise<void>,
|
|
||||||
identities: Identity[] = [],
|
|
||||||
) {
|
|
||||||
const fixture = await mountWizard(identities);
|
|
||||||
try {
|
|
||||||
await run(fixture);
|
|
||||||
} finally {
|
|
||||||
await fixture.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountWizard(identities: Identity[]) {
|
|
||||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
|
||||||
url: "http://localhost", pretendToBeVisual: true,
|
|
||||||
});
|
|
||||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
|
||||||
const install = (key: string, value: unknown) => {
|
|
||||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
|
||||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
|
||||||
};
|
|
||||||
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent"] as const) {
|
|
||||||
install(key, key === "window" ? dom.window : dom.window[key]);
|
|
||||||
}
|
|
||||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
|
||||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
|
||||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
|
||||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
|
||||||
const { act, useState } = await import("react");
|
|
||||||
const { createRoot } = await import("react-dom/client");
|
|
||||||
const { I18nProvider } = await import("../application/i18n/I18nProvider");
|
|
||||||
const { default: QuickConnectWizard } = await import("./QuickConnectWizard");
|
|
||||||
const target = { hostname: "example.com" };
|
|
||||||
const connected: Host[] = [];
|
|
||||||
const saved: Host[] = [];
|
|
||||||
let closeCount = 0;
|
|
||||||
function Fixture() {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
return (
|
|
||||||
<I18nProvider locale="en">
|
|
||||||
{open && <QuickConnectWizard
|
|
||||||
open target={target} keys={[]} identities={identities}
|
|
||||||
onConnect={(host) => connected.push(host)}
|
|
||||||
onSaveHost={(host) => saved.push(host)}
|
|
||||||
onClose={() => { closeCount++; setOpen(false); }}
|
|
||||||
/>}
|
|
||||||
</I18nProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const root = createRoot(document.getElementById("root")!);
|
|
||||||
await act(async () => { root.render(<Fixture />); });
|
|
||||||
const button = (name: string) => {
|
|
||||||
const element = [...document.querySelectorAll<HTMLButtonElement>("button")]
|
|
||||||
.find((candidate) => candidate.getAttribute("aria-label") === name || candidate.textContent?.trim() === name);
|
|
||||||
assert.ok(element, `Button ${name} is present`);
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
const input = (selector: string) => {
|
|
||||||
const element = document.querySelector<HTMLInputElement>(selector);
|
|
||||||
assert.ok(element, `Input ${selector} is present`);
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
const overlay = () => {
|
|
||||||
const element = document.querySelector<HTMLElement>(".fixed.inset-0");
|
|
||||||
assert.ok(element, "Quick Connect is still open");
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
const click = async (element: HTMLElement) => {
|
|
||||||
await act(async () => { element.click(); });
|
|
||||||
};
|
|
||||||
const type = async (selector: string, value: string) => {
|
|
||||||
await act(async () => {
|
|
||||||
const element = input(selector);
|
|
||||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
|
|
||||||
element.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
||||||
});
|
|
||||||
assert.equal(input(selector).value, value);
|
|
||||||
};
|
|
||||||
const key = async (element: HTMLElement, key: string) => {
|
|
||||||
await act(async () => { element.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true })); });
|
|
||||||
};
|
|
||||||
const settle = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); };
|
|
||||||
const outsideClick = async () => {
|
|
||||||
await act(async () => {
|
|
||||||
overlay().dispatchEvent(new dom.window.MouseEvent("mousedown", { bubbles: true }));
|
|
||||||
overlay().click();
|
|
||||||
});
|
|
||||||
assert.equal(closeCount, 0, "Background clicks must not close Quick Connect");
|
|
||||||
overlay();
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
button, input, overlay, click, type, key, settle, outsideClick, connected, saved,
|
|
||||||
closeCount: () => closeCount,
|
|
||||||
async cleanup() {
|
|
||||||
await act(async () => { root.unmount(); });
|
|
||||||
dom.window.close();
|
|
||||||
for (const [key, descriptor] of previous) {
|
|
||||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
||||||
else Reflect.deleteProperty(globalThis, key);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("background clicks preserve each Quick Connect step and typed connection details", async () => {
|
|
||||||
await withWizard(async (f) => {
|
|
||||||
await f.type('input[type="number"]', "2222");
|
|
||||||
await f.outsideClick();
|
|
||||||
assert.equal(f.input('input[type="number"]').value, "2222");
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
await f.type("#quick-username", "alice");
|
|
||||||
await f.outsideClick();
|
|
||||||
assert.equal(f.input("#quick-username").value, "alice");
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
await f.type("#quick-password", "partly configured password");
|
|
||||||
await f.outsideClick();
|
|
||||||
await f.key(f.input("#quick-password"), "Escape");
|
|
||||||
assert.equal(f.closeCount(), 0);
|
|
||||||
assert.equal(f.input("#quick-password").value, "partly configured password");
|
|
||||||
await f.click(f.button("Back"));
|
|
||||||
assert.equal(f.input("#quick-username").value, "alice");
|
|
||||||
await f.click(f.button("Back"));
|
|
||||||
assert.equal(f.input('input[type="number"]').value, "2222");
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
assert.equal(f.input("#quick-password").value, "partly configured password");
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
assert.equal(f.connected.length, 1);
|
|
||||||
assert.equal(f.connected[0].username, "alice");
|
|
||||||
assert.equal(f.connected[0].port, 2222);
|
|
||||||
assert.equal(f.connected[0].password, "partly configured password");
|
|
||||||
assert.equal(f.closeCount(), 1);
|
|
||||||
assert.equal(document.querySelector("h2"), null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the portaled credential picker retains the wizard and selected identity", async () => {
|
|
||||||
await withWizard(async (f) => {
|
|
||||||
await f.type('input[type="number"]', "2222");
|
|
||||||
await f.click(f.input('[role="combobox"]'));
|
|
||||||
await f.settle();
|
|
||||||
const option = document.querySelector<HTMLElement>('[role="option"]');
|
|
||||||
assert.ok(option);
|
|
||||||
assert.equal(f.overlay().contains(option), false, "Options are portaled outside the wizard DOM");
|
|
||||||
await f.click(option);
|
|
||||||
assert.equal(f.closeCount(), 0);
|
|
||||||
assert.equal(f.input('[role="combobox"]').value, "Production identity");
|
|
||||||
assert.equal(f.input('input[type="number"]').value, "2222");
|
|
||||||
await f.outsideClick();
|
|
||||||
await f.click(f.button("Connect with preset"));
|
|
||||||
assert.equal(f.connected.length, 1);
|
|
||||||
assert.equal(f.connected[0].identityId, "identity-alice");
|
|
||||||
assert.equal(f.connected[0].username, "alice");
|
|
||||||
assert.equal(f.connected[0].port, 2222);
|
|
||||||
assert.equal(f.closeCount(), 1);
|
|
||||||
}, [{ id: "identity-alice", label: "Production identity", username: "alice", authMethod: "password", password: "preset secret", created: 1 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the portaled save menu preserves credentials until explicit save and connect", async () => {
|
|
||||||
await withWizard(async (f) => {
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
await f.type("#quick-username", "alice");
|
|
||||||
await f.key(f.input("#quick-username"), "Enter");
|
|
||||||
await f.type("#quick-password", "secret");
|
|
||||||
const trigger = f.button("Continue & Save");
|
|
||||||
await f.click(trigger);
|
|
||||||
await f.settle();
|
|
||||||
const menuItem = [...document.querySelectorAll<HTMLButtonElement>("button")]
|
|
||||||
.find((candidate) => candidate.textContent === "Continue & Save");
|
|
||||||
assert.ok(menuItem);
|
|
||||||
assert.equal(f.overlay().contains(menuItem), false, "Save menu is portaled outside the wizard DOM");
|
|
||||||
await f.click(menuItem.parentElement!);
|
|
||||||
assert.equal(f.closeCount(), 0);
|
|
||||||
assert.equal(f.input("#quick-password").value, "secret");
|
|
||||||
await f.key(document.body, "Escape");
|
|
||||||
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
||||||
assert.equal(f.closeCount(), 0);
|
|
||||||
await f.click(trigger);
|
|
||||||
await f.settle();
|
|
||||||
await f.outsideClick();
|
|
||||||
assert.equal(trigger.getAttribute("aria-expanded"), "false");
|
|
||||||
assert.equal(f.input("#quick-password").value, "secret");
|
|
||||||
await f.click(trigger);
|
|
||||||
await f.settle();
|
|
||||||
const save = [...document.querySelectorAll<HTMLButtonElement>("button")]
|
|
||||||
.find((candidate) => candidate.textContent === "Continue & Save");
|
|
||||||
assert.ok(save);
|
|
||||||
await f.click(save);
|
|
||||||
assert.equal(f.saved.length, 1);
|
|
||||||
assert.equal(f.connected.length, 1);
|
|
||||||
assert.equal(f.saved[0], f.connected[0]);
|
|
||||||
assert.equal(f.connected[0].username, "alice");
|
|
||||||
assert.equal(f.connected[0].password, "secret");
|
|
||||||
assert.equal(f.closeCount(), 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("explicit Close dismisses the protocol step", async () => {
|
|
||||||
await withWizard(async (f) => {
|
|
||||||
await f.click(f.button("Close"));
|
|
||||||
assert.equal(f.closeCount(), 1);
|
|
||||||
assert.equal(document.querySelector("h2"), null);
|
|
||||||
assert.equal(f.connected.length, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const step of ["username", "auth"]) {
|
|
||||||
test(`the header X explicitly closes the ${step} step`, async () => {
|
|
||||||
await withWizard(async (f) => {
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
await f.type("#quick-username", "alice");
|
|
||||||
if (step === "auth") {
|
|
||||||
await f.click(f.button("Continue"));
|
|
||||||
await f.type("#quick-password", "secret");
|
|
||||||
}
|
|
||||||
const close = f.button("Close");
|
|
||||||
assert.ok(close.querySelector("svg.lucide-x"), "A visible X is provided on steps with Back");
|
|
||||||
await f.click(close);
|
|
||||||
assert.equal(f.closeCount(), 1);
|
|
||||||
assert.equal(document.querySelector("h2"), null);
|
|
||||||
assert.equal(f.connected.length, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Plug,
|
Plug,
|
||||||
Plus,
|
Plus,
|
||||||
User,
|
User,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -676,6 +675,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
|||||||
const wizard = (
|
const wizard = (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-[560px] max-w-[90vw] bg-background border border-border rounded-2xl animate-in fade-in-0 zoom-in-95 duration-200"
|
className="w-[560px] max-w-[90vw] bg-background border border-border rounded-2xl animate-in fade-in-0 zoom-in-95 duration-200"
|
||||||
@@ -686,7 +686,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-4 border-b border-border/50 flex items-center justify-between gap-4">
|
<div className="px-6 py-4 border-b border-border/50">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Plug size={18} className="shrink-0 text-foreground" />
|
<Plug size={18} className="shrink-0 text-foreground" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -696,17 +696,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{step !== "protocol" && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 shrink-0"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
|||||||
data-section="pane-magnification-hint"
|
data-section="pane-magnification-hint"
|
||||||
>
|
>
|
||||||
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
|
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
|
||||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
|
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -245,14 +245,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
onTerminalDataCapture,
|
onTerminalDataCapture,
|
||||||
onCreateWorkspaceFromSessions,
|
onCreateWorkspaceFromSessions,
|
||||||
onAddSessionToWorkspace,
|
onAddSessionToWorkspace,
|
||||||
onMoveWorkspacePane,
|
|
||||||
onRequestAddToWorkspace,
|
onRequestAddToWorkspace,
|
||||||
onAppendHostToWorkspace,
|
onAppendHostToWorkspace,
|
||||||
onUpdateSplitSizes,
|
onUpdateSplitSizes,
|
||||||
onSetDraggingSessionId,
|
onSetDraggingSessionId,
|
||||||
onToggleWorkspaceViewMode,
|
onToggleWorkspaceViewMode,
|
||||||
onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession,
|
||||||
onSetWorkspaceAnchorChat,
|
|
||||||
onReorderWorkspaceSessions,
|
onReorderWorkspaceSessions,
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
onCopySession,
|
onCopySession,
|
||||||
@@ -2714,7 +2712,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
systemMountedTabIds,
|
systemMountedTabIds,
|
||||||
themeMountedTabIds,
|
themeMountedTabIds,
|
||||||
onAddSessionToWorkspace,
|
onAddSessionToWorkspace,
|
||||||
onMoveWorkspacePane,
|
|
||||||
onConnectToHost,
|
onConnectToHost,
|
||||||
onCreateLocalTerminal,
|
onCreateLocalTerminal,
|
||||||
onCreateWorkspaceFromSessions,
|
onCreateWorkspaceFromSessions,
|
||||||
@@ -2729,7 +2726,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
|||||||
onSessionData,
|
onSessionData,
|
||||||
onSetDraggingSessionId,
|
onSetDraggingSessionId,
|
||||||
onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession,
|
||||||
onSetWorkspaceAnchorChat,
|
|
||||||
onStartSessionRename,
|
onStartSessionRename,
|
||||||
onSubmitSessionRename,
|
onSubmitSessionRename,
|
||||||
onRemoveSessionFromWorkspace,
|
onRemoveSessionFromWorkspace,
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import {
|
|||||||
shouldForceDraftViewSync,
|
shouldForceDraftViewSync,
|
||||||
} from "./aiPanelViewState.ts";
|
} from "./aiPanelViewState.ts";
|
||||||
|
|
||||||
function createSession(id: string, updatedAt = 1): AISession {
|
function createSession(id: string): AISession {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: `Session ${id}`,
|
title: `Session ${id}`,
|
||||||
messages: [],
|
messages: [],
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
updatedAt,
|
updatedAt: 1,
|
||||||
agentId: "catty",
|
agentId: "catty",
|
||||||
scope: {
|
scope: {
|
||||||
type: "terminal",
|
type: "terminal",
|
||||||
@@ -133,33 +133,6 @@ test("missing explicit panel view resumes the most recent matching history when
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("auto-resume prefers the anchored chat over recency and position (#4098 phase 2)", () => {
|
|
||||||
const sessions = [createSession("session-2", 9), createSession("session-1", 4)];
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace", "session-1"),
|
|
||||||
{ mode: "session", sessionId: "session-1" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("auto-resume without an anchor picks the most recently updated chat, not sessions[0]", () => {
|
|
||||||
const sessions = [createSession("session-2", 4), createSession("session-1", 9)];
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
|
|
||||||
{ mode: "session", sessionId: "session-1" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a stale anchor that left the scope falls through to recency", () => {
|
|
||||||
const sessions = [createSession("session-2", 4), createSession("session-1", 9)];
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace", "deleted-chat"),
|
|
||||||
{ mode: "session", sessionId: "session-1" },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
|
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
|
||||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ export function resolveDisplayedPanelView(
|
|||||||
sessions: AISession[],
|
sessions: AISession[],
|
||||||
persistedSessionId?: string | null,
|
persistedSessionId?: string | null,
|
||||||
scopeType: "terminal" | "workspace" = "workspace",
|
scopeType: "terminal" | "workspace" = "workspace",
|
||||||
anchorChatId?: string | null,
|
|
||||||
preferredHistorySessionId?: string | null,
|
|
||||||
): AIPanelView {
|
): AIPanelView {
|
||||||
if (panelView) {
|
if (panelView) {
|
||||||
return normalizePanelView(panelView, sessions);
|
return normalizePanelView(panelView, sessions);
|
||||||
@@ -68,44 +66,13 @@ export function resolveDisplayedPanelView(
|
|||||||
return DEFAULT_PANEL_VIEW;
|
return DEFAULT_PANEL_VIEW;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the workspace anchor, then the candidate selected by scoped
|
if (sessions[0]) {
|
||||||
// history's match-rank/recency ordering. Callers without a ranked candidate
|
return { mode: "session", sessionId: sessions[0].id };
|
||||||
// retain the recency fallback below.
|
|
||||||
if (anchorChatId && sessions.some((s) => s.id === anchorChatId)) {
|
|
||||||
return { mode: "session", sessionId: anchorChatId };
|
|
||||||
}
|
|
||||||
// Scoped history already ranks exact/member matches before other scopes,
|
|
||||||
// then sorts each rank by recency. Preserve its selected candidate instead
|
|
||||||
// of letting a newer chat from another workspace overtake that match.
|
|
||||||
const mostRecent = preferredHistorySessionId === undefined
|
|
||||||
? resolveMostRecentlyUpdatedSession(sessions)
|
|
||||||
: sessions.find((session) => session.id === preferredHistorySessionId);
|
|
||||||
if (mostRecent) {
|
|
||||||
return { mode: "session", sessionId: mostRecent.id };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return DEFAULT_PANEL_VIEW;
|
return DEFAULT_PANEL_VIEW;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The most recently updated session by `updatedAt`, or null when the list is
|
|
||||||
* empty. Ties keep the earlier entry so the result is deterministic.
|
|
||||||
*/
|
|
||||||
export function resolveMostRecentlyUpdatedSession(
|
|
||||||
sessions: AISession[],
|
|
||||||
): AISession | null {
|
|
||||||
let best: AISession | null = null;
|
|
||||||
let bestUpdatedAt = 0;
|
|
||||||
for (const session of sessions) {
|
|
||||||
const updatedAt = session.updatedAt ?? 0;
|
|
||||||
if (best === null || updatedAt > bestUpdatedAt) {
|
|
||||||
best = session;
|
|
||||||
bestUpdatedAt = updatedAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestUpdatedAt > 0 ? best : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizePanelView(
|
export function normalizePanelView(
|
||||||
panelView: AIPanelView,
|
panelView: AIPanelView,
|
||||||
sessions: AISession[],
|
sessions: AISession[],
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import type { AISession } from "../../infrastructure/ai/types.ts";
|
import type { AISession } from "../../infrastructure/ai/types.ts";
|
||||||
import { resolveDisplayedPanelView } from "./aiPanelViewState.ts";
|
|
||||||
import {
|
import {
|
||||||
_getScopedHistoryCacheSizeForTests,
|
_getScopedHistoryCacheSizeForTests,
|
||||||
getScopedHistorySessions,
|
getScopedHistorySessions,
|
||||||
@@ -24,49 +23,6 @@ function createSession(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("workspace auto-resume keeps scope rank ahead of recency", () => {
|
|
||||||
const sessions = [
|
|
||||||
createSession("exact-older", { type: "workspace", targetId: "workspace-current" }, 1),
|
|
||||||
createSession("member-newer", { type: "terminal", targetId: "member" }, 2),
|
|
||||||
createSession("other-newest", { type: "workspace", targetId: "workspace-other" }, 20),
|
|
||||||
];
|
|
||||||
const history = getScopedHistorySessions(
|
|
||||||
sessions, "workspace", "workspace-current", [], new Set(), new Set(["member"]),
|
|
||||||
);
|
|
||||||
assert.deepEqual(history.map((session) => session.id), ["member-newer", "exact-older", "other-newest"]);
|
|
||||||
const preferredId = history[0]?.id ?? null;
|
|
||||||
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "workspace", undefined, preferredId),
|
|
||||||
{ mode: "session", sessionId: "member-newer" });
|
|
||||||
|
|
||||||
// The ranking only controls implicit fallback, never an explicit selection,
|
|
||||||
// saved selection, anchor, or deliberate new-chat draft.
|
|
||||||
for (const [view, persisted, anchor] of [
|
|
||||||
[{ mode: "session", sessionId: "other-newest" }, undefined, undefined],
|
|
||||||
[undefined, "other-newest", undefined],
|
|
||||||
[undefined, undefined, "other-newest"],
|
|
||||||
] as const) {
|
|
||||||
assert.deepEqual(resolveDisplayedPanelView(view, false, history, persisted, "workspace", anchor, preferredId),
|
|
||||||
{ mode: "session", sessionId: "other-newest" });
|
|
||||||
}
|
|
||||||
assert.deepEqual(resolveDisplayedPanelView({ mode: "draft" }, true, history, undefined, "workspace", undefined, preferredId),
|
|
||||||
{ mode: "draft" });
|
|
||||||
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "terminal", undefined, preferredId),
|
|
||||||
{ mode: "draft" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace auto-resume prefers an exact match and retains history fallback when none exists", () => {
|
|
||||||
const exact = createSession("exact", { type: "workspace", targetId: "workspace-current" }, 1);
|
|
||||||
const others = [
|
|
||||||
createSession("other-old", { type: "workspace", targetId: "workspace-other" }, 5),
|
|
||||||
createSession("other-new", { type: "workspace", targetId: "workspace-other" }, 10),
|
|
||||||
];
|
|
||||||
for (const [sessions, expected] of [[others, "other-new"], [[...others, exact], "exact"], [[], undefined]] as const) {
|
|
||||||
const history = getScopedHistorySessions([...sessions], "workspace", "workspace-current", [], new Set());
|
|
||||||
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "workspace", undefined, history[0]?.id ?? null),
|
|
||||||
expected ? { mode: "session", sessionId: expected } : { mode: "draft" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspace history remains visible after the original workspace target is gone", () => {
|
test("workspace history remains visible after the original workspace target is gone", () => {
|
||||||
const staleWorkspaceSession = createSession(
|
const staleWorkspaceSession = createSession(
|
||||||
"workspace-stale",
|
"workspace-stale",
|
||||||
|
|||||||
@@ -70,13 +70,11 @@ test('rejected privacy draft stays open and accepted draft commits once and retu
|
|||||||
accepted = true;
|
accepted = true;
|
||||||
await input(1, 'INTERNAL_[A-Z0-9]+');
|
await input(1, 'INTERNAL_[A-Z0-9]+');
|
||||||
await click('Apply');
|
await click('Apply');
|
||||||
// Radix restores focus on a later frame; flush it before asserting.
|
|
||||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
|
||||||
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
|
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
|
||||||
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
|
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
|
||||||
assert.deepEqual(calls[1], { redaction: saved });
|
assert.deepEqual(calls[1], { redaction: saved });
|
||||||
assert.equal(document.querySelector('[role="dialog"]'), null);
|
assert.equal(document.querySelector('[role="dialog"]'), null);
|
||||||
assert.ok(document.activeElement === trigger, "focus returns to the dialog opener");
|
assert.equal(document.activeElement, trigger);
|
||||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
|
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
|
||||||
await click('Configure privacy');
|
await click('Configure privacy');
|
||||||
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
|
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test, { after } from "node:test";
|
|
||||||
import React from "react";
|
|
||||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
|
||||||
import { createEmptyPane } from "../../application/state/sftp/types";
|
|
||||||
import type { SftpFileEntry } from "../../types";
|
|
||||||
import { ContextMenuContent } from "../ui/context-menu";
|
|
||||||
import { TooltipProvider } from "../ui/tooltip";
|
|
||||||
import { installDomEnvironment } from "../test-support/renderReactDom";
|
|
||||||
import { SftpPaneFileList } from "./SftpPaneFileList";
|
|
||||||
import { useSftpPaneTreeContextMenu } from "./useSftpPaneTreeContextMenu";
|
|
||||||
|
|
||||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
|
||||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
|
||||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
|
||||||
|
|
||||||
type MenuItem = React.ReactElement<{ children?: React.ReactNode; onClick: () => void }>;
|
|
||||||
function archiveAction(children: React.ReactNode) {
|
|
||||||
return React.Children.toArray(children).find((child): child is MenuItem =>
|
|
||||||
React.isValidElement<{ children?: React.ReactNode }>(child)
|
|
||||||
&& React.Children.toArray(child.props.children).includes("sftp.archive.action"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const folder: SftpFileEntry = { name: "assets", type: "directory", size: 0, sizeFormatted: "0 B", lastModified: 0, lastModifiedFormatted: "" };
|
|
||||||
const file: SftpFileEntry = { name: "config.json", type: "file", size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" };
|
|
||||||
|
|
||||||
test("list archive menu includes files and folders and respects the clicked selection", async (t) => {
|
|
||||||
const dom = installDomEnvironment();
|
|
||||||
const noop = () => {};
|
|
||||||
const captured: string[][] = [];
|
|
||||||
let props: React.ComponentProps<typeof SftpPaneFileList> = {
|
|
||||||
t: (key) => key,
|
|
||||||
pane: {
|
|
||||||
...createEmptyPane("pane-archive"),
|
|
||||||
connection: { id: "conn", hostId: "host", hostLabel: "Host", isLocal: false, status: "connected", currentPath: "/srv/site" },
|
|
||||||
selectedFiles: new Set([folder.name, file.name]), files: [folder, file],
|
|
||||||
},
|
|
||||||
side: "left", isPaneFocused: true,
|
|
||||||
sorting: {
|
|
||||||
sortField: "name", sortOrder: "asc", directoriesFirst: true,
|
|
||||||
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
|
|
||||||
visibleColumns: { name: true, modified: true, size: true, type: true, owner: true },
|
|
||||||
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop,
|
|
||||||
},
|
|
||||||
fileListRef: { current: null }, handleFileListScroll: noop,
|
|
||||||
shouldVirtualize: false, totalHeight: 0, sortedDisplayFiles: [folder, file],
|
|
||||||
isDragOverPane: false, draggedFiles: null, onRefresh: noop, onNavigateTo: noop, onClearSelection: noop,
|
|
||||||
setShowNewFolderDialog: noop, setShowNewFileDialog: noop, getNextUntitledName: () => "", setNewFileName: noop, setFileNameError: noop,
|
|
||||||
dragOverEntry: null, handleRowSelect: noop, handleRowOpen: noop, handleFileDragStart: noop,
|
|
||||||
onDragEnd: noop, handleEntryDragOver: noop, handleRowDragLeave: noop, handleEntryDrop: noop,
|
|
||||||
onCopyToOtherPane: noop, onMoveEntriesToPath: async () => {}, openRenameDialog: noop, openDeleteConfirm: noop,
|
|
||||||
onCreateArchive: (paths) => { captured.push(paths); }, rowHeight: 28, visibleRows: [],
|
|
||||||
};
|
|
||||||
const render = () => React.createElement(TooltipProvider, null, React.createElement(SftpPaneFileList, props));
|
|
||||||
let renderer!: ReactTestRenderer;
|
|
||||||
await act(async () => { renderer = create(render()); });
|
|
||||||
t.after(async () => {
|
|
||||||
await act(async () => { renderer.unmount(); });
|
|
||||||
dom.cleanup();
|
|
||||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
});
|
|
||||||
const actions = () => renderer.root.findAllByType(ContextMenuContent)
|
|
||||||
.map((menu) => archiveAction(menu.props.children)).filter((item): item is MenuItem => !!item);
|
|
||||||
assert.equal(actions().length, 2, "both a folder and a file offer compression");
|
|
||||||
actions()[0].props.onClick();
|
|
||||||
assert.deepEqual(captured, [["/srv/site/assets", "/srv/site/config.json"]]);
|
|
||||||
props = { ...props, pane: { ...props.pane, selectedFiles: new Set([folder.name]) } };
|
|
||||||
await act(async () => { renderer.update(render()); });
|
|
||||||
actions()[1].props.onClick();
|
|
||||||
assert.deepEqual(captured[1], ["/srv/site/config.json"], "right-clicking an unselected item does not include the previous selection");
|
|
||||||
props = { ...props, isLocal: true, pane: { ...props.pane, connection: { ...props.pane.connection!, isLocal: true } } };
|
|
||||||
await act(async () => { renderer.update(render()); });
|
|
||||||
assert.equal(actions().length, 0, "remote archive commands are not exposed on a local pane");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tree archive menu passes full paths for nested multiselection and hides local and parent entries", async (t) => {
|
|
||||||
const captured: string[][] = [];
|
|
||||||
const selected = new Set(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/config.json"]);
|
|
||||||
const emptyRef = { current: undefined };
|
|
||||||
let props: Parameters<typeof useSftpPaneTreeContextMenu>[0] = {
|
|
||||||
contextTarget: { entry: folder, entryPath: "/srv/site/assets" },
|
|
||||||
pane: { connection: { id: "conn", isLocal: false } },
|
|
||||||
tRef: { current: (key: string) => key },
|
|
||||||
onOpenFileWithSystemDefaultRef: emptyRef, onOpenFileWithRef: emptyRef,
|
|
||||||
onEditFileRef: emptyRef, onDownloadFileRef: emptyRef,
|
|
||||||
onExtractArchiveRef: emptyRef, onEditPermissionsRef: emptyRef, onDuplicateEntriesRef: emptyRef,
|
|
||||||
onCreateArchiveRef: { current: (paths: string[]) => { captured.push(paths); } },
|
|
||||||
getActionPaths: (path: string) => selected.has(path) ? [...selected] : [path],
|
|
||||||
};
|
|
||||||
let menu: React.ReactNode;
|
|
||||||
function Probe() {
|
|
||||||
menu = useSftpPaneTreeContextMenu(props);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let renderer!: ReactTestRenderer;
|
|
||||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
|
||||||
t.after(async () => { await act(async () => { renderer.unmount(); }); });
|
|
||||||
const action = () => archiveAction((menu as React.ReactElement<{ children: React.ReactNode }>).props.children);
|
|
||||||
assert.ok(action());
|
|
||||||
action()!.props.onClick();
|
|
||||||
assert.deepEqual(captured[0], [...selected]);
|
|
||||||
props = { ...props, contextTarget: { entry: file, entryPath: "/srv/unselected.json" } };
|
|
||||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
|
||||||
action()!.props.onClick();
|
|
||||||
assert.deepEqual(captured[1], ["/srv/unselected.json"]);
|
|
||||||
props = { ...props, pane: { connection: { id: "local", isLocal: true } } };
|
|
||||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
|
||||||
assert.equal(action(), undefined);
|
|
||||||
props = { ...props, pane: { connection: { id: "conn", isLocal: false } }, contextTarget: { entry: { ...folder, name: ".." }, entryPath: "/srv" } };
|
|
||||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
|
||||||
assert.equal(action(), undefined);
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||||
import type { SftpConnectedHostEntry } from "../../domain/sftpConnectedHosts";
|
import type { SftpConnectedHostEntry } from "../../domain/sftpConnectedHosts";
|
||||||
import type { SftpArchiveCreationOptions, SftpArchiveCreationResult, SftpArchiveCreationTarget } from "../../domain/sftpArchiveCreation";
|
|
||||||
import type { SftpConnectOptions } from "../../application/state/sftp/useSftpConnections";
|
import type { SftpConnectOptions } from "../../application/state/sftp/useSftpConnections";
|
||||||
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
|
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
|
||||||
|
|
||||||
@@ -54,8 +53,6 @@ export interface SftpPaneCallbacks {
|
|||||||
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
|
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
|
||||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||||
/** Create copies next to the selected entries (same pane, same folder). */
|
|
||||||
onDuplicateEntries: (files: SftpTransferSource[]) => void;
|
|
||||||
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
||||||
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
|
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
|
||||||
// File operations
|
// File operations
|
||||||
@@ -66,7 +63,6 @@ export interface SftpPaneCallbacks {
|
|||||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||||
onCreateArchive?: (options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => Promise<SftpArchiveCreationResult>;
|
|
||||||
// External file upload (supports folders via DataTransfer)
|
// External file upload (supports folders via DataTransfer)
|
||||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||||
// External file upload from <input type="file" multiple> picker (FileList).
|
// External file upload from <input type="file" multiple> picker (FileList).
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import React from "react";
|
|
||||||
import type { SftpArchiveDialogState } from "../../application/state/sftp/useSftpArchiveDialog";
|
|
||||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "../test-support/renderReactDom";
|
|
||||||
|
|
||||||
test("archive dialog focuses its name and requires explicit overwrite activation", async (t) => {
|
|
||||||
const env = installDomEnvironment();
|
|
||||||
const globals = {
|
|
||||||
MutationObserver: globalThis.MutationObserver,
|
|
||||||
NodeFilter: globalThis.NodeFilter,
|
|
||||||
HTMLInputElement: globalThis.HTMLInputElement,
|
|
||||||
DocumentFragment: globalThis.DocumentFragment,
|
|
||||||
};
|
|
||||||
for (const key of Object.keys(globals) as Array<keyof typeof globals>) {
|
|
||||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: env.window[key] });
|
|
||||||
}
|
|
||||||
const { SftpCreateArchiveDialog } = await import("./SftpCreateArchiveDialog");
|
|
||||||
const renderer = await createDomRenderer(env.document);
|
|
||||||
t.after(async () => {
|
|
||||||
await renderer.unmount();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
||||||
for (const [key, value] of Object.entries(globals)) {
|
|
||||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
|
||||||
}
|
|
||||||
env.cleanup();
|
|
||||||
});
|
|
||||||
let state: SftpArchiveDialogState = {
|
|
||||||
target: { paneId: "pane-1", connectionId: "conn-1" },
|
|
||||||
directory: "/srv", paths: ["site"], archiveName: "site.tar.gz", format: "tar.gz",
|
|
||||||
busy: false, error: null, confirmOverwrite: false,
|
|
||||||
};
|
|
||||||
const submissions: Array<boolean | undefined> = [];
|
|
||||||
let closes = 0;
|
|
||||||
const render = () => renderer.render(React.createElement(SftpCreateArchiveDialog, {
|
|
||||||
state,
|
|
||||||
t: (key) => key,
|
|
||||||
onClose: () => { closes += 1; },
|
|
||||||
onNameChange: () => {},
|
|
||||||
onFormatChange: () => {},
|
|
||||||
onSubmit: async (overwrite) => { submissions.push(overwrite); },
|
|
||||||
}));
|
|
||||||
await render();
|
|
||||||
await flushEffects();
|
|
||||||
const input = env.document.querySelector("input");
|
|
||||||
assert.ok(input);
|
|
||||||
assert.equal(env.document.activeElement, input);
|
|
||||||
assert.equal(input.value, "site.tar.gz");
|
|
||||||
assert.ok(env.document.querySelector("label[for='" + input.id + "']"));
|
|
||||||
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }));
|
|
||||||
assert.deepEqual(submissions, [], "confirming an IME candidate must not create an archive");
|
|
||||||
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
||||||
assert.deepEqual(submissions, [undefined]);
|
|
||||||
state = { ...state, confirmOverwrite: true };
|
|
||||||
await render();
|
|
||||||
assert.match(env.document.querySelector('[role="alert"]')?.textContent ?? "", /sftp.archive.overwriteDescription/);
|
|
||||||
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
||||||
assert.deepEqual(submissions, [undefined], "Enter in the name field does not replace an existing archive");
|
|
||||||
const overwrite = [...env.document.querySelectorAll("button")].find((button) => button.textContent === "sftp.archive.overwrite");
|
|
||||||
assert.ok(overwrite);
|
|
||||||
await dispatchDomEvent(overwrite, new env.window.MouseEvent("click", { bubbles: true }));
|
|
||||||
assert.deepEqual(submissions, [undefined, true]);
|
|
||||||
state = { ...state, busy: true };
|
|
||||||
await render();
|
|
||||||
assert.equal(input.disabled, true);
|
|
||||||
assert.ok([...env.document.querySelectorAll("button")].filter((button) => button.textContent === "common.cancel" || button.textContent === "sftp.archive.creating").every((button) => button.disabled));
|
|
||||||
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
|
||||||
assert.equal(closes, 0);
|
|
||||||
});
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import React, { useId, useRef } from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import type { SftpArchiveDialogState } from "../../application/state/sftp/useSftpArchiveDialog";
|
|
||||||
import type { SftpArchiveFormat } from "../../domain/sftpArchiveCreation";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
|
||||||
import { Input } from "../ui/input";
|
|
||||||
import { Label } from "../ui/label";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
|
||||||
|
|
||||||
interface SftpCreateArchiveDialogProps {
|
|
||||||
state: SftpArchiveDialogState | null;
|
|
||||||
t: (key: string, params?: Record<string, unknown>) => string;
|
|
||||||
onClose: () => void;
|
|
||||||
onNameChange: (name: string) => void;
|
|
||||||
onFormatChange: (format: SftpArchiveFormat) => void;
|
|
||||||
onSubmit: (overwrite?: boolean) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SftpCreateArchiveDialog({
|
|
||||||
state, t, onClose, onNameChange, onFormatChange, onSubmit,
|
|
||||||
}: SftpCreateArchiveDialogProps) {
|
|
||||||
const id = useId();
|
|
||||||
const nameRef = useRef<HTMLInputElement>(null);
|
|
||||||
return (
|
|
||||||
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
||||||
<DialogContent
|
|
||||||
className="sm:max-w-md"
|
|
||||||
hideCloseButton={state?.busy}
|
|
||||||
aria-describedby={`${id}-description`}
|
|
||||||
onOpenAutoFocus={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
nameRef.current?.focus();
|
|
||||||
nameRef.current?.select();
|
|
||||||
}}
|
|
||||||
onEscapeKeyDown={(event) => { if (state?.busy) event.preventDefault(); }}
|
|
||||||
onInteractOutside={(event) => { if (state?.busy) event.preventDefault(); }}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("sftp.archive.title")}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<span id={`${id}-description`}>{t("sftp.archive.description", { count: state?.paths.length ?? 0 })}</span>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{state && (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`${id}-name`}>{t("sftp.archive.name")}</Label>
|
|
||||||
<Input
|
|
||||||
ref={nameRef}
|
|
||||||
id={`${id}-name`}
|
|
||||||
value={state.archiveName}
|
|
||||||
disabled={state.busy}
|
|
||||||
aria-invalid={!!state.error}
|
|
||||||
aria-describedby={state.error ? `${id}-error` : undefined}
|
|
||||||
onChange={(event) => onNameChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && !event.nativeEvent.isComposing && !state.confirmOverwrite) {
|
|
||||||
event.preventDefault();
|
|
||||||
void onSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`${id}-format`}>{t("sftp.archive.format")}</Label>
|
|
||||||
<Select
|
|
||||||
value={state.format}
|
|
||||||
disabled={state.busy}
|
|
||||||
onValueChange={(value) => onFormatChange(value as SftpArchiveFormat)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`${id}-format`}><SelectValue /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="tar.gz">TAR.GZ</SelectItem>
|
|
||||||
<SelectItem value="zip">ZIP</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
|
||||||
<div>{t("sftp.archive.destination")}</div>
|
|
||||||
<div className="break-all font-mono">{state.directory}</div>
|
|
||||||
</div>
|
|
||||||
{state.error && <p id={`${id}-error`} role="alert" className="text-xs text-destructive">{state.error}</p>}
|
|
||||||
{state.confirmOverwrite && (
|
|
||||||
<div role="alert" className="space-y-1 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
|
||||||
<p className="font-medium">{t("sftp.archive.overwriteTitle")}</p>
|
|
||||||
<p className="break-words text-muted-foreground">
|
|
||||||
{t("sftp.archive.overwriteDescription", { name: state.archiveName })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" size="sm" disabled={state.busy} onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={state.confirmOverwrite ? "destructive" : "default"}
|
|
||||||
disabled={state.busy || !state.archiveName.trim() || state.paths.length === 0}
|
|
||||||
onClick={() => void onSubmit(state.confirmOverwrite)}
|
|
||||||
>
|
|
||||||
{state.busy && <Loader2 size={14} className="mr-2 animate-spin" />}
|
|
||||||
{t(state.busy ? "sftp.archive.creating" : state.confirmOverwrite ? "sftp.archive.overwrite" : "sftp.archive.create")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, CopyPlus, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
|
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -61,7 +61,6 @@ interface SftpPaneFileListProps {
|
|||||||
handleRowDragLeave: () => void;
|
handleRowDragLeave: () => void;
|
||||||
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||||
onDuplicateEntries?: (files: SftpTransferSource[]) => void;
|
|
||||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||||
onOpenFileWithSystemDefault?: (entry: SftpFileEntry) => void;
|
onOpenFileWithSystemDefault?: (entry: SftpFileEntry) => void;
|
||||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||||
@@ -69,7 +68,6 @@ interface SftpPaneFileListProps {
|
|||||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||||
onExtractArchive?: (entry: SftpFileEntry) => void;
|
onExtractArchive?: (entry: SftpFileEntry) => void;
|
||||||
onCreateArchive?: (sourcePaths: string[]) => void;
|
|
||||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
||||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
|
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
|
||||||
@@ -153,7 +151,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
handleRowDragLeave,
|
handleRowDragLeave,
|
||||||
handleEntryDrop,
|
handleEntryDrop,
|
||||||
onCopyToOtherPane,
|
onCopyToOtherPane,
|
||||||
onDuplicateEntries,
|
|
||||||
onMoveEntriesToPath,
|
onMoveEntriesToPath,
|
||||||
onOpenFileWithSystemDefault,
|
onOpenFileWithSystemDefault,
|
||||||
onOpenFileWith,
|
onOpenFileWith,
|
||||||
@@ -161,7 +158,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
onDownloadFiles,
|
onDownloadFiles,
|
||||||
onExtractArchive,
|
onExtractArchive,
|
||||||
onCreateArchive,
|
|
||||||
onEditPermissions,
|
onEditPermissions,
|
||||||
onUploadExternalFileList,
|
onUploadExternalFileList,
|
||||||
onUploadExternalFolder,
|
onUploadExternalFolder,
|
||||||
@@ -292,23 +288,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
void onUploadExternalFileList(files, targetPath);
|
void onUploadExternalFileList(files, targetPath);
|
||||||
}, [onUploadExternalFileList]);
|
}, [onUploadExternalFileList]);
|
||||||
|
|
||||||
const getSelectedTransferSources = useCallback((entry: SftpFileEntry): SftpTransferSource[] => {
|
|
||||||
const currentSelected = selectedFilesRef.current;
|
|
||||||
const names = currentSelected.has(entry.name)
|
|
||||||
? Array.from(currentSelected)
|
|
||||||
: [entry.name];
|
|
||||||
return names.map((name) => {
|
|
||||||
const fileName = String(name);
|
|
||||||
const file = filesByName.get(fileName);
|
|
||||||
return {
|
|
||||||
name: fileName,
|
|
||||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
|
||||||
sourceConnectionId: pane.connection?.id,
|
|
||||||
sourcePath: pane.connection?.currentPath,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [filesByName, pane.connection?.id, pane.connection?.currentPath]);
|
|
||||||
|
|
||||||
const renderRow = useCallback(
|
const renderRow = useCallback(
|
||||||
(entry: SftpFileEntry, index: number) => (
|
(entry: SftpFileEntry, index: number) => (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
@@ -397,32 +376,29 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
{t("sftp.context.extract")}
|
{t("sftp.context.extract")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
{!isLocal && onCreateArchive && (
|
|
||||||
<ContextMenuItem onClick={() => {
|
|
||||||
const selected = selectedFilesRef.current;
|
|
||||||
const names = selected.has(entry.name) ? Array.from(selected) : [entry.name];
|
|
||||||
onCreateArchive(names.filter((name) => name !== "..").map((name) =>
|
|
||||||
joinPath(pane.connection.currentPath, name),
|
|
||||||
));
|
|
||||||
}}>
|
|
||||||
<Archive size={14} className="mr-2" />{t("sftp.archive.action")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => onCopyToOtherPane(getSelectedTransferSources(entry))}
|
onClick={() => {
|
||||||
|
const currentSelected = selectedFilesRef.current;
|
||||||
|
const files = currentSelected.has(entry.name)
|
||||||
|
? Array.from(currentSelected)
|
||||||
|
: [entry.name];
|
||||||
|
const fileData = files.map((name) => {
|
||||||
|
const fileName = String(name);
|
||||||
|
const file = filesByName.get(fileName);
|
||||||
|
return {
|
||||||
|
name: fileName,
|
||||||
|
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||||
|
sourceConnectionId: pane.connection?.id,
|
||||||
|
sourcePath: pane.connection?.currentPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
onCopyToOtherPane(fileData);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Copy size={14} className="mr-2" />{" "}
|
<Copy size={14} className="mr-2" />{" "}
|
||||||
{t("sftp.context.copyToOtherPane")}
|
{t("sftp.context.copyToOtherPane")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{onDuplicateEntries && (
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => onDuplicateEntries(getSelectedTransferSources(entry))}
|
|
||||||
>
|
|
||||||
<CopyPlus size={14} className="mr-2" />{" "}
|
|
||||||
{t("sftp.context.duplicate")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
||||||
@@ -521,15 +497,11 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
|||||||
handleRowSelect,
|
handleRowSelect,
|
||||||
dragOverEntry,
|
dragOverEntry,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
isLocal,
|
|
||||||
onCopyToOtherPane,
|
onCopyToOtherPane,
|
||||||
onDuplicateEntries,
|
|
||||||
getSelectedTransferSources,
|
|
||||||
onMoveEntriesToPath,
|
onMoveEntriesToPath,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
onDownloadFiles,
|
onDownloadFiles,
|
||||||
onExtractArchive,
|
onExtractArchive,
|
||||||
onCreateArchive,
|
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onEditFile,
|
onEditFile,
|
||||||
onEditPermissions,
|
onEditPermissions,
|
||||||
|
|||||||
@@ -5,19 +5,14 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { TreeNode } from "./SftpPaneTreeNode.tsx";
|
import { TreeNode } from "./SftpPaneTreeNode.tsx";
|
||||||
|
|
||||||
import { SftpPaneTreeView, getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
|
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
|
||||||
import type { SftpFileEntry } from "../../types";
|
import type { SftpFileEntry } from "../../types";
|
||||||
import { createEmptyPane } from "../../application/state/sftp/types";
|
|
||||||
import { sftpTreeSelectionStore } from "../../application/state/sftp/sftpTreeSelectionStore";
|
|
||||||
import { installDomEnvironment, createDomRenderer } from "../test-support/renderReactDom";
|
|
||||||
|
|
||||||
const entry = (name: string, type: SftpFileEntry["type"]): SftpFileEntry => ({
|
const entry = (name: string, type: SftpFileEntry["type"]): SftpFileEntry => ({
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
size: 0,
|
size: 0,
|
||||||
sizeFormatted: "--",
|
|
||||||
lastModified: 0,
|
lastModified: 0,
|
||||||
lastModifiedFormatted: "--",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tree activation enters directories and keeps file opening separate", () => {
|
test("tree activation enters directories and keeps file opening separate", () => {
|
||||||
@@ -26,7 +21,7 @@ test("tree activation enters directories and keeps file opening separate", () =>
|
|||||||
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
|
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tree double clicks open entries while the chevron only toggles expansion", async () => {
|
test("tree double clicks preserve folder, parent, file and arrow actions", async () => {
|
||||||
const dom = new JSDOM('<div id="root"></div>');
|
const dom = new JSDOM('<div id="root"></div>');
|
||||||
const keys = ["window", "document", "IS_REACT_ACT_ENVIRONMENT"] as const;
|
const keys = ["window", "document", "IS_REACT_ACT_ENVIRONMENT"] as const;
|
||||||
const originals = keys.map(key => Object.getOwnPropertyDescriptor(globalThis, key));
|
const originals = keys.map(key => Object.getOwnPropertyDescriptor(globalThis, key));
|
||||||
@@ -43,11 +38,7 @@ test("tree double clicks open entries while the chevron only toggles expansion",
|
|||||||
columnTemplate: "1fr", visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
|
columnTemplate: "1fr", visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
|
||||||
isSelected: false, isExpanded: false, isLoading, isDragOver: false,
|
isSelected: false, isExpanded: false, isLoading, isDragOver: false,
|
||||||
onToggleExpand: () => calls.push("toggle"), onNodeClick: () => calls.push("select"),
|
onToggleExpand: () => calls.push("toggle"), onNodeClick: () => calls.push("select"),
|
||||||
onOpenEntry: (opened, path) => {
|
onOpenEntry: () => calls.push("open"), onContextMenu: () => calls.push("menu"),
|
||||||
assert.equal(opened, item);
|
|
||||||
assert.equal(path, `/root/${item.name}`);
|
|
||||||
calls.push("open");
|
|
||||||
}, onContextMenu: () => calls.push("menu"),
|
|
||||||
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
|
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
|
||||||
})));
|
})));
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
@@ -60,18 +51,14 @@ test("tree double clicks open entries while the chevron only toggles expansion",
|
|||||||
try {
|
try {
|
||||||
await render(entry("docs", "directory"));
|
await render(entry("docs", "directory"));
|
||||||
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
|
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
|
||||||
assert.deepEqual(calls, ["select", "select", "open"]);
|
assert.deepEqual(calls, ["select", "select", "toggle"]);
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
await doubleClick(container.querySelector('.lucide-chevron-right')!);
|
await doubleClick(container.querySelector('.lucide-chevron-right')!);
|
||||||
assert.deepEqual(calls, ["toggle", "toggle"]);
|
assert.deepEqual(calls, ["toggle", "toggle"]);
|
||||||
await render(entry("docs", "directory"), true);
|
await render(entry("docs", "directory"), true);
|
||||||
await doubleClick(container.querySelector('.lucide-loader-circle, .lucide-loader-2')!);
|
await doubleClick(container.querySelector('.lucide-loader-circle, .lucide-loader-2')!);
|
||||||
assert.deepEqual(calls, ["select", "select"]);
|
assert.deepEqual(calls, ["select", "select"]);
|
||||||
for (const item of [
|
for (const item of [entry("..", "directory"), entry("notes.txt", "file")]) {
|
||||||
entry("..", "directory"), entry("notes.txt", "file"),
|
|
||||||
{ ...entry("linked-docs", "symlink"), linkTarget: "directory" as const },
|
|
||||||
{ ...entry("linked-file", "symlink"), linkTarget: "file" as const },
|
|
||||||
]) {
|
|
||||||
await render(item);
|
await render(item);
|
||||||
await doubleClick(container.querySelector('[data-section]')!);
|
await doubleClick(container.querySelector('[data-section]')!);
|
||||||
assert.deepEqual(calls, ["select", "select", "open"]);
|
assert.deepEqual(calls, ["select", "select", "open"]);
|
||||||
@@ -86,114 +73,3 @@ test("tree double clicks open entries while the chevron only toggles expansion",
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tree view routes double clicks to navigation and preserves selection, expansion and Enter", async () => {
|
|
||||||
const dom = installDomEnvironment();
|
|
||||||
const previousObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver");
|
|
||||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
|
||||||
configurable: true,
|
|
||||||
value: class { observe() {} disconnect() {} },
|
|
||||||
});
|
|
||||||
dom.window.requestAnimationFrame = callback => dom.window.setTimeout(() => callback(0), 0);
|
|
||||||
dom.window.cancelAnimationFrame = id => dom.window.clearTimeout(id);
|
|
||||||
const renderer = await createDomRenderer(dom.document);
|
|
||||||
const noop = () => {};
|
|
||||||
const calls: string[] = [];
|
|
||||||
const paneId = "tree-activation";
|
|
||||||
const directoryLink: SftpFileEntry = { ...entry("linked-docs", "symlink"), linkTarget: "directory" };
|
|
||||||
const fileLink: SftpFileEntry = { ...entry("linked-file", "symlink"), linkTarget: "file" };
|
|
||||||
const files = [entry("docs", "directory"), entry("notes.txt", "file"), directoryLink, fileLink];
|
|
||||||
let props: React.ComponentProps<typeof SftpPaneTreeView> = {
|
|
||||||
pane: { ...createEmptyPane(paneId), files,
|
|
||||||
connection: { id: "conn", hostId: "host", hostLabel: "Host", isLocal: false, status: "connected", currentPath: "/root" } },
|
|
||||||
side: "left", onPrepareSelection: noop,
|
|
||||||
onLoadChildren: async path => { calls.push(`load:${path}`); return [entry("nested", "directory")]; },
|
|
||||||
onMoveEntriesToPath: async () => {},
|
|
||||||
onNavigateUp: () => calls.push("up"),
|
|
||||||
onNavigateTo: path => calls.push(`navigate:${path}`),
|
|
||||||
onOpenEntry: (item, path) => { assert.equal(item, files.find(file => file.name === item.name)); calls.push(`open:${path}`); },
|
|
||||||
onRefresh: noop, onDragStart: noop, onDragEnd: noop, openRenameDialog: noop, openDeleteConfirm: noop,
|
|
||||||
onCopyToOtherPane: noop, onReceiveFromOtherPane: noop, draggedFiles: null,
|
|
||||||
openNewFolderDialog: noop, openNewFileDialog: noop,
|
|
||||||
sorting: { sortField: "name", sortOrder: "asc", directoriesFirst: true,
|
|
||||||
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
|
|
||||||
visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
|
|
||||||
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop },
|
|
||||||
reloadRequest: { token: 0 },
|
|
||||||
};
|
|
||||||
const row = (name: string) => {
|
|
||||||
const element = renderer.container.querySelector(`[data-entry-name="${name}"]`);
|
|
||||||
assert.ok(element, `missing row ${name}`);
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
const click = async (target: Element, type = "click", detail = 1, ctrlKey = false) => {
|
|
||||||
await act(async () => { target.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, detail, ctrlKey })); });
|
|
||||||
};
|
|
||||||
const doubleClick = async (target: Element) => {
|
|
||||||
await click(target);
|
|
||||||
await click(target, "click", 2);
|
|
||||||
await click(target, "dblclick", 2);
|
|
||||||
};
|
|
||||||
const enter = async () => {
|
|
||||||
await act(async () => { dom.document.activeElement!.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); });
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await renderer.render(React.createElement(SftpPaneTreeView, props));
|
|
||||||
await click(row("docs"));
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
assert.equal(row("docs").getAttribute("data-selected"), "true");
|
|
||||||
await click(row("notes.txt"), "click", 1, true);
|
|
||||||
assert.equal(row("docs").getAttribute("data-selected"), "true");
|
|
||||||
assert.equal(row("notes.txt").getAttribute("data-selected"), "true");
|
|
||||||
|
|
||||||
await doubleClick(row("docs").querySelector("span.truncate")!);
|
|
||||||
assert.deepEqual(calls.splice(0), ["navigate:/root/docs"]);
|
|
||||||
assert.equal(row("docs").getAttribute("data-expanded"), "false");
|
|
||||||
assert.equal(row("docs").getAttribute("data-selected"), "true");
|
|
||||||
assert.equal(row("notes.txt").getAttribute("data-selected"), "false");
|
|
||||||
|
|
||||||
await click(row("docs").querySelector(".lucide-chevron-right")!);
|
|
||||||
assert.deepEqual(calls.splice(0), ["load:/root/docs"]);
|
|
||||||
assert.equal(row("docs").getAttribute("data-expanded"), "true");
|
|
||||||
row("nested");
|
|
||||||
await doubleClick(row("docs").querySelector(".lucide-chevron-right")!);
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
assert.equal(row("docs").getAttribute("data-expanded"), "true");
|
|
||||||
assert.equal(row("docs").getAttribute("data-selected"), "true");
|
|
||||||
|
|
||||||
await doubleClick(row("nested"));
|
|
||||||
assert.deepEqual(calls.splice(0), ["navigate:/root/docs/nested"]);
|
|
||||||
assert.equal(row("docs").getAttribute("data-expanded"), "true");
|
|
||||||
await doubleClick(row("docs"));
|
|
||||||
assert.deepEqual(calls.splice(0), ["navigate:/root/docs"]);
|
|
||||||
assert.equal(row("docs").getAttribute("data-expanded"), "true");
|
|
||||||
await enter();
|
|
||||||
assert.equal(row("docs").getAttribute("data-expanded"), "false");
|
|
||||||
await enter();
|
|
||||||
assert.equal(row("docs").getAttribute("data-expanded"), "true");
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
|
|
||||||
for (const [name, action] of [["notes.txt", "open:/root/notes.txt"], ["linked-docs", "navigate:/root/linked-docs"], ["linked-file", "open:/root/linked-file"], ["..", "up"]]) {
|
|
||||||
await doubleClick(row(name));
|
|
||||||
assert.deepEqual(calls.splice(0), [action]);
|
|
||||||
}
|
|
||||||
await click(row("notes.txt"));
|
|
||||||
await enter();
|
|
||||||
assert.deepEqual(calls.splice(0), ["open:/root/notes.txt"]);
|
|
||||||
|
|
||||||
// The application commits navigation by updating the pane root. Keep its
|
|
||||||
// existing reset behavior for cached children, expansion and selection.
|
|
||||||
props = { ...props, pane: { ...props.pane, files: [entry("inside", "file")],
|
|
||||||
connection: { ...props.pane.connection!, currentPath: "/root/docs" } } };
|
|
||||||
await renderer.render(React.createElement(SftpPaneTreeView, props));
|
|
||||||
row("inside");
|
|
||||||
assert.equal(renderer.container.querySelector('[data-entry-name="nested"]'), null);
|
|
||||||
assert.equal(sftpTreeSelectionStore.getPaneState(paneId).selectedPaths.size, 0);
|
|
||||||
} finally {
|
|
||||||
await renderer.unmount();
|
|
||||||
sftpTreeSelectionStore.clearPane(paneId);
|
|
||||||
if (previousObserver) Object.defineProperty(globalThis, "ResizeObserver", previousObserver);
|
|
||||||
else Reflect.deleteProperty(globalThis, "ResizeObserver");
|
|
||||||
dom.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -67,16 +67,15 @@ export const TreeNode = React.memo<TreeNodeProps>(({
|
|||||||
)}
|
)}
|
||||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||||
onClick={e => onNodeClick(entry, entryPath, e)}
|
onClick={e => onNodeClick(entry, entryPath, e)}
|
||||||
onDoubleClick={() => onOpenEntry(entry, entryPath)}
|
onDoubleClick={() => {
|
||||||
|
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
|
||||||
|
if (isDir) void onToggleExpand(entry, entryPath);
|
||||||
|
else onOpenEntry(entry, entryPath);
|
||||||
|
}}
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
if (isParentEntry) {
|
if (!isParentEntry) {
|
||||||
// The shared menu still holds the previous real entry. Do not let
|
onContextMenu(entry, entryPath, e);
|
||||||
// parent navigation reopen actions for that stale target.
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
onContextMenu(entry, entryPath, e);
|
|
||||||
}}
|
}}
|
||||||
draggable={!isParentEntry}
|
draggable={!isParentEntry}
|
||||||
onDragStart={e => { if (!isParentEntry) onDragStart(entry, entryPath, isDir, e); }}
|
onDragStart={e => { if (!isParentEntry) onDragStart(entry, entryPath, isDir, e); }}
|
||||||
|
|||||||
@@ -50,14 +50,12 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
openRenameDialog,
|
openRenameDialog,
|
||||||
openDeleteConfirm,
|
openDeleteConfirm,
|
||||||
onCopyToOtherPane,
|
onCopyToOtherPane,
|
||||||
onDuplicateEntries,
|
|
||||||
onReceiveFromOtherPane,
|
onReceiveFromOtherPane,
|
||||||
onOpenFileWithSystemDefault,
|
onOpenFileWithSystemDefault,
|
||||||
onOpenFileWith,
|
onOpenFileWith,
|
||||||
onEditFile,
|
onEditFile,
|
||||||
onDownloadFile,
|
onDownloadFile,
|
||||||
onExtractArchive,
|
onExtractArchive,
|
||||||
onCreateArchive,
|
|
||||||
onEditPermissions,
|
onEditPermissions,
|
||||||
draggedFiles,
|
draggedFiles,
|
||||||
openNewFolderDialog,
|
openNewFolderDialog,
|
||||||
@@ -205,8 +203,6 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
onDragEndRef.current = onDragEnd;
|
onDragEndRef.current = onDragEnd;
|
||||||
const onCopyToOtherPaneRef = useRef(onCopyToOtherPane);
|
const onCopyToOtherPaneRef = useRef(onCopyToOtherPane);
|
||||||
onCopyToOtherPaneRef.current = onCopyToOtherPane;
|
onCopyToOtherPaneRef.current = onCopyToOtherPane;
|
||||||
const onDuplicateEntriesRef = useRef(onDuplicateEntries);
|
|
||||||
onDuplicateEntriesRef.current = onDuplicateEntries;
|
|
||||||
const onReceiveFromOtherPaneRef = useRef(onReceiveFromOtherPane);
|
const onReceiveFromOtherPaneRef = useRef(onReceiveFromOtherPane);
|
||||||
onReceiveFromOtherPaneRef.current = onReceiveFromOtherPane;
|
onReceiveFromOtherPaneRef.current = onReceiveFromOtherPane;
|
||||||
const onOpenFileWithSystemDefaultRef = useRef(onOpenFileWithSystemDefault);
|
const onOpenFileWithSystemDefaultRef = useRef(onOpenFileWithSystemDefault);
|
||||||
@@ -219,8 +215,6 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
onDownloadFileRef.current = onDownloadFile;
|
onDownloadFileRef.current = onDownloadFile;
|
||||||
const onExtractArchiveRef = useRef(onExtractArchive);
|
const onExtractArchiveRef = useRef(onExtractArchive);
|
||||||
onExtractArchiveRef.current = onExtractArchive;
|
onExtractArchiveRef.current = onExtractArchive;
|
||||||
const onCreateArchiveRef = useRef(onCreateArchive);
|
|
||||||
onCreateArchiveRef.current = onCreateArchive;
|
|
||||||
const onEditPermissionsRef = useRef(onEditPermissions);
|
const onEditPermissionsRef = useRef(onEditPermissions);
|
||||||
onEditPermissionsRef.current = onEditPermissions;
|
onEditPermissionsRef.current = onEditPermissions;
|
||||||
const openRenameDialogRef = useRef(openRenameDialog);
|
const openRenameDialogRef = useRef(openRenameDialog);
|
||||||
@@ -944,14 +938,12 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
|||||||
setShowMoveToDialog,
|
setShowMoveToDialog,
|
||||||
tRef,
|
tRef,
|
||||||
onCopyToOtherPaneRef,
|
onCopyToOtherPaneRef,
|
||||||
onDuplicateEntriesRef,
|
|
||||||
onNavigateToRef,
|
onNavigateToRef,
|
||||||
onOpenFileWithSystemDefaultRef,
|
onOpenFileWithSystemDefaultRef,
|
||||||
onOpenFileWithRef,
|
onOpenFileWithRef,
|
||||||
onEditFileRef,
|
onEditFileRef,
|
||||||
onDownloadFileRef,
|
onDownloadFileRef,
|
||||||
onExtractArchiveRef,
|
onExtractArchiveRef,
|
||||||
onCreateArchiveRef,
|
|
||||||
onEditPermissionsRef,
|
onEditPermissionsRef,
|
||||||
openDeleteConfirmRef,
|
openDeleteConfirmRef,
|
||||||
openRenameDialogRef,
|
openRenameDialogRef,
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ export interface SftpPaneTreeViewProps {
|
|||||||
openRenameDialog: (entryPath: string) => void;
|
openRenameDialog: (entryPath: string) => void;
|
||||||
openDeleteConfirm: (targets: string[]) => void;
|
openDeleteConfirm: (targets: string[]) => void;
|
||||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||||
onDuplicateEntries?: (files: SftpTransferSource[]) => void;
|
|
||||||
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
||||||
onOpenFileWithSystemDefault?: (entry: SftpFileEntry, fullPath?: string) => void;
|
onOpenFileWithSystemDefault?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
|
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||||
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||||
onCreateArchive?: (sourcePaths: string[]) => void;
|
|
||||||
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
|
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||||
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
|
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
|
||||||
openNewFolderDialog: (targetPath: string) => void;
|
openNewFolderDialog: (targetPath: string) => void;
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { useRenderTracker } from "../../lib/useRenderTracker";
|
|||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { SftpPaneDialogs } from "./SftpPaneDialogs";
|
import { SftpPaneDialogs } from "./SftpPaneDialogs";
|
||||||
import { SftpClipboardUploadDialog } from "./SftpClipboardUploadDialog";
|
import { SftpClipboardUploadDialog } from "./SftpClipboardUploadDialog";
|
||||||
import { SftpCreateArchiveDialog } from "./SftpCreateArchiveDialog";
|
|
||||||
import { useSftpArchiveDialog } from "../../application/state/sftp/useSftpArchiveDialog";
|
|
||||||
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
|
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
|
||||||
import { SftpPaneFileList } from "./SftpPaneFileList";
|
import { SftpPaneFileList } from "./SftpPaneFileList";
|
||||||
import { SftpPaneToolbar } from "./SftpPaneToolbar";
|
import { SftpPaneToolbar } from "./SftpPaneToolbar";
|
||||||
@@ -155,16 +153,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
}
|
}
|
||||||
}, [requestTreeReload]);
|
}, [requestTreeReload]);
|
||||||
|
|
||||||
const archiveDialog = useSftpArchiveDialog({
|
|
||||||
paneId: pane.id,
|
|
||||||
connectionId: pane.connection?.id,
|
|
||||||
isLocal: !!pane.connection?.isLocal,
|
|
||||||
isActive: !!isActive,
|
|
||||||
t,
|
|
||||||
onCreateArchive: callbacks.onCreateArchive,
|
|
||||||
onCreated: (directory) => requestNestedTreeReload([directory]),
|
|
||||||
});
|
|
||||||
|
|
||||||
useRenderTracker(`SftpPaneView[${side}]`, {
|
useRenderTracker(`SftpPaneView[${side}]`, {
|
||||||
side,
|
side,
|
||||||
paneId: pane.id,
|
paneId: pane.id,
|
||||||
@@ -648,14 +636,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
openRenameDialog={openRenameDialog}
|
openRenameDialog={openRenameDialog}
|
||||||
openDeleteConfirm={openDeleteConfirm}
|
openDeleteConfirm={openDeleteConfirm}
|
||||||
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
||||||
onDuplicateEntries={callbacks.onDuplicateEntries}
|
|
||||||
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
|
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
|
||||||
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
||||||
onOpenFileWith={callbacks.onOpenFileWith}
|
onOpenFileWith={callbacks.onOpenFileWith}
|
||||||
onEditFile={callbacks.onEditFile}
|
onEditFile={callbacks.onEditFile}
|
||||||
onDownloadFile={callbacks.onDownloadFile}
|
onDownloadFile={callbacks.onDownloadFile}
|
||||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||||
onCreateArchive={callbacks.onCreateArchive && !pane.connection?.isLocal ? archiveDialog.open : undefined}
|
|
||||||
onEditPermissions={callbacks.onEditPermissions}
|
onEditPermissions={callbacks.onEditPermissions}
|
||||||
draggedFiles={draggedFiles}
|
draggedFiles={draggedFiles}
|
||||||
openNewFolderDialog={openNewFolderDialogAtPath}
|
openNewFolderDialog={openNewFolderDialogAtPath}
|
||||||
@@ -701,7 +687,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
handleRowDragLeave={handleRowDragLeave}
|
handleRowDragLeave={handleRowDragLeave}
|
||||||
handleEntryDrop={handleEntryDrop}
|
handleEntryDrop={handleEntryDrop}
|
||||||
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
||||||
onDuplicateEntries={callbacks.onDuplicateEntries}
|
|
||||||
onMoveEntriesToPath={handleMoveEntriesToPath}
|
onMoveEntriesToPath={handleMoveEntriesToPath}
|
||||||
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
||||||
onOpenFileWith={callbacks.onOpenFileWith}
|
onOpenFileWith={callbacks.onOpenFileWith}
|
||||||
@@ -709,7 +694,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
onDownloadFile={callbacks.onDownloadFile}
|
onDownloadFile={callbacks.onDownloadFile}
|
||||||
onDownloadFiles={callbacks.onDownloadFiles}
|
onDownloadFiles={callbacks.onDownloadFiles}
|
||||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||||
onCreateArchive={callbacks.onCreateArchive && !pane.connection?.isLocal ? archiveDialog.open : undefined}
|
|
||||||
onEditPermissions={callbacks.onEditPermissions}
|
onEditPermissions={callbacks.onEditPermissions}
|
||||||
onUploadExternalFileList={handleUploadExternalFileList}
|
onUploadExternalFileList={handleUploadExternalFileList}
|
||||||
onUploadExternalFolder={handleUploadExternalFolder}
|
onUploadExternalFolder={handleUploadExternalFolder}
|
||||||
@@ -722,15 +706,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SftpCreateArchiveDialog
|
|
||||||
state={archiveDialog.state}
|
|
||||||
t={t}
|
|
||||||
onClose={archiveDialog.close}
|
|
||||||
onNameChange={archiveDialog.setArchiveName}
|
|
||||||
onFormatChange={archiveDialog.setFormat}
|
|
||||||
onSubmit={archiveDialog.submit}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SftpPaneDialogs
|
<SftpPaneDialogs
|
||||||
t={t}
|
t={t}
|
||||||
hostLabel={pane.connection?.hostLabel}
|
hostLabel={pane.connection?.hostLabel}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
/** @test issue #4132 — the tree's ".." row must not be offered as a Duplicate target */
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import React, { act } from "react";
|
|
||||||
import { TreeNode } from "./SftpPaneTreeNode.tsx";
|
|
||||||
import { installDomEnvironment, createDomRenderer } from "../test-support/renderReactDom";
|
|
||||||
import { isSftpTreeParentEntry } from "./useSftpPaneTreeContextMenu.tsx";
|
|
||||||
|
|
||||||
test("only the synthetic parent row is treated as a navigation entry", () => {
|
|
||||||
assert.equal(isSftpTreeParentEntry({ name: ".." }), true);
|
|
||||||
assert.equal(isSftpTreeParentEntry({ name: "." }), false);
|
|
||||||
assert.equal(isSftpTreeParentEntry({ name: "docs" }), false);
|
|
||||||
assert.equal(isSftpTreeParentEntry({ name: "..hidden" }), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parent navigation cannot reopen the previous entry's shared context menu", async () => {
|
|
||||||
const dom = installDomEnvironment();
|
|
||||||
const renderer = await createDomRenderer(dom.document);
|
|
||||||
const openedTargets: string[] = [];
|
|
||||||
let contextTarget = "";
|
|
||||||
const noop = () => {};
|
|
||||||
const renderRow = async (name: string) => {
|
|
||||||
const props: React.ComponentProps<typeof TreeNode> = {
|
|
||||||
entry: { name, type: "directory", size: 0, sizeFormatted: "--", lastModified: 0, lastModifiedFormatted: "--" },
|
|
||||||
entryPath: `/root/${name}`, depth: 0, columnTemplate: "1fr",
|
|
||||||
visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
|
|
||||||
isSelected: false, isExpanded: false, isLoading: false, isDragOver: false,
|
|
||||||
onToggleExpand: noop, onNodeClick: noop, onOpenEntry: noop,
|
|
||||||
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
|
|
||||||
onContextMenu: item => { contextTarget = item.name; },
|
|
||||||
};
|
|
||||||
await renderer.render(React.createElement("div", {
|
|
||||||
onContextMenu: () => { openedTargets.push(contextTarget); },
|
|
||||||
}, React.createElement(TreeNode, props)));
|
|
||||||
const event = new dom.window.MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
|
||||||
await act(async () => {
|
|
||||||
renderer.container.querySelector('[data-entry-name]')!.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
return event;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await renderRow("photos");
|
|
||||||
assert.deepEqual(openedTargets, ["photos"]);
|
|
||||||
const parentEvent = await renderRow("..");
|
|
||||||
assert.deepEqual(openedTargets, ["photos"], "parent must not reopen the stale photos menu");
|
|
||||||
assert.equal(parentEvent.defaultPrevented, true);
|
|
||||||
await renderRow("docs");
|
|
||||||
assert.deepEqual(openedTargets, ["photos", "docs"], "ordinary entry menus still work");
|
|
||||||
} finally {
|
|
||||||
await renderer.unmount();
|
|
||||||
dom.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -19,8 +19,6 @@ import { toEditorTabId, activeTabStore } from "../../../application/state/active
|
|||||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
|
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
|
||||||
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
|
import { assertSftpFileFitsBuiltinEditor } from "../sftpEditorFileLimits";
|
||||||
import { createSftpArchiveForPane } from "../../../application/state/sftp/createSftpArchive";
|
|
||||||
import type { SftpArchiveCreationOptions, SftpArchiveCreationTarget } from "../../../domain/sftpArchiveCreation";
|
|
||||||
|
|
||||||
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
|
/** Local multi-select blob downloads read whole files into ArrayBuffers. */
|
||||||
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
|
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
|
||||||
@@ -661,33 +659,6 @@ export const useSftpViewFileOps = ({
|
|||||||
[handleExtractArchiveForSide],
|
[handleExtractArchiveForSide],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateArchiveForSide = useCallback(
|
|
||||||
async (side: "left" | "right", options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => {
|
|
||||||
const result = await createSftpArchiveForPane({
|
|
||||||
getPane: () => side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane,
|
|
||||||
target,
|
|
||||||
options,
|
|
||||||
bridge: netcattyBridge.get(),
|
|
||||||
getSftpIdForConnection,
|
|
||||||
refresh: (tabId) => sftpRef.current.refresh(side, { tabId }),
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(t("sftp.archive.success", { fileName: options.archiveName }), "SFTP");
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
[getSftpIdForConnection, sftpRef, t],
|
|
||||||
);
|
|
||||||
const onCreateArchiveLeft = useCallback(
|
|
||||||
(options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => handleCreateArchiveForSide("left", options, target),
|
|
||||||
[handleCreateArchiveForSide],
|
|
||||||
);
|
|
||||||
const onCreateArchiveRight = useCallback(
|
|
||||||
(options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => handleCreateArchiveForSide("right", options, target),
|
|
||||||
[handleCreateArchiveForSide],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Multi-file download. For local panes, each file auto-downloads as a blob
|
// Multi-file download. For local panes, each file auto-downloads as a blob
|
||||||
// (no prompt). For remote panes, prompts for a target directory once and
|
// (no prompt). For remote panes, prompts for a target directory once and
|
||||||
// streams all selected entries into it — avoids the per-file save dialog
|
// streams all selected entries into it — avoids the per-file save dialog
|
||||||
@@ -904,8 +875,6 @@ export const useSftpViewFileOps = ({
|
|||||||
onDownloadFileRight,
|
onDownloadFileRight,
|
||||||
onExtractArchiveLeft,
|
onExtractArchiveLeft,
|
||||||
onExtractArchiveRight,
|
onExtractArchiveRight,
|
||||||
onCreateArchiveLeft,
|
|
||||||
onCreateArchiveRight,
|
|
||||||
onDownloadFilesLeft,
|
onDownloadFilesLeft,
|
||||||
onDownloadFilesRight,
|
onDownloadFilesRight,
|
||||||
onUploadExternalFilesLeft,
|
onUploadExternalFilesLeft,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../t
|
|||||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||||
import type { SftpArchiveCreationOptions, SftpArchiveCreationResult, SftpArchiveCreationTarget } from "../../../domain/sftpArchiveCreation";
|
|
||||||
|
|
||||||
export interface UseSftpViewFileOpsParams {
|
export interface UseSftpViewFileOpsParams {
|
||||||
sftpRef: MutableRefObject<SftpStateApi>;
|
sftpRef: MutableRefObject<SftpStateApi>;
|
||||||
@@ -88,8 +87,6 @@ export interface UseSftpViewFileOpsResult {
|
|||||||
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||||
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
onExtractArchiveLeft: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||||
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
onExtractArchiveRight: (file: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||||
onCreateArchiveLeft: (options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => Promise<SftpArchiveCreationResult>;
|
|
||||||
onCreateArchiveRight: (options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => Promise<SftpArchiveCreationResult>;
|
|
||||||
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
|
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
|
||||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ interface UseSftpViewPaneActionsResult {
|
|||||||
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
|
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
|
||||||
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
|
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
|
||||||
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
|
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
|
||||||
onDuplicateEntriesLeft: (files: SftpTransferSource[]) => void;
|
|
||||||
onDuplicateEntriesRight: (files: SftpTransferSource[]) => void;
|
|
||||||
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
|
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
|
||||||
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
|
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
|
||||||
}
|
}
|
||||||
@@ -147,43 +145,6 @@ export const useSftpViewPaneActions = ({
|
|||||||
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
|
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
|
||||||
[startGroupedTransfer],
|
[startGroupedTransfer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const startDuplicateTransfer = useCallback(
|
|
||||||
(files: SftpTransferSource[], side: "left" | "right") => {
|
|
||||||
const groups = new Map<string, SftpTransferSource[]>();
|
|
||||||
for (const file of files) {
|
|
||||||
// ".." is the tree's parent-navigation row, not a real entry.
|
|
||||||
if (!file.sourcePath || file.name === "..") continue;
|
|
||||||
const key = `${file.sourceConnectionId ?? ""}::${file.sourcePath}`;
|
|
||||||
const group = groups.get(key) ?? [];
|
|
||||||
group.push(file);
|
|
||||||
groups.set(key, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const group of groups.values()) {
|
|
||||||
const [{ sourceConnectionId, sourcePath }] = group;
|
|
||||||
if (!sourcePath) continue;
|
|
||||||
// Same pane, same folder: every task conflicts with its source name
|
|
||||||
// and the duplicate option resolves it to "<name> (copy)".
|
|
||||||
void sftpRef.current.startTransfer(group, side, side, {
|
|
||||||
sourceConnectionId,
|
|
||||||
sourcePath,
|
|
||||||
targetPath: sourcePath,
|
|
||||||
duplicate: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sftpRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDuplicateEntriesLeft = useCallback(
|
|
||||||
(files: SftpTransferSource[]) => startDuplicateTransfer(files, "left"),
|
|
||||||
[startDuplicateTransfer],
|
|
||||||
);
|
|
||||||
const onDuplicateEntriesRight = useCallback(
|
|
||||||
(files: SftpTransferSource[]) => startDuplicateTransfer(files, "right"),
|
|
||||||
[startDuplicateTransfer],
|
|
||||||
);
|
|
||||||
const onReceiveFromOtherPaneLeft = useCallback(
|
const onReceiveFromOtherPaneLeft = useCallback(
|
||||||
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
|
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
|
||||||
[startGroupedTransfer],
|
[startGroupedTransfer],
|
||||||
@@ -439,8 +400,6 @@ export const useSftpViewPaneActions = ({
|
|||||||
onMoveEntriesToPathRight,
|
onMoveEntriesToPathRight,
|
||||||
onCopyToOtherPaneLeft,
|
onCopyToOtherPaneLeft,
|
||||||
onCopyToOtherPaneRight,
|
onCopyToOtherPaneRight,
|
||||||
onDuplicateEntriesLeft,
|
|
||||||
onDuplicateEntriesRight,
|
|
||||||
onReceiveFromOtherPaneLeft,
|
onReceiveFromOtherPaneLeft,
|
||||||
onReceiveFromOtherPaneRight,
|
onReceiveFromOtherPaneRight,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -151,7 +151,6 @@ export const useSftpViewPaneCallbacks = ({
|
|||||||
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
|
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
|
||||||
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
|
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
|
||||||
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
|
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
|
||||||
onDuplicateEntries: paneActions.onDuplicateEntriesLeft,
|
|
||||||
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
|
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
|
||||||
onEditPermissions: fileOps.onEditPermissionsLeft,
|
onEditPermissions: fileOps.onEditPermissionsLeft,
|
||||||
onEditFile: fileOps.onEditFileLeft,
|
onEditFile: fileOps.onEditFileLeft,
|
||||||
@@ -161,7 +160,6 @@ export const useSftpViewPaneCallbacks = ({
|
|||||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||||
onExtractArchive: fileOps.onExtractArchiveLeft,
|
onExtractArchive: fileOps.onExtractArchiveLeft,
|
||||||
onCreateArchive: fileOps.onCreateArchiveLeft,
|
|
||||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||||
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
||||||
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
||||||
@@ -196,7 +194,6 @@ export const useSftpViewPaneCallbacks = ({
|
|||||||
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
|
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
|
||||||
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
|
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
|
||||||
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
|
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
|
||||||
onDuplicateEntries: paneActions.onDuplicateEntriesRight,
|
|
||||||
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
|
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
|
||||||
onEditPermissions: fileOps.onEditPermissionsRight,
|
onEditPermissions: fileOps.onEditPermissionsRight,
|
||||||
onEditFile: fileOps.onEditFileRight,
|
onEditFile: fileOps.onEditFileRight,
|
||||||
@@ -206,7 +203,6 @@ export const useSftpViewPaneCallbacks = ({
|
|||||||
onDownloadFile: fileOps.onDownloadFileRight,
|
onDownloadFile: fileOps.onDownloadFileRight,
|
||||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||||
onExtractArchive: fileOps.onExtractArchiveRight,
|
onExtractArchive: fileOps.onExtractArchiveRight,
|
||||||
onCreateArchive: fileOps.onCreateArchiveRight,
|
|
||||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||||
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
||||||
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, CopyPlus, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
|
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
|
||||||
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
|
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
|
||||||
import { getParentPath } from '../../application/state/sftp/utils';
|
import { getParentPath } from '../../application/state/sftp/utils';
|
||||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||||
@@ -10,16 +10,13 @@ import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUp
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type SftpPaneTreeContextMenuProps = Record<string, any>;
|
type SftpPaneTreeContextMenuProps = Record<string, any>;
|
||||||
|
|
||||||
/** ".." is the tree's synthetic parent-navigation row, never a real entry. */
|
|
||||||
export const isSftpTreeParentEntry = (entry: { name: string }): boolean => entry.name === '..';
|
|
||||||
|
|
||||||
export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps) {
|
export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps) {
|
||||||
const {
|
const {
|
||||||
contextTarget, pane, toggleExpand, stableOnOpenEntry, stableOnRefresh, getActionPaths, toTransferSources,
|
contextTarget, pane, toggleExpand, stableOnOpenEntry, stableOnRefresh, getActionPaths, toTransferSources,
|
||||||
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
|
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
|
||||||
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
|
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
|
||||||
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onDuplicateEntriesRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
|
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
|
||||||
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onCreateArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
||||||
openNewFolderDialogRef, openNewFileDialogRef,
|
openNewFolderDialogRef, openNewFileDialogRef,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -30,10 +27,6 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
|||||||
const { entry, entryPath } = target;
|
const { entry, entryPath } = target;
|
||||||
const isDir = isNavigableDirectory(entry);
|
const isDir = isNavigableDirectory(entry);
|
||||||
const isLocal = pane.connection?.isLocal;
|
const isLocal = pane.connection?.isLocal;
|
||||||
// The synthetic ".." row navigates to the parent directory; it is not a
|
|
||||||
// real entry, so duplicating it would target an ancestor (e.g. "/") and
|
|
||||||
// could recurse into its own output.
|
|
||||||
const isParentEntry = isSftpTreeParentEntry(entry);
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (isDir) void toggleExpand(entry, entryPath);
|
if (isDir) void toggleExpand(entry, entryPath);
|
||||||
@@ -54,21 +47,6 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
|||||||
onCopyToOtherPaneRef.current(files);
|
onCopyToOtherPaneRef.current(files);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = () => {
|
|
||||||
if (!onDuplicateEntriesRef.current || isParentEntry) return;
|
|
||||||
const paths = getActionPaths(entryPath);
|
|
||||||
const files = toTransferSources(paths);
|
|
||||||
if (files.length === 0) {
|
|
||||||
files.push({
|
|
||||||
name: entry.name,
|
|
||||||
isDirectory: isDir,
|
|
||||||
sourceConnectionId: pane.connection?.id,
|
|
||||||
sourcePath: getParentPath(entryPath),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onDuplicateEntriesRef.current(files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
openDeleteConfirmRef.current(getActionPaths(entryPath));
|
openDeleteConfirmRef.current(getActionPaths(entryPath));
|
||||||
};
|
};
|
||||||
@@ -110,20 +88,10 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
|||||||
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
|
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
{!isLocal && entry.name !== '..' && onCreateArchiveRef?.current && (
|
|
||||||
<ContextMenuItem onClick={() => onCreateArchiveRef.current?.(getActionPaths(entryPath))}>
|
|
||||||
<Archive size={14} className="mr-2" />{tRef.current('sftp.archive.action')}
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onClick={handleCopyToOtherPane}>
|
<ContextMenuItem onClick={handleCopyToOtherPane}>
|
||||||
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
|
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{onDuplicateEntriesRef.current && !isParentEntry && (
|
|
||||||
<ContextMenuItem onClick={handleDuplicate}>
|
|
||||||
<CopyPlus size={14} className="mr-2" />{tRef.current('sftp.context.duplicate')}
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
|
||||||
<ContextMenuItem onClick={() => navigator.clipboard.writeText(entryPath)}>
|
<ContextMenuItem onClick={() => navigator.clipboard.writeText(entryPath)}>
|
||||||
<ClipboardCopy size={14} className="mr-2" />{tRef.current('sftp.context.copyPath')}
|
<ClipboardCopy size={14} className="mr-2" />{tRef.current('sftp.context.copyPath')}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
@@ -212,10 +180,8 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
|||||||
folderUploadEnabled,
|
folderUploadEnabled,
|
||||||
onUploadExternalFolder,
|
onUploadExternalFolder,
|
||||||
onCopyToOtherPaneRef,
|
onCopyToOtherPaneRef,
|
||||||
onDuplicateEntriesRef,
|
|
||||||
onDownloadFileRef,
|
onDownloadFileRef,
|
||||||
onExtractArchiveRef,
|
onExtractArchiveRef,
|
||||||
onCreateArchiveRef,
|
|
||||||
onEditFileRef,
|
onEditFileRef,
|
||||||
onEditPermissionsRef,
|
onEditPermissionsRef,
|
||||||
onNavigateToRef,
|
onNavigateToRef,
|
||||||
|
|||||||
@@ -142,31 +142,6 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
const selectedRef = useRef<HTMLDivElement>(null);
|
const selectedRef = useRef<HTMLDivElement>(null);
|
||||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||||
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
|
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
const hoverClearTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const cancelHoverClear = () => {
|
|
||||||
if (hoverClearTimerRef.current === null) return;
|
|
||||||
window.clearTimeout(hoverClearTimerRef.current);
|
|
||||||
hoverClearTimerRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rows and the detail panel are separate boxes with a small gap. Keep the
|
|
||||||
// hovered row alive briefly so moving the pointer into the detail panel does
|
|
||||||
// not unmount the panel before it can be read, selected, or scrolled.
|
|
||||||
const scheduleHoverClear = () => {
|
|
||||||
cancelHoverClear();
|
|
||||||
hoverClearTimerRef.current = window.setTimeout(() => {
|
|
||||||
hoverClearTimerRef.current = null;
|
|
||||||
setHoveredIndex(-1);
|
|
||||||
}, 400);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
if (hoverClearTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(hoverClearTimerRef.current);
|
|
||||||
hoverClearTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRef.current && listRef.current) {
|
if (selectedRef.current && listRef.current) {
|
||||||
@@ -179,10 +154,6 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
|
|
||||||
// Reset hover when suggestions change
|
// Reset hover when suggestions change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hoverClearTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(hoverClearTimerRef.current);
|
|
||||||
hoverClearTimerRef.current = null;
|
|
||||||
}
|
|
||||||
setHoveredIndex(-1);
|
setHoveredIndex(-1);
|
||||||
}, [suggestions]);
|
}, [suggestions]);
|
||||||
|
|
||||||
@@ -397,40 +368,8 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
color: textColor,
|
color: textColor,
|
||||||
overscrollBehavior: "contain" as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep terminal focus when clicking a panel's padding, but leave the native
|
|
||||||
// scrollbar alone so its thumb can still be dragged. `preventDefault()` on
|
|
||||||
// the scrollbar mousedown made the right-side detail panel impossible to
|
|
||||||
// scroll by dragging.
|
|
||||||
const handlePanelMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const element = event.currentTarget;
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const scrollbarWidth = Math.max(16, element.offsetWidth - element.clientWidth);
|
|
||||||
const overVerticalScrollbar = event.clientX >= rect.right - scrollbarWidth;
|
|
||||||
if (!overVerticalScrollbar) event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePanelWheel = (event: React.WheelEvent<HTMLDivElement>) => {
|
|
||||||
// Keep wheel scrolling inside the popup instead of letting it reach the
|
|
||||||
// terminal's history/font-size handlers.
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keep every inline description starting at the same x. The option column is
|
|
||||||
// sized from the longest suggestion in this popup, capped so descriptions
|
|
||||||
// still get useful width in a narrow terminal pane.
|
|
||||||
const longestCommandLength = suggestions.reduce(
|
|
||||||
(max, suggestion) => Math.max(max, suggestion.displayText.length),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const commandColumnWidth = Math.max(
|
|
||||||
52,
|
|
||||||
Math.min(150, longestCommandLength * 7 + 12, availableWidth * 0.42),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
ref={wrapperRef}
|
||||||
@@ -468,16 +407,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
// Keep the terminal focused when clicking the list's padding or
|
// Keep the terminal focused when clicking the list's padding or
|
||||||
// scrollbar; scrollbar dragging is left to the browser.
|
// scrollbar (row clicks handle this themselves).
|
||||||
onMouseDown={handlePanelMouseDown}
|
onMouseDown={(e) => {
|
||||||
onWheel={handlePanelWheel}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{suggestions.map((suggestion, index) => {
|
{suggestions.map((suggestion, index) => {
|
||||||
const isSelected = index === selectedIndex;
|
const isSelected = index === selectedIndex;
|
||||||
const isHovered = index === hoveredIndex;
|
const isHovered = index === hoveredIndex;
|
||||||
const sourceInfo = SOURCE_LABELS[suggestion.source];
|
const sourceInfo = SOURCE_LABELS[suggestion.source];
|
||||||
const hasInlineDescription = suggestion.source !== "snippet"
|
|
||||||
&& Boolean(suggestion.description);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -495,11 +434,8 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
gap: "8px",
|
gap: "8px",
|
||||||
lineHeight: "1.4",
|
lineHeight: "1.4",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => setHoveredIndex(index)}
|
||||||
cancelHoverClear();
|
onMouseLeave={() => setHoveredIndex(-1)}
|
||||||
setHoveredIndex(index);
|
|
||||||
}}
|
|
||||||
onMouseLeave={scheduleHoverClear}
|
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -532,15 +468,10 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Command text. A shared fixed column keeps every description
|
{/* Command text */}
|
||||||
aligned on the same left edge instead of following each
|
|
||||||
variable-width option label. */}
|
|
||||||
<span
|
<span
|
||||||
title={hasInlineDescription ? suggestion.displayText : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
flex: hasInlineDescription ? "0 0 auto" : "1 1 auto",
|
flex: 1,
|
||||||
width: hasInlineDescription ? `${commandColumnWidth}px` : undefined,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
@@ -553,16 +484,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
|
|
||||||
{/* Inline description (truncated). Snippets show only their label
|
{/* Inline description (truncated). Snippets show only their label
|
||||||
in the row — the full command lives in the detail preview. */}
|
in the row — the full command lives in the detail preview. */}
|
||||||
{hasInlineDescription && (
|
{suggestion.source !== "snippet" && suggestion.description && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 auto",
|
|
||||||
minWidth: 0,
|
|
||||||
fontSize: "11px",
|
fontSize: "11px",
|
||||||
color: dimTextColor,
|
color: dimTextColor,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: "160px",
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{suggestion.description}
|
{suggestion.description}
|
||||||
@@ -618,9 +549,11 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
// Keep the terminal focused when clicking the panel's padding or
|
// Keep the terminal focused when clicking the panel's padding or
|
||||||
// scrollbar; scrollbar dragging is left to the browser.
|
// scrollbar (row clicks handle this themselves).
|
||||||
onMouseDown={handlePanelMouseDown}
|
onMouseDown={(e) => {
|
||||||
onWheel={handlePanelWheel}
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{panel.entries.map((entry, idx) => {
|
{panel.entries.map((entry, idx) => {
|
||||||
const isFocused = level === subDirFocusLevel;
|
const isFocused = level === subDirFocusLevel;
|
||||||
@@ -682,23 +615,15 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
|||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
alignSelf: renderUpward ? "flex-end" : "flex-start",
|
alignSelf: renderUpward ? "flex-end" : "flex-start",
|
||||||
visibility: detailVisible ? "visible" : "hidden",
|
visibility: detailVisible ? "visible" : "hidden",
|
||||||
// The detail text can be selected and copied; use text cursor and
|
|
||||||
// native selection instead of forcing terminal focus.
|
|
||||||
userSelect: "text",
|
|
||||||
cursor: "text",
|
|
||||||
// visibility:hidden already skips hit testing, but keep the
|
// visibility:hidden already skips hit testing, but keep the
|
||||||
// hidden reserve explicitly non-interactive so the mounted
|
// hidden reserve explicitly non-interactive so the mounted
|
||||||
// panel can never swallow clicks meant for the terminal.
|
// panel can never swallow clicks meant for the terminal.
|
||||||
pointerEvents: detailVisible ? "auto" : "none",
|
pointerEvents: detailVisible ? "auto" : "none",
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(e) => {
|
||||||
// Do not preventDefault: that would block selecting the long
|
e.preventDefault();
|
||||||
// description or dragging the panel's scrollbar.
|
e.stopPropagation();
|
||||||
event.stopPropagation();
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={cancelHoverClear}
|
|
||||||
onMouseLeave={scheduleHoverClear}
|
|
||||||
onWheel={handlePanelWheel}
|
|
||||||
>
|
>
|
||||||
{detailVisible && detailItem && (
|
{detailVisible && detailItem && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -154,7 +154,6 @@ export const COMMON_FIG_SPECS = [
|
|||||||
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
|
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
|
||||||
"vim", "nano", "less", "head", "tail", "sort",
|
"vim", "nano", "less", "head", "tail", "sort",
|
||||||
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
|
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
|
||||||
"ln", "stat", "wc", "cut", "uniq", "tee", "xargs",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const ROOT = path.resolve(HERE, "../..");
|
|
||||||
const SPEC_DIR = path.join(ROOT, "node_modules/@withfig/autocomplete/build");
|
|
||||||
const DICT_DIR = path.join(HERE, "autocomplete");
|
|
||||||
const COMMANDS = ["zip", "unzip", "make", "nano"] as const;
|
|
||||||
|
|
||||||
const specs = Object.fromEntries(
|
|
||||||
await Promise.all(
|
|
||||||
COMMANDS.map(async (name) => {
|
|
||||||
const mod = await import(pathToFileURL(path.join(SPEC_DIR, `${name}.js`)).href);
|
|
||||||
return [name, mod.default?.default ?? mod.default] as const;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
) as Record<(typeof COMMANDS)[number], FigSpec>;
|
|
||||||
|
|
||||||
const simplified = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-CN.json"), "utf8"),
|
|
||||||
) as Record<string, string>;
|
|
||||||
const traditional = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-TW.json"), "utf8"),
|
|
||||||
) as Record<string, string>;
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "localStorage", {
|
|
||||||
value: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {},
|
|
||||||
removeItem: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: {
|
|
||||||
netcatty: {
|
|
||||||
listFigSpecs: async () => [...COMMANDS],
|
|
||||||
loadFigSpec: async (commandName: string) =>
|
|
||||||
specs[commandName as (typeof COMMANDS)[number]] ?? null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
|
||||||
const { localizeSpecSuggestionDescriptions } = await import(
|
|
||||||
"./autocomplete/autocompleteDescriptionI18n.ts"
|
|
||||||
);
|
|
||||||
|
|
||||||
function collectDescriptions(node: unknown, out = new Set<string>(), seen = new WeakSet<object>()): Set<string> {
|
|
||||||
if (!node || typeof node !== "object" || seen.has(node)) return out;
|
|
||||||
seen.add(node);
|
|
||||||
const record = node as { description?: unknown };
|
|
||||||
if (typeof record.description === "string" && record.description.trim()) {
|
|
||||||
out.add(record.description);
|
|
||||||
}
|
|
||||||
for (const value of Object.values(node)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) collectDescriptions(item, out, seen);
|
|
||||||
} else if (value && typeof value === "object") {
|
|
||||||
collectDescriptions(value, out, seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchDescriptions = [...new Set(COMMANDS.flatMap((name) => [...collectDescriptions(specs[name])]))];
|
|
||||||
|
|
||||||
function suggestionNamed(
|
|
||||||
suggestions: Awaited<ReturnType<typeof getCompletions>>,
|
|
||||||
displayText: string,
|
|
||||||
) {
|
|
||||||
return suggestions.find((suggestion) => suggestion.displayText === displayText);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("batch specs have complete zh-CN and zh-TW description coverage", async () => {
|
|
||||||
assert.ok(batchDescriptions.length >= 32);
|
|
||||||
for (const description of batchDescriptions) {
|
|
||||||
assert.equal(typeof simplified[description], "string", `missing zh-CN: ${description}`);
|
|
||||||
assert.ok(simplified[description].length > 0, `empty zh-CN: ${description}`);
|
|
||||||
assert.equal(typeof traditional[description], "string", `missing zh-TW: ${description}`);
|
|
||||||
assert.ok(traditional[description].length > 0, `empty zh-TW: ${description}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const localized = await localizeSpecSuggestionDescriptions(
|
|
||||||
batchDescriptions.map((description) => ({ text: description, description })),
|
|
||||||
"zh-CN",
|
|
||||||
);
|
|
||||||
for (const [index, suggestion] of localized.entries()) {
|
|
||||||
assert.equal(suggestion.text, batchDescriptions[index]);
|
|
||||||
assert.notEqual(suggestion.description, batchDescriptions[index]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("batch zh-TW spot checks cover corrected semantics", () => {
|
|
||||||
assert.equal(
|
|
||||||
traditional["Archive a directory and its contents with the highest level [9] of compression"],
|
|
||||||
"使用最高壓縮級別 9",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Extract a zip file created in windows, containing files with non-ascii (chinese) filenames"],
|
|
||||||
"上游選項疑似錯誤,請核對當前 unzip 版本支援的引數",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Turn on value of next option"],
|
|
||||||
"啟用指定的 NeXT 相容選項(Apple 擴充套件)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("zip, unzip, and make options localize without changing insertion text", async () => {
|
|
||||||
const cases = [
|
|
||||||
{ input: "zip -", option: "-r", zhCn: "递归打包并压缩目录及其内容" },
|
|
||||||
{ input: "zip -", option: "-9", zhCn: "使用最高压缩级别 9" },
|
|
||||||
{ input: "unzip -", option: "-l", zhCn: "列出 zip 文件内容而不解压" },
|
|
||||||
{
|
|
||||||
input: "unzip -",
|
|
||||||
option: "-0",
|
|
||||||
zhCn: "上游选项疑似错误,请核对当前 unzip 版本支持的参数",
|
|
||||||
},
|
|
||||||
{ input: "make -", option: "-N", zhCn: "启用指定的 NeXT 兼容选项(Apple 扩展)" },
|
|
||||||
{ input: "make --", option: "--warn-undefined-variables", zhCn: "引用未定义变量时发出警告" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { input, option, zhCn } of cases) {
|
|
||||||
const suggestions = await getCompletions(input, {
|
|
||||||
locale: "zh-CN",
|
|
||||||
skipPathCompletion: true,
|
|
||||||
maxResults: 50,
|
|
||||||
});
|
|
||||||
const suggestion = suggestionNamed(suggestions, option);
|
|
||||||
assert.equal(suggestion?.text, `${input.split(" ")[0]} ${option}`);
|
|
||||||
assert.equal(suggestion?.description, zhCn);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("batch command descriptions localize and English remains the fallback", async () => {
|
|
||||||
const nano = specs.nano.description;
|
|
||||||
assert.ok(nano);
|
|
||||||
|
|
||||||
const [zhCn] = await localizeSpecSuggestionDescriptions([{ description: nano }], "zh-CN");
|
|
||||||
const [zhTw] = await localizeSpecSuggestionDescriptions([{ description: nano }], "zh-TW");
|
|
||||||
const [en] = await localizeSpecSuggestionDescriptions([{ description: nano }], "en");
|
|
||||||
|
|
||||||
assert.equal(zhCn.description, "Nano 的 ANOther 编辑器,一个增强的免费 Pico 克隆");
|
|
||||||
assert.equal(zhTw.description, "Nano 的 ANOther 編輯器,一個增強的免費 Pico 克隆");
|
|
||||||
assert.equal(en.description, nano);
|
|
||||||
assert.equal(specs.nano.description, nano);
|
|
||||||
});
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const ROOT = path.resolve(HERE, "../..");
|
|
||||||
const SPEC_DIR = path.join(ROOT, "node_modules/@withfig/autocomplete/build");
|
|
||||||
const DICT_DIR = path.join(HERE, "autocomplete");
|
|
||||||
const COMMANDS = ["python", "python3", "node", "vim", "sort", "awk", "sed"] as const;
|
|
||||||
|
|
||||||
const DEFERRED_NODE_KEY =
|
|
||||||
"Follows symlinks to directories when examining source code and templates for translation strings";
|
|
||||||
|
|
||||||
const specs = Object.fromEntries(
|
|
||||||
await Promise.all(
|
|
||||||
COMMANDS.map(async (name) => {
|
|
||||||
const localSpec = path.join(ROOT, "electron/specs", `${name}.js`);
|
|
||||||
const specFile = fs.existsSync(localSpec) ? localSpec : path.join(SPEC_DIR, `${name}.js`);
|
|
||||||
const mod = await import(pathToFileURL(specFile).href);
|
|
||||||
return [name, mod.default?.default ?? mod.default] as const;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
) as Record<(typeof COMMANDS)[number], FigSpec>;
|
|
||||||
|
|
||||||
const simplified = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-CN.json"), "utf8"),
|
|
||||||
) as Record<string, string>;
|
|
||||||
const traditional = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-TW.json"), "utf8"),
|
|
||||||
) as Record<string, string>;
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "localStorage", {
|
|
||||||
value: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {},
|
|
||||||
removeItem: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: {
|
|
||||||
netcatty: {
|
|
||||||
listFigSpecs: async () => [...COMMANDS],
|
|
||||||
loadFigSpec: async (commandName: string) =>
|
|
||||||
(specs as Record<string, FigSpec>)[commandName] ?? null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
|
||||||
|
|
||||||
function collectDescriptions(
|
|
||||||
node: unknown,
|
|
||||||
out = new Set<string>(),
|
|
||||||
seen = new WeakSet<object>(),
|
|
||||||
): Set<string> {
|
|
||||||
if (!node || typeof node !== "object" || seen.has(node)) return out;
|
|
||||||
seen.add(node);
|
|
||||||
const record = node as { description?: unknown };
|
|
||||||
if (typeof record.description === "string" && record.description.trim()) {
|
|
||||||
out.add(record.description);
|
|
||||||
}
|
|
||||||
for (const value of Object.values(node)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) collectDescriptions(item, out, seen);
|
|
||||||
} else if (value && typeof value === "object") {
|
|
||||||
collectDescriptions(value, out, seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchDescriptions = [...new Set(COMMANDS.flatMap((name) => [...collectDescriptions(specs[name])]))];
|
|
||||||
|
|
||||||
function suggestionNamed(
|
|
||||||
suggestions: Awaited<ReturnType<typeof getCompletions>>,
|
|
||||||
displayText: string,
|
|
||||||
) {
|
|
||||||
return suggestions.find((suggestion) => suggestion.displayText === displayText);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("batch specs cover every description except the documented node shared-key conflict", () => {
|
|
||||||
const missing = batchDescriptions.filter(
|
|
||||||
(description) => !(typeof simplified[description] === "string" && simplified[description].length > 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(missing, [DEFERRED_NODE_KEY]);
|
|
||||||
assert.equal(typeof simplified[DEFERRED_NODE_KEY], "undefined");
|
|
||||||
assert.equal(typeof traditional[DEFERRED_NODE_KEY], "undefined");
|
|
||||||
assert.ok(batchDescriptions.length >= 178);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("batch zh-TW spot checks cover generated terminology", () => {
|
|
||||||
assert.equal(
|
|
||||||
traditional["Don’t display the copyright and version messages even in interactive mode"],
|
|
||||||
"上游選項疑似錯誤:互動模式下不顯示版權和版本資訊請用 -q",
|
|
||||||
);
|
|
||||||
assert.equal(traditional["Watch input files"], "監視輸入檔案");
|
|
||||||
assert.equal(
|
|
||||||
traditional["'nocompatible', Vim defaults, no plugins, no viminfo"],
|
|
||||||
"'nocompatible'、Vim 預設值、不載入外掛、不使用 viminfo",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Set the maximum number of execution threads. Default number equals to the number of CPUs"],
|
|
||||||
"設定並行排序的執行緒數",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Prints the pattern space, then, regardless, replaces the pattern space with the next line of input. If there is no more input then sed exits without processing any more commands"],
|
|
||||||
"未停用自動列印時,先列印模式空間;隨後用下一行輸入替換模式空間。若無更多輸入則退出。",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Run in POSIX compatibility mode (gawk)"],
|
|
||||||
"以傳統相容模式執行,行為等同 Brian Kernighan 的 awk(gawk)",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Interprets regular expressions as extended (modern) regular expressions rather than basic regular expressions"],
|
|
||||||
"將正規表示式解釋為擴充套件(現代)正規表示式,而不是基本正規表示式",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("python, node, vim, sort, awk, and sed options localize without changing insertion text", async () => {
|
|
||||||
const cases = [
|
|
||||||
{
|
|
||||||
input: "python -",
|
|
||||||
option: "-B",
|
|
||||||
key: "If given, Python won’t try to write .pyc files on the import of source modules",
|
|
||||||
zhCn: "指定后,Python 在导入源模块时不会写入 .pyc 文件",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "python -",
|
|
||||||
option: "-I",
|
|
||||||
key: "Run Python in isolated mode. This also implies -E and -s. In isolated mode sys.path contains neither the script’s directory nor the user’s site-packages directory",
|
|
||||||
zhCn: "以隔离模式运行 Python,同时隐含 -E 和 -s。隔离模式下 sys.path 不包含脚本目录和用户 site-packages 目录",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "python -",
|
|
||||||
option: "-g",
|
|
||||||
key: "Don’t display the copyright and version messages even in interactive mode",
|
|
||||||
zhCn: "上游选项疑似错误:交互模式下不显示版权和版本信息请用 -q",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "python3 -",
|
|
||||||
option: "-m",
|
|
||||||
key: "Search sys.path for the named module and execute its contents as the __main__ module",
|
|
||||||
zhCn: "在 sys.path 中搜索指定模块,并将其内容作为 __main__ 模块执行",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "node -",
|
|
||||||
option: "--watch",
|
|
||||||
key: "Watch input files",
|
|
||||||
zhCn: "监视输入文件",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "node -",
|
|
||||||
option: "--env-file",
|
|
||||||
key: "Specify a file containing environment variables",
|
|
||||||
zhCn: "指定包含环境变量的文件",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "node -",
|
|
||||||
option: "--inspect",
|
|
||||||
key: "Activate inspector on host:port (default: 127.0.0.1:9229)",
|
|
||||||
zhCn: "在 host:port 上启用检查器(默认:127.0.0.1:9229)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "node -",
|
|
||||||
option: "--preserve-symlinks",
|
|
||||||
key: DEFERRED_NODE_KEY,
|
|
||||||
zhCn: DEFERRED_NODE_KEY,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "vim -",
|
|
||||||
option: "-R",
|
|
||||||
key: "Readonly mode (like 'view')",
|
|
||||||
zhCn: "只读模式(类似 'view')",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "vim -",
|
|
||||||
option: "--clean",
|
|
||||||
key: "'nocompatible', Vim defaults, no plugins, no viminfo",
|
|
||||||
zhCn: "'nocompatible'、Vim 默认值、不加载插件、不使用 viminfo",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "sort -",
|
|
||||||
option: "--parallel",
|
|
||||||
key: "Set the maximum number of execution threads. Default number equals to the number of CPUs",
|
|
||||||
zhCn: "设置并行排序的线程数",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "sort -",
|
|
||||||
option: "--radixsort",
|
|
||||||
key: "Try to use radix sort, if the sort specifications allow",
|
|
||||||
zhCn: "若排序规范允许,尝试使用基数排序",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "awk -",
|
|
||||||
option: "-c",
|
|
||||||
key: "Run in POSIX compatibility mode (gawk)",
|
|
||||||
zhCn: "以传统兼容模式运行,行为等同 Brian Kernighan 的 awk(gawk)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "awk -",
|
|
||||||
option: "-n",
|
|
||||||
key: "Disable automatic input record splitting (gawk)",
|
|
||||||
zhCn: "识别输入数据中的八进制和十六进制值,请谨慎使用(gawk)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "awk -",
|
|
||||||
option: "-t",
|
|
||||||
key: "Enable type checking (gawk)",
|
|
||||||
zhCn: "对无法移植到原始 UNIX awk 的构造发出警告(gawk)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "sed -",
|
|
||||||
option: "-E",
|
|
||||||
key: "Interprets regular expressions as extended (modern) regular expressions rather than basic regular expressions",
|
|
||||||
zhCn: "将正则表达式解释为扩展(现代)正则表达式,而不是基本正则表达式",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "sed -",
|
|
||||||
option: "-r",
|
|
||||||
key: "Same as `-E` for compatibility with GNU sed",
|
|
||||||
zhCn: "与 `-E` 相同,用于兼容 GNU sed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "sed ",
|
|
||||||
option: "n",
|
|
||||||
key: "Prints the pattern space, then, regardless, replaces the pattern space with the next line of input. If there is no more input then sed exits without processing any more commands",
|
|
||||||
zhCn: "未禁用自动打印时,先打印模式空间;随后用下一行输入替换模式空间。若无更多输入则退出。",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { input, option, key, zhCn } of cases) {
|
|
||||||
const command = input.split(" ")[0];
|
|
||||||
const localized = await getCompletions(input, {
|
|
||||||
locale: "zh-CN",
|
|
||||||
skipPathCompletion: true,
|
|
||||||
maxResults: 100,
|
|
||||||
});
|
|
||||||
const suggestion = suggestionNamed(localized, option);
|
|
||||||
assert.equal(suggestion?.text, `${command} ${option}`);
|
|
||||||
assert.equal(suggestion?.description, zhCn);
|
|
||||||
|
|
||||||
for (const locale of ["en", "ru", undefined]) {
|
|
||||||
const fallback = await getCompletions(input, {
|
|
||||||
locale,
|
|
||||||
skipPathCompletion: true,
|
|
||||||
maxResults: 100,
|
|
||||||
});
|
|
||||||
const fallbackSuggestion = suggestionNamed(fallback, option);
|
|
||||||
assert.equal(fallbackSuggestion?.text, `${command} ${option}`);
|
|
||||||
assert.equal(fallbackSuggestion?.description, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(specs.python.description, "Run the python interpreter");
|
|
||||||
assert.equal(specs.sort.description, "Sort or merge records (lines) of text and binary files");
|
|
||||||
});
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const ROOT = path.resolve(HERE, "../..");
|
|
||||||
const SPEC_DIR = path.join(ROOT, "node_modules/@withfig/autocomplete/build");
|
|
||||||
const DICT_DIR = path.join(HERE, "autocomplete");
|
|
||||||
const COMMANDS = ["ln", "stat", "wc", "cut", "uniq", "tee", "xargs"] as const;
|
|
||||||
|
|
||||||
const specs = Object.fromEntries(
|
|
||||||
await Promise.all(
|
|
||||||
COMMANDS.map(async (name) => {
|
|
||||||
const mod = await import(pathToFileURL(path.join(SPEC_DIR, `${name}.js`)).href);
|
|
||||||
return [name, mod.default?.default ?? mod.default] as const;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
) as Record<(typeof COMMANDS)[number], FigSpec>;
|
|
||||||
|
|
||||||
const simplified = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-CN.json"), "utf8"),
|
|
||||||
) as Record<string, string>;
|
|
||||||
const traditional = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(DICT_DIR, "descriptions.zh-TW.json"), "utf8"),
|
|
||||||
) as Record<string, string>;
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "localStorage", {
|
|
||||||
value: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {},
|
|
||||||
removeItem: () => {},
|
|
||||||
clear: () => {},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "window", {
|
|
||||||
value: {
|
|
||||||
netcatty: {
|
|
||||||
listFigSpecs: async () => [...COMMANDS],
|
|
||||||
loadFigSpec: async (commandName: string) =>
|
|
||||||
(specs as Record<string, FigSpec>)[commandName] ?? null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
|
|
||||||
|
|
||||||
function collectDescriptions(
|
|
||||||
node: unknown,
|
|
||||||
out = new Set<string>(),
|
|
||||||
seen = new WeakSet<object>(),
|
|
||||||
): Set<string> {
|
|
||||||
if (!node || typeof node !== "object" || seen.has(node)) return out;
|
|
||||||
seen.add(node);
|
|
||||||
const record = node as { description?: unknown };
|
|
||||||
if (typeof record.description === "string" && record.description.trim()) {
|
|
||||||
out.add(record.description);
|
|
||||||
}
|
|
||||||
for (const value of Object.values(node)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) collectDescriptions(item, out, seen);
|
|
||||||
} else if (value && typeof value === "object") {
|
|
||||||
collectDescriptions(value, out, seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchDescriptions = [...new Set(COMMANDS.flatMap((name) => [...collectDescriptions(specs[name])]))];
|
|
||||||
|
|
||||||
function suggestionNamed(
|
|
||||||
suggestions: Awaited<ReturnType<typeof getCompletions>>,
|
|
||||||
displayText: string,
|
|
||||||
) {
|
|
||||||
return suggestions.find((suggestion) => suggestion.displayText === displayText);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("batch 3 specs have complete zh-CN and zh-TW description coverage", () => {
|
|
||||||
const missing = batchDescriptions.filter(
|
|
||||||
(description) => !(typeof simplified[description] === "string" && simplified[description].length > 0),
|
|
||||||
);
|
|
||||||
const missingTraditional = batchDescriptions.filter(
|
|
||||||
(description) => !(typeof traditional[description] === "string" && traditional[description].length > 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(missing, []);
|
|
||||||
assert.deepEqual(missingTraditional, []);
|
|
||||||
assert.ok(batchDescriptions.length >= 70);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("batch 3 spot checks correct verified semantics and upstream wording", () => {
|
|
||||||
assert.equal(
|
|
||||||
simplified["World, line, character, and byte count"],
|
|
||||||
"统计单词数、行数、字符数和字节数",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["World, line, character, and byte count"],
|
|
||||||
"統計單詞數、行數、字元數和位元組數",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
simplified["Terminal if the arguments will not fit in the maximum line length"],
|
|
||||||
"若参数超出最大行长度则终止",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Terminal if the arguments will not fit in the maximum line length"],
|
|
||||||
"若引數超出最大行長度則終止",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
simplified["Report or omit repeated line"],
|
|
||||||
"报告或省略重复行",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Report or omit repeated line"],
|
|
||||||
"報告或省略重複行",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
simplified["Use delim as the field delimiter character instead of the tab character"],
|
|
||||||
"使用 delim 作为字段分隔符,而不是制表符",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
traditional["Use delim as the field delimiter character instead of the tab character"],
|
|
||||||
"使用 delim 作為欄位分隔符,而不是製表符",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ln, stat, wc, cut, uniq, tee and xargs descriptions localize without changing insertion text", async () => {
|
|
||||||
const cases = [
|
|
||||||
{
|
|
||||||
input: "ln -",
|
|
||||||
option: "-s",
|
|
||||||
key: "Create a symbolic link",
|
|
||||||
zhCn: "创建符号链接",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "stat -",
|
|
||||||
option: "-f",
|
|
||||||
key: "Display information using the specified format; similar to printf(3) formats in that they start with %, are then followed by a sequence of formatting characters, and end in a character that selects the field of the struct stat which is to be formatted",
|
|
||||||
zhCn: "按指定格式显示信息;类似 printf(3):以 % 开头,后接格式化字符,并以选择 struct stat 待格式化字段的字符结尾",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "wc -",
|
|
||||||
option: "-l",
|
|
||||||
key: "Output the number of lines to the standard input",
|
|
||||||
zhCn: "输出行数",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "cut -",
|
|
||||||
option: "-d",
|
|
||||||
key: "Use delim as the field delimiter character instead of the tab character",
|
|
||||||
zhCn: "使用 delim 作为字段分隔符,而不是制表符",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "uniq -",
|
|
||||||
option: "-c",
|
|
||||||
key: "Prefix lines by the number of occurrences",
|
|
||||||
zhCn: "在每行前加上出现次数",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "tee -",
|
|
||||||
option: "-a",
|
|
||||||
key: "Append the output to the files rather than overwriting them",
|
|
||||||
zhCn: "将输出追加到文件而不是覆盖",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "xargs -",
|
|
||||||
option: "-0",
|
|
||||||
key: "Use NUL (0x00) as a separator, instead of whitespace",
|
|
||||||
zhCn: "使用 NUL(0x00)而非空白作为分隔符",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { input, option, key, zhCn } of cases) {
|
|
||||||
const command = input.split(" ")[0];
|
|
||||||
const localized = await getCompletions(input, {
|
|
||||||
locale: "zh-CN",
|
|
||||||
skipPathCompletion: true,
|
|
||||||
maxResults: 100,
|
|
||||||
});
|
|
||||||
const suggestion = suggestionNamed(localized, option);
|
|
||||||
assert.equal(suggestion?.text, `${command} ${option}`);
|
|
||||||
assert.equal(suggestion?.description, zhCn);
|
|
||||||
|
|
||||||
for (const locale of ["en", "ru", undefined]) {
|
|
||||||
const fallback = await getCompletions(input, {
|
|
||||||
locale,
|
|
||||||
skipPathCompletion: true,
|
|
||||||
maxResults: 100,
|
|
||||||
});
|
|
||||||
const fallbackSuggestion = suggestionNamed(fallback, option);
|
|
||||||
assert.equal(fallbackSuggestion?.text, `${command} ${option}`);
|
|
||||||
assert.equal(fallbackSuggestion?.description, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const traditionalResults = await getCompletions("uniq -", {
|
|
||||||
locale: "zh-TW",
|
|
||||||
skipPathCompletion: true,
|
|
||||||
maxResults: 100,
|
|
||||||
});
|
|
||||||
assert.equal(suggestionNamed(traditionalResults, "-c")?.description, "在每行前加上出現次數");
|
|
||||||
});
|
|
||||||
@@ -6,9 +6,6 @@ test("preload common specs includes dnf alongside yum and apt", () => {
|
|||||||
assert.ok(COMMON_FIG_SPECS.includes("apt"));
|
assert.ok(COMMON_FIG_SPECS.includes("apt"));
|
||||||
assert.ok(COMMON_FIG_SPECS.includes("yum"));
|
assert.ok(COMMON_FIG_SPECS.includes("yum"));
|
||||||
assert.ok(COMMON_FIG_SPECS.includes("dnf"));
|
assert.ok(COMMON_FIG_SPECS.includes("dnf"));
|
||||||
for (const command of ["ln", "stat", "wc", "cut", "uniq", "tee", "xargs"]) {
|
|
||||||
assert.ok(COMMON_FIG_SPECS.includes(command), `${command} should be preloaded`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizeCommandName strips path and extension", () => {
|
test("normalizeCommandName strips path and extension", () => {
|
||||||
|
|||||||
@@ -1037,14 +1037,7 @@ test("PTY builders chunk the exact shared script into lines below the canonical
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", {
|
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", { timeout: 30000 }, async () => {
|
||||||
timeout: 30000,
|
|
||||||
// macOS MAX_CANON is 1024 bytes while Linux is 4096. --noediting forces the
|
|
||||||
// kernel canonical path; real interactive shells use readline/zle and are not
|
|
||||||
// subject to this limit, so this Linux canonical-buffer regression test is
|
|
||||||
// not applicable on macOS.
|
|
||||||
skip: process.platform === "darwin" ? "macOS canonical input limit is 1024 bytes" : false,
|
|
||||||
}, async () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), "netcatty-osc7-canonical-"));
|
const home = mkdtempSync(join(tmpdir(), "netcatty-osc7-canonical-"));
|
||||||
const prompt = "__NETCATTY_TEST_PROMPT__> ";
|
const prompt = "__NETCATTY_TEST_PROMPT__> ";
|
||||||
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
|
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
|
||||||
|
|||||||
@@ -716,9 +716,7 @@ export const buildOsc7PtySetupCommands = (shell: Osc7PtyShell): string[] => {
|
|||||||
`if test -n ${file} && printf '%s' '${payload.slice(offset, offset + chunkSize)}' >> ${file}; then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, ack)}; else rm -f -- ${file}; unset __netcatty_osc7_pty_file; ${failed}; fi`,
|
`if test -n ${file} && printf '%s' '${payload.slice(offset, offset + chunkSize)}' >> ${file}; then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, ack)}; else rm -f -- ${file}; unset __netcatty_osc7_pty_file; ${failed}; fi`,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Read through stdin: GNU base64 accepts a file operand, but BSD/macOS
|
const runner = 'c=$(base64 -d "$1") && printf "%s\\n" "$c" | sh';
|
||||||
// base64 only accepts -i or stdin, which broke zsh setup on macOS.
|
|
||||||
const runner = 'c=$(base64 -d < "$1") && printf "%s\\n" "$c" | sh';
|
|
||||||
const shellEnv = shell === "zsh" ? ' NETCATTY_ZDOTDIR="${ZDOTDIR:-}"' : "";
|
const shellEnv = shell === "zsh" ? ' NETCATTY_ZDOTDIR="${ZDOTDIR:-}"' : "";
|
||||||
const run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
|
const run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
|
||||||
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
|
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { registerHooks } from 'node:module';
|
|
||||||
import test from 'node:test';
|
|
||||||
import React, { act } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { JSDOM } from 'jsdom';
|
|
||||||
import type { AISession } from '../../infrastructure/ai/types';
|
|
||||||
import type { AIPanelContext } from '../../domain/buildAITerminalSessionInfo';
|
|
||||||
import type { Workspace } from '../../types';
|
|
||||||
import { installFakeIndexedDb } from '../../application/state/testSupport/fakeIndexedDb';
|
|
||||||
import {
|
|
||||||
__flushIdbWritesForTests, __resetIdbPersistenceForTests,
|
|
||||||
setLatestAISessionsSnapshot, setLatestAIActiveSessionMapSnapshot,
|
|
||||||
setLatestAIDraftsByScopeSnapshot, setLatestAIPanelViewByScopeSnapshot,
|
|
||||||
} from '../../application/state/aiStateSnapshots';
|
|
||||||
import { getAISessionsSnapshot } from '../../application/state/aiSessionsStore';
|
|
||||||
|
|
||||||
// Keep the production provider, maintenance host, panels host and panel effects.
|
|
||||||
// Only terminal rendering and the chat presentation are replaced: capture the
|
|
||||||
// view actually selected by the mounted panel, including its New Chat action.
|
|
||||||
const contentUrl = `data:text/javascript,${encodeURIComponent(`
|
|
||||||
let props;
|
|
||||||
export function AIChatPanelContent(value) { props = value; return null; }
|
|
||||||
export function readPanel() { return props; }
|
|
||||||
`)}`;
|
|
||||||
registerHooks({
|
|
||||||
resolve(specifier, context, nextResolve) {
|
|
||||||
if (specifier === '../Terminal' && context.parentURL?.endsWith('/TerminalLayerSupport.tsx')) {
|
|
||||||
return { url: 'data:text/javascript,export default function Terminal(){return null}', shortCircuit: true };
|
|
||||||
}
|
|
||||||
if (specifier === './AIChatPanelContent' && context.parentURL?.endsWith('/AIChatSidePanel.tsx')) {
|
|
||||||
return { url: contentUrl, shortCircuit: true };
|
|
||||||
}
|
|
||||||
return nextResolve(specifier, context);
|
|
||||||
},
|
|
||||||
load(url, context, nextLoad) {
|
|
||||||
if (url.endsWith('.css')) return { format: 'module', source: 'export default {};', shortCircuit: true };
|
|
||||||
return nextLoad(url, context);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mounted workspace chat survives last-member close without defeating detach or New Chat', async (t) => {
|
|
||||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
|
||||||
const globals = {
|
|
||||||
window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage,
|
|
||||||
CustomEvent: dom.window.CustomEvent, Event: dom.window.Event, navigator: dom.window.navigator,
|
|
||||||
IS_REACT_ACT_ENVIRONMENT: true,
|
|
||||||
};
|
|
||||||
const previous = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
|
||||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
|
||||||
t.after(async () => {
|
|
||||||
await __flushIdbWritesForTests();
|
|
||||||
dom.window.close();
|
|
||||||
for (const [key, descriptor] of previous) {
|
|
||||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
||||||
else Reflect.deleteProperty(globalThis, key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider');
|
|
||||||
const { AIStateProvider, AIStateMaintenanceHost, AIChatPanelsHost } = await import('./TerminalLayerSupport');
|
|
||||||
await import('../AIChatSidePanel');
|
|
||||||
const { readPanel } = await import(contentUrl);
|
|
||||||
const scopeKey = 'workspace:workspace';
|
|
||||||
|
|
||||||
for (const scenario of ['close', 'close-inherited', 'close-resumed', 'detach', 'detach-resumed', 'out-of-scope'] as const) {
|
|
||||||
await t.test(scenario, async () => {
|
|
||||||
dom.window.localStorage.clear();
|
|
||||||
const idb = installFakeIndexedDb();
|
|
||||||
__resetIdbPersistenceForTests();
|
|
||||||
const chat: AISession = {
|
|
||||||
id: `chat-${scenario}`, title: 'Inherited conversation', agentId: 'catty',
|
|
||||||
scope: { type: 'terminal', targetId: scenario.endsWith('resumed') ? 'old-terminal' : 'terminal', hostIds: ['host'] },
|
|
||||||
messages: [{ id: 'message', role: 'user', content: 'Keep this question', timestamp: 1 }],
|
|
||||||
createdAt: 1, updatedAt: 2,
|
|
||||||
};
|
|
||||||
const activeMap = { [scopeKey]: chat.id, 'terminal:terminal': chat.id };
|
|
||||||
setLatestAISessionsSnapshot([chat]);
|
|
||||||
setLatestAIActiveSessionMapSnapshot(activeMap);
|
|
||||||
setLatestAIDraftsByScopeSnapshot({});
|
|
||||||
setLatestAIPanelViewByScopeSnapshot(scenario === 'close-inherited' ? {} : {
|
|
||||||
[scopeKey]: { mode: 'session', sessionId: chat.id },
|
|
||||||
});
|
|
||||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify([chat]));
|
|
||||||
dom.window.localStorage.setItem('netcatty_ai_active_session_map_v1', JSON.stringify(activeMap));
|
|
||||||
let workspace: Workspace = {
|
|
||||||
id: 'workspace', title: 'Workspace', anchorChatId: chat.id, focusedSessionId: 'terminal',
|
|
||||||
root: { id: 'pane', type: 'pane', sessionId: 'terminal' },
|
|
||||||
};
|
|
||||||
let liveIds = new Set(['workspace', 'terminal']);
|
|
||||||
let context: AIPanelContext = {
|
|
||||||
scopeType: 'workspace', scopeTargetId: 'workspace', scopeLabel: 'Workspace', scopeHostIds: ['host'],
|
|
||||||
focusedSessionId: 'terminal', terminalSessions: [{ sessionId: 'terminal', hostId: 'host', status: 'connected' } as AIPanelContext['terminalSessions'][number]],
|
|
||||||
};
|
|
||||||
const container = dom.window.document.createElement('div');
|
|
||||||
dom.window.document.body.append(container);
|
|
||||||
const root = createRoot(container);
|
|
||||||
const render = () => React.createElement(I18nProvider, null,
|
|
||||||
React.createElement(AIStateProvider, null,
|
|
||||||
React.createElement(AIStateMaintenanceHost, { workspaces: [workspace], validAIScopeTargetIds: liveIds }),
|
|
||||||
React.createElement(AIChatPanelsHost, {
|
|
||||||
mountedTabIds: ['workspace'], activeTabId: 'workspace', activeSidePanelTab: 'ai',
|
|
||||||
liveScopeTargetIds: liveIds, contextsByTabId: new Map([['workspace', context]]),
|
|
||||||
resolveExecutorContext: () => ({}) as never, notes: [], hosts: [], snippets: [], workspaces: [workspace],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await act(async () => root.render(render()));
|
|
||||||
assert.equal(readPanel().activeSessionId, chat.id);
|
|
||||||
if (scenario !== 'close-inherited') assert.equal(getAISessionsSnapshot().panelViewByScope[scopeKey]?.mode, 'session');
|
|
||||||
|
|
||||||
if (scenario === 'out-of-scope') {
|
|
||||||
// No member loss/handoff: a panel displaying the wrong scope must
|
|
||||||
// still clear its explicit session instead of keeping global history.
|
|
||||||
context = { ...context, terminalSessions: [], focusedSessionId: undefined };
|
|
||||||
} else {
|
|
||||||
workspace = { ...workspace, focusedSessionId: undefined,
|
|
||||||
root: { id: 'empty', type: 'split', direction: 'vertical', children: [], sizes: [] } };
|
|
||||||
liveIds = new Set(scenario.startsWith('close') ? ['workspace'] : ['workspace', 'terminal']);
|
|
||||||
context = { ...context, terminalSessions: [], focusedSessionId: undefined };
|
|
||||||
}
|
|
||||||
await act(async () => root.render(render()));
|
|
||||||
const snapshot = getAISessionsSnapshot();
|
|
||||||
if (scenario.startsWith('close')) {
|
|
||||||
assert.equal(snapshot.sessions.find(session => session.id === chat.id)?.scope.type, 'workspace');
|
|
||||||
if (scenario !== 'close-inherited') assert.equal(snapshot.panelViewByScope[scopeKey]?.mode, 'session', 'passive normalization must not overwrite the handoff');
|
|
||||||
else assert.notEqual(snapshot.panelViewByScope[scopeKey]?.mode, 'draft');
|
|
||||||
assert.equal(snapshot.activeSessionIdMap[scopeKey], chat.id);
|
|
||||||
assert.equal(readPanel().activeSessionId, chat.id);
|
|
||||||
assert.equal(readPanel().messages[0].content, 'Keep this question');
|
|
||||||
// No provider is configured: the real send path records the follow-up
|
|
||||||
// and a local configuration error without contacting any model.
|
|
||||||
readPanel().setInputValue('Continue after closing the terminal');
|
|
||||||
await act(async () => readPanel().handleSend());
|
|
||||||
const afterSend = getAISessionsSnapshot();
|
|
||||||
assert.deepEqual(afterSend.sessions.map(session => session.id), [chat.id], 'follow-up must not create a new chat');
|
|
||||||
assert.ok(afterSend.sessions[0].messages.some(message => message.content === 'Continue after closing the terminal'));
|
|
||||||
await act(async () => readPanel().handleNewChat());
|
|
||||||
assert.equal(getAISessionsSnapshot().panelViewByScope[scopeKey]?.mode, 'draft');
|
|
||||||
assert.ok(getAISessionsSnapshot().activeSessionIdMap[scopeKey] == null);
|
|
||||||
} else {
|
|
||||||
assert.equal(snapshot.panelViewByScope[scopeKey]?.mode, 'draft');
|
|
||||||
assert.equal(snapshot.activeSessionIdMap[scopeKey], null);
|
|
||||||
assert.equal(snapshot.sessions.find(session => session.id === chat.id)?.scope.type, 'terminal');
|
|
||||||
if (scenario.startsWith('detach')) assert.equal(snapshot.activeSessionIdMap['terminal:terminal'], chat.id);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await act(async () => root.unmount());
|
|
||||||
container.remove();
|
|
||||||
await __flushIdbWritesForTests();
|
|
||||||
idb.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -359,18 +359,3 @@ test('available-width observer releases a removed focus sidebar immediately', ()
|
|||||||
assert.match(sectionSource, /if \(observedFocusSidebar\) resizeObserver\.unobserve\(observedFocusSidebar\)/);
|
assert.match(sectionSource, /if \(observedFocusSidebar\) resizeObserver\.unobserve\(observedFocusSidebar\)/);
|
||||||
assert.match(sectionSource, /observedFocusSidebar = null/);
|
assert.match(sectionSource, /observedFocusSidebar = null/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('side panel close button stays visible at every panel width', () => {
|
|
||||||
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
|
|
||||||
|
|
||||||
// The toolbar close button is the panel's only dismiss control and the
|
|
||||||
// rightmost item of the nowrap toolbar row. Closing is a core function, so
|
|
||||||
// it must never be a flex-shrink victim and must never carry responsive
|
|
||||||
// hiding classes; secondary toolbar actions yield first instead.
|
|
||||||
const closeLabelIndex = sectionSource.indexOf("t('terminal.layer.closePanel')");
|
|
||||||
assert.ok(closeLabelIndex > 0, 'close button tooltip label is rendered');
|
|
||||||
const closeSlice = sectionSource.slice(Math.max(0, closeLabelIndex - 600), closeLabelIndex);
|
|
||||||
assert.match(closeSlice, /onClick=\{handleCloseSidePanel\}/);
|
|
||||||
assert.match(closeSlice, /className="h-7 w-7 shrink-0 rounded-md p-0 hover:bg-transparent"/);
|
|
||||||
assert.doesNotMatch(closeSlice, /\bhidden\b|max-(sm|md|lg|xl):/);
|
|
||||||
});
|
|
||||||
@@ -1577,7 +1577,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
|||||||
data-section="pane-magnification-hint"
|
data-section="pane-magnification-hint"
|
||||||
>
|
>
|
||||||
{t('terminal.paneMagnification.hint')}: {sidePanelToolLabels.get(magnifiedSidePane.tool) ?? magnifiedSidePane.tool}
|
{t('terminal.paneMagnification.hint')}: {sidePanelToolLabels.get(magnifiedSidePane.tool) ?? magnifiedSidePane.tool}
|
||||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
|
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ test("pointer drag cleanup runs when the pane unmounts without pointerup", async
|
|||||||
pointerDown = useWorkspaceDetachPointerDrag({
|
pointerDown = useWorkspaceDetachPointerDrag({
|
||||||
inActiveWorkspace: true,
|
inActiveWorkspace: true,
|
||||||
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
||||||
|
workspaceById: new Map(),
|
||||||
onStartSessionDrag: () => { dragStartCount += 1; },
|
onStartSessionDrag: () => { dragStartCount += 1; },
|
||||||
onEndSessionDrag: () => { dragEndCount += 1; },
|
onEndSessionDrag: () => { dragEndCount += 1; },
|
||||||
});
|
});
|
||||||
@@ -143,6 +144,7 @@ test("unmount removes an active drag overlay and ends drag state", async () => {
|
|||||||
pointerDown = useWorkspaceDetachPointerDrag({
|
pointerDown = useWorkspaceDetachPointerDrag({
|
||||||
inActiveWorkspace: true,
|
inActiveWorkspace: true,
|
||||||
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
||||||
|
workspaceById: new Map(),
|
||||||
onStartSessionDrag: () => { dragStartCount += 1; },
|
onStartSessionDrag: () => { dragStartCount += 1; },
|
||||||
onEndSessionDrag: () => { dragEndCount += 1; },
|
onEndSessionDrag: () => { dragEndCount += 1; },
|
||||||
});
|
});
|
||||||
@@ -190,6 +192,7 @@ test("all pointer drag exit paths share one idempotent cleanup", async () => {
|
|||||||
pointerDown = useWorkspaceDetachPointerDrag({
|
pointerDown = useWorkspaceDetachPointerDrag({
|
||||||
inActiveWorkspace: true,
|
inActiveWorkspace: true,
|
||||||
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
||||||
|
workspaceById: new Map(),
|
||||||
onStartSessionDrag: () => { dragStartCount += 1; },
|
onStartSessionDrag: () => { dragStartCount += 1; },
|
||||||
onEndSessionDrag: () => { dragEndCount += 1; },
|
onEndSessionDrag: () => { dragEndCount += 1; },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -301,8 +301,6 @@ interface AIChatPanelsHostProps {
|
|||||||
onOpenVaultHostFromChat?: (hostId: string) => void;
|
onOpenVaultHostFromChat?: (hostId: string) => void;
|
||||||
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => void;
|
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => void;
|
||||||
onOpenVaultSnippetFromChat?: (snippetId: string) => void;
|
onOpenVaultSnippetFromChat?: (snippetId: string) => void;
|
||||||
/** Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to. */
|
|
||||||
onSetWorkspaceAnchorChat?: (workspaceId: string, chatId: string | null) => void;
|
|
||||||
/** Live workspaces, threaded to AI chats for `/chats` scope titles. */
|
/** Live workspaces, threaded to AI chats for `/chats` scope titles. */
|
||||||
workspaces?: Workspace[] | null;
|
workspaces?: Workspace[] | null;
|
||||||
}
|
}
|
||||||
@@ -396,7 +394,6 @@ const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
|
|||||||
previousMemberTerminalIds: previousMemberIds,
|
previousMemberTerminalIds: previousMemberIds,
|
||||||
currentMemberTerminalIds: currentMemberIds,
|
currentMemberTerminalIds: currentMemberIds,
|
||||||
preferredTerminalId: workspace.focusedSessionId,
|
preferredTerminalId: workspace.focusedSessionId,
|
||||||
activeTargetIds: validAIScopeTargetIds,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +468,6 @@ function aiChatPanelsHostAreEqual(
|
|||||||
if (prev.onOpenVaultHostFromChat !== next.onOpenVaultHostFromChat) return false;
|
if (prev.onOpenVaultHostFromChat !== next.onOpenVaultHostFromChat) return false;
|
||||||
if (prev.onOpenVaultSectionFromChat !== next.onOpenVaultSectionFromChat) return false;
|
if (prev.onOpenVaultSectionFromChat !== next.onOpenVaultSectionFromChat) return false;
|
||||||
if (prev.onOpenVaultSnippetFromChat !== next.onOpenVaultSnippetFromChat) return false;
|
if (prev.onOpenVaultSnippetFromChat !== next.onOpenVaultSnippetFromChat) return false;
|
||||||
if (prev.onSetWorkspaceAnchorChat !== next.onSetWorkspaceAnchorChat) return false;
|
|
||||||
if (prev.workspaces !== next.workspaces) return false;
|
if (prev.workspaces !== next.workspaces) return false;
|
||||||
if (prev.activeTabId === next.activeTabId) return true;
|
if (prev.activeTabId === next.activeTabId) return true;
|
||||||
|
|
||||||
@@ -512,7 +508,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
|||||||
onOpenVaultHostFromChat,
|
onOpenVaultHostFromChat,
|
||||||
onOpenVaultSectionFromChat,
|
onOpenVaultSectionFromChat,
|
||||||
onOpenVaultSnippetFromChat,
|
onOpenVaultSnippetFromChat,
|
||||||
onSetWorkspaceAnchorChat,
|
|
||||||
workspaces,
|
workspaces,
|
||||||
}) => {
|
}) => {
|
||||||
const aiConfig = useContext(AIConfigContext);
|
const aiConfig = useContext(AIConfigContext);
|
||||||
@@ -532,11 +527,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
|||||||
updateDraft,
|
updateDraft,
|
||||||
} = aiConfig;
|
} = aiConfig;
|
||||||
const aiPanelWorkspaces = useMemo(
|
const aiPanelWorkspaces = useMemo(
|
||||||
() => (workspaces ?? []).map((workspace) => ({
|
() => (workspaces ?? []).map((workspace) => ({ id: workspace.id, title: workspace.title })),
|
||||||
id: workspace.id,
|
|
||||||
title: workspace.title,
|
|
||||||
anchorChatId: workspace.anchorChatId,
|
|
||||||
})),
|
|
||||||
[workspaces],
|
[workspaces],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -647,7 +638,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
|||||||
scopeLabel={context.scopeLabel}
|
scopeLabel={context.scopeLabel}
|
||||||
focusedSessionId={context.focusedSessionId}
|
focusedSessionId={context.focusedSessionId}
|
||||||
workspaces={aiPanelWorkspaces}
|
workspaces={aiPanelWorkspaces}
|
||||||
onSetWorkspaceAnchorChat={onSetWorkspaceAnchorChat}
|
|
||||||
terminalSessions={context.terminalSessions}
|
terminalSessions={context.terminalSessions}
|
||||||
liveScopeTargetIds={liveScopeTargetIds}
|
liveScopeTargetIds={liveScopeTargetIds}
|
||||||
resolveExecutorContext={resolveExecutorContext}
|
resolveExecutorContext={resolveExecutorContext}
|
||||||
@@ -728,15 +718,12 @@ export interface TerminalLayerProps {
|
|||||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||||
onMoveWorkspacePane: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
|
||||||
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
onRequestAddToWorkspace?: (workspaceId: string) => void;
|
||||||
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
||||||
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
||||||
onSetDraggingSessionId: (id: string | null) => void;
|
onSetDraggingSessionId: (id: string | null) => void;
|
||||||
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
|
||||||
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
|
||||||
/** Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to. */
|
|
||||||
onSetWorkspaceAnchorChat?: (workspaceId: string, chatId: string | null) => void;
|
|
||||||
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
|
||||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||||
onCopySession?: (sessionId: string) => void;
|
onCopySession?: (sessionId: string) => void;
|
||||||
@@ -894,7 +881,6 @@ interface TerminalPaneProps {
|
|||||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||||
onStartSessionDrag?: (sessionId: string) => void;
|
onStartSessionDrag?: (sessionId: string) => void;
|
||||||
onEndSessionDrag?: () => void;
|
onEndSessionDrag?: () => void;
|
||||||
onDropWorkspacePane?: (sessionId: string, point: { clientX: number; clientY: number }) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPaneAppearanceThemeId = (props: TerminalPaneProps): string => {
|
const getPaneAppearanceThemeId = (props: TerminalPaneProps): string => {
|
||||||
@@ -1008,7 +994,6 @@ const terminalPanePropsAreEqual = (
|
|||||||
prev.onRemoveSessionFromWorkspace === next.onRemoveSessionFromWorkspace &&
|
prev.onRemoveSessionFromWorkspace === next.onRemoveSessionFromWorkspace &&
|
||||||
prev.onReorderTabs === next.onReorderTabs &&
|
prev.onReorderTabs === next.onReorderTabs &&
|
||||||
prev.onStartSessionDrag === next.onStartSessionDrag &&
|
prev.onStartSessionDrag === next.onStartSessionDrag &&
|
||||||
prev.onDropWorkspacePane === next.onDropWorkspacePane &&
|
|
||||||
prev.onEndSessionDrag === next.onEndSessionDrag
|
prev.onEndSessionDrag === next.onEndSessionDrag
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1016,9 +1001,9 @@ type WorkspaceDetachPointerDragOptions = {
|
|||||||
inActiveWorkspace: boolean;
|
inActiveWorkspace: boolean;
|
||||||
session: TerminalSession;
|
session: TerminalSession;
|
||||||
terminalSettings?: TerminalSettings;
|
terminalSettings?: TerminalSettings;
|
||||||
|
workspaceById: Map<string, Workspace>;
|
||||||
onStartSessionDrag?: (sessionId: string) => void;
|
onStartSessionDrag?: (sessionId: string) => void;
|
||||||
onEndSessionDrag?: () => void;
|
onEndSessionDrag?: () => void;
|
||||||
onDropWorkspacePane?: (sessionId: string, point: { clientX: number; clientY: number }) => void;
|
|
||||||
onRemoveSessionFromWorkspace?: TerminalPaneProps['onRemoveSessionFromWorkspace'];
|
onRemoveSessionFromWorkspace?: TerminalPaneProps['onRemoveSessionFromWorkspace'];
|
||||||
onReorderTabs?: TerminalPaneProps['onReorderTabs'];
|
onReorderTabs?: TerminalPaneProps['onReorderTabs'];
|
||||||
};
|
};
|
||||||
@@ -1027,9 +1012,9 @@ export function useWorkspaceDetachPointerDrag({
|
|||||||
inActiveWorkspace,
|
inActiveWorkspace,
|
||||||
session,
|
session,
|
||||||
terminalSettings,
|
terminalSettings,
|
||||||
|
workspaceById,
|
||||||
onStartSessionDrag,
|
onStartSessionDrag,
|
||||||
onEndSessionDrag,
|
onEndSessionDrag,
|
||||||
onDropWorkspacePane,
|
|
||||||
onRemoveSessionFromWorkspace,
|
onRemoveSessionFromWorkspace,
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
}: WorkspaceDetachPointerDragOptions): (event: React.PointerEvent<HTMLElement>) => void {
|
}: WorkspaceDetachPointerDragOptions): (event: React.PointerEvent<HTMLElement>) => void {
|
||||||
@@ -1135,6 +1120,19 @@ export function useWorkspaceDetachPointerDrag({
|
|||||||
return insertionTarget;
|
return insertionTarget;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveStableInsertionTarget = (insertionTarget: ReturnType<typeof getTopTabInsertionTarget>) => {
|
||||||
|
if (!insertionTarget || insertionTarget.tabId !== session.workspaceId) return insertionTarget;
|
||||||
|
const sourceWorkspace = session.workspaceId ? workspaceById.get(session.workspaceId) : undefined;
|
||||||
|
if (!sourceWorkspace) return insertionTarget;
|
||||||
|
const remainingSessionIds = collectSessionIds(sourceWorkspace.root)
|
||||||
|
.filter((candidateId) => candidateId !== session.id);
|
||||||
|
if (remainingSessionIds.length !== 1) return insertionTarget;
|
||||||
|
return {
|
||||||
|
tabId: remainingSessionIds[0],
|
||||||
|
position: insertionTarget.position,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const startDragIfNeeded = (pointerEvent: PointerEvent) => {
|
const startDragIfNeeded = (pointerEvent: PointerEvent) => {
|
||||||
if (cleanupFinished || dragStarted) return;
|
if (cleanupFinished || dragStarted) return;
|
||||||
const dx = pointerEvent.clientX - startPoint.clientX;
|
const dx = pointerEvent.clientX - startPoint.clientX;
|
||||||
@@ -1181,10 +1179,9 @@ export function useWorkspaceDetachPointerDrag({
|
|||||||
const shouldDetach = dragStarted
|
const shouldDetach = dragStarted
|
||||||
&& !!topTabsRoot
|
&& !!topTabsRoot
|
||||||
&& isPointInsideRect(pointerEvent, topTabsRoot.getBoundingClientRect());
|
&& isPointInsideRect(pointerEvent, topTabsRoot.getBoundingClientRect());
|
||||||
if (dragStarted && !shouldDetach) onDropWorkspacePane?.(session.id, pointerEvent);
|
|
||||||
cleanup();
|
cleanup();
|
||||||
if (shouldDetach) {
|
if (shouldDetach) {
|
||||||
const stableInsertionTarget = insertionTarget;
|
const stableInsertionTarget = resolveStableInsertionTarget(insertionTarget);
|
||||||
if (onRemoveSessionFromWorkspace) {
|
if (onRemoveSessionFromWorkspace) {
|
||||||
onRemoveSessionFromWorkspace(
|
onRemoveSessionFromWorkspace(
|
||||||
session.id,
|
session.id,
|
||||||
@@ -1214,13 +1211,13 @@ export function useWorkspaceDetachPointerDrag({
|
|||||||
cleanupActiveDrag,
|
cleanupActiveDrag,
|
||||||
inActiveWorkspace,
|
inActiveWorkspace,
|
||||||
onEndSessionDrag,
|
onEndSessionDrag,
|
||||||
onDropWorkspacePane,
|
|
||||||
onRemoveSessionFromWorkspace,
|
onRemoveSessionFromWorkspace,
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
onStartSessionDrag,
|
onStartSessionDrag,
|
||||||
session,
|
session,
|
||||||
terminalSettings?.dynamicTabTitleMode,
|
terminalSettings?.dynamicTabTitleMode,
|
||||||
]);
|
workspaceById,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||||
@@ -1303,7 +1300,6 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
|||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
onStartSessionDrag,
|
onStartSessionDrag,
|
||||||
onEndSessionDrag,
|
onEndSessionDrag,
|
||||||
onDropWorkspacePane,
|
|
||||||
}) => {
|
}) => {
|
||||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||||
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
|
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
|
||||||
@@ -1579,9 +1575,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
|||||||
inActiveWorkspace,
|
inActiveWorkspace,
|
||||||
session,
|
session,
|
||||||
terminalSettings,
|
terminalSettings,
|
||||||
|
workspaceById,
|
||||||
onStartSessionDrag,
|
onStartSessionDrag,
|
||||||
onEndSessionDrag,
|
onEndSessionDrag,
|
||||||
onDropWorkspacePane,
|
|
||||||
onRemoveSessionFromWorkspace,
|
onRemoveSessionFromWorkspace,
|
||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
});
|
});
|
||||||
@@ -1600,12 +1596,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
|||||||
"absolute bg-background",
|
"absolute bg-background",
|
||||||
inActiveWorkspace && "workspace-pane",
|
inActiveWorkspace && "workspace-pane",
|
||||||
isVisible && "z-10",
|
isVisible && "z-10",
|
||||||
isMagnified && magnifiedSurfaceBounds && "overflow-hidden rounded-md border shadow-2xl animate-in fade-in zoom-in-95 duration-150",
|
isMagnified && magnifiedSurfaceBounds && "animate-in fade-in zoom-in-95 duration-150",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={style}
|
||||||
...style,
|
|
||||||
borderColor: isMagnified && magnifiedSurfaceBounds ? sessionAppearanceTheme.colors.cursor : undefined,
|
|
||||||
}}
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={handlePaneClick}
|
onClick={handlePaneClick}
|
||||||
onFocusCapture={handlePaneClick}
|
onFocusCapture={handlePaneClick}
|
||||||
@@ -1800,7 +1793,6 @@ interface TerminalPanesHostProps {
|
|||||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||||
onStartSessionDrag?: (sessionId: string) => void;
|
onStartSessionDrag?: (sessionId: string) => void;
|
||||||
onEndSessionDrag?: () => void;
|
onEndSessionDrag?: () => void;
|
||||||
onDropWorkspacePane?: (sessionId: string, point: { clientX: number; clientY: number }) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminalPanesHostPropsAreEqual = (
|
const terminalPanesHostPropsAreEqual = (
|
||||||
@@ -1884,7 +1876,6 @@ const terminalPanesHostPropsAreEqual = (
|
|||||||
if (prev.onReorderTabs !== next.onReorderTabs) return false;
|
if (prev.onReorderTabs !== next.onReorderTabs) return false;
|
||||||
if (prev.onStartSessionDrag !== next.onStartSessionDrag) return false;
|
if (prev.onStartSessionDrag !== next.onStartSessionDrag) return false;
|
||||||
if (prev.onEndSessionDrag !== next.onEndSessionDrag) return false;
|
if (prev.onEndSessionDrag !== next.onEndSessionDrag) return false;
|
||||||
if (prev.onDropWorkspacePane !== next.onDropWorkspacePane) return false;
|
|
||||||
|
|
||||||
if (prev.workspaceRectsById === next.workspaceRectsById) return true;
|
if (prev.workspaceRectsById === next.workspaceRectsById) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
|||||||
dropHint,
|
dropHint,
|
||||||
findSplitNode,
|
findSplitNode,
|
||||||
handleWorkspaceDrop,
|
handleWorkspaceDrop,
|
||||||
handleWorkspacePanePointerDrop,
|
|
||||||
resizing,
|
resizing,
|
||||||
setDropHint,
|
setDropHint,
|
||||||
setResizing,
|
setResizing,
|
||||||
@@ -111,14 +110,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
|||||||
activeSession,
|
activeSession,
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
isFocusMode,
|
isFocusMode,
|
||||||
hasMagnifiedPane: !!activeWorkspace && activeWorkspace.id === s.magnifiedPane?.tabId,
|
|
||||||
shouldKeepHiddenWorkspaceLaidOut,
|
shouldKeepHiddenWorkspaceLaidOut,
|
||||||
onAddSessionToWorkspace: s.onAddSessionToWorkspace,
|
onAddSessionToWorkspace: s.onAddSessionToWorkspace,
|
||||||
onCreateWorkspaceFromSessions: s.onCreateWorkspaceFromSessions,
|
onCreateWorkspaceFromSessions: s.onCreateWorkspaceFromSessions,
|
||||||
onMoveWorkspacePane: s.onMoveWorkspacePane,
|
|
||||||
draggingSession: s.draggingSessionId
|
|
||||||
? sessions.find((session) => session.id === s.draggingSessionId) ?? null
|
|
||||||
: null,
|
|
||||||
onSetDraggingSessionId: s.onSetDraggingSessionId,
|
onSetDraggingSessionId: s.onSetDraggingSessionId,
|
||||||
onUpdateSplitSizes: s.onUpdateSplitSizes,
|
onUpdateSplitSizes: s.onUpdateSplitSizes,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -550,7 +544,6 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
|||||||
handleToggleWorkspaceComposeBar: s.handleToggleWorkspaceComposeBar,
|
handleToggleWorkspaceComposeBar: s.handleToggleWorkspaceComposeBar,
|
||||||
handleUpdateHost: s.handleUpdateHost,
|
handleUpdateHost: s.handleUpdateHost,
|
||||||
handleWorkspaceDrop,
|
handleWorkspaceDrop,
|
||||||
handleWorkspacePanePointerDrop,
|
|
||||||
hosts: s.hosts,
|
hosts: s.hosts,
|
||||||
hotkeyScheme: s.hotkeyScheme,
|
hotkeyScheme: s.hotkeyScheme,
|
||||||
disableTerminalFontZoom: s.disableTerminalFontZoom,
|
disableTerminalFontZoom: s.disableTerminalFontZoom,
|
||||||
@@ -591,7 +584,6 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
|||||||
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
|
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
|
||||||
onAppendHostToWorkspace: s.onAppendHostToWorkspace,
|
onAppendHostToWorkspace: s.onAppendHostToWorkspace,
|
||||||
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
|
||||||
onSetWorkspaceAnchorChat: s.onSetWorkspaceAnchorChat,
|
|
||||||
onStartSessionRename: s.onStartSessionRename,
|
onStartSessionRename: s.onStartSessionRename,
|
||||||
onSubmitSessionRename: s.onSubmitSessionRename,
|
onSubmitSessionRename: s.onSubmitSessionRename,
|
||||||
onRemoveSessionFromWorkspace: s.onRemoveSessionFromWorkspace,
|
onRemoveSessionFromWorkspace: s.onRemoveSessionFromWorkspace,
|
||||||
@@ -602,7 +594,6 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
|||||||
onSaveWorkspaceLayoutAsDefault: s.onSaveWorkspaceLayoutAsDefault,
|
onSaveWorkspaceLayoutAsDefault: s.onSaveWorkspaceLayoutAsDefault,
|
||||||
onStartSessionDrag: s.onStartSessionDrag,
|
onStartSessionDrag: s.onStartSessionDrag,
|
||||||
onEndSessionDrag: s.onEndSessionDrag,
|
onEndSessionDrag: s.onEndSessionDrag,
|
||||||
onDropWorkspacePane: handleWorkspacePanePointerDrop,
|
|
||||||
onSplitSession: s.onSplitSession,
|
onSplitSession: s.onSplitSession,
|
||||||
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
|
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
|
||||||
Palette: s.Palette,
|
Palette: s.Palette,
|
||||||
@@ -702,12 +693,10 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
|||||||
aiContextsByTabId,
|
aiContextsByTabId,
|
||||||
computeSplitHint,
|
computeSplitHint,
|
||||||
dropHint,
|
dropHint,
|
||||||
s.draggingSessionId,
|
|
||||||
focusedHost,
|
focusedHost,
|
||||||
focusedSessionId,
|
focusedSessionId,
|
||||||
s.restoreTerminalCwd,
|
s.restoreTerminalCwd,
|
||||||
handleWorkspaceDrop,
|
handleWorkspaceDrop,
|
||||||
handleWorkspacePanePointerDrop,
|
|
||||||
handleTerminalContextReaderChange,
|
handleTerminalContextReaderChange,
|
||||||
hibernateHiddenTabs,
|
hibernateHiddenTabs,
|
||||||
historySessionId,
|
historySessionId,
|
||||||
|
|||||||
@@ -2,21 +2,10 @@ import assert from "node:assert/strict";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { act, create } from "react-test-renderer";
|
|
||||||
import { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } from "../../application/state/sidePanelLiveStore.ts";
|
|
||||||
import { createEmptyWorkspaceRoot } from "../../domain/workspace.ts";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
import { TerminalLayerWorkspaceSection } from "./TerminalLayerWorkspaceSection.tsx";
|
import { TerminalLayerWorkspaceSection } from "./TerminalLayerWorkspaceSection.tsx";
|
||||||
|
|
||||||
test("magnified terminal panes use the resolved terminal accent for border chrome", () => {
|
|
||||||
const source = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
|
||||||
const paneSource = source.slice(source.indexOf('data-section="terminal-split-pane"'), source.indexOf("<Terminal\n"));
|
|
||||||
|
|
||||||
assert.match(paneSource, /isMagnified && magnifiedSurfaceBounds && "overflow-hidden rounded-md border shadow-2xl /);
|
|
||||||
assert.match(paneSource, /borderColor: isMagnified && magnifiedSurfaceBounds \? sessionAppearanceTheme\.colors\.cursor : undefined/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("terminal keyboard focus updates the pane selected for magnification", () => {
|
test("terminal keyboard focus updates the pane selected for magnification", () => {
|
||||||
const source = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
const source = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
@@ -170,37 +159,3 @@ test("workspace section passes resolved session host ids to terminal panes", ()
|
|||||||
|
|
||||||
assert.equal(sawResolvedIds, true);
|
assert.equal(sawResolvedIds, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test("empty workspace offers adding to the current workspace and hides the prompt once populated", async () => {
|
|
||||||
const requested: string[] = [];
|
|
||||||
const ctx = {
|
|
||||||
TerminalPanesHost: () => null,
|
|
||||||
activeResizers: [], keyBindings: [], hotkeyScheme: "mac", Array,
|
|
||||||
cn: (...values: unknown[]) => values.filter(Boolean).join(" "),
|
|
||||||
t: (key: string) => key,
|
|
||||||
onRequestAddToWorkspace: (id: string) => requested.push(id),
|
|
||||||
};
|
|
||||||
const workspace = { id: "empty-one", title: "Empty", viewMode: "split" as const, root: createEmptyWorkspaceRoot() };
|
|
||||||
const previous = sidePanelLiveStore.getSnapshot();
|
|
||||||
let renderer: ReturnType<typeof create> | undefined;
|
|
||||||
try {
|
|
||||||
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace });
|
|
||||||
await act(async () => { renderer = create(React.createElement(TerminalLayerWorkspaceSection, { ctx })); });
|
|
||||||
assert.equal(renderer!.root.findAllByProps({ "data-section": "empty-workspace" }).length, 1);
|
|
||||||
await act(async () => renderer!.root.findByType("button").props.onClick());
|
|
||||||
assert.deepEqual(requested, ["empty-one"]);
|
|
||||||
await act(async () => {
|
|
||||||
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: { ...workspace, id: "empty-two" } });
|
|
||||||
});
|
|
||||||
await act(async () => renderer!.root.findByType("button").props.onClick());
|
|
||||||
assert.deepEqual(requested, ["empty-one", "empty-two"]);
|
|
||||||
await act(async () => {
|
|
||||||
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: { ...workspace, root: { id: "pane", type: "pane", sessionId: "terminal" } } });
|
|
||||||
});
|
|
||||||
assert.equal(renderer!.root.findAllByProps({ "data-section": "empty-workspace" }).length, 0);
|
|
||||||
} finally {
|
|
||||||
await act(async () => renderer?.unmount());
|
|
||||||
sidePanelLiveStore.update(previous);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { memo, useEffect, useState, useSyncExternalStore } from 'react';
|
import React, { memo, useEffect, useState, useSyncExternalStore } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { LayoutGrid, Plus } from 'lucide-react';
|
|
||||||
import { Button } from '../ui/button';
|
|
||||||
import { collectSessionIds } from '../../domain/workspace';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSidePanelLiveSnapshot,
|
getSidePanelLiveSnapshot,
|
||||||
@@ -103,7 +100,6 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
|||||||
handleCommandExecuted,
|
handleCommandExecuted,
|
||||||
handleCommandSubmitted,
|
handleCommandSubmitted,
|
||||||
onSetWorkspaceFocusedSession,
|
onSetWorkspaceFocusedSession,
|
||||||
onRequestAddToWorkspace,
|
|
||||||
onSplitSession,
|
onSplitSession,
|
||||||
isBroadcastEnabled,
|
isBroadcastEnabled,
|
||||||
isComposeSendToAll,
|
isComposeSendToAll,
|
||||||
@@ -130,7 +126,6 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
|||||||
onReorderTabs,
|
onReorderTabs,
|
||||||
onStartSessionDrag,
|
onStartSessionDrag,
|
||||||
onEndSessionDrag,
|
onEndSessionDrag,
|
||||||
onDropWorkspacePane,
|
|
||||||
isGlobalBroadcastEnabled,
|
isGlobalBroadcastEnabled,
|
||||||
isGlobalComposeSendToAll,
|
isGlobalComposeSendToAll,
|
||||||
canUseGlobalBroadcast,
|
canUseGlobalBroadcast,
|
||||||
@@ -158,21 +153,6 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
|||||||
return (
|
return (
|
||||||
<div data-section="terminal-workspace-column" className="flex-1 min-h-0 flex flex-col">
|
<div data-section="terminal-workspace-column" className="flex-1 min-h-0 flex flex-col">
|
||||||
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
|
<div ref={workspaceInnerRef} className="flex-1 min-h-0 overflow-hidden relative">
|
||||||
{activeWorkspace && collectSessionIds(activeWorkspace.root).length === 0 && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-6" data-section="empty-workspace">
|
|
||||||
<div className="flex max-w-sm flex-col items-center gap-3 text-center">
|
|
||||||
<LayoutGrid size={32} className="text-muted-foreground/60" aria-hidden="true" />
|
|
||||||
<h2 className="text-sm font-medium">{t('terminal.layer.emptyWorkspace')}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('terminal.layer.emptyWorkspaceHint')}</p>
|
|
||||||
{onRequestAddToWorkspace && (
|
|
||||||
<Button className="mt-1 gap-2" onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}>
|
|
||||||
<Plus size={16} aria-hidden="true" />
|
|
||||||
{t('terminal.layer.addTerminal')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{draggingSessionId && !isFocusMode && (
|
{draggingSessionId && !isFocusMode && (
|
||||||
<div
|
<div
|
||||||
ref={workspaceOverlayRef}
|
ref={workspaceOverlayRef}
|
||||||
@@ -287,7 +267,6 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
|||||||
onReorderTabs={onReorderTabs}
|
onReorderTabs={onReorderTabs}
|
||||||
onStartSessionDrag={onStartSessionDrag}
|
onStartSessionDrag={onStartSessionDrag}
|
||||||
onEndSessionDrag={onEndSessionDrag}
|
onEndSessionDrag={onEndSessionDrag}
|
||||||
onDropWorkspacePane={onDropWorkspacePane}
|
|
||||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||||
@@ -361,7 +340,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
|||||||
aria-label={t('terminal.paneMagnification.restore')}
|
aria-label={t('terminal.paneMagnification.restore')}
|
||||||
>
|
>
|
||||||
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.terminal')}
|
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.terminal')}
|
||||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
|
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>,
|
</>,
|
||||||
|
|||||||
@@ -604,7 +604,6 @@ function SidePanelAiSlotInner({
|
|||||||
onOpenVaultHostFromChat,
|
onOpenVaultHostFromChat,
|
||||||
onOpenVaultSectionFromChat,
|
onOpenVaultSectionFromChat,
|
||||||
onOpenVaultSnippetFromChat,
|
onOpenVaultSnippetFromChat,
|
||||||
onSetWorkspaceAnchorChat,
|
|
||||||
validAIScopeTargetIds,
|
validAIScopeTargetIds,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
const workspaces = ctx.workspaces as Workspace[];
|
const workspaces = ctx.workspaces as Workspace[];
|
||||||
@@ -667,7 +666,6 @@ function SidePanelAiSlotInner({
|
|||||||
onOpenVaultHostFromChat={onOpenVaultHostFromChat}
|
onOpenVaultHostFromChat={onOpenVaultHostFromChat}
|
||||||
onOpenVaultSectionFromChat={onOpenVaultSectionFromChat}
|
onOpenVaultSectionFromChat={onOpenVaultSectionFromChat}
|
||||||
onOpenVaultSnippetFromChat={onOpenVaultSnippetFromChat}
|
onOpenVaultSnippetFromChat={onOpenVaultSnippetFromChat}
|
||||||
onSetWorkspaceAnchorChat={onSetWorkspaceAnchorChat as ((workspaceId: string, chatId: string | null) => void) | undefined}
|
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
/>
|
/>
|
||||||
</AISidePanelStateRoot>
|
</AISidePanelStateRoot>
|
||||||
|
|||||||
@@ -361,7 +361,6 @@ const WORKSPACE_CTX_KEYS = [
|
|||||||
'setDropHint',
|
'setDropHint',
|
||||||
'computeSplitHint',
|
'computeSplitHint',
|
||||||
'handleWorkspaceDrop',
|
'handleWorkspaceDrop',
|
||||||
'onRequestAddToWorkspace',
|
|
||||||
'sessions',
|
'sessions',
|
||||||
'sessionHostsMap',
|
'sessionHostsMap',
|
||||||
'sftpAuthHostBySessionId',
|
'sftpAuthHostBySessionId',
|
||||||
@@ -421,7 +420,6 @@ const WORKSPACE_CTX_KEYS = [
|
|||||||
'handleCommandExecuted',
|
'handleCommandExecuted',
|
||||||
'handleCommandSubmitted',
|
'handleCommandSubmitted',
|
||||||
'onSetWorkspaceFocusedSession',
|
'onSetWorkspaceFocusedSession',
|
||||||
'onSetWorkspaceAnchorChat',
|
|
||||||
'onSplitSession',
|
'onSplitSession',
|
||||||
'isBroadcastEnabled',
|
'isBroadcastEnabled',
|
||||||
'isComposeSendToAll',
|
'isComposeSendToAll',
|
||||||
@@ -451,7 +449,6 @@ const WORKSPACE_CTX_KEYS = [
|
|||||||
'onReorderTabs',
|
'onReorderTabs',
|
||||||
'onStartSessionDrag',
|
'onStartSessionDrag',
|
||||||
'onEndSessionDrag',
|
'onEndSessionDrag',
|
||||||
'onDropWorkspacePane',
|
|
||||||
'isGlobalBroadcastEnabled',
|
'isGlobalBroadcastEnabled',
|
||||||
'isGlobalComposeSendToAll',
|
'isGlobalComposeSendToAll',
|
||||||
'canUseGlobalBroadcast',
|
'canUseGlobalBroadcast',
|
||||||
@@ -534,7 +531,6 @@ export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boole
|
|||||||
&& eq(prev, next, 'onCopySessionToNewWindow')
|
&& eq(prev, next, 'onCopySessionToNewWindow')
|
||||||
&& eq(prev, next, 'onRemoveSessionFromWorkspace')
|
&& eq(prev, next, 'onRemoveSessionFromWorkspace')
|
||||||
&& eq(prev, next, 'onSetWorkspaceFocusedSession')
|
&& eq(prev, next, 'onSetWorkspaceFocusedSession')
|
||||||
&& eq(prev, next, 'onSetWorkspaceAnchorChat')
|
|
||||||
&& eq(prev, next, 'onToggleWorkspaceViewMode')
|
&& eq(prev, next, 'onToggleWorkspaceViewMode')
|
||||||
&& eq(prev, next, 'onSubmitSessionRename');
|
&& eq(prev, next, 'onSubmitSessionRename');
|
||||||
}
|
}
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import React, { memo } from 'react';
|
|
||||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
|
||||||
import type { TerminalSession, Workspace } from '../../types';
|
|
||||||
import { useTerminalWorkspaceLayout } from './useTerminalWorkspaceLayout';
|
|
||||||
|
|
||||||
test('workspace switches keep mounted pane drop props stable and saved callbacks use the latest layout', async (t) => {
|
|
||||||
const previousActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
|
||||||
let renderer: ReactTestRenderer | undefined;
|
|
||||||
t.after(async () => {
|
|
||||||
await act(async () => renderer?.unmount());
|
|
||||||
if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment);
|
|
||||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessions = ['a1', 'a2', 'b1', 'b2'].map(id => ({
|
|
||||||
id, workspaceId: id[0],
|
|
||||||
})) as TerminalSession[];
|
|
||||||
const workspaces = ['a', 'b'].map(id => ({
|
|
||||||
id, title: id,
|
|
||||||
root: {
|
|
||||||
id: `split-${id}`, type: 'split', direction: id === 'a' ? 'vertical' : 'horizontal',
|
|
||||||
children: sessions.filter(session => session.workspaceId === id)
|
|
||||||
.map(session => ({ id: `pane-${session.id}`, type: 'pane', sessionId: session.id })),
|
|
||||||
},
|
|
||||||
})) as Workspace[];
|
|
||||||
type DropHandler = ReturnType<typeof useTerminalWorkspaceLayout>['handleWorkspacePanePointerDrop'];
|
|
||||||
type MoveHandler = Parameters<typeof useTerminalWorkspaceLayout>[0]['onMoveWorkspacePane'];
|
|
||||||
const calls: Array<{ handler: string; args: Parameters<MoveHandler> }> = [];
|
|
||||||
const firstHandler: MoveHandler = (...args) => calls.push({ handler: 'first', args });
|
|
||||||
const secondHandler: MoveHandler = (...args) => calls.push({ handler: 'second', args });
|
|
||||||
const noop = () => {};
|
|
||||||
const keepHidden = () => true;
|
|
||||||
const renders = new Map<string, number>();
|
|
||||||
const Pane = memo(function Pane({ session, onDrop }: { session: TerminalSession; onDrop: DropHandler }) {
|
|
||||||
renders.set(session.id, (renders.get(session.id) ?? 0) + 1);
|
|
||||||
return React.createElement('button', { onClick: () => onDrop(session.id, { clientX: 0, clientY: 0 }) });
|
|
||||||
});
|
|
||||||
let layout!: ReturnType<typeof useTerminalWorkspaceLayout>;
|
|
||||||
function Harness({ activeId, magnified = false, onMove = firstHandler }: {
|
|
||||||
activeId: string; magnified?: boolean; onMove?: MoveHandler;
|
|
||||||
}) {
|
|
||||||
layout = useTerminalWorkspaceLayout({
|
|
||||||
activeSession: undefined, activeWorkspace: workspaces.find(workspace => workspace.id === activeId),
|
|
||||||
isFocusMode: false, hasMagnifiedPane: magnified,
|
|
||||||
shouldKeepHiddenWorkspaceLaidOut: keepHidden,
|
|
||||||
onAddSessionToWorkspace: noop, onCreateWorkspaceFromSessions: noop,
|
|
||||||
onMoveWorkspacePane: onMove, draggingSession: null,
|
|
||||||
onSetDraggingSessionId: noop, onUpdateSplitSizes: noop, sessions, workspaces,
|
|
||||||
});
|
|
||||||
// Like TerminalPanesHost, retain every pane while the active tab changes.
|
|
||||||
// Ordinary memo compares onDrop; ignoring it would hide the regression.
|
|
||||||
return React.createElement(React.Fragment, null, sessions.map(session => React.createElement(Pane, {
|
|
||||||
key: session.id, session, onDrop: layout.handleWorkspacePanePointerDrop,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
const render = (activeId: string, magnified = false, onMove = firstHandler) =>
|
|
||||||
React.createElement(Harness, { activeId, magnified, onMove });
|
|
||||||
await act(async () => { renderer = create(render('a')); });
|
|
||||||
let size = { width: 1000, height: 500 };
|
|
||||||
layout.workspaceInnerRef.current = {
|
|
||||||
getBoundingClientRect: () => ({ left: 0, top: 0, ...size }),
|
|
||||||
} as HTMLDivElement;
|
|
||||||
await act(async () => layout.setWorkspaceArea(size));
|
|
||||||
const savedDrop = layout.handleWorkspacePanePointerDrop;
|
|
||||||
const initialRenders = [...renders];
|
|
||||||
await act(async () => renderer!.update(render('b')));
|
|
||||||
await act(async () => renderer!.update(render('a')));
|
|
||||||
assert.deepEqual([...renders], initialRenders, 'A -> B -> A must not repaint mounted hidden panes for a callback change');
|
|
||||||
assert.equal(layout.handleWorkspacePanePointerDrop, savedDrop);
|
|
||||||
|
|
||||||
await act(async () => renderer!.update(render('b')));
|
|
||||||
await act(async () => savedDrop('a1', { clientX: 800, clientY: 400 }));
|
|
||||||
assert.equal(calls.length, 0, 'an old gesture cannot move a pane from the inactive workspace');
|
|
||||||
await act(async () => savedDrop('b1', { clientX: 800, clientY: 400 }));
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
assert.deepEqual(calls[0], { handler: 'first', args: ['b', 'b1', {
|
|
||||||
direction: 'vertical', position: 'right', targetSessionId: 'b2',
|
|
||||||
rect: { x: 500, y: 250, w: 500, h: 250 },
|
|
||||||
}] });
|
|
||||||
|
|
||||||
await act(async () => renderer!.update(render('b', true, secondHandler)));
|
|
||||||
await act(async () => savedDrop('b1', { clientX: 800, clientY: 400 }));
|
|
||||||
assert.equal(calls.length, 1, 'the saved callback honors current magnification');
|
|
||||||
size = { width: 2000, height: 1000 };
|
|
||||||
await act(async () => layout.setWorkspaceArea(size));
|
|
||||||
await act(async () => renderer!.update(render('b', false, secondHandler)));
|
|
||||||
await act(async () => savedDrop('b1', { clientX: 1600, clientY: 800 }));
|
|
||||||
assert.deepEqual(calls[1], { handler: 'second', args: ['b', 'b1', {
|
|
||||||
direction: 'vertical', position: 'right', targetSessionId: 'b2',
|
|
||||||
rect: { x: 1000, y: 500, w: 1000, h: 500 },
|
|
||||||
}] });
|
|
||||||
assert.equal(layout.handleWorkspacePanePointerDrop, savedDrop);
|
|
||||||
assert.deepEqual([...renders], initialRenders, 'layout and handler changes also retain the stable pane callback');
|
|
||||||
});
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { registerHooks } from 'node:module';
|
|
||||||
import test from 'node:test';
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
|
||||||
import type { TerminalSession, Workspace } from '../../types';
|
|
||||||
import { useTerminalWorkspaceLayout } from './useTerminalWorkspaceLayout';
|
|
||||||
|
|
||||||
registerHooks({
|
|
||||||
resolve(specifier, context, nextResolve) {
|
|
||||||
if (specifier === '../Terminal' && context.parentURL?.endsWith('/TerminalLayerSupport.tsx')) {
|
|
||||||
return { url: 'data:text/javascript,export default function Terminal(){return null}', shortCircuit: true };
|
|
||||||
}
|
|
||||||
return nextResolve(specifier, context);
|
|
||||||
},
|
|
||||||
load(url, context, nextLoad) {
|
|
||||||
if (url.endsWith('.css')) return { format: 'module', source: 'export default {};', shortCircuit: true };
|
|
||||||
return nextLoad(url, context);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { useWorkspaceDetachPointerDrag } = await import('./TerminalLayerSupport');
|
|
||||||
|
|
||||||
type Listener = EventListenerOrEventListenerObject;
|
|
||||||
|
|
||||||
function eventSurface() {
|
|
||||||
const listeners = new Map<string, Map<Listener, boolean>>();
|
|
||||||
return {
|
|
||||||
addEventListener(type: string, listener: Listener, capture = false) {
|
|
||||||
const entries = listeners.get(type) ?? new Map<Listener, boolean>();
|
|
||||||
entries.set(listener, capture);
|
|
||||||
listeners.set(type, entries);
|
|
||||||
},
|
|
||||||
removeEventListener(type: string, listener: Listener, capture = false) {
|
|
||||||
const entries = listeners.get(type);
|
|
||||||
if (entries?.get(listener) === capture) entries.delete(listener);
|
|
||||||
},
|
|
||||||
dispatch(type: string, capture: boolean, event: PointerEvent) {
|
|
||||||
for (const [listener, registeredCapture] of [...(listeners.get(type) ?? [])]) {
|
|
||||||
if (registeredCapture !== capture) continue;
|
|
||||||
if (typeof listener === 'function') listener(event);
|
|
||||||
else listener.handleEvent(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
count(type: string) { return listeners.get(type)?.size ?? 0; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const scenario of ['move', 'release-only', 'self', 'cancel', 'blur', 'magnified', 'magnified-detach'] as const) {
|
|
||||||
test(`member pane gesture: ${scenario}`, async (t) => {
|
|
||||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
||||||
const previousActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
const windowSurface = eventSurface();
|
|
||||||
const documentSurface = eventSurface();
|
|
||||||
const dragElements = new Set<object>();
|
|
||||||
const ownerDocument = {
|
|
||||||
...documentSurface,
|
|
||||||
defaultView: windowSurface,
|
|
||||||
body: { appendChild: (element: object) => dragElements.add(element) },
|
|
||||||
createElement: () => {
|
|
||||||
const element = { style: {}, remove: () => dragElements.delete(element) };
|
|
||||||
return element;
|
|
||||||
},
|
|
||||||
querySelector: () => scenario === 'magnified-detach' ? {
|
|
||||||
getBoundingClientRect: () => ({ left: 0, right: 1000, top: -50, bottom: 0 }),
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
} : null,
|
|
||||||
} as unknown as Document;
|
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: windowSurface });
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
|
||||||
let renderer: ReactTestRenderer | undefined;
|
|
||||||
t.after(async () => {
|
|
||||||
await act(async () => renderer?.unmount());
|
|
||||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
|
||||||
else Reflect.deleteProperty(globalThis, 'window');
|
|
||||||
if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment);
|
|
||||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
const sessions = [
|
|
||||||
{ id: 'pane-a', workspaceId: 'workspace' },
|
|
||||||
{ id: 'pane-b', workspaceId: 'workspace' },
|
|
||||||
] as TerminalSession[];
|
|
||||||
const workspace = {
|
|
||||||
id: 'workspace',
|
|
||||||
root: {
|
|
||||||
id: 'split', type: 'split', direction: 'vertical',
|
|
||||||
children: sessions.map(session => ({ id: session.id, type: 'pane', sessionId: session.id })),
|
|
||||||
},
|
|
||||||
} as Workspace;
|
|
||||||
const noop = () => {};
|
|
||||||
const events: string[] = [];
|
|
||||||
const moves: unknown[][] = [];
|
|
||||||
const detachedIds: string[] = [];
|
|
||||||
let hasMagnifiedPane = scenario === 'magnified' || scenario === 'magnified-detach';
|
|
||||||
let pointerDown!: ReturnType<typeof useWorkspaceDetachPointerDrag>;
|
|
||||||
let layout!: ReturnType<typeof useTerminalWorkspaceLayout>;
|
|
||||||
function Harness() {
|
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
||||||
layout = useTerminalWorkspaceLayout({
|
|
||||||
activeSession: undefined, activeWorkspace: workspace, isFocusMode: false,
|
|
||||||
hasMagnifiedPane,
|
|
||||||
shouldKeepHiddenWorkspaceLaidOut: () => false,
|
|
||||||
onAddSessionToWorkspace: noop, onCreateWorkspaceFromSessions: noop,
|
|
||||||
onMoveWorkspacePane: (...args) => { events.push('drop'); moves.push(args); },
|
|
||||||
draggingSession: sessions.find(session => session.id === draggingId),
|
|
||||||
onSetDraggingSessionId: setDraggingId, onUpdateSplitSizes: noop,
|
|
||||||
sessions, workspaces: [workspace],
|
|
||||||
});
|
|
||||||
pointerDown = useWorkspaceDetachPointerDrag({
|
|
||||||
inActiveWorkspace: true, session: sessions[0],
|
|
||||||
onStartSessionDrag: id => { events.push('start'); setDraggingId(id); },
|
|
||||||
onEndSessionDrag: () => { events.push('end'); setDraggingId(null); },
|
|
||||||
onDropWorkspacePane: layout.handleWorkspacePanePointerDrop,
|
|
||||||
onRemoveSessionFromWorkspace: id => { events.push('detach'); detachedIds.push(id); },
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
|
||||||
layout.workspaceInnerRef.current = {
|
|
||||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 1000, height: 500 }),
|
|
||||||
} as HTMLDivElement;
|
|
||||||
await act(async () => layout.setWorkspaceArea({ width: 1000, height: 500 }));
|
|
||||||
const destination = {
|
|
||||||
clientX: scenario === 'self' ? 100 : 800,
|
|
||||||
clientY: scenario === 'magnified-detach' ? -20 : 100,
|
|
||||||
} as PointerEvent;
|
|
||||||
await act(async () => {
|
|
||||||
pointerDown({
|
|
||||||
button: 0, clientX: 10, clientY: 10, currentTarget: { ownerDocument },
|
|
||||||
preventDefault: noop, stopPropagation: noop,
|
|
||||||
} as unknown as React.PointerEvent<HTMLElement>);
|
|
||||||
if (scenario !== 'release-only') documentSurface.dispatch('pointermove', true, destination);
|
|
||||||
});
|
|
||||||
if (scenario !== 'release-only') {
|
|
||||||
await act(async () => {
|
|
||||||
windowSurface.dispatch('pointermove', true, destination);
|
|
||||||
documentSurface.dispatch('pointermove', true, destination);
|
|
||||||
windowSurface.dispatch('pointermove', false, destination);
|
|
||||||
});
|
|
||||||
assert.equal(layout.dropHint?.targetSessionId, scenario === 'self' || hasMagnifiedPane ? undefined : 'pane-b');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include a commit between native listener phases. Release must complete in
|
|
||||||
// the gesture owner, even when no move was delivered before pointerup.
|
|
||||||
const endingEvent = scenario === 'cancel' ? 'pointercancel' : 'pointerup';
|
|
||||||
if (scenario === 'blur') {
|
|
||||||
await act(async () => windowSurface.dispatch('blur', false, destination));
|
|
||||||
} else {
|
|
||||||
await act(async () => {
|
|
||||||
windowSurface.dispatch(endingEvent, true, destination);
|
|
||||||
documentSurface.dispatch(endingEvent, true, destination);
|
|
||||||
});
|
|
||||||
await act(async () => windowSurface.dispatch(endingEvent, false, destination));
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldDrop = scenario === 'move' || scenario === 'release-only';
|
|
||||||
assert.deepEqual(events, shouldDrop ? ['start', 'drop', 'end']
|
|
||||||
: scenario === 'magnified-detach' ? ['start', 'end', 'detach'] : ['start', 'end']);
|
|
||||||
assert.deepEqual(moves, shouldDrop ? [['workspace', 'pane-a', {
|
|
||||||
direction: 'horizontal', position: 'top', targetSessionId: 'pane-b',
|
|
||||||
rect: { x: 500, y: 0, w: 500, h: 250 },
|
|
||||||
}]] : []);
|
|
||||||
assert.deepEqual(detachedIds, scenario === 'magnified-detach' ? ['pane-a'] : []);
|
|
||||||
assert.equal(layout.dropHint, null);
|
|
||||||
assert.equal(dragElements.size, 0);
|
|
||||||
for (const type of ['pointermove', 'pointerup', 'pointercancel']) {
|
|
||||||
assert.equal(documentSurface.count(type), 0);
|
|
||||||
assert.equal(windowSurface.count(type), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scenario === 'magnified') {
|
|
||||||
hasMagnifiedPane = false;
|
|
||||||
await act(async () => renderer!.update(React.createElement(Harness)));
|
|
||||||
await act(async () => {
|
|
||||||
pointerDown({
|
|
||||||
button: 0, clientX: 10, clientY: 10, currentTarget: { ownerDocument },
|
|
||||||
preventDefault: noop, stopPropagation: noop,
|
|
||||||
} as unknown as React.PointerEvent<HTMLElement>);
|
|
||||||
documentSurface.dispatch('pointermove', true, destination);
|
|
||||||
});
|
|
||||||
await act(async () => windowSurface.dispatch('pointermove', false, destination));
|
|
||||||
assert.equal(layout.dropHint?.targetSessionId, 'pane-b', 'restoring the tiled view restores preview');
|
|
||||||
await act(async () => documentSurface.dispatch('pointerup', true, destination));
|
|
||||||
assert.equal(moves.length, 1, 'restoring the tiled view permits exactly one move');
|
|
||||||
assert.deepEqual(events, ['start', 'end', 'start', 'drop', 'end']);
|
|
||||||
assert.equal(layout.dropHint, null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { type DragEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
|
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
|
||||||
import type { TerminalSession, Workspace, WorkspaceNode } from '../../types';
|
import type { TerminalSession, Workspace, WorkspaceNode } from '../../types';
|
||||||
import type { ResizerHandle, SplitHint, WorkspaceRect } from './TerminalLayerSupport';
|
import type { ResizerHandle, SplitHint, WorkspaceRect } from './TerminalLayerSupport';
|
||||||
import { isSameSplitHint } from './splitHintEquality';
|
|
||||||
import {
|
import {
|
||||||
computeSplitSizesFromDelta,
|
computeSplitSizesFromDelta,
|
||||||
patchWorkspaceSplitSizes,
|
patchWorkspaceSplitSizes,
|
||||||
@@ -27,15 +26,9 @@ interface UseTerminalWorkspaceLayoutOptions {
|
|||||||
activeSession: TerminalSession | undefined;
|
activeSession: TerminalSession | undefined;
|
||||||
activeWorkspace: Workspace | undefined;
|
activeWorkspace: Workspace | undefined;
|
||||||
isFocusMode: boolean;
|
isFocusMode: boolean;
|
||||||
hasMagnifiedPane?: boolean;
|
|
||||||
shouldKeepHiddenWorkspaceLaidOut: (workspace: Workspace) => boolean;
|
shouldKeepHiddenWorkspaceLaidOut: (workspace: Workspace) => boolean;
|
||||||
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
|
||||||
/** Issue #4037: rearrange an existing member pane within its own workspace. */
|
|
||||||
onMoveWorkspacePane: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
|
|
||||||
/** Session currently being dragged, if any. Member panes drag via pointer
|
|
||||||
* events (not HTML5 DnD), so this drives the in-workspace preview + drop. */
|
|
||||||
draggingSession: TerminalSession | null | undefined;
|
|
||||||
onSetDraggingSessionId: (id: string | null) => void;
|
onSetDraggingSessionId: (id: string | null) => void;
|
||||||
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
||||||
sessions: TerminalSession[];
|
sessions: TerminalSession[];
|
||||||
@@ -46,12 +39,9 @@ export function useTerminalWorkspaceLayout({
|
|||||||
activeSession,
|
activeSession,
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
isFocusMode,
|
isFocusMode,
|
||||||
hasMagnifiedPane = false,
|
|
||||||
shouldKeepHiddenWorkspaceLaidOut,
|
shouldKeepHiddenWorkspaceLaidOut,
|
||||||
onAddSessionToWorkspace,
|
onAddSessionToWorkspace,
|
||||||
onCreateWorkspaceFromSessions,
|
onCreateWorkspaceFromSessions,
|
||||||
onMoveWorkspacePane,
|
|
||||||
draggingSession,
|
|
||||||
onSetDraggingSessionId,
|
onSetDraggingSessionId,
|
||||||
onUpdateSplitSizes,
|
onUpdateSplitSizes,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -280,12 +270,8 @@ export function useTerminalWorkspaceLayout({
|
|||||||
[activeWorkspace, workspaceArea, collectResizers, workspaceForLayout],
|
[activeWorkspace, workspaceArea, collectResizers, workspaceForLayout],
|
||||||
);
|
);
|
||||||
|
|
||||||
// DragEvent and PointerEvent both satisfy this shape; the hook only reads
|
const computeSplitHint = useCallback((e: DragEvent): SplitHint => {
|
||||||
// coordinates, so member-pane pointer drags (#4037) reuse the same hint
|
if (isFocusMode) return null;
|
||||||
// machinery as HTML5 orphan drags.
|
|
||||||
const computeSplitHint = useCallback((e: { clientX: number; clientY: number }): SplitHint => {
|
|
||||||
// The tiled rectangles do not describe the visible magnified surface.
|
|
||||||
if (isFocusMode || hasMagnifiedPane) return null;
|
|
||||||
const surface = workspaceOverlayRef.current || workspaceInnerRef.current || workspaceOuterRef.current;
|
const surface = workspaceOverlayRef.current || workspaceInnerRef.current || workspaceOuterRef.current;
|
||||||
if (!surface || !workspaceArea.width || !workspaceArea.height) return null;
|
if (!surface || !workspaceArea.width || !workspaceArea.height) return null;
|
||||||
const rect = surface.getBoundingClientRect();
|
const rect = surface.getBoundingClientRect();
|
||||||
@@ -334,7 +320,7 @@ export function useTerminalWorkspaceLayout({
|
|||||||
targetSessionId,
|
targetSessionId,
|
||||||
rect: previewRect,
|
rect: previewRect,
|
||||||
};
|
};
|
||||||
}, [isFocusMode, hasMagnifiedPane, workspaceArea, activeWorkspaceRects, workspaceOverlayRef, workspaceInnerRef, workspaceOuterRef]);
|
}, [isFocusMode, workspaceArea, activeWorkspaceRects, workspaceOverlayRef, workspaceInnerRef, workspaceOuterRef]);
|
||||||
|
|
||||||
const handleWorkspaceDrop = useCallback((e: DragEvent) => {
|
const handleWorkspaceDrop = useCallback((e: DragEvent) => {
|
||||||
if (isFocusMode) return;
|
if (isFocusMode) return;
|
||||||
@@ -345,67 +331,18 @@ export function useTerminalWorkspaceLayout({
|
|||||||
setDropHint(null);
|
setDropHint(null);
|
||||||
onSetDraggingSessionId(null);
|
onSetDraggingSessionId(null);
|
||||||
if (!hint) return;
|
if (!hint) return;
|
||||||
|
|
||||||
if (activeWorkspace) {
|
if (activeWorkspace) {
|
||||||
const draggedSession = sessions.find(s => s.id === draggedSessionId);
|
const draggedSession = sessions.find(s => s.id === draggedSessionId);
|
||||||
if (!draggedSession) return;
|
if (!draggedSession || draggedSession.workspaceId) return;
|
||||||
if (draggedSession.workspaceId) {
|
|
||||||
// Issue #4037 scope: an existing member pane can only be dropped
|
|
||||||
// back into its own workspace (rearrange). Cross-workspace moves
|
|
||||||
// stay rejected — binding policy (#4098).
|
|
||||||
if (draggedSession.workspaceId === activeWorkspace.id) {
|
|
||||||
onMoveWorkspacePane(activeWorkspace.id, draggedSessionId, hint);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onAddSessionToWorkspace(activeWorkspace.id, draggedSessionId, hint);
|
onAddSessionToWorkspace(activeWorkspace.id, draggedSessionId, hint);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSession) {
|
if (activeSession) {
|
||||||
onCreateWorkspaceFromSessions(activeSession.id, draggedSessionId, hint);
|
onCreateWorkspaceFromSessions(activeSession.id, draggedSessionId, hint);
|
||||||
}
|
}
|
||||||
}, [isFocusMode, computeSplitHint, setDropHint, onSetDraggingSessionId, activeWorkspace, sessions, onAddSessionToWorkspace, onMoveWorkspacePane, activeSession, onCreateWorkspaceFromSessions]);
|
}, [isFocusMode, computeSplitHint, setDropHint, onSetDraggingSessionId, activeWorkspace, sessions, onAddSessionToWorkspace, activeSession, onCreateWorkspaceFromSessions]);
|
||||||
|
|
||||||
// All mounted panes share this callback. Keep its identity across tab/layout
|
|
||||||
// changes, but resolve a saved gesture against the latest committed state.
|
|
||||||
const paneDropStateRef = useRef({ isFocusMode, activeWorkspace, sessions, computeSplitHint, onMoveWorkspacePane });
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
paneDropStateRef.current = { isFocusMode, activeWorkspace, sessions, computeSplitHint, onMoveWorkspacePane };
|
|
||||||
}, [isFocusMode, activeWorkspace, sessions, computeSplitHint, onMoveWorkspacePane]);
|
|
||||||
|
|
||||||
// Commit directly from the gesture owner before it clears drag state, even
|
|
||||||
// when the release crosses the threshold without a delivered move.
|
|
||||||
const handleWorkspacePanePointerDrop = useCallback((sessionId: string, point: { clientX: number; clientY: number }) => {
|
|
||||||
setDropHint(null);
|
|
||||||
const current = paneDropStateRef.current;
|
|
||||||
if (current.isFocusMode || !current.activeWorkspace) return;
|
|
||||||
const session = current.sessions.find((candidate) => candidate.id === sessionId);
|
|
||||||
if (session?.workspaceId !== current.activeWorkspace.id) return;
|
|
||||||
const hint = current.computeSplitHint(point);
|
|
||||||
if (!hint || hint.targetSessionId === sessionId) return;
|
|
||||||
current.onMoveWorkspacePane(current.activeWorkspace.id, sessionId, hint);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Pointer movement only updates the visual preview. The gesture owner handles
|
|
||||||
// release/cancellation, so completion does not depend on listener ordering.
|
|
||||||
const isMemberPaneDrag = !!draggingSession?.workspaceId
|
|
||||||
&& draggingSession.workspaceId === activeWorkspace?.id
|
|
||||||
&& !isFocusMode
|
|
||||||
&& !hasMagnifiedPane;
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMemberPaneDrag || !draggingSession) {
|
|
||||||
setDropHint(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const onPointerMove = (event: PointerEvent) => {
|
|
||||||
const candidate = computeSplitHint(event);
|
|
||||||
const hint = candidate?.targetSessionId === draggingSession.id ? null : candidate;
|
|
||||||
setDropHint((prev) => (isSameSplitHint(prev, hint) ? prev : hint));
|
|
||||||
};
|
|
||||||
window.addEventListener('pointermove', onPointerMove);
|
|
||||||
return () => window.removeEventListener('pointermove', onPointerMove);
|
|
||||||
}, [isMemberPaneDrag, draggingSession, computeSplitHint]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeResizers,
|
activeResizers,
|
||||||
@@ -413,7 +350,6 @@ export function useTerminalWorkspaceLayout({
|
|||||||
dropHint,
|
dropHint,
|
||||||
findSplitNode,
|
findSplitNode,
|
||||||
handleWorkspaceDrop,
|
handleWorkspaceDrop,
|
||||||
handleWorkspacePanePointerDrop,
|
|
||||||
resizing,
|
resizing,
|
||||||
setDropHint,
|
setDropHint,
|
||||||
setResizing,
|
setResizing,
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
// Source-contract tests for issue #4037 (rearrange member panes within a
|
|
||||||
// workspace). The layout hook is render-heavy; these pin the wiring the way
|
|
||||||
// TerminalLayerTabBridge.test.ts pins its bridge.
|
|
||||||
|
|
||||||
const layoutSource = readFileSync(new URL('./useTerminalWorkspaceLayout.ts', import.meta.url), 'utf8');
|
|
||||||
const bridgeSource = readFileSync(new URL('./TerminalLayerTabBridge.tsx', import.meta.url), 'utf8');
|
|
||||||
const stateSource = readFileSync(new URL('../../application/state/useSessionState.ts', import.meta.url), 'utf8');
|
|
||||||
|
|
||||||
test('drop handler rearranges same-workspace panes and still rejects cross-workspace drops', () => {
|
|
||||||
assert.match(layoutSource, /draggedSession\.workspaceId === activeWorkspace\.id/);
|
|
||||||
// Cross-workspace drops must fall through without calling either action:
|
|
||||||
// the branch only moves when the ids match, then returns unconditionally.
|
|
||||||
const dropBody = layoutSource.slice(layoutSource.indexOf('const handleWorkspaceDrop'));
|
|
||||||
assert.match(dropBody, /if \(draggedSession\.workspaceId === activeWorkspace\.id\) \{\s*onMoveWorkspacePane/);
|
|
||||||
assert.match(dropBody, /onAddSessionToWorkspace\(activeWorkspace\.id, draggedSessionId, hint\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('member pane drags drive the split-hint preview over the pointer stream', () => {
|
|
||||||
assert.match(layoutSource, /isMemberPaneDrag/);
|
|
||||||
assert.match(layoutSource, /window\.addEventListener\('pointermove', onPointerMove\)/);
|
|
||||||
// Release is owned by the pane gesture, not a competing window listener.
|
|
||||||
assert.match(bridgeSource, /onDropWorkspacePane: handleWorkspacePanePointerDrop/);
|
|
||||||
assert.doesNotMatch(layoutSource, /window\.addEventListener\('pointerup'/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the bridge threads the move action and the dragged member session', () => {
|
|
||||||
assert.match(bridgeSource, /onMoveWorkspacePane: s\.onMoveWorkspacePane/);
|
|
||||||
assert.match(bridgeSource, /draggingSession: s\.draggingSessionId/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('moveWorkspacePane mutates only the workspace pane tree', () => {
|
|
||||||
const start = stateSource.indexOf('const moveWorkspacePane = useCallback');
|
|
||||||
const end = stateSource.indexOf('const createWorkspaceFromSessions = useCallback', start);
|
|
||||||
assert.ok(start > -1 && end > start, 'moveWorkspacePane callback exists');
|
|
||||||
const body = stateSource.slice(start, end);
|
|
||||||
assert.match(body, /moveWorkspacePaneInWorkspaces\(prev, workspaceId, sessionId, hint\)/);
|
|
||||||
// Identity guarantee: no session, tab-order, focus or AI-state writes.
|
|
||||||
assert.doesNotMatch(body, /setSessions|setTabOrder|setActiveTabId|setWorkspaceFocusedSession|anchorChat|activeSessionIdMap/);
|
|
||||||
});
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Autocomplete description overlay: zip/unzip/make/nano batch
|
|
||||||
|
|
||||||
This batch continues the offline Fig description overlay after the `touch`
|
|
||||||
pilot. The Fig specs keep their English names, arguments, and structure; only
|
|
||||||
suggestion descriptions are localized for `zh-CN` and generated into `zh-TW`.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
| Source | Version / location | Used for |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `@withfig/autocomplete` | `2.692.3`, npm `gitHead` `aef52acff84c45edde61ae610cc2c964802b9a38`; `src/{zip,unzip,make,nano}.ts` and compiled `build/*.js` | English keys, option names, and arguments |
|
|
||||||
| `jaywcjlove/linux-command` | repository HEAD `8041aeda04762e136078decb2de201ec4348527c` (2026-10-31); `command/zip.md`, `command/unzip.md`, `command/make.md`, `command/nano.md` | Simplified-Chinese terminology and common examples |
|
|
||||||
| GNU Make | local `GNU Make 3.81`, `make --help` and `make(1)` | Make option semantics |
|
|
||||||
| Apple gnumake | tag `gnumake-136`, commit `d16cdb2067133c031532d19c5a06d2e74993ef2a`, [`main.c`](https://github.com/apple-oss-distributions/gnumake/blob/d16cdb2067133c031532d19c5a06d2e74993ef2a/main.c) | `-N` NeXT compatibility extension |
|
|
||||||
| Info-ZIP | local `zip 3.0` and `unzip 6.00` (Apple builds), `-h`/manual output | ZIP option semantics |
|
|
||||||
| ArchWiki | `Character encoding`, checked 2026-11-04, `https://wiki.archlinux.org/title/Character_encoding` | Confirm patched unzip uses `-O gbk` for legacy Windows filenames |
|
|
||||||
|
|
||||||
Checked on 2026-11-04. Every adopted English key below occurs only in the
|
|
||||||
`zip`, `unzip`, `make`, or `nano` spec in the bundled Fig package and local
|
|
||||||
`electron/specs`; no other command shares these keys. Where the Fig English text
|
|
||||||
conflicts with the verified implementation, the zh-CN text follows the verified
|
|
||||||
behavior and the discrepancy is recorded below.
|
|
||||||
|
|
||||||
## Adopted entries
|
|
||||||
|
|
||||||
### zip
|
|
||||||
|
|
||||||
| Entry | English | zh-CN |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| command | Package and compress (archive) files into zip file | 将文件打包并压缩为 zip 文件 |
|
|
||||||
| argument | Name of archive | 压缩包名称 |
|
|
||||||
| `-r` | Package and compress a directory and its contents, recursively | 递归打包并压缩目录及其内容 |
|
|
||||||
| `-9` | Archive a directory and its contents with the highest level [9] of compression | 使用最高压缩级别 9 |
|
|
||||||
|
|
||||||
### unzip
|
|
||||||
|
|
||||||
| Entry | English | zh-CN |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| command | Extract compressed files in a ZIP archive | 解压 ZIP 压缩包中的文件 |
|
|
||||||
| `-l` | List the contents of a zip file without extracting | 列出 zip 文件内容而不解压 |
|
|
||||||
| `-0` | Extract a zip file created in windows, containing files with non-ascii (chinese) filenames | 上游选项疑似错误,请核对当前 unzip 版本支持的参数 |
|
|
||||||
|
|
||||||
### make
|
|
||||||
|
|
||||||
| Entry | English | zh-CN |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `--file`, `-f`, `--makefile` | Read FILE as a makefile | 将 FILE 作为 makefile 读取 |
|
|
||||||
| `-C`, `--directory` | Change to DIRECTORY before doing anything | 执行任何操作前先切换到 DIRECTORY |
|
|
||||||
| `-B`, `--always-make` | Unconditionally make all targets | 无条件构建所有目标 |
|
|
||||||
| `-e`, `--environment-overrides` | Environment variables override makefiles | 环境变量覆盖 makefile 中的设置 |
|
|
||||||
| `-h`, `--help` | Print this message and exit | 显示此帮助信息并退出 |
|
|
||||||
| `-i`, `--ignore-errors` | Ignore errors from commands | 忽略命令返回的错误 |
|
|
||||||
| `-k`, `--keep-going` | Keep going when some targets can't be made | 某些目标无法构建时继续执行 |
|
|
||||||
| `-L`, `--check-symlink-times` | Use the latest mtime between symlinks and target | 在符号链接与目标之间使用最新的修改时间 |
|
|
||||||
| `-p`, `--print-data-base` | Print make's internal database | 打印 make 的内部数据库 |
|
|
||||||
| `-q`, `--question` | Run no commands; exit status says if up to date | 不运行任何命令;退出状态表示目标是否最新 |
|
|
||||||
| `-r`, `--no-builtin-rules` | Disable the built-in implicit rules | 禁用内置隐式规则 |
|
|
||||||
| `-R`, `--no-builtin-variables` | Disable the built-in variable settings | 禁用内置变量设置 |
|
|
||||||
| `-t`, `--touch` | Touch targets instead of remaking them | 仅更新目标的时间戳,而不重新构建 |
|
|
||||||
| `-v`, `--version` | Print the version number of make and exit | 显示 make 版本号并退出 |
|
|
||||||
| `-w`, `--print-directory` | Print the current directory | 显示当前目录 |
|
|
||||||
| `-d` | Print lots of debugging information | 输出大量调试信息 |
|
|
||||||
| `--debug` | Print various types of debugging information | 输出各类调试信息 |
|
|
||||||
| `-I`, `--include-dir` | Search directory for included makefiles | 在目录中搜索被包含的 makefile |
|
|
||||||
| `-l`, `--load-average` | Don't start multiple jobs unless load is below N | 仅当负载低于 N 时才启动多个作业 |
|
|
||||||
| `-o`, `--old-file` | Consider file to be very old and don't remake it | 将文件视为非常旧,不重新构建 |
|
|
||||||
| `--no-print-directory` | Turn off -w, even if it was turned on implicitly | 关闭 -w,即使它是被隐式启用的 |
|
|
||||||
| `-W`, `--what-if`, `--new-file`, `--assume-new` | Consider file to be infinitely new | 将文件视为永远最新 |
|
|
||||||
| `--warn-undefined-variables` | Warn when an undefined variable is referenced | 引用未定义变量时发出警告 |
|
|
||||||
| `-N`, `--Next-option` | Turn on value of next option | 启用指定的 NeXT 兼容选项(Apple 扩展) |
|
|
||||||
|
|
||||||
### nano
|
|
||||||
|
|
||||||
| Entry | English | zh-CN |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| command | Nano's ANOther editor, an enhanced free Pico clone | Nano 的 ANOther 编辑器,一个增强的免费 Pico 克隆 |
|
|
||||||
|
|
||||||
`zh-TW` is generated from these values with
|
|
||||||
`node scripts/generate-autocomplete-descriptions-zh-TW.mjs` (OpenCC s2twp), not
|
|
||||||
edited by hand. Spot checks include `遞迴`, `檔案`, `資訊`, and `除錯`.
|
|
||||||
|
|
||||||
## Semantic notes
|
|
||||||
|
|
||||||
- `zip -r` enables recursive directory archiving. `zip -9` only selects the
|
|
||||||
highest compression level 9; it does not recurse by itself. The Fig English
|
|
||||||
description mentions archiving a directory, but `zip -h2` separates the two,
|
|
||||||
so the zh-CN text is limited to the compression level.
|
|
||||||
- `unzip -l` matches `linux-command` and the local manual: list archive contents
|
|
||||||
without extracting.
|
|
||||||
- `unzip -0` is a Fig-only entry in this package. The local `unzip 6.00` and
|
|
||||||
`linux-command` do not list `-0`. Some Linux builds with filename-encoding
|
|
||||||
patches use `-O gbk` for Windows/GBK filenames (confirmed against the ArchWiki
|
|
||||||
character-encoding notes), but that support is not universal and must be
|
|
||||||
checked against the installed build. The zh-CN text therefore only warns that
|
|
||||||
the upstream option looks wrong and asks users to verify the current unzip
|
|
||||||
version; `-O gbk` remains in this research note. The Fig option name and
|
|
||||||
argument structure are unchanged, and no platform detection or option rename
|
|
||||||
was added.
|
|
||||||
- Make options were checked against the local GNU Make 3.81 help/man output.
|
|
||||||
`-N`/`--NeXT-option` is an Apple/NeXT compatibility extension, not a generic
|
|
||||||
GNU Make option: Apple's source enables NeXT features such as `vpath`,
|
|
||||||
`quiet`, `makefiles`, and `errexit`. Fig spells the long form
|
|
||||||
`--Next-option`, which differs in capitalization from Apple's
|
|
||||||
`--NeXT-option`; the option name is preserved while the zh-CN text describes
|
|
||||||
the NeXT compatibility purpose.
|
|
||||||
- `unzip`/`zip` entry semantics match the local Apple Info-ZIP builds and
|
|
||||||
`linux-command`; no platform-specific text was added.
|
|
||||||
- `nano`'s description is a recursive wordplay ("Nano's ANOther editor"); the
|
|
||||||
translation keeps `ANOther` and describes it as an enhanced free Pico clone.
|
|
||||||
|
|
||||||
## Later batches
|
|
||||||
|
|
||||||
This batch completed `zip`, `unzip`, `make`, and `nano`. The remaining preloaded
|
|
||||||
common commands were completed in batch 2; see
|
|
||||||
`docs/research/autocomplete-descriptions-batch-2.md` for the current unresolved
|
|
||||||
item and overall counts. `ip`, `netstat`, and `ss` remain listed without a
|
|
||||||
bundled Fig spec in `2.692.3`.
|
|
||||||
|
|
||||||
## License notices
|
|
||||||
|
|
||||||
The English description strings are derived from `@withfig/autocomplete`, and
|
|
||||||
the terminology review used `jaywcjlove/linux-command`. Both are MIT licensed:
|
|
||||||
Copyright (c) 2021 Hercules Labs Inc. (Fig); Copyright © 2019 小弟调调™.
|
|
||||||
The full MIT text is preserved in
|
|
||||||
`docs/research/autocomplete-descriptions-touch.md#license-notices`.
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
# Autocomplete description overlay: python/node/vim/sort/awk/sed batch
|
|
||||||
|
|
||||||
This batch completes the remaining preloaded common commands. The Fig specs keep
|
|
||||||
their English option names, arguments, and structure; only suggestion
|
|
||||||
descriptions are localized for `zh-CN` and generated into `zh-TW`.
|
|
||||||
|
|
||||||
Scope: `python`, `python3`, `node`, `vim`, `sort`, `awk`, and `sed`. The batch
|
|
||||||
adopts 177 unique English keys and deliberately defers one shared `node` key.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
| Source | Version / location | Used for |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `@withfig/autocomplete` | `2.692.3`, npm `gitHead` `aef52acff84c45edde61ae610cc2c964802b9a38`; `src/{python,python3,node,vim,sort,sed}.ts` and compiled `build/*.js` | English keys and option structure |
|
|
||||||
| local `electron/specs/awk.js` | Netcatty local spec | AWK options and arguments |
|
|
||||||
| `jaywcjlove/linux-command` | repository HEAD `8041aeda04762e136078decb2de201ec4348527c` (2026-10-31); `command/{sort,awk,sed}.md` | Simplified-Chinese terminology where available |
|
|
||||||
| Python | local `Python 3.15.0`, `python3 -h`; `https://docs.python.org/3/using/cmdline.html` and Python 3.11 docs | Python option behavior and version differences |
|
|
||||||
| Node.js | local `Node.js 26.12.0`, `node --help`; `https://nodejs.org/api/cli.html` | Node option behavior |
|
|
||||||
| Vim | local `Vim 9.1`, `vim --help`; `https://vimhelp.org/starting.txt.html` | Vim startup options |
|
|
||||||
| GNU Coreutils | 9.12, `sort invocation` (`https://www.gnu.org/software/coreutils/manual/html_node/sort-invocation.html`) | GNU sort options |
|
|
||||||
| macOS `sort(1)` | local macOS 26.7 manual and `sort --help` | BSD/macOS sort options |
|
|
||||||
| gawk | Debian bookworm `gawk(1)` (`https://manpages.debian.org/bookworm/gawk/gawk.1.en.html`) | gawk-specific options |
|
|
||||||
| GNU sed | `https://www.gnu.org/software/sed/manual/sed.html` | GNU sed extensions and automatic-print semantics |
|
|
||||||
| macOS `sed(1)` | local macOS 26.7 manual | BSD/Apple sed options |
|
|
||||||
|
|
||||||
Checked on 2026-11-04. `linux-command` has no `command/python.md`, `command/node.md`,
|
|
||||||
or `command/vim.md` at this revision, so those commands use the official manuals
|
|
||||||
and the local tool help instead.
|
|
||||||
|
|
||||||
## Coverage
|
|
||||||
|
|
||||||
| Command | Adopted | Notes |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| `python` | 23 | 23 keys shared with `python3` |
|
|
||||||
| `python3` | 24 | 23 shared keys plus `-m` |
|
|
||||||
| `node` | 12 | 1 shared key deferred |
|
|
||||||
| `vim` | 39 | includes keys shared with `nvim`/`lvim` |
|
|
||||||
| `sort` | 37 | Fig mixes shared and BSD/macOS-only options |
|
|
||||||
| `awk` | 23 | local spec; gawk-specific options are marked |
|
|
||||||
| `sed` | 42 | Fig mixes BSD/Apple and GNU options |
|
|
||||||
|
|
||||||
Total adopted: 177 unique English keys.
|
|
||||||
|
|
||||||
## Semantic notes
|
|
||||||
|
|
||||||
- **Python**: the local Python 3.15 help does not list `-g`; the behavior in the
|
|
||||||
Fig description belongs to `-q`, so the zh-CN text warns that the upstream
|
|
||||||
option looks wrong and points to `-q`. `-R` is version-dependent: it appears
|
|
||||||
in Python 3.11 documentation but is absent from the Python 3.15 help, so the
|
|
||||||
translation keeps the Fig meaning and the version difference is recorded
|
|
||||||
here. `-b` is deprecated in 3.15 (scheduled to become a no-op), and `-I`
|
|
||||||
additionally implies `-P` in 3.15; the translations describe the Fig-era
|
|
||||||
behavior without claiming it is universal.
|
|
||||||
- **Node.js**: the local Node 26.12 help matches `--watch`, `--watch-path`,
|
|
||||||
`--env-file`, `-e`, `-p`, `-c`, `-i`, `--inspect`, and the other adopted
|
|
||||||
options.
|
|
||||||
- **Vim**: the adopted startup options match the local Vim 9.1 `--help` output.
|
|
||||||
Keys shared with `nvim`/`lvim` use wording valid for all three.
|
|
||||||
- **sort**: Fig mixes shared options (including `--sort`, `--debug`, and
|
|
||||||
`--parallel`) with BSD/macOS-only options (`--radixsort`, `--mergesort`,
|
|
||||||
`--qsort`, `--heapsort`, `--mmap`). The GNU manual caps its default
|
|
||||||
`--parallel` value at 8, while the local BSD/macOS manual says the default is
|
|
||||||
the number of CPUs; the zh-CN text therefore only says that the option sets
|
|
||||||
the number of parallel sorting threads, without a universal default.
|
|
||||||
- **awk**: the local macOS `awk` is BSD awk, while the spec also exposes
|
|
||||||
gawk-specific options. The gawk manual confirms that `-c` is traditional
|
|
||||||
compatibility mode (not POSIX), `-n` is `--non-decimal-data`, and `-t` is
|
|
||||||
`--lint-old` (not type checking). The zh-CN text follows the gawk manual;
|
|
||||||
`electron/specs/awk.js` itself is left unchanged.
|
|
||||||
- **sed**: the local macOS sed is BSD sed. Fig mixes BSD/Apple options
|
|
||||||
(`-a`, `-I`, `-i`, `-l`) with GNU extensions (`e`, `F`, `v`, `z`) and shared
|
|
||||||
options. In particular, BSD `-i` requires an extension while GNU `-i` treats
|
|
||||||
it as optional; the Fig description follows the BSD form and the difference is
|
|
||||||
recorded here. The `n` command prints the pattern space only when automatic
|
|
||||||
printing has not been suppressed by `-n`; the zh-CN text states that
|
|
||||||
condition.
|
|
||||||
|
|
||||||
## Unresolved
|
|
||||||
|
|
||||||
One `node` key is intentionally not translated:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Follows symlinks to directories when examining source code and templates for translation strings
|
|
||||||
```
|
|
||||||
|
|
||||||
The same English key is also used by `django-admin --symlinks`, where it is
|
|
||||||
correct. For Node.js `--preserve-symlinks`, the upstream description is a copy
|
|
||||||
of the Django text and does not describe Node's module-resolution behavior. A
|
|
||||||
global dictionary key cannot hold two different translations, so this key is
|
|
||||||
left in English and the maintenance script reports it as the only missing
|
|
||||||
description. No platform detection or per-command dictionary was added.
|
|
||||||
|
|
||||||
## Remaining missing descriptions
|
|
||||||
|
|
||||||
All preloaded common commands are now covered except the deferred `node` key.
|
|
||||||
`ip`, `netstat`, and `ss` remain listed without a bundled Fig spec in `2.692.3`.
|
|
||||||
The wider app-visible library remains outside this 7-command scope: 699 specs
|
|
||||||
contain at least one missing description, totaling 46,887 occurrences and 38,276
|
|
||||||
unique English strings.
|
|
||||||
|
|
||||||
## Adopted entries
|
|
||||||
|
|
||||||
| Commands | English | zh-CN |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| awk | Assign a variable (var=value) | 赋值一个变量(var=value) |
|
|
||||||
| awk | AWK program text (e.g. '{print $1}') | AWK 程序文本(例如 '{print $1}') |
|
|
||||||
| awk | Disable automatic input record splitting (gawk) | 识别输入数据中的八进制和十六进制值,请谨慎使用(gawk) |
|
|
||||||
| awk | Dump variables to file (gawk) | 将变量转储到文件(gawk) |
|
|
||||||
| awk | e.g. ',' or '\t' | 例如 ',' 或 '\t' |
|
|
||||||
| awk | Enable pretty-printed output | 启用美化(pretty-print)输出 |
|
|
||||||
| awk | Enable type checking (gawk) | 对无法移植到原始 UNIX awk 的构造发出警告(gawk) |
|
|
||||||
| awk | Include AWK source library | 包含 AWK 源库 |
|
|
||||||
| awk | Input file(s) | 输入文件 |
|
|
||||||
| awk | Load dynamic extension (gawk) | 加载动态扩展(gawk) |
|
|
||||||
| awk | Pattern scanning and text processing language | 模式扫描和文本处理语言 |
|
|
||||||
| awk | POSIX compatibility mode (gawk) | POSIX 兼容模式(gawk) |
|
|
||||||
| awk | Print copyright information | 显示版权信息 |
|
|
||||||
| awk | Print version information | 打印版本信息 |
|
|
||||||
| awk | Profile execution and write to file (gawk) | 分析执行情况并写入文件(gawk) |
|
|
||||||
| awk | Read AWK program from file | 从文件读取 AWK 程序 |
|
|
||||||
| awk | Read AWK program from file (like -f but disables command-line variable assignments) | 从文件读取 AWK 程序(类似 -f,但禁用命令行变量赋值) |
|
|
||||||
| awk | Run in POSIX compatibility mode (gawk) | 以传统兼容模式运行,行为等同 Brian Kernighan 的 awk(gawk) |
|
|
||||||
| awk | Sandbox mode — disable system(), I/O redirection (gawk) | 沙箱模式——禁用 system() 和 I/O 重定向(gawk) |
|
|
||||||
| awk | Set field separator | 设置字段分隔符 |
|
|
||||||
| awk | Specify AWK program text | 指定 AWK 程序文本 |
|
|
||||||
| awk | Treat all input data as single-byte characters (gawk) | 将所有输入数据视为单字节字符(gawk) |
|
|
||||||
| awk | Use locale decimal point for parsing input data (gawk) | 解析输入数据时使用区域设置的小数点(gawk) |
|
|
||||||
| node | Activate inspector on host:port (default: 127.0.0.1:9229) | 在 host:port 上启用检查器(默认:127.0.0.1:9229) |
|
|
||||||
| node | Always enter the REPL even if stdin does not appear to be a terminal | 即使 stdin 不是终端也始终进入 REPL |
|
|
||||||
| node | Disable the clearing of the console when watch mode restarts the process | 监视模式重启进程时不清空控制台 |
|
|
||||||
| node | Evaluate script | 执行脚本 |
|
|
||||||
| node | Evaluate script and print result | 执行脚本并打印结果 |
|
|
||||||
| node | Print node command line options (currently set) | 打印 node 命令行选项(当前设置) |
|
|
||||||
| node | Print Node.js version | 打印 Node.js 版本 |
|
|
||||||
| node | Run the node interpreter | 运行 Node.js 解释器 |
|
|
||||||
| node | Specify a file containing environment variables | 指定包含环境变量的文件 |
|
|
||||||
| node | Specify a watch directory or file | 指定要监视的目录或文件 |
|
|
||||||
| node | Syntax check script without executing | 仅做语法检查,不执行脚本 |
|
|
||||||
| node | Watch input files | 监视输入文件 |
|
|
||||||
| python | Module | 模块 |
|
|
||||||
| python, python3 | Control the validation behavior of hash-based .pyc files. See Cached bytecode invalidation | 控制基于哈希的 .pyc 文件的校验行为。参见“缓存字节码失效” |
|
|
||||||
| python, python3 | Disable the import of the module site and the site-dependent manipulations of sys.path that it entails | 禁用 site 模块的导入,以及随之进行的 sys.path 站点相关调整 |
|
|
||||||
| python, python3 | Do -O and also discard docstrings | 执行 -O 的操作,并丢弃文档字符串 |
|
|
||||||
| python, python3 | Don’t add the user site-packages directory to sys.path | 不将用户 site-packages 目录加入 sys.path |
|
|
||||||
| python, python3 | Don’t display the copyright and version messages even in interactive mode | 上游选项疑似错误:交互模式下不显示版权和版本信息请用 -q |
|
|
||||||
| python, python3 | Execute the Python code in command. command can be one or more statements separated by newlines, with significant leading whitespace as in normal module code | 执行 command 中的 Python 代码。command 可以是用换行分隔的一条或多条语句,并像普通模块代码一样保留有意义的行首空白 |
|
|
||||||
| python, python3 | Force the stdout and stderr streams to be unbuffered. This option has no effect on the stdin stream | 强制 stdout 和 stderr 不缓冲;此选项对 stdin 无效 |
|
|
||||||
| python, python3 | If given, Python won’t try to write .pyc files on the import of source modules | 指定后,Python 在导入源模块时不会写入 .pyc 文件 |
|
|
||||||
| python, python3 | Ignore all PYTHON* environment variables, e.g. PYTHONPATH and PYTHONHOME, that might be set | 忽略所有可能设置的 PYTHON* 环境变量,例如 PYTHONPATH 和 PYTHONHOME |
|
|
||||||
| python, python3 | Issue a warning when comparing bytes or bytearray with str or bytes with int. Issue an error when the option is given twice (-bb) | 比较 bytes/bytearray 与 str,或 bytes 与 int 时发出警告;重复指定(-bb)时改为报错 |
|
|
||||||
| python, python3 | Print a message each time a module is initialized, showing the place (filename or built-in module) from which it is loaded | 每次初始化模块时输出一条消息,显示其加载位置(文件名或内置模块) |
|
|
||||||
| python, python3 | Print a short description of all command line options | 显示所有命令行选项的简要说明 |
|
|
||||||
| python, python3 | Print the Python version number and exit | 显示 Python 版本号并退出 |
|
|
||||||
| python, python3 | Remove assert statements and any code conditional on the value of __debug__ | 移除 assert 语句以及所有以 __debug__ 值为条件的代码 |
|
|
||||||
| python, python3 | Reserved for various implementation-specific options | 保留用于各种实现特定选项 |
|
|
||||||
| python, python3 | Run Python in isolated mode. This also implies -E and -s. In isolated mode sys.path contains neither the script’s directory nor the user’s site-packages directory | 以隔离模式运行 Python,同时隐含 -E 和 -s。隔离模式下 sys.path 不包含脚本目录和用户 site-packages 目录 |
|
|
||||||
| python, python3 | Run the python interpreter | 运行 Python 解释器 |
|
|
||||||
| python, python3 | Skip the first line of the source, allowing use of non-Unix forms of #!cmd. This is intended for a DOS specific hack only | 跳过源代码第一行,以支持非 Unix 形式的 #!cmd;仅用于 DOS 特定场景 |
|
|
||||||
| python, python3 | Turn on hash randomization. This option only has an effect if the PYTHONHASHSEED environment variable is set to 0, since hash randomization is enabled by default | 启用哈希随机化。仅当 PYTHONHASHSEED 环境变量设为 0 时此选项才有效,因为哈希随机化默认已启用 |
|
|
||||||
| python, python3 | Turn on parser debugging output (for expert only, depending on compilation options) | 启用解析器调试输出(仅供专家使用,是否可用取决于编译选项) |
|
|
||||||
| python, python3 | Warning control. Python’s warning machinery by default prints warning messages to sys.stderr | 警告控制。Python 的警告机制默认将警告消息输出到 sys.stderr |
|
|
||||||
| python, python3 | When a script is passed as first argument or the -c option is used, enter interactive mode after executing the script or the command, even when sys.stdin does not appear to be a terminal | 以脚本作为首个参数或使用 -c 时,在执行脚本或命令后进入交互模式,即使 sys.stdin 不是终端 |
|
|
||||||
| python3 | Search sys.path for the named module and execute its contents as the __main__ module | 在 sys.path 中搜索指定模块,并将其内容作为 __main__ 模块执行 |
|
|
||||||
| sed | (test) Branch to label only if there has been a successful substitution since the last input line was read or conditional branch was taken. The label may be omitted, in which case the next cycle is started | (test)仅当自上次读取输入行或执行条件分支以来发生过成功替换时,才分支到 label;可省略 label,此时开始下一轮循环 |
|
|
||||||
| sed | (test) Branch to label only if there have been no successful substitutions since the last input line was read or conditional branch was taken. The label may be omitted, in which case the next cycle is started | (test)仅当自上次读取输入行或执行条件分支以来没有发生成功替换时,才分支到 label;可省略 label,此时开始下一轮循环 |
|
|
||||||
| sed | (zap) Empties the content of pattern space | (zap)清空模式空间内容 |
|
|
||||||
| sed | Adds a newline to the pattern space, then appends the next line of input to the pattern space. If there is no more input then sed exits without processing any more commands | 在模式空间后追加一个换行符,再将下一行输入追加到模式空间;若没有更多输入,sed 退出,不再处理任何命令 |
|
|
||||||
| sed | Appends `text` after a line | 在行后追加 `text` |
|
|
||||||
| sed | Appends a newline to the contents of the hold space, and then appends the contents of the pattern space to that of the hold space | 在保持空间内容后追加一个换行符,再将模式空间的内容追加到保持空间内容之后 |
|
|
||||||
| sed | Appends a newline to the contents of the pattern space, and then appends the contents of the hold space to that of the pattern space | 在模式空间内容后追加一个换行符,再将保持空间的内容追加到模式空间内容之后 |
|
|
||||||
| sed | Appends the editing commands found in the file command_file to the list of commands. The editing commands should each be listed on a separate line | 将文件 command_file 中的编辑命令追加到命令列表。每条编辑命令应单独占一行 |
|
|
||||||
| sed | Appends the editing commands specified by the command argument to the list of commands | 将由 command 参数指定的编辑命令追加到命令列表 |
|
|
||||||
| sed | Branch unconditionally to `label` | 无条件分支到 `label` |
|
|
||||||
| sed | By default, each line of input is echoed to the standard output after all of the commands have been applied to it. The `-n` option suppresses this behavior | 默认情况下,所有命令应用后每行输入都会回显到标准输出。`-n` 选项会抑制此行为 |
|
|
||||||
| sed | Causes sed to delay opening each file until a command containing the related ``w'' function is applied to a line of input | 使 sed 延迟打开每个文件,直到对某行输入应用包含相关 ``w'' 函数的命令 |
|
|
||||||
| sed | Comment until the next newline | 注释直到下一个换行符 |
|
|
||||||
| sed | Delete the pattern space; immediately start next cycle | 删除模式空间;立即开始下一轮循环 |
|
|
||||||
| sed | Edits files in-place similarly to `-I`, but treats each file independently from other files. In particular, line numbers in each file start at 1, the ``$'' address matches the last line of the current file, and address ranges are limited to the current file | 类似 `-I` 就地编辑文件,但将每个文件独立处理。具体而言,每个文件的行号从 1 开始,``$'' 地址匹配当前文件的最后一行,地址范围限于当前文件 |
|
|
||||||
| sed | Edits files in-place, saving backups with the specified extension. If a zero-length extension is given, no backup will be saved | 就地编辑文件,并使用指定扩展名保存备份。若扩展名为空,则不保存备份 |
|
|
||||||
| sed | Exchanges the contents of the hold and pattern spaces | 交换保持空间和模式空间的内容 |
|
|
||||||
| sed | Executes the command that is found in pattern space and replaces the pattern space with the output; a trailing newline is suppressed | 执行模式空间中的命令,并用命令输出替换模式空间;末尾换行符会被抑制 |
|
|
||||||
| sed | Exit sed without processing any more commands or input | 退出 sed,不再处理任何命令或输入 |
|
|
||||||
| sed | If pattern space contains newlines, delete text in the pattern space up to the first newline, and restart cycle with the resultant pattern space, without reading a new line of input. If pattern space contains no newline, start a normal new cycle as if the d command was issued | 若模式空间包含换行符,删除模式空间中第一个换行符之前的文本,并用剩余的模式空间重新开始循环,不读取新的输入行;若模式空间不含换行符,则像执行 d 命令一样开始正常的下一轮循环 |
|
|
||||||
| sed | Insert text before a line | 在行前插入文本 |
|
|
||||||
| sed | Interprets regular expressions as extended (modern) regular expressions rather than basic regular expressions | 将正则表达式解释为扩展(现代)正则表达式,而不是基本正则表达式 |
|
|
||||||
| sed | Makes output line buffered | 使输出行缓冲 |
|
|
||||||
| sed | Makes output unbuffered | 使输出不缓冲 |
|
|
||||||
| sed | Makes sed fail if GNU sed extensions are not supported, or if the requested version is not available | 若当前 sed 不支持 GNU sed 扩展或请求的版本不可用,则使 sed 失败 |
|
|
||||||
| sed | Match the regular-expression against the content of the pattern space. If found, replace matched string with replacement | 用正则表达式匹配模式空间内容;若匹配,将匹配到的字符串替换为 replacement |
|
|
||||||
| sed | Prints the file name of the current input file | 打印当前输入文件的文件名 |
|
|
||||||
| sed | Prints the pattern space | 打印模式空间 |
|
|
||||||
| sed | Prints the pattern space in an unambiguous form | 以无歧义的形式打印模式空间 |
|
|
||||||
| sed | Prints the pattern space up to the first newline | 打印模式空间中第一个换行符之前的部分 |
|
|
||||||
| sed | Prints the pattern space, then, regardless, replaces the pattern space with the next line of input. If there is no more input then sed exits without processing any more commands | 未禁用自动打印时,先打印模式空间;随后用下一行输入替换模式空间。若无更多输入则退出。 |
|
|
||||||
| sed | Queue a line of filename to be read and inserted into the output stream at the end of the current cycle, or when the next input line is read | 排队读取 filename 的一行,并在当前循环结束或读取下一行输入时插入输出流 |
|
|
||||||
| sed | Reads file | 读取文件 |
|
|
||||||
| sed | Replace (change) lines with `text` | 用 `text` 替换(更改)行 |
|
|
||||||
| sed | Replaces the contents of the hold space with the contents of the pattern space | 用模式空间的内容替换保持空间的内容 |
|
|
||||||
| sed | Replaces the contents of the pattern space with the contents of the hold space | 用保持空间的内容替换模式空间的内容 |
|
|
||||||
| sed | Same as `-E` for compatibility with GNU sed | 与 `-E` 相同,用于兼容 GNU sed |
|
|
||||||
| sed | Stream editor | 流编辑器 |
|
|
||||||
| sed | This command is the same as q, but will not print the contents of pattern space | 与 q 相同,但不打印模式空间的内容 |
|
|
||||||
| sed | Transliterate any characters in the pattern space which match any of the source-chars with the corresponding character in dest-chars | 将模式空间中与 source-chars 中任一字符匹配的字符转换为 dest-chars 中对应的字符 |
|
|
||||||
| sed | Writes the pattern space to the file | 将模式空间写入文件 |
|
|
||||||
| sed | Writes to the given filename the portion of the pattern space up to the first newline | 将模式空间中第一个换行符之前的部分写入指定文件 |
|
|
||||||
| sort | Check that the single input file is sorted | 检查单个输入文件是否已排序 |
|
|
||||||
| sort | Consider only blank spaces and alphanumeric characters in comparisons | 比较时仅考虑空白字符和字母数字字符 |
|
|
||||||
| sort | Convert all lowercase characters to their upper case equivalent before comparison | 比较前将所有小写字符转换为对应的大写形式 |
|
|
||||||
| sort | Define a restricted sort key that has the starting position field1, and optional ending position field2 | 定义受限排序键,包含起始位置 field1 和可选的结束位置 field2 |
|
|
||||||
| sort | Displays the current version of sort | 显示 sort 的当前版本 |
|
|
||||||
| sort | Ignore all non-printable characters | 忽略所有不可打印字符 |
|
|
||||||
| sort | Ignore leading blank characters when comparing lines | 比较行时忽略前导空白字符 |
|
|
||||||
| sort | In random sort, the file content is used as the source of the 'seed' data for the hash function choice | 随机排序时,使用文件内容作为哈希函数选择所需的 'seed' 数据来源 |
|
|
||||||
| sort | Merge only. The input files are assumed to be pre-sorted. If they are not sorted the output order is undefined | 仅合并。假定输入文件已预先排序;若未排序,输出顺序未定义 |
|
|
||||||
| sort | Print some extra information about the sorting process to the standard output | 将排序过程的一些额外信息输出到标准输出 |
|
|
||||||
| sort | Print the output to the output file instead of the standard output | 将输出写入输出文件而不是标准输出 |
|
|
||||||
| sort | Select how to sort values | 选择排序类型 |
|
|
||||||
| sort | Set the maximum number of execution threads. Default number equals to the number of CPUs | 设置并行排序的线程数 |
|
|
||||||
| sort | Shows help message | 显示帮助信息 |
|
|
||||||
| sort | Sort by a random order | 按随机顺序排序 |
|
|
||||||
| sort | Sort by general numerical value | 按通用数值排序 |
|
|
||||||
| sort | Sort by month abbreviations. Unknown strings are considered smaller than the month names | 按月份缩写排序。未知字符串视为小于月份名称 |
|
|
||||||
| sort | Sort by numerical value, but take into account the SI suffix, if present | 按数值排序,但考虑存在的 SI 后缀 |
|
|
||||||
| sort | Sort fields numerically by arithmetic value | 按算术值对字段进行数值排序 |
|
|
||||||
| sort | Sort in reverse order | 按逆序排序 |
|
|
||||||
| sort | Sort or merge records (lines) of text and binary files | 对文本和二进制文件中的记录(行)进行排序或合并 |
|
|
||||||
| sort | Sort version numbers | 对版本号排序 |
|
|
||||||
| sort | Specify maximum number of files that can be opened by sort at once | 指定 sort 一次最多可打开的文件数 |
|
|
||||||
| sort | Stable sort. This option maintains the original record order of records that have an equal key | 稳定排序。保持键相等的记录原有的相对顺序 |
|
|
||||||
| sort | Store temporary files in the directory dir | 将临时文件存储在目录 dir 中 |
|
|
||||||
| sort | Suppress errors on false check | 检查失败时不报告错误 |
|
|
||||||
| sort | Take the input file list from the file filename | 从文件 filename 中读取输入文件列表 |
|
|
||||||
| sort | Try to use file memory mapping system call. It may increase speed in some cases | 尝试使用文件内存映射系统调用。在某些情况下可提高速度 |
|
|
||||||
| sort | Try to use heap sort, if the sort specifications allow. This sort algorithm cannot be used with -u and -s | 若排序规范允许,尝试使用堆排序。此算法不能与 -u 和 -s 一起使用 |
|
|
||||||
| sort | Try to use quick sort, if the sort specifications allow. This sort algorithm cannot be used with -u and -s | 若排序规范允许,尝试使用快速排序。此算法不能与 -u 和 -s 一起使用 |
|
|
||||||
| sort | Try to use radix sort, if the sort specifications allow | 若排序规范允许,尝试使用基数排序 |
|
|
||||||
| sort | Unique keys. Suppress all lines that have a key that is equal to an already processed one | 仅保留唯一键:丢弃键与已处理行相同的所有行 |
|
|
||||||
| sort | Use char as a field separator character | 使用 char 作为字段分隔符 |
|
|
||||||
| sort | Use mergesort. This is a universal algorithm that can always be used, but it is not always the fastest | 使用归并排序。这是一种通用算法,但未必最快 |
|
|
||||||
| sort | Use NUL as record separator | 使用 NUL 作为记录分隔符 |
|
|
||||||
| sort | Use PROGRAM to compress temporary files (eg. bzip2) | 使用 PROGRAM 压缩临时文件(例如 bzip2) |
|
|
||||||
| sort | Use size for the maximum size of the memory buffer | 将 size 用作内存缓冲区的最大大小 |
|
|
||||||
| vim | 'nocompatible', Vim defaults, no plugins, no viminfo | 'nocompatible'、Vim 默认值、不加载插件、不使用 viminfo |
|
|
||||||
| vim | Append all typed commands to file <scriptout> | 将所有输入的命令追加到文件 <scriptout> |
|
|
||||||
| vim | Be verbose [level N] [log messages to fname] | 详细输出 [级别 N] [将日志消息写入 fname] |
|
|
||||||
| vim | Binary mode | 二进制模式 |
|
|
||||||
| vim | Compatible with Vi: 'compatible' | 与 Vi 兼容:'compatible' |
|
|
||||||
| vim | Debugging mode | 调试模式 |
|
|
||||||
| vim | Diff mode (like 'vimdiff') | 差异模式(类似 'vimdiff') |
|
|
||||||
| vim | Don't load plugin scripts | 不加载插件脚本 |
|
|
||||||
| vim | Easy mode (like 'evim', modeless) | 简易模式(类似 'evim',无模式) |
|
|
||||||
| vim | Edit encrypted files | 编辑加密文件 |
|
|
||||||
| vim | Enable silent mode (when in ex mode), or Read Normal mode commands from file | 启用静默模式(在 Ex 模式下),或从文件读取普通模式命令 |
|
|
||||||
| vim | Ex mode (like 'ex') | Ex 模式(类似 'ex') |
|
|
||||||
| vim | Execute <command> after loading the first file | 在加载第一个文件后执行 <command> |
|
|
||||||
| vim | Execute <command> before loading any vimrc file | 在加载任何 vimrc 文件前执行 <command> |
|
|
||||||
| vim | Exit if input or output is not a terminal | 输入或输出不是终端时退出 |
|
|
||||||
| vim | Improved Ex mode | 改进的 Ex 模式 |
|
|
||||||
| vim | Like -o but split vertically | 类似 -o,但垂直分割 |
|
|
||||||
| vim | Lisp mode | Lisp 模式 |
|
|
||||||
| vim | Modifications (writing files) not allowed | 不允许修改(写入文件) |
|
|
||||||
| vim | Modifications in text not allowed | 不允许修改文本 |
|
|
||||||
| vim | No swap file, use memory only | 不使用交换文件,仅使用内存 |
|
|
||||||
| vim | Not fully Vi compatible: 'nocompatible' | 不完全兼容 Vi:'nocompatible' |
|
|
||||||
| vim | Open N tab pages (default: one for each file) | 打开 N 个标签页(默认:每个文件一个) |
|
|
||||||
| vim | Open N windows (default: one for each file) | 打开 N 个窗口(默认:每个文件一个) |
|
|
||||||
| vim | Print Help message and exit | 显示帮助信息并退出 |
|
|
||||||
| vim | Readonly mode (like 'view') | 只读模式(类似 'view') |
|
|
||||||
| vim | Recover crashed session if filename is specified, otherwise list swap files and exit | 若指定文件名则恢复崩溃的会话,否则列出交换文件并退出 |
|
|
||||||
| vim | Restricted mode (like 'rvim') | 受限模式(类似 'rvim') |
|
|
||||||
| vim | Same as -r | 与 -r 相同 |
|
|
||||||
| vim | Set terminal type to <terminal> | 将终端类型设为 <terminal> |
|
|
||||||
| vim | Skip warning for input/output not being a terminal | 跳过输入/输出不是终端的警告 |
|
|
||||||
| vim | Source file <session> after loading the first file | 在加载第一个文件后执行会话文件 <session> |
|
|
||||||
| vim | Start at end of file, if line number is specified, start at that line | 从文件末尾开始;若指定行号,则从该行开始 |
|
|
||||||
| vim | Use <viminfo> instead of .viminfo | 使用 <viminfo> 代替 .viminfo |
|
|
||||||
| vim | Use <vimrc> instead of any .vimrc | 使用 <vimrc> 代替任何 .vimrc |
|
|
||||||
| vim | Vi IMproved, a programmer's text editor | Vi IMproved,程序员的文本编辑器 |
|
|
||||||
| vim | Vi mode (like 'vi') | Vi 模式(类似 'vi') |
|
|
||||||
| vim | Write all typed commands to file <scriptout> | 将所有输入的命令写入文件 <scriptout> |
|
|
||||||
| vim | Write startup timing messages to <file> | 将启动计时消息写入 <file> |
|
|
||||||
|
|
||||||
`zh-TW` is generated from these values with
|
|
||||||
`node scripts/generate-autocomplete-descriptions-zh-TW.mjs` (OpenCC s2twp), not
|
|
||||||
edited by hand.
|
|
||||||
|
|
||||||
## License notices
|
|
||||||
|
|
||||||
The English description strings are derived from `@withfig/autocomplete`, and
|
|
||||||
the terminology review used `jaywcjlove/linux-command` where available. Both are
|
|
||||||
MIT licensed: Copyright (c) 2021 Hercules Labs Inc. (Fig); Copyright © 2019
|
|
||||||
小弟调调™. The full MIT text is preserved in
|
|
||||||
`docs/research/autocomplete-descriptions-touch.md#license-notices`.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# Autocomplete description overlay: file/text utilities batch
|
|
||||||
|
|
||||||
This batch continues the offline Fig description overlay after the
|
|
||||||
`python`/`node`/`vim`/`sort`/`awk`/`sed` batch. The Fig specs keep their English
|
|
||||||
names, arguments, and structure; only suggestion descriptions are localized for
|
|
||||||
`zh-CN` and generated into `zh-TW`.
|
|
||||||
|
|
||||||
Scope: `ln`, `stat`, `wc`, `cut`, `uniq`, `tee`, and `xargs`. The batch adopts
|
|
||||||
70 previously untranslated English keys.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
| Source | Version / location | Used for |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `@withfig/autocomplete` | `2.692.3`, npm `gitHead` `aef52acff84c45edde61ae610cc2c964802b9a38`; `src/{ln,stat,wc,cut,uniq,tee,xargs}.ts` and compiled `build/*.js` | English keys, option names, and arguments |
|
|
||||||
| GNU Coreutils | 9.12, `wc`, `cut`, `uniq`, `tee`, `xargs` invocations | Shared GNU option semantics |
|
|
||||||
| BSD/macOS manuals | local macOS 26.7 `man ln`, `stat`, `wc`, `cut`, `uniq`, `tee`, `xargs` | BSD-specific behavior exposed by the Fig specs |
|
|
||||||
| `jaywcjlove/linux-command` | repository HEAD `8041aeda04762e136078decb2de201ec4348527c` (2026-10-31) | Simplified-Chinese terminology where available |
|
|
||||||
|
|
||||||
Checked on 2026-11-07. Every adopted English key occurs only in one of these
|
|
||||||
seven specs in the bundled Fig package; no other command shares these keys.
|
|
||||||
|
|
||||||
## Coverage
|
|
||||||
|
|
||||||
| Command | Adopted | Notes |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| `ln` | 8 | hard/symbolic link flags |
|
|
||||||
| `stat` | 12 | BSD/macOS `stat` output formats |
|
|
||||||
| `wc` | 6 | byte/line/character/word counts |
|
|
||||||
| `cut` | 11 | byte/column/field selection |
|
|
||||||
| `uniq` | 10 | repeated/unique line filtering |
|
|
||||||
| `tee` | 4 | append and signal handling |
|
|
||||||
| `xargs` | 19 | replacement, batching, and parallel execution |
|
|
||||||
|
|
||||||
Total adopted: 70 unique English keys.
|
|
||||||
|
|
||||||
The seven commands are also added to `COMMON_FIG_SPECS` so their specs are
|
|
||||||
preloaded with the other common commands instead of waiting for an on-demand
|
|
||||||
load.
|
|
||||||
|
|
||||||
## Semantic notes
|
|
||||||
|
|
||||||
- **wc**: the upstream description says “World, line, character, and byte
|
|
||||||
count”. This is an upstream typo for “Word”; the zh-CN text translates the
|
|
||||||
intended word-count meaning and does not repeat the typo.
|
|
||||||
- **xargs**: the upstream `-x` description says “Terminal if the arguments will
|
|
||||||
not fit in the maximum line length”. This is an upstream typo for
|
|
||||||
“Terminate”; the zh-CN text says the command terminates instead.
|
|
||||||
- **cut**: the upstream `-d` argument description uses “deliminator”. The
|
|
||||||
zh-CN text uses the standard term 字段分隔符 and keeps `delim` as a literal
|
|
||||||
placeholder from the option description.
|
|
||||||
- **stat**: the Fig spec follows the BSD/macOS `stat` output formats
|
|
||||||
(`-F`, `-l`, `-r`, `-s`, `-t`), not GNU `stat`. The translations keep those
|
|
||||||
BSD meanings rather than substituting GNU `--format` wording.
|
|
||||||
- **uniq**: `-c`/`--count`, `-d`/`--repeated`, and `-D`/`--all-repeated` are
|
|
||||||
separate suggestions in the Fig spec; both the short and long spellings keep
|
|
||||||
the same localized explanation.
|
|
||||||
|
|
||||||
## Adopted entries
|
|
||||||
|
|
||||||
| Command | English | zh-CN |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `ln` | Create (default hard) symbolic links to files | 为文件创建符号链接(默认为硬链接) |
|
|
||||||
| `ln -s` | Create a symbolic link | 创建符号链接 |
|
|
||||||
| `stat` | Display file status | 显示文件状态 |
|
|
||||||
| `stat -f` | Display information using the specified format; similar to printf(3) formats in that they start with %, are then followed by a sequence of formatting characters, and end in a character that selects the field of the struct stat which is to be formatted | 按指定格式显示信息;类似 printf(3):以 % 开头,后接格式化字符,并以选择 struct stat 待格式化字段的字符结尾 |
|
|
||||||
| `wc` | World, line, character, and byte count | 统计单词数、行数、字符数和字节数 |
|
|
||||||
| `cut` | Cut out selected portions of each line of a file | 从文件的每一行中截取选定部分 |
|
|
||||||
| `cut -d` | Use delim as the field delimiter character instead of the tab character | 使用 delim 作为字段分隔符,而不是制表符 |
|
|
||||||
| `uniq -c` | Prefix lines by the number of occurrences | 在每行前加上出现次数 |
|
|
||||||
| `tee -a` | Append the output to the files rather than overwriting them | 将输出追加到文件而不是覆盖 |
|
|
||||||
| `xargs -0` | Use NUL (0x00) as a separator, instead of whitespace | 使用 NUL(0x00)而非空白作为分隔符 |
|
|
||||||
| `xargs -x` | Terminal if the arguments will not fit in the maximum line length | 若参数超出最大行长度则终止 |
|
|
||||||
|
|
||||||
`zh-TW` is generated from these values with
|
|
||||||
`node scripts/generate-autocomplete-descriptions-zh-TW.mjs` (OpenCC s2twp), not
|
|
||||||
edited by hand. Spot checks include `檔案`, `預設`, `欄位`, `訊號`, and `引數`.
|
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { resolveAgentHostOpenWorkspace, resolveAgentHostWorkspace } from './agentHostWorkspace';
|
import { resolveAgentHostWorkspace } from './agentHostWorkspace';
|
||||||
|
|
||||||
const CONTEXT = (activeTabId: string | undefined) => ({
|
|
||||||
activeTabId,
|
|
||||||
workspaceIds: ['ws-a', 'ws-b'],
|
|
||||||
terminalSessions: [
|
|
||||||
{ id: 'term-in-a', workspaceId: 'ws-a' },
|
|
||||||
{ id: 'term-standalone' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
test('current resolves only to an existing workspace, not a terminal tab', () => {
|
test('current resolves only to an existing workspace, not a terminal tab', () => {
|
||||||
assert.deepEqual(resolveAgentHostWorkspace('current', 'ws-1', ['ws-1']), { workspaceId: 'ws-1' });
|
assert.deepEqual(resolveAgentHostWorkspace('current', 'ws-1', ['ws-1']), { workspaceId: 'ws-1' });
|
||||||
@@ -21,75 +12,3 @@ test('explicit missing workspace is rejected without standalone fallback', () =>
|
|||||||
assert.deepEqual(resolveAgentHostWorkspace('ws-1', 'other', ['ws-1']), { workspaceId: 'ws-1' });
|
assert.deepEqual(resolveAgentHostWorkspace('ws-1', 'other', ['ws-1']), { workspaceId: 'ws-1' });
|
||||||
assert.deepEqual(resolveAgentHostWorkspace(undefined, 'ws-1', ['ws-1']), {});
|
assert.deepEqual(resolveAgentHostWorkspace(undefined, 'ws-1', ['ws-1']), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('workspace chat opens into its own workspace even when another is focused', () => {
|
|
||||||
const chat = { scope: { type: 'workspace', targetId: 'ws-b' } };
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace(undefined, chat, CONTEXT('ws-a')),
|
|
||||||
{ workspaceId: 'ws-b' },
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace('current', chat, CONTEXT('ws-a')),
|
|
||||||
{ workspaceId: 'ws-b' },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('terminal chat opens into the workspace containing its terminal', () => {
|
|
||||||
const chat = { scope: { type: 'terminal', targetId: 'term-in-a' } };
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace(undefined, chat, CONTEXT('ws-b')),
|
|
||||||
{ workspaceId: 'ws-a' },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('terminal chat without a workspace and unbound chats stay standalone', () => {
|
|
||||||
const standaloneTermChat = { scope: { type: 'terminal', targetId: 'term-standalone' } };
|
|
||||||
assert.deepEqual(resolveAgentHostOpenWorkspace(undefined, standaloneTermChat, CONTEXT('ws-a')), {});
|
|
||||||
assert.deepEqual(resolveAgentHostOpenWorkspace(undefined, undefined, CONTEXT('ws-a')), {});
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace(undefined, { scope: { type: 'global' } }, CONTEXT('ws-a')),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dangling or closed chat bindings fall back instead of erroring', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace(
|
|
||||||
undefined,
|
|
||||||
{ scope: { type: 'workspace', targetId: 'ws-b' }, scopeDangling: true },
|
|
||||||
CONTEXT('ws-a'),
|
|
||||||
),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace(
|
|
||||||
undefined,
|
|
||||||
{ scope: { type: 'workspace', targetId: 'ws-closed' } },
|
|
||||||
CONTEXT('ws-a'),
|
|
||||||
),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an explicit workspaceId keeps overriding the chat binding', () => {
|
|
||||||
const chat = { scope: { type: 'workspace', targetId: 'ws-b' } };
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace('ws-a', chat, CONTEXT('ws-b')),
|
|
||||||
{ workspaceId: 'ws-a' },
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
resolveAgentHostOpenWorkspace('missing', chat, CONTEXT('ws-b')).error ?? '',
|
|
||||||
/workspace/i,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unbound 'current' keeps the active-workspace meaning", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
resolveAgentHostOpenWorkspace('current', undefined, CONTEXT('ws-a')),
|
|
||||||
{ workspaceId: 'ws-a' },
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
resolveAgentHostOpenWorkspace('current', undefined, CONTEXT('terminal-1')).error ?? '',
|
|
||||||
/workspace/i,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user