Ctrl+W close priority + local shell busy confirmation (#739)
* feat(ctrl-w): add ps-node + windows-process-tree + tsx deps for close-priority feature * fix(ctrl-w): drop ps-node dep and add windows-process-tree to asarUnpack Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): add ptyProcessTree bridge with per-platform child-process enumeration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ctrl-w): ptyProcessTree uses args= for full command + warns on pid overwrite - Replace `comm=` with `args=` in defaultListPosix so the full command line is captured on both macOS (BSD ps) and Linux (GNU ps), avoiding the 15-char TASK_COMM_LEN truncation. - Add console.warn in registerPid when the same sessionId is overwritten with a different pid, making the race condition visible in logs. - Add test: registerPid warns exactly once on a pid change, not on a same-pid re-registration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): register local PTY pid with ptyProcessTree on spawn/exit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ctrl-w): unregister pids in cleanupAllSessions to match per-delete invariant Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): add IPC handlers for pty child processes and confirm-close dialog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ctrl-w): guard BrowserWindow.fromWebContents null and document dialog dismiss contract Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): expose ptyGetChildProcesses and confirmCloseBusy on window.netcatty Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): add i18n strings for close-busy-terminal dialog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): add resolveCloseIntent pure function with 8 unit tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): expose handleCloseSidePanel via ref to App.tsx Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): wire resolveCloseIntent + local-shell busy confirmation into closeTab hotkey Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ctrl-w): add re-entrancy guard, aggregate busy count, sync sidebar ref, dedupe intent branches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ctrl-w): auto-close workspace when its last session is closed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ctrl-w): sidebar close wins over focused terminal in priority chain Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ctrl-w): sidebar priority applies to single-session tabs too, not just workspaces Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ctrl-w): compute empty-workspace auto-close outside setSessions updater Addresses Codex P2 on #739: React 18+ does not guarantee updater execution timing under concurrent scheduling. Moving the decision outside the updater makes the microtask queue deterministic. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
101
App.tsx
101
App.tsx
@@ -18,6 +18,7 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
@@ -992,6 +993,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
@@ -1025,6 +1030,44 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const confirmIfBusyLocalTerminal = useCallback(
|
||||
async (sessionIds: string[]): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const localIds = sessionIds.filter((id) => {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
return s?.protocol === 'local';
|
||||
});
|
||||
const busyCommands: string[] = [];
|
||||
for (const id of localIds) {
|
||||
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
|
||||
if (children.length > 0) {
|
||||
busyCommands.push(children[0].command);
|
||||
}
|
||||
}
|
||||
if (busyCommands.length === 0) return true;
|
||||
|
||||
const primary = busyCommands[0];
|
||||
const extraCount = busyCommands.length - 1;
|
||||
const message =
|
||||
extraCount > 0
|
||||
? t('confirm.closeBusyTerminal.messageWithMore', {
|
||||
command: primary,
|
||||
count: extraCount,
|
||||
})
|
||||
: t('confirm.closeBusyTerminal.message', { command: primary });
|
||||
|
||||
const ok = await bridge?.confirmCloseBusy?.({
|
||||
command: primary,
|
||||
title: t('confirm.closeBusyTerminal.title'),
|
||||
message,
|
||||
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
|
||||
closeLabel: t('confirm.closeBusyTerminal.close'),
|
||||
});
|
||||
return ok === true;
|
||||
},
|
||||
[sessions, t],
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
@@ -1069,18 +1112,52 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
case 'closeTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (currentId !== 'vault' && currentId !== 'sftp') {
|
||||
// Find if it's a session or workspace
|
||||
const session = sessions.find(s => s.id === currentId);
|
||||
if (session) {
|
||||
closeSession(currentId);
|
||||
} else {
|
||||
const workspace = workspaces.find(w => w.id === currentId);
|
||||
if (workspace) {
|
||||
closeWorkspace(currentId);
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
const activeSidePanel = activeSidePanelTabRef.current;
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
activeSidePanelTab: activeSidePanel,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
switch (intent.kind) {
|
||||
case 'closeTerminal':
|
||||
case 'closeSingleTab': {
|
||||
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeSidePanel': {
|
||||
closeSidePanelRef.current?.();
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
if (ok) closeWorkspace(intent.workspaceId);
|
||||
return;
|
||||
}
|
||||
case 'noop':
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
@@ -1193,7 +1270,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -1684,6 +1761,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
Reference in New Issue
Block a user