Compare commits

...
14 changed files with 373 additions and 110 deletions

No files matched your search

+102 -48
View File
@@ -1,57 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { closeTabsBatchImpl } from './app/AppHandlers.ts';
test('batch tab close removes standalone sessions in one state update', async () => {
const closedSessionBatches: string[][] = [];
const probedSessionBatches: string[][] = [];
const closeTabsInFlightRef = { current: false };
const sessions = [
{ id: 's1', protocol: 'ssh' },
{ id: 's2', protocol: 'ssh' },
{ id: 's3', protocol: 'ssh' },
];
function setup() {
const closed: string[] = [];
const probes: string[][] = [];
const editors = [{ id: 'e', sessionId: 'connection', sftpTabId: 'pane' }];
let active = 's1';
const ctx = {
closeLogView: (id: string) => { closed.push(id); },
closeSessions: (ids: string[]) => { closed.push(...ids); },
closeTabsInFlightRef: { current: false },
closeWorkspace: (id: string) => { closed.push(id); },
confirmIfBusyLocalTerminal: async (ids: string[]) => { probes.push(ids); return true; },
logViews: [{ id: 'log' }],
sessions: [{ id: 's1', workspaceId: null as string | null }, { id: 's2', workspaceId: null as string | null }],
workspaces: [{ id: 'ws' }],
activeTabStore: { getActiveTabId: () => active, setActiveTabId: (id: string) => { active = id; } },
editorTabStore: { getTabs: () => editors },
pluginViewTabStore: {
getTab: (id: string) => id === 'plugin',
close: (id: string) => { closed.push(id); },
},
handleRequestCloseEditorTabRef: { current: async (id: string) => {
closed.push(`editor:${id}`);
editors.splice(editors.findIndex((editor) => editor.id === id), 1);
return true;
} },
findEditorSftpOwnerTabId: (_connection?: string, _pane?: string): string | null => null,
orderedTabsWithEditors: ['s1', 's2', 'ws', 'editor:e', 'log', 'plugin'],
};
return { ctx, closed, probes, run: (ids: string[]) => closeTabsBatchImpl(() => ctx, ids) };
}
const result = await closeTabsBatchImpl(
() => ({
closeLogView: () => {},
closeSessions: (sessionIds: string[]) => closedSessionBatches.push(sessionIds),
closeTabsInFlightRef,
closeWorkspace: () => {},
confirmIfBusyLocalTerminal: async (sessionIds: string[]) => {
probedSessionBatches.push(sessionIds);
return true;
},
logViews: [],
sessions,
workspaces: [],
}),
['s1', 's2', 's3'],
);
assert.deepEqual(probedSessionBatches, [['s1', 's2', 's3']]);
assert.deepEqual(closedSessionBatches, [['s1', 's2', 's3']]);
assert.equal(closeTabsInFlightRef.current, false);
assert.equal(result, true);
test('batch closes sessions, workspaces, editors, logs and plugins and selects surviving tab', async () => {
const { ctx, closed, probes, run } = setup();
assert.equal(await run(['s1', 'ws', 'editor:e', 'log', 'plugin']), true);
assert.deepEqual(closed, ['editor:e', 's1', 'ws', 'log', 'plugin']);
assert.deepEqual(probes, [['s1']]);
assert.equal(ctx.activeTabStore.getActiveTabId(), 's2');
assert.equal(ctx.closeTabsInFlightRef.current, false);
});
test('batch tab close reports cancellation before mutating any tab', async () => {
let mutated = false;
const result = await closeTabsBatchImpl(
() => ({
closeLogView: () => { mutated = true; },
closeSessions: () => { mutated = true; },
closeTabsInFlightRef: { current: false },
closeWorkspace: () => { mutated = true; },
confirmIfBusyLocalTerminal: async () => false,
logViews: [],
sessions: [{ id: 's1', protocol: 'local' }],
workspaces: [],
}),
['s1'],
);
test('busy cancellation happens before any editor or other tab mutation', async () => {
const { ctx, closed, run } = setup();
ctx.confirmIfBusyLocalTerminal = async () => false;
assert.equal(await run(ctx.orderedTabsWithEditors), false);
assert.deepEqual(closed, []);
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
assert.equal(ctx.closeTabsInFlightRef.current, false);
});
assert.equal(result, false);
assert.equal(mutated, false);
test('cancelled editor preserves its owner and active focus', async () => {
const { ctx, closed, run } = setup();
ctx.findEditorSftpOwnerTabId = () => 's1';
ctx.handleRequestCloseEditorTabRef.current = async () => false;
await run(ctx.orderedTabsWithEditors);
assert.deepEqual(closed, ['s2', 'ws', 'log', 'plugin']);
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
});
test('editor outside close-left range preserves its owner', async () => {
const { ctx, closed, run } = setup();
ctx.findEditorSftpOwnerTabId = () => 's1';
await run(['s1']);
assert.deepEqual(closed, []);
});
test('preserved workspace containing an editor owner remains focused', async () => {
const { ctx, closed, probes, run } = setup();
ctx.sessions[0].workspaceId = 'ws';
ctx.findEditorSftpOwnerTabId = () => 's1';
ctx.activeTabStore.setActiveTabId('ws');
await run(['ws']);
assert.deepEqual(closed, []);
assert.deepEqual(probes, [['s1']]);
assert.equal(ctx.activeTabStore.getActiveTabId(), 'ws');
});
test('cancelled editor becomes focus destination when other tabs close', async () => {
const { ctx, run } = setup();
ctx.handleRequestCloseEditorTabRef.current = async () => false;
await run(ctx.orderedTabsWithEditors);
assert.equal(ctx.activeTabStore.getActiveTabId(), 'editor:e');
});
for (const phase of ['busy', 'editor'] as const) {
test(`overlapping batch is ignored during ${phase} prompt, including editor-only batches`, async () => {
const { ctx, closed, run } = setup();
let release!: (result: boolean) => void;
const pending = new Promise<boolean>((resolve) => { release = resolve; });
if (phase === 'busy') ctx.confirmIfBusyLocalTerminal = () => pending;
else ctx.handleRequestCloseEditorTabRef.current = () => pending;
const first = run(phase === 'busy' ? ['s1'] : ['editor:e']);
assert.equal(ctx.closeTabsInFlightRef.current, true);
assert.equal(await run(['s2', 'plugin']), false);
assert.deepEqual(closed, []);
release(false);
await first;
assert.equal(ctx.closeTabsInFlightRef.current, false);
});
}
test('failed prompt releases guard so next batch can run', async () => {
const { ctx, run } = setup();
ctx.handleRequestCloseEditorTabRef.current = async () => { throw new Error('save failed'); };
await assert.rejects(run(['editor:e']), /save failed/);
assert.equal(ctx.closeTabsInFlightRef.current, false);
assert.equal(await run(['s1']), true);
});
+80 -40
View File
@@ -11,6 +11,8 @@ import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
@@ -677,48 +679,86 @@ export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, s
}
}
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
const { closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
{
if (targetIds.length === 0) return true;
if (closeTabsInFlightRef.current) return false;
// Expand workspace ids into their constituent session ids so the busy
// probe sees every local shell that's about to be killed.
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
closeTabsInFlightRef.current = true;
try {
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
if (!ok) return false;
const standaloneSessionIds = targetIds.filter((tabId) => (
sessions.some((session) => session.id === tabId)
));
if (standaloneSessionIds.length > 0) {
closeSessions(standaloneSessionIds);
}
for (const tabId of targetIds) {
if (workspaces.find((w) => w.id === tabId)) {
closeWorkspace(tabId);
} else if (logViews.find((lv) => lv.id === tabId)) {
closeLogView(tabId);
}
}
return true;
} finally {
closeTabsInFlightRef.current = false;
/**
* Expand batch-close target ids into the terminal session ids whose local
* shells the busy probe must inspect: workspace ids contribute every session
* they contain; standalone session ids pass through.
*/
export function collectBatchBusyProbeSessionIds(
sessions: readonly { id: string; workspaceId?: string | null }[],
workspaces: readonly { id: string }[],
targetIds: readonly string[],
): string[] {
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
return sessionIdsToProbe;
}
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
const {
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
activeTabStore, editorTabStore, pluginViewTabStore,
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
} = getCtx();
if (targetIds.length === 0) return true;
if (closeTabsInFlightRef.current) return false;
closeTabsInFlightRef.current = true;
try {
const activeBeforeClose = activeTabStore.getActiveTabId();
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
const editorIds = targetIds.filter(isEditorTabId);
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id) && !isEditorTabId(id));
// Cancel busy-terminal confirmation before making any changes, including editors.
if (regularIds.length && !await confirmIfBusyLocalTerminal(
collectBatchBusyProbeSessionIds(sessions, workspaces, regularIds),
)) return false;
const closedEditorIds: string[] = [];
for (const id of editorIds) {
if (await handleRequestCloseEditorTabRef.current(fromEditorTabId(id))) closedEditorIds.push(id);
}
// Owners must remain mounted while any editor survives (cancelled or outside
// the range), because unmounting their SFTP panel discards its editors.
const keepTabIds = new Set<string>();
for (const editor of editorTabStore.getTabs()) {
const owner = findEditorSftpOwnerTabId(editor.sessionId, editor.sftpTabId);
if (owner) keepTabIds.add(owner);
}
const effectiveRegularIds = regularIds.filter((id) => !keepTabIds.has(id) && !(
workspaces.some((workspace) => workspace.id === id) &&
sessions.some((session) => session.workspaceId === id && keepTabIds.has(session.id))
));
const standaloneSessionIds = effectiveRegularIds.filter((id) => sessions.some((session) => session.id === id));
if (standaloneSessionIds.length) closeSessions(standaloneSessionIds);
for (const id of effectiveRegularIds) {
if (workspaces.some((workspace) => workspace.id === id)) closeWorkspace(id);
else if (logViews.some((logView) => logView.id === id)) closeLogView(id);
}
for (const id of pluginIds) pluginViewTabStore.close(id);
const closingTabIds = new Set([...effectiveRegularIds, ...closedEditorIds, ...pluginIds]);
if (closingTabIds.has(activeBeforeClose)) {
activeTabStore.setActiveTabId(resolveBatchTabCloseFocus({
orderedTabIds: orderedTabsWithEditors,
closingTabIds,
activeTabId: activeBeforeClose,
}));
}
return true;
} finally {
closeTabsInFlightRef.current = false;
}
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
+13 -22
View File
@@ -21,11 +21,11 @@ import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { useEditorTabChromeList } from '../state/editorTabStore';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
import {
isPluginViewTabId,
pluginViewTabStore,
resolveBatchTabCloseFocus,
usePluginViewTabs,
} from '../state/pluginViewTabStore';
import {
@@ -1010,27 +1010,18 @@ export function AppSideEffects() {
}, [orderedTabsWithEditors]);
// Close many tabs at once with a single batched busy-shell confirmation.
// Used by the "Close all / Close others / Close to the right" context-menu
// actions on tabs (#748).
// Used by the "Close all / Close others / Close to the left / Close to the
// right" context-menu actions on tabs (#748).
const closeTabsBatch = useCallback(
async (targetIds: string[]) => {
const closingTabIds = new Set(targetIds);
const activeBeforeClose = activeTabStore.getActiveTabId();
const focusAfterClose = resolveBatchTabCloseFocus({
orderedTabIds: orderedTabsWithEditors,
closingTabIds,
activeTabId: activeBeforeClose,
});
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id));
const canClose = !regularIds.length || await closeTabsBatchImpl(
() => ({ closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, targetIds: regularIds, workspaces }),
regularIds,
);
if (!canClose) return;
for (const id of pluginIds) pluginViewTabStore.close(id);
if (closingTabIds.has(activeBeforeClose)) activeTabStore.setActiveTabId(focusAfterClose);
},
(targetIds: string[]) => closeTabsBatchImpl(
() => ({
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
activeTabStore, editorTabStore, pluginViewTabStore,
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
}),
targetIds,
),
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSessions, closeLogView, orderedTabsWithEditors],
);
+1
View File
@@ -637,6 +637,7 @@ export const enTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
'tabs.closeToLeft': 'Close Tabs to the Left',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',
'keychain.edit.labelRequired': 'Label *',
+1
View File
@@ -637,6 +637,7 @@ export const esTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
'tabs.closeOthers': 'Cerrar las demás',
'tabs.closeToLeft': 'Cerrar pestañas a la izquierda',
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
'tabs.closeAll': 'Cerrar todas',
'keychain.edit.labelRequired': 'Etiqueta *',
+1
View File
@@ -652,6 +652,7 @@ export const ruTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
'tabs.closeToLeft': 'Закрыть вкладки слева',
'tabs.closeToRight': 'Закрыть вкладки справа',
'tabs.closeAll': 'Закрыть все',
'keychain.edit.labelRequired': 'Метка *',
@@ -722,6 +722,7 @@ export const zhCNTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToLeft': '关闭左侧标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',
'keychain.edit.labelRequired': 'Label *',
@@ -722,6 +722,7 @@ export const zhTWTerminalMessages: Messages = {
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
'tabs.closeOthers': '關閉其他分頁',
'tabs.closeToLeft': '關閉左側分頁',
'tabs.closeToRight': '關閉右側分頁',
'tabs.closeAll': '關閉所有分頁',
'keychain.edit.labelRequired': 'Label *',
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import { findEditorSftpOwnerTabId, registerEditorSftpOwnerResolver } from "./editorSftpOwnerRegistry";
test("resolves the owning tab for a registered SFTP connection id", () => {
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["left-abc", "right-def"],
ownerTabId: "session-1",
}));
try {
assert.equal(findEditorSftpOwnerTabId("left-abc"), "session-1");
assert.equal(findEditorSftpOwnerTabId("right-def"), "session-1");
assert.equal(findEditorSftpOwnerTabId("left-unknown"), null);
assert.equal(findEditorSftpOwnerTabId(undefined), null);
} finally {
unregister();
}
});
test("falls back to the stable pane tab id when the connection id is stale", () => {
// Browse reconnects regenerate connection ids; an unsaved editor may still
// reference the pre-reconnect id, so owner resolution must also match on the
// stable pane tab id.
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["conn_new"],
paneTabIds: ["pane_1"],
ownerTabId: "session-1",
}));
try {
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_1"), "session-1");
assert.equal(findEditorSftpOwnerTabId(undefined, "pane_1"), "session-1");
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_unknown"), null);
assert.equal(findEditorSftpOwnerTabId(undefined, undefined), null);
} finally {
unregister();
}
});
test("unregistering a resolver stops it from resolving owners", () => {
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["conn-x"],
ownerTabId: "tab-x",
}));
unregister();
assert.equal(findEditorSftpOwnerTabId("conn-x"), null);
});
test("resolvers without an owner tab id never match", () => {
const unregister = registerEditorSftpOwnerResolver(() => ({
connectionIds: ["conn-y"],
ownerTabId: null,
}));
try {
assert.equal(findEditorSftpOwnerTabId("conn-y"), null);
} finally {
unregister();
}
});
@@ -0,0 +1,55 @@
/**
* Maps live SFTP connection ids to the top-level tab that owns the panel
* hosting them (a terminal/workspace tab for SftpSidePanel instances).
*
* Editor tabs record the SFTP `connection.id` as their `sessionId` — not the
* owning terminal session id — so batch-close logic that wants to keep an
* editor's owning tab open after a cancelled dirty-save prompt must resolve
* the owner through this registry instead of comparing `editorTab.sessionId`
* against terminal session ids.
*
* Each mounted SftpSidePanel registers a resolver that reports the connection
* ids it currently owns plus its host tab id; the top-level SftpView either
* registers nothing (its owner is not a closable terminal tab) or registers
* with a non-terminal owner id that simply never matches.
*/
export interface EditorSftpOwnerSnapshot {
connectionIds: readonly string[];
/**
* Stable SFTP pane tab ids (pane.id) hosted by this panel. Unlike connection
* ids these survive browse reconnects that regenerate connection ids, so
* owner resolution can fall back to them when an editor still references a
* pre-reconnect connection id.
*/
paneTabIds?: readonly string[];
ownerTabId: string | null;
}
type EditorSftpOwnerResolver = () => EditorSftpOwnerSnapshot;
const resolvers = new Set<EditorSftpOwnerResolver>();
export const registerEditorSftpOwnerResolver = (resolver: EditorSftpOwnerResolver): (() => void) => {
resolvers.add(resolver);
return () => {
resolvers.delete(resolver);
};
};
export const findEditorSftpOwnerTabId = (
connectionId: string | undefined,
paneTabId?: string,
): string | null => {
if (!connectionId && !paneTabId) return null;
for (const resolve of resolvers) {
const snapshot = resolve();
if (!snapshot.ownerTabId) continue;
if (connectionId && snapshot.connectionIds.includes(connectionId)) {
return snapshot.ownerTabId;
}
if (paneTabId && snapshot.paneTabIds?.includes(paneTabId)) {
return snapshot.ownerTabId;
}
}
return null;
};
+31
View File
@@ -31,6 +31,7 @@ import {
import { useSftpFollowTerminalCwd } from "../application/state/sftp/useSftpFollowTerminalCwd";
import { usePendingSftpUploadRebind } from "../application/state/sftp/usePendingSftpUploadRebind";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { registerEditorSftpOwnerResolver } from "../application/state/editorSftpOwnerRegistry";
import {
editorTabStore,
useEditorTabPresenceRevision,
@@ -147,6 +148,9 @@ interface SftpSidePanelProps {
* focused). Keeps browse SFTP sessions warm across History/System switches.
*/
ownerPanelOpen?: boolean;
/** Top-level tab id hosting this panel (terminal/workspace tab), used to
* resolve which tab owns an editor's SFTP connection. */
ownerTabId?: string | null;
renderOverlays?: boolean;
pendingUpload?: {
requestId: string;
@@ -201,6 +205,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
showWorkspaceHostHeader = false,
isVisible = true,
ownerPanelOpen = false,
ownerTabId = null,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
@@ -478,6 +483,32 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
);
}, []);
const ownerTabIdRef = useRef(ownerTabId);
ownerTabIdRef.current = ownerTabId;
// While mounted, report which SFTP connections this panel owns and which
// top-level tab hosts it, so batch-close logic can keep the owning tab open
// when an editor bound to one of those connections survives its close
// prompt. Intentionally no deps — go through sftpRef so connection churn
// doesn't register/unregister on every re-render.
useEffect(() => {
return registerEditorSftpOwnerResolver(() => {
const s = sftpRef.current;
if (!s) return { connectionIds: [], ownerTabId: ownerTabIdRef.current };
const connectionIds: string[] = [];
const paneTabIds: string[] = [];
for (const tab of [...(s.leftTabs?.tabs ?? []), ...(s.rightTabs?.tabs ?? [])]) {
const id = tab.connection?.id;
if (id) connectionIds.push(id);
// Pane tab ids are stable across browse reconnects (unlike connection
// ids), so owner resolution can match editors bound to a stale
// pre-reconnect connection id.
if (tab.id) paneTabIds.push(tab.id);
}
return { connectionIds, paneTabIds, ownerTabId: ownerTabIdRef.current };
});
}, []);
// When this side panel unmounts (its hosting terminal tab was closed) we
// force-close any editor tabs bound to connections this panel owned — the
// save channel is gone with the SFTP session and there's no way to recover
+21
View File
@@ -634,3 +634,24 @@ test("App shell retains presentation-stable sessions for domain memos", () => {
assert.match(appSource, /sessions: sessionsForShell/);
assert.match(appSource, /orphanSessions: orphanSessionsForShell/);
});
test("bulk close context menu offers symmetric close-to-left option", () => {
const bulkCloseBlock = topTabsSource.slice(
topTabsSource.indexOf("Bulk-close menu items shared by session and workspace context menus"),
topTabsSource.indexOf("// Render the tabs"),
);
// Left of the anchor is symmetric with close-to-right: slice(0, anchorIdx).
assert.match(bulkCloseBlock, /orderedTabs\.slice\(0, anchorIdx\)/);
assert.match(bulkCloseBlock, /tabs\.closeToLeft/);
assert.match(bulkCloseBlock, /onCloseTabsBatch\(leftIds\)/);
// Locale parity: the new key is translated in every terminal locale.
const locales = ["en", "es", "ru", "zh-CN", "zh-TW"] as const;
for (const locale of locales) {
const source = readFileSync(
new URL(`../application/i18n/locales/${locale}/terminal.ts`, import.meta.url),
"utf8",
);
assert.match(source, /'tabs\.closeToLeft':/);
}
});
+7
View File
@@ -769,6 +769,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const anchorIdx = orderedTabs.indexOf(anchorId);
const othersIds = orderedTabs.filter((id) => id !== anchorId);
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
const leftIds = anchorIdx > 0 ? orderedTabs.slice(0, anchorIdx) : [];
return (
<>
<ContextMenuSeparator />
@@ -778,6 +779,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
>
{t('tabs.closeOthers')}
</ContextMenuItem>
<ContextMenuItem
disabled={leftIds.length === 0}
onClick={() => onCloseTabsBatch(leftIds)}
>
{t('tabs.closeToLeft')}
</ContextMenuItem>
<ContextMenuItem
disabled={rightIds.length === 0}
onClick={() => onCloseTabsBatch(rightIds)}
@@ -196,6 +196,7 @@ function SidePanelSftpSlotInner({
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<SftpSidePanel
transferOwnerId={`terminal:${tabId}`}
ownerTabId={tabId}
hosts={effectiveHosts}
writableHosts={hosts}
sessions={sessions}