mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a55492c17 | ||
|
|
51d9f715da | ||
|
|
23d3fe454b | ||
|
|
3622d482a4 | ||
|
|
c1c5787cab | ||
|
|
419a6734fc | ||
|
|
b2be70aae7 | ||
|
|
9491b800b6 | ||
|
|
3c36e7e1a2 | ||
|
|
642bf03ea7 | ||
|
|
22856a368a | ||
|
|
4ed3d09c7a | ||
|
|
945722fbe8 | ||
|
|
77f90999c7 | ||
|
|
a62cc1bd3f | ||
|
|
c8f8adcc87 | ||
|
|
7bb91d0fe2 | ||
|
|
a5a4117d8e | ||
|
|
2ecf471a29 | ||
|
|
968e84ff59 | ||
|
|
19ab38217b | ||
|
|
98d75c13fe | ||
|
|
ba135c0e98 |
No files matched your search
@@ -141,6 +141,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
||||
- **リモート圧縮** — ファイルやフォルダーを1つ以上選び、右クリックの **Compress...** から、表示された保存先に名前を指定して `.tar.gz` または `.zip` アーカイブを作成できます。サーバーには選択した形式に対応する `tar`/`gzip` または `zip` が必要です。
|
||||
|
||||
### 🎨 パーソナライズ
|
||||
- **カスタムテーマ** — UI の見た目を好みに調整
|
||||
|
||||
@@ -144,6 +144,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
- **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
|
||||
- **Custom themes** — tune the app appearance to your taste
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
- **就地编辑** —— 内置编辑器快速修改文件
|
||||
- **远程压缩** —— 单选或多选文件、文件夹,右键选择“压缩…”,在对话框显示的保存目录生成指定名称的 `.tar.gz` 或 `.zip` 压缩包;服务器需安装对应的 `tar`/`gzip` 或 `zip`。
|
||||
|
||||
### 🎨 个性化
|
||||
- **自定义主题** —— 按喜好调整应用外观
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
### 📁 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);
|
||||
});
|
||||
|
||||
test('Escape restores magnification after transient dialogs are closed', () => {
|
||||
test('Escape leaves magnification unchanged and reaches the terminal', () => {
|
||||
let restores = 0;
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
@@ -405,30 +405,29 @@ test('Escape restores magnification after transient dialogs are closed', () => {
|
||||
},
|
||||
}), event);
|
||||
|
||||
assert.equal(restores, 1);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(stopped, true);
|
||||
assert.equal(restores, 0);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(stopped, false);
|
||||
});
|
||||
|
||||
test('consumed Escape does not restore magnification', () => {
|
||||
let restores = 0;
|
||||
test('Escape closes the quick switcher', () => {
|
||||
let open = true;
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: false,
|
||||
setIsQuickSwitcherOpen: () => {},
|
||||
terminalPaneMagnificationRef: {
|
||||
current: {
|
||||
getState: () => 'focused',
|
||||
focus: () => false,
|
||||
restore: () => {
|
||||
restores += 1;
|
||||
return true;
|
||||
},
|
||||
toggle: () => false,
|
||||
},
|
||||
},
|
||||
isQuickSwitcherOpen: open,
|
||||
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
|
||||
}), { key: 'Escape', defaultPrevented: false } as KeyboardEvent);
|
||||
|
||||
assert.equal(open, false);
|
||||
});
|
||||
|
||||
test('consumed Escape leaves the quick switcher open', () => {
|
||||
let open = true;
|
||||
handleEscapeKeyDownImpl(() => ({
|
||||
isQuickSwitcherOpen: open,
|
||||
setIsQuickSwitcherOpen: (value: boolean) => { open = value; },
|
||||
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
|
||||
|
||||
assert.equal(restores, 0);
|
||||
assert.equal(open, true);
|
||||
});
|
||||
|
||||
test('close tab hotkey routes native plugin view tabs through their owner', () => {
|
||||
|
||||
@@ -365,8 +365,6 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
|
||||
const {
|
||||
isQuickSwitcherOpen,
|
||||
setIsQuickSwitcherOpen,
|
||||
sftpPaneMagnificationRef,
|
||||
terminalPaneMagnificationRef,
|
||||
} = getCtx();
|
||||
{
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
||||
@@ -374,13 +372,6 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
|
||||
setIsQuickSwitcherOpen(false);
|
||||
return;
|
||||
}
|
||||
if (getAvailablePaneMagnificationController([
|
||||
sftpPaneMagnificationRef?.current,
|
||||
terminalPaneMagnificationRef?.current,
|
||||
])?.restore()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PluginContributionHost } from '../../components/plugins/PluginContribut
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
import { useActiveTabId } from '../state/activeTabStore';
|
||||
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.
|
||||
@@ -23,11 +25,19 @@ export function AppPluginKeybindingHost({
|
||||
workspaces: Workspace[];
|
||||
}) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
|
||||
activeTabId,
|
||||
sessions,
|
||||
workspaces,
|
||||
}), [activeTabId, sessions, workspaces]);
|
||||
const keybindingContext = useMemo(() => {
|
||||
// AI chat recency drives the workspace anchor fallback (#4098 phase 2).
|
||||
// Read without subscribing: keybinding context is best-effort and refreshes
|
||||
// on every tab/session/workspaces change that matters here.
|
||||
const live = getAISessionsSnapshot().sessions;
|
||||
const aiSessions = live.length > 0 ? live : getPersistedAiSessionsSnapshot();
|
||||
return resolveActivePluginKeybindingContext({
|
||||
activeTabId,
|
||||
sessions,
|
||||
workspaces,
|
||||
aiSessions,
|
||||
});
|
||||
}, [activeTabId, sessions, workspaces]);
|
||||
|
||||
return (
|
||||
<PluginContributionHost
|
||||
|
||||
@@ -4,12 +4,13 @@ import test from 'node:test';
|
||||
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
|
||||
test('terminal Escape restoration runs before xterm can stop propagation', () => {
|
||||
const effectStart = source.indexOf("const onCaptureKeyDown = (e: KeyboardEvent) => {");
|
||||
const effectSource = source.slice(effectStart, effectStart + 900);
|
||||
test('quick-switcher Escape handling stays in the bubble phase', () => {
|
||||
const effectStart = source.indexOf('const onKeyDown = (e: KeyboardEvent) => {\n _handleEscapeKeyDown(e);');
|
||||
const effectEnd = source.indexOf('}, [appLockLocked]);', effectStart);
|
||||
const effectSource = source.slice(effectStart, effectEnd);
|
||||
|
||||
assert.notEqual(effectStart, -1);
|
||||
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
|
||||
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
|
||||
assert.match(effectSource, /window\.addEventListener\('keydown', onKeyDown\)/);
|
||||
assert.match(effectSource, /window\.removeEventListener\('keydown', onKeyDown\)/);
|
||||
assert.doesNotMatch(source, /onCaptureKeyDown/);
|
||||
});
|
||||
@@ -58,7 +58,9 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { resolveAgentHostOpenWorkspace } from '../../domain/agentHostWorkspace';
|
||||
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
|
||||
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
|
||||
@@ -613,7 +615,7 @@ export function AppSideEffects() {
|
||||
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 _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, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
|
||||
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
|
||||
|
||||
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
|
||||
// dedicated transfer resume so quick-connect rows do not break tray connect.
|
||||
@@ -1233,19 +1235,11 @@ export function AppSideEffects() {
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
_handleEscapeKeyDown(e);
|
||||
};
|
||||
window.addEventListener('keydown', onCaptureKeyDown, true);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onCaptureKeyDown, true);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [appLockLocked]);
|
||||
@@ -1505,8 +1499,21 @@ export function AppSideEffects() {
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
|
||||
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string; chatSessionId?: string }) => {
|
||||
// A resumed history chat can run in a panel other than its saved binding.
|
||||
// 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 };
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||
|
||||
@@ -264,7 +264,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, moveWorkspacePane,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
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,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
setWorkspaceFocusedSession, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
setWorkspaceFocusedSession, setWorkspaceAnchorChat, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
|
||||
sftpShowHiddenFiles, sftpUseCompressedUpload, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
|
||||
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
|
||||
toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, terminalPaneMagnificationRef, sftpPaneMagnificationRef, toggleWorkspaceViewMode, unmanageSource,
|
||||
@@ -774,12 +774,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onMoveWorkspacePane={moveWorkspacePane}
|
||||
onRequestAddToWorkspace={handleRequestAddToWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
|
||||
onSetWorkspaceAnchorChat={setWorkspaceAnchorChat}
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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,6 +170,7 @@ export function TerminalHost() {
|
||||
if (!handlers) return null;
|
||||
return {
|
||||
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
|
||||
moveWorkspacePane: sessionActions?.moveWorkspacePane,
|
||||
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
|
||||
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
|
||||
@@ -227,6 +228,7 @@ export function TerminalHost() {
|
||||
setTerminalFontFamilyId: terminalSettingsActions?.setTerminalFontFamilyId,
|
||||
setTerminalFontSize: terminalSettingsActions?.setTerminalFontSize,
|
||||
setWorkspaceFocusedSession: sessionActions?.setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat: sessionActions?.setWorkspaceAnchorChat,
|
||||
sftpAutoOpenSidebar: terminalSettings.sftpAutoOpenSidebar,
|
||||
sftpFollowTerminalCwd: terminalSettings.sftpFollowTerminalCwd,
|
||||
setSftpFollowTerminalCwd: terminalSettingsActions?.setSftpFollowTerminalCwd,
|
||||
|
||||
@@ -48,6 +48,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
@@ -58,6 +59,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
moveWorkspacePane,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
@@ -133,6 +135,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
resetWorkspaceRename,
|
||||
removeSessionFromWorkspace,
|
||||
setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat,
|
||||
toggleWorkspaceViewMode,
|
||||
createLocalTerminal,
|
||||
createSerialSession,
|
||||
@@ -143,6 +146,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
moveWorkspacePane,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
@@ -172,6 +176,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
updateSessionCodingCliProvider,
|
||||
}), [
|
||||
addSessionToWorkspace,
|
||||
moveWorkspacePane,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
appendLocalTerminalToWorkspace,
|
||||
@@ -208,6 +213,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
setActiveTabId,
|
||||
setDraggingSessionId,
|
||||
setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat,
|
||||
splitSession,
|
||||
startSessionRename,
|
||||
startWorkspaceRename,
|
||||
|
||||
@@ -720,6 +720,8 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'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.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Install from GitHub",
|
||||
"settings.plugins.github.description": "Download a published .ncpkg release, then review its source and permissions.",
|
||||
"settings.plugins.github.repository": "GitHub repository",
|
||||
"settings.plugins.github.formats": "Accepts https://github.com/owner/repo, github.com/owner/repo or owner/repo.",
|
||||
"settings.plugins.github.busy": "Downloading, validating or installing the package...",
|
||||
"settings.plugins.github.review": "Repository ownership and package integrity do not verify publisher trust. No build scripts run. The plugin installs disabled; review permission modes before enabling.",
|
||||
"settings.plugins.github.accept": "I trust this repository and have reviewed the declared permissions.",
|
||||
"settings.plugins.github.confirm": "Install disabled",
|
||||
"settings.plugins.github.download": "Download and review",
|
||||
"settings.plugins.github.error.invalidRepository": "Enter https://github.com/owner/repo, github.com/owner/repo or owner/repo (repository only; no credentials, query, fragment or extra paths).",
|
||||
"settings.plugins.github.error.unavailable": "GitHub plugin installation is unavailable in this session.",
|
||||
"settings.plugins.github.error.reviewFirst": "Download and review a GitHub plugin first.",
|
||||
"settings.plugins.github.error.network": "Cannot reach GitHub. Check your network connection and try again.",
|
||||
"settings.plugins.github.error.timeout": "The GitHub download timed out. Please try again.",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API rate limit reached. Try again later.",
|
||||
"settings.plugins.github.error.notFound": "GitHub repository or asset not found. Only accessible public repositories are supported; import private packages locally.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub access denied. Private repositories and sign-in are not supported.",
|
||||
"settings.plugins.github.error.http": "GitHub download failed (HTTP {status}). Please try again later.",
|
||||
"settings.plugins.github.error.moved": "This GitHub repository has moved. Enter its current repository URL.",
|
||||
"settings.plugins.github.error.redirect": "The GitHub download redirect could not be verified. Download a trusted .ncpkg package and import it locally.",
|
||||
"settings.plugins.github.error.noRelease": "This repository has no published stable release. Ask the author to publish one with a .ncpkg asset, or import a built package locally.",
|
||||
"settings.plugins.github.error.noAsset": "This release has no .ncpkg asset. Ask the author to build and attach one; Netcatty cannot build source repositories.",
|
||||
"settings.plugins.github.error.multipleAssets": "This release contains multiple .ncpkg assets. Download the intended package and import it locally.",
|
||||
"settings.plugins.github.error.metadata": "The GitHub release metadata is invalid. Ask the author to check the release, tag and .ncpkg asset.",
|
||||
"settings.plugins.github.error.identity": "The package does not match the selected GitHub repository and release. Installation was stopped.",
|
||||
"settings.plugins.github.error.commit": "The release tag has no accessible commit. Ask the author to repair the release tag.",
|
||||
"settings.plugins.github.error.size": "The GitHub package size is invalid or exceeds the download limit.",
|
||||
"settings.plugins.github.error.integrity": "The downloaded package failed its size or SHA-256 check. Download and review it again.",
|
||||
"settings.plugins.github.error.package": "GitHub did not return a valid .ncpkg ZIP package or release metadata. Ask the author to check the release asset.",
|
||||
"settings.plugins.github.error.busy": "A GitHub plugin operation is already active. Close the preview and try again.",
|
||||
"settings.plugins.github.error.expired": "The GitHub package preview expired. Download and review it again.",
|
||||
"settings.plugins.github.error.advanced": "This package requires advanced developer mode. Normal plugin management accepts browser-only packages.",
|
||||
"settings.plugins.github.error.incompatible": "This plugin is incompatible with this version of Netcatty. Check the technical details with the author.",
|
||||
"settings.plugins.github.error.source": "This plugin is already bound to another source. Uninstall it and explicitly clear its old source data before changing sources.",
|
||||
"settings.plugins.github.error.failed": "The plugin could not be validated or installed. Check the technical details before trying again.",
|
||||
"settings.plugins.github.error.details": "Technical details",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Check for updates",
|
||||
"settings.plugins.landing.checkingUpdates": "Checking…",
|
||||
"settings.plugins.landing.updateSummary": "Checked: {checked} · Published updates: {updates} · Not checked: {skipped}.",
|
||||
|
||||
@@ -316,7 +316,26 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.extract.extracting': 'Extracting {fileName}...',
|
||||
'sftp.extract.success': 'Extracted {fileName}',
|
||||
'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.duplicate': 'Duplicate',
|
||||
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
|
||||
'sftp.copyCurrentPath': 'Copy current path',
|
||||
'sftp.copyCurrentPath.success': 'Current path copied',
|
||||
|
||||
@@ -720,6 +720,8 @@ export const esAiMessages: Messages = {
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'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.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Scripts',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Instalar desde GitHub",
|
||||
"settings.plugins.github.description": "Descarga una versión .ncpkg publicada y revisa su origen y permisos.",
|
||||
"settings.plugins.github.repository": "Repositorio de GitHub",
|
||||
"settings.plugins.github.formats": "Acepta https://github.com/owner/repo, github.com/owner/repo u owner/repo.",
|
||||
"settings.plugins.github.busy": "Descargando, validando o instalando el paquete...",
|
||||
"settings.plugins.github.review": "El origen y la integridad no verifican la confianza en el autor. No se ejecutan scripts de compilación. Se instala desactivado; revisa los permisos antes de activarlo.",
|
||||
"settings.plugins.github.accept": "Confío en este repositorio y he revisado los permisos declarados.",
|
||||
"settings.plugins.github.confirm": "Instalar desactivado",
|
||||
"settings.plugins.github.download": "Descargar y revisar",
|
||||
"settings.plugins.github.error.invalidRepository": "Introduce https://github.com/owner/repo, github.com/owner/repo u owner/repo. Usa solo el repositorio, sin credenciales, parámetros, fragmentos ni rutas adicionales.",
|
||||
"settings.plugins.github.error.unavailable": "La instalación de plugins desde GitHub no está disponible en esta sesión.",
|
||||
"settings.plugins.github.error.reviewFirst": "Primero descarga y revisa un plugin de GitHub.",
|
||||
"settings.plugins.github.error.network": "No se puede conectar con GitHub. Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.timeout": "La descarga de GitHub agotó el tiempo de espera. Inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.rateLimit": "Se alcanzó el límite de solicitudes de la API de GitHub. Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.notFound": "No se encontró el repositorio o archivo de GitHub. Solo se admiten repositorios públicos accesibles; importa los paquetes privados desde un archivo local.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub denegó el acceso. No se admiten repositorios privados ni inicio de sesión.",
|
||||
"settings.plugins.github.error.http": "La descarga de GitHub falló (HTTP {status}). Inténtalo más tarde.",
|
||||
"settings.plugins.github.error.moved": "Este repositorio de GitHub se ha movido. Introduce su dirección actual.",
|
||||
"settings.plugins.github.error.redirect": "No se pudo verificar la redirección de GitHub. Descarga un paquete .ncpkg de confianza e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.noRelease": "Este repositorio no tiene una versión estable publicada. Pide al autor que publique una con un archivo .ncpkg o importa un paquete compilado local.",
|
||||
"settings.plugins.github.error.noAsset": "Esta versión no incluye un archivo .ncpkg. Pide al autor que lo compile y adjunte; Netcatty no puede compilar repositorios de código fuente.",
|
||||
"settings.plugins.github.error.multipleAssets": "Esta versión contiene varios archivos .ncpkg. Descarga el paquete deseado e impórtalo desde un archivo local.",
|
||||
"settings.plugins.github.error.metadata": "Los metadatos de la versión de GitHub no son válidos. Pide al autor que revise la versión, la etiqueta y el archivo .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "El paquete no coincide con el repositorio y la versión de GitHub seleccionados. Se detuvo la instalación.",
|
||||
"settings.plugins.github.error.commit": "La etiqueta de la versión no tiene un commit accesible. Pide al autor que corrija la etiqueta.",
|
||||
"settings.plugins.github.error.size": "El tamaño del paquete de GitHub no es válido o supera el límite de descarga.",
|
||||
"settings.plugins.github.error.integrity": "El paquete descargado no superó la comprobación de tamaño o SHA-256. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.package": "GitHub no devolvió un paquete ZIP .ncpkg o metadatos de versión válidos. Pide al autor que revise el archivo publicado.",
|
||||
"settings.plugins.github.error.busy": "Ya hay una operación de plugin de GitHub en curso. Cierra la vista previa e inténtalo de nuevo.",
|
||||
"settings.plugins.github.error.expired": "La vista previa del paquete de GitHub caducó. Descárgalo y revísalo de nuevo.",
|
||||
"settings.plugins.github.error.advanced": "Este paquete requiere el modo de desarrollo avanzado. La gestión normal solo admite paquetes para navegador.",
|
||||
"settings.plugins.github.error.incompatible": "Este plugin no es compatible con esta versión de Netcatty. Consulta los detalles técnicos con el autor.",
|
||||
"settings.plugins.github.error.source": "Este plugin ya está vinculado a otro origen. Desinstálalo y borra explícitamente los datos del origen anterior antes de cambiarlo.",
|
||||
"settings.plugins.github.error.failed": "No se pudo validar o instalar el plugin. Revisa los detalles técnicos antes de intentarlo de nuevo.",
|
||||
"settings.plugins.github.error.details": "Detalles técnicos",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "Buscar actualizaciones",
|
||||
"settings.plugins.landing.checkingUpdates": "Buscando…",
|
||||
"settings.plugins.landing.updateSummary": "Comprobados: {checked} · Actualizaciones publicadas: {updates} · Sin comprobar: {skipped}.",
|
||||
|
||||
@@ -316,7 +316,26 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.extract.extracting': 'Extrayendo {fileName}...',
|
||||
'sftp.extract.success': 'Se extrajo {fileName}',
|
||||
'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.duplicate': 'Duplicar',
|
||||
'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.success': 'Ruta actual copiada',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { SUPPORTED_UI_LOCALES } from '../../../infrastructure/config/i18n';
|
||||
import { getLoadedLocaleMessages, loadLocaleMessages } from '../messages';
|
||||
import en from './en';
|
||||
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
|
||||
|
||||
test('GitHub install copy and error interpolation are complete in every supported UI locale', async () => {
|
||||
const keys = Object.keys(en).filter(key => key.startsWith('settings.plugins.github.'));
|
||||
assert.ok(keys.length > 30);
|
||||
for (const { id } of SUPPORTED_UI_LOCALES) {
|
||||
await loadLocaleMessages(id);
|
||||
const messages = getLoadedLocaleMessages(id)!;
|
||||
for (const key of keys) {
|
||||
assert.ok(messages[key]?.trim(), `${id}: ${key}`);
|
||||
assert.deepEqual(messages[key].match(/\{\w+\}/g) ?? [], en[key].match(/\{\w+\}/g) ?? [], `${id}: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('GitHub error presentation handles Electron wrappers, HTTP status and diagnostic fallback', () => {
|
||||
const expired = describePluginGitHubInstallError("Error invoking remote method 'plugins:github:install': Error: GitHub package preview expired. Download and review it again");
|
||||
assert.equal(expired.key, 'settings.plugins.github.error.expired');
|
||||
assert.equal(expired.details, null);
|
||||
assert.deepEqual(describePluginGitHubInstallError('GitHub download failed (HTTP 503)').values, { status: '503' });
|
||||
const validator = 'manifest version must be a semantic version';
|
||||
assert.equal(describePluginGitHubInstallError(validator).details, validator);
|
||||
assert.equal(describePluginGitHubInstallError(validator).key, 'settings.plugins.github.error.failed');
|
||||
assert.equal(describePluginGitHubInstallError('Plugin is incompatible: engine version').key, 'settings.plugins.github.error.incompatible');
|
||||
});
|
||||
@@ -659,6 +659,8 @@ export const ruAiMessages: Messages = {
|
||||
|
||||
// Unified tooltips for terminal workspace and top tabs (issue #954)
|
||||
'terminal.layer.addTerminal': 'Добавить терминал',
|
||||
'terminal.layer.emptyWorkspace': 'В этом рабочем пространстве пока нет терминалов',
|
||||
'terminal.layer.emptyWorkspaceHint': 'Добавьте локальный терминал или подключитесь к хосту, чтобы начать.',
|
||||
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
|
||||
'terminal.layer.sftp': 'SFTP',
|
||||
'terminal.layer.scripts': 'Скрипты',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "Установить из GitHub",
|
||||
"settings.plugins.github.description": "Загрузите опубликованный выпуск .ncpkg и проверьте источник и разрешения.",
|
||||
"settings.plugins.github.repository": "Репозиторий GitHub",
|
||||
"settings.plugins.github.formats": "Поддерживаются https://github.com/owner/repo, github.com/owner/repo и owner/repo.",
|
||||
"settings.plugins.github.busy": "Загрузка, проверка или установка пакета...",
|
||||
"settings.plugins.github.review": "Источник и целостность пакета не подтверждают доверие к издателю. Скрипты сборки не запускаются. Плагин устанавливается выключенным; проверьте разрешения перед включением.",
|
||||
"settings.plugins.github.accept": "Я доверяю этому репозиторию и проверил заявленные разрешения.",
|
||||
"settings.plugins.github.confirm": "Установить выключенным",
|
||||
"settings.plugins.github.download": "Загрузить и проверить",
|
||||
"settings.plugins.github.error.invalidRepository": "Введите https://github.com/owner/repo, github.com/owner/repo или owner/repo. Укажите только репозиторий, без учётных данных, параметров, фрагментов и дополнительных путей.",
|
||||
"settings.plugins.github.error.unavailable": "Установка плагинов из GitHub недоступна в этом сеансе.",
|
||||
"settings.plugins.github.error.reviewFirst": "Сначала загрузите и проверьте плагин из GitHub.",
|
||||
"settings.plugins.github.error.network": "Не удалось подключиться к GitHub. Проверьте соединение и повторите попытку.",
|
||||
"settings.plugins.github.error.timeout": "Время ожидания загрузки из GitHub истекло. Повторите попытку.",
|
||||
"settings.plugins.github.error.rateLimit": "Достигнут лимит запросов API GitHub. Повторите попытку позже.",
|
||||
"settings.plugins.github.error.notFound": "Репозиторий или файл GitHub не найден. Поддерживаются только доступные публичные репозитории; импортируйте приватные пакеты из локального файла.",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub отказал в доступе. Приватные репозитории и вход в аккаунт не поддерживаются.",
|
||||
"settings.plugins.github.error.http": "Ошибка загрузки из GitHub (HTTP {status}). Повторите попытку позже.",
|
||||
"settings.plugins.github.error.moved": "Репозиторий GitHub перемещён. Введите его текущий адрес.",
|
||||
"settings.plugins.github.error.redirect": "Не удалось проверить перенаправление загрузки GitHub. Загрузите доверенный пакет .ncpkg и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.noRelease": "В репозитории нет опубликованного стабильного выпуска. Попросите автора опубликовать выпуск с файлом .ncpkg или импортируйте готовый пакет из локального файла.",
|
||||
"settings.plugins.github.error.noAsset": "В выпуске нет файла .ncpkg. Попросите автора собрать и прикрепить его; Netcatty не собирает репозитории исходного кода.",
|
||||
"settings.plugins.github.error.multipleAssets": "В выпуске несколько файлов .ncpkg. Загрузите нужный пакет и импортируйте его из локального файла.",
|
||||
"settings.plugins.github.error.metadata": "Некорректные метаданные выпуска GitHub. Попросите автора проверить выпуск, тег и файл .ncpkg.",
|
||||
"settings.plugins.github.error.identity": "Пакет не соответствует выбранному репозиторию и выпуску GitHub. Установка остановлена.",
|
||||
"settings.plugins.github.error.commit": "У тега выпуска нет доступного коммита. Попросите автора исправить тег.",
|
||||
"settings.plugins.github.error.size": "Размер пакета GitHub некорректен или превышает лимит загрузки.",
|
||||
"settings.plugins.github.error.integrity": "Загруженный пакет не прошёл проверку размера или SHA-256. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.package": "GitHub не вернул корректный ZIP-пакет .ncpkg или метаданные выпуска. Попросите автора проверить файл выпуска.",
|
||||
"settings.plugins.github.error.busy": "Операция с плагином GitHub уже выполняется. Закройте предпросмотр и повторите попытку.",
|
||||
"settings.plugins.github.error.expired": "Предпросмотр пакета GitHub устарел. Загрузите и проверьте его снова.",
|
||||
"settings.plugins.github.error.advanced": "Для этого пакета нужен расширенный режим разработчика. Обычный менеджер принимает только браузерные пакеты.",
|
||||
"settings.plugins.github.error.incompatible": "Плагин несовместим с этой версией Netcatty. Уточните технические подробности у автора.",
|
||||
"settings.plugins.github.error.source": "Плагин уже привязан к другому источнику. Перед сменой источника удалите плагин и явно очистите данные прежнего источника.",
|
||||
"settings.plugins.github.error.failed": "Не удалось проверить или установить плагин. Изучите технические подробности перед повторной попыткой.",
|
||||
"settings.plugins.github.error.details": "Технические подробности",
|
||||
|
||||
|
||||
// Common
|
||||
'common.save': 'Сохранить',
|
||||
'common.cancel': 'Отмена',
|
||||
|
||||
@@ -360,7 +360,26 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.extract.extracting': 'Распаковка {fileName}...',
|
||||
'sftp.extract.success': 'Распакован {fileName}',
|
||||
'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.duplicate': 'Дублировать',
|
||||
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
|
||||
'sftp.copyCurrentPath': 'Копировать текущий путь',
|
||||
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
|
||||
|
||||
@@ -720,6 +720,8 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '添加终端',
|
||||
'terminal.layer.emptyWorkspace': '工作区中还没有终端',
|
||||
'terminal.layer.emptyWorkspaceHint': '添加本地终端或连接主机,开始使用这个工作区。',
|
||||
'terminal.layer.switchToSplitView': '切换到分屏视图',
|
||||
'terminal.layer.sftp': '文件传输',
|
||||
'terminal.layer.scripts': '脚本',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "从 GitHub 安装",
|
||||
"settings.plugins.github.description": "下载已发布的 .ncpkg 插件包,安装前请审查来源和所需权限。",
|
||||
"settings.plugins.github.repository": "GitHub 仓库",
|
||||
"settings.plugins.github.formats": "支持 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下载、验证或安装插件包…",
|
||||
"settings.plugins.github.review": "确认仓库来源并通过完整性校验,并不代表发布者值得信任。安装过程不会运行构建脚本,插件安装后默认禁用。启用前,请确认来源可信并检查各项权限的授权方式。",
|
||||
"settings.plugins.github.accept": "已审查并信任此仓库,且已查看声明的权限。",
|
||||
"settings.plugins.github.confirm": "安装并保持禁用",
|
||||
"settings.plugins.github.download": "下载并审查",
|
||||
"settings.plugins.github.error.invalidRepository": "请输入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。请只填写仓库地址,不要包含账号密码、查询参数、片段或额外路径。",
|
||||
"settings.plugins.github.error.unavailable": "当前会话无法从 GitHub 安装插件。",
|
||||
"settings.plugins.github.error.reviewFirst": "请先下载并审查 GitHub 插件。",
|
||||
"settings.plugins.github.error.network": "无法连接 GitHub,请检查网络连接后重试。",
|
||||
"settings.plugins.github.error.timeout": "从 GitHub 下载超时,请重试。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 请求次数已达上限,请稍后重试。",
|
||||
"settings.plugins.github.error.notFound": "未找到 GitHub 仓库或发布附件。仅支持可访问的公开仓库;私有插件包请从本地导入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒绝访问。此功能不支持私有仓库或登录验证。",
|
||||
"settings.plugins.github.error.http": "从 GitHub 下载失败(HTTP {status}),请稍后重试。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 仓库已迁移,请输入当前仓库地址。",
|
||||
"settings.plugins.github.error.redirect": "无法验证 GitHub 下载重定向。请下载可信的 .ncpkg 插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.noRelease": "此仓库尚未发布稳定版本。请作者发布包含 .ncpkg 附件的稳定版本,或从本地导入已构建的插件包。",
|
||||
"settings.plugins.github.error.noAsset": "此版本没有 .ncpkg 附件。请作者构建并上传插件包;Netcatty 无法直接构建源码仓库。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多个 .ncpkg 附件。请下载需要的插件包,再从本地导入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 发布信息无效。请作者检查版本发布、标签和 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "插件包与所选 GitHub 仓库或发布版本不匹配,已停止安装。",
|
||||
"settings.plugins.github.error.commit": "无法访问发布标签对应的提交,请作者修复发布标签。",
|
||||
"settings.plugins.github.error.size": "GitHub 插件包大小无效或超出下载限制。",
|
||||
"settings.plugins.github.error.integrity": "下载的插件包未通过大小或 SHA-256 校验,请重新下载并审查。",
|
||||
"settings.plugins.github.error.package": "GitHub 返回的内容不是有效的 .ncpkg ZIP 插件包或发布信息,请作者检查发布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 插件操作正在进行,请关闭预览后重试。",
|
||||
"settings.plugins.github.error.expired": "GitHub 插件包预览已失效,请重新下载并审查。",
|
||||
"settings.plugins.github.error.advanced": "此插件包需要高级开发者模式。普通插件管理仅接受浏览器运行时插件包。",
|
||||
"settings.plugins.github.error.incompatible": "此插件与当前 Netcatty 版本不兼容,请联系作者确认技术详情。",
|
||||
"settings.plugins.github.error.source": "此插件已绑定其他来源。更换来源前,请先卸载插件并明确清除原来源的私有数据。",
|
||||
"settings.plugins.github.error.failed": "无法验证或安装插件,请查看技术详情后再重试。",
|
||||
"settings.plugins.github.error.details": "技术详情",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "检查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在检查…",
|
||||
"settings.plugins.landing.updateSummary": "已检查:{checked} · 已发布更新:{updates} · 未检查:{skipped}。",
|
||||
@@ -1015,7 +1053,26 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'sftp.extract.extracting': '正在解压 {fileName}...',
|
||||
'sftp.extract.success': '已解压 {fileName}',
|
||||
'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.duplicate': '创建副本',
|
||||
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
|
||||
'sftp.copyCurrentPath': '复制当前路径',
|
||||
'sftp.copyCurrentPath.success': '已复制当前路径',
|
||||
|
||||
@@ -710,6 +710,8 @@ export const zhTWAiMessages: Messages = {
|
||||
|
||||
// 統一終端工作區和頂部標籤的 tooltip 文案 (issue #954)
|
||||
'terminal.layer.addTerminal': '新增終端',
|
||||
'terminal.layer.emptyWorkspace': '工作區中還沒有終端',
|
||||
'terminal.layer.emptyWorkspaceHint': '新增本機終端或連線至主機,開始使用這個工作區。',
|
||||
'terminal.layer.switchToSplitView': '切換到分割檢視',
|
||||
'terminal.layer.sftp': '檔案傳輸',
|
||||
'terminal.layer.scripts': '指令碼',
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWCoreMessages: Messages = {
|
||||
"settings.plugins.github.install": "從 GitHub 安裝",
|
||||
"settings.plugins.github.description": "下載已發布的 .ncpkg 外掛套件,安裝前請審查來源與所需權限。",
|
||||
"settings.plugins.github.repository": "GitHub 儲存庫",
|
||||
"settings.plugins.github.formats": "支援 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。",
|
||||
"settings.plugins.github.busy": "正在下載、驗證或安裝套件…",
|
||||
"settings.plugins.github.review": "確認儲存庫來源並通過完整性驗證,並不代表發布者值得信任。安裝過程不會執行建置腳本,外掛安裝後預設停用。啟用前,請確認來源可信並檢查各項權限的授權方式。",
|
||||
"settings.plugins.github.accept": "已審查並信任此儲存庫,且已查看宣告的權限。",
|
||||
"settings.plugins.github.confirm": "安裝並保持停用",
|
||||
"settings.plugins.github.download": "下載並審查",
|
||||
"settings.plugins.github.error.invalidRepository": "請輸入 https://github.com/owner/repo、github.com/owner/repo 或 owner/repo。請只填寫儲存庫位址,不要包含帳號密碼、查詢參數、片段或額外路徑。",
|
||||
"settings.plugins.github.error.unavailable": "目前的工作階段無法從 GitHub 安裝外掛。",
|
||||
"settings.plugins.github.error.reviewFirst": "請先下載並審查 GitHub 外掛。",
|
||||
"settings.plugins.github.error.network": "無法連線至 GitHub,請檢查網路連線後重試。",
|
||||
"settings.plugins.github.error.timeout": "從 GitHub 下載逾時,請重試。",
|
||||
"settings.plugins.github.error.rateLimit": "GitHub API 請求次數已達上限,請稍後重試。",
|
||||
"settings.plugins.github.error.notFound": "找不到 GitHub 儲存庫或發布附件。僅支援可存取的公開儲存庫;私有外掛套件請從本機匯入。",
|
||||
"settings.plugins.github.error.accessDenied": "GitHub 拒絕存取。此功能不支援私有儲存庫或登入驗證。",
|
||||
"settings.plugins.github.error.http": "從 GitHub 下載失敗(HTTP {status}),請稍後重試。",
|
||||
"settings.plugins.github.error.moved": "此 GitHub 儲存庫已遷移,請輸入目前的儲存庫位址。",
|
||||
"settings.plugins.github.error.redirect": "無法驗證 GitHub 下載重新導向。請下載可信的 .ncpkg 外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.noRelease": "此儲存庫尚未發布穩定版本。請作者發布包含 .ncpkg 附件的穩定版本,或從本機匯入已建置的外掛套件。",
|
||||
"settings.plugins.github.error.noAsset": "此版本沒有 .ncpkg 附件。請作者建置並上傳外掛套件;Netcatty 無法直接建置原始碼儲存庫。",
|
||||
"settings.plugins.github.error.multipleAssets": "此版本包含多個 .ncpkg 附件。請下載需要的外掛套件,再從本機匯入。",
|
||||
"settings.plugins.github.error.metadata": "GitHub 發布資訊無效。請作者檢查版本發布、標籤及 .ncpkg 附件。",
|
||||
"settings.plugins.github.error.identity": "外掛套件與所選 GitHub 儲存庫或發布版本不符,已停止安裝。",
|
||||
"settings.plugins.github.error.commit": "無法存取發布標籤對應的提交,請作者修復發布標籤。",
|
||||
"settings.plugins.github.error.size": "GitHub 外掛套件大小無效或超出下載限制。",
|
||||
"settings.plugins.github.error.integrity": "下載的外掛套件未通過大小或 SHA-256 驗證,請重新下載並審查。",
|
||||
"settings.plugins.github.error.package": "GitHub 傳回的內容不是有效的 .ncpkg ZIP 外掛套件或發布資訊,請作者檢查發布附件。",
|
||||
"settings.plugins.github.error.busy": "已有 GitHub 外掛操作正在進行,請關閉預覽後重試。",
|
||||
"settings.plugins.github.error.expired": "GitHub 外掛套件預覽已失效,請重新下載並審查。",
|
||||
"settings.plugins.github.error.advanced": "此外掛套件需要進階開發者模式。一般外掛管理僅接受瀏覽器執行環境的套件。",
|
||||
"settings.plugins.github.error.incompatible": "此外掛與目前的 Netcatty 版本不相容,請聯絡作者確認技術詳細資料。",
|
||||
"settings.plugins.github.error.source": "此外掛已綁定其他來源。更換來源前,請先解除安裝並明確清除原來源的私有資料。",
|
||||
"settings.plugins.github.error.failed": "無法驗證或安裝外掛,請查看技術詳細資料後再重試。",
|
||||
"settings.plugins.github.error.details": "技術詳細資料",
|
||||
|
||||
|
||||
"settings.plugins.landing.checkUpdates": "檢查更新",
|
||||
"settings.plugins.landing.checkingUpdates": "正在檢查…",
|
||||
"settings.plugins.landing.updateSummary": "已檢查:{checked} · 已發佈更新:{updates} · 未檢查:{skipped}。",
|
||||
@@ -1017,7 +1055,26 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'sftp.extract.extracting': '正在解壓 {fileName}...',
|
||||
'sftp.extract.success': '已解壓 {fileName}',
|
||||
'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.duplicate': '建立副本',
|
||||
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
|
||||
'sftp.copyCurrentPath': '複製目前路徑',
|
||||
'sftp.copyCurrentPath.success': '已複製目前路徑',
|
||||
|
||||
@@ -165,7 +165,7 @@ function isSuccessfulApplyResult(result: unknown): boolean {
|
||||
* the DB before collection. Ordinary last-known collect snapshots are never
|
||||
* 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
|
||||
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
|
||||
|
||||
@@ -75,6 +75,95 @@ 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', () => {
|
||||
assert.equal(
|
||||
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
|
||||
@@ -89,3 +178,18 @@ test('resolves standalone sessions and fails closed for non-terminal tabs', () =
|
||||
'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,4 +1,6 @@
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { resolveMostRecentlyActiveMemberTerminalId } from '../../domain/aiSessionRecency';
|
||||
import type { AIChatRecencyEntry } from '../../domain/aiSessionRecency';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
|
||||
export type PluginContributionContextValue = string | boolean | number | null;
|
||||
@@ -44,14 +46,17 @@ export function resolveActivePluginKeybindingContext({
|
||||
activeTabId,
|
||||
sessions,
|
||||
workspaces,
|
||||
aiSessions,
|
||||
}: {
|
||||
activeTabId: string;
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
/** AI chat snapshot: resolves the workspace anchor and recency fallback (#4098 phase 2). */
|
||||
aiSessions?: readonly AIChatRecencyEntry[];
|
||||
}): PluginContributionContext {
|
||||
const workspace = workspaces.find((candidate) => candidate.id === activeTabId);
|
||||
const activeSessionId = workspace
|
||||
? (workspace.focusedSessionId ?? collectSessionIds(workspace.root)[0])
|
||||
? resolveWorkspaceActiveTerminalId(workspace, aiSessions)
|
||||
: sessions.some((candidate) => candidate.id === activeTabId)
|
||||
? activeTabId
|
||||
: undefined;
|
||||
@@ -72,3 +77,32 @@ export function resolveActivePluginKeybindingContext({
|
||||
'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,6 +45,7 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'resetWorkspaceRename'
|
||||
| 'removeSessionFromWorkspace'
|
||||
| 'setWorkspaceFocusedSession'
|
||||
| 'setWorkspaceAnchorChat'
|
||||
| 'toggleWorkspaceViewMode'
|
||||
// Terminal / chrome mutators previously read only via App session runtime.
|
||||
| 'createLocalTerminal'
|
||||
@@ -56,6 +57,7 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'createWorkspaceWithHosts'
|
||||
| 'createWorkspaceFromSessions'
|
||||
| 'addSessionToWorkspace'
|
||||
| 'moveWorkspacePane'
|
||||
| 'appendHostToWorkspace'
|
||||
| 'appendHostToWorkspaceConfirmed'
|
||||
| 'appendLocalTerminalToWorkspace'
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
|
||||
import {
|
||||
applyCloseSessionToSessions,
|
||||
closeSessionsState,
|
||||
closeSessionWorkspaceLayoutState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
resolveActiveTabAfterCloseSession,
|
||||
} from "./sessionWorkspaceDetach";
|
||||
|
||||
@@ -14,11 +14,19 @@ const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||||
id,
|
||||
hostId: id,
|
||||
hostLabel: id,
|
||||
username: "user",
|
||||
hostname: id,
|
||||
status: "connected",
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[]): Workspace => ({
|
||||
const chat = (id: string, targetId: string, updatedAt: number): AIChatRecencyEntry => ({
|
||||
id,
|
||||
scope: { type: "terminal", targetId },
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Workspace => ({
|
||||
id: "ws-1",
|
||||
title: "Workspace",
|
||||
focusedSessionId: sessionIds[0],
|
||||
@@ -36,9 +44,17 @@ const workspace = (sessionIds: string[]): Workspace => ({
|
||||
})),
|
||||
sizes: sessionIds.map(() => 1),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("detach dissolves the original workspace when one session remains", () => {
|
||||
const rootPaneSessionIds = (workspace: Workspace): string[] => (
|
||||
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({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
@@ -49,149 +65,126 @@ test("detach dissolves the original workspace when one session remains", () => {
|
||||
assert.equal(result.activeTabId, "s1");
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", undefined],
|
||||
["s2", undefined],
|
||||
// The survivor keeps its workspace binding — no orphan promotion.
|
||||
["s2", "ws-1"],
|
||||
]);
|
||||
assert.equal(result.workspaces.length, 0);
|
||||
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||||
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].id, "ws-1");
|
||||
assert.deepEqual(rootPaneSessionIds(result.workspaces[0]), ["s2"]);
|
||||
});
|
||||
|
||||
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||||
test("detaching the last member keeps the workspace with an empty root (#4098 phase 2)", () => {
|
||||
const result = detachSessionFromWorkspaceState({
|
||||
sessions: [session("s1"), session("s2"), session("s3")],
|
||||
workspaces: [workspace(["s1", "s2", "s3"])],
|
||||
sessionId: "s2",
|
||||
sessions: [session("s1")],
|
||||
workspaces: [workspace(["s1"])],
|
||||
sessionId: "s1",
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||||
["s1", "ws-1"],
|
||||
["s2", undefined],
|
||||
["s3", "ws-1"],
|
||||
]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||||
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.map((child) => child.type === "pane" ? child.sessionId : null)
|
||||
: [],
|
||||
["s1", "s3"],
|
||||
result.workspaces[0].root.type === "split" ? result.workspaces[0].root.children : null,
|
||||
[],
|
||||
);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, undefined);
|
||||
});
|
||||
|
||||
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("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("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)", () => {
|
||||
test("member loss hands focus to the most-recently-active survivor, not sessions[0]", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
[workspace(["s1", "s2", "s3"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
|
||||
);
|
||||
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
|
||||
// remainingSessionIds[0] is s2, but s3's chat is the most recent.
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s3");
|
||||
});
|
||||
|
||||
test("closing a non-focused session keeps the focused session", () => {
|
||||
test("reordering focus order does not change the survivor retarget", () => {
|
||||
const result = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1", "s2", "s3", "s4"])],
|
||||
[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.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
|
||||
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2"]);
|
||||
});
|
||||
|
||||
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
test("with no AI recency signal focus stays on a surviving terminal", () => {
|
||||
const result = 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",
|
||||
);
|
||||
assert.equal(result.workspaces.length, 1);
|
||||
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
||||
});
|
||||
|
||||
test("closing the last workspace session does not invent a surviving terminal", () => {
|
||||
test("closing ALL members keeps the workspace alive with an empty root (#4098 phase 2)", () => {
|
||||
const result = closeSessionsState({
|
||||
sessions: [session("s1"), session("s2")],
|
||||
workspaces: [workspace(["s1", "s2"])],
|
||||
sessionIds: ["s1", "s2"],
|
||||
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);
|
||||
});
|
||||
|
||||
test("closing a solo workspace member still replaces the closed tab's activity via fallbacks", () => {
|
||||
const layoutResult = closeSessionWorkspaceLayoutState(
|
||||
[workspace(["s1"])],
|
||||
"ws-1",
|
||||
"s1",
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions(
|
||||
[session("s1")],
|
||||
"s1",
|
||||
layoutResult,
|
||||
);
|
||||
const nextSessions = applyCloseSessionToSessions([session("s1")], "s1");
|
||||
|
||||
assert.deepEqual(nextSessions, []);
|
||||
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
|
||||
assert.equal(layoutResult.lastRemainingSessionId, undefined);
|
||||
// The workspace tab survives, so an active workspace tab needs no fallback.
|
||||
assert.equal(
|
||||
resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: "ws-1",
|
||||
@@ -200,25 +193,7 @@ test("closing the last workspace session does not invent a surviving terminal",
|
||||
layoutResult,
|
||||
remainingSessions: nextSessions,
|
||||
}),
|
||||
"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",
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import type { TerminalSession, Workspace } from "../../domain/models";
|
||||
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
|
||||
import { resolveMostRecentlyActiveMemberTerminalId } from "../../domain/aiSessionRecency";
|
||||
import { collectSessionIds, createEmptyWorkspaceRoot, pruneWorkspaceNode } from "../../domain/workspace";
|
||||
|
||||
export type DetachSessionFromWorkspaceStateResult = {
|
||||
changed: boolean;
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
activeTabId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
replacementTabIds?: string[];
|
||||
};
|
||||
|
||||
export type CloseSessionWorkspaceLayoutResult = {
|
||||
workspaces: Workspace[];
|
||||
removedWorkspaceId?: string;
|
||||
dissolvedWorkspaceId?: string;
|
||||
lastRemainingSessionId?: string;
|
||||
};
|
||||
|
||||
export type CloseSessionsStateResult = {
|
||||
@@ -28,82 +25,65 @@ type DetachSessionFromWorkspaceStateOptions = {
|
||||
sessions: TerminalSession[];
|
||||
workspaces: Workspace[];
|
||||
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(
|
||||
tabOrder: readonly string[],
|
||||
workspaceId: string | undefined,
|
||||
replacementTabIds: readonly string[] | undefined,
|
||||
): string[] {
|
||||
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
|
||||
|
||||
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
|
||||
tabId && list.indexOf(tabId) === index
|
||||
));
|
||||
if (uniqueReplacementIds.length === 0) return [...tabOrder];
|
||||
|
||||
if (!tabOrder.includes(workspaceId)) {
|
||||
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
|
||||
return hasAllReplacementIds ? [...tabOrder] : [
|
||||
...tabOrder,
|
||||
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
|
||||
];
|
||||
/**
|
||||
* Resolve the member that should receive focus (or an anchor retarget) after a
|
||||
* member loss. Prefers the AI-chat recency winner; falls back to the still-
|
||||
* valid preferred (focused) member because that is explicit user intent, and
|
||||
* falls back to a surviving pane so focus mode never hides all terminals.
|
||||
*/
|
||||
const resolveSurvivingFocusMemberId = (
|
||||
remainingSessionIds: readonly string[],
|
||||
previousFocusedSessionId: string | undefined,
|
||||
aiSessions: readonly AIChatRecencyEntry[] | undefined,
|
||||
): string | undefined => {
|
||||
if (previousFocusedSessionId && remainingSessionIds.includes(previousFocusedSessionId)) {
|
||||
return previousFocusedSessionId;
|
||||
}
|
||||
|
||||
const replacementIdSet = new Set(uniqueReplacementIds);
|
||||
let inserted = false;
|
||||
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;
|
||||
}
|
||||
return resolveMostRecentlyActiveMemberTerminalId({
|
||||
memberTerminalIds: remainingSessionIds,
|
||||
aiSessions,
|
||||
}) ?? remainingSessionIds[0];
|
||||
};
|
||||
|
||||
export function closeSessionWorkspaceLayoutState(
|
||||
workspaces: readonly Workspace[],
|
||||
workspaceId: string | undefined,
|
||||
sessionId: string,
|
||||
aiSessions?: readonly AIChatRecencyEntry[],
|
||||
): CloseSessionWorkspaceLayoutResult {
|
||||
if (!workspaceId) return { workspaces: [...workspaces] };
|
||||
|
||||
let removedWorkspaceId: string | undefined;
|
||||
let dissolvedWorkspaceId: string | undefined;
|
||||
let lastRemainingSessionId: string | undefined;
|
||||
const nextWorkspaces = workspaces
|
||||
.map((workspace) => {
|
||||
if (workspace.id !== workspaceId) return workspace;
|
||||
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
|
||||
if (!prunedRoot) {
|
||||
removedWorkspaceId = workspace.id;
|
||||
return null;
|
||||
// Issue #4098 phase 2: the last member closed, but the workspace is a
|
||||
// first-class entity — it survives with an empty root so its id (and
|
||||
// the AI chats anchored to it) survive restart and member churn.
|
||||
return {
|
||||
...workspace,
|
||||
root: createEmptyWorkspaceRoot(),
|
||||
focusedSessionId: undefined,
|
||||
focusSessionOrder: [],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
dissolvedWorkspaceId = workspace.id;
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Closing the focused session hands focus to the most-recently-active
|
||||
// surviving member when AI chat recency is available.
|
||||
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
|
||||
remainingSessionIds,
|
||||
workspace.focusedSessionId,
|
||||
aiSessions,
|
||||
);
|
||||
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
|
||||
(candidateId) => remainingSessionIds.includes(candidateId),
|
||||
);
|
||||
@@ -114,36 +94,23 @@ export function closeSessionWorkspaceLayoutState(
|
||||
focusedSessionId: nextFocusedSessionId,
|
||||
focusSessionOrder: nextFocusSessionOrder,
|
||||
};
|
||||
})
|
||||
.filter((workspace): workspace is Workspace => Boolean(workspace));
|
||||
});
|
||||
|
||||
return {
|
||||
workspaces: nextWorkspaces,
|
||||
removedWorkspaceId,
|
||||
dissolvedWorkspaceId,
|
||||
lastRemainingSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a close-session action to the session list using the layout result.
|
||||
* When a 2-pane workspace dissolves to one terminal, that remaining session
|
||||
* must become an orphan tab — not stay bound to a deleted workspaceId.
|
||||
* Since issue #4098 phase 2 workspaces survive member loss (no dissolve), the
|
||||
* remaining members keep their workspace binding.
|
||||
*/
|
||||
export function applyCloseSessionToSessions(
|
||||
sessions: readonly TerminalSession[],
|
||||
sessionId: string,
|
||||
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
|
||||
): TerminalSession[] {
|
||||
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
|
||||
));
|
||||
return sessions.filter((session) => session.id !== sessionId);
|
||||
}
|
||||
|
||||
export function isPreferredTabAvailableAfterClose(
|
||||
@@ -161,12 +128,6 @@ export function isPreferredTabAvailableAfterClose(
|
||||
} {
|
||||
const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input;
|
||||
if (!preferredTabId || preferredTabId === closedSessionId) return false;
|
||||
if (
|
||||
preferredTabId === layoutResult.dissolvedWorkspaceId
|
||||
|| preferredTabId === layoutResult.removedWorkspaceId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (preferredTabId === "vault" || preferredTabId === "sftp") return true;
|
||||
if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true;
|
||||
return remainingSessions.some((session) => (
|
||||
@@ -179,7 +140,6 @@ export function isPreferredTabAvailableAfterClose(
|
||||
export function resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId,
|
||||
closedSessionId,
|
||||
workspaceId,
|
||||
layoutResult,
|
||||
remainingSessions,
|
||||
preferredTabId,
|
||||
@@ -202,18 +162,14 @@ export function resolveActiveTabAfterCloseSession({
|
||||
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
|
||||
? preferredInput.preferredTabId
|
||||
: undefined;
|
||||
const fallback = layoutResult.lastRemainingSessionId
|
||||
?? preferredFallback
|
||||
const fallback = preferredFallback
|
||||
?? fallbackWorkspace?.id
|
||||
?? fallbackSolo?.id
|
||||
?? "vault";
|
||||
|
||||
if (
|
||||
currentActiveTabId === closedSessionId
|
||||
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|
||||
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|
||||
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
|
||||
) {
|
||||
// Workspaces survive member loss (#4098 phase 2), so only a closed session
|
||||
// tab that was active needs a replacement.
|
||||
if (currentActiveTabId === closedSessionId) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -227,6 +183,7 @@ export function closeSessionsState({
|
||||
currentActiveTabId,
|
||||
preferredTabId,
|
||||
tabOrder,
|
||||
aiSessions,
|
||||
}: {
|
||||
sessions: readonly TerminalSession[];
|
||||
workspaces: readonly Workspace[];
|
||||
@@ -234,6 +191,7 @@ export function closeSessionsState({
|
||||
currentActiveTabId: string | null;
|
||||
preferredTabId?: string | null;
|
||||
tabOrder: readonly string[];
|
||||
aiSessions?: readonly AIChatRecencyEntry[];
|
||||
}): CloseSessionsStateResult {
|
||||
let nextSessions = [...sessions];
|
||||
let nextWorkspaces = [...workspaces];
|
||||
@@ -250,17 +208,10 @@ export function closeSessionsState({
|
||||
nextWorkspaces,
|
||||
workspaceId,
|
||||
sessionId,
|
||||
aiSessions,
|
||||
);
|
||||
nextWorkspaces = layoutResult.workspaces;
|
||||
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
|
||||
|
||||
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
|
||||
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
nextTabOrder,
|
||||
layoutResult.dissolvedWorkspaceId,
|
||||
[layoutResult.lastRemainingSessionId],
|
||||
);
|
||||
}
|
||||
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId);
|
||||
|
||||
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
|
||||
currentActiveTabId: nextActiveTabId,
|
||||
@@ -288,6 +239,7 @@ export function detachSessionFromWorkspaceState({
|
||||
sessions,
|
||||
workspaces,
|
||||
sessionId,
|
||||
aiSessions,
|
||||
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (!session?.workspaceId) {
|
||||
@@ -301,40 +253,36 @@ export function detachSessionFromWorkspaceState({
|
||||
}
|
||||
|
||||
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
|
||||
let nextSessions = sessions.map((candidate) => (
|
||||
const nextSessions = sessions.map((candidate) => (
|
||||
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
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 {
|
||||
changed: true,
|
||||
sessions: nextSessions,
|
||||
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
|
||||
workspaces: workspaces.map((workspace) => (
|
||||
workspace.id === workspaceId
|
||||
? {
|
||||
...workspace,
|
||||
root: createEmptyWorkspaceRoot(),
|
||||
focusedSessionId: undefined,
|
||||
focusSessionOrder: [],
|
||||
}
|
||||
: workspace
|
||||
)),
|
||||
activeTabId: sessionId,
|
||||
dissolvedWorkspaceId: workspaceId,
|
||||
replacementTabIds: [sessionId],
|
||||
};
|
||||
}
|
||||
|
||||
const remainingSessionIds = collectSessionIds(prunedRoot);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
nextSessions = nextSessions.map((candidate) => (
|
||||
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
|
||||
));
|
||||
|
||||
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 nextFocusedSessionId = resolveSurvivingFocusMemberId(
|
||||
remainingSessionIds,
|
||||
targetWorkspace.focusedSessionId,
|
||||
aiSessions,
|
||||
);
|
||||
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
|
||||
.filter((candidateId, index, list) => (
|
||||
candidateId !== sessionId &&
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** @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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/** @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,10 +61,11 @@ export function useSftpTransferConflictOps() {
|
||||
|
||||
const getDuplicateTarget = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
task: Pick<TransferTask, "fileName" | "isDirectory" | "targetPath">,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
isTargetTaken?: (targetPath: string) => boolean,
|
||||
) => {
|
||||
const parentPath = getParentPath(task.targetPath);
|
||||
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
|
||||
@@ -73,6 +74,9 @@ export function useSftpTransferConflictOps() {
|
||||
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
|
||||
const fileName = `${baseName}${suffix}${ext}`;
|
||||
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.
|
||||
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
|
||||
if (!existing) return { fileName, targetPath };
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
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,6 +1326,13 @@ export const useSftpTransfers = ({
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: 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>;
|
||||
},
|
||||
) => {
|
||||
@@ -1429,6 +1436,10 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
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 =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
? "upload"
|
||||
@@ -1461,8 +1472,36 @@ export const useSftpTransfers = ({
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
const nextSourcePath = joinPath(sourcePath, file.name);
|
||||
const nextTargetPath = joinPath(targetPath, file.name);
|
||||
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(
|
||||
[...sftpTransferCenterStore.getSnapshot().tasks, ...transfersRef.current, ...newTasks],
|
||||
{
|
||||
@@ -1481,7 +1520,7 @@ export const useSftpTransfers = ({
|
||||
const attentionTask: TransferTask = {
|
||||
id: crypto.randomUUID(),
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
fileName: nextFileName,
|
||||
originalFileName: file.name,
|
||||
sourcePath: nextSourcePath,
|
||||
targetPath: nextTargetPath,
|
||||
@@ -1522,7 +1561,7 @@ export const useSftpTransfers = ({
|
||||
newTasks.push({
|
||||
id: taskId,
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
fileName: nextFileName,
|
||||
originalFileName: file.name,
|
||||
sourcePath: nextSourcePath,
|
||||
targetPath: nextTargetPath,
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface UseSftpTransfersResult {
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: 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>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 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,6 +127,8 @@ export interface SendToCattyContext {
|
||||
}
|
||||
|
||||
export interface SendToExternalContext {
|
||||
scopeType?: 'terminal' | 'workspace' | 'global';
|
||||
scopeTargetId?: string;
|
||||
existingSessionId?: string;
|
||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
|
||||
@@ -1597,6 +1597,7 @@ export function useAIState() {
|
||||
previousMemberTerminalIds: readonly string[];
|
||||
currentMemberTerminalIds: readonly string[];
|
||||
preferredTerminalId?: string | null;
|
||||
activeTargetIds?: ReadonlySet<string>;
|
||||
}) => {
|
||||
const currentMap =
|
||||
latestAIActiveSessionMapSnapshot
|
||||
@@ -1612,6 +1613,7 @@ export function useAIState() {
|
||||
previousMemberTerminalIds: input.previousMemberTerminalIds,
|
||||
currentMemberTerminalIds: input.currentMemberTerminalIds,
|
||||
preferredTerminalId: input.preferredTerminalId,
|
||||
activeTargetIds: input.activeTargetIds,
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { parsePluginGitHubRepository } from '@/domain/pluginGitHubRepository.cjs';
|
||||
|
||||
export function usePluginGitHubInstall(onInstalled: () => Promise<unknown>) {
|
||||
const [preview, setPreview] = useState<NetcattyGitHubPluginPreview | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const token = useRef<string | null>(null);
|
||||
const active = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const reset = useCallback(() => {
|
||||
const previous = token.current;
|
||||
token.current = null;
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
if (previous) void netcattyBridge.get()?.discardGitHubPlugin?.(previous).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
if (token.current) void netcattyBridge.get()?.discardGitHubPlugin?.(token.current).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
const run = async (operation: () => Promise<void>) => {
|
||||
if (active.current) return;
|
||||
active.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await operation(); }
|
||||
catch (cause) { if (mounted.current) setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally { active.current = false; if (mounted.current) setBusy(false); }
|
||||
};
|
||||
return {
|
||||
preview, busy, error, reset,
|
||||
prepare: (input: string) => run(async () => {
|
||||
const { origin } = parsePluginGitHubRepository(input);
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.prepareGitHubPlugin) throw new Error('GitHub plugin installation unavailable');
|
||||
reset();
|
||||
const result = await bridge.prepareGitHubPlugin(origin);
|
||||
if (!mounted.current) { await bridge.discardGitHubPlugin?.(result.token); return; }
|
||||
token.current = result.token;
|
||||
setPreview(result);
|
||||
}),
|
||||
install: () => run(async () => {
|
||||
const bridge = netcattyBridge.require();
|
||||
if (!bridge.installGitHubPlugin || !token.current) throw new Error('Download and review a GitHub plugin first');
|
||||
const selected = token.current;
|
||||
// The host consumes the selection even on failure; retry starts with a new review.
|
||||
token.current = null;
|
||||
setPreview(null);
|
||||
await bridge.installGitHubPlugin(selected);
|
||||
await onInstalled();
|
||||
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
|
||||
|
||||
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
import { installTestLocalStorage } from "./testSupport/localStorage.ts";
|
||||
|
||||
test.beforeEach((t) => { t.after(installTestLocalStorage()); });
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
@@ -86,6 +89,15 @@ test("global broadcast availability follows visible orphan session count", async
|
||||
await act(async () => {
|
||||
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().isGlobalBroadcastEnabled, false);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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,6 +19,7 @@ createWorkspaceFromSessionIds,
|
||||
FocusDirection,
|
||||
getNextFocusSessionId,
|
||||
insertPaneIntoWorkspace,
|
||||
movePaneInWorkspace,
|
||||
pruneWorkspaceNode,
|
||||
reorderWorkspaceFocusSessionOrder,
|
||||
setWorkspaceFocusedSessionId,
|
||||
@@ -42,8 +43,9 @@ import {
|
||||
import {
|
||||
closeSessionsState,
|
||||
detachSessionFromWorkspaceState,
|
||||
replaceDissolvedWorkspaceTabOrder,
|
||||
} from './sessionWorkspaceDetach';
|
||||
import { getAISessionsSnapshot } from './aiSessionsStore';
|
||||
import { getPersistedAiSessionsSnapshot } from './aiStateSnapshots';
|
||||
import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
@@ -122,6 +124,26 @@ 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(
|
||||
prevTabOrder: string[],
|
||||
sourceTabId: string,
|
||||
@@ -615,6 +637,11 @@ export const useSessionState = ({
|
||||
currentActiveTabId: activeTabStore.getActiveTabId(),
|
||||
preferredTabId: activeTabStore.getPreviousActiveTabId(),
|
||||
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);
|
||||
@@ -822,6 +849,20 @@ export const useSessionState = ({
|
||||
});
|
||||
}, [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
|
||||
// workspace, that workspace (and its id) is reused instead of dissolving
|
||||
// and recreating: the id is what workspace-scoped AI chats bind to, so
|
||||
@@ -1106,6 +1147,25 @@ export const useSessionState = ({
|
||||
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((
|
||||
workspaceId: string,
|
||||
draggedSessionId: string,
|
||||
@@ -1435,18 +1495,16 @@ export const useSessionState = ({
|
||||
sessions: sessionsRef.current,
|
||||
workspaces: workspacesRef.current,
|
||||
sessionId,
|
||||
aiSessions: getAISessionsSnapshot().sessions.length > 0
|
||||
? getAISessionsSnapshot().sessions
|
||||
: getPersistedAiSessionsSnapshot(),
|
||||
});
|
||||
if (!result.changed) return;
|
||||
|
||||
setWorkspaces(result.workspaces);
|
||||
setSessions(result.sessions);
|
||||
setTabOrder((prevTabOrder) => {
|
||||
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
|
||||
prevTabOrder,
|
||||
result.dissolvedWorkspaceId,
|
||||
result.replacementTabIds,
|
||||
);
|
||||
if (!tabInsertionTarget) return replacedOrder;
|
||||
if (!tabInsertionTarget) return prevTabOrder;
|
||||
|
||||
const allTabIds = [
|
||||
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
|
||||
@@ -1455,7 +1513,7 @@ export const useSessionState = ({
|
||||
...(tabInsertionTarget.additionalTabIds ?? []),
|
||||
];
|
||||
return reorderWorkTabIds(
|
||||
replacedOrder,
|
||||
prevTabOrder,
|
||||
allTabIds,
|
||||
sessionId,
|
||||
tabInsertionTarget.tabId,
|
||||
@@ -1515,6 +1573,7 @@ export const useSessionState = ({
|
||||
createWorkspaceFromTargets,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
moveWorkspacePane,
|
||||
removeSessionFromWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendHostToWorkspaceConfirmed,
|
||||
@@ -1523,6 +1582,7 @@ export const useSessionState = ({
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
setWorkspaceFocusedSession,
|
||||
setWorkspaceAnchorChat,
|
||||
reorderWorkspaceSessions,
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
|
||||
@@ -4,6 +4,10 @@ import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
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>;
|
||||
|
||||
@@ -121,6 +125,15 @@ test("merged tab titles follow the workspace lifecycle until the user renames it
|
||||
assert.equal(workspace(workspaceId).title, "Pinned");
|
||||
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 leftJoiningSessionId = "";
|
||||
await act(async () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
tryBeginSendForKey,
|
||||
} from './ai/draftSendGate';
|
||||
import { draftsByScopeEqualIgnoringComposerText, selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import { latestAIActiveSessionMapSnapshot, latestAISessionsSnapshot } from '../application/state/aiStateSnapshots';
|
||||
import { sanitizeContextWindow } from '../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
buildPromptWithTerminalSelectionAttachments,
|
||||
@@ -316,6 +317,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
focusedSessionId,
|
||||
onSetWorkspaceAnchorChat,
|
||||
workspaces = [],
|
||||
terminalSessions = [],
|
||||
liveScopeTargetIds,
|
||||
@@ -444,9 +446,16 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
preferredTerminalId: focusedSessionId,
|
||||
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>(
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId],
|
||||
);
|
||||
const activeSession = useMemo(
|
||||
() => resolveDisplayedSession(normalizedPanelView, historySessions),
|
||||
@@ -641,6 +650,21 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}, { initialDelayMs: 250 });
|
||||
}, [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(() => {
|
||||
if (!isVisible) return;
|
||||
// Predicate must match normalizePanelView's list (scoped history), not the
|
||||
@@ -648,16 +672,23 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (!shouldForceDraftViewSync(
|
||||
explicitPanelView,
|
||||
normalizedPanelView,
|
||||
(sessionId) => historySessions.some((session) => session.id === sessionId),
|
||||
isSessionVisibleInCurrentScope,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
showDraftView(scopeKey);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, showDraftView]);
|
||||
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, isSessionVisibleInCurrentScope, showDraftView]);
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
@@ -665,18 +696,24 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
activeSession,
|
||||
activeSessionIdMap,
|
||||
scopeKey,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
isVisible,
|
||||
onSetWorkspaceAnchorChat,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (normalizedPanelView.mode !== 'draft') return;
|
||||
// Keep ownership while an explicit session is still displayable in this
|
||||
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
|
||||
// Keep a selection that maintenance just made displayable, but always
|
||||
// honor an explicit New Chat draft. Out-of-scope / missing ids still clear.
|
||||
const selectedSessionId = explicitPanelView?.mode === 'session'
|
||||
? explicitPanelView.sessionId : persistedSessionId;
|
||||
if (
|
||||
explicitPanelView?.mode === 'session'
|
||||
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
|
||||
explicitPanelView?.mode !== 'draft'
|
||||
&& selectedSessionId
|
||||
&& isSessionVisibleInCurrentScope(selectedSessionId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -686,7 +723,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
isVisible,
|
||||
normalizedPanelView.mode,
|
||||
explicitPanelView,
|
||||
historySessions,
|
||||
isSessionVisibleInCurrentScope,
|
||||
persistedSessionId,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
@@ -1788,6 +1825,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, assistantMsgId, modelPrompt, agentConfig, abortController, modelAttachments, {
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
|
||||
@@ -109,8 +109,10 @@ export interface AIChatSidePanelProps {
|
||||
scopeLabel?: string;
|
||||
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
|
||||
focusedSessionId?: string;
|
||||
/** Live workspaces, used to resolve workspace titles for `/chats`. */
|
||||
workspaces?: Array<{ id: string; title: string }>;
|
||||
/** Issue #4098 phase 2: pin the AI chat a workspace scope is anchored to. */
|
||||
onSetWorkspaceAnchorChat?: (workspaceId: string, chatId: string | null) => void;
|
||||
/** 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. */
|
||||
liveScopeTargetIds?: Set<string>;
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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,6 +8,7 @@ import {
|
||||
Plug,
|
||||
Plus,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -675,7 +676,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
const wizard = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
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()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border/50">
|
||||
<div className="px-6 py-4 border-b border-border/50 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Plug size={18} className="shrink-0 text-foreground" />
|
||||
<div className="min-w-0">
|
||||
@@ -696,6 +696,17 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
) : null}
|
||||
</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>
|
||||
|
||||
|
||||
|
||||
@@ -759,7 +759,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
data-section="pane-magnification-hint"
|
||||
>
|
||||
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -245,12 +245,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
onMoveWorkspacePane,
|
||||
onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace,
|
||||
onUpdateSplitSizes,
|
||||
onSetDraggingSessionId,
|
||||
onToggleWorkspaceViewMode,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSetWorkspaceAnchorChat,
|
||||
onReorderWorkspaceSessions,
|
||||
onReorderTabs,
|
||||
onCopySession,
|
||||
@@ -2712,6 +2714,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
systemMountedTabIds,
|
||||
themeMountedTabIds,
|
||||
onAddSessionToWorkspace,
|
||||
onMoveWorkspacePane,
|
||||
onConnectToHost,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspaceFromSessions,
|
||||
@@ -2726,6 +2729,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSessionData,
|
||||
onSetDraggingSessionId,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onSetWorkspaceAnchorChat,
|
||||
onStartSessionRename,
|
||||
onSubmitSessionRename,
|
||||
onRemoveSessionFromWorkspace,
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
shouldForceDraftViewSync,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
function createSession(id: string, updatedAt = 1): AISession {
|
||||
return {
|
||||
id,
|
||||
title: `Session ${id}`,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
updatedAt,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
@@ -133,6 +133,33 @@ 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", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ export function resolveDisplayedPanelView(
|
||||
sessions: AISession[],
|
||||
persistedSessionId?: string | null,
|
||||
scopeType: "terminal" | "workspace" = "workspace",
|
||||
anchorChatId?: string | null,
|
||||
preferredHistorySessionId?: string | null,
|
||||
): AIPanelView {
|
||||
if (panelView) {
|
||||
return normalizePanelView(panelView, sessions);
|
||||
@@ -66,13 +68,44 @@ export function resolveDisplayedPanelView(
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
if (sessions[0]) {
|
||||
return { mode: "session", sessionId: sessions[0].id };
|
||||
// Prefer the workspace anchor, then the candidate selected by scoped
|
||||
// history's match-rank/recency ordering. Callers without a ranked candidate
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AISession } from "../../infrastructure/ai/types.ts";
|
||||
import { resolveDisplayedPanelView } from "./aiPanelViewState.ts";
|
||||
import {
|
||||
_getScopedHistoryCacheSizeForTests,
|
||||
getScopedHistorySessions,
|
||||
@@ -23,6 +24,49 @@ 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", () => {
|
||||
const staleWorkspaceSession = createSession(
|
||||
"workspace-stale",
|
||||
|
||||
@@ -16,8 +16,8 @@ export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }:
|
||||
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
|
||||
const source = plugin?.source;
|
||||
const sourceKey = source?.kind === 'third-party' ? 'thirdparty' : source?.kind === 'official' || source?.kind === 'local' ? source.kind : undefined;
|
||||
const sourceLabel = t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
|
||||
const sourceDescription = sourceKey
|
||||
const sourceLabel = source?.kind === 'github' ? 'GitHub' : t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
|
||||
const sourceDescription = source?.kind === 'github' ? [source.origin, source.tag, source.commit].filter(Boolean).join(' · ') : sourceKey
|
||||
? [t(`settings.plugins.sources.${sourceKey}`), source?.name, source?.origin].filter(Boolean).join(' · ')
|
||||
: sourceLabel;
|
||||
const SourceIcon = source?.kind === 'local' ? HardDrive : source?.kind === 'official' ? Store : Globe;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
for (const locale of ['en', 'es', 'ru', 'zh-CN', 'zh-TW']) test(`real dialog (${locale}) reviews permissions and requires fresh trust after failed installation for same-package retries and different repositories`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
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', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
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 { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
|
||||
const { loadLocaleMessages, getLoadedLocaleMessages } = await import('../../../application/i18n/messages');
|
||||
await loadLocaleMessages(locale);
|
||||
const messages = getLoadedLocaleMessages(locale)!;
|
||||
const t = (key: string) => messages[key]!;
|
||||
const { PluginGitHubInstall } = await import('./PluginGitHubInstall');
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const requests: string[] = [];
|
||||
const installed: string[] = [];
|
||||
const discarded: string[] = [];
|
||||
let refreshed = 0;
|
||||
let resolveDownload: ((value: NetcattyGitHubPluginPreview) => void) | undefined;
|
||||
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
|
||||
prepareGitHubPlugin: async (input: string) => { requests.push(input); if (requests.length === 1) throw new Error('GitHub API rate limit reached. Try again later'); return new Promise<NetcattyGitHubPluginPreview>(resolve => { resolveDownload = resolve; }); },
|
||||
installGitHubPlugin: async (token: string) => { installed.push(token); if (installed.length <= 2) throw new Error('GitHub package preview expired. Download and review it again'); return {} as NetcattyInstalledPlugin; },
|
||||
discardGitHubPlugin: async (token: string) => { discarded.push(token); },
|
||||
} as NetcattyBridge });
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
|
||||
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find(el => el.textContent === text)!;
|
||||
const click = async (el: HTMLElement) => { assert.ok(el); await act(async () => { el.click(); await settle(); }); };
|
||||
const type = async (text: string) => {
|
||||
await act(async () => {
|
||||
const input = document.querySelector<HTMLInputElement>('input:not([type=checkbox])')!;
|
||||
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(input, text);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await settle();
|
||||
});
|
||||
};
|
||||
const preview = { token: 'reviewed-token', source: { id: 'github:fixture', kind: 'github', name: 'owner/repo', origin: 'https://github.com/owner/repo', tag: 'v1.0.0', commit: 'a'.repeat(40) }, assetName: 'plugin.ncpkg', size: 400,
|
||||
archiveSha256: 'b'.repeat(64), manifest: { manifestVersion: 1, id: 'com.example.fixture', name: 'fixture', publisher: 'example', version: '1.0.0', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' }, permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: 'Read only fixture' }] } } } as NetcattyGitHubPluginPreview;
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
const trigger = button(t('settings.plugins.github.install'));
|
||||
await click(trigger);
|
||||
assert.equal(document.activeElement?.tagName, 'INPUT');
|
||||
assert.ok(button(t('settings.plugins.github.download')).disabled);
|
||||
await type('https://evil.com/owner/repo');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
|
||||
assert.equal(requests.length, 0);
|
||||
if (locale !== 'en') {
|
||||
// The existing error follows provider state without repeating the host call.
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
assert.match(document.querySelector('[role=alert]')!.textContent!, /repository only/);
|
||||
await act(async () => root.render(<I18nProvider locale={locale}><PluginGitHubInstall disabled={false} onInstalled={async () => { refreshed++; }} /></I18nProvider>));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.invalidRepository'));
|
||||
assert.equal(requests.length, 0);
|
||||
}
|
||||
await type('github.com/Owner/Repo.git/');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.rateLimit'));
|
||||
assert.deepEqual(requests, ['https://github.com/owner/repo']);
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
assert.ok(button(t('settings.plugins.github.download')).disabled);
|
||||
assert.equal(document.querySelector('form')?.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input')?.disabled, true);
|
||||
await act(async () => { resolveDownload!(preview); await settle(); });
|
||||
const review = document.querySelector('[data-plugin-github-preview]')!;
|
||||
assert.match(review.textContent!, /https:\/\/github.com\/owner\/repo/);
|
||||
assert.match(review.textContent!, /v1\.0\.0.*aaaaaaaa/);
|
||||
assert.match(review.textContent!, /https:\/\/example.com/);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token']);
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
|
||||
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
|
||||
assert.equal(refreshed, 0);
|
||||
// A retry of the very same archive/repository is a fresh preview too.
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'retry-token' }); await settle(); });
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token']);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.equal(button(t('settings.plugins.github.confirm')).disabled, false);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.equal(document.querySelector('[role=alert] p')!.textContent, t('settings.plugins.github.error.expired'));
|
||||
await type('another/plugin');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'different-token', source: { ...preview.source, origin: 'https://github.com/another/plugin', name: 'another/plugin' } }); await settle(); });
|
||||
assert.equal(requests.at(-1), 'https://github.com/another/plugin');
|
||||
assert.match(document.querySelector('[data-plugin-github-preview]')!.textContent!, /https:\/\/github.com\/another\/plugin/);
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input[type=checkbox]')!.checked, false);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token', 'retry-token']);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
assert.ok(button(t('settings.plugins.github.confirm')).disabled);
|
||||
await click(document.querySelector('input[type=checkbox]')!);
|
||||
await click(button(t('settings.plugins.github.confirm')));
|
||||
assert.deepEqual(installed, ['reviewed-token', 'retry-token', 'different-token']);
|
||||
assert.equal(refreshed, 1);
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
await click(trigger);
|
||||
assert.equal(document.querySelector('[data-plugin-github-preview]'), null);
|
||||
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, '');
|
||||
await type('owner/repo');
|
||||
await click(button(t('settings.plugins.github.download')));
|
||||
await act(async () => { resolveDownload!({ ...preview, token: 'cancel-token' }); await settle(); });
|
||||
await click(button(t('common.back')));
|
||||
assert.deepEqual(discarded, ['cancel-token']);
|
||||
await act(async () => { document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { Github } from 'lucide-react';
|
||||
import { usePluginGitHubInstall } from '../../../application/state/usePluginGitHubInstall';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
import { describePluginGitHubInstallError } from '../../../domain/pluginGitHubInstallError';
|
||||
|
||||
export function PluginGitHubInstall({ disabled, onInstalled }: { disabled: boolean; onInstalled: () => Promise<unknown> }) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const flow = usePluginGitHubInstall(async () => { await onInstalled(); setOpen(false); });
|
||||
const preview = flow.preview;
|
||||
const error = flow.error ? describePluginGitHubInstallError(flow.error) : null;
|
||||
return <Dialog open={open} onOpenChange={(next) => {
|
||||
if (flow.busy) return;
|
||||
setOpen(next); flow.reset(); setAccepted(false); setInput('');
|
||||
}}>
|
||||
<DialogTrigger asChild><Button variant="outline" size="sm" disabled={disabled}><Github size={14} className="mr-2" />{t('settings.plugins.github.install')}</Button></DialogTrigger>
|
||||
<DialogContent hideCloseButton={flow.busy} className="app-no-drag max-h-[85dvh] overflow-y-auto sm:max-w-xl" data-plugin-github-install="">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.plugins.github.install')}</DialogTitle>
|
||||
<DialogDescription>{t('settings.plugins.github.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" aria-busy={flow.busy} onSubmit={(event) => { event.preventDefault(); if (!flow.busy && input.trim() && !preview) void flow.prepare(input); }}>
|
||||
<label className="block space-y-2 text-sm"><span>{t('settings.plugins.github.repository')}</span>
|
||||
<Input autoFocus value={input} disabled={flow.busy || Boolean(preview)} maxLength={512} placeholder="https://github.com/owner/repo" onChange={(event) => setInput(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.formats')}</p>
|
||||
{flow.busy && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.github.busy')}</p>}
|
||||
{error && <div role="alert" className="break-words text-sm text-destructive">
|
||||
<p>{t(error.key, error.values)}</p>
|
||||
{error.details && <details className="mt-2 text-xs"><summary>{t('settings.plugins.github.error.details')}</summary><p className="whitespace-pre-wrap">{error.details}</p></details>}
|
||||
</div>}
|
||||
{preview && <div className="space-y-3" data-plugin-github-preview="">
|
||||
<p className="break-words text-sm font-medium">{preview.manifest.name} · {preview.manifest.version}</p>
|
||||
<div className="break-all rounded-md bg-muted/40 p-3 text-xs">
|
||||
<p>{preview.source.origin}</p><p>{preview.source.tag} · {preview.source.commit}</p>
|
||||
<p>{preview.assetName} · {preview.size.toLocaleString(resolvedLocale)} B</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.plugins.github.review')}</p>
|
||||
<PluginPackageDetails plugin={{ id: preview.manifest.id, enabled: false, activeVersion: preview.manifest.version, manifest: preview.manifest,
|
||||
archiveSha256: preview.archiveSha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />
|
||||
<label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={accepted} disabled={flow.busy} onChange={(event) => setAccepted(event.target.checked)} className="mt-1" />{t('settings.plugins.github.accept')}</label>
|
||||
</div>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={flow.busy} onClick={() => { if (preview) { flow.reset(); setAccepted(false); } else setOpen(false); }}>{t(preview ? 'common.back' : 'common.cancel')}</Button>
|
||||
{preview ? <Button type="button" disabled={flow.busy || !accepted} onClick={() => { setAccepted(false); void flow.install(); }}>{t('settings.plugins.github.confirm')}</Button>
|
||||
: <Button type="submit" disabled={flow.busy || !input.trim()}>{t('settings.plugins.github.download')}</Button>}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
|
||||
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
|
||||
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
|
||||
{plugin.source?.kind === 'github' && <><dt>GitHub</dt><dd className="break-all">{plugin.source.origin}<br />{plugin.source.tag}<br /><span className="font-mono">{plugin.source.commit}</span></dd></>}
|
||||
</dl>
|
||||
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
|
||||
import { PluginGitHubInstall } from './PluginGitHubInstall';
|
||||
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { PluginManagementDialog } from './PluginManagementDialog';
|
||||
@@ -35,6 +36,7 @@ function PluginManagementContent({ manager }: {
|
||||
<div className="flex items-center gap-2">
|
||||
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
|
||||
<div ref={setCatalogToolbar} className="flex items-center gap-2" />
|
||||
<PluginGitHubInstall disabled={!manager.available || manager.busy} onInstalled={async () => { setPluginView('installed'); await manager.refresh(); }} />
|
||||
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,11 +70,13 @@ test('rejected privacy draft stays open and accepted draft commits once and retu
|
||||
accepted = true;
|
||||
await input(1, 'INTERNAL_[A-Z0-9]+');
|
||||
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)!);
|
||||
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.equal(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
assert.ok(document.activeElement === trigger, "focus returns to the dialog opener");
|
||||
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
|
||||
await click('Configure privacy');
|
||||
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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,6 +8,7 @@
|
||||
|
||||
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpConnectedHostEntry } from "../../domain/sftpConnectedHosts";
|
||||
import type { SftpArchiveCreationOptions, SftpArchiveCreationResult, SftpArchiveCreationTarget } from "../../domain/sftpArchiveCreation";
|
||||
import type { SftpConnectOptions } from "../../application/state/sftp/useSftpConnections";
|
||||
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
|
||||
|
||||
@@ -53,6 +54,8 @@ export interface SftpPaneCallbacks {
|
||||
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
/** Create copies next to the selected entries (same pane, same folder). */
|
||||
onDuplicateEntries: (files: SftpTransferSource[]) => void;
|
||||
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
// File operations
|
||||
@@ -63,6 +66,7 @@ export interface SftpPaneCallbacks {
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
|
||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onCreateArchive?: (options: SftpArchiveCreationOptions, target: SftpArchiveCreationTarget) => Promise<SftpArchiveCreationResult>;
|
||||
// External file upload (supports folders via DataTransfer)
|
||||
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
|
||||
// External file upload from <input type="file" multiple> picker (FileList).
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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 { 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 { 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 { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -61,6 +61,7 @@ interface SftpPaneFileListProps {
|
||||
handleRowDragLeave: () => void;
|
||||
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onDuplicateEntries?: (files: SftpTransferSource[]) => void;
|
||||
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
|
||||
onOpenFileWithSystemDefault?: (entry: SftpFileEntry) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry) => void;
|
||||
@@ -68,6 +69,7 @@ interface SftpPaneFileListProps {
|
||||
onDownloadFile?: (entry: SftpFileEntry) => void;
|
||||
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
|
||||
onExtractArchive?: (entry: SftpFileEntry) => void;
|
||||
onCreateArchive?: (sourcePaths: string[]) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry) => void;
|
||||
onUploadExternalFileList?: (fileList: FileList, targetPath?: string) => Promise<void> | void;
|
||||
onUploadExternalFolder?: (targetPath?: string) => Promise<void> | void;
|
||||
@@ -151,6 +153,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
handleRowDragLeave,
|
||||
handleEntryDrop,
|
||||
onCopyToOtherPane,
|
||||
onDuplicateEntries,
|
||||
onMoveEntriesToPath,
|
||||
onOpenFileWithSystemDefault,
|
||||
onOpenFileWith,
|
||||
@@ -158,6 +161,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onCreateArchive,
|
||||
onEditPermissions,
|
||||
onUploadExternalFileList,
|
||||
onUploadExternalFolder,
|
||||
@@ -288,6 +292,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
void onUploadExternalFileList(files, targetPath);
|
||||
}, [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(
|
||||
(entry: SftpFileEntry, index: number) => (
|
||||
<ContextMenu>
|
||||
@@ -376,29 +397,32 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
{t("sftp.context.extract")}
|
||||
</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 />
|
||||
<ContextMenuItem
|
||||
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);
|
||||
}}
|
||||
onClick={() => onCopyToOtherPane(getSelectedTransferSources(entry))}
|
||||
>
|
||||
<Copy size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.copyToOtherPane")}
|
||||
</ContextMenuItem>
|
||||
{onDuplicateEntries && (
|
||||
<ContextMenuItem
|
||||
onClick={() => onDuplicateEntries(getSelectedTransferSources(entry))}
|
||||
>
|
||||
<CopyPlus size={14} className="mr-2" />{" "}
|
||||
{t("sftp.context.duplicate")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
|
||||
@@ -497,11 +521,15 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
|
||||
handleRowSelect,
|
||||
dragOverEntry,
|
||||
isPaneFocused,
|
||||
isLocal,
|
||||
onCopyToOtherPane,
|
||||
onDuplicateEntries,
|
||||
getSelectedTransferSources,
|
||||
onMoveEntriesToPath,
|
||||
onDownloadFile,
|
||||
onDownloadFiles,
|
||||
onExtractArchive,
|
||||
onCreateArchive,
|
||||
onDragEnd,
|
||||
onEditFile,
|
||||
onEditPermissions,
|
||||
|
||||
@@ -5,14 +5,19 @@ import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { TreeNode } from "./SftpPaneTreeNode.tsx";
|
||||
|
||||
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
|
||||
import { SftpPaneTreeView, getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
|
||||
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 => ({
|
||||
name,
|
||||
type,
|
||||
size: 0,
|
||||
sizeFormatted: "--",
|
||||
lastModified: 0,
|
||||
lastModifiedFormatted: "--",
|
||||
});
|
||||
|
||||
test("tree activation enters directories and keeps file opening separate", () => {
|
||||
@@ -21,7 +26,7 @@ test("tree activation enters directories and keeps file opening separate", () =>
|
||||
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
|
||||
});
|
||||
|
||||
test("tree double clicks preserve folder, parent, file and arrow actions", async () => {
|
||||
test("tree double clicks open entries while the chevron only toggles expansion", async () => {
|
||||
const dom = new JSDOM('<div id="root"></div>');
|
||||
const keys = ["window", "document", "IS_REACT_ACT_ENVIRONMENT"] as const;
|
||||
const originals = keys.map(key => Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
@@ -38,7 +43,11 @@ test("tree double clicks preserve folder, parent, file and arrow actions", async
|
||||
columnTemplate: "1fr", visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
|
||||
isSelected: false, isExpanded: false, isLoading, isDragOver: false,
|
||||
onToggleExpand: () => calls.push("toggle"), onNodeClick: () => calls.push("select"),
|
||||
onOpenEntry: () => calls.push("open"), onContextMenu: () => calls.push("menu"),
|
||||
onOpenEntry: (opened, path) => {
|
||||
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,
|
||||
})));
|
||||
calls.length = 0;
|
||||
@@ -51,14 +60,18 @@ test("tree double clicks preserve folder, parent, file and arrow actions", async
|
||||
try {
|
||||
await render(entry("docs", "directory"));
|
||||
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
|
||||
assert.deepEqual(calls, ["select", "select", "toggle"]);
|
||||
assert.deepEqual(calls, ["select", "select", "open"]);
|
||||
calls.length = 0;
|
||||
await doubleClick(container.querySelector('.lucide-chevron-right')!);
|
||||
assert.deepEqual(calls, ["toggle", "toggle"]);
|
||||
await render(entry("docs", "directory"), true);
|
||||
await doubleClick(container.querySelector('.lucide-loader-circle, .lucide-loader-2')!);
|
||||
assert.deepEqual(calls, ["select", "select"]);
|
||||
for (const item of [entry("..", "directory"), entry("notes.txt", "file")]) {
|
||||
for (const item of [
|
||||
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 doubleClick(container.querySelector('[data-section]')!);
|
||||
assert.deepEqual(calls, ["select", "select", "open"]);
|
||||
@@ -73,3 +86,114 @@ test("tree double clicks preserve folder, parent, file and arrow actions", async
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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,15 +67,16 @@ export const TreeNode = React.memo<TreeNodeProps>(({
|
||||
)}
|
||||
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
||||
onClick={e => onNodeClick(entry, entryPath, e)}
|
||||
onDoubleClick={() => {
|
||||
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
|
||||
if (isDir) void onToggleExpand(entry, entryPath);
|
||||
else onOpenEntry(entry, entryPath);
|
||||
}}
|
||||
onDoubleClick={() => onOpenEntry(entry, entryPath)}
|
||||
onContextMenu={e => {
|
||||
if (!isParentEntry) {
|
||||
onContextMenu(entry, entryPath, e);
|
||||
if (isParentEntry) {
|
||||
// The shared menu still holds the previous real entry. Do not let
|
||||
// parent navigation reopen actions for that stale target.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onContextMenu(entry, entryPath, e);
|
||||
}}
|
||||
draggable={!isParentEntry}
|
||||
onDragStart={e => { if (!isParentEntry) onDragStart(entry, entryPath, isDir, e); }}
|
||||
|
||||
@@ -50,12 +50,14 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
openRenameDialog,
|
||||
openDeleteConfirm,
|
||||
onCopyToOtherPane,
|
||||
onDuplicateEntries,
|
||||
onReceiveFromOtherPane,
|
||||
onOpenFileWithSystemDefault,
|
||||
onOpenFileWith,
|
||||
onEditFile,
|
||||
onDownloadFile,
|
||||
onExtractArchive,
|
||||
onCreateArchive,
|
||||
onEditPermissions,
|
||||
draggedFiles,
|
||||
openNewFolderDialog,
|
||||
@@ -203,6 +205,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onDragEndRef.current = onDragEnd;
|
||||
const onCopyToOtherPaneRef = useRef(onCopyToOtherPane);
|
||||
onCopyToOtherPaneRef.current = onCopyToOtherPane;
|
||||
const onDuplicateEntriesRef = useRef(onDuplicateEntries);
|
||||
onDuplicateEntriesRef.current = onDuplicateEntries;
|
||||
const onReceiveFromOtherPaneRef = useRef(onReceiveFromOtherPane);
|
||||
onReceiveFromOtherPaneRef.current = onReceiveFromOtherPane;
|
||||
const onOpenFileWithSystemDefaultRef = useRef(onOpenFileWithSystemDefault);
|
||||
@@ -215,6 +219,8 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
onDownloadFileRef.current = onDownloadFile;
|
||||
const onExtractArchiveRef = useRef(onExtractArchive);
|
||||
onExtractArchiveRef.current = onExtractArchive;
|
||||
const onCreateArchiveRef = useRef(onCreateArchive);
|
||||
onCreateArchiveRef.current = onCreateArchive;
|
||||
const onEditPermissionsRef = useRef(onEditPermissions);
|
||||
onEditPermissionsRef.current = onEditPermissions;
|
||||
const openRenameDialogRef = useRef(openRenameDialog);
|
||||
@@ -938,12 +944,14 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
|
||||
setShowMoveToDialog,
|
||||
tRef,
|
||||
onCopyToOtherPaneRef,
|
||||
onDuplicateEntriesRef,
|
||||
onNavigateToRef,
|
||||
onOpenFileWithSystemDefaultRef,
|
||||
onOpenFileWithRef,
|
||||
onEditFileRef,
|
||||
onDownloadFileRef,
|
||||
onExtractArchiveRef,
|
||||
onCreateArchiveRef,
|
||||
onEditPermissionsRef,
|
||||
openDeleteConfirmRef,
|
||||
openRenameDialogRef,
|
||||
|
||||
@@ -18,12 +18,14 @@ export interface SftpPaneTreeViewProps {
|
||||
openRenameDialog: (entryPath: string) => void;
|
||||
openDeleteConfirm: (targets: string[]) => void;
|
||||
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onDuplicateEntries?: (files: SftpTransferSource[]) => void;
|
||||
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
|
||||
onOpenFileWithSystemDefault?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
onExtractArchive?: (entry: SftpFileEntry, fullPath?: string) => void | Promise<void>;
|
||||
onCreateArchive?: (sourcePaths: string[]) => void;
|
||||
onEditPermissions?: (entry: SftpFileEntry, fullPath?: string) => void;
|
||||
draggedFiles: (SftpTransferSource & { side: 'left' | 'right' })[] | null;
|
||||
openNewFolderDialog: (targetPath: string) => void;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { SftpPaneDialogs } from "./SftpPaneDialogs";
|
||||
import { SftpClipboardUploadDialog } from "./SftpClipboardUploadDialog";
|
||||
import { SftpCreateArchiveDialog } from "./SftpCreateArchiveDialog";
|
||||
import { useSftpArchiveDialog } from "../../application/state/sftp/useSftpArchiveDialog";
|
||||
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
|
||||
import { SftpPaneFileList } from "./SftpPaneFileList";
|
||||
import { SftpPaneToolbar } from "./SftpPaneToolbar";
|
||||
@@ -153,6 +155,16 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
}
|
||||
}, [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}]`, {
|
||||
side,
|
||||
paneId: pane.id,
|
||||
@@ -636,12 +648,14 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
openRenameDialog={openRenameDialog}
|
||||
openDeleteConfirm={openDeleteConfirm}
|
||||
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
||||
onDuplicateEntries={callbacks.onDuplicateEntries}
|
||||
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
|
||||
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
onEditFile={callbacks.onEditFile}
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||
onCreateArchive={callbacks.onCreateArchive && !pane.connection?.isLocal ? archiveDialog.open : undefined}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
draggedFiles={draggedFiles}
|
||||
openNewFolderDialog={openNewFolderDialogAtPath}
|
||||
@@ -687,6 +701,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handleRowDragLeave={handleRowDragLeave}
|
||||
handleEntryDrop={handleEntryDrop}
|
||||
onCopyToOtherPane={callbacks.onCopyToOtherPane}
|
||||
onDuplicateEntries={callbacks.onDuplicateEntries}
|
||||
onMoveEntriesToPath={handleMoveEntriesToPath}
|
||||
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
|
||||
onOpenFileWith={callbacks.onOpenFileWith}
|
||||
@@ -694,6 +709,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
onDownloadFile={callbacks.onDownloadFile}
|
||||
onDownloadFiles={callbacks.onDownloadFiles}
|
||||
onExtractArchive={callbacks.onExtractArchive ? handleExtractArchive : undefined}
|
||||
onCreateArchive={callbacks.onCreateArchive && !pane.connection?.isLocal ? archiveDialog.open : undefined}
|
||||
onEditPermissions={callbacks.onEditPermissions}
|
||||
onUploadExternalFileList={handleUploadExternalFileList}
|
||||
onUploadExternalFolder={handleUploadExternalFolder}
|
||||
@@ -706,6 +722,15 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SftpCreateArchiveDialog
|
||||
state={archiveDialog.state}
|
||||
t={t}
|
||||
onClose={archiveDialog.close}
|
||||
onNameChange={archiveDialog.setArchiveName}
|
||||
onFormatChange={archiveDialog.setFormat}
|
||||
onSubmit={archiveDialog.submit}
|
||||
/>
|
||||
|
||||
<SftpPaneDialogs
|
||||
t={t}
|
||||
hostLabel={pane.connection?.hostLabel}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** @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,6 +19,8 @@ import { toEditorTabId, activeTabStore } from "../../../application/state/active
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "./useSftpViewFileOps.types";
|
||||
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. */
|
||||
const LOCAL_BLOB_DOWNLOAD_CONCURRENCY = 1;
|
||||
@@ -659,6 +661,33 @@ export const useSftpViewFileOps = ({
|
||||
[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
|
||||
// (no prompt). For remote panes, prompts for a target directory once and
|
||||
// streams all selected entries into it — avoids the per-file save dialog
|
||||
@@ -875,6 +904,8 @@ export const useSftpViewFileOps = ({
|
||||
onDownloadFileRight,
|
||||
onExtractArchiveLeft,
|
||||
onExtractArchiveRight,
|
||||
onCreateArchiveLeft,
|
||||
onCreateArchiveRight,
|
||||
onDownloadFilesLeft,
|
||||
onDownloadFilesRight,
|
||||
onUploadExternalFilesLeft,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../t
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
|
||||
import type { SftpArchiveCreationOptions, SftpArchiveCreationResult, SftpArchiveCreationTarget } from "../../../domain/sftpArchiveCreation";
|
||||
|
||||
export interface UseSftpViewFileOpsParams {
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
@@ -87,6 +88,8 @@ export interface UseSftpViewFileOpsResult {
|
||||
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
|
||||
onExtractArchiveLeft: (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;
|
||||
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
|
||||
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
|
||||
|
||||
@@ -69,6 +69,8 @@ interface UseSftpViewPaneActionsResult {
|
||||
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
|
||||
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
|
||||
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
|
||||
onDuplicateEntriesLeft: (files: SftpTransferSource[]) => void;
|
||||
onDuplicateEntriesRight: (files: SftpTransferSource[]) => void;
|
||||
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
|
||||
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
|
||||
}
|
||||
@@ -145,6 +147,43 @@ export const useSftpViewPaneActions = ({
|
||||
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
|
||||
[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(
|
||||
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
|
||||
[startGroupedTransfer],
|
||||
@@ -400,6 +439,8 @@ export const useSftpViewPaneActions = ({
|
||||
onMoveEntriesToPathRight,
|
||||
onCopyToOtherPaneLeft,
|
||||
onCopyToOtherPaneRight,
|
||||
onDuplicateEntriesLeft,
|
||||
onDuplicateEntriesRight,
|
||||
onReceiveFromOtherPaneLeft,
|
||||
onReceiveFromOtherPaneRight,
|
||||
};
|
||||
|
||||
@@ -151,6 +151,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
|
||||
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
|
||||
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
|
||||
onDuplicateEntries: paneActions.onDuplicateEntriesLeft,
|
||||
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
|
||||
onEditPermissions: fileOps.onEditPermissionsLeft,
|
||||
onEditFile: fileOps.onEditFileLeft,
|
||||
@@ -160,6 +161,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onDownloadFile: fileOps.onDownloadFileLeft,
|
||||
onDownloadFiles: fileOps.onDownloadFilesLeft,
|
||||
onExtractArchive: fileOps.onExtractArchiveLeft,
|
||||
onCreateArchive: fileOps.onCreateArchiveLeft,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
|
||||
@@ -194,6 +196,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
|
||||
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
|
||||
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
|
||||
onDuplicateEntries: paneActions.onDuplicateEntriesRight,
|
||||
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
|
||||
onEditPermissions: fileOps.onEditPermissionsRight,
|
||||
onEditFile: fileOps.onEditFileRight,
|
||||
@@ -203,6 +206,7 @@ export const useSftpViewPaneCallbacks = ({
|
||||
onDownloadFile: fileOps.onDownloadFileRight,
|
||||
onDownloadFiles: fileOps.onDownloadFilesRight,
|
||||
onExtractArchive: fileOps.onExtractArchiveRight,
|
||||
onCreateArchive: fileOps.onCreateArchiveRight,
|
||||
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
|
||||
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
|
||||
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-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 { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
|
||||
@@ -10,13 +10,16 @@ import { getSftpTreeUploadFilesTargetPath, getSftpUploadFilesLabelKey, getSftpUp
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-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) {
|
||||
const {
|
||||
contextTarget, pane, toggleExpand, stableOnOpenEntry, stableOnRefresh, getActionPaths, toTransferSources,
|
||||
executeMoveAction, triggerUploadPicker, onUploadExternalFolder, uploadEnabled, folderUploadEnabled,
|
||||
setMoveTargetPaths, setMoveToPath, setMoveToError, setMoveToSuggestions, setMoveToSuggestionIndex,
|
||||
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
|
||||
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
||||
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onDuplicateEntriesRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
|
||||
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onCreateArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
|
||||
openNewFolderDialogRef, openNewFileDialogRef,
|
||||
} = props;
|
||||
|
||||
@@ -27,6 +30,10 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
const { entry, entryPath } = target;
|
||||
const isDir = isNavigableDirectory(entry);
|
||||
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 = () => {
|
||||
if (isDir) void toggleExpand(entry, entryPath);
|
||||
@@ -47,6 +54,21 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
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 = () => {
|
||||
openDeleteConfirmRef.current(getActionPaths(entryPath));
|
||||
};
|
||||
@@ -88,10 +110,20 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
<Archive size={14} className="mr-2" />{tRef.current('sftp.context.extract')}
|
||||
</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 />
|
||||
<ContextMenuItem onClick={handleCopyToOtherPane}>
|
||||
<Copy size={14} className="mr-2" />{tRef.current('sftp.context.copyToOtherPane')}
|
||||
</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)}>
|
||||
<ClipboardCopy size={14} className="mr-2" />{tRef.current('sftp.context.copyPath')}
|
||||
</ContextMenuItem>
|
||||
@@ -180,8 +212,10 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
|
||||
folderUploadEnabled,
|
||||
onUploadExternalFolder,
|
||||
onCopyToOtherPaneRef,
|
||||
onDuplicateEntriesRef,
|
||||
onDownloadFileRef,
|
||||
onExtractArchiveRef,
|
||||
onCreateArchiveRef,
|
||||
onEditFileRef,
|
||||
onEditPermissionsRef,
|
||||
onNavigateToRef,
|
||||
|
||||
@@ -142,6 +142,31 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
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(() => {
|
||||
if (selectedRef.current && listRef.current) {
|
||||
@@ -154,6 +179,10 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
|
||||
// Reset hover when suggestions change
|
||||
useEffect(() => {
|
||||
if (hoverClearTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverClearTimerRef.current);
|
||||
hoverClearTimerRef.current = null;
|
||||
}
|
||||
setHoveredIndex(-1);
|
||||
}, [suggestions]);
|
||||
|
||||
@@ -368,8 +397,40 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
fontFamily: "inherit",
|
||||
fontSize: "13px",
|
||||
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 (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
@@ -407,16 +468,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
// Keep the terminal focused when clicking the list's padding or
|
||||
// scrollbar (row clicks handle this themselves).
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
// scrollbar; scrollbar dragging is left to the browser.
|
||||
onMouseDown={handlePanelMouseDown}
|
||||
onWheel={handlePanelWheel}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isHovered = index === hoveredIndex;
|
||||
const sourceInfo = SOURCE_LABELS[suggestion.source];
|
||||
const hasInlineDescription = suggestion.source !== "snippet"
|
||||
&& Boolean(suggestion.description);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -434,8 +495,11 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
gap: "8px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(-1)}
|
||||
onMouseEnter={() => {
|
||||
cancelHoverClear();
|
||||
setHoveredIndex(index);
|
||||
}}
|
||||
onMouseLeave={scheduleHoverClear}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -468,10 +532,15 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Command text */}
|
||||
{/* Command text. A shared fixed column keeps every description
|
||||
aligned on the same left edge instead of following each
|
||||
variable-width option label. */}
|
||||
<span
|
||||
title={hasInlineDescription ? suggestion.displayText : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
flex: hasInlineDescription ? "0 0 auto" : "1 1 auto",
|
||||
width: hasInlineDescription ? `${commandColumnWidth}px` : undefined,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
@@ -484,16 +553,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
|
||||
{/* Inline description (truncated). Snippets show only their label
|
||||
in the row — the full command lives in the detail preview. */}
|
||||
{suggestion.source !== "snippet" && suggestion.description && (
|
||||
{hasInlineDescription && (
|
||||
<span
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
fontSize: "11px",
|
||||
color: dimTextColor,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "160px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{suggestion.description}
|
||||
@@ -549,11 +618,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
// Keep the terminal focused when clicking the panel's padding or
|
||||
// scrollbar (row clicks handle this themselves).
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
// scrollbar; scrollbar dragging is left to the browser.
|
||||
onMouseDown={handlePanelMouseDown}
|
||||
onWheel={handlePanelWheel}
|
||||
>
|
||||
{panel.entries.map((entry, idx) => {
|
||||
const isFocused = level === subDirFocusLevel;
|
||||
@@ -615,15 +682,23 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
overflowY: "auto",
|
||||
alignSelf: renderUpward ? "flex-end" : "flex-start",
|
||||
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
|
||||
// hidden reserve explicitly non-interactive so the mounted
|
||||
// panel can never swallow clicks meant for the terminal.
|
||||
pointerEvents: detailVisible ? "auto" : "none",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMouseDown={(event) => {
|
||||
// Do not preventDefault: that would block selecting the long
|
||||
// description or dragging the panel's scrollbar.
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onMouseEnter={cancelHoverClear}
|
||||
onMouseLeave={scheduleHoverClear}
|
||||
onWheel={handlePanelWheel}
|
||||
>
|
||||
{detailVisible && detailItem && (
|
||||
<>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -154,6 +154,7 @@ export const COMMON_FIG_SPECS = [
|
||||
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
|
||||
"vim", "nano", "less", "head", "tail", "sort",
|
||||
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
|
||||
"ln", "stat", "wc", "cut", "uniq", "tee", "xargs",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
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,6 +6,9 @@ test("preload common specs includes dnf alongside yum and apt", () => {
|
||||
assert.ok(COMMON_FIG_SPECS.includes("apt"));
|
||||
assert.ok(COMMON_FIG_SPECS.includes("yum"));
|
||||
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", () => {
|
||||
|
||||
@@ -1037,7 +1037,14 @@ 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", { timeout: 30000 }, async () => {
|
||||
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", {
|
||||
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 prompt = "__NETCATTY_TEST_PROMPT__> ";
|
||||
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
|
||||
|
||||
@@ -716,7 +716,9 @@ 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`,
|
||||
));
|
||||
}
|
||||
const runner = 'c=$(base64 -d "$1") && printf "%s\\n" "$c" | sh';
|
||||
// Read through stdin: GNU base64 accepts a file operand, but BSD/macOS
|
||||
// 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 run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
|
||||
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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,3 +359,18 @@ test('available-width observer releases a removed focus sidebar immediately', ()
|
||||
assert.match(sectionSource, /if \(observedFocusSidebar\) resizeObserver\.unobserve\(observedFocusSidebar\)/);
|
||||
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"
|
||||
>
|
||||
{t('terminal.paneMagnification.hint')}: {sidePanelToolLabels.get(magnifiedSidePane.tool) ?? magnifiedSidePane.tool}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
|
||||
@@ -91,7 +91,6 @@ test("pointer drag cleanup runs when the pane unmounts without pointerup", async
|
||||
pointerDown = useWorkspaceDetachPointerDrag({
|
||||
inActiveWorkspace: true,
|
||||
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
||||
workspaceById: new Map(),
|
||||
onStartSessionDrag: () => { dragStartCount += 1; },
|
||||
onEndSessionDrag: () => { dragEndCount += 1; },
|
||||
});
|
||||
@@ -144,7 +143,6 @@ test("unmount removes an active drag overlay and ends drag state", async () => {
|
||||
pointerDown = useWorkspaceDetachPointerDrag({
|
||||
inActiveWorkspace: true,
|
||||
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
||||
workspaceById: new Map(),
|
||||
onStartSessionDrag: () => { dragStartCount += 1; },
|
||||
onEndSessionDrag: () => { dragEndCount += 1; },
|
||||
});
|
||||
@@ -192,7 +190,6 @@ test("all pointer drag exit paths share one idempotent cleanup", async () => {
|
||||
pointerDown = useWorkspaceDetachPointerDrag({
|
||||
inActiveWorkspace: true,
|
||||
session: { id: "session-1", workspaceId: "workspace-1" } as never,
|
||||
workspaceById: new Map(),
|
||||
onStartSessionDrag: () => { dragStartCount += 1; },
|
||||
onEndSessionDrag: () => { dragEndCount += 1; },
|
||||
});
|
||||
|
||||
@@ -301,6 +301,8 @@ interface AIChatPanelsHostProps {
|
||||
onOpenVaultHostFromChat?: (hostId: string) => void;
|
||||
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => 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. */
|
||||
workspaces?: Workspace[] | null;
|
||||
}
|
||||
@@ -394,6 +396,7 @@ const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
|
||||
previousMemberTerminalIds: previousMemberIds,
|
||||
currentMemberTerminalIds: currentMemberIds,
|
||||
preferredTerminalId: workspace.focusedSessionId,
|
||||
activeTargetIds: validAIScopeTargetIds,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,6 +471,7 @@ function aiChatPanelsHostAreEqual(
|
||||
if (prev.onOpenVaultHostFromChat !== next.onOpenVaultHostFromChat) return false;
|
||||
if (prev.onOpenVaultSectionFromChat !== next.onOpenVaultSectionFromChat) 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.activeTabId === next.activeTabId) return true;
|
||||
|
||||
@@ -508,6 +512,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
onOpenVaultHostFromChat,
|
||||
onOpenVaultSectionFromChat,
|
||||
onOpenVaultSnippetFromChat,
|
||||
onSetWorkspaceAnchorChat,
|
||||
workspaces,
|
||||
}) => {
|
||||
const aiConfig = useContext(AIConfigContext);
|
||||
@@ -527,7 +532,11 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
updateDraft,
|
||||
} = aiConfig;
|
||||
const aiPanelWorkspaces = useMemo(
|
||||
() => (workspaces ?? []).map((workspace) => ({ id: workspace.id, title: workspace.title })),
|
||||
() => (workspaces ?? []).map((workspace) => ({
|
||||
id: workspace.id,
|
||||
title: workspace.title,
|
||||
anchorChatId: workspace.anchorChatId,
|
||||
})),
|
||||
[workspaces],
|
||||
);
|
||||
|
||||
@@ -638,6 +647,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
scopeLabel={context.scopeLabel}
|
||||
focusedSessionId={context.focusedSessionId}
|
||||
workspaces={aiPanelWorkspaces}
|
||||
onSetWorkspaceAnchorChat={onSetWorkspaceAnchorChat}
|
||||
terminalSessions={context.terminalSessions}
|
||||
liveScopeTargetIds={liveScopeTargetIds}
|
||||
resolveExecutorContext={resolveExecutorContext}
|
||||
@@ -718,12 +728,15 @@ export interface TerminalLayerProps {
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void | Promise<void>;
|
||||
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: 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;
|
||||
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
|
||||
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
|
||||
onSetDraggingSessionId: (id: string | null) => void;
|
||||
onToggleWorkspaceViewMode?: (workspaceId: 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;
|
||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||
onCopySession?: (sessionId: string) => void;
|
||||
@@ -881,6 +894,7 @@ interface TerminalPaneProps {
|
||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||
onStartSessionDrag?: (sessionId: string) => void;
|
||||
onEndSessionDrag?: () => void;
|
||||
onDropWorkspacePane?: (sessionId: string, point: { clientX: number; clientY: number }) => void;
|
||||
}
|
||||
|
||||
const getPaneAppearanceThemeId = (props: TerminalPaneProps): string => {
|
||||
@@ -994,6 +1008,7 @@ const terminalPanePropsAreEqual = (
|
||||
prev.onRemoveSessionFromWorkspace === next.onRemoveSessionFromWorkspace &&
|
||||
prev.onReorderTabs === next.onReorderTabs &&
|
||||
prev.onStartSessionDrag === next.onStartSessionDrag &&
|
||||
prev.onDropWorkspacePane === next.onDropWorkspacePane &&
|
||||
prev.onEndSessionDrag === next.onEndSessionDrag
|
||||
);
|
||||
|
||||
@@ -1001,9 +1016,9 @@ type WorkspaceDetachPointerDragOptions = {
|
||||
inActiveWorkspace: boolean;
|
||||
session: TerminalSession;
|
||||
terminalSettings?: TerminalSettings;
|
||||
workspaceById: Map<string, Workspace>;
|
||||
onStartSessionDrag?: (sessionId: string) => void;
|
||||
onEndSessionDrag?: () => void;
|
||||
onDropWorkspacePane?: (sessionId: string, point: { clientX: number; clientY: number }) => void;
|
||||
onRemoveSessionFromWorkspace?: TerminalPaneProps['onRemoveSessionFromWorkspace'];
|
||||
onReorderTabs?: TerminalPaneProps['onReorderTabs'];
|
||||
};
|
||||
@@ -1012,9 +1027,9 @@ export function useWorkspaceDetachPointerDrag({
|
||||
inActiveWorkspace,
|
||||
session,
|
||||
terminalSettings,
|
||||
workspaceById,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onDropWorkspacePane,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onReorderTabs,
|
||||
}: WorkspaceDetachPointerDragOptions): (event: React.PointerEvent<HTMLElement>) => void {
|
||||
@@ -1120,19 +1135,6 @@ export function useWorkspaceDetachPointerDrag({
|
||||
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) => {
|
||||
if (cleanupFinished || dragStarted) return;
|
||||
const dx = pointerEvent.clientX - startPoint.clientX;
|
||||
@@ -1179,9 +1181,10 @@ export function useWorkspaceDetachPointerDrag({
|
||||
const shouldDetach = dragStarted
|
||||
&& !!topTabsRoot
|
||||
&& isPointInsideRect(pointerEvent, topTabsRoot.getBoundingClientRect());
|
||||
if (dragStarted && !shouldDetach) onDropWorkspacePane?.(session.id, pointerEvent);
|
||||
cleanup();
|
||||
if (shouldDetach) {
|
||||
const stableInsertionTarget = resolveStableInsertionTarget(insertionTarget);
|
||||
const stableInsertionTarget = insertionTarget;
|
||||
if (onRemoveSessionFromWorkspace) {
|
||||
onRemoveSessionFromWorkspace(
|
||||
session.id,
|
||||
@@ -1211,13 +1214,13 @@ export function useWorkspaceDetachPointerDrag({
|
||||
cleanupActiveDrag,
|
||||
inActiveWorkspace,
|
||||
onEndSessionDrag,
|
||||
onDropWorkspacePane,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
session,
|
||||
terminalSettings?.dynamicTabTitleMode,
|
||||
workspaceById,
|
||||
]);
|
||||
]);
|
||||
}
|
||||
|
||||
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
@@ -1300,6 +1303,7 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onDropWorkspacePane,
|
||||
}) => {
|
||||
const layoutSuppressActive = useTerminalLayoutSuppressActive();
|
||||
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
|
||||
@@ -1575,9 +1579,9 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
inActiveWorkspace,
|
||||
session,
|
||||
terminalSettings,
|
||||
workspaceById,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onDropWorkspacePane,
|
||||
onRemoveSessionFromWorkspace,
|
||||
onReorderTabs,
|
||||
});
|
||||
@@ -1596,9 +1600,12 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
"absolute bg-background",
|
||||
inActiveWorkspace && "workspace-pane",
|
||||
isVisible && "z-10",
|
||||
isMagnified && magnifiedSurfaceBounds && "animate-in fade-in zoom-in-95 duration-150",
|
||||
isMagnified && magnifiedSurfaceBounds && "overflow-hidden rounded-md border shadow-2xl animate-in fade-in zoom-in-95 duration-150",
|
||||
)}
|
||||
style={style}
|
||||
style={{
|
||||
...style,
|
||||
borderColor: isMagnified && magnifiedSurfaceBounds ? sessionAppearanceTheme.colors.cursor : undefined,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
onClick={handlePaneClick}
|
||||
onFocusCapture={handlePaneClick}
|
||||
@@ -1793,6 +1800,7 @@ interface TerminalPanesHostProps {
|
||||
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
|
||||
onStartSessionDrag?: (sessionId: string) => void;
|
||||
onEndSessionDrag?: () => void;
|
||||
onDropWorkspacePane?: (sessionId: string, point: { clientX: number; clientY: number }) => void;
|
||||
}
|
||||
|
||||
const terminalPanesHostPropsAreEqual = (
|
||||
@@ -1876,6 +1884,7 @@ const terminalPanesHostPropsAreEqual = (
|
||||
if (prev.onReorderTabs !== next.onReorderTabs) return false;
|
||||
if (prev.onStartSessionDrag !== next.onStartSessionDrag) return false;
|
||||
if (prev.onEndSessionDrag !== next.onEndSessionDrag) return false;
|
||||
if (prev.onDropWorkspacePane !== next.onDropWorkspacePane) return false;
|
||||
|
||||
if (prev.workspaceRectsById === next.workspaceRectsById) return true;
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
dropHint,
|
||||
findSplitNode,
|
||||
handleWorkspaceDrop,
|
||||
handleWorkspacePanePointerDrop,
|
||||
resizing,
|
||||
setDropHint,
|
||||
setResizing,
|
||||
@@ -110,9 +111,14 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
activeSession,
|
||||
activeWorkspace,
|
||||
isFocusMode,
|
||||
hasMagnifiedPane: !!activeWorkspace && activeWorkspace.id === s.magnifiedPane?.tabId,
|
||||
shouldKeepHiddenWorkspaceLaidOut,
|
||||
onAddSessionToWorkspace: s.onAddSessionToWorkspace,
|
||||
onCreateWorkspaceFromSessions: s.onCreateWorkspaceFromSessions,
|
||||
onMoveWorkspacePane: s.onMoveWorkspacePane,
|
||||
draggingSession: s.draggingSessionId
|
||||
? sessions.find((session) => session.id === s.draggingSessionId) ?? null
|
||||
: null,
|
||||
onSetDraggingSessionId: s.onSetDraggingSessionId,
|
||||
onUpdateSplitSizes: s.onUpdateSplitSizes,
|
||||
sessions,
|
||||
@@ -544,6 +550,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleToggleWorkspaceComposeBar: s.handleToggleWorkspaceComposeBar,
|
||||
handleUpdateHost: s.handleUpdateHost,
|
||||
handleWorkspaceDrop,
|
||||
handleWorkspacePanePointerDrop,
|
||||
hosts: s.hosts,
|
||||
hotkeyScheme: s.hotkeyScheme,
|
||||
disableTerminalFontZoom: s.disableTerminalFontZoom,
|
||||
@@ -584,6 +591,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
|
||||
onAppendHostToWorkspace: s.onAppendHostToWorkspace,
|
||||
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
|
||||
onSetWorkspaceAnchorChat: s.onSetWorkspaceAnchorChat,
|
||||
onStartSessionRename: s.onStartSessionRename,
|
||||
onSubmitSessionRename: s.onSubmitSessionRename,
|
||||
onRemoveSessionFromWorkspace: s.onRemoveSessionFromWorkspace,
|
||||
@@ -594,6 +602,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
onSaveWorkspaceLayoutAsDefault: s.onSaveWorkspaceLayoutAsDefault,
|
||||
onStartSessionDrag: s.onStartSessionDrag,
|
||||
onEndSessionDrag: s.onEndSessionDrag,
|
||||
onDropWorkspacePane: handleWorkspacePanePointerDrop,
|
||||
onSplitSession: s.onSplitSession,
|
||||
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
|
||||
Palette: s.Palette,
|
||||
@@ -693,10 +702,12 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
aiContextsByTabId,
|
||||
computeSplitHint,
|
||||
dropHint,
|
||||
s.draggingSessionId,
|
||||
focusedHost,
|
||||
focusedSessionId,
|
||||
s.restoreTerminalCwd,
|
||||
handleWorkspaceDrop,
|
||||
handleWorkspacePanePointerDrop,
|
||||
handleTerminalContextReaderChange,
|
||||
hibernateHiddenTabs,
|
||||
historySessionId,
|
||||
|
||||
@@ -2,10 +2,21 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
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 { 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", () => {
|
||||
const source = readFileSync(new URL("./TerminalLayerSupport.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -159,3 +170,37 @@ test("workspace section passes resolved session host ids to terminal panes", ()
|
||||
|
||||
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,6 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { LayoutGrid, Plus } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
@@ -100,6 +103,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
handleCommandExecuted,
|
||||
handleCommandSubmitted,
|
||||
onSetWorkspaceFocusedSession,
|
||||
onRequestAddToWorkspace,
|
||||
onSplitSession,
|
||||
isBroadcastEnabled,
|
||||
isComposeSendToAll,
|
||||
@@ -126,6 +130,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onReorderTabs,
|
||||
onStartSessionDrag,
|
||||
onEndSessionDrag,
|
||||
onDropWorkspacePane,
|
||||
isGlobalBroadcastEnabled,
|
||||
isGlobalComposeSendToAll,
|
||||
canUseGlobalBroadcast,
|
||||
@@ -153,6 +158,21 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
return (
|
||||
<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">
|
||||
{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 && (
|
||||
<div
|
||||
ref={workspaceOverlayRef}
|
||||
@@ -267,6 +287,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
onReorderTabs={onReorderTabs}
|
||||
onStartSessionDrag={onStartSessionDrag}
|
||||
onEndSessionDrag={onEndSessionDrag}
|
||||
onDropWorkspacePane={onDropWorkspacePane}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
@@ -340,7 +361,7 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
aria-label={t('terminal.paneMagnification.restore')}
|
||||
>
|
||||
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.terminal')}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
|
||||
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
|
||||
</button>
|
||||
)}
|
||||
</>,
|
||||
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user