Compare commits

..
207 changed files with 1047 additions and 10696 deletions

No files matched your search

-1
View File
@@ -141,7 +141,6 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
- **その場で編集** — 内蔵エディタで小さな修正を素早く
- **リモート圧縮** — ファイルやフォルダーを1つ以上選び、右クリックの **Compress...** から、表示された保存先に名前を指定して `.tar.gz` または `.zip` アーカイブを作成できます。サーバーには選択した形式に対応する `tar`/`gzip` または `zip` が必要です。
### 🎨 パーソナライズ
- **カスタムテーマ** — UI の見た目を好みに調整
-1
View File
@@ -144,7 +144,6 @@ 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
-1
View File
@@ -141,7 +141,6 @@
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
- **就地编辑** —— 内置编辑器快速修改文件
- **远程压缩** —— 单选或多选文件、文件夹,右键选择“压缩…”,在对话框显示的保存目录生成指定名称的 `.tar.gz` 或 `.zip` 压缩包;服务器需安装对应的 `tar`/`gzip` 或 `zip`。
### 🎨 个性化
- **自定义主题** —— 按喜好调整应用外观
-1
View File
@@ -141,7 +141,6 @@
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
- **就地編輯** —— 內建編輯器,快速修改檔案
- **遠端壓縮** —— 單選或多選檔案、資料夾,按右鍵選擇「壓縮…」,在對話方塊顯示的儲存目錄建立指定名稱的 `.tar.gz` 或 `.zip` 壓縮檔;伺服器需安裝對應的 `tar`/`gzip` 或 `zip`。
### 🎨 個人化
- **自訂主題** —— 依你的喜好調整外觀
+20 -19
View File
@@ -377,7 +377,7 @@ test('move-focus shortcut cannot send input behind a magnified pane', () => {
assert.equal(moveCalls, 0);
});
test('Escape leaves magnification unchanged and reaches the terminal', () => {
test('Escape restores magnification after transient dialogs are closed', () => {
let restores = 0;
let prevented = false;
let stopped = false;
@@ -405,29 +405,30 @@ test('Escape leaves magnification unchanged and reaches the terminal', () => {
},
}), event);
assert.equal(restores, 0);
assert.equal(prevented, false);
assert.equal(stopped, false);
assert.equal(restores, 1);
assert.equal(prevented, true);
assert.equal(stopped, true);
});
test('Escape closes the quick switcher', () => {
let open = true;
test('consumed Escape does not restore magnification', () => {
let restores = 0;
handleEscapeKeyDownImpl(() => ({
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; },
isQuickSwitcherOpen: false,
setIsQuickSwitcherOpen: () => {},
terminalPaneMagnificationRef: {
current: {
getState: () => 'focused',
focus: () => false,
restore: () => {
restores += 1;
return true;
},
toggle: () => false,
},
},
}), { key: 'Escape', defaultPrevented: true } as KeyboardEvent);
assert.equal(open, true);
assert.equal(restores, 0);
});
test('close tab hotkey routes native plugin view tabs through their owner', () => {
+9
View File
@@ -365,6 +365,8 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
const {
isQuickSwitcherOpen,
setIsQuickSwitcherOpen,
sftpPaneMagnificationRef,
terminalPaneMagnificationRef,
} = getCtx();
{
if (e.key !== 'Escape' || e.defaultPrevented) return;
@@ -372,6 +374,13 @@ export function handleEscapeKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEve
setIsQuickSwitcherOpen(false);
return;
}
if (getAvailablePaneMagnificationController([
sftpPaneMagnificationRef?.current,
terminalPaneMagnificationRef?.current,
])?.restore()) {
e.preventDefault();
e.stopPropagation();
}
}
}
+5 -15
View File
@@ -4,8 +4,6 @@ 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.
@@ -25,19 +23,11 @@ export function AppPluginKeybindingHost({
workspaces: Workspace[];
}) {
const activeTabId = useActiveTabId();
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]);
const keybindingContext = useMemo(() => resolveActivePluginKeybindingContext({
activeTabId,
sessions,
workspaces,
}), [activeTabId, sessions, workspaces]);
return (
<PluginContributionHost
@@ -4,13 +4,12 @@ import test from 'node:test';
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
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);
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);
assert.notEqual(effectStart, -1);
assert.match(effectSource, /window\.addEventListener\('keydown', onKeyDown\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onKeyDown\)/);
assert.doesNotMatch(source, /onCaptureKeyDown/);
assert.match(effectSource, /target\.closest\('\.xterm'\)/);
assert.match(effectSource, /window\.addEventListener\('keydown', onCaptureKeyDown, true\)/);
assert.match(effectSource, /window\.removeEventListener\('keydown', onCaptureKeyDown, true\)/);
});
+12 -19
View File
@@ -58,9 +58,7 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostOpenWorkspace } from '../../domain/agentHostWorkspace';
import { getAISessionsSnapshot } from '../state/aiSessionsStore';
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
@@ -615,7 +613,7 @@ export function AppSideEffects() {
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _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 }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
// dedicated transfer resume so quick-connect rows do not break tray connect.
@@ -1235,11 +1233,19 @@ 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]);
@@ -1499,21 +1505,8 @@ export function AppSideEffects() {
return sessionId;
}, [handleConnectToHost, renameSessionInline]);
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,
});
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean, options?: { workspaceId?: string }) => {
const target = resolveAgentHostWorkspace(options?.workspaceId, activeTabStore.getActiveTabId(), workspaces.map(workspace => workspace.id));
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
+2 -4
View File
@@ -264,7 +264,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, moveWorkspacePane,
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
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, setWorkspaceAnchorChat, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
setWorkspaceFocusedSession, 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,14 +774,12 @@ 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}
@@ -1,93 +0,0 @@
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import {
EMPTY_SESSION_SNAPSHOT,
getSessionSnapshotActions,
publishSessionSnapshot,
} from '../state/sessionSnapshotStore';
import { getAppShellPropsSnapshot } from './appShellPropsStore';
import { registerAppHandlers } from './appHandlersBridge';
// Keep both production publishers and the store between them real. Only the
// state owner and unrelated theme side effects are replaced with fixtures.
const sessionFixtureUrl = `data:text/javascript,${encodeURIComponent(`
let session;
export function setSession(value) { session = value; }
export function useSessionState() { return session; }
`)}`;
const themeFixtureUrl = `data:text/javascript,${encodeURIComponent(`
const noop = () => {};
const theme = {};
const runtime = {
globalAppearance: theme, accentedGlobalAppearance: theme,
clearIntent: noop, settleManualIntent: noop, pickTheme: noop,
resolveFocusedAppearance: noop, currentTerminalTheme: theme,
};
export function useThemeRuntime() { return runtime; }
export function useTerminalAppearanceInjection() {}
`)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '../../state/useSessionState' && context.parentURL?.endsWith('/SessionPublisher.tsx')) {
return { url: sessionFixtureUrl, shortCircuit: true };
}
if (specifier === '../../state/useThemeRuntime' && context.parentURL?.endsWith('/TerminalHost.tsx')) {
return { url: themeFixtureUrl, shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
const { setSession } = await import(sessionFixtureUrl);
const { SessionPublisher } = await import('./publishers/SessionPublisher');
const { TerminalHost } = await import('./hosts/TerminalHost');
test('workspace actions reach the terminal shell through SessionPublisher and TerminalHost', async (t) => {
const previousActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let renderer: ReactTestRenderer | undefined;
t.after(async () => {
await act(async () => renderer?.unmount());
registerAppHandlers(null);
publishSessionSnapshot(EMPTY_SESSION_SNAPSHOT);
if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
const calls: unknown[][] = [];
const moveWorkspacePane = (...args: unknown[]) => calls.push(['move', ...args]);
const setWorkspaceAnchorChat = (...args: unknown[]) => calls.push(['anchor', ...args]);
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane, setWorkspaceAnchorChat });
registerAppHandlers({});
const renderTree = () => React.createElement(
SessionPublisher,
{ persistSessionRestore: false },
React.createElement(TerminalHost),
);
await act(async () => { renderer = create(renderTree()); });
assert.equal(getSessionSnapshotActions()?.moveWorkspacePane, moveWorkspacePane);
assert.equal(getSessionSnapshotActions()?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
const terminal = getAppShellPropsSnapshot().domains?.terminal;
assert.equal(terminal?.moveWorkspacePane, moveWorkspacePane);
assert.equal(terminal?.setWorkspaceAnchorChat, setWorkspaceAnchorChat);
const hint = { direction: 'horizontal', position: 'top', targetSessionId: 'pane-b' };
(terminal?.moveWorkspacePane as typeof moveWorkspacePane)('workspace', 'pane-a', hint);
(terminal?.setWorkspaceAnchorChat as typeof setWorkspaceAnchorChat)('workspace', 'chat');
assert.deepEqual(calls, [
['move', 'workspace', 'pane-a', hint],
['anchor', 'workspace', 'chat'],
]);
// A changed state-owner callback must not be trapped in a stale memoized
// action bag even when all session/workspace snapshots stay identical.
const replacementMove = (...args: unknown[]) => calls.push(['replacement-move', ...args]);
const replacementAnchor = (...args: unknown[]) => calls.push(['replacement-anchor', ...args]);
setSession({ ...EMPTY_SESSION_SNAPSHOT, moveWorkspacePane: replacementMove, setWorkspaceAnchorChat: replacementAnchor });
await act(async () => renderer!.update(renderTree()));
assert.equal(getAppShellPropsSnapshot().domains?.terminal.moveWorkspacePane, replacementMove);
assert.equal(getAppShellPropsSnapshot().domains?.terminal.setWorkspaceAnchorChat, replacementAnchor);
});
-2
View File
@@ -170,7 +170,6 @@ export function TerminalHost() {
if (!handlers) return null;
return {
addSessionToWorkspace: sessionActions?.addSessionToWorkspace,
moveWorkspacePane: sessionActions?.moveWorkspacePane,
appendHostToWorkspace: sessionActions?.appendHostToWorkspace,
appendLocalTerminalToWorkspace: sessionActions?.appendLocalTerminalToWorkspace,
clearSessionFontSizeOverride: sessionActions?.clearSessionFontSizeOverride,
@@ -228,7 +227,6 @@ 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,7 +48,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
@@ -59,7 +58,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -135,7 +133,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
resetWorkspaceRename,
removeSessionFromWorkspace,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
toggleWorkspaceViewMode,
createLocalTerminal,
createSerialSession,
@@ -146,7 +143,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -176,7 +172,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
updateSessionCodingCliProvider,
}), [
addSessionToWorkspace,
moveWorkspacePane,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
@@ -213,7 +208,6 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
setActiveTabId,
setDraggingSessionId,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
splitSession,
startSessionRename,
startWorkspaceRename,
@@ -21,20 +21,13 @@ Object.defineProperty(globalThis, 'localStorage', {
});
const {
applyLegacySyncPayload,
convergentConflictAddressKey,
createConvergentSyncStateFromPayload,
dotKey,
mergeConvergentSyncStates,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
stripConvergentSyncEnvelope,
} = await import('../domain/convergentSync/index.ts');
const { getConvergentSyncLocalConfig } = await import('../infrastructure/services/convergentSyncConfig.ts');
const {
initializePreparedConvergentMigration,
prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
} = await import('./convergentSyncMigration.ts');
function payload(): SyncPayload {
@@ -51,42 +44,6 @@ test.beforeEach(() => {
localStorageValues.clear();
});
test('preview resolution uses the manager device identity and only updates the prepared plan', () => {
const baseline: SyncPayload = { ...payload(), settings: { theme: 'system' } };
const base = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const state = mergeConvergentSyncStates(
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'light' } }, 'left', NOW + 1),
applyLegacySyncPayload(base, baseline, { ...baseline, settings: { theme: 'dark' } }, 'right', NOW + 2),
);
const plan = refreshConvergentSyncMigrationPlan(planConvergentSyncMigration({
localPayload: baseline, localTrustedBaseline: null, providers: [], deviceId: 'local', now: NOW,
}), state);
const prepared = { plan, localSnapshot: baseline, providerBaselines: [] };
const conflict = plan.preview.conflicts[0];
const addressKey = convergentConflictAddressKey(conflict.address);
const selected = conflict.candidates.find((candidate) => candidate.value === 'light')!;
const manager = { getState: () => ({ deviceId: 'resolver' }) } as unknown as CloudSyncManager;
const resolved = resolvePreparedConvergentMigrationConflict(
prepared, addressKey, dotKey(selected.dot), manager, NOW + 3,
);
assert.equal(resolved.plan.preview.canInitialize, true);
assert.equal(resolved.plan.preview.conflictCount, 0);
assert.deepEqual(resolved.plan.preview.blockedReasons, []);
assert.equal(resolved.plan.payload?.settings?.theme, 'light');
assert.ok(resolved.plan.state?.vector.resolver);
assert.equal(resolved.localSnapshot, prepared.localSnapshot);
assert.equal(resolved.providerBaselines, prepared.providerBaselines);
assert.equal(prepared.plan.preview.conflictCount, 1);
assert.equal(prepared.plan.payload, null);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, 'missing', dotKey(selected.dot), manager,
), /conflict no longer exists/);
assert.throws(() => resolvePreparedConvergentMigrationConflict(
prepared, addressKey, 'missing', manager,
), /candidate no longer exists/);
});
test('preparation seeds a trusted baseline for an unchanged v1 provider', async () => {
const remotePayload: SyncPayload = {
...payload(),
-22
View File
@@ -6,11 +6,8 @@ import type {
} from '../domain/sync';
import {
cloudSyncPayloadsEqual,
convergentConflictAddressKey,
materializeSyncPayloadFromConvergentState,
planConvergentSyncMigration,
refreshConvergentSyncMigrationPlan,
resolveConvergentFieldConflict,
stripConvergentSyncEnvelope,
validateConvergentSyncPayload,
type ConvergentMigrationPlan,
@@ -28,25 +25,6 @@ export interface PreparedConvergentMigration {
localSnapshot: SyncPayload;
}
export function resolvePreparedConvergentMigrationConflict(
prepared: PreparedConvergentMigration,
addressKey: string,
candidateDot: string,
manager: CloudSyncManager = getCloudSyncManager(),
now = Date.now(),
): PreparedConvergentMigration {
const conflict = prepared.plan.preview.conflicts.find(
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
);
if (!prepared.plan.state || !conflict) {
throw new Error('The convergent migration conflict no longer exists');
}
const state = resolveConvergentFieldConflict(
prepared.plan.state, conflict, candidateDot, manager.getState().deviceId, now,
);
return { ...prepared, plan: refreshConvergentSyncMigrationPlan(prepared.plan, state) };
}
interface LegacyProviderBaselineSeed {
provider: CloudProvider;
remoteVersion: number;
-2
View File
@@ -720,8 +720,6 @@ 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',
-38
View File
@@ -1,44 +1,6 @@
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}.",
-19
View File
@@ -316,26 +316,7 @@ 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',
-2
View File
@@ -720,8 +720,6 @@ 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',
-38
View File
@@ -1,44 +1,6 @@
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}.",
-19
View File
@@ -316,26 +316,7 @@ 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',
@@ -1,30 +0,0 @@
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');
});
-2
View File
@@ -659,8 +659,6 @@ 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': 'Скрипты',
-38
View File
@@ -1,44 +1,6 @@
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': 'Отмена',
-19
View File
@@ -360,26 +360,7 @@ 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': 'Текущий путь скопирован',
-2
View File
@@ -720,8 +720,6 @@ 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': '脚本',
-57
View File
@@ -1,44 +1,6 @@
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}。",
@@ -1053,26 +1015,7 @@ 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': '已复制当前路径',
-2
View File
@@ -710,8 +710,6 @@ 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': '指令碼',
-57
View File
@@ -1,44 +1,6 @@
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}。",
@@ -1055,26 +1017,7 @@ 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': '已複製目前路徑',
+1 -1
View File
@@ -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
@@ -1,6 +1,5 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { HistoryEntry } from "./commandHistoryStore";
import {
STORAGE_KEY_COMMAND_HISTORY,
@@ -74,9 +73,7 @@ const {
getCommandHistoryClearGeneration,
hasCommandHistoryBeenCleared,
markCommandHistoryCleared,
fuzzyQueryHistory,
queryHistory,
queryRecentHistoryByCommand,
recordCommand,
subscribeCommandHistoryCleared,
} = await import("./commandHistoryStore.ts");
@@ -346,155 +343,3 @@ test("saving after a command and peer writes read the clear generation without p
parse.mock.restore();
assert.ok(queryHistory("echo fast", { hostId: "host-fast" }).some((entry) => entry.command === "echo fast-path-2"));
});
const QUERY_NOW = 1_700_000_000_000;
const DAY_MS = 24 * 60 * 60 * 1000;
function historyEntry(command: string, overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
command, hostId: "host-1", os: "linux", frequency: 1,
createdAt: QUERY_NOW, lastUsedAt: QUERY_NOW, ...overrides,
};
}
function installHistoryEntries(entries: HistoryEntry[]): void {
clearHistory();
const raw = JSON.stringify({
clearGeneration: getCommandHistoryClearGeneration(), version: 1, entries,
});
storage.set(COMMAND_HISTORY_STORAGE_KEY, raw);
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, raw);
}
test("prefix history preserves relevance, stable ties, host isolation and best duplicate", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vim duplicate"),
historyEntry("vim frequent", { frequency: 8, lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("Vim case"),
historyEntry("vim tied"),
historyEntry("vim duplicate", { hostId: "host-2", frequency: 5 }),
historyEntry("unrelated", { frequency: 100 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 4 }), [entries[5], entries[2], entries[3], entries[4]]);
assert.deepEqual(queryHistory("vi", { hostId: "host-1", limit: 3 }), [entries[2], entries[1], entries[3]]);
assert.deepEqual(queryHistory("VI", { limit: 1 }), [entries[0]]);
assert.deepEqual(queryHistory("missing"), []);
assert.deepEqual(queryHistory("vi", { limit: 0 }), []);
});
test("top-k history replaces and evicts duplicates without losing distinct results", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vim a", { frequency: 2 }),
historyEntry("vim b", { frequency: 3 }),
historyEntry("vim c", { frequency: 4 }),
historyEntry("vim b", { hostId: "host-2", frequency: 5 }),
historyEntry("vim a", { hostId: "host-2", frequency: 6 }),
historyEntry("vim a", { hostId: "host-3", frequency: 1 }),
];
installHistoryEntries(entries);
assert.deepEqual(queryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 2 }), [entries[4], entries[3]]);
});
test("fuzzy history preserves subsequence quality, recency, exclusions and stable ties", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("vi", { frequency: 100 }),
historyEntry("vi alpha"),
historyEntry("vi beta"),
historyEntry("very indirect", { frequency: 2 }),
historyEntry("vi stale", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("vi alpha", { hostId: "host-2", frequency: 3 }),
historyEntry("iv does not match"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: 4 }), [entries[5], entries[3], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory("vi", { hostId: "host-1", limit: 4 }), [entries[3], entries[1], entries[2], entries[4]]);
assert.deepEqual(fuzzyQueryHistory(""), []);
assert.deepEqual(fuzzyQueryHistory("vi", { limit: -1 }), []);
});
test("recent history preserves timestamps, command boundaries and quoted argument prefixes", () => {
const entries = [
historyEntry("cd /work/older", { lastUsedAt: QUERY_NOW - DAY_MS }),
historyEntry("cd '/work/new folder'"),
historyEntry("CD /work/tied"),
historyEntry("cd /work/older", { hostId: "host-2", lastUsedAt: QUERY_NOW + 1 }),
historyEntry("cd /other", { lastUsedAt: QUERY_NOW + 2 }),
historyEntry("cdb /work/wrong", { lastUsedAt: QUERY_NOW + 3 }),
historyEntry("cd /work/excluded", { lastUsedAt: QUERY_NOW + 4 }),
];
installHistoryEntries(entries);
const options = { commandName: " CD ", argumentPrefix: "'/work/", excludeCommand: "cd /work/excluded", limit: 3 };
assert.deepEqual(queryRecentHistoryByCommand(options), [entries[3], entries[1], entries[2]]);
assert.deepEqual(queryRecentHistoryByCommand({ ...options, hostId: "host-1" }), [entries[1], entries[2], entries[0]]);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "" }), []);
assert.deepEqual(queryRecentHistoryByCommand({ commandName: "cd", limit: 0 }), []);
});
test("fuzzy scoring preserves consecutive and word-boundary bonuses across long gaps", (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = [
historyEntry("av-x"),
historyEntry("avx"),
historyEntry(`v${"a".repeat(1900)}x`),
historyEntry("v-x"),
historyEntry("v/x"),
historyEntry("v_x"),
historyEntry("v x"),
historyEntry("vx adjacent"),
historyEntry("v missing second character"),
historyEntry("xv wrong order"),
];
installHistoryEntries(entries);
assert.deepEqual(fuzzyQueryHistory("vx"), [
entries[7], entries[3], entries[4], entries[5], entries[6], entries[2], entries[1], entries[0],
]);
assert.deepEqual(fuzzyQueryHistory("vv"), []);
});
for (const size of [4_000, 40_000]) {
test(`history queries keep five best results with linear scoring for ${size} entries`, (t) => {
t.mock.method(Date, "now", () => QUERY_NOW);
const entries = Array.from({ length: size }, (_, i) => historyEntry(`vi file-${i}`, {
// Unsorted scores/timestamps, with the best match at the end of the store.
frequency: i === size - 1 ? size + 1 : (i * 7919) % size + 1,
lastUsedAt: QUERY_NOW + (i === size - 1 ? size + 1 : (i * 7919) % size),
}));
installHistoryEntries(entries);
queryHistory("missing"); // Warm the cache before measuring query operations.
const expected = entries.slice().sort((a, b) => b.frequency - a.frequency).slice(0, 5);
const pow = t.mock.method(Math, "pow");
const lower = t.mock.method(String.prototype, "toLowerCase");
// node:test cannot mock methods directly on arrays (including Array.prototype).
const originalSort = Array.prototype.sort;
const sortedSizes: number[] = [];
Array.prototype.sort = function<T>(this: T[], compare?: (a: T, b: T) => number): T[] {
sortedSizes.push(this.length);
return originalSort.call(this, compare);
};
t.after(() => { Array.prototype.sort = originalSort; });
const queries = [
() => queryHistory("vi", { limit: 5 }),
() => fuzzyQueryHistory("vi", { limit: 5 }),
() => queryRecentHistoryByCommand({ commandName: "vi", argumentPrefix: "file-", limit: 5 }),
];
for (const query of queries) {
const beforePow = pow.mock.callCount();
const beforeLower = lower.mock.callCount();
const result = query();
assert.deepEqual(result, expected);
assert.equal(new Set(result.map((entry) => entry.command)).size, 5);
assert.ok(pow.mock.callCount() - beforePow <= size, "score each entry at most once");
assert.ok(lower.mock.callCount() - beforeLower <= 2 * size + 2, "normalize in one linear pass");
}
assert.deepEqual(sortedSizes, [], "never sort all matches before limiting");
});
}
+76 -65
View File
@@ -352,36 +352,6 @@ export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
argumentPrefix?: string;
}
interface RankedHistoryEntry {
entry: HistoryEntry;
score: number;
}
/**
* Keep only the best k distinct commands. Queries score each match once and
* retain O(k) candidates instead of sorting/allocating all matches (k is 5
* for autocomplete). Entries arrive in store order for stable score ties.
*/
function retainHistoryMatch(
best: RankedHistoryEntry[],
entry: HistoryEntry,
score: number,
limit: number,
): void {
if (best.length >= limit && score <= best[best.length - 1].score) return;
const duplicate = best.findIndex((candidate) => candidate.entry.command === entry.command);
if (duplicate >= 0) {
if (score <= best[duplicate].score) return;
best.splice(duplicate, 1);
}
// Insert after equal scores to preserve the original store order on ties.
const index = best.findIndex((candidate) => score > candidate.score);
if (best.length >= limit) best.pop();
best.splice(index < 0 ? best.length : index, 0, { entry, score });
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
@@ -396,17 +366,33 @@ export function queryHistory(
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const filtered = store.entries.filter((entry) => {
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) continue;
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
// Must not be identical to prefix
if (entry.command === prefix) continue;
retainHistoryMatch(best, entry, scoreEntryAt(entry, now), limit);
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return best.map(({ entry }) => entry);
return results;
}
/**
@@ -424,7 +410,7 @@ export function fuzzyQueryHistory(
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const best: RankedHistoryEntry[] = [];
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
for (const entry of store.entries) {
// Host filtering
@@ -434,11 +420,24 @@ export function fuzzyQueryHistory(
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
retainHistoryMatch(best, entry, matchScore * scoreEntryAt(entry, now), limit);
scored.push({ entry, matchScore });
}
}
return best.map(({ entry }) => entry);
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
@@ -463,26 +462,38 @@ export function queryRecentHistoryByCommand(
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const best: RankedHistoryEntry[] = [];
for (const entry of store.entries) {
if (hostId && entry.hostId !== hostId) continue;
const filtered = store.entries.filter((entry) => {
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
continue;
return false;
}
if (excludeCommand && entry.command === excludeCommand) continue;
if (excludeCommand && entry.command === excludeCommand) return false;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
continue;
return false;
}
}
retainHistoryMatch(best, entry, entry.lastUsedAt, limit);
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return best.map(({ entry }) => entry);
return results;
}
function getCurrentCommandToken(command: string): string {
@@ -558,28 +569,28 @@ function fuzzyScore(query: string, target: string): number {
if (query.length > target.length) return 0;
let score = 0;
let searchFrom = 0;
let queryIdx = 0;
let prevMatchIdx = -2;
for (let queryIdx = 0; queryIdx < query.length; queryIdx++) {
// Skip unmatched runs in native string search, especially for long commands.
const i = target.indexOf(query[queryIdx], searchFrom);
if (i < 0) return 0;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
}
score += 1;
prevMatchIdx = i;
searchFrom = i + 1;
}
return score;
// All query characters must be matched
return queryIdx === query.length ? score : 0;
}
/**
@@ -75,95 +75,6 @@ test('resolves the focused workspace session for global keybindings', () => {
});
});
test('uses the anchor chat owner when focus is missing (#4098 phase 2)', () => {
const splitWorkspace: Workspace = {
id: 'workspace-1',
title: 'Workspace',
anchorChatId: 'chat-2',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
],
sizes: [1, 1],
},
};
const chatOwnerSessions: TerminalSession[] = [
...sessions,
{
id: 'session-3',
hostId: 'host-3',
hostLabel: 'Host 3',
username: 'root',
hostname: 'example.org',
status: 'connected',
protocol: 'ssh',
workspaceId: 'workspace-1',
},
];
assert.equal(
resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: chatOwnerSessions,
workspaces: [splitWorkspace],
aiSessions: [
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 2 },
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
],
})['terminal.sessionId'],
// anchorChatId's chat is terminal-scoped to session-3, which wins even
// though session-1's chat is older and session-1 is the first member.
'session-3',
);
});
test('falls back to the most-recently-active member when there is no anchor (#4098 phase 2)', () => {
const splitWorkspace: Workspace = {
id: 'workspace-1',
title: 'Workspace',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 'session-1' },
{ id: 'pane-2', type: 'pane', sessionId: 'session-3' },
],
sizes: [1, 1],
},
};
const chatOwnerSessions: TerminalSession[] = [
...sessions,
{
id: 'session-3',
hostId: 'host-3',
hostLabel: 'Host 3',
username: 'root',
hostname: 'example.org',
status: 'connected',
protocol: 'ssh',
workspaceId: 'workspace-1',
},
];
assert.equal(
resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: chatOwnerSessions,
workspaces: [splitWorkspace],
aiSessions: [
{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 },
{ id: 'chat-2', scope: { type: 'terminal', targetId: 'session-3' }, updatedAt: 9 },
],
})['terminal.sessionId'],
'session-3',
);
});
test('resolves standalone sessions and fails closed for non-terminal tabs', () => {
assert.equal(
resolveActivePluginKeybindingContext({ activeTabId: 'session-2', sessions, workspaces })['terminal.sessionId'],
@@ -178,18 +89,3 @@ test('resolves standalone sessions and fails closed for non-terminal tabs', () =
'netcatty.activeTabId': 'vault',
});
});
test('empty workspace does not target the detached owner of its anchored chat', () => {
const context = resolveActivePluginKeybindingContext({
activeTabId: 'workspace-1',
sessions: sessions.map((session) => ({ ...session, workspaceId: undefined })),
workspaces: [{ ...workspaces[0], focusedSessionId: undefined, anchorChatId: 'chat-1', root: {
id: 'empty', type: 'split', direction: 'vertical', children: [], sizes: [],
} }],
aiSessions: [{ id: 'chat-1', scope: { type: 'terminal', targetId: 'session-1' }, updatedAt: 1 }],
});
assert.equal(context['terminal.sessionId'], undefined);
assert.equal(context['host.id'], undefined);
assert.equal(context['workspace.id'], 'workspace-1');
});
@@ -1,6 +1,4 @@
import { collectSessionIds } from '../../domain/workspace';
import { resolveMostRecentlyActiveMemberTerminalId } from '../../domain/aiSessionRecency';
import type { AIChatRecencyEntry } from '../../domain/aiSessionRecency';
import type { TerminalSession, Workspace } from '../../types';
export type PluginContributionContextValue = string | boolean | number | null;
@@ -46,17 +44,14 @@ 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
? resolveWorkspaceActiveTerminalId(workspace, aiSessions)
? (workspace.focusedSessionId ?? collectSessionIds(workspace.root)[0])
: sessions.some((candidate) => candidate.id === activeTabId)
? activeTabId
: undefined;
@@ -77,32 +72,3 @@ 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,7 +45,6 @@ export type SessionSnapshotActions = Pick<
| 'resetWorkspaceRename'
| 'removeSessionFromWorkspace'
| 'setWorkspaceFocusedSession'
| 'setWorkspaceAnchorChat'
| 'toggleWorkspaceViewMode'
// Terminal / chrome mutators previously read only via App session runtime.
| 'createLocalTerminal'
@@ -57,7 +56,6 @@ export type SessionSnapshotActions = Pick<
| 'createWorkspaceWithHosts'
| 'createWorkspaceFromSessions'
| 'addSessionToWorkspace'
| 'moveWorkspacePane'
| 'appendHostToWorkspace'
| 'appendHostToWorkspaceConfirmed'
| 'appendLocalTerminalToWorkspace'
+136 -111
View File
@@ -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,19 +14,11 @@ const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
id,
hostId: id,
hostLabel: id,
username: "user",
hostname: id,
status: "connected",
workspaceId,
});
const chat = (id: string, targetId: string, updatedAt: number): AIChatRecencyEntry => ({
id,
scope: { type: "terminal", targetId },
updatedAt,
});
const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Workspace => ({
const workspace = (sessionIds: string[]): Workspace => ({
id: "ws-1",
title: "Workspace",
focusedSessionId: sessionIds[0],
@@ -44,17 +36,9 @@ const workspace = (sessionIds: string[], overrides: Partial<Workspace> = {}): Wo
})),
sizes: sessionIds.map(() => 1),
},
...overrides,
});
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)", () => {
test("detach dissolves the original workspace when one session remains", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
@@ -65,126 +49,149 @@ test("detaching to one member keeps the workspace alive (#4098 phase 2)", () =>
assert.equal(result.activeTabId, "s1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", undefined],
// The survivor keeps its workspace binding — no orphan promotion.
["s2", "ws-1"],
["s2", undefined],
]);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.deepEqual(rootPaneSessionIds(result.workspaces[0]), ["s2"]);
assert.equal(result.workspaces.length, 0);
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
});
test("detaching the last member keeps the workspace with an empty root (#4098 phase 2)", () => {
test("detach preserves the other sessions in a multi-pane workspace", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1")],
workspaces: [workspace(["s1"])],
sessionId: "s1",
sessions: [session("s1"), session("s2"), session("s3")],
workspaces: [workspace(["s1", "s2", "s3"])],
sessionId: "s2",
});
assert.equal(result.changed, true);
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,
[],
);
assert.equal(result.workspaces[0].focusedSessionId, undefined);
});
test("closing one member keeps the workspace and its surviving member bound (#4098 phase 2)", () => {
const result = closeSessionsState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
sessionIds: ["s1"],
currentActiveTabId: "ws-1",
tabOrder: ["ws-1"],
});
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].id, "ws-1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [["s2", "ws-1"]]);
// The workspace tab is still live, so the active tab does not move.
assert.equal(result.activeTabId, undefined);
});
test("member loss hands focus to the most-recently-active survivor, not sessions[0]", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3"])],
"ws-1",
"s1",
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
);
assert.equal(result.workspaces.length, 1);
// remainingSessionIds[0] is s2, but s3's chat is the most recent.
assert.equal(result.workspaces[0].focusedSessionId, "s3");
});
test("reordering focus order does not change the survivor retarget", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3"], { focusSessionOrder: ["s3", "s2", "s1"] })],
"ws-1",
"s1",
[chat("chat-s2", "s2", 4), chat("chat-s3", "s3", 9)],
);
assert.equal(result.workspaces[0].focusedSessionId, "s3");
});
test("closing a non-focused member keeps the focused member", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3"])],
"ws-1",
"s3",
);
assert.equal(result.workspaces.length, 1);
assert.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.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2"]);
assert.deepEqual(
result.workspaces[0].root.type === "split"
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
: [],
["s1", "s3"],
);
});
test("with no AI recency signal focus stays on a surviving terminal", () => {
test("dissolved workspace replacement preserves its tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["log-1", "s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement removes duplicate replacement ids", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement is idempotent", () => {
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
once,
);
});
test("single remaining session preserves dissolved workspace tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
["log-1", "s2", "session-3"],
);
});
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.equal(result.lastRemainingSessionId, "s2");
assert.deepEqual(result.workspaces, []);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(
["log-1", result.dissolvedWorkspaceId!, "session-3"],
result.dissolvedWorkspaceId,
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
),
["log-1", "s2", "session-3"],
);
});
test("closing the focused session hands focus to a remaining session (#3046)", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s1",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
});
test("closing ALL members keeps the workspace alive with an empty root (#4098 phase 2)", () => {
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,
[],
test("closing a non-focused session keeps the focused session", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s3",
);
// The workspace tab survives, so the active tab is left alone.
assert.equal(result.activeTabId, undefined);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
});
test("closing a solo workspace member still replaces the closed tab's activity via fallbacks", () => {
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions(
[session("s1"), session("s2")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
["s2", undefined],
]);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
closedSessionId: "s1",
workspaceId: "ws-1",
layoutResult,
remainingSessions: nextSessions,
preferredTabId: "s-outside",
}),
"s2",
);
});
test("closing the last workspace session does not invent a surviving terminal", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1"])],
"ws-1",
"s1",
);
const nextSessions = applyCloseSessionToSessions([session("s1")], "s1");
const nextSessions = applyCloseSessionToSessions(
[session("s1")],
"s1",
layoutResult,
);
assert.deepEqual(nextSessions, []);
// The workspace tab survives, so an active workspace tab needs no fallback.
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
assert.equal(layoutResult.lastRemainingSessionId, undefined);
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: "ws-1",
@@ -193,7 +200,25 @@ test("closing a solo workspace member still replaces the closed tab's activity v
layoutResult,
remainingSessions: nextSessions,
}),
null,
"vault",
);
});
test("closing the last visible solo tab does not fall back to a hidden MCP session", () => {
const visibleSession: TerminalSession = { ...session("s1", undefined) };
const hiddenSession: TerminalSession = { ...session("s2", undefined), hiddenFromTabs: true };
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s1");
const remainingSessions = [hiddenSession];
assert.equal(
resolveActiveTabAfterCloseSession({
currentActiveTabId: visibleSession.id,
closedSessionId: "s1",
workspaceId: undefined,
layoutResult,
remainingSessions,
}),
"vault",
);
});
+126 -74
View File
@@ -1,17 +1,20 @@
import type { TerminalSession, Workspace } from "../../domain/models";
import type { AIChatRecencyEntry } from "../../domain/aiSessionRecency";
import { resolveMostRecentlyActiveMemberTerminalId } from "../../domain/aiSessionRecency";
import { collectSessionIds, createEmptyWorkspaceRoot, pruneWorkspaceNode } from "../../domain/workspace";
import { collectSessionIds, 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 = {
@@ -25,65 +28,82 @@ 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[];
};
/**
* 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;
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)),
];
}
return resolveMostRecentlyActiveMemberTerminalId({
memberTerminalIds: remainingSessionIds,
aiSessions,
}) ?? remainingSessionIds[0];
};
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;
}
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) {
// 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: [],
};
removedWorkspaceId = workspace.id;
return null;
}
const remainingSessionIds = collectSessionIds(prunedRoot);
// 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,
);
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;
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
(candidateId) => remainingSessionIds.includes(candidateId),
);
@@ -94,23 +114,36 @@ 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.
* Since issue #4098 phase 2 workspaces survive member loss (no dissolve), the
* remaining members keep their workspace binding.
* When a 2-pane workspace dissolves to one terminal, that remaining session
* must become an orphan tab — not stay bound to a deleted workspaceId.
*/
export function applyCloseSessionToSessions(
sessions: readonly TerminalSession[],
sessionId: string,
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
): TerminalSession[] {
return sessions.filter((session) => session.id !== sessionId);
const remaining = sessions.filter((session) => session.id !== sessionId);
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
if (!lastRemainingSessionId) return remaining;
return remaining.map((session) => (
session.id === lastRemainingSessionId
? { ...session, workspaceId: undefined }
: session
));
}
export function isPreferredTabAvailableAfterClose(
@@ -128,6 +161,12 @@ 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) => (
@@ -140,6 +179,7 @@ export function isPreferredTabAvailableAfterClose(
export function resolveActiveTabAfterCloseSession({
currentActiveTabId,
closedSessionId,
workspaceId,
layoutResult,
remainingSessions,
preferredTabId,
@@ -162,14 +202,18 @@ export function resolveActiveTabAfterCloseSession({
const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput)
? preferredInput.preferredTabId
: undefined;
const fallback = preferredFallback
const fallback = layoutResult.lastRemainingSessionId
?? preferredFallback
?? fallbackWorkspace?.id
?? fallbackSolo?.id
?? "vault";
// Workspaces survive member loss (#4098 phase 2), so only a closed session
// tab that was active needs a replacement.
if (currentActiveTabId === closedSessionId) {
if (
currentActiveTabId === closedSessionId
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
) {
return fallback;
}
@@ -183,7 +227,6 @@ export function closeSessionsState({
currentActiveTabId,
preferredTabId,
tabOrder,
aiSessions,
}: {
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
@@ -191,7 +234,6 @@ export function closeSessionsState({
currentActiveTabId: string | null;
preferredTabId?: string | null;
tabOrder: readonly string[];
aiSessions?: readonly AIChatRecencyEntry[];
}): CloseSessionsStateResult {
let nextSessions = [...sessions];
let nextWorkspaces = [...workspaces];
@@ -208,10 +250,17 @@ export function closeSessionsState({
nextWorkspaces,
workspaceId,
sessionId,
aiSessions,
);
nextWorkspaces = layoutResult.workspaces;
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId);
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
nextTabOrder,
layoutResult.dissolvedWorkspaceId,
[layoutResult.lastRemainingSessionId],
);
}
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
currentActiveTabId: nextActiveTabId,
@@ -239,7 +288,6 @@ export function detachSessionFromWorkspaceState({
sessions,
workspaces,
sessionId,
aiSessions,
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session?.workspaceId) {
@@ -253,36 +301,40 @@ export function detachSessionFromWorkspaceState({
}
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
const nextSessions = sessions.map((candidate) => (
let 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.map((workspace) => (
workspace.id === workspaceId
? {
...workspace,
root: createEmptyWorkspaceRoot(),
focusedSessionId: undefined,
focusSessionOrder: [],
}
: workspace
)),
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
const nextFocusedSessionId = resolveSurvivingFocusMemberId(
remainingSessionIds,
targetWorkspace.focusedSessionId,
aiSessions,
);
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 nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
.filter((candidateId, index, list) => (
candidateId !== sessionId &&
@@ -1,89 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { SftpPane } from "./types";
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
import { createSftpArchiveForPane } from "./createSftpArchive.ts";
function setup() {
let pane = {
id: "pane-a", connection: { id: "connection-a", isLocal: false }, filenameEncoding: "gb18030",
} as SftpPane;
const calls: unknown[][] = [];
const refreshes: string[] = [];
const params = {
getPane: () => pane,
target: { paneId: "pane-a", connectionId: "connection-a" },
options: { directory: "/srv", paths: ["中文", "logs"], archiveName: "backup.zip", format: "zip" as const },
getSftpIdForConnection: (id: string) => id === "connection-a" ? "sftp-a" : undefined,
bridge: {
createSftpArchive: async (...args: unknown[]): Promise<SftpArchiveCreationResult> => {
calls.push(args);
return { success: true };
},
},
refresh: async (id: string) => { refreshes.push(id); },
t: (key: string) => key,
};
return { params, calls, refreshes, setPane: (value: SftpPane) => { pane = value; } };
}
test("archive creation passes selected paths and encoding to the captured remote session", async () => {
const { params, calls, refreshes } = setup();
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.deepEqual(calls, [["sftp-a", params.options, "gb18030"]]);
assert.deepEqual(refreshes, ["pane-a"]);
});
test("a switched or disconnected pane cannot compress files on another host", async () => {
for (const change of [
{ id: "pane-b" },
{ connection: { id: "connection-b", isLocal: false } },
{ connection: null },
]) {
const { params, calls, setPane } = setup();
setPane({ ...params.getPane(), ...change } as SftpPane);
await assert.rejects(createSftpArchiveForPane(params), /connectionChanged/);
assert.equal(calls.length, 0);
}
});
test("archive creation requires a remote session and a supported bridge", async () => {
const { params, calls, setPane } = setup();
await assert.rejects(createSftpArchiveForPane({ ...params, bridge: undefined }), /unavailable/);
await assert.rejects(createSftpArchiveForPane({ ...params, getSftpIdForConnection: () => undefined }), /unavailable/);
setPane({ ...params.getPane(), connection: { id: "connection-a", isLocal: true } } as SftpPane);
await assert.rejects(createSftpArchiveForPane(params), /unavailable/);
assert.equal(calls.length, 0);
});
test("existing archives are returned for confirmation without retry or refresh", async () => {
const { params, refreshes } = setup();
const conflict = { success: false, code: "EEXIST", error: "Already exists" };
params.bridge.createSftpArchive = async () => conflict;
assert.equal(await createSftpArchiveForPane(params), conflict);
assert.deepEqual(refreshes, []);
});
test("finishing after a tab switch does not refresh the new connection", async () => {
const { params, refreshes, setPane } = setup();
params.bridge.createSftpArchive = async () => {
setPane({ ...params.getPane(), id: "pane-b" } as SftpPane);
return { success: true };
};
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.deepEqual(refreshes, []);
});
test("a failed operation is not refreshed or converted to success", async () => {
const { params, refreshes } = setup();
params.bridge.createSftpArchive = async () => { throw new Error("zip is not installed"); };
await assert.rejects(createSftpArchiveForPane(params), /zip is not installed/);
assert.deepEqual(refreshes, []);
});
test("a listing error after publication does not invite a second archive creation", async () => {
const { params, calls } = setup();
params.refresh = async () => { throw new Error("Disconnected after publication"); };
assert.deepEqual(await createSftpArchiveForPane(params), { success: true });
assert.equal(calls.length, 1);
});
@@ -1,46 +0,0 @@
import type {
SftpArchiveCreationOptions,
SftpArchiveCreationResult,
SftpArchiveCreationTarget,
} from "../../../domain/sftpArchiveCreation";
import type { SftpPane } from "./types";
import { logger } from "../../../lib/logger";
interface CreateSftpArchiveParams {
getPane: () => Pick<SftpPane, "id" | "connection" | "filenameEncoding">;
target: SftpArchiveCreationTarget;
options: SftpArchiveCreationOptions;
bridge: Pick<NetcattyBridge, "createSftpArchive"> | undefined;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
refresh: (paneId: string) => Promise<void>;
t: (key: string) => string;
}
/** Bind the selected files to the pane/connection that opened the dialog. */
export async function createSftpArchiveForPane({
getPane, target, options, bridge, getSftpIdForConnection, refresh, t,
}: CreateSftpArchiveParams): Promise<SftpArchiveCreationResult> {
const pane = getPane();
if (pane.id !== target.paneId || pane.connection?.id !== target.connectionId) {
throw new Error(t("sftp.archive.connectionChanged"));
}
const sftpId = getSftpIdForConnection?.(target.connectionId);
if (pane.connection.isLocal || !sftpId || !bridge?.createSftpArchive) {
throw new Error(t("sftp.archive.unavailable"));
}
const result = await bridge.createSftpArchive(sftpId, options, pane.filenameEncoding);
if (!result.success) return result;
// A tab switch during compression must not refresh an unrelated connection.
const current = getPane();
if (current.id === target.paneId && current.connection?.id === target.connectionId) {
try {
await refresh(target.paneId);
} catch (error) {
// The archive has already been published. A listing failure must not
// invite the user to repeat a successful operation (or overwrite it).
logger.warn("[SFTP] Archive created but directory refresh failed:", error);
}
}
return result;
}
@@ -1,131 +0,0 @@
/** @test issue #4132 — a one-click duplicate copies next to the source without a conflict prompt */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
test("duplicate transfers resolve same-name conflicts to (copy) without prompting", async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-entries-"));
const ownerId = `duplicate-entries-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
// A duplicate has to cover both files and folders: the conflict type key
// differs between the two and the seeded defaults must match both.
const fileNames = ["report.txt"];
const folderNames = ["photos"];
for (const name of fileNames) {
await fs.writeFile(path.join(dir, name), "original data");
}
for (const name of folderNames) {
await fs.mkdir(path.join(dir, name));
await fs.writeFile(path.join(dir, name, "nested.txt"), "nested data");
}
const pane = createEmptyPane("duplicate-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
try {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch {
return null;
}
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
})),
});
return null;
}
const waitForSettledRoots = async (expected: number) => {
await act(async () => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (roots.length === expected && roots.every(row => ["completed", "failed"].includes(row.status))) return;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
};
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
const sources = [...fileNames, ...folderNames].map(name => ({ name, isDirectory: folderNames.includes(name) }));
await act(async () => {
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
});
// The duplicate option must resolve conflicts by itself; a prompt here
// would defeat the one-click action the issue asks for.
assert.deepEqual(ops.conflicts, []);
await waitForSettledRoots(sources.length);
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.equal(roots.length, sources.length, JSON.stringify(roots.map(row => row.fileName)));
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
for (const name of fileNames) {
assert.equal(await fs.readFile(path.join(dir, name), "utf8"), "original data");
}
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "photos (copy)", "nested.txt"), "utf8"), "nested data");
// A second duplicate must pick the next free name instead of colliding
// with the copy created by the first run.
await act(async () => {
await ops.startTransfer(sources, "left", "left", { sourcePath: dir, targetPath: dir, duplicate: true });
});
assert.deepEqual(ops.conflicts, []);
await waitForSettledRoots(sources.length * 2);
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "photos (copy 2)", "nested.txt"), "utf8"), "nested data");
// The tree's ".." navigation row must never be duplicated: it would copy
// an ancestor into itself.
const tasksBefore = sftpTransferCenterStore.getOwnerTasks(ownerId).length;
let navigationResults: unknown;
await act(async () => {
navigationResults = await ops.startTransfer(
[{ name: "..", isDirectory: true }],
"left",
"left",
{ sourcePath: dir, targetPath: dir, duplicate: true },
);
});
assert.deepEqual(navigationResults, []);
assert.equal(sftpTransferCenterStore.getOwnerTasks(ownerId).length, tasksBefore);
assert.deepEqual(ops.conflicts, []);
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -1,109 +0,0 @@
/** @test issue #4132 — a duplicate must not reuse a destination an earlier copy still holds */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { useSftpTransfers } from "./useSftpTransfers";
import { createEmptyPane } from "./types";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
test("duplicate names around a destination an in-flight copy has not created yet", async () => {
const dir = await fs.mkdtemp(path.join(process.cwd(), ".duplicate-inflight-"));
const ownerId = `duplicate-inflight-${crypto.randomUUID()}`;
const previousWindow = globalThis.window;
const previousStorage = globalThis.localStorage;
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
globals.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let ops!: ReturnType<typeof useSftpTransfers>;
let releaseFirst: (() => void) | undefined;
const firstCopyGate = new Promise<void>((resolve) => { releaseFirst = resolve; });
let streamStarts = 0;
await fs.writeFile(path.join(dir, "report.txt"), "original data");
const pane = createEmptyPane("duplicate-inflight-pane");
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
const statPath = async (value: string) => {
try {
const stat = await fs.lstat(value);
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch {
return null;
}
};
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
mkdirLocal: async (value: string) => { await fs.mkdir(value, { recursive: true }); },
deleteLocalFile: async (value: string) => { await fs.rm(value, { recursive: true, force: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.rename(source, target); },
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
streamStarts += 1;
// Hold the first copy before it writes its destination: the second
// duplicate then sees no "(copy)" on disk even though one is reserved.
if (streamStarts === 1) await firstCopyGate;
await fs.copyFile(options.sourcePath, options.targetPath);
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
return {};
},
} };
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
function Probe() {
ops = useSftpTransfers({
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
updateTab: () => undefined, refresh: async () => undefined,
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
listRemoteFiles: async () => [],
listLocalFiles: async (value: string) => Promise.all((await fs.readdir(value)).map(async name => {
const stat = await fs.lstat(path.join(value, name));
return { name, type: stat.isSymbolicLink() ? "symlink" as const : stat.isDirectory() ? "directory" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
})),
});
return null;
}
const sources = [{ name: "report.txt", isDirectory: false }];
const options = { sourcePath: dir, targetPath: dir, duplicate: true };
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
// First duplicate starts copying and blocks before writing "(copy)".
let firstRun: ReturnType<typeof ops.startTransfer> | undefined;
await act(async () => {
firstRun = ops.startTransfer(sources, "left", "left", options);
const deadline = Date.now() + 3000;
while (streamStarts === 0 && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 5));
}
});
assert.equal(streamStarts, 1, "the first copy should be in flight");
// Second duplicate runs while "(copy)" is still absent from the disk.
await act(async () => { await ops.startTransfer(sources, "left", "left", options); });
assert.equal(ops.conflicts.length, 0, "duplicate must not prompt for a reserved name");
releaseFirst?.();
await act(async () => { await firstRun; });
await act(async () => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) return;
await new Promise(resolve => setTimeout(resolve, 20));
}
});
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
assert.deepEqual(roots.map(row => row.fileName).sort(), ["report (copy 2).txt", "report (copy).txt"]);
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
assert.equal(await fs.readFile(path.join(dir, "report (copy).txt"), "utf8"), "original data");
assert.equal(await fs.readFile(path.join(dir, "report (copy 2).txt"), "utf8"), "original data");
} finally {
releaseFirst?.();
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
(globalThis as { window?: unknown }).window = previousWindow;
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
await fs.rm(dir, { recursive: true, force: true });
}
});
@@ -61,11 +61,10 @@ export function useSftpTransferConflictOps() {
const getDuplicateTarget = useCallback(
async (
task: Pick<TransferTask, "fileName" | "isDirectory" | "targetPath">,
task: TransferTask,
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
isTargetTaken?: (targetPath: string) => boolean,
) => {
const parentPath = getParentPath(task.targetPath);
const { baseName, ext } = splitNameForDuplicate(task.fileName, task.isDirectory);
@@ -74,9 +73,6 @@ 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 };
@@ -1,161 +0,0 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { SftpArchiveCreationResult } from "../../../domain/sftpArchiveCreation";
import { useSftpArchiveDialog } from "./useSftpArchiveDialog";
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
type Params = Parameters<typeof useSftpArchiveDialog>[0];
async function mount(overrides: Partial<Params> = {}) {
let params: Params = {
paneId: "pane-1", connectionId: "conn-1", isLocal: false, isActive: true,
t: (key) => key, onCreateArchive: async () => ({ success: true }), onCreated: () => {},
...overrides,
};
let current!: ReturnType<typeof useSftpArchiveDialog>;
function Probe() {
current = useSftpArchiveDialog(params);
return null;
}
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
return {
get value() { return current; },
async update(changes: Partial<Params>) {
params = { ...params, ...changes };
await act(async () => { renderer.update(React.createElement(Probe)); });
},
async unmount() { await act(async () => { renderer.unmount(); }); },
};
}
test("archive dialog snapshots nested selections, format, and connection identity", async (t) => {
const requests: unknown[] = [];
const reloaded: string[] = [];
const harness = await mount({
onCreateArchive: async (options, target) => { requests.push({ options, target }); return { success: true }; },
onCreated: (directory) => { reloaded.push(directory); },
});
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/site/config/app.json"]); });
assert.deepEqual(harness.value.state?.paths, ["assets", "config/app.json"]);
assert.equal(harness.value.state?.directory, "/srv/site");
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); harness.value.setFormat("zip"); });
assert.equal(harness.value.state?.archiveName, "backup.zip");
await act(async () => { await harness.value.submit(); });
assert.deepEqual(requests, [{
options: { directory: "/srv/site", paths: ["assets", "config/app.json"], archiveName: "backup.zip", format: "zip", overwrite: false },
target: { paneId: "pane-1", connectionId: "conn-1" },
}]);
assert.deepEqual(reloaded, ["/srv/site"]);
assert.equal(harness.value.state, null);
});
test("archive dialog requires a separate overwrite decision and revokes it on edits", async (t) => {
const overwriteFlags: Array<boolean | undefined> = [];
const harness = await mount({ onCreateArchive: async (options) => {
overwriteFlags.push(options.overwrite);
return options.overwrite ? { success: true } : { success: false, code: "EEXIST" };
} });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [], "overwrite is unavailable before a collision");
await act(async () => { await harness.value.submit(); });
assert.equal(harness.value.state?.confirmOverwrite, true);
await act(async () => { await harness.value.submit(); });
assert.deepEqual(overwriteFlags, [false], "Enter must not confirm overwrite");
await act(async () => { harness.value.setArchiveName("other.tar.gz"); });
assert.equal(harness.value.state?.confirmOverwrite, false);
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [false]);
await act(async () => { await harness.value.submit(); });
await act(async () => { await harness.value.submit(true); });
assert.deepEqual(overwriteFlags, [false, false, true]);
assert.equal(harness.value.state, null);
});
test("pending archive creation blocks duplicate submits, edits, and dismissal", async (t) => {
let finish!: (result: SftpArchiveCreationResult) => void;
let requests = 0;
const harness = await mount({ onCreateArchive: async () => {
requests += 1;
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
} });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
let pending!: Promise<void>;
await act(async () => {
pending = harness.value.submit();
void harness.value.submit();
harness.value.close();
harness.value.setArchiveName("changed.tar.gz");
harness.value.open(["/different"]);
});
assert.equal(requests, 1);
assert.equal(harness.value.state?.busy, true);
assert.equal(harness.value.state?.archiveName, "site.tar.gz");
await act(async () => { finish({ success: true }); await pending; });
assert.equal(harness.value.state, null);
});
test("switching connections dismisses the old dialog and ignores its late result", async (t) => {
let finish!: (result: SftpArchiveCreationResult) => void;
const reloaded: string[] = [];
let requests = 0;
const harness = await mount({
onCreateArchive: async () => {
requests += 1;
return new Promise<SftpArchiveCreationResult>((resolve) => { finish = resolve; });
},
onCreated: (directory) => { reloaded.push(directory); },
});
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
let pending!: Promise<void>;
await act(async () => { pending = harness.value.submit(); });
await harness.update({ connectionId: "conn-2" });
assert.equal(harness.value.state, null);
await act(async () => { harness.value.open(["/srv/new-site"]); });
await act(async () => { finish({ success: false, code: "EEXIST" }); await pending; });
assert.equal(requests, 1);
assert.equal(harness.value.state?.archiveName, "new-site.tar.gz");
assert.equal(harness.value.state?.confirmOverwrite, false);
assert.deepEqual(reloaded, []);
});
test("tab switches invalidate the old form even when its connection stays mounted", async (t) => {
let requests = 0;
const harness = await mount({ onCreateArchive: async () => { requests += 1; return { success: true }; } });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); });
const staleSubmit = harness.value.submit;
await harness.update({ isActive: false });
await act(async () => { await staleSubmit(); });
assert.equal(harness.value.state, null);
assert.equal(requests, 0);
await harness.update({ isActive: true, isLocal: true });
await act(async () => { harness.value.open(["/srv/site"]); });
assert.equal(harness.value.state, null);
});
test("invalid names do not invoke the backend; failures keep a retryable form", async (t) => {
let requests = 0;
const harness = await mount({ onCreateArchive: async () => { requests += 1; throw new Error("zip is not installed"); } });
t.after(() => harness.unmount());
await act(async () => { harness.value.open(["/srv/site"]); harness.value.setArchiveName("../backup.tar.gz"); });
await act(async () => { await harness.value.submit(); });
assert.equal(requests, 0);
assert.equal(harness.value.state?.error, "sftp.archive.invalidName");
await act(async () => { harness.value.setArchiveName("backup.tar.gz"); });
await act(async () => { await harness.value.submit(); });
assert.equal(requests, 1);
assert.equal(harness.value.state?.busy, false);
assert.equal(harness.value.state?.error, "zip is not installed");
});
@@ -1,162 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
prepareSftpArchiveSelection,
validateSftpArchiveName,
withSftpArchiveExtension,
type SftpArchiveCreationOptions,
type SftpArchiveCreationResult,
type SftpArchiveCreationTarget,
type SftpArchiveFormat,
} from "../../../domain/sftpArchiveCreation";
export type SftpArchiveTarget = SftpArchiveCreationTarget;
export interface SftpArchiveDialogState {
target: SftpArchiveTarget;
directory: string;
paths: string[];
archiveName: string;
format: SftpArchiveFormat;
busy: boolean;
error: string | null;
confirmOverwrite: boolean;
}
interface UseSftpArchiveDialogParams {
paneId: string;
connectionId?: string;
isLocal: boolean;
isActive: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
onCreateArchive?: (
options: SftpArchiveCreationOptions,
target: SftpArchiveTarget,
) => Promise<SftpArchiveCreationResult>;
onCreated: (directory: string) => void;
}
export function useSftpArchiveDialog(params: UseSftpArchiveDialogParams) {
const [state, setState] = useState<SftpArchiveDialogState | null>(null);
const stateRef = useRef(state);
const paramsRef = useRef(params);
paramsRef.current = params;
const generation = useRef(0);
const update = useCallback((next: SftpArchiveDialogState | null) => {
stateRef.current = next;
setState(next);
}, []);
const matchesTarget = useCallback((target: SftpArchiveTarget) => {
const current = paramsRef.current;
return current.isActive && !current.isLocal
&& current.paneId === target.paneId
&& current.connectionId === target.connectionId;
}, []);
// A dialog belongs to the connection and pane on which it was opened. A tab
// switch may leave its component mounted, so visibility is part of identity.
useEffect(() => {
if (stateRef.current && !matchesTarget(stateRef.current.target)) {
generation.current += 1;
update(null);
}
}, [params.paneId, params.connectionId, params.isLocal, params.isActive, matchesTarget, update]);
useEffect(() => () => { generation.current += 1; }, []);
const open = useCallback((sourcePaths: string[]) => {
const current = paramsRef.current;
if (!current.connectionId || current.isLocal || !current.isActive
|| !current.onCreateArchive || stateRef.current?.busy) return;
generation.current += 1;
const target = { paneId: current.paneId, connectionId: current.connectionId };
try {
const selection = prepareSftpArchiveSelection(sourcePaths);
update({
target,
directory: selection.directory,
paths: selection.paths,
archiveName: withSftpArchiveExtension(selection.defaultName, "tar.gz"),
format: "tar.gz",
busy: false,
error: null,
confirmOverwrite: false,
});
} catch {
update({
target, directory: "", paths: [], archiveName: "", format: "tar.gz",
busy: false, error: current.t("sftp.archive.invalidSelection"), confirmOverwrite: false,
});
}
}, [update]);
const close = useCallback(() => {
if (stateRef.current?.busy) return;
generation.current += 1;
update(null);
}, [update]);
const setArchiveName = useCallback((archiveName: string) => {
const current = stateRef.current;
if (!current || current.busy) return;
update({ ...current, archiveName, error: null, confirmOverwrite: false });
}, [update]);
const setFormat = useCallback((format: SftpArchiveFormat) => {
const current = stateRef.current;
if (!current || current.busy) return;
update({
...current, format,
archiveName: withSftpArchiveExtension(current.archiveName, format),
error: null, confirmOverwrite: false,
});
}, [update]);
const submit = useCallback(async (overwrite = false) => {
const current = stateRef.current;
const { onCreateArchive, t } = paramsRef.current;
if (!current || current.busy || !onCreateArchive || !matchesTarget(current.target)
|| current.paths.length === 0 || (overwrite && !current.confirmOverwrite)) return;
// Enter in the name field must not implicitly confirm replacement.
if (current.confirmOverwrite && !overwrite) return;
const validationError = validateSftpArchiveName(current.archiveName, current.format);
if (validationError) {
update({ ...current, error: t(validationError) });
return;
}
const requestGeneration = generation.current;
update({ ...current, busy: true, error: null });
try {
const result = await onCreateArchive({
directory: current.directory,
paths: current.paths,
archiveName: current.archiveName,
format: current.format,
overwrite,
}, current.target);
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
if (!result.success) {
update({
...current, busy: false,
confirmOverwrite: result.code === "EEXIST",
error: result.code === "EEXIST" ? null : result.error || t("sftp.archive.failed"),
});
return;
}
update(null);
paramsRef.current.onCreated(current.directory);
} catch (error) {
if (generation.current !== requestGeneration || !matchesTarget(current.target)) return;
update({
...current, busy: false, confirmOverwrite: false,
error: error instanceof Error ? error.message : String(error),
});
}
}, [matchesTarget, update]);
return {
state: state && matchesTarget(state.target) ? state : null,
open, close, setArchiveName, setFormat, submit,
};
}
+3 -42
View File
@@ -1326,13 +1326,6 @@ 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>;
},
) => {
@@ -1436,10 +1429,6 @@ 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"
@@ -1472,36 +1461,8 @@ 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],
{
@@ -1520,7 +1481,7 @@ export const useSftpTransfers = ({
const attentionTask: TransferTask = {
id: crypto.randomUUID(),
batchId,
fileName: nextFileName,
fileName: file.name,
originalFileName: file.name,
sourcePath: nextSourcePath,
targetPath: nextTargetPath,
@@ -1561,7 +1522,7 @@ export const useSftpTransfers = ({
newTasks.push({
id: taskId,
batchId,
fileName: nextFileName,
fileName: file.name,
originalFileName: file.name,
sourcePath: nextSourcePath,
targetPath: nextTargetPath,
@@ -36,12 +36,6 @@ 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[]>;
@@ -1,18 +0,0 @@
/** Install isolated browser storage for hook tests, including Node without Web Storage. */
export function installTestLocalStorage(): () => void {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: key => values.get(String(key)) ?? null,
key: index => [...values.keys()][index] ?? null,
removeItem: key => { values.delete(String(key)); },
setItem: (key, value) => { values.set(String(key), String(value)); },
};
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage });
return () => {
if (previous) Object.defineProperty(globalThis, 'localStorage', previous);
else Reflect.deleteProperty(globalThis, 'localStorage');
};
}
-2
View File
@@ -127,8 +127,6 @@ 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 }>;
-2
View File
@@ -1597,7 +1597,6 @@ export function useAIState() {
previousMemberTerminalIds: readonly string[];
currentMemberTerminalIds: readonly string[];
preferredTerminalId?: string | null;
activeTargetIds?: ReadonlySet<string>;
}) => {
const currentMap =
latestAIActiveSessionMapSnapshot
@@ -1613,7 +1612,6 @@ export function useAIState() {
previousMemberTerminalIds: input.previousMemberTerminalIds,
currentMemberTerminalIds: input.currentMemberTerminalIds,
preferredTerminalId: input.preferredTerminalId,
activeTargetIds: input.activeTargetIds,
});
if (!result.changed) return;
@@ -1,59 +0,0 @@
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,9 +4,6 @@ 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,9 +4,6 @@ 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>;
@@ -89,15 +86,6 @@ 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 {
@@ -1,92 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Workspace } from '../../domain/models';
import { collectSessionIds } from '../../domain/workspace';
import { moveWorkspacePaneInWorkspaces } from './useSessionState';
// Issue #4037: moving a member pane must rearrange only the tree shape.
// Session ids, workspace id, anchorChatId (AI binding), focus fields and tab
// order all live outside the touched surface, and these tests pin that.
const workspace = (id = 'ws-1'): Workspace => ({
id,
title: 'Workspace',
focusedSessionId: 's1',
focusSessionOrder: ['s1', 's2', 's3'],
anchorChatId: 'chat-anchor-1',
root: {
id: 'split-1',
type: 'split',
direction: 'vertical',
children: [
{ id: 'pane-1', type: 'pane', sessionId: 's1' },
{ id: 'pane-2', type: 'pane', sessionId: 's2' },
{ id: 'pane-3', type: 'pane', sessionId: 's3' },
],
sizes: [1, 1, 1],
},
});
test('moveWorkspacePaneInWorkspaces rearranges the tree and preserves every identity field', () => {
const ws = workspace();
const next = moveWorkspacePaneInWorkspaces([ws], 'ws-1', 's3', {
direction: 'vertical',
position: 'left',
targetSessionId: 's1',
});
assert.equal(next.length, 1);
const moved = next[0];
assert.equal(moved.id, ws.id, 'workspace id (AI binding key) unchanged');
assert.equal(moved.title, ws.title);
assert.equal(moved.anchorChatId, 'chat-anchor-1', 'anchorChatId unchanged');
assert.equal(moved.focusedSessionId, 's1', 'focus stays on the same session id');
assert.deepEqual(moved.focusSessionOrder, ['s1', 's2', 's3'], 'tab/focus order unchanged');
assert.deepEqual(collectSessionIds(moved.root), ['s3', 's1', 's2']);
assert.notEqual(moved.root, ws.root, 'the tree shape actually changed');
});
test('moveWorkspacePaneInWorkspaces leaves other workspaces and no-op cases untouched', () => {
const ws1 = workspace('ws-1');
const ws2 = workspace('ws-2');
// Same-reference no-op: pane dropped onto itself.
const selfDrop = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 's2', {
direction: 'vertical',
position: 'left',
targetSessionId: 's2',
});
assert.equal(selfDrop[0], ws1, 'no-op workspace keeps its reference');
assert.equal(selfDrop[1], ws2);
// Unknown session / foreign workspace id: nothing moves.
const ghost = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-1', 'ghost', {
direction: 'vertical',
position: 'left',
targetSessionId: 's1',
});
assert.equal(ghost[0], ws1);
assert.equal(ghost[1], ws2);
const foreign = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-other', 's1', {
direction: 'vertical',
position: 'left',
targetSessionId: 's2',
});
assert.equal(foreign[0], ws1);
assert.equal(foreign[1], ws2);
});
test('moveWorkspacePaneInWorkspaces only rewrites the targeted workspace entry', () => {
const ws1 = workspace('ws-1');
const ws2 = workspace('ws-2');
const next = moveWorkspacePaneInWorkspaces([ws1, ws2], 'ws-2', 's2', {
direction: 'vertical',
position: 'right',
targetSessionId: 's3',
});
assert.equal(next[0], ws1, 'sibling workspaces keep their references');
assert.notEqual(next[1], ws2);
assert.deepEqual(collectSessionIds(next[1].root), ['s1', 's3', 's2']);
});
+8 -68
View File
@@ -19,7 +19,6 @@ createWorkspaceFromSessionIds,
FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
movePaneInWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
setWorkspaceFocusedSessionId,
@@ -43,9 +42,8 @@ import {
import {
closeSessionsState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import { getAISessionsSnapshot } from './aiSessionsStore';
import { getPersistedAiSessionsSnapshot } from './aiStateSnapshots';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
@@ -124,26 +122,6 @@ export function appendWorkspaceRootPaneIfMissing(
});
}
/**
* Issue #4037: rearrange an existing member pane within its own workspace.
* Only `workspaces[].root` shape changes — session ids, tab order, focus
* fields and every AI binding (anchorChatId / activeSessionIdMap scopes) are
* untouched, because the moved session keeps its id and its workspaceId.
*/
export function moveWorkspacePaneInWorkspaces(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
hint: SplitHint,
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (!collectSessionIds(ws.root).includes(sessionId)) return ws;
const nextRoot = movePaneInWorkspace(ws.root, sessionId, hint);
return nextRoot === ws.root ? ws : { ...ws, root: nextRoot };
});
}
export function insertCopiedTabOrderIdOnce(
prevTabOrder: string[],
sourceTabId: string,
@@ -637,11 +615,6 @@ 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);
@@ -849,20 +822,6 @@ 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
@@ -1147,25 +1106,6 @@ 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,
@@ -1495,16 +1435,18 @@ 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) => {
if (!tabInsertionTarget) return prevTabOrder;
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
const allTabIds = [
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
@@ -1513,7 +1455,7 @@ export const useSessionState = ({
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
prevTabOrder,
replacedOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
@@ -1573,7 +1515,6 @@ export const useSessionState = ({
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
moveWorkspacePane,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
@@ -1582,7 +1523,6 @@ export const useSessionState = ({
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
setWorkspaceAnchorChat,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
@@ -4,10 +4,6 @@ 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>;
@@ -125,15 +121,6 @@ test("merged tab titles follow the workspace lifecycle until the user renames it
assert.equal(workspace(workspaceId).title, "Pinned");
assert.equal(workspace(workspaceId).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 () => {
+9 -48
View File
@@ -50,7 +50,6 @@ 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,
@@ -317,7 +316,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
scopeLabel,
focusedSessionId,
onSetWorkspaceAnchorChat,
workspaces = [],
terminalSessions = [],
liveScopeTargetIds,
@@ -446,16 +444,9 @@ 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, workspaceAnchorChatId, preferredHistorySessionId),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType, workspaceAnchorChatId, preferredHistorySessionId],
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
);
const activeSession = useMemo(
() => resolveDisplayedSession(normalizedPanelView, historySessions),
@@ -650,21 +641,6 @@ 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
@@ -672,23 +648,16 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (!shouldForceDraftViewSync(
explicitPanelView,
normalizedPanelView,
isSessionVisibleInCurrentScope,
(sessionId) => historySessions.some((session) => session.id === sessionId),
)) {
return;
}
showDraftView(scopeKey);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, isSessionVisibleInCurrentScope, showDraftView]);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, historySessions, 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);
}
@@ -696,24 +665,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
activeSession,
activeSessionIdMap,
scopeKey,
scopeType,
scopeTargetId,
isVisible,
onSetWorkspaceAnchorChat,
setActiveSessionId,
]);
useEffect(() => {
if (!isVisible) return;
if (normalizedPanelView.mode !== 'draft') return;
// 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;
// Keep ownership while an explicit session is still displayable in this
// scope (normalize has not demoted it). Out-of-scope / missing ids clear.
if (
explicitPanelView?.mode !== 'draft'
&& selectedSessionId
&& isSessionVisibleInCurrentScope(selectedSessionId)
explicitPanelView?.mode === 'session'
&& historySessions.some((session) => session.id === explicitPanelView.sessionId)
) {
return;
}
@@ -723,7 +686,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
isVisible,
normalizedPanelView.mode,
explicitPanelView,
isSessionVisibleInCurrentScope,
historySessions,
persistedSessionId,
setActiveSessionId,
]);
@@ -1825,8 +1788,6 @@ 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),
+2 -4
View File
@@ -109,10 +109,8 @@ export interface AIChatSidePanelProps {
scopeLabel?: string;
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
focusedSessionId?: 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 }>;
/** Live workspaces, used to resolve workspace titles for `/chats`. */
workspaces?: Array<{ id: string; title: string }>;
/** All currently live terminal (and workspace) scope target ids. */
liveScopeTargetIds?: Set<string>;
+1 -11
View File
@@ -35,7 +35,6 @@ import {
import {
initializePreparedConvergentMigration,
prepareConvergentSyncMigration,
resolvePreparedConvergentMigrationConflict,
type PreparedConvergentMigration,
} from '../application/convergentSyncMigration';
import type { ShrinkFinding } from '../domain/syncGuards';
@@ -359,15 +358,6 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
setConvergentBusy(true);
setConvergentError(null);
try {
if (preparedConvergentMigration && !sync.convergentSyncConfig.initialized) {
const prepared = resolvePreparedConvergentMigrationConflict(
preparedConvergentMigration, addressKey, candidateDot,
);
setPreparedConvergentMigration(prepared);
setConvergentPreview(prepared.plan.preview);
toast.success(t('cloudSync.convergent.conflict.resolved'));
return;
}
const { results } = await sync.resolveConvergentConflict(
addressKey,
candidateDot,
@@ -381,7 +371,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} finally {
setConvergentBusy(false);
}
}, [onApplyConvergentPayload, preparedConvergentMigration, sync, t]);
}, [onApplyConvergentPayload, sync, t]);
const handleDowngradeConvergent = useCallback(async () => {
if (!window.confirm(t('cloudSync.convergent.downgrade.confirm'))) return;
+4 -19
View File
@@ -49,13 +49,11 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
showAlgorithmOverrides,
setShowAlgorithmOverrides,
chainedHosts,
hostChainInherited = false,
setActiveSubPanel,
clearHostChain,
proxySummaryType,
proxySummaryLabel,
proxySummaryTooltip,
proxyInherited = false,
clearProxyConfig,
}) => {
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
@@ -643,16 +641,9 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
title={t("hostDetails.jumpHosts")}
action={
chainedHosts.length > 0 ? (
<div className="flex items-center gap-1">
{hostChainInherited && (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("vault.groups.details.inherited")}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
</div>
<Badge variant="secondary" className="text-xs">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.direct")}
@@ -673,10 +664,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
<span className="text-xs text-muted-foreground">
{t("hostDetails.jumpHosts.hops", { count: chainedHosts.length })}
{hostChainInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</div>
{!hostChainInherited && (
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
@@ -685,7 +674,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
clearHostChain();
}}
/>
)}
</div>
<div className="w-full space-y-1 pl-5">
{chainedHosts.slice(0, 5).map((h, idx) => (
@@ -747,7 +735,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<X size={14} />
</Button>
</div>
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId || proxyInherited ? (
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
@@ -762,7 +750,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{proxySummaryLabel}
{proxyInherited ? ` · ${t("vault.groups.details.inherited")}` : ""}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
@@ -771,7 +758,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</Tooltip>
</TooltipProvider>
</button>
{!proxyInherited && (
<Button
type="button"
variant="ghost"
@@ -782,7 +768,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
>
<X size={14} />
</Button>
)}
</div>
) : (
<Button
-67
View File
@@ -1,5 +1,4 @@
import type { GroupConfig } from "../domain/models";
import { applyGroupDefaults } from "../domain/groupConfig";
import {
findProxyProfile,
hasIncompleteProxyIdentity,
@@ -158,69 +157,3 @@ export const prepareProxyConfigForSave = (params: {
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
};
};
export const EMPTY_CUSTOM_PROXY_DRAFT: ProxyConfig = { type: "http", host: "", port: 8080 };
export const resolveDisplayProxyProfileId = (
form: Pick<Host, "directProxy" | "proxyProfileId" | "proxyConfig">,
inheritedProfileId?: string,
): string | undefined => {
if (form.directProxy || form.proxyConfig !== undefined) return form.proxyProfileId;
return form.proxyProfileId ?? inheritedProfileId;
};
export const nextFormAfterSelectProxyProfile = (
prev: Host,
profileId: string | undefined,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) {
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& groupDefaults
? applyGroupDefaults(prev, groupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: prev.proxyConfig ?? inheritedConfig ?? EMPTY_CUSTOM_PROXY_DRAFT,
} as Host;
}
return { ...rest, proxyProfileId: profileId } as Host;
};
export const resolveFormHostChainIds = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string[] => {
if (host.hostChain !== undefined) return host.hostChain.hostIds || [];
if (!groupDefaults) return [];
return applyGroupDefaults(host, groupDefaults).hostChain?.hostIds || [];
};
export const nextFormAfterClearHostChain = (
prev: Host,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const groupWouldRestore = Boolean(
groupDefaults
&& applyGroupDefaults({ ...prev, hostChain: undefined }, groupDefaults).hostChain?.hostIds?.length,
);
if (prev.hostChain === undefined || groupWouldRestore) {
return { ...prev, hostChain: { hostIds: [] } };
}
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
};
export const nextFormAfterRemoveHostFromChain = (
prev: Host,
index: number,
groupDefaults?: Partial<GroupConfig>,
): Host => {
const ids = resolveFormHostChainIds(prev, groupDefaults).filter((_, i) => i !== index);
if (ids.length > 0) return { ...prev, hostChain: { hostIds: ids } };
return nextFormAfterClearHostChain(prev, groupDefaults);
};
@@ -6,17 +6,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host, Identity } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { applyGroupDefaults } from "../domain/groupConfig.ts";
import {
prepareProxyConfigForSave,
prepareTelnetCredentialsForSave,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
validateProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
@@ -554,198 +549,3 @@ test("HostDetailsPanel expands notes when the host already has notes", () => {
"expected notes to render after connection settings",
);
});
const networkSshHost: Host = {
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
group: "network",
};
const jumpHost: Host = {
id: "jump-1",
label: "Bastion",
hostname: "bastion.example.com",
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
createdAt: 1,
};
const hostOwnedJump: Host = {
...jumpHost,
id: "jump-host",
label: "Edge",
hostname: "edge.example.com",
};
const renderHostDetailsWithGroup = (
initialData: Host,
groupConfig: Record<string, unknown>,
extraHosts: Host[] = [],
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: extraHosts,
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", ...groupConfig }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
test("HostDetailsPanel displays inherited group HTTP proxy", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /group-proxy\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host proxy override wins over the group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
proxyConfig: { type: "http", host: "host-proxy.example.com", port: 9090 },
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /host-proxy\.example\.com/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel direct proxy hides inherited group proxy", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
directProxy: true,
}, {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 8080 },
});
assert.match(markup, /Direct \(no proxy\)/);
assert.doesNotMatch(markup, /group-proxy\.example\.com/);
});
test("HostDetailsPanel displays inherited group jump hosts", () => {
const markup = renderHostDetailsWithGroup(networkSshHost, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost]);
assert.match(markup, /bastion\.example\.com/);
assert.match(markup, /Inherited from group/);
});
test("HostDetailsPanel host jump-host override wins over the group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: ["jump-host"] },
}, {
hostChain: { hostIds: ["jump-1"] },
}, [jumpHost, hostOwnedJump]);
assert.match(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("selecting Custom while inheriting a saved proxy keeps a custom draft instead of the profile", () => {
const group = { proxyProfileId: "office-proxy" };
const next = nextFormAfterSelectProxyProfile(networkSshHost, undefined, group);
assert.equal(next.proxyProfileId, undefined);
assert.equal(next.directProxy, undefined);
assert.deepEqual(next.proxyConfig, { type: "http", host: "", port: 8080 });
assert.equal(resolveDisplayProxyProfileId(next, "office-proxy"), undefined);
assert.equal(applyGroupDefaults(next, group).proxyProfileId, undefined);
});
test("removing the last hop of a multi-hop inherited chain keeps an empty override", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const afterFirst = nextFormAfterRemoveHostFromChain(networkSshHost, 0, group);
assert.deepEqual(afterFirst.hostChain, { hostIds: ["jump-host"] });
const afterLast = nextFormAfterRemoveHostFromChain(afterFirst, 0, group);
assert.deepEqual(afterLast.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(afterLast, group).hostChain, { hostIds: [] });
});
test("clearing an inherited-only chain keeps an empty override instead of restoring group hops", () => {
const group = { hostChain: { hostIds: ["jump-1", "jump-host"] } };
const cleared = nextFormAfterClearHostChain(networkSshHost, group);
assert.deepEqual(cleared.hostChain, { hostIds: [] });
assert.deepEqual(applyGroupDefaults(cleared, group).hostChain, { hostIds: [] });
});
test("HostDetailsPanel custom proxy draft hides the inherited saved proxy", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...networkSshHost,
proxyConfig: { type: "http", host: "", port: 8080 },
},
availableKeys: [],
identities: [],
proxyProfiles: [{
id: "office-proxy",
label: "Office Proxy",
config: { type: "http", host: "office-proxy.example.com", port: 8080 },
createdAt: 1,
}],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", proxyProfileId: "office-proxy" }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
assert.doesNotMatch(markup, /Office Proxy/);
assert.doesNotMatch(markup, /office-proxy\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
test("HostDetailsPanel empty jump-host override hides the inherited group chain", () => {
const markup = renderHostDetailsWithGroup({
...networkSshHost,
hostChain: { hostIds: [] },
}, {
hostChain: { hostIds: ["jump-1", "jump-host"] },
}, [jumpHost, hostOwnedJump]);
assert.doesNotMatch(markup, /bastion\.example\.com/);
assert.doesNotMatch(markup, /edge\.example\.com/);
assert.doesNotMatch(markup, /Inherited from group/);
});
+31 -52
View File
@@ -55,11 +55,6 @@ import {
resolvePrimaryProtocolSwitchPort,
prepareTelnetCredentialsForSave,
prepareProxyConfigForSave,
nextFormAfterSelectProxyProfile,
nextFormAfterClearHostChain,
nextFormAfterRemoveHostFromChain,
resolveFormHostChainIds,
resolveDisplayProxyProfileId,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
@@ -429,34 +424,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const hasHostProxyOverride = form.directProxy === true
|| form.proxyProfileId !== undefined
|| form.proxyConfig !== undefined;
const displayProxyProfileId = resolveDisplayProxyProfileId(form, effectiveAuthHost.proxyProfileId);
const displayProxyConfig = form.directProxy
? undefined
: (form.proxyConfig ?? effectiveAuthHost.proxyConfig);
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === displayProxyProfileId),
[displayProxyProfileId, proxyProfiles],
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(displayProxyProfileId && !selectedProxyProfile);
const proxyInherited = !hasHostProxyOverride && Boolean(
displayProxyProfileId || displayProxyConfig?.host || displayProxyConfig?.command,
) && Boolean(effectiveGroupDefaults?.proxyProfileId || effectiveGroupDefaults?.proxyConfig);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const proxySummaryType = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missing")
: formatProxyConfigType(selectedProxyProfile?.config || displayProxyConfig) || "HTTP";
: formatProxyConfigType(selectedProxyProfile?.config || form.proxyConfig) || "HTTP";
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: formatProxyConfigEndpoint(displayProxyConfig);
: formatProxyConfigEndpoint(form.proxyConfig);
const proxySummaryTooltip = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? `${selectedProxyProfile.label} - ${formatProxyConfigEndpoint(selectedProxyProfile.config)}`
: `${formatProxyConfigType(displayProxyConfig)} ${formatProxyConfigEndpoint(displayProxyConfig)}`;
: `${formatProxyConfigType(form.proxyConfig)} ${formatProxyConfigEndpoint(form.proxyConfig)}`;
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
@@ -473,19 +458,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
const inheritedConfig = !prev.directProxy
&& prev.proxyProfileId === undefined
&& prev.proxyConfig === undefined
&& effectiveGroupDefaults
? applyGroupDefaults(prev, effectiveGroupDefaults).proxyConfig
: undefined;
return {
...rest,
proxyConfig: updateProxyConfigField(prev.proxyConfig ?? inheritedConfig, field, value),
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
} as Host;
});
},
[effectiveGroupDefaults],
[],
);
const clearProxyConfig = useCallback(() => {
@@ -496,8 +475,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => nextFormAfterSelectProxyProfile(prev, profileId, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
const selectDirectProxy = useCallback(() => {
setForm((prev) => {
@@ -510,18 +493,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setForm((prev) => ({
...prev,
hostChain: {
hostIds: [...resolveFormHostChainIds(prev, effectiveGroupDefaults), hostId],
hostIds: [...(prev.hostChain?.hostIds || []), hostId],
},
}));
};
const removeHostFromChain = (index: number) => {
setForm((prev) => nextFormAfterRemoveHostFromChain(prev, index, effectiveGroupDefaults));
setForm((prev) => {
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
});
};
const clearHostChain = useCallback(() => {
setForm((prev) => nextFormAfterClearHostChain(prev, effectiveGroupDefaults));
}, [effectiveGroupDefaults]);
setForm((prev) => {
const { hostChain: _hostChain, ...rest } = prev;
return rest as Host;
});
}, []);
const [newEnvName, setNewEnvName] = useState("");
const [newEnvValue, setNewEnvValue] = useState("");
@@ -784,16 +773,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setActiveSubPanel("none");
};
const displayHostChainIds = useMemo(
() => form.hostChain !== undefined
? (form.hostChain.hostIds || [])
: (effectiveAuthHost.hostChain?.hostIds || []),
[effectiveAuthHost.hostChain?.hostIds, form.hostChain],
);
const hostChainInherited = form.hostChain === undefined && displayHostChainIds.length > 0;
const availableHostsForChain = useMemo(() => {
const chainedIds = new Set(displayHostChainIds);
const chainedIds = new Set(form.hostChain?.hostIds || []);
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
// entries (e.g. saved local shells) as jump hosts.
return allHosts.filter((h) =>
@@ -801,13 +782,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
&& !chainedIds.has(h.id)
&& canServeAsSshJumpHost(h),
);
}, [allHosts, displayHostChainIds, form.id]);
}, [allHosts, form.id, form.hostChain?.hostIds]);
const chainedHosts = useMemo(() => {
return displayHostChainIds
const ids = form.hostChain?.hostIds || [];
return ids
.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[];
}, [allHosts, displayHostChainIds]);
}, [allHosts, form.hostChain?.hostIds]);
const groupOptions: ComboboxOption[] = useMemo(() => {
return groups.map((g) => ({
@@ -923,12 +905,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
if (activeSubPanel === "proxy") {
return (
<ProxyPanel
proxyConfig={displayProxyConfig}
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={displayProxyProfileId}
selectedProxyProfileId={form.proxyProfileId}
directProxy={form.directProxy}
allowClear={hasHostProxyOverride}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onSelectDirectProxy={selectDirectProxy}
@@ -1256,13 +1237,11 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
chainedHosts={chainedHosts}
hostChainInherited={hostChainInherited}
setActiveSubPanel={setActiveSubPanel}
clearHostChain={clearHostChain}
proxySummaryType={proxySummaryType}
proxySummaryLabel={proxySummaryLabel}
proxySummaryTooltip={proxySummaryTooltip}
proxyInherited={proxyInherited}
clearProxyConfig={clearProxyConfig}
/>
@@ -1,229 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type { Identity, Host } from "../types";
async function withWizard(
run: (fixture: Awaited<ReturnType<typeof mountWizard>>) => Promise<void>,
identities: Identity[] = [],
) {
const fixture = await mountWizard(identities);
try {
await run(fixture);
} finally {
await fixture.cleanup();
}
}
async function mountWizard(identities: Identity[]) {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
url: "http://localhost", pretendToBeVisual: true,
});
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ["window", "document", "navigator", "HTMLElement", "HTMLInputElement", "Element", "Node", "MutationObserver", "Event", "CustomEvent", "MouseEvent", "KeyboardEvent", "FocusEvent"] as const) {
install(key, key === "window" ? dom.window : dom.window[key]);
}
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
install("IS_REACT_ACT_ENVIRONMENT", true);
const { act, useState } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import("../application/i18n/I18nProvider");
const { default: QuickConnectWizard } = await import("./QuickConnectWizard");
const target = { hostname: "example.com" };
const connected: Host[] = [];
const saved: Host[] = [];
let closeCount = 0;
function Fixture() {
const [open, setOpen] = useState(true);
return (
<I18nProvider locale="en">
{open && <QuickConnectWizard
open target={target} keys={[]} identities={identities}
onConnect={(host) => connected.push(host)}
onSaveHost={(host) => saved.push(host)}
onClose={() => { closeCount++; setOpen(false); }}
/>}
</I18nProvider>
);
}
const root = createRoot(document.getElementById("root")!);
await act(async () => { root.render(<Fixture />); });
const button = (name: string) => {
const element = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.getAttribute("aria-label") === name || candidate.textContent?.trim() === name);
assert.ok(element, `Button ${name} is present`);
return element;
};
const input = (selector: string) => {
const element = document.querySelector<HTMLInputElement>(selector);
assert.ok(element, `Input ${selector} is present`);
return element;
};
const overlay = () => {
const element = document.querySelector<HTMLElement>(".fixed.inset-0");
assert.ok(element, "Quick Connect is still open");
return element;
};
const click = async (element: HTMLElement) => {
await act(async () => { element.click(); });
};
const type = async (selector: string, value: string) => {
await act(async () => {
const element = input(selector);
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
element.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
});
assert.equal(input(selector).value, value);
};
const key = async (element: HTMLElement, key: string) => {
await act(async () => { element.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true })); });
};
const settle = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); };
const outsideClick = async () => {
await act(async () => {
overlay().dispatchEvent(new dom.window.MouseEvent("mousedown", { bubbles: true }));
overlay().click();
});
assert.equal(closeCount, 0, "Background clicks must not close Quick Connect");
overlay();
};
return {
button, input, overlay, click, type, key, settle, outsideClick, connected, saved,
closeCount: () => closeCount,
async cleanup() {
await act(async () => { root.unmount(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
},
};
}
test("background clicks preserve each Quick Connect step and typed connection details", async () => {
await withWizard(async (f) => {
await f.type('input[type="number"]', "2222");
await f.outsideClick();
assert.equal(f.input('input[type="number"]').value, "2222");
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
await f.outsideClick();
assert.equal(f.input("#quick-username").value, "alice");
await f.click(f.button("Continue"));
await f.type("#quick-password", "partly configured password");
await f.outsideClick();
await f.key(f.input("#quick-password"), "Escape");
assert.equal(f.closeCount(), 0);
assert.equal(f.input("#quick-password").value, "partly configured password");
await f.click(f.button("Back"));
assert.equal(f.input("#quick-username").value, "alice");
await f.click(f.button("Back"));
assert.equal(f.input('input[type="number"]').value, "2222");
await f.click(f.button("Continue"));
await f.click(f.button("Continue"));
assert.equal(f.input("#quick-password").value, "partly configured password");
await f.click(f.button("Continue"));
assert.equal(f.connected.length, 1);
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].port, 2222);
assert.equal(f.connected[0].password, "partly configured password");
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
});
});
test("the portaled credential picker retains the wizard and selected identity", async () => {
await withWizard(async (f) => {
await f.type('input[type="number"]', "2222");
await f.click(f.input('[role="combobox"]'));
await f.settle();
const option = document.querySelector<HTMLElement>('[role="option"]');
assert.ok(option);
assert.equal(f.overlay().contains(option), false, "Options are portaled outside the wizard DOM");
await f.click(option);
assert.equal(f.closeCount(), 0);
assert.equal(f.input('[role="combobox"]').value, "Production identity");
assert.equal(f.input('input[type="number"]').value, "2222");
await f.outsideClick();
await f.click(f.button("Connect with preset"));
assert.equal(f.connected.length, 1);
assert.equal(f.connected[0].identityId, "identity-alice");
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].port, 2222);
assert.equal(f.closeCount(), 1);
}, [{ id: "identity-alice", label: "Production identity", username: "alice", authMethod: "password", password: "preset secret", created: 1 }]);
});
test("the portaled save menu preserves credentials until explicit save and connect", async () => {
await withWizard(async (f) => {
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
await f.key(f.input("#quick-username"), "Enter");
await f.type("#quick-password", "secret");
const trigger = f.button("Continue & Save");
await f.click(trigger);
await f.settle();
const menuItem = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.textContent === "Continue & Save");
assert.ok(menuItem);
assert.equal(f.overlay().contains(menuItem), false, "Save menu is portaled outside the wizard DOM");
await f.click(menuItem.parentElement!);
assert.equal(f.closeCount(), 0);
assert.equal(f.input("#quick-password").value, "secret");
await f.key(document.body, "Escape");
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(f.closeCount(), 0);
await f.click(trigger);
await f.settle();
await f.outsideClick();
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(f.input("#quick-password").value, "secret");
await f.click(trigger);
await f.settle();
const save = [...document.querySelectorAll<HTMLButtonElement>("button")]
.find((candidate) => candidate.textContent === "Continue & Save");
assert.ok(save);
await f.click(save);
assert.equal(f.saved.length, 1);
assert.equal(f.connected.length, 1);
assert.equal(f.saved[0], f.connected[0]);
assert.equal(f.connected[0].username, "alice");
assert.equal(f.connected[0].password, "secret");
assert.equal(f.closeCount(), 1);
});
});
test("explicit Close dismisses the protocol step", async () => {
await withWizard(async (f) => {
await f.click(f.button("Close"));
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
assert.equal(f.connected.length, 0);
});
});
for (const step of ["username", "auth"]) {
test(`the header X explicitly closes the ${step} step`, async () => {
await withWizard(async (f) => {
await f.click(f.button("Continue"));
await f.type("#quick-username", "alice");
if (step === "auth") {
await f.click(f.button("Continue"));
await f.type("#quick-password", "secret");
}
const close = f.button("Close");
assert.ok(close.querySelector("svg.lucide-x"), "A visible X is provided on steps with Back");
await f.click(close);
assert.equal(f.closeCount(), 1);
assert.equal(document.querySelector("h2"), null);
assert.equal(f.connected.length, 0);
});
});
}
+2 -13
View File
@@ -8,7 +8,6 @@ import {
Plug,
Plus,
User,
X,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { createPortal } from "react-dom";
@@ -676,6 +675,7 @@ 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 flex items-center justify-between gap-4">
<div className="px-6 py-4 border-b border-border/50">
<div className="flex items-center gap-2 min-w-0">
<Plug size={18} className="shrink-0 text-foreground" />
<div className="min-w-0">
@@ -696,17 +696,6 @@ 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>
+1 -1
View File
@@ -759,7 +759,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
data-section="pane-magnification-hint"
>
{t('terminal.paneMagnification.hint')}: {t('terminal.layer.sftp')}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel}` : ''}
{paneMagnificationShortcutLabel ? ` · ${paneMagnificationShortcutLabel} / Esc` : ' · Esc'}
</div>
)}
</div>
+1 -7
View File
@@ -3536,8 +3536,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
options?: {
broadcast?: boolean;
multiLineRunMode?: Snippet["multiLineRunMode"];
/** Submit compose blocks through the shell's advertised paste support. */
preferBracketedPaste?: boolean;
/** When false, skip term.focus() so multi-tab fan-out does not steal focus. */
focus?: boolean;
/** Force sensitive classification on the write (bypassed password fan-out, #3488). */
@@ -3586,11 +3584,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
// Compose submits a command block, so keep it in the shell's input buffer
// even when clipboard bracketing is disabled. Otherwise a running command
// can consume the later lines as stdin (#4062). Still require shell support.
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode
&& (options?.preferBracketedPaste || !disableBracketedPasteRef.current)) {
if (!lineDelayMs && isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
data = wrapBracketedPaste(data);
}
if (!noAutoRun) data = `${data}\r`;
+1 -9
View File
@@ -245,14 +245,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onMoveWorkspacePane,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
onSetWorkspaceFocusedSession,
onSetWorkspaceAnchorChat,
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
@@ -2501,7 +2499,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (executor) {
pendingSends.push(Promise.resolve(executor(text, false, {
broadcast: false,
preferBracketedPaste: true,
// Preserve both prompt snapshots across an asynchronous wake: the
// peer may clear its prompt classification before the actual write.
...(focusedSensitive || recipientSensitive ? { sensitive: true } : {}),
@@ -2541,10 +2538,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sensitive = isTerminalSensitiveInputActive(targetId);
// Explicitly local: an omitted broadcast option falls back to the
// toolbar switch, which can still be on while compose is active-only.
pendingSends.push(Promise.resolve(executor(text, false, {
broadcast: false,
preferBracketedPaste: true,
})).then(
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
));
} else {
@@ -2714,7 +2708,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
systemMountedTabIds,
themeMountedTabIds,
onAddSessionToWorkspace,
onMoveWorkspacePane,
onConnectToHost,
onCreateLocalTerminal,
onCreateWorkspaceFromSessions,
@@ -2729,7 +2722,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
onSetWorkspaceAnchorChat,
onStartSessionRename,
onSubmitSessionRename,
onRemoveSessionFromWorkspace,
+2 -29
View File
@@ -15,13 +15,13 @@ import {
shouldForceDraftViewSync,
} from "./aiPanelViewState.ts";
function createSession(id: string, updatedAt = 1): AISession {
function createSession(id: string): AISession {
return {
id,
title: `Session ${id}`,
messages: [],
createdAt: 1,
updatedAt,
updatedAt: 1,
agentId: "catty",
scope: {
type: "terminal",
@@ -133,33 +133,6 @@ test("missing explicit panel view resumes the most recent matching history when
);
});
test("auto-resume prefers the anchored chat over recency and position (#4098 phase 2)", () => {
const sessions = [createSession("session-2", 9), createSession("session-1", 4)];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace", "session-1"),
{ mode: "session", sessionId: "session-1" },
);
});
test("auto-resume without an anchor picks the most recently updated chat, not sessions[0]", () => {
const sessions = [createSession("session-2", 4), createSession("session-1", 9)];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
{ mode: "session", sessionId: "session-1" },
);
});
test("a stale anchor that left the scope falls through to recency", () => {
const sessions = [createSession("session-2", 4), createSession("session-1", 9)];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace", "deleted-chat"),
{ mode: "session", sessionId: "session-1" },
);
});
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
+2 -35
View File
@@ -39,8 +39,6 @@ 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);
@@ -68,44 +66,13 @@ export function resolveDisplayedPanelView(
return DEFAULT_PANEL_VIEW;
}
// 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 };
if (sessions[0]) {
return { mode: "session", sessionId: sessions[0].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,7 +2,6 @@ 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,
@@ -24,49 +23,6 @@ function createSession(
};
}
test("workspace auto-resume keeps scope rank ahead of recency", () => {
const sessions = [
createSession("exact-older", { type: "workspace", targetId: "workspace-current" }, 1),
createSession("member-newer", { type: "terminal", targetId: "member" }, 2),
createSession("other-newest", { type: "workspace", targetId: "workspace-other" }, 20),
];
const history = getScopedHistorySessions(
sessions, "workspace", "workspace-current", [], new Set(), new Set(["member"]),
);
assert.deepEqual(history.map((session) => session.id), ["member-newer", "exact-older", "other-newest"]);
const preferredId = history[0]?.id ?? null;
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "workspace", undefined, preferredId),
{ mode: "session", sessionId: "member-newer" });
// The ranking only controls implicit fallback, never an explicit selection,
// saved selection, anchor, or deliberate new-chat draft.
for (const [view, persisted, anchor] of [
[{ mode: "session", sessionId: "other-newest" }, undefined, undefined],
[undefined, "other-newest", undefined],
[undefined, undefined, "other-newest"],
] as const) {
assert.deepEqual(resolveDisplayedPanelView(view, false, history, persisted, "workspace", anchor, preferredId),
{ mode: "session", sessionId: "other-newest" });
}
assert.deepEqual(resolveDisplayedPanelView({ mode: "draft" }, true, history, undefined, "workspace", undefined, preferredId),
{ mode: "draft" });
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "terminal", undefined, preferredId),
{ mode: "draft" });
});
test("workspace auto-resume prefers an exact match and retains history fallback when none exists", () => {
const exact = createSession("exact", { type: "workspace", targetId: "workspace-current" }, 1);
const others = [
createSession("other-old", { type: "workspace", targetId: "workspace-other" }, 5),
createSession("other-new", { type: "workspace", targetId: "workspace-other" }, 10),
];
for (const [sessions, expected] of [[others, "other-new"], [[...others, exact], "exact"], [[], undefined]] as const) {
const history = getScopedHistorySessions([...sessions], "workspace", "workspace-current", [], new Set());
assert.deepEqual(resolveDisplayedPanelView(undefined, false, history, undefined, "workspace", undefined, history[0]?.id ?? null),
expected ? { mode: "session", sessionId: expected } : { mode: "draft" });
}
});
test("workspace history remains visible after the original workspace target is gone", () => {
const staleWorkspaceSession = createSession(
"workspace-stale",
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { AsidePanel } from './ui/aside-panel';
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
async function fixture(t: test.TestContext, layout: 'inline' | 'overlay' = 'inline') {
const env = installDomEnvironment();
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: env.window.localStorage });
env.window.localStorage.setItem('desired-width', '720');
const renderer = await createDomRenderer(env.document);
const writes: number[] = [];
const setItem = env.window.Storage.prototype.setItem;
t.mock.method(env.window.Storage.prototype, 'setItem', function (this: Storage, key: string, value: string) {
if (key === 'desired-width') writes.push(Number(value));
setItem.call(this, key, value);
});
const render = (open = true) => renderer.render(<AsidePanel open={open} onClose={() => {}} title="Editor"
layout={layout} resizable persistWidthStorageKey="desired-width" resizeAriaLabel="Resize details"
dataSection="test-panel">Content</AsidePanel>);
await render();
const panel = env.document.querySelector<HTMLElement>('[data-section="test-panel"]')!;
// Browser layout is covered by native-matrix.mjs. Here the actual rendered
// border box is narrower than the remembered 720px preference.
panel.getBoundingClientRect = () => new env.window.DOMRect(320, 36, 378, 494);
const separator = panel.querySelector('[role="separator"]')!;
const pointer = (target: EventTarget, type: string, x: number) => dispatchDomEvent(target,
new env.window.MouseEvent(type, { bubbles: true, clientX: x }));
t.after(async () => {
await pointer(env.window, 'pointerup', 100);
await renderer.unmount();
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage);
else Reflect.deleteProperty(globalThis, 'localStorage');
env.cleanup();
});
return { writes, render, unmount: () => renderer.unmount(),
start: () => pointer(separator, 'pointerdown', 100),
move: (x: number) => pointer(env.window, 'pointermove', x),
up: (x: number) => pointer(env.window, 'pointerup', x),
cancel: (x: number) => pointer(env.window, 'pointercancel', x), env };
}
test('dragging a fitted inline panel starts at its visible edge, then remembers the explicit size', async (t) => {
const f = await fixture(t);
await f.start(); await f.move(120); await f.up(120);
assert.deepEqual(f.writes, [358], '20px inward from visible 378px must remember 358px, without a jump from 720px');
});
test('pressing a fitted resize handle without dragging preserves the larger desired width', async (t) => {
const f = await fixture(t);
assert.deepEqual(f.writes, [], 'mount must not persist');
await f.start(); await f.up(100);
assert.deepEqual(f.writes, [], 'a click must not replace the remembered desired width');
});
test('canceling a resize leaves the remembered width unchanged and restores pointer styles', async (t) => {
const f = await fixture(t);
await f.start(); await f.move(120); await f.cancel(120); await f.up(120);
assert.deepEqual(f.writes, []);
assert.equal(f.env.document.body.style.cursor, '');
assert.equal(f.env.document.body.style.userSelect, '');
});
for (const mode of ['hidden', 'unmounted'] as const) {
test(`${mode} panels do not persist a viewport clamp or a stale drag`, async (t) => {
const f = await fixture(t);
await f.start(); await f.move(120);
if (mode === 'hidden') await f.render(false); else await f.unmount();
await f.up(120);
assert.deepEqual(f.writes, []);
assert.equal(f.env.document.body.style.cursor, '');
assert.equal(f.env.document.body.style.userSelect, '');
});
}
test('overlay resizing retains its existing desired-width behavior', async (t) => {
const f = await fixture(t, 'overlay');
await f.start(); await f.move(120); await f.up(120);
assert.deepEqual(f.writes, [700]);
});
@@ -4,64 +4,12 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { ConvergentSyncPanel } from './ConvergentSyncPanel.tsx';
import type { ConvergentMigrationPreview } from '../../domain/sync.ts';
const t = (key: string, values?: Record<string, string | number>) =>
key === 'cloudSync.convergent.conflict.secretSet'
? 'SECRET_SET'
: `${key}${values?.count === undefined ? '' : `:${values.count}`}`;
test('uninitialized migration preview shows conflict choices and disables confirmation', () => {
const preview: ConvergentMigrationPreview = {
schemaVersion: 2,
canInitialize: false,
entityCounts: { hosts: 1 },
settingsLeafCount: 0,
conflictCount: 1,
conflicts: [{
address: { kind: 'entity-field', collection: 'hosts', entityId: 'host-1', field: 'label' },
candidates: ['Left', 'Right'].map((value, index) => ({
dot: { deviceId: value, counter: 1 },
hlc: { wallTime: index + 1, logical: 0 },
tombstone: false,
value,
selected: index === 0,
})),
}],
shrinkFindings: [],
providers: [],
oldClientCompatibility: 'materialized-v1-snapshot',
blockedReasons: ['The convergent state contains unresolved field conflicts'],
};
const renderPreview = (value: ConvergentMigrationPreview) => renderToStaticMarkup(
<ConvergentSyncPanel
t={t}
resolvedLocale="en"
config={{ enabled: false, initialized: false }}
preview={value}
busy={false}
error={null}
conflicts={[]}
onToggle={() => {}}
onConfirmMigration={() => {}}
onCancelMigration={() => {}}
onResolveConflict={() => {}}
onDowngrade={() => {}}
/>,
);
const markup = renderPreview(preview);
assert.match(markup, /cloudSync\.convergent\.conflicts\.title:1/);
assert.match(markup, /hosts\/host-1/);
assert.equal((markup.match(/cloudSync\.convergent\.conflict\.choose/g) ?? []).length, 2);
assert.match(markup, /<button[^>]*disabled=""[^>]*>cloudSync\.convergent\.preview\.confirm<\/button>/);
const resolvedMarkup = renderPreview({
...preview, conflicts: [], conflictCount: 0, blockedReasons: [], canInitialize: true,
});
assert.doesNotMatch(resolvedMarkup, /cloudSync\.convergent\.conflict\.choose/);
assert.doesNotMatch(resolvedMarkup, /<button[^>]*disabled=""[^>]*>cloudSync\.convergent\.preview\.confirm<\/button>/);
});
test('secret conflict candidates never render their value', () => {
const markup = renderToStaticMarkup(
<ConvergentSyncPanel
@@ -79,9 +79,7 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
onCancelMigration,
onResolveConflict,
onDowngrade,
}) => {
const conflictsToShow = config.initialized ? conflicts : (preview?.conflicts ?? []);
return (
}) => (
<div className="space-y-4 rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
@@ -178,13 +176,13 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
</div>
)}
{conflictsToShow.length > 0 && (
{config.initialized && conflicts.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('cloudSync.convergent.conflicts.title', { count: conflictsToShow.length })}
{t('cloudSync.convergent.conflicts.title', { count: conflicts.length })}
</div>
<div className="space-y-2">
{conflictsToShow.map((conflict) => {
{conflicts.map((conflict) => {
const addressKey = convergentConflictAddressKey(conflict.address);
return (
<div key={addressKey} className="space-y-2 rounded-md bg-muted/40 p-3">
@@ -232,5 +230,4 @@ export const ConvergentSyncPanel: React.FC<ConvergentSyncPanelProps> = ({
</div>
)}
</div>
);
};
);
+1 -3
View File
@@ -34,7 +34,6 @@ export interface ProxyPanelProps {
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
allowClear?: boolean;
layout?: AsidePanelLayout;
}
@@ -52,7 +51,6 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
onClearProxy,
onBack,
onCancel,
allowClear = true,
layout = 'overlay',
resizable,
persistWidthStorageKey,
@@ -348,7 +346,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</>
)}
{allowClear && (proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>
+2 -2
View File
@@ -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 = 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
const sourceLabel = t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
const sourceDescription = 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;
@@ -1,132 +0,0 @@
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();
}
});
@@ -1,58 +0,0 @@
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,7 +30,6 @@ 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,5 +1,4 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { PluginGitHubInstall } from './PluginGitHubInstall';
import { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
@@ -36,7 +35,6 @@ 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,13 +70,11 @@ 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.ok(document.activeElement === trigger, "focus returns to the dialog opener");
assert.equal(document.activeElement, trigger);
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
await click('Configure privacy');
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
-114
View File
@@ -1,114 +0,0 @@
import assert from "node:assert/strict";
import test, { after } from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { createEmptyPane } from "../../application/state/sftp/types";
import type { SftpFileEntry } from "../../types";
import { ContextMenuContent } from "../ui/context-menu";
import { TooltipProvider } from "../ui/tooltip";
import { installDomEnvironment } from "../test-support/renderReactDom";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { useSftpPaneTreeContextMenu } from "./useSftpPaneTreeContextMenu";
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
type MenuItem = React.ReactElement<{ children?: React.ReactNode; onClick: () => void }>;
function archiveAction(children: React.ReactNode) {
return React.Children.toArray(children).find((child): child is MenuItem =>
React.isValidElement<{ children?: React.ReactNode }>(child)
&& React.Children.toArray(child.props.children).includes("sftp.archive.action"),
);
}
const folder: SftpFileEntry = { name: "assets", type: "directory", size: 0, sizeFormatted: "0 B", lastModified: 0, lastModifiedFormatted: "" };
const file: SftpFileEntry = { name: "config.json", type: "file", size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" };
test("list archive menu includes files and folders and respects the clicked selection", async (t) => {
const dom = installDomEnvironment();
const noop = () => {};
const captured: string[][] = [];
let props: React.ComponentProps<typeof SftpPaneFileList> = {
t: (key) => key,
pane: {
...createEmptyPane("pane-archive"),
connection: { id: "conn", hostId: "host", hostLabel: "Host", isLocal: false, status: "connected", currentPath: "/srv/site" },
selectedFiles: new Set([folder.name, file.name]), files: [folder, file],
},
side: "left", isPaneFocused: true,
sorting: {
sortField: "name", sortOrder: "asc", directoriesFirst: true,
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
visibleColumns: { name: true, modified: true, size: true, type: true, owner: true },
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop,
},
fileListRef: { current: null }, handleFileListScroll: noop,
shouldVirtualize: false, totalHeight: 0, sortedDisplayFiles: [folder, file],
isDragOverPane: false, draggedFiles: null, onRefresh: noop, onNavigateTo: noop, onClearSelection: noop,
setShowNewFolderDialog: noop, setShowNewFileDialog: noop, getNextUntitledName: () => "", setNewFileName: noop, setFileNameError: noop,
dragOverEntry: null, handleRowSelect: noop, handleRowOpen: noop, handleFileDragStart: noop,
onDragEnd: noop, handleEntryDragOver: noop, handleRowDragLeave: noop, handleEntryDrop: noop,
onCopyToOtherPane: noop, onMoveEntriesToPath: async () => {}, openRenameDialog: noop, openDeleteConfirm: noop,
onCreateArchive: (paths) => { captured.push(paths); }, rowHeight: 28, visibleRows: [],
};
const render = () => React.createElement(TooltipProvider, null, React.createElement(SftpPaneFileList, props));
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(render()); });
t.after(async () => {
await act(async () => { renderer.unmount(); });
dom.cleanup();
environment.IS_REACT_ACT_ENVIRONMENT = true;
});
const actions = () => renderer.root.findAllByType(ContextMenuContent)
.map((menu) => archiveAction(menu.props.children)).filter((item): item is MenuItem => !!item);
assert.equal(actions().length, 2, "both a folder and a file offer compression");
actions()[0].props.onClick();
assert.deepEqual(captured, [["/srv/site/assets", "/srv/site/config.json"]]);
props = { ...props, pane: { ...props.pane, selectedFiles: new Set([folder.name]) } };
await act(async () => { renderer.update(render()); });
actions()[1].props.onClick();
assert.deepEqual(captured[1], ["/srv/site/config.json"], "right-clicking an unselected item does not include the previous selection");
props = { ...props, isLocal: true, pane: { ...props.pane, connection: { ...props.pane.connection!, isLocal: true } } };
await act(async () => { renderer.update(render()); });
assert.equal(actions().length, 0, "remote archive commands are not exposed on a local pane");
});
test("tree archive menu passes full paths for nested multiselection and hides local and parent entries", async (t) => {
const captured: string[][] = [];
const selected = new Set(["/srv/site/assets", "/srv/site/assets/logo.png", "/srv/config.json"]);
const emptyRef = { current: undefined };
let props: Parameters<typeof useSftpPaneTreeContextMenu>[0] = {
contextTarget: { entry: folder, entryPath: "/srv/site/assets" },
pane: { connection: { id: "conn", isLocal: false } },
tRef: { current: (key: string) => key },
onOpenFileWithSystemDefaultRef: emptyRef, onOpenFileWithRef: emptyRef,
onEditFileRef: emptyRef, onDownloadFileRef: emptyRef,
onExtractArchiveRef: emptyRef, onEditPermissionsRef: emptyRef, onDuplicateEntriesRef: emptyRef,
onCreateArchiveRef: { current: (paths: string[]) => { captured.push(paths); } },
getActionPaths: (path: string) => selected.has(path) ? [...selected] : [path],
};
let menu: React.ReactNode;
function Probe() {
menu = useSftpPaneTreeContextMenu(props);
return null;
}
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
t.after(async () => { await act(async () => { renderer.unmount(); }); });
const action = () => archiveAction((menu as React.ReactElement<{ children: React.ReactNode }>).props.children);
assert.ok(action());
action()!.props.onClick();
assert.deepEqual(captured[0], [...selected]);
props = { ...props, contextTarget: { entry: file, entryPath: "/srv/unselected.json" } };
await act(async () => { renderer.update(React.createElement(Probe)); });
action()!.props.onClick();
assert.deepEqual(captured[1], ["/srv/unselected.json"]);
props = { ...props, pane: { connection: { id: "local", isLocal: true } } };
await act(async () => { renderer.update(React.createElement(Probe)); });
assert.equal(action(), undefined);
props = { ...props, pane: { connection: { id: "conn", isLocal: false } }, contextTarget: { entry: { ...folder, name: ".." }, entryPath: "/srv" } };
await act(async () => { renderer.update(React.createElement(Probe)); });
assert.equal(action(), undefined);
});
-4
View File
@@ -8,7 +8,6 @@
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";
@@ -54,8 +53,6 @@ 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
@@ -66,7 +63,6 @@ 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).
@@ -1,69 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import type { SftpArchiveDialogState } from "../../application/state/sftp/useSftpArchiveDialog";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "../test-support/renderReactDom";
test("archive dialog focuses its name and requires explicit overwrite activation", async (t) => {
const env = installDomEnvironment();
const globals = {
MutationObserver: globalThis.MutationObserver,
NodeFilter: globalThis.NodeFilter,
HTMLInputElement: globalThis.HTMLInputElement,
DocumentFragment: globalThis.DocumentFragment,
};
for (const key of Object.keys(globals) as Array<keyof typeof globals>) {
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: env.window[key] });
}
const { SftpCreateArchiveDialog } = await import("./SftpCreateArchiveDialog");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 20));
for (const [key, value] of Object.entries(globals)) {
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
env.cleanup();
});
let state: SftpArchiveDialogState = {
target: { paneId: "pane-1", connectionId: "conn-1" },
directory: "/srv", paths: ["site"], archiveName: "site.tar.gz", format: "tar.gz",
busy: false, error: null, confirmOverwrite: false,
};
const submissions: Array<boolean | undefined> = [];
let closes = 0;
const render = () => renderer.render(React.createElement(SftpCreateArchiveDialog, {
state,
t: (key) => key,
onClose: () => { closes += 1; },
onNameChange: () => {},
onFormatChange: () => {},
onSubmit: async (overwrite) => { submissions.push(overwrite); },
}));
await render();
await flushEffects();
const input = env.document.querySelector("input");
assert.ok(input);
assert.equal(env.document.activeElement, input);
assert.equal(input.value, "site.tar.gz");
assert.ok(env.document.querySelector("label[for='" + input.id + "']"));
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }));
assert.deepEqual(submissions, [], "confirming an IME candidate must not create an archive");
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
assert.deepEqual(submissions, [undefined]);
state = { ...state, confirmOverwrite: true };
await render();
assert.match(env.document.querySelector('[role="alert"]')?.textContent ?? "", /sftp.archive.overwriteDescription/);
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
assert.deepEqual(submissions, [undefined], "Enter in the name field does not replace an existing archive");
const overwrite = [...env.document.querySelectorAll("button")].find((button) => button.textContent === "sftp.archive.overwrite");
assert.ok(overwrite);
await dispatchDomEvent(overwrite, new env.window.MouseEvent("click", { bubbles: true }));
assert.deepEqual(submissions, [undefined, true]);
state = { ...state, busy: true };
await render();
assert.equal(input.disabled, true);
assert.ok([...env.document.querySelectorAll("button")].filter((button) => button.textContent === "common.cancel" || button.textContent === "sftp.archive.creating").every((button) => button.disabled));
await dispatchDomEvent(input, new env.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
assert.equal(closes, 0);
});
-111
View File
@@ -1,111 +0,0 @@
import React, { useId, useRef } from "react";
import { Loader2 } from "lucide-react";
import type { SftpArchiveDialogState } from "../../application/state/sftp/useSftpArchiveDialog";
import type { SftpArchiveFormat } from "../../domain/sftpArchiveCreation";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
interface SftpCreateArchiveDialogProps {
state: SftpArchiveDialogState | null;
t: (key: string, params?: Record<string, unknown>) => string;
onClose: () => void;
onNameChange: (name: string) => void;
onFormatChange: (format: SftpArchiveFormat) => void;
onSubmit: (overwrite?: boolean) => Promise<void>;
}
export function SftpCreateArchiveDialog({
state, t, onClose, onNameChange, onFormatChange, onSubmit,
}: SftpCreateArchiveDialogProps) {
const id = useId();
const nameRef = useRef<HTMLInputElement>(null);
return (
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent
className="sm:max-w-md"
hideCloseButton={state?.busy}
aria-describedby={`${id}-description`}
onOpenAutoFocus={(event) => {
event.preventDefault();
nameRef.current?.focus();
nameRef.current?.select();
}}
onEscapeKeyDown={(event) => { if (state?.busy) event.preventDefault(); }}
onInteractOutside={(event) => { if (state?.busy) event.preventDefault(); }}
>
<DialogHeader>
<DialogTitle>{t("sftp.archive.title")}</DialogTitle>
<DialogDescription>
<span id={`${id}-description`}>{t("sftp.archive.description", { count: state?.paths.length ?? 0 })}</span>
</DialogDescription>
</DialogHeader>
{state && (
<>
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("sftp.archive.name")}</Label>
<Input
ref={nameRef}
id={`${id}-name`}
value={state.archiveName}
disabled={state.busy}
aria-invalid={!!state.error}
aria-describedby={state.error ? `${id}-error` : undefined}
onChange={(event) => onNameChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing && !state.confirmOverwrite) {
event.preventDefault();
void onSubmit();
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-format`}>{t("sftp.archive.format")}</Label>
<Select
value={state.format}
disabled={state.busy}
onValueChange={(value) => onFormatChange(value as SftpArchiveFormat)}
>
<SelectTrigger id={`${id}-format`}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tar.gz">TAR.GZ</SelectItem>
<SelectItem value="zip">ZIP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div>{t("sftp.archive.destination")}</div>
<div className="break-all font-mono">{state.directory}</div>
</div>
{state.error && <p id={`${id}-error`} role="alert" className="text-xs text-destructive">{state.error}</p>}
{state.confirmOverwrite && (
<div role="alert" className="space-y-1 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p className="font-medium">{t("sftp.archive.overwriteTitle")}</p>
<p className="break-words text-muted-foreground">
{t("sftp.archive.overwriteDescription", { name: state.archiveName })}
</p>
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" disabled={state.busy} onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant={state.confirmOverwrite ? "destructive" : "default"}
disabled={state.busy || !state.archiveName.trim() || state.paths.length === 0}
onClick={() => void onSubmit(state.confirmOverwrite)}
>
{state.busy && <Loader2 size={14} className="mr-2 animate-spin" />}
{t(state.busy ? "sftp.archive.creating" : state.confirmOverwrite ? "sftp.archive.overwrite" : "sftp.archive.create")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
+18 -46
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, CopyPlus, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { AppWindow, Archive, ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug, Upload } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -61,7 +61,6 @@ 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;
@@ -69,7 +68,6 @@ 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;
@@ -153,7 +151,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
handleRowDragLeave,
handleEntryDrop,
onCopyToOtherPane,
onDuplicateEntries,
onMoveEntriesToPath,
onOpenFileWithSystemDefault,
onOpenFileWith,
@@ -161,7 +158,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onCreateArchive,
onEditPermissions,
onUploadExternalFileList,
onUploadExternalFolder,
@@ -292,23 +288,6 @@ 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>
@@ -397,32 +376,29 @@ 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={() => onCopyToOtherPane(getSelectedTransferSources(entry))}
onClick={() => {
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected)
: [entry.name];
const fileData = files.map((name) => {
const fileName = String(name);
const file = filesByName.get(fileName);
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
};
});
onCopyToOtherPane(fileData);
}}
>
<Copy size={14} className="mr-2" />{" "}
{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));
@@ -521,15 +497,11 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
handleRowSelect,
dragOverEntry,
isPaneFocused,
isLocal,
onCopyToOtherPane,
onDuplicateEntries,
getSelectedTransferSources,
onMoveEntriesToPath,
onDownloadFile,
onDownloadFiles,
onExtractArchive,
onCreateArchive,
onDragEnd,
onEditFile,
onEditPermissions,
+5 -129
View File
@@ -5,19 +5,14 @@ import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { TreeNode } from "./SftpPaneTreeNode.tsx";
import { SftpPaneTreeView, getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
import { 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", () => {
@@ -26,7 +21,7 @@ test("tree activation enters directories and keeps file opening separate", () =>
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
});
test("tree double clicks open entries while the chevron only toggles expansion", async () => {
test("tree double clicks preserve folder, parent, file and arrow actions", async () => {
const dom = new JSDOM('<div id="root"></div>');
const keys = ["window", "document", "IS_REACT_ACT_ENVIRONMENT"] as const;
const originals = keys.map(key => Object.getOwnPropertyDescriptor(globalThis, key));
@@ -43,11 +38,7 @@ test("tree double clicks open entries while the chevron only toggles expansion",
columnTemplate: "1fr", visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
isSelected: false, isExpanded: false, isLoading, isDragOver: false,
onToggleExpand: () => calls.push("toggle"), onNodeClick: () => calls.push("select"),
onOpenEntry: (opened, path) => {
assert.equal(opened, item);
assert.equal(path, `/root/${item.name}`);
calls.push("open");
}, onContextMenu: () => calls.push("menu"),
onOpenEntry: () => calls.push("open"), onContextMenu: () => calls.push("menu"),
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
})));
calls.length = 0;
@@ -60,18 +51,14 @@ test("tree double clicks open entries while the chevron only toggles expansion",
try {
await render(entry("docs", "directory"));
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
assert.deepEqual(calls, ["select", "select", "open"]);
assert.deepEqual(calls, ["select", "select", "toggle"]);
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"),
{ ...entry("linked-docs", "symlink"), linkTarget: "directory" as const },
{ ...entry("linked-file", "symlink"), linkTarget: "file" as const },
]) {
for (const item of [entry("..", "directory"), entry("notes.txt", "file")]) {
await render(item);
await doubleClick(container.querySelector('[data-section]')!);
assert.deepEqual(calls, ["select", "select", "open"]);
@@ -86,114 +73,3 @@ test("tree double clicks open entries while the chevron only toggles expansion",
});
}
});
test("tree view routes double clicks to navigation and preserves selection, expansion and Enter", async () => {
const dom = installDomEnvironment();
const previousObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver");
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class { observe() {} disconnect() {} },
});
dom.window.requestAnimationFrame = callback => dom.window.setTimeout(() => callback(0), 0);
dom.window.cancelAnimationFrame = id => dom.window.clearTimeout(id);
const renderer = await createDomRenderer(dom.document);
const noop = () => {};
const calls: string[] = [];
const paneId = "tree-activation";
const directoryLink: SftpFileEntry = { ...entry("linked-docs", "symlink"), linkTarget: "directory" };
const fileLink: SftpFileEntry = { ...entry("linked-file", "symlink"), linkTarget: "file" };
const files = [entry("docs", "directory"), entry("notes.txt", "file"), directoryLink, fileLink];
let props: React.ComponentProps<typeof SftpPaneTreeView> = {
pane: { ...createEmptyPane(paneId), files,
connection: { id: "conn", hostId: "host", hostLabel: "Host", isLocal: false, status: "connected", currentPath: "/root" } },
side: "left", onPrepareSelection: noop,
onLoadChildren: async path => { calls.push(`load:${path}`); return [entry("nested", "directory")]; },
onMoveEntriesToPath: async () => {},
onNavigateUp: () => calls.push("up"),
onNavigateTo: path => calls.push(`navigate:${path}`),
onOpenEntry: (item, path) => { assert.equal(item, files.find(file => file.name === item.name)); calls.push(`open:${path}`); },
onRefresh: noop, onDragStart: noop, onDragEnd: noop, openRenameDialog: noop, openDeleteConfirm: noop,
onCopyToOtherPane: noop, onReceiveFromOtherPane: noop, draggedFiles: null,
openNewFolderDialog: noop, openNewFileDialog: noop,
sorting: { sortField: "name", sortOrder: "asc", directoriesFirst: true,
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop },
reloadRequest: { token: 0 },
};
const row = (name: string) => {
const element = renderer.container.querySelector(`[data-entry-name="${name}"]`);
assert.ok(element, `missing row ${name}`);
return element;
};
const click = async (target: Element, type = "click", detail = 1, ctrlKey = false) => {
await act(async () => { target.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, detail, ctrlKey })); });
};
const doubleClick = async (target: Element) => {
await click(target);
await click(target, "click", 2);
await click(target, "dblclick", 2);
};
const enter = async () => {
await act(async () => { dom.document.activeElement!.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); });
};
try {
await renderer.render(React.createElement(SftpPaneTreeView, props));
await click(row("docs"));
assert.deepEqual(calls, []);
assert.equal(row("docs").getAttribute("data-selected"), "true");
await click(row("notes.txt"), "click", 1, true);
assert.equal(row("docs").getAttribute("data-selected"), "true");
assert.equal(row("notes.txt").getAttribute("data-selected"), "true");
await doubleClick(row("docs").querySelector("span.truncate")!);
assert.deepEqual(calls.splice(0), ["navigate:/root/docs"]);
assert.equal(row("docs").getAttribute("data-expanded"), "false");
assert.equal(row("docs").getAttribute("data-selected"), "true");
assert.equal(row("notes.txt").getAttribute("data-selected"), "false");
await click(row("docs").querySelector(".lucide-chevron-right")!);
assert.deepEqual(calls.splice(0), ["load:/root/docs"]);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
row("nested");
await doubleClick(row("docs").querySelector(".lucide-chevron-right")!);
assert.deepEqual(calls, []);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
assert.equal(row("docs").getAttribute("data-selected"), "true");
await doubleClick(row("nested"));
assert.deepEqual(calls.splice(0), ["navigate:/root/docs/nested"]);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
await doubleClick(row("docs"));
assert.deepEqual(calls.splice(0), ["navigate:/root/docs"]);
assert.equal(row("docs").getAttribute("data-expanded"), "true");
await enter();
assert.equal(row("docs").getAttribute("data-expanded"), "false");
await enter();
assert.equal(row("docs").getAttribute("data-expanded"), "true");
assert.deepEqual(calls, []);
for (const [name, action] of [["notes.txt", "open:/root/notes.txt"], ["linked-docs", "navigate:/root/linked-docs"], ["linked-file", "open:/root/linked-file"], ["..", "up"]]) {
await doubleClick(row(name));
assert.deepEqual(calls.splice(0), [action]);
}
await click(row("notes.txt"));
await enter();
assert.deepEqual(calls.splice(0), ["open:/root/notes.txt"]);
// The application commits navigation by updating the pane root. Keep its
// existing reset behavior for cached children, expansion and selection.
props = { ...props, pane: { ...props.pane, files: [entry("inside", "file")],
connection: { ...props.pane.connection!, currentPath: "/root/docs" } } };
await renderer.render(React.createElement(SftpPaneTreeView, props));
row("inside");
assert.equal(renderer.container.querySelector('[data-entry-name="nested"]'), null);
assert.equal(sftpTreeSelectionStore.getPaneState(paneId).selectedPaths.size, 0);
} finally {
await renderer.unmount();
sftpTreeSelectionStore.clearPane(paneId);
if (previousObserver) Object.defineProperty(globalThis, "ResizeObserver", previousObserver);
else Reflect.deleteProperty(globalThis, "ResizeObserver");
dom.cleanup();
}
});
+7 -8
View File
@@ -67,16 +67,15 @@ export const TreeNode = React.memo<TreeNodeProps>(({
)}
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
onClick={e => onNodeClick(entry, entryPath, e)}
onDoubleClick={() => onOpenEntry(entry, entryPath)}
onDoubleClick={() => {
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
if (isDir) void onToggleExpand(entry, entryPath);
else onOpenEntry(entry, entryPath);
}}
onContextMenu={e => {
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;
if (!isParentEntry) {
onContextMenu(entry, entryPath, e);
}
onContextMenu(entry, entryPath, e);
}}
draggable={!isParentEntry}
onDragStart={e => { if (!isParentEntry) onDragStart(entry, entryPath, isDir, e); }}
-8
View File
@@ -50,14 +50,12 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
openRenameDialog,
openDeleteConfirm,
onCopyToOtherPane,
onDuplicateEntries,
onReceiveFromOtherPane,
onOpenFileWithSystemDefault,
onOpenFileWith,
onEditFile,
onDownloadFile,
onExtractArchive,
onCreateArchive,
onEditPermissions,
draggedFiles,
openNewFolderDialog,
@@ -205,8 +203,6 @@ 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);
@@ -219,8 +215,6 @@ 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);
@@ -944,14 +938,12 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
setShowMoveToDialog,
tRef,
onCopyToOtherPaneRef,
onDuplicateEntriesRef,
onNavigateToRef,
onOpenFileWithSystemDefaultRef,
onOpenFileWithRef,
onEditFileRef,
onDownloadFileRef,
onExtractArchiveRef,
onCreateArchiveRef,
onEditPermissionsRef,
openDeleteConfirmRef,
openRenameDialogRef,
@@ -18,14 +18,12 @@ 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;
-25
View File
@@ -5,8 +5,6 @@ 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";
@@ -155,16 +153,6 @@ 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,
@@ -648,14 +636,12 @@ 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}
@@ -701,7 +687,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onDuplicateEntries={callbacks.onDuplicateEntries}
onMoveEntriesToPath={handleMoveEntriesToPath}
onOpenFileWithSystemDefault={callbacks.onOpenFileWithSystemDefault}
onOpenFileWith={callbacks.onOpenFileWith}
@@ -709,7 +694,6 @@ 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}
@@ -722,15 +706,6 @@ 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}
@@ -1,53 +0,0 @@
/** @test issue #4132 — the tree's ".." row must not be offered as a Duplicate target */
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { TreeNode } from "./SftpPaneTreeNode.tsx";
import { installDomEnvironment, createDomRenderer } from "../test-support/renderReactDom";
import { isSftpTreeParentEntry } from "./useSftpPaneTreeContextMenu.tsx";
test("only the synthetic parent row is treated as a navigation entry", () => {
assert.equal(isSftpTreeParentEntry({ name: ".." }), true);
assert.equal(isSftpTreeParentEntry({ name: "." }), false);
assert.equal(isSftpTreeParentEntry({ name: "docs" }), false);
assert.equal(isSftpTreeParentEntry({ name: "..hidden" }), false);
});
test("parent navigation cannot reopen the previous entry's shared context menu", async () => {
const dom = installDomEnvironment();
const renderer = await createDomRenderer(dom.document);
const openedTargets: string[] = [];
let contextTarget = "";
const noop = () => {};
const renderRow = async (name: string) => {
const props: React.ComponentProps<typeof TreeNode> = {
entry: { name, type: "directory", size: 0, sizeFormatted: "--", lastModified: 0, lastModifiedFormatted: "--" },
entryPath: `/root/${name}`, depth: 0, columnTemplate: "1fr",
visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
isSelected: false, isExpanded: false, isLoading: false, isDragOver: false,
onToggleExpand: noop, onNodeClick: noop, onOpenEntry: noop,
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
onContextMenu: item => { contextTarget = item.name; },
};
await renderer.render(React.createElement("div", {
onContextMenu: () => { openedTargets.push(contextTarget); },
}, React.createElement(TreeNode, props)));
const event = new dom.window.MouseEvent("contextmenu", { bubbles: true, cancelable: true });
await act(async () => {
renderer.container.querySelector('[data-entry-name]')!.dispatchEvent(event);
});
return event;
};
try {
await renderRow("photos");
assert.deepEqual(openedTargets, ["photos"]);
const parentEvent = await renderRow("..");
assert.deepEqual(openedTargets, ["photos"], "parent must not reopen the stale photos menu");
assert.equal(parentEvent.defaultPrevented, true);
await renderRow("docs");
assert.deepEqual(openedTargets, ["photos", "docs"], "ordinary entry menus still work");
} finally {
await renderer.unmount();
dom.cleanup();
}
});
@@ -19,8 +19,6 @@ import { toEditorTabId, activeTabStore } from "../../../application/state/active
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
import type { 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;
@@ -661,33 +659,6 @@ 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
@@ -904,8 +875,6 @@ export const useSftpViewFileOps = ({
onDownloadFileRight,
onExtractArchiveLeft,
onExtractArchiveRight,
onCreateArchiveLeft,
onCreateArchiveRight,
onDownloadFilesLeft,
onDownloadFilesRight,
onUploadExternalFilesLeft,
@@ -4,7 +4,6 @@ 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>;
@@ -88,8 +87,6 @@ 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,8 +69,6 @@ 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;
}
@@ -147,43 +145,6 @@ 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],
@@ -439,8 +400,6 @@ export const useSftpViewPaneActions = ({
onMoveEntriesToPathRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onDuplicateEntriesLeft,
onDuplicateEntriesRight,
onReceiveFromOtherPaneLeft,
onReceiveFromOtherPaneRight,
};
@@ -151,7 +151,6 @@ export const useSftpViewPaneCallbacks = ({
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onDuplicateEntries: paneActions.onDuplicateEntriesLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
onEditFile: fileOps.onEditFileLeft,
@@ -161,7 +160,6 @@ export const useSftpViewPaneCallbacks = ({
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onExtractArchive: fileOps.onExtractArchiveLeft,
onCreateArchive: fileOps.onCreateArchiveLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
@@ -196,7 +194,6 @@ export const useSftpViewPaneCallbacks = ({
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onDuplicateEntries: paneActions.onDuplicateEntriesRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
onEditFile: fileOps.onEditFileRight,
@@ -206,7 +203,6 @@ export const useSftpViewPaneCallbacks = ({
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onExtractArchive: fileOps.onExtractArchiveRight,
onCreateArchive: fileOps.onCreateArchiveRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
+3 -37
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, CopyPlus, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { AppWindow, Archive, ArrowRight, ArrowUp, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderInput, FolderPlus, Pencil, RefreshCw, Shield, Trash2, Upload } from 'lucide-react';
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../ui/context-menu';
import { getParentPath } from '../../application/state/sftp/utils';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
@@ -10,16 +10,13 @@ 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, onDuplicateEntriesRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onCreateArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
setIsMoving, setShowMoveToDialog, tRef, onCopyToOtherPaneRef, onNavigateToRef, onOpenFileWithSystemDefaultRef, onOpenFileWithRef,
onEditFileRef, onDownloadFileRef, onExtractArchiveRef, onEditPermissionsRef, openDeleteConfirmRef, openRenameDialogRef,
openNewFolderDialogRef, openNewFileDialogRef,
} = props;
@@ -30,10 +27,6 @@ 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);
@@ -54,21 +47,6 @@ 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));
};
@@ -110,20 +88,10 @@ 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>
@@ -212,10 +180,8 @@ export function useSftpPaneTreeContextMenu(props: SftpPaneTreeContextMenuProps)
folderUploadEnabled,
onUploadExternalFolder,
onCopyToOtherPaneRef,
onDuplicateEntriesRef,
onDownloadFileRef,
onExtractArchiveRef,
onCreateArchiveRef,
onEditFileRef,
onEditPermissionsRef,
onNavigateToRef,
@@ -1,13 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("overview CPU cores list renders every reported core and can scroll", () => {
const source = readFileSync(new URL("./SystemOverviewTab.tsx", import.meta.url), "utf8");
assert.doesNotMatch(source, /cpuPerCore\.slice\(/);
assert.match(
source,
/className="grid grid-cols-2 gap-x-3 gap-y-1\.5 max-h-\[\d+px\] overflow-y-auto pr-1 overscroll-contain"/,
);
});
@@ -358,8 +358,8 @@ export const SystemOverviewTab = memo(function SystemOverviewTab({
</span>
</div>
{stats.cpuPerCore.length > 0 ? (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain">
{stats.cpuPerCore.map((core, index) => (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{stats.cpuPerCore.slice(0, 12).map((core, index) => (
<ResourceBar key={`core-${index}`} label={`C${index + 1}`} value={core} />
))}
</div>
-1
View File
@@ -1446,7 +1446,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
let anyRecipientSensitive = false;
let broadcastRecipientIds: readonly string[] = [];
const sent = await executeSnippetCommand(text, false, {
preferBracketedPaste: true,
// Explicit target: active-only stays local while toolbar
// broadcast (keystrokes) can remain on, and send-to-all
// still fans out when the toolbar switch is off.
@@ -142,31 +142,6 @@ 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) {
@@ -179,10 +154,6 @@ 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]);
@@ -397,40 +368,8 @@ 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}
@@ -468,16 +407,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
pointerEvents: "auto",
}}
// Keep the terminal focused when clicking the list's padding or
// scrollbar; scrollbar dragging is left to the browser.
onMouseDown={handlePanelMouseDown}
onWheel={handlePanelWheel}
// scrollbar (row clicks handle this themselves).
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{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
@@ -495,11 +434,8 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
gap: "8px",
lineHeight: "1.4",
}}
onMouseEnter={() => {
cancelHoverClear();
setHoveredIndex(index);
}}
onMouseLeave={scheduleHoverClear}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -532,15 +468,10 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
</span>
)}
{/* Command text. A shared fixed column keeps every description
aligned on the same left edge instead of following each
variable-width option label. */}
{/* Command text */}
<span
title={hasInlineDescription ? suggestion.displayText : undefined}
style={{
flex: hasInlineDescription ? "0 0 auto" : "1 1 auto",
width: hasInlineDescription ? `${commandColumnWidth}px` : undefined,
minWidth: 0,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
@@ -553,16 +484,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. */}
{hasInlineDescription && (
{suggestion.source !== "snippet" && suggestion.description && (
<span
style={{
flex: "1 1 auto",
minWidth: 0,
fontSize: "11px",
color: dimTextColor,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "160px",
flexShrink: 0,
}}
>
{suggestion.description}
@@ -618,9 +549,11 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
pointerEvents: "auto",
}}
// Keep the terminal focused when clicking the panel's padding or
// scrollbar; scrollbar dragging is left to the browser.
onMouseDown={handlePanelMouseDown}
onWheel={handlePanelWheel}
// scrollbar (row clicks handle this themselves).
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{panel.entries.map((entry, idx) => {
const isFocused = level === subDirFocusLevel;
@@ -682,23 +615,15 @@ 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={(event) => {
// Do not preventDefault: that would block selecting the long
// description or dragging the panel's scrollbar.
event.stopPropagation();
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onMouseEnter={cancelHoverClear}
onMouseLeave={scheduleHoverClear}
onWheel={handlePanelWheel}
>
{detailVisible && detailItem && (
<>
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff. Show more