fix: Project action terminal lifecycle (#3287)
* fix(terminal): make command sessions own action lifecycle * fix(ui): reconcile project action terminal state * feat(ui): show running project actions in terminal tabs * feat(ui): run project actions from linked worktrees * fix(ui): guard project action reconciliation * fix(ui): scope project action preview fallback * fix(ui): default project actions to worktrees * fix(ui): reveal project action terminals * fix(ui): retain terminal output after snapshot replay * fix(ui): restore running action terminals on revisit
This commit is contained in:
@@ -18,6 +18,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
import { useTerminalSessionKeepalive } from '@/hooks/useTerminalSessionKeepalive';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
@@ -105,6 +106,10 @@ type MobileSurface = 'instances' | 'settings' | 'update';
|
||||
|
||||
const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => {
|
||||
const { t } = useI18n();
|
||||
// The mobile root does not mount MainLayout, so it owns its own terminal
|
||||
// keepalive: without it, background PTYs (running project actions included)
|
||||
// are idle-reaped by the server while the workspace drawer is closed.
|
||||
useTerminalSessionKeepalive();
|
||||
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
|
||||
const [activeSurface, setActiveSurface] = React.useState<MobileSurface | null>(null);
|
||||
// Phone right drawer with the workspace tabs; the tab persists across
|
||||
|
||||
@@ -969,8 +969,8 @@ export const ContextPanel: React.FC = () => {
|
||||
() => tabs.filter((tab) => tab.mode === 'diff'),
|
||||
[tabs],
|
||||
);
|
||||
const hasTerminalTab = React.useMemo(
|
||||
() => tabs.some((tab) => tab.mode === 'terminal'),
|
||||
const terminalTab = React.useMemo(
|
||||
() => tabs.find((tab) => tab.mode === 'terminal') ?? null,
|
||||
[tabs],
|
||||
);
|
||||
// Keep-alive: the walkthrough holds reading progress and scroll position that
|
||||
@@ -1283,9 +1283,9 @@ export const ContextPanel: React.FC = () => {
|
||||
</React.Suspense>
|
||||
</div>
|
||||
))}
|
||||
{hasTerminalTab ? (
|
||||
{terminalTab ? (
|
||||
<div className={cn('absolute inset-0', activeTab?.mode === 'terminal' ? 'block' : 'hidden')}>
|
||||
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} />
|
||||
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} directory={terminalTab.targetDirectory} />
|
||||
</div>
|
||||
) : null}
|
||||
{hasWalkthroughTab ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useSessionTreeMoveConfirmation,
|
||||
} from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useTerminalSessionKeepalive } from '@/hooks/useTerminalSessionKeepalive';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -43,6 +44,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se
|
||||
*/
|
||||
export const MainLayout: React.FC = () => {
|
||||
useSessionListSync({ isVSCode: false });
|
||||
useTerminalSessionKeepalive();
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import type { CreateTerminalOptions, TerminalHandlers, TerminalStreamEvent } from '@/lib/api/types';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import type { OpenChamberProjectAction } from '@/lib/openchamberConfig';
|
||||
|
||||
const toastCalls = {
|
||||
error: new Array<string>(),
|
||||
info: new Array<string>(),
|
||||
success: new Array<string>(),
|
||||
} satisfies { error: string[]; info: string[]; success: string[] };
|
||||
|
||||
const openContextPreviewCalls: Array<{ directory: string; url: string }> = [];
|
||||
const openContextPanelTabCalls: Array<{ directory: string; mode: string; targetDirectory: string | null | undefined }> = [];
|
||||
const openExternalCalls: string[] = [];
|
||||
const detectedDevServer: MockedDetectedDevServer = { command: null, previewUrlHint: null };
|
||||
const mockedDeviceInfo = { isMobile: false, isTablet: false, hasTouchOnlyPointer: false };
|
||||
let effectiveDirectory = '/repo';
|
||||
|
||||
const uiState = {
|
||||
terminalShell: 'zsh',
|
||||
terminalLoginShells: ['zsh'],
|
||||
setSettingsPage: () => undefined,
|
||||
setSettingsDialogOpen: () => undefined,
|
||||
setSettingsProjectsSelectedId: () => undefined,
|
||||
openContextPreview: (directory: string, url: string) => {
|
||||
openContextPreviewCalls.push({ directory, url });
|
||||
},
|
||||
openContextPanelTab: (directory: string, options: { mode: string; targetDirectory?: string | null }) => {
|
||||
openContextPanelTabCalls.push({ directory, mode: options.mode, targetDirectory: options.targetDirectory });
|
||||
},
|
||||
openContextSurface: () => undefined,
|
||||
};
|
||||
|
||||
const useUiStoreMock = Object.assign(
|
||||
<T,>(selector: (state: typeof uiState) => T): T => selector(uiState),
|
||||
{ getState: () => uiState },
|
||||
);
|
||||
|
||||
const desktopSshState = { instances: [], load: async () => undefined };
|
||||
const useDesktopSshStoreMock = <T,>(selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState);
|
||||
|
||||
type SubscriptionRecord = {
|
||||
sessionId: string;
|
||||
handlers: TerminalHandlers;
|
||||
closed: number;
|
||||
};
|
||||
|
||||
interface MockedActionsState {
|
||||
actions: OpenChamberProjectAction[];
|
||||
}
|
||||
|
||||
interface MockedDetectedDevServer {
|
||||
command: string | null;
|
||||
previewUrlHint: string | null;
|
||||
}
|
||||
|
||||
const createCalls: CreateTerminalOptions[] = [];
|
||||
const sendCalls: string[] = [];
|
||||
const forceKillCalls: string[] = [];
|
||||
const closeCalls: string[] = [];
|
||||
const subscriptions: SubscriptionRecord[] = [];
|
||||
let sessionCounter = 0;
|
||||
const mockedActionsState: MockedActionsState = {
|
||||
actions: [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build' }],
|
||||
};
|
||||
|
||||
const emitToSession = (sessionId: string, event: TerminalStreamEvent) => {
|
||||
subscriptions
|
||||
.filter((entry) => entry.sessionId === sessionId && entry.closed === 0)
|
||||
.forEach((entry) => entry.handlers.onEvent(event));
|
||||
};
|
||||
|
||||
const terminal = {
|
||||
listSessions: async () => [],
|
||||
createSession: async (options: CreateTerminalOptions) => {
|
||||
createCalls.push(options);
|
||||
sessionCounter += 1;
|
||||
return {
|
||||
sessionId: `session-${sessionCounter}`,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
status: 'running' as const,
|
||||
mode: 'command' as const,
|
||||
purpose: options.purpose,
|
||||
};
|
||||
},
|
||||
connect: (sessionId: string, handlers: SubscriptionRecord['handlers']) => {
|
||||
const record: SubscriptionRecord = { sessionId, handlers, closed: 0 };
|
||||
subscriptions.push(record);
|
||||
return {
|
||||
close: () => {
|
||||
record.closed += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
sendInput: async (sessionId: string, input: string) => {
|
||||
sendCalls.push(`${sessionId}:${input}`);
|
||||
queueMicrotask(() => {
|
||||
emitToSession(sessionId, { type: 'exit', sequence: 1, exitCode: 0, signal: null });
|
||||
});
|
||||
},
|
||||
resize: async () => undefined,
|
||||
updateAppearance: async () => undefined,
|
||||
close: async (sessionId: string) => {
|
||||
closeCalls.push(sessionId);
|
||||
},
|
||||
restartSession: async () => { throw new Error('not used'); },
|
||||
forceKill: async ({ sessionId }: { sessionId?: string }) => {
|
||||
forceKillCalls.push(sessionId ?? '');
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
DropdownMenuItem: ({ children, onClick, className }: { children: React.ReactNode; onClick?: () => void; className?: string }) => React.createElement('button', { type: 'button', onClick, className }, children),
|
||||
DropdownMenuSeparator: () => React.createElement('hr'),
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
}));
|
||||
mock.module('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
}));
|
||||
mock.module('@/components/ui', () => ({
|
||||
toast: {
|
||||
error: (message: string) => { toastCalls.error.push(message); },
|
||||
info: (message: string) => { toastCalls.info.push(message); },
|
||||
success: (message: string) => { toastCalls.success.push(message); },
|
||||
},
|
||||
}));
|
||||
mock.module('@/components/icon/Icon', () => ({ Icon: ({ name, className }: { name: string; className?: string }) => React.createElement('span', { 'data-icon': name, className }) }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ terminal, runtime: { isVSCode: false, platform: 'web' } }) }));
|
||||
mock.module('@/lib/device', () => ({ useDeviceInfo: () => mockedDeviceInfo }));
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false }));
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: useUiStoreMock }));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({ useThemeSystem: () => ({ currentTheme: { metadata: { variant: 'dark' }, colors: { surface: { background: '#000' }, syntax: { base: { foreground: '#fff' } } } } }) }));
|
||||
mock.module('@/stores/useDesktopSshStore', () => ({ useDesktopSshStore: useDesktopSshStoreMock }));
|
||||
mock.module('@/lib/url', () => ({ openExternalUrl: async (url: string) => { openExternalCalls.push(url); } }));
|
||||
mock.module('@/lib/openchamberConfig', () => ({
|
||||
getProjectActionsState: async () => mockedActionsState,
|
||||
}));
|
||||
mock.module('@/lib/browser/announcedServers', () => ({ setAnnouncedDevServers: () => undefined }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => effectiveDirectory }));
|
||||
mock.module('@/lib/detectDevServer', () => ({
|
||||
detectDevServerCommand: async () => (
|
||||
detectedDevServer.command
|
||||
? { command: detectedDevServer.command, previewUrlHint: detectedDevServer.previewUrlHint ?? undefined }
|
||||
: null
|
||||
),
|
||||
readPackageJsonScripts: async () => ({}),
|
||||
}));
|
||||
|
||||
const { ProjectActionsButton } = await import('./ProjectActionsButton');
|
||||
|
||||
describe('ProjectActionsButton lifecycle', () => {
|
||||
let windowInstance: Window;
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
const scheduledWindowTimeouts = new Map<ReturnType<Window['setTimeout']>, { delay: number; run: () => void }>();
|
||||
|
||||
const runWindowTimeouts = async (delay: number) => {
|
||||
const matching = [...scheduledWindowTimeouts.entries()].filter(([, timeout]) => timeout.delay === delay);
|
||||
for (const [id] of matching) scheduledWindowTimeouts.delete(id);
|
||||
await act(async () => {
|
||||
for (const [, timeout] of matching) timeout.run();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
scheduledWindowTimeouts.clear();
|
||||
const originalSetTimeout = windowInstance.setTimeout.bind(windowInstance);
|
||||
const originalClearTimeout = windowInstance.clearTimeout.bind(windowInstance);
|
||||
windowInstance.setTimeout = (callback, delay = 0, ...args) => {
|
||||
const id = originalSetTimeout(() => undefined, 0);
|
||||
originalClearTimeout(id);
|
||||
scheduledWindowTimeouts.set(id, { delay, run: () => callback(...args) });
|
||||
return id;
|
||||
};
|
||||
windowInstance.clearTimeout = (id) => {
|
||||
if (id !== undefined) scheduledWindowTimeouts.delete(id);
|
||||
};
|
||||
Object.assign(globalThis, {
|
||||
window: windowInstance,
|
||||
document: windowInstance.document,
|
||||
navigator: windowInstance.navigator,
|
||||
Node: windowInstance.Node,
|
||||
Element: windowInstance.Element,
|
||||
HTMLElement: windowInstance.HTMLElement,
|
||||
Event: windowInstance.Event,
|
||||
MouseEvent: windowInstance.MouseEvent,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
useTerminalStore.getState().clearAll();
|
||||
createCalls.length = 0;
|
||||
sendCalls.length = 0;
|
||||
forceKillCalls.length = 0;
|
||||
closeCalls.length = 0;
|
||||
subscriptions.length = 0;
|
||||
toastCalls.error.length = 0;
|
||||
toastCalls.info.length = 0;
|
||||
toastCalls.success.length = 0;
|
||||
openContextPreviewCalls.length = 0;
|
||||
openContextPanelTabCalls.length = 0;
|
||||
openExternalCalls.length = 0;
|
||||
detectedDevServer.command = null;
|
||||
detectedDevServer.previewUrlHint = null;
|
||||
mockedDeviceInfo.isMobile = true;
|
||||
effectiveDirectory = '/repo';
|
||||
sessionCounter = 0;
|
||||
mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build' }];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
const renderButton = async ({
|
||||
projectPath = '/repo',
|
||||
directory = '/repo',
|
||||
}: { projectPath?: string; directory?: string } = {}) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(I18nProvider, null,
|
||||
React.createElement(ProjectActionsButton, {
|
||||
projectRef: { id: 'project-1', path: projectPath },
|
||||
directory,
|
||||
allowMobile: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
};
|
||||
|
||||
test('runs, stops, and reruns on the same action tab while cleaning old subscriptions once', async () => {
|
||||
await renderButton();
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const firstTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
|
||||
expect(firstTab?.terminalSessionId).toBe('session-1');
|
||||
expect(firstTab?.purpose.type).toBe('project-action');
|
||||
const firstExecution = firstTab?.purpose.type === 'project-action' ? firstTab.purpose.executionId : null;
|
||||
expect(firstExecution).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const stoppedTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
|
||||
expect(stoppedTab?.lifecycle).toBe('exited');
|
||||
expect(stoppedTab?.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const rerunTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
|
||||
expect(rerunTab?.id).toBe(firstTab?.id);
|
||||
expect(rerunTab?.terminalSessionId).toBe('session-2');
|
||||
expect(rerunTab?.lifecycle).toBe('running');
|
||||
const secondExecution = rerunTab?.purpose.type === 'project-action' ? rerunTab.purpose.executionId : null;
|
||||
expect(secondExecution).not.toBeNull();
|
||||
expect(secondExecution).not.toBe(firstExecution);
|
||||
|
||||
expect(createCalls).toHaveLength(2);
|
||||
expect(sendCalls).toEqual(['session-1:\x03']);
|
||||
expect(forceKillCalls).toEqual([]);
|
||||
expect(closeCalls).toEqual(['session-1']);
|
||||
expect(subscriptions.map((entry) => entry.closed)).toEqual([1, 1, 0]);
|
||||
});
|
||||
|
||||
test('default action runs in the current worktree and stores its tab there', async () => {
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
await renderButton({ projectPath: '/repo', directory: '/repo-worktree' });
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(createCalls).toHaveLength(1);
|
||||
expect(createCalls[0]?.cwd).toBe('/repo-worktree');
|
||||
expect(useTerminalStore.getState().getDirectoryState('/repo-worktree')?.tabs.some((tab) => tab.purpose.type === 'project-action' && tab.purpose.actionId === 'build')).toBe(true);
|
||||
expect(useTerminalStore.getState().getDirectoryState('/repo')?.tabs.some((tab) => tab.purpose.type === 'project-action') ?? false).toBe(false);
|
||||
expect(openContextPanelTabCalls).toEqual([{ directory: '/repo-worktree', mode: 'terminal', targetDirectory: null }]);
|
||||
});
|
||||
|
||||
test('parent action runs in the parent checkout, stores its tab there, and reveals it from the live worktree host', async () => {
|
||||
mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build', runIn: 'parent' }];
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
await renderButton({ projectPath: '/repo', directory: '/repo-worktree' });
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(createCalls).toHaveLength(1);
|
||||
expect(createCalls[0]?.cwd).toBe('/repo');
|
||||
expect(useTerminalStore.getState().getDirectoryState('/repo')?.tabs.some((tab) => tab.purpose.type === 'project-action' && tab.purpose.actionId === 'build')).toBe(true);
|
||||
expect(useTerminalStore.getState().getDirectoryState('/repo-worktree')?.tabs.some((tab) => tab.purpose.type === 'project-action') ?? false).toBe(false);
|
||||
expect(openContextPanelTabCalls).toEqual([{ directory: '/repo-worktree', mode: 'terminal', targetDirectory: '/repo' }]);
|
||||
});
|
||||
|
||||
test('project action reveal uses the live effective host instead of the sticky action context directory', async () => {
|
||||
effectiveDirectory = '/live-host';
|
||||
await renderButton({ projectPath: '/repo', directory: '/repo-worktree' });
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(createCalls).toHaveLength(1);
|
||||
expect(createCalls[0]?.cwd).toBe('/repo-worktree');
|
||||
expect(openContextPanelTabCalls).toEqual([{ directory: '/live-host', mode: 'terminal', targetDirectory: '/repo-worktree' }]);
|
||||
});
|
||||
|
||||
test('auto-discover without a preview hint settles on an announced localhost URL in context preview only', async () => {
|
||||
mockedDeviceInfo.isMobile = false;
|
||||
detectedDevServer.command = 'bun run dev';
|
||||
await renderButton();
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const autoDiscoverTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => (
|
||||
tab.purpose.type === 'project-action' && tab.purpose.actionId === '__openchamber_auto_discover_preview__'
|
||||
));
|
||||
expect(autoDiscoverTab?.terminalSessionId).toBe('session-1');
|
||||
|
||||
await act(async () => {
|
||||
emitToSession('session-1', {
|
||||
type: 'data',
|
||||
data: 'Ready at http://127.0.0.1:4321\n',
|
||||
sequence: 1,
|
||||
replayData: undefined,
|
||||
});
|
||||
});
|
||||
await runWindowTimeouts(3_000);
|
||||
|
||||
expect(openContextPreviewCalls).toEqual([{ directory: '/repo', url: 'http://127.0.0.1:4321' }]);
|
||||
expect(openExternalCalls).toEqual([]);
|
||||
await runWindowTimeouts(15_000);
|
||||
expect(openContextPanelTabCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('auto-discover opens its terminal when no preview URL appears before the fallback timeout', async () => {
|
||||
mockedDeviceInfo.isMobile = false;
|
||||
detectedDevServer.command = 'bun run dev';
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
await renderButton({ projectPath: '/repo', directory: '/repo' });
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openContextPanelTabCalls).toEqual([]);
|
||||
effectiveDirectory = '/switched-after-launch';
|
||||
await runWindowTimeouts(15_000);
|
||||
expect(openContextPanelTabCalls).toEqual([{ directory: '/repo-worktree', mode: 'terminal', targetDirectory: '/repo' }]);
|
||||
});
|
||||
|
||||
test('unmount closes active subscriptions and cancels pending preview timeouts', async () => {
|
||||
mockedDeviceInfo.isMobile = false;
|
||||
detectedDevServer.command = 'bun run dev';
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
await renderButton({ projectPath: '/repo', directory: '/repo' });
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(subscriptions.length).toBeGreaterThan(0);
|
||||
expect(subscriptions.every((entry) => entry.closed === 0)).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
expect(subscriptions.every((entry) => entry.closed === 1)).toBe(true);
|
||||
|
||||
effectiveDirectory = '/switched-after-unmount';
|
||||
await runWindowTimeouts(15_000);
|
||||
expect(openContextPanelTabCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('stops watching running subscriptions when their execution directory leaves the watched set', async () => {
|
||||
mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', icon: 'build', runIn: 'parent' }];
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
await renderButton({ projectPath: '/repo', directory: '/repo-worktree' });
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(subscriptions.length).toBeGreaterThan(0);
|
||||
expect(subscriptions.every((entry) => entry.closed === 0)).toBe(true);
|
||||
|
||||
await renderButton({ projectPath: '/other-repo', directory: '/other-repo' });
|
||||
|
||||
expect(subscriptions.every((entry) => entry.closed === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('manual action URL does not open a second output-derived URL', async () => {
|
||||
mockedActionsState.actions = [{
|
||||
id: 'build',
|
||||
name: 'Build',
|
||||
command: 'echo hello',
|
||||
icon: 'build',
|
||||
autoOpenUrl: true,
|
||||
openUrl: '127.0.0.1:3000',
|
||||
}];
|
||||
await renderButton();
|
||||
|
||||
const primaryButton = host.querySelector('button');
|
||||
if (!primaryButton) {
|
||||
throw new Error('expected primary button');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
primaryButton.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openContextPreviewCalls).toEqual([{ directory: '/repo', url: 'http://127.0.0.1:3000/' }]);
|
||||
expect(openExternalCalls).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
emitToSession('session-1', {
|
||||
type: 'data',
|
||||
data: 'Server listening at http://127.0.0.1:4000\n',
|
||||
sequence: 1,
|
||||
replayData: undefined,
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openContextPreviewCalls).toEqual([{ directory: '/repo', url: 'http://127.0.0.1:3000/' }]);
|
||||
expect(openExternalCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useTerminalSessionKeepalive } from '@/hooks/useTerminalSessionKeepalive';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -529,6 +530,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
}, [usesExpandedLayout, currentView, viewMode]);
|
||||
|
||||
useSessionListSync({ isVSCode: true });
|
||||
useTerminalSessionKeepalive();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Regression guard for persisted context-panel terminal targets.
|
||||
*
|
||||
* The context panel keeps the singleton terminal pane mounted even when another
|
||||
* context tab is active. The mounted `TerminalView` must therefore receive its
|
||||
* directory from the stored terminal tab itself, not from whichever tab is
|
||||
* currently active.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
|
||||
|
||||
describe('context panel terminal target wiring', () => {
|
||||
test('keeps a singleton terminal tab lookup independent of the active tab', () => {
|
||||
expect(contextPanelSource).toContain('const terminalTab = React.useMemo(');
|
||||
expect(contextPanelSource).toContain("tabs.find((tab) => tab.mode === 'terminal')");
|
||||
expect(contextPanelSource).not.toContain('const hasTerminalTab = React.useMemo(');
|
||||
});
|
||||
|
||||
test('passes the stored terminal targetDirectory into the mounted TerminalView', () => {
|
||||
const renderStart = contextPanelSource.indexOf('{terminalTab ? (');
|
||||
expect(renderStart).toBeGreaterThan(-1);
|
||||
const renderEnd = contextPanelSource.indexOf('{hasWalkthroughTab ? (', renderStart);
|
||||
expect(renderEnd).toBeGreaterThan(renderStart);
|
||||
const renderBlock = contextPanelSource.slice(renderStart, renderEnd);
|
||||
|
||||
expect(renderBlock).toContain("activeTab?.mode === 'terminal' ? 'block' : 'hidden'");
|
||||
expect(renderBlock).toContain("<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} directory={terminalTab.targetDirectory} />");
|
||||
expect(renderBlock).not.toContain('directory={activeTab?.targetDirectory}');
|
||||
expect(renderBlock).not.toContain('directory={effectiveDirectory}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
const desktopSshState = { instances: [], load: async () => undefined };
|
||||
|
||||
mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false }));
|
||||
mock.module('@/stores/useDesktopSshStore', () => ({
|
||||
useDesktopSshStore: <T,>(selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState),
|
||||
}));
|
||||
mock.module('@/lib/openchamberConfig', () => ({
|
||||
getProjectActionsState: async () => ({
|
||||
actions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build' }],
|
||||
primaryActionId: null,
|
||||
}),
|
||||
saveProjectActionsState: async () => true,
|
||||
}));
|
||||
|
||||
const { ProjectActionsSection } = await import('./ProjectActionsSection');
|
||||
|
||||
describe('ProjectActionsSection', () => {
|
||||
let windowInstance: Window;
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
Object.assign(globalThis, {
|
||||
window: windowInstance,
|
||||
document: windowInstance.document,
|
||||
navigator: windowInstance.navigator,
|
||||
Node: windowInstance.Node,
|
||||
Element: windowInstance.Element,
|
||||
HTMLElement: windowInstance.HTMLElement,
|
||||
Event: windowInstance.Event,
|
||||
MouseEvent: windowInstance.MouseEvent,
|
||||
MutationObserver: windowInstance.MutationObserver,
|
||||
getComputedStyle: windowInstance.getComputedStyle.bind(windowInstance),
|
||||
requestAnimationFrame: windowInstance.requestAnimationFrame.bind(windowInstance),
|
||||
cancelAnimationFrame: windowInstance.cancelAnimationFrame.bind(windowInstance),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
windowInstance.close();
|
||||
});
|
||||
|
||||
test('shows the current worktree label when runIn is omitted', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<I18nProvider>
|
||||
<ProjectActionsSection projectRef={{ id: 'project-1', path: '/repo' }} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const actionTrigger = Array.from(host.querySelectorAll('button'))
|
||||
.find((button) => button.textContent?.includes('Build'));
|
||||
if (!actionTrigger) {
|
||||
throw new Error('expected saved action trigger');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
actionTrigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
const runInTrigger = host.querySelector<HTMLButtonElement>('button[aria-label="Working directory for this action"]');
|
||||
expect(runInTrigger?.textContent).toContain('Current worktree');
|
||||
expect(runInTrigger?.textContent).not.toContain('__project__');
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
import {
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
PROJECT_SETTINGS_CONTROL_WIDTH,
|
||||
ProjectSettingsSubsection,
|
||||
} from '@/components/sections/projects/ProjectSettingsSubsection';
|
||||
import { SETTINGS_SELECT_SIZE } from '@/components/sections/shared/SettingsSection';
|
||||
import {
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_SELECT_TRIGGER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -48,6 +51,7 @@ import { cn } from '@/lib/utils';
|
||||
type EditableProjectAction = OpenChamberProjectAction;
|
||||
|
||||
const AUTO_SAVE_DELAY_MS = 450;
|
||||
const PROJECT_RUN_IN_PARENT_VALUE = '__project__';
|
||||
|
||||
const createActionId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
@@ -342,6 +346,43 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.runIn.label')}</p>
|
||||
<SettingsInfoHint contentClassName="max-w-xs">
|
||||
{t('settings.projects.actions.runIn.info')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
<Select
|
||||
value={action.runIn === 'parent' ? PROJECT_RUN_IN_PARENT_VALUE : 'worktree'}
|
||||
onValueChange={(value) => {
|
||||
updateAction(action.id, (current) => {
|
||||
if (value === PROJECT_RUN_IN_PARENT_VALUE) {
|
||||
return { ...current, runIn: 'parent' };
|
||||
}
|
||||
|
||||
return { ...current, runIn: undefined };
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={SETTINGS_SELECT_SIZE}
|
||||
className={SETTINGS_SELECT_TRIGGER_CLASS}
|
||||
aria-label={t('settings.projects.actions.runIn.aria')}
|
||||
>
|
||||
<SelectValue>
|
||||
{(value) => value === 'worktree'
|
||||
? t('settings.projects.actions.runIn.worktree')
|
||||
: t('settings.projects.actions.runIn.project')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROJECT_RUN_IN_PARENT_VALUE}>{t('settings.projects.actions.runIn.project')}</SelectItem>
|
||||
<SelectItem value="worktree">{t('settings.projects.actions.runIn.worktree')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.projects.actions.field.autoOpenUrl')}</span>
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { useTerminalStore, type TerminalChunk } from '@/stores/useTerminalStore';
|
||||
|
||||
const terminalEvents: Array<{ type: 'write'; data: string } | { type: 'reset' }> = [];
|
||||
|
||||
class GhosttyTerminalDouble {
|
||||
public options: { cursorBlink: boolean };
|
||||
public cols = 80;
|
||||
public rows = 24;
|
||||
|
||||
constructor(options: { cursorBlink?: boolean }) {
|
||||
this.options = { cursorBlink: options.cursorBlink ?? false };
|
||||
}
|
||||
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData() {
|
||||
return { dispose() {} };
|
||||
}
|
||||
write(data: string, callback?: () => void) {
|
||||
terminalEvents.push({ type: 'write', data });
|
||||
callback?.();
|
||||
}
|
||||
reset() {
|
||||
terminalEvents.push({ type: 'reset' });
|
||||
}
|
||||
focus() {}
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
class FitAddonDouble {
|
||||
fit() {}
|
||||
}
|
||||
|
||||
mock.module('ghostty-web', () => ({
|
||||
Ghostty: { load: async () => ({}) },
|
||||
Terminal: GhosttyTerminalDouble,
|
||||
FitAddon: FitAddonDouble,
|
||||
}));
|
||||
|
||||
const { TerminalViewport } = await import('./TerminalViewport');
|
||||
|
||||
const theme = {
|
||||
background: '#000000',
|
||||
foreground: '#ffffff',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selectionBackground: '#334155',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#111111',
|
||||
red: '#ff0000',
|
||||
green: '#00ff00',
|
||||
yellow: '#ffff00',
|
||||
blue: '#0000ff',
|
||||
magenta: '#ff00ff',
|
||||
cyan: '#00ffff',
|
||||
white: '#ffffff',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#ff0000',
|
||||
brightGreen: '#00ff00',
|
||||
brightYellow: '#ffff00',
|
||||
brightBlue: '#0000ff',
|
||||
brightMagenta: '#ff00ff',
|
||||
brightCyan: '#00ffff',
|
||||
brightWhite: '#ffffff',
|
||||
} as const;
|
||||
|
||||
const flushGhosttyLoad = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const TERMINAL_BUFFER_CAP = 512 * 1024;
|
||||
|
||||
const replayWriteEvents = (expectedPayloads: string[]) => terminalEvents.filter(
|
||||
(event): event is { type: 'write'; data: string } => event.type === 'write' && expectedPayloads.includes(event.data),
|
||||
);
|
||||
|
||||
const buildReplacedBufferChunks = (content: string): TerminalChunk[] => {
|
||||
const directory = '/fixture';
|
||||
useTerminalStore.getState().clearAll();
|
||||
useTerminalStore.getState().ensureDirectory(directory);
|
||||
const tabId = useTerminalStore.getState().getDirectoryState(directory)?.tabs[0]?.id;
|
||||
if (!tabId) throw new Error('fixture tab missing');
|
||||
useTerminalStore.getState().replaceBuffer(directory, tabId, content, 1);
|
||||
return [...useTerminalStore.getState().getBuffer(directory, tabId).chunks];
|
||||
};
|
||||
|
||||
const renderViewport = (root: Root, chunks: TerminalChunk[]) => act(async () => {
|
||||
root.render(
|
||||
<TerminalViewport
|
||||
sessionKey="session-1"
|
||||
chunks={chunks}
|
||||
onInput={() => undefined}
|
||||
onResize={() => undefined}
|
||||
theme={theme}
|
||||
monoFont="geist-mono"
|
||||
fontFamily="Geist Mono"
|
||||
fontSize={14}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
describe('TerminalViewport chunk replay integration', () => {
|
||||
let windowInstance: Window;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
terminalEvents.length = 0;
|
||||
useTerminalStore.getState().clearAll();
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
Object.assign(globalThis, {
|
||||
window: windowInstance,
|
||||
document: windowInstance.document,
|
||||
navigator: windowInstance.navigator,
|
||||
HTMLElement: windowInstance.HTMLElement,
|
||||
Element: windowInstance.Element,
|
||||
Node: windowInstance.Node,
|
||||
Event: windowInstance.Event,
|
||||
InputEvent: windowInstance.InputEvent,
|
||||
KeyboardEvent: windowInstance.KeyboardEvent,
|
||||
MouseEvent: windowInstance.MouseEvent,
|
||||
FocusEvent: windowInstance.FocusEvent,
|
||||
ResizeObserver: class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame: () => undefined,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
Object.defineProperty(windowInstance.document, 'hasFocus', {
|
||||
configurable: true,
|
||||
value: () => true,
|
||||
});
|
||||
Object.defineProperty(windowInstance.HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value() {
|
||||
return { x: 0, y: 0, top: 0, left: 0, right: 800, bottom: 600, width: 800, height: 600 };
|
||||
},
|
||||
});
|
||||
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
useTerminalStore.getState().clearAll();
|
||||
});
|
||||
|
||||
test('would fail if adopted-buffer remount replay split history writes or exceeded the capped buffer payload', async () => {
|
||||
const replayChunks: TerminalChunk[] = [
|
||||
{ id: 1, data: 'live-one\n', replayData: 'replay-one\n', byteLength: 9 },
|
||||
{ id: 2, data: 'live-two\n', replayData: 'replay-two\n', byteLength: 9 },
|
||||
{ id: 3, data: 'live-three\n', byteLength: 11 },
|
||||
];
|
||||
const replayPayload = 'replay-one\nreplay-two\nlive-three\n';
|
||||
|
||||
await renderViewport(root, replayChunks);
|
||||
await flushGhosttyLoad();
|
||||
|
||||
expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(0);
|
||||
expect(replayWriteEvents([replayPayload])).toEqual([{ type: 'write', data: replayPayload }]);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
terminalEvents.length = 0;
|
||||
|
||||
const oversizedReplayChunks = buildReplacedBufferChunks(`${'🙂'.repeat(180_000)}tail`);
|
||||
const oversizedPayload = oversizedReplayChunks.map((chunk) => chunk.data).join('');
|
||||
|
||||
await renderViewport(root, oversizedReplayChunks);
|
||||
await flushGhosttyLoad();
|
||||
|
||||
expect(replayWriteEvents([oversizedPayload])).toEqual([{ type: 'write', data: oversizedPayload }]);
|
||||
expect(new TextEncoder().encode(oversizedPayload).byteLength).toBeLessThanOrEqual(TERMINAL_BUFFER_CAP);
|
||||
});
|
||||
|
||||
test('would fail if authoritative replacement replay reset twice or re-streamed replacement history chunk-by-chunk', async () => {
|
||||
const initialChunks: TerminalChunk[] = [
|
||||
{ id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 },
|
||||
];
|
||||
const appendedChunks: TerminalChunk[] = [
|
||||
...initialChunks,
|
||||
{ id: 2, data: 'append-live\n', replayData: 'append-replay\n', byteLength: 12 },
|
||||
];
|
||||
const replacementChunks: TerminalChunk[] = [
|
||||
{ id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 },
|
||||
{ id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 },
|
||||
];
|
||||
const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n';
|
||||
|
||||
await renderViewport(root, initialChunks);
|
||||
await flushGhosttyLoad();
|
||||
terminalEvents.length = 0;
|
||||
|
||||
await renderViewport(root, appendedChunks);
|
||||
expect(terminalEvents).toEqual([{ type: 'write', data: 'append-live\n' }]);
|
||||
|
||||
terminalEvents.length = 0;
|
||||
await renderViewport(root, replacementChunks);
|
||||
expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1);
|
||||
expect(replayWriteEvents([replacementReplayPayload])).toEqual([{ type: 'write', data: replacementReplayPayload }]);
|
||||
expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-1\n')).toBe(false);
|
||||
expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-replay-2\n')).toBe(false);
|
||||
expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-1\n')).toBe(false);
|
||||
expect(terminalEvents.some((event) => event.type === 'write' && event.data === 'history-live-2\n')).toBe(false);
|
||||
});
|
||||
|
||||
test('would fail if a live append after replacement replay duplicated history or lost the new chunk ordering', async () => {
|
||||
const initialChunks: TerminalChunk[] = [
|
||||
{ id: 1, data: 'initial-live\n', replayData: 'initial-replay\n', byteLength: 13 },
|
||||
];
|
||||
const replacementChunks: TerminalChunk[] = [
|
||||
{ id: 3, data: 'history-live-1\n', replayData: 'history-replay-1\n', byteLength: 15 },
|
||||
{ id: 4, data: 'history-live-2\n', replayData: 'history-replay-2\n', byteLength: 15 },
|
||||
];
|
||||
const resumedChunks: TerminalChunk[] = [
|
||||
...replacementChunks,
|
||||
{ id: 5, data: 'tail-live\n', replayData: 'tail-replay\n', byteLength: 10 },
|
||||
];
|
||||
const replacementReplayPayload = 'history-replay-1\nhistory-replay-2\n';
|
||||
|
||||
await renderViewport(root, initialChunks);
|
||||
await flushGhosttyLoad();
|
||||
|
||||
terminalEvents.length = 0;
|
||||
await renderViewport(root, replacementChunks);
|
||||
await renderViewport(root, resumedChunks);
|
||||
|
||||
expect(terminalEvents.filter((event) => event.type === 'reset')).toHaveLength(1);
|
||||
expect(replayWriteEvents([replacementReplayPayload, 'tail-live\n'])).toEqual([
|
||||
{ type: 'write', data: replacementReplayPayload },
|
||||
{ type: 'write', data: 'tail-live\n' },
|
||||
]);
|
||||
expect(terminalEvents.filter((event) => event.type === 'write' && event.data === replacementReplayPayload)).toHaveLength(1);
|
||||
expect(terminalEvents.filter((event) => event.type === 'write' && event.data === 'tail-live\n')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from '@/lib/terminalTouchSelection';
|
||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
|
||||
import { selectTerminalChunkReplay } from './terminalChunkReplay';
|
||||
|
||||
// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView
|
||||
// stays eagerly importable for the bottom dock without pulling the emulator
|
||||
// into the startup graph before a terminal is actually mounted.
|
||||
@@ -328,29 +330,12 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||
React.useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
if (chunks.length === 0) {
|
||||
if (lastChunkRef.current !== null) recreateRenderer();
|
||||
return;
|
||||
}
|
||||
const previous = lastChunkRef.current;
|
||||
// Chunk ids are monotonic and the store appends, so the already-written chunk
|
||||
// is normally the last one. Scanning from the end keeps this O(1) per chunk
|
||||
// instead of O(chunks) on every streamed write.
|
||||
let previousIndex = -1;
|
||||
if (previous !== null) {
|
||||
for (let index = chunks.length - 1; index >= 0; index -= 1) {
|
||||
const id = chunks[index].id;
|
||||
if (id === previous) { previousIndex = index; break; }
|
||||
if (id < previous) break;
|
||||
}
|
||||
if (previousIndex < 0) {
|
||||
recreateRenderer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const isReplay = previousIndex < 0;
|
||||
const pending = previousIndex >= 0 ? chunks.slice(previousIndex + 1) : chunks;
|
||||
writeQueueRef.current += pending.map((chunk) => isReplay ? (chunk.replayData ?? chunk.data) : chunk.data).join('');
|
||||
const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current);
|
||||
if (reset) recreateRenderer();
|
||||
if (pending.length === 0) return;
|
||||
writeQueueRef.current += pending
|
||||
.map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data)
|
||||
.join('');
|
||||
lastChunkRef.current = chunks.at(-1)?.id ?? null;
|
||||
flush();
|
||||
}, [chunks, flush, ready, recreateRenderer]);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
|
||||
import { selectTerminalChunkReplay } from './terminalChunkReplay';
|
||||
|
||||
const chunk = (id: number, data = `${id}`): TerminalChunk => ({
|
||||
id,
|
||||
data,
|
||||
byteLength: data.length,
|
||||
});
|
||||
|
||||
describe('selectTerminalChunkReplay', () => {
|
||||
const firstMountChunks = [chunk(1, 'one'), chunk(2, 'two')];
|
||||
const incrementalChunks = [chunk(4, 'four'), chunk(5, 'five'), chunk(6, 'six')];
|
||||
const replacementChunks = [chunk(8, 'eight'), chunk(9, 'nine')];
|
||||
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
chunks: TerminalChunk[];
|
||||
lastChunkId: number | null;
|
||||
expected: { reset: boolean; replay: boolean; pending: TerminalChunk[] };
|
||||
}> = [
|
||||
{
|
||||
name: 'first mount replays the full current buffer without resetting the renderer',
|
||||
chunks: firstMountChunks,
|
||||
lastChunkId: null,
|
||||
expected: { reset: false, replay: true, pending: firstMountChunks },
|
||||
},
|
||||
{
|
||||
name: 'known tail appends only newer chunks incrementally',
|
||||
chunks: incrementalChunks,
|
||||
lastChunkId: 5,
|
||||
expected: { reset: false, replay: false, pending: [incrementalChunks[2]!] },
|
||||
},
|
||||
{
|
||||
name: 'missing prior id replaces the whole current buffer',
|
||||
chunks: replacementChunks,
|
||||
lastChunkId: 7,
|
||||
expected: { reset: true, replay: true, pending: replacementChunks },
|
||||
},
|
||||
{
|
||||
name: 'a prior id newer than the current tail replaces the whole current buffer',
|
||||
chunks: replacementChunks,
|
||||
lastChunkId: 10,
|
||||
expected: { reset: true, replay: true, pending: replacementChunks },
|
||||
},
|
||||
{
|
||||
name: 'an empty current buffer resets only when prior content existed',
|
||||
chunks: [],
|
||||
lastChunkId: 12,
|
||||
expected: { reset: true, replay: false, pending: [] },
|
||||
},
|
||||
{
|
||||
name: 'an empty current buffer without prior content is a no-op',
|
||||
chunks: [],
|
||||
lastChunkId: null,
|
||||
expected: { reset: false, replay: false, pending: [] },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, chunks, lastChunkId, expected } of cases) {
|
||||
test(name, () => {
|
||||
expect(selectTerminalChunkReplay(chunks, lastChunkId)).toEqual(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
|
||||
export interface TerminalChunkReplaySelection {
|
||||
reset: boolean;
|
||||
replay: boolean;
|
||||
pending: TerminalChunk[];
|
||||
}
|
||||
|
||||
export function selectTerminalChunkReplay(
|
||||
chunks: TerminalChunk[],
|
||||
lastChunkId: number | null,
|
||||
): TerminalChunkReplaySelection {
|
||||
if (chunks.length === 0) {
|
||||
return {
|
||||
reset: lastChunkId !== null,
|
||||
replay: false,
|
||||
pending: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (lastChunkId === null) {
|
||||
return {
|
||||
reset: false,
|
||||
replay: true,
|
||||
pending: chunks,
|
||||
};
|
||||
}
|
||||
|
||||
let previousIndex = -1;
|
||||
for (let index = chunks.length - 1; index >= 0; index -= 1) {
|
||||
const id = chunks[index].id;
|
||||
if (id === lastChunkId) {
|
||||
previousIndex = index;
|
||||
break;
|
||||
}
|
||||
if (id < lastChunkId) break;
|
||||
}
|
||||
|
||||
if (previousIndex < 0) {
|
||||
return {
|
||||
reset: true,
|
||||
replay: true,
|
||||
pending: chunks,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reset: false,
|
||||
replay: false,
|
||||
pending: chunks.slice(previousIndex + 1),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import type { TerminalHandlers } from '@/lib/api/types';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
|
||||
let effectiveDirectory = '/repo';
|
||||
const openContextPreviewCalls: Array<[string, string]> = [];
|
||||
const createSessionCalls: Array<{ cwd: string }> = [];
|
||||
const connectCalls: string[] = [];
|
||||
const ensureDirectoryCalls: string[] = [];
|
||||
const openContextPreview = (directory: string, url: string) => {
|
||||
openContextPreviewCalls.push([directory, url]);
|
||||
};
|
||||
const createSession = async ({ cwd }: { cwd: string }) => {
|
||||
createSessionCalls.push({ cwd });
|
||||
return { sessionId: 'unused', cols: 80, rows: 24, status: 'running' as const };
|
||||
};
|
||||
let connectBehavior: (sessionId: string, handlers: TerminalHandlers) => { close: () => void } = () => ({ close: () => undefined });
|
||||
const terminalRuntime = {
|
||||
createSession,
|
||||
sendInput: async () => undefined,
|
||||
resize: async () => undefined,
|
||||
close: async () => undefined,
|
||||
updateAppearance: async () => undefined,
|
||||
connect: (sessionId: string, handlers: TerminalHandlers) => {
|
||||
connectCalls.push(sessionId);
|
||||
return connectBehavior(sessionId, handlers);
|
||||
},
|
||||
};
|
||||
const runtimeApis = {
|
||||
runtime: { platform: 'web' as const },
|
||||
terminal: terminalRuntime,
|
||||
};
|
||||
const i18n = { t: (key: string) => key };
|
||||
|
||||
const sessionUiState = {
|
||||
currentSessionId: 'session-1',
|
||||
newSessionDraft: null,
|
||||
};
|
||||
|
||||
const useSessionUIStoreMock = <T,>(selector: (state: typeof sessionUiState) => T): T => selector(sessionUiState);
|
||||
|
||||
const uiState = {
|
||||
terminalFontSize: 14,
|
||||
terminalShell: 'zsh',
|
||||
terminalLoginShells: ['zsh'],
|
||||
showTerminalQuickKeysOnDesktop: false,
|
||||
openContextPreview,
|
||||
};
|
||||
|
||||
const useUiStoreMock = Object.assign(
|
||||
<T,>(selector: (state: typeof uiState) => T): T => selector(uiState),
|
||||
{ getState: () => uiState },
|
||||
);
|
||||
|
||||
mock.module('@/sync/session-ui-store', () => ({ useSessionUIStore: useSessionUIStoreMock }));
|
||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => effectiveDirectory }));
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({
|
||||
useRuntimeAPIs: () => runtimeApis,
|
||||
}));
|
||||
mock.module('@/contexts/useThemeSystem', () => ({
|
||||
useThemeSystem: () => ({
|
||||
currentTheme: {
|
||||
metadata: { variant: 'dark' },
|
||||
colors: {
|
||||
surface: {
|
||||
background: '#000',
|
||||
muted: '#111',
|
||||
elevatedForeground: '#fff',
|
||||
},
|
||||
syntax: {
|
||||
base: { foreground: '#fff' },
|
||||
function: '#7dd3fc',
|
||||
keyword: '#c084fc',
|
||||
type: '#67e8f9',
|
||||
comment: '#6b7280',
|
||||
},
|
||||
interactive: {
|
||||
cursor: '#fff',
|
||||
selection: '#334155',
|
||||
selectionForeground: '#fff',
|
||||
},
|
||||
status: {
|
||||
error: '#f87171',
|
||||
success: '#4ade80',
|
||||
warning: '#fbbf24',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
mock.module('@/hooks/useFontPreferences', () => ({ useFontPreferences: () => ({ monoFont: 'geist-mono' }) }));
|
||||
mock.module('@/lib/device', () => ({ useDeviceInfo: () => ({ isMobile: false, isTablet: false, hasTouchOnlyPointer: false }) }));
|
||||
mock.module('@/stores/useUIStore', () => ({ useUIStore: useUiStoreMock }));
|
||||
mock.module('@/stores/useInlineCommentDraftStore', () => ({ useInlineCommentDraftStore: () => ({ addDraft: () => undefined }) }));
|
||||
mock.module('@/components/terminal/TerminalViewport', () => ({
|
||||
TerminalViewport: React.forwardRef(function TerminalViewportMock(
|
||||
{ sessionKey, chunks, isVisible }: { sessionKey: string; chunks: unknown[]; isVisible: boolean },
|
||||
ref: React.ForwardedRef<{ focus: () => void; fit: () => void; getSelection: () => null }>,
|
||||
) {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => undefined,
|
||||
fit: () => undefined,
|
||||
getSelection: () => null,
|
||||
}), []);
|
||||
|
||||
return React.createElement('div', {
|
||||
'data-terminal-viewport': 'true',
|
||||
'data-session-key': sessionKey,
|
||||
'data-visible': String(isVisible),
|
||||
'data-chunk-count': String(chunks.length),
|
||||
});
|
||||
}),
|
||||
}));
|
||||
mock.module('@/components/icon/Icon', () => ({
|
||||
Icon: ({ name, className }: { name: string; className?: string }) => React.createElement('span', { 'data-icon': name, className }),
|
||||
}));
|
||||
mock.module('@/components/ui/sortable-tabs-strip', () => ({
|
||||
SortableTabsStrip: ({ items }: { items: Array<{ id: string; label: string; icon?: React.ReactNode }> }) => React.createElement(
|
||||
'div',
|
||||
{ 'data-tabs-strip': 'terminal' },
|
||||
items.map((item) => React.createElement(
|
||||
'div',
|
||||
{ key: item.id, 'data-tab-id': item.id },
|
||||
item.icon,
|
||||
React.createElement('span', { 'data-tab-label': item.id }, item.label),
|
||||
)),
|
||||
),
|
||||
}));
|
||||
mock.module('@/lib/i18n', () => ({ useI18n: () => i18n }));
|
||||
|
||||
const { TerminalView } = await import('./TerminalView');
|
||||
|
||||
const ensureDirectorySpy = (directory: string) => {
|
||||
ensureDirectoryCalls.push(directory);
|
||||
useTerminalStore.setState((state) => {
|
||||
if (state.sessions.get(directory)) return state;
|
||||
|
||||
const tab = {
|
||||
id: `spy-tab-${directory}`,
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle' as const,
|
||||
purpose: { type: 'terminal' as const },
|
||||
label: 'Terminal',
|
||||
iconKey: null,
|
||||
isConnecting: false,
|
||||
createdAt: Date.now(),
|
||||
previewUrl: null,
|
||||
previewAutoOpened: false,
|
||||
previewUrlLocked: false,
|
||||
};
|
||||
|
||||
const sessions = new Map(state.sessions);
|
||||
sessions.set(directory, { tabs: [tab], activeTabId: tab.id });
|
||||
return { sessions };
|
||||
});
|
||||
};
|
||||
|
||||
const flushEffects = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const bufferEntryKey = (directory: string, tabId: string) => `${directory}\u0000${tabId}`;
|
||||
const readBufferContent = (directory: string, tabId: string) => useTerminalStore.getState().getBuffer(directory, tabId).chunks.map((chunk) => chunk.data).join('');
|
||||
|
||||
describe('TerminalView project action tab indicator', () => {
|
||||
let windowInstance: Window;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
effectiveDirectory = '/repo';
|
||||
openContextPreviewCalls.length = 0;
|
||||
createSessionCalls.length = 0;
|
||||
connectCalls.length = 0;
|
||||
ensureDirectoryCalls.length = 0;
|
||||
connectBehavior = () => ({ close: () => undefined });
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
Object.assign(globalThis, {
|
||||
window: windowInstance,
|
||||
document: windowInstance.document,
|
||||
navigator: windowInstance.navigator,
|
||||
HTMLElement: windowInstance.HTMLElement,
|
||||
Element: windowInstance.Element,
|
||||
Node: windowInstance.Node,
|
||||
Event: windowInstance.Event,
|
||||
KeyboardEvent: windowInstance.KeyboardEvent,
|
||||
MouseEvent: windowInstance.MouseEvent,
|
||||
ResizeObserver: class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
|
||||
useTerminalStore.getState().clearAll();
|
||||
useTerminalStore.setState({ ensureDirectory: ensureDirectorySpy });
|
||||
useTerminalStore.getState().ensureDirectory('/repo');
|
||||
|
||||
const interactiveTabId = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabLabel('/repo', interactiveTabId, 'Interactive');
|
||||
|
||||
const runningActionTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabLabel('/repo', runningActionTabId, 'Build');
|
||||
useTerminalStore.getState().setTabIconKey('/repo', runningActionTabId, 'build');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', runningActionTabId, { type: 'project-action', actionId: 'build', executionId: 'exec-running' });
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', runningActionTabId, 'running');
|
||||
|
||||
const exitedActionTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabLabel('/repo', exitedActionTabId, 'Deploy');
|
||||
useTerminalStore.getState().setTabIconKey('/repo', exitedActionTabId, 'play');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', exitedActionTabId, { type: 'project-action', actionId: 'deploy', executionId: 'exec-exited' });
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', exitedActionTabId, 'exited');
|
||||
ensureDirectoryCalls.length = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
useTerminalStore.getState().clearAll();
|
||||
});
|
||||
|
||||
test('shows a spinner only for active project-action tabs and keeps terminal or action icons elsewhere', async () => {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: false }));
|
||||
});
|
||||
|
||||
const tabs = Array.from(host.querySelectorAll('[data-tab-id]'));
|
||||
expect(tabs).toHaveLength(3);
|
||||
|
||||
const interactiveTab = tabs.find((tab) => tab.querySelector('[data-tab-label]')?.textContent === 'Interactive');
|
||||
const runningActionTab = tabs.find((tab) => tab.querySelector('[data-tab-label]')?.textContent === 'Build');
|
||||
const exitedActionTab = tabs.find((tab) => tab.querySelector('[data-tab-label]')?.textContent === 'Deploy');
|
||||
|
||||
expect(interactiveTab?.querySelector('[data-icon]')?.getAttribute('data-icon')).toBe('terminal');
|
||||
expect(runningActionTab?.querySelector('[data-icon]')?.getAttribute('data-icon')).toBe('loader-4');
|
||||
expect(runningActionTab?.querySelector('[data-icon]')?.className).toContain('animate-spin');
|
||||
expect(runningActionTab?.querySelector('[data-icon]')?.className).toContain('motion-reduce:animate-none');
|
||||
expect(runningActionTab?.querySelector('[data-icon]')?.className).toContain('text-muted-foreground');
|
||||
expect(exitedActionTab?.querySelector('[data-icon]')?.getAttribute('data-icon')).toBe('play');
|
||||
expect(host.querySelectorAll('[data-icon="loader-4"]').length).toBe(1);
|
||||
});
|
||||
|
||||
test('uses the explicit terminal directory for terminal tabs and session creation while preview ownership stays on the host directory', async () => {
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
useTerminalStore.getState().ensureDirectory('/repo-worktree');
|
||||
const worktreeTabId = useTerminalStore.getState().getDirectoryState('/repo-worktree')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabLabel('/repo-worktree', worktreeTabId, 'Worktree Terminal');
|
||||
|
||||
const repoTabId = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabLabel('/repo', repoTabId, 'Repo Terminal');
|
||||
useTerminalStore.getState().setTabPreviewUrl('/repo', repoTabId, 'https://preview.example.test');
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: true, directory: '/repo' }));
|
||||
});
|
||||
|
||||
const tabLabels = Array.from(host.querySelectorAll('[data-tab-label]')).map((node) => node.textContent);
|
||||
expect(tabLabels).toContain('Repo Terminal');
|
||||
expect(tabLabels).not.toContain('Worktree Terminal');
|
||||
expect(createSessionCalls.length).toBe(1);
|
||||
expect(createSessionCalls[0]?.cwd).toBe('/repo');
|
||||
|
||||
const previewButton = host.querySelector<HTMLElement>('[title="terminalView.preview.openTitle"]');
|
||||
expect(previewButton).not.toBeNull();
|
||||
previewButton?.click();
|
||||
expect(openContextPreviewCalls).toEqual([['/repo-worktree', 'https://preview.example.test']]);
|
||||
});
|
||||
|
||||
test('keeps the existing context-directory behavior when no explicit terminal directory is provided', async () => {
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
useTerminalStore.getState().ensureDirectory('/repo-worktree');
|
||||
const worktreeTabId = useTerminalStore.getState().getDirectoryState('/repo-worktree')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabLabel('/repo-worktree', worktreeTabId, 'Worktree Terminal');
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: true }));
|
||||
});
|
||||
|
||||
const tabLabels = Array.from(host.querySelectorAll('[data-tab-label]')).map((node) => node.textContent);
|
||||
expect(tabLabels).toContain('Worktree Terminal');
|
||||
expect(createSessionCalls.length).toBe(1);
|
||||
expect(createSessionCalls[0]?.cwd).toBe('/repo-worktree');
|
||||
});
|
||||
|
||||
test('treats an explicit terminal target with no terminal state as an inert reveal', async () => {
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
useTerminalStore.getState().ensureDirectory('/repo-worktree');
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: true, directory: '/missing-repo' }));
|
||||
});
|
||||
|
||||
expect(ensureDirectoryCalls).not.toContain('/missing-repo');
|
||||
expect(createSessionCalls.length).toBe(0);
|
||||
expect(host.querySelector('[data-tabs-strip="terminal"]')).toBeNull();
|
||||
expect(host.querySelector('[data-terminal-viewport="true"]')?.getAttribute('data-chunk-count')).toBe('0');
|
||||
});
|
||||
|
||||
test('includes the terminal directory in the viewport identity key', async () => {
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
useTerminalStore.getState().ensureDirectory('/repo-worktree');
|
||||
|
||||
useTerminalStore.setState((state) => {
|
||||
const repoTab = state.sessions.get('/repo')!.tabs[0]!;
|
||||
const sessions = new Map(state.sessions);
|
||||
sessions.set('/repo-worktree', {
|
||||
tabs: [{ ...repoTab, label: 'Mirrored Terminal' }],
|
||||
activeTabId: repoTab.id,
|
||||
});
|
||||
return { sessions };
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: true }));
|
||||
});
|
||||
const contextKey = host.querySelector('[data-terminal-viewport="true"]')!.getAttribute('data-session-key');
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: true, directory: '/repo' }));
|
||||
});
|
||||
const targetKey = host.querySelector('[data-terminal-viewport="true"]')!.getAttribute('data-session-key');
|
||||
|
||||
expect(contextKey).not.toBe(targetKey);
|
||||
expect(contextKey).toContain('/repo-worktree');
|
||||
expect(targetKey).toContain('/repo');
|
||||
});
|
||||
|
||||
test('would fail if revisit attach skipped the active running project-action snapshot restore', async () => {
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo');
|
||||
const actionTab = state?.tabs.find((tab) => tab.label === 'Build');
|
||||
expect(actionTab).toBeDefined();
|
||||
if (!actionTab) throw new Error('action tab missing');
|
||||
|
||||
useTerminalStore.getState().setTabSessionId('/repo', actionTab.id, 'srv-build', { expectedExecutionId: 'exec-running' });
|
||||
useTerminalStore.getState().setActiveTab('/repo', actionTab.id);
|
||||
|
||||
const snapshotData = 'snapshot history\nfinal line\n';
|
||||
let replaceCount = 0;
|
||||
const unsubscribe = useTerminalStore.subscribe((nextState, previousState) => {
|
||||
const next = nextState.buffers.get(bufferEntryKey('/repo', actionTab.id));
|
||||
const previous = previousState.buffers.get(bufferEntryKey('/repo', actionTab.id));
|
||||
const nextContent = next?.chunks.map((chunk) => chunk.data).join('') ?? '';
|
||||
const previousContent = previous?.chunks.map((chunk) => chunk.data).join('') ?? '';
|
||||
if (nextContent === snapshotData && previousContent !== snapshotData && next?.lastSequence === 7) {
|
||||
replaceCount += 1;
|
||||
}
|
||||
});
|
||||
connectBehavior = (_sessionId, handlers) => {
|
||||
void Promise.resolve().then(() => {
|
||||
handlers.onEvent({ type: 'snapshot', data: snapshotData, sequence: 7, status: 'running' });
|
||||
});
|
||||
return { close: () => undefined };
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: true }));
|
||||
});
|
||||
await flushEffects();
|
||||
unsubscribe();
|
||||
|
||||
expect(connectCalls).toEqual(['srv-build']);
|
||||
expect(createSessionCalls.length).toBe(0);
|
||||
expect(readBufferContent('/repo', actionTab.id)).toBe(snapshotData);
|
||||
expect(useTerminalStore.getState().getBuffer('/repo', actionTab.id).lastSequence).toBe(7);
|
||||
expect(replaceCount).toBe(1);
|
||||
});
|
||||
|
||||
test('would fail if retained parent action targets rendered worktree tabs or attached the wrong session', async () => {
|
||||
effectiveDirectory = '/repo-worktree';
|
||||
useTerminalStore.getState().ensureDirectory('/repo-worktree');
|
||||
const worktreeTabId = useTerminalStore.getState().getDirectoryState('/repo-worktree')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabLabel('/repo-worktree', worktreeTabId, 'Worktree Terminal');
|
||||
|
||||
const repoState = useTerminalStore.getState().getDirectoryState('/repo');
|
||||
const repoActionTab = repoState?.tabs.find((tab) => tab.label === 'Build');
|
||||
expect(repoActionTab).toBeDefined();
|
||||
if (!repoActionTab) throw new Error('repo action tab missing');
|
||||
|
||||
useTerminalStore.getState().setTabLabel('/repo', repoActionTab.id, 'Repo Build');
|
||||
useTerminalStore.getState().setTabSessionId('/repo', repoActionTab.id, 'srv-parent-build', { expectedExecutionId: 'exec-running' });
|
||||
useTerminalStore.getState().setActiveTab('/repo', repoActionTab.id);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TerminalView, { visible: true, directory: '/repo' }));
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
const tabLabels = Array.from(host.querySelectorAll('[data-tab-label]')).map((node) => node.textContent);
|
||||
expect(tabLabels).toContain('Repo Build');
|
||||
expect(tabLabels).toContain('Interactive');
|
||||
expect(tabLabels).not.toContain('Worktree Terminal');
|
||||
expect(connectCalls).toEqual(['srv-parent-build']);
|
||||
expect(createSessionCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { ACTIVE_PROJECT_ACTION_LIFECYCLES, EMPTY_TERMINAL_BUFFER, useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { type TerminalStreamEvent } from '@/lib/api/types';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
@@ -14,22 +14,29 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
|
||||
import { PROJECT_ACTION_ICONS } from '@/lib/projectActions';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import { reconcileTerminalSessionAuthority } from '@/lib/projectActionTerminal';
|
||||
|
||||
type TerminalViewProps = {
|
||||
visible?: boolean;
|
||||
directory?: string | null;
|
||||
};
|
||||
|
||||
const FALLBACK_TERMINAL_SIZE = { cols: 80, rows: 24 } as const;
|
||||
const resolveTabIconName = (iconKey: string | null): IconName => {
|
||||
const matchedIcon = PROJECT_ACTION_ICONS.find((entry) => entry.key === iconKey);
|
||||
return matchedIcon?.Icon ?? 'terminal';
|
||||
};
|
||||
|
||||
export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }) => {
|
||||
const { t } = useI18n();
|
||||
const { terminal, runtime } = useRuntimeAPIs();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
@@ -51,15 +58,19 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
|
||||
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? null;
|
||||
const directoryTerminalState = useTerminalStore((s) => effectiveDirectory ? s.sessions.get(effectiveDirectory) : undefined);
|
||||
const contextDirectory = useEffectiveDirectory() ?? null;
|
||||
const targetDirectory = directory ?? null;
|
||||
const terminalDirectory = targetDirectory || contextDirectory;
|
||||
const hasExplicitTerminalTarget = targetDirectory !== null;
|
||||
const directoryTerminalState = useTerminalStore((s) => terminalDirectory ? s.sessions.get(terminalDirectory) : undefined);
|
||||
const terminalHydrated = useTerminalStore((s) => s.hasHydrated);
|
||||
const ensureDirectory = useTerminalStore((s) => s.ensureDirectory);
|
||||
const createTab = useTerminalStore((s) => s.createTab);
|
||||
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
|
||||
const closeTab = useTerminalStore((s) => s.closeTab);
|
||||
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
|
||||
const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions);
|
||||
const reconcileServerSessions = useTerminalStore((s) => s.reconcileServerSessions);
|
||||
const captureStartedActionMutationRevisions = useTerminalStore((s) => s.captureStartedActionMutationRevisions);
|
||||
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
|
||||
const setConnecting = useTerminalStore((s) => s.setConnecting);
|
||||
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
|
||||
@@ -89,8 +100,20 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const terminalTabItems = React.useMemo(() => {
|
||||
return (directoryTerminalState?.tabs ?? []).map((tab) => ({
|
||||
icon: (() => {
|
||||
const tabIconName = tab.iconKey ? PROJECT_ACTION_ICON_MAP[tab.iconKey as ProjectActionIconKey] ?? 'terminal' : 'terminal';
|
||||
return <Icon name={tabIconName} className="h-4 w-4" />;
|
||||
const showProjectActionSpinner = tab.purpose.type === 'project-action'
|
||||
&& ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle);
|
||||
const tabIconName = showProjectActionSpinner
|
||||
? 'loader-4'
|
||||
: resolveTabIconName(tab.iconKey);
|
||||
return (
|
||||
<Icon
|
||||
name={tabIconName}
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
showProjectActionSpinner && 'animate-spin text-muted-foreground motion-reduce:animate-none'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})(),
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
@@ -101,9 +124,10 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
|
||||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
|
||||
const isActionTab = activeTab?.purpose.type === 'project-action';
|
||||
// Scrollback is a leaf subscription: streaming output must not rerender the tab strip.
|
||||
const bufferChunks = useTerminalStore((s) => (
|
||||
effectiveDirectory && activeTabId ? s.getBuffer(effectiveDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks
|
||||
terminalDirectory && activeTabId ? s.getBuffer(terminalDirectory, activeTabId).chunks : EMPTY_TERMINAL_BUFFER.chunks
|
||||
));
|
||||
const isConnecting = activeTab?.isConnecting ?? false;
|
||||
const previewUrl = activeTab?.previewUrl ?? null;
|
||||
@@ -118,7 +142,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const activeTerminalIdRef = React.useRef<string | null>(null);
|
||||
const activeTabIdRef = React.useRef<string | null>(activeTabId);
|
||||
const terminalIdRef = React.useRef<string | null>(terminalSessionId);
|
||||
const directoryRef = React.useRef<string | null>(effectiveDirectory);
|
||||
const directoryRef = React.useRef<string | null>(terminalDirectory);
|
||||
const terminalControllerRef = React.useRef<TerminalController | null>(null);
|
||||
const lastViewportSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingTerminalCreatesRef = React.useRef(new Set<string>());
|
||||
@@ -173,52 +197,32 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
}, [activeTabId, resetTerminalPreviewScan]);
|
||||
|
||||
React.useEffect(() => {
|
||||
directoryRef.current = effectiveDirectory;
|
||||
}, [effectiveDirectory]);
|
||||
directoryRef.current = terminalDirectory;
|
||||
}, [terminalDirectory]);
|
||||
|
||||
// The tab list is a per-client projection, so ask the server what actually
|
||||
// exists for this directory and adopt sessions no local tab references
|
||||
// (another device, a fresh browser tab, or a reload with cleared storage).
|
||||
// A failed listing changes nothing: adoption is additive only.
|
||||
React.useEffect(() => {
|
||||
if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) {
|
||||
if (!terminalHydrated || !terminalDirectory || !terminal.listSessions) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const directory = effectiveDirectory;
|
||||
void terminal.listSessions(directory)
|
||||
.then((serverSessions) => {
|
||||
if (cancelled || directoryRef.current !== directory) return;
|
||||
adoptServerSessions(directory, serverSessions);
|
||||
})
|
||||
.catch(() => { /* keep local tabs; the next mount or directory switch retries */ });
|
||||
const directory = terminalDirectory;
|
||||
void reconcileTerminalSessionAuthority(terminal, directory, {
|
||||
captureStartedActionMutationRevisions,
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled || directoryRef.current !== directory || !result) return;
|
||||
reconcileServerSessions(directory, result.sessions, {
|
||||
startedActionMutationRevisions: result.startedActionMutationRevisions,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]);
|
||||
|
||||
// The server reaps terminals with no attached socket after an idle timeout,
|
||||
// but only the active tab holds an attachment. While this client is open,
|
||||
// periodically mark every session its tabs reference as active so
|
||||
// background tabs (and other directories' terminals) are not reaped.
|
||||
React.useEffect(() => {
|
||||
if (!terminal.touchSessions) {
|
||||
return;
|
||||
}
|
||||
const touch = () => {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) return;
|
||||
const ids: string[] = [];
|
||||
for (const dirState of useTerminalStore.getState().sessions.values()) {
|
||||
for (const tab of dirState.tabs) {
|
||||
if (tab.terminalSessionId) ids.push(tab.terminalSessionId);
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
|
||||
};
|
||||
touch();
|
||||
const interval = setInterval(touch, 10 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [terminal]);
|
||||
}, [captureStartedActionMutationRevisions, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showQuickKeys && activeModifier !== null) {
|
||||
@@ -345,7 +349,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const currentTab = useTerminalStore.getState()
|
||||
.getDirectoryState(directory)
|
||||
?.tabs.find((t) => t.id === tabId);
|
||||
const isActionTab = Boolean(currentTab?.label?.startsWith('Action:'));
|
||||
const isActionTab = currentTab?.purpose.type === 'project-action';
|
||||
appendToBuffer(
|
||||
directory,
|
||||
tabId,
|
||||
@@ -384,7 +388,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
setIsReconnectPending(false);
|
||||
if (error.code === 'SESSION_NOT_FOUND') {
|
||||
const currentTab = useTerminalStore.getState().getDirectoryState(directory)?.tabs.find((tab) => tab.id === tabId);
|
||||
if (!currentTab?.label?.startsWith('Action:')) {
|
||||
if (currentTab?.purpose.type !== 'project-action') {
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setConnecting(directory, tabId, false);
|
||||
@@ -432,7 +436,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!effectiveDirectory) {
|
||||
if (!terminalDirectory) {
|
||||
setConnectionError(
|
||||
hasActiveContext
|
||||
? t('terminalView.empty.noWorkingDirectory')
|
||||
@@ -443,11 +447,14 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
}
|
||||
|
||||
const ensureSession = async () => {
|
||||
const directory = effectiveDirectory;
|
||||
const directory = terminalDirectory;
|
||||
if (!directoryRef.current || directoryRef.current !== directory) return;
|
||||
|
||||
const existingState = useTerminalStore.getState().getDirectoryState(directory);
|
||||
if (!existingState) {
|
||||
if (hasExplicitTerminalTarget) {
|
||||
return;
|
||||
}
|
||||
ensureDirectory(directory);
|
||||
return;
|
||||
}
|
||||
@@ -467,17 +474,14 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||||
const terminalId = tab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = tab?.lifecycle ?? 'idle';
|
||||
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
|
||||
const buffer = useTerminalStore.getState().getBuffer(directory, tabId);
|
||||
const hasBufferedOutput = buffer.byteLength > 0 || buffer.chunks.length > 0;
|
||||
|
||||
const tabIsActionTab = tab?.purpose.type === 'project-action';
|
||||
if (!terminalId) {
|
||||
if (terminalLifecycle === 'exited') {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActionTab && hasBufferedOutput) {
|
||||
if (tabIsActionTab) {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
}
|
||||
@@ -574,7 +578,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
};
|
||||
}, [
|
||||
hasActiveContext,
|
||||
effectiveDirectory,
|
||||
terminalDirectory,
|
||||
hasExplicitTerminalTarget,
|
||||
terminalSessionId,
|
||||
terminalLifecycle,
|
||||
activeTabId,
|
||||
@@ -613,10 +618,11 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
}, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible, useTouchTerminalInput]);
|
||||
|
||||
const handleRestart = React.useCallback(async () => {
|
||||
if (!effectiveDirectory) return;
|
||||
if (!terminalDirectory) return;
|
||||
if (isRestarting) return;
|
||||
if (isActionTab) return;
|
||||
|
||||
const state = useTerminalStore.getState().getDirectoryState(effectiveDirectory);
|
||||
const state = useTerminalStore.getState().getDirectoryState(terminalDirectory);
|
||||
const tabId = enableTabs
|
||||
? (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null)
|
||||
: (state?.tabs[0]?.id ?? null);
|
||||
@@ -634,19 +640,19 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
|
||||
try {
|
||||
const size = lastViewportSizeRef.current ?? FALLBACK_TERMINAL_SIZE;
|
||||
const restarted = await terminal.restartSession(originalSessionId, { cwd: effectiveDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
|
||||
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
|
||||
const restarted = await terminal.restartSession(originalSessionId, { cwd: terminalDirectory, shell: terminalShell, loginShell: terminalLoginShell, ...size, ...terminalAppearanceRef.current });
|
||||
const owningTab = useTerminalStore.getState().getDirectoryState(terminalDirectory)?.tabs.find((tab) => tab.id === tabId);
|
||||
if (owningTab?.terminalSessionId !== originalSessionId) return;
|
||||
setTabSessionId(effectiveDirectory, tabId, restarted.sessionId);
|
||||
setTabLifecycle(effectiveDirectory, tabId, 'running');
|
||||
if (directoryRef.current !== effectiveDirectory || activeTabIdRef.current !== tabId) return;
|
||||
setTabSessionId(terminalDirectory, tabId, restarted.sessionId);
|
||||
setTabLifecycle(terminalDirectory, tabId, 'running');
|
||||
if (directoryRef.current !== terminalDirectory || activeTabIdRef.current !== tabId) return;
|
||||
terminalIdRef.current = restarted.sessionId;
|
||||
startStream(effectiveDirectory, tabId, restarted.sessionId);
|
||||
startStream(terminalDirectory, tabId, restarted.sessionId);
|
||||
} catch (error) {
|
||||
const owningTab = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId);
|
||||
const owningTab = useTerminalStore.getState().getDirectoryState(terminalDirectory)?.tabs.find((tab) => tab.id === tabId);
|
||||
if (
|
||||
owningTab?.terminalSessionId !== originalSessionId
|
||||
|| directoryRef.current !== effectiveDirectory
|
||||
|| directoryRef.current !== terminalDirectory
|
||||
|| activeTabIdRef.current !== tabId
|
||||
) return;
|
||||
setConnectionError(
|
||||
@@ -655,11 +661,11 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
terminalIdRef.current = originalSessionId;
|
||||
startStream(effectiveDirectory, tabId, originalSessionId);
|
||||
startStream(terminalDirectory, tabId, originalSessionId);
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
}, [activeTabId, disconnectStream, effectiveDirectory, enableTabs, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]);
|
||||
}, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]);
|
||||
|
||||
const handleHardRestart = React.useCallback(async () => {
|
||||
// Keep semantics: “close tab -> new clean tab”.
|
||||
@@ -667,20 +673,20 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
}, [handleRestart]);
|
||||
|
||||
const handleCreateTab = React.useCallback(() => {
|
||||
if (!effectiveDirectory) return;
|
||||
const tabId = createTab(effectiveDirectory);
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
if (!terminalDirectory) return;
|
||||
const tabId = createTab(terminalDirectory);
|
||||
setActiveTab(terminalDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
}, [createTab, disconnectStream, effectiveDirectory, setActiveTab]);
|
||||
}, [createTab, disconnectStream, terminalDirectory, setActiveTab]);
|
||||
|
||||
const handleAttachSelection = React.useCallback(() => {
|
||||
const selection = terminalControllerRef.current?.getSelection();
|
||||
const sessionKey = currentSessionId ?? (newSessionDraft?.open ? 'draft' : null);
|
||||
if (!selection || !sessionKey || !activeTab || !effectiveDirectory) return;
|
||||
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
if (!selection || !sessionKey || !activeTab || !contextDirectory) return;
|
||||
addContextDraft({ directory: contextDirectory, sessionKey }, {
|
||||
source: 'terminal',
|
||||
fileLabel: activeTab.label,
|
||||
startLine: selection.startLine,
|
||||
@@ -690,23 +696,23 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
terminalId: activeTab.terminalSessionId ?? activeTab.id,
|
||||
text: '',
|
||||
});
|
||||
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
||||
}, [activeTab, addContextDraft, contextDirectory, currentSessionId, newSessionDraft?.open]);
|
||||
|
||||
const handleSelectTab = React.useCallback(
|
||||
(tabId: string) => {
|
||||
if (!effectiveDirectory) return;
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
if (!terminalDirectory) return;
|
||||
setActiveTab(terminalDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
},
|
||||
[disconnectStream, effectiveDirectory, setActiveTab]
|
||||
[disconnectStream, terminalDirectory, setActiveTab]
|
||||
);
|
||||
|
||||
const handleCloseTab = React.useCallback(
|
||||
(tabId: string) => {
|
||||
if (!effectiveDirectory) return;
|
||||
if (!terminalDirectory) return;
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
disconnectStream();
|
||||
@@ -715,13 +721,13 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
const sessionId = useTerminalStore.getState().getDirectoryState(effectiveDirectory)?.tabs.find((tab) => tab.id === tabId)?.terminalSessionId;
|
||||
const sessionId = useTerminalStore.getState().getDirectoryState(terminalDirectory)?.tabs.find((tab) => tab.id === tabId)?.terminalSessionId;
|
||||
void (async () => {
|
||||
if (sessionId) await terminal.close(sessionId);
|
||||
closeTab(effectiveDirectory, tabId);
|
||||
closeTab(terminalDirectory, tabId);
|
||||
})().catch((error) => setConnectionError(error instanceof Error ? error.message : t('terminalView.error.sessionEnded')));
|
||||
},
|
||||
[activeTabId, closeTab, disconnectStream, effectiveDirectory, t, terminal]
|
||||
[activeTabId, closeTab, disconnectStream, terminalDirectory, t, terminal]
|
||||
);
|
||||
|
||||
const handleViewportInput = React.useCallback(
|
||||
@@ -862,7 +868,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
// here tore down and rebuilt the Ghostty terminal (WASM VT + canvas + font
|
||||
// atlas) a second time the moment `createSession` resolved, doubling the cost
|
||||
// of every terminal open. Session changes are handled by the chunk replay path.
|
||||
const terminalViewportKey = `${effectiveDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`;
|
||||
const terminalViewportKey = `${terminalDirectory ?? 'no-dir'}::${activeTabId ?? 'no-tab'}`;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTerminalVisible || useTouchTerminalInput) {
|
||||
@@ -914,7 +920,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveDirectory) {
|
||||
if (!terminalDirectory) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
|
||||
<p>{t('terminalView.empty.noWorkingDirectoryForSession')}</p>
|
||||
@@ -1077,7 +1083,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
</Button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-visible">
|
||||
<Button type="button" size="xs" variant="ghost" className="h-7 w-7 p-0" onClick={() => void handleRestart()} disabled={isRestarting} title={t('terminalView.actions.restart')} aria-label={t('terminalView.actions.restart')}>
|
||||
<Button type="button" size="xs" variant="ghost" className="h-7 w-7 p-0" onClick={() => void handleRestart()} disabled={isRestarting || isActionTab} title={t('terminalView.actions.restart')} aria-label={t('terminalView.actions.restart')}>
|
||||
<Icon name="restart" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1098,8 +1104,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
variant="outline"
|
||||
className="h-6 shrink-0 gap-1 px-2"
|
||||
onClick={() => {
|
||||
if (!effectiveDirectory) return;
|
||||
openContextPreview(effectiveDirectory, previewUrl);
|
||||
if (!contextDirectory) return;
|
||||
openContextPreview(contextDirectory, previewUrl);
|
||||
}}
|
||||
title={t('terminalView.preview.openTitle')}
|
||||
>
|
||||
|
||||
@@ -32,8 +32,8 @@ const viewportKeyDeclaration = terminalViewSource
|
||||
.find((line) => line.includes('const terminalViewportKey =')) ?? '';
|
||||
|
||||
describe('terminal viewport remount guard', () => {
|
||||
test('viewport identity excludes the PTY session id', () => {
|
||||
expect(viewportKeyDeclaration).toContain('effectiveDirectory');
|
||||
test('viewport identity uses the authoritative terminal directory and excludes the PTY session id', () => {
|
||||
expect(viewportKeyDeclaration).toContain('terminalDirectory');
|
||||
expect(viewportKeyDeclaration).toContain('activeTabId');
|
||||
expect(viewportKeyDeclaration).not.toContain('terminalSessionId');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveProjectActionsOwner } from './useProjectActionsContext';
|
||||
|
||||
const projects = [
|
||||
{ id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' },
|
||||
];
|
||||
|
||||
describe('resolveProjectActionsOwner', () => {
|
||||
test('resolves a worktree directory to its owning parent project', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map([
|
||||
['/workspace/openchamber', [{
|
||||
path: '/workspace/openchamber-feature',
|
||||
projectDirectory: '/workspace/openchamber',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
}]],
|
||||
]),
|
||||
directory: '/workspace/openchamber-feature',
|
||||
activeProjectId: null,
|
||||
});
|
||||
|
||||
expect(owner).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('resolves a directory under the project path to that project', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/workspace/openchamber/packages/ui',
|
||||
activeProjectId: null,
|
||||
});
|
||||
|
||||
expect(owner).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('falls back to the active project when the directory does not resolve', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/some/other/project',
|
||||
activeProjectId: 'openchamber',
|
||||
});
|
||||
|
||||
expect(owner).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('falls back to the active project when the directory is empty or null', () => {
|
||||
expect(resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '',
|
||||
activeProjectId: 'openchamber',
|
||||
})).toEqual(projects[0]);
|
||||
|
||||
expect(resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: null,
|
||||
activeProjectId: 'openchamber',
|
||||
})).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('returns null when the directory does not resolve and the active project is unknown', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/some/other/project',
|
||||
activeProjectId: 'missing-project',
|
||||
});
|
||||
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,47 @@
|
||||
import React from 'react';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
export interface ProjectActionsContext {
|
||||
projectRef: ProjectRef;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
interface ProjectActionsOwnerInput {
|
||||
projects: ProjectEntry[];
|
||||
worktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
directory: string | null;
|
||||
activeProjectId: string | null;
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
export const resolveProjectActionsOwner = ({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory,
|
||||
activeProjectId,
|
||||
}: ProjectActionsOwnerInput): ProjectEntry | null => {
|
||||
const normalizedDirectory = normalize(directory ?? '');
|
||||
if (normalizedDirectory) {
|
||||
const sessionProject = resolveProjectForSessionDirectory(projects, worktreesByProject, normalizedDirectory);
|
||||
if (sessionProject) {
|
||||
return sessionProject;
|
||||
}
|
||||
}
|
||||
|
||||
return projects.find((project) => project.id === activeProjectId) ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the active project ref + working directory used by
|
||||
* {@link ProjectActionsButton}. Directory priority mirrors the header:
|
||||
@@ -22,12 +49,9 @@ const normalize = (value: string): string => {
|
||||
* good context so the actions button doesn't flicker during session switches.
|
||||
*/
|
||||
export function useProjectActionsContext(): ProjectActionsContext | null {
|
||||
const activeProject = useProjectsStore((state) => {
|
||||
if (!state.activeProjectId) {
|
||||
return null;
|
||||
}
|
||||
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
|
||||
});
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSession = useSession(currentSessionId ?? null);
|
||||
@@ -50,16 +74,22 @@ export function useProjectActionsContext(): ProjectActionsContext | null {
|
||||
}, [currentSession?.directory]);
|
||||
|
||||
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory;
|
||||
const ownerProject = React.useMemo(() => resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory: openDirectory,
|
||||
activeProjectId,
|
||||
}), [activeProjectId, openDirectory, projects, worktreesByProject]);
|
||||
const actionDirectory = React.useMemo(
|
||||
() => normalize(openDirectory || activeProject?.path || ''),
|
||||
[activeProject?.path, openDirectory],
|
||||
() => normalize(openDirectory || ownerProject?.path || ''),
|
||||
[openDirectory, ownerProject?.path],
|
||||
);
|
||||
const activeProjectRef = React.useMemo<ProjectRef | null>(() => {
|
||||
if (!activeProject) {
|
||||
if (!ownerProject) {
|
||||
return null;
|
||||
}
|
||||
return { id: activeProject.id, path: activeProject.path };
|
||||
}, [activeProject]);
|
||||
return { id: ownerProject.id, path: ownerProject.path };
|
||||
}, [ownerProject]);
|
||||
|
||||
const lastContextRef = React.useRef<ProjectActionsContext | null>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
|
||||
const touchCalls: string[][] = [];
|
||||
|
||||
const terminal = {
|
||||
touchSessions: async (sessionIds: string[]) => {
|
||||
touchCalls.push(sessionIds);
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({
|
||||
useRuntimeAPIs: () => ({ terminal }),
|
||||
}));
|
||||
|
||||
const { useTerminalSessionKeepalive } = await import('./useTerminalSessionKeepalive');
|
||||
|
||||
const HookHarness = () => {
|
||||
useTerminalSessionKeepalive();
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('useTerminalSessionKeepalive', () => {
|
||||
let windowInstance: Window;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
const originalClearInterval = globalThis.clearInterval;
|
||||
const scheduledIntervals = new Map<number, { delay: number; callback: () => void }>();
|
||||
let nextIntervalId = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
scheduledIntervals.clear();
|
||||
nextIntervalId = 1;
|
||||
Object.assign(globalThis, {
|
||||
window: windowInstance,
|
||||
document: windowInstance.document,
|
||||
navigator: windowInstance.navigator,
|
||||
HTMLElement: windowInstance.HTMLElement,
|
||||
Element: windowInstance.Element,
|
||||
Node: windowInstance.Node,
|
||||
Event: windowInstance.Event,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
const windowSetInterval = windowInstance.setInterval.bind(windowInstance);
|
||||
const windowClearInterval = windowInstance.clearInterval.bind(windowInstance);
|
||||
const fakeSetInterval = (callback: TimerHandler, delay = 0, ...args: unknown[]) => {
|
||||
const liveHandle = windowSetInterval(() => undefined, 0);
|
||||
windowClearInterval(liveHandle);
|
||||
const id = nextIntervalId;
|
||||
nextIntervalId += 1;
|
||||
const callbackFn = () => {
|
||||
if (callback instanceof Function) {
|
||||
callback(...args);
|
||||
return;
|
||||
}
|
||||
new Function(String(callback))();
|
||||
};
|
||||
scheduledIntervals.set(id, {
|
||||
delay,
|
||||
callback: callbackFn,
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const fakeClearInterval = (intervalId: number) => {
|
||||
scheduledIntervals.delete(intervalId);
|
||||
};
|
||||
Object.defineProperty(globalThis, 'setInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fakeSetInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'clearInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fakeClearInterval,
|
||||
});
|
||||
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
touchCalls.length = 0;
|
||||
useTerminalStore.getState().clearAll();
|
||||
|
||||
const interactiveTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabSessionId('/repo', interactiveTabId, 'interactive-session');
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', interactiveTabId, 'running');
|
||||
|
||||
const runningActionTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', runningActionTabId, {
|
||||
type: 'project-action',
|
||||
actionId: 'build',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
useTerminalStore.getState().setTabSessionId('/repo', runningActionTabId, 'action-session');
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', runningActionTabId, 'running');
|
||||
|
||||
const exitedTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabSessionId('/repo', exitedTabId, 'exited-session');
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', exitedTabId, 'exited');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
Object.defineProperty(globalThis, 'setInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalSetInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'clearInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalClearInterval,
|
||||
});
|
||||
useTerminalStore.getState().clearAll();
|
||||
});
|
||||
|
||||
test('touches non-exited tab sessions immediately and on the interval, without a TerminalView mount', async () => {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HookHarness));
|
||||
});
|
||||
|
||||
expect(touchCalls).toEqual([['interactive-session', 'action-session']]);
|
||||
expect([...scheduledIntervals.values()].map((entry) => entry.delay)).toEqual([10 * 60 * 1000]);
|
||||
|
||||
await act(async () => {
|
||||
[...scheduledIntervals.values()][0]?.callback();
|
||||
});
|
||||
|
||||
expect(touchCalls).toEqual([
|
||||
['interactive-session', 'action-session'],
|
||||
['interactive-session', 'action-session'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('skips touches while offline and clears the interval on unmount', async () => {
|
||||
Object.defineProperty(globalThis.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HookHarness));
|
||||
});
|
||||
|
||||
expect(touchCalls).toEqual([]);
|
||||
expect(scheduledIntervals.size).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
expect(scheduledIntervals.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every app root that renders terminals must own keepalive itself: the server
|
||||
* reaps sessions with no attached socket after 30 idle minutes, and the loop
|
||||
* no longer lives in TerminalView. The mobile shell runs its own root and
|
||||
* mounts neither desktop layout, so a missing call there silently reintroduces
|
||||
* background PTY reaping (reviewer finding on the revisit fix).
|
||||
*/
|
||||
describe('terminal keepalive root coverage', () => {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootSources: Array<[string, string]> = [
|
||||
['MainLayout', join(__dirname, '..', 'components', 'layout', 'MainLayout.tsx')],
|
||||
['VSCodeLayout', join(__dirname, '..', 'components', 'layout', 'VSCodeLayout.tsx')],
|
||||
['MobileApp shell', join(__dirname, '..', 'apps', 'MobileApp.tsx')],
|
||||
];
|
||||
|
||||
for (const [rootName, sourcePath] of rootSources) {
|
||||
test(`${rootName} mounts useTerminalSessionKeepalive`, () => {
|
||||
const source = readFileSync(sourcePath, 'utf-8');
|
||||
expect(source).toContain('useTerminalSessionKeepalive()');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
|
||||
const TERMINAL_SESSION_KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
export const useTerminalSessionKeepalive = (): void => {
|
||||
const { terminal } = useRuntimeAPIs();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!terminal.touchSessions) {
|
||||
return;
|
||||
}
|
||||
const touch = () => {
|
||||
if (globalThis.navigator?.onLine === false) return;
|
||||
const ids: string[] = [];
|
||||
for (const dirState of useTerminalStore.getState().sessions.values()) {
|
||||
for (const tab of dirState.tabs) {
|
||||
if (tab.terminalSessionId && tab.lifecycle !== 'exited') ids.push(tab.terminalSessionId);
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
|
||||
};
|
||||
touch();
|
||||
const interval = setInterval(touch, TERMINAL_SESSION_KEEPALIVE_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [terminal]);
|
||||
};
|
||||
@@ -23,8 +23,14 @@ export interface TerminalSession {
|
||||
cols: number;
|
||||
rows: number;
|
||||
status: 'running' | 'exited' | 'error';
|
||||
mode?: 'interactive' | 'command';
|
||||
purpose?: TerminalSessionPurpose;
|
||||
}
|
||||
|
||||
export type TerminalSessionPurpose =
|
||||
| { type: 'terminal' }
|
||||
| { type: 'project-action'; actionId: string; executionId: string };
|
||||
|
||||
export type TerminalShell = 'auto' | 'bash' | 'zsh' | 'sh' | 'fish' | 'pwsh' | 'powershell' | 'cmd' | 'dash' | 'ksh' | 'nu';
|
||||
|
||||
export interface TerminalShellOption {
|
||||
@@ -46,13 +52,15 @@ export interface TerminalStreamEvent {
|
||||
|
||||
runtime?: 'node' | 'bun';
|
||||
ptyBackend?: string;
|
||||
mode?: 'interactive' | 'command';
|
||||
purpose?: TerminalSessionPurpose;
|
||||
}
|
||||
|
||||
export interface TerminalError extends Error {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface CreateTerminalOptions {
|
||||
interface BaseCreateTerminalOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
cols?: number;
|
||||
@@ -62,8 +70,21 @@ export interface CreateTerminalOptions {
|
||||
terminalForeground?: string;
|
||||
shell?: TerminalShell;
|
||||
loginShell?: boolean;
|
||||
purpose?: TerminalSessionPurpose;
|
||||
}
|
||||
|
||||
interface InteractiveCreateTerminalOptions extends BaseCreateTerminalOptions {
|
||||
mode?: 'interactive';
|
||||
}
|
||||
|
||||
interface CommandCreateTerminalOptions extends BaseCreateTerminalOptions {
|
||||
mode: 'command';
|
||||
command: string;
|
||||
}
|
||||
|
||||
export type CreateTerminalOptions = InteractiveCreateTerminalOptions | CommandCreateTerminalOptions;
|
||||
export type RestartTerminalOptions = InteractiveCreateTerminalOptions;
|
||||
|
||||
export interface ResizeTerminalPayload {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
@@ -85,6 +106,8 @@ export interface TerminalServerSession {
|
||||
cwd: string;
|
||||
status: 'running' | 'exited';
|
||||
createdAt: number | null;
|
||||
mode?: 'interactive' | 'command';
|
||||
purpose?: TerminalSessionPurpose;
|
||||
}
|
||||
|
||||
export interface TerminalAPI {
|
||||
@@ -99,7 +122,7 @@ export interface TerminalAPI {
|
||||
resize(payload: ResizeTerminalPayload): Promise<void>;
|
||||
updateAppearance?(sessionId: string, appearance: Pick<CreateTerminalOptions, 'themeMode' | 'terminalBackground' | 'terminalForeground'>): Promise<void>;
|
||||
close(sessionId: string): Promise<void>;
|
||||
restartSession?(currentSessionId: string, options: CreateTerminalOptions): Promise<TerminalSession>;
|
||||
restartSession?(currentSessionId: string, options: RestartTerminalOptions): Promise<TerminalSession>;
|
||||
forceKill?(options: ForceKillOptions): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -449,6 +449,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': 'Aktionsname',
|
||||
'settings.projects.actions.field.command': 'Befehl',
|
||||
'settings.projects.actions.field.commandPlaceholder': 'z. B. bun run lint',
|
||||
'settings.projects.actions.runIn.label': 'Ausführen in',
|
||||
'settings.projects.actions.runIn.info': 'Legt fest, wo diese Aktion ausgeführt wird, wenn sie aus einem verknüpften Worktree gestartet wird.',
|
||||
'settings.projects.actions.runIn.project': 'Übergeordneter Checkout',
|
||||
'settings.projects.actions.runIn.worktree': 'Aktueller Worktree',
|
||||
'settings.projects.actions.runIn.aria': 'Arbeitsverzeichnis dieser Aktion',
|
||||
'settings.projects.actions.field.autoOpenUrl': 'URL automatisch öffnen',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': 'URL für {title} automatisch öffnen',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': 'URL aus der Ausgabe oder benutzerdefinierte URL unten öffnen',
|
||||
|
||||
@@ -470,6 +470,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': 'Action name',
|
||||
'settings.projects.actions.field.command': 'Command',
|
||||
'settings.projects.actions.field.commandPlaceholder': 'e.g. bun run lint',
|
||||
'settings.projects.actions.runIn.label': 'Run in',
|
||||
'settings.projects.actions.runIn.info': 'Choose where this action runs when started from a linked worktree.',
|
||||
'settings.projects.actions.runIn.project': 'Parent checkout',
|
||||
'settings.projects.actions.runIn.worktree': 'Current worktree',
|
||||
'settings.projects.actions.runIn.aria': 'Working directory for this action',
|
||||
'settings.projects.actions.field.autoOpenUrl': 'Auto-open URL',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': 'Auto-open URL for {title}',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': 'Open URL from output or custom URL below',
|
||||
|
||||
@@ -438,6 +438,11 @@ export const settingsDict = {
|
||||
"settings.projects.actions.field.actionNamePlaceholder": "Nombre de la acción",
|
||||
"settings.projects.actions.field.command": "Comando",
|
||||
"settings.projects.actions.field.commandPlaceholder": "p. ej. bun run lint",
|
||||
"settings.projects.actions.runIn.label": "Ejecutar en",
|
||||
"settings.projects.actions.runIn.info": "Elige dónde se ejecuta esta acción cuando se inicia desde un worktree vinculado.",
|
||||
"settings.projects.actions.runIn.project": "Checkout principal",
|
||||
"settings.projects.actions.runIn.worktree": "Worktree actual",
|
||||
"settings.projects.actions.runIn.aria": "Directorio de trabajo de esta acción",
|
||||
"settings.projects.actions.field.autoOpenUrl": "Abrir URL automáticamente",
|
||||
"settings.projects.actions.field.autoOpenUrlForAria": "Abrir URL automáticamente para {title}",
|
||||
"settings.projects.actions.field.autoOpenUrlDescription": "Abrir URL desde la salida o la URL personalizada de abajo",
|
||||
|
||||
@@ -361,6 +361,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': 'Nom de l\'action',
|
||||
'settings.projects.actions.field.command': 'Commande',
|
||||
'settings.projects.actions.field.commandPlaceholder': 'p. ex. bun install',
|
||||
'settings.projects.actions.runIn.label': 'Exécuter dans',
|
||||
'settings.projects.actions.runIn.info': 'Choisissez où cette action s\'exécute lorsqu\'elle est lancée depuis un worktree lié.',
|
||||
'settings.projects.actions.runIn.project': 'Checkout parent',
|
||||
'settings.projects.actions.runIn.worktree': 'Worktree courant',
|
||||
'settings.projects.actions.runIn.aria': 'Répertoire d\'exécution de cette action',
|
||||
'settings.projects.actions.field.autoOpenUrl': 'Ouverture automatique de l’URL',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': 'Ouverture automatique de l’URL pour {title}',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': 'Ouvrir l’URL détectée dans la sortie, ou l’URL personnalisée ci-dessous',
|
||||
|
||||
@@ -471,6 +471,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': 'アクション名',
|
||||
'settings.projects.actions.field.command': 'コマンド',
|
||||
'settings.projects.actions.field.commandPlaceholder': '例: bun run lint',
|
||||
'settings.projects.actions.runIn.label': '実行場所',
|
||||
'settings.projects.actions.runIn.info': 'リンクされたワークツリーから起動したときにこのアクションを実行する場所を選択します。',
|
||||
'settings.projects.actions.runIn.project': '親チェックアウト',
|
||||
'settings.projects.actions.runIn.worktree': '現在のワークツリー',
|
||||
'settings.projects.actions.runIn.aria': 'このアクションの作業ディレクトリ',
|
||||
'settings.projects.actions.field.autoOpenUrl': 'URL を自動開く',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': '{title} の URL を自動開く',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': '出力または以下のカスタム URL から URL を開く',
|
||||
|
||||
@@ -438,6 +438,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': '작업 이름',
|
||||
'settings.projects.actions.field.command': '명령어',
|
||||
'settings.projects.actions.field.commandPlaceholder': '예: bun run lint',
|
||||
'settings.projects.actions.runIn.label': '실행 위치',
|
||||
'settings.projects.actions.runIn.info': '연결된 워크트리에서 시작할 때 이 작업을 실행할 위치를 선택합니다.',
|
||||
'settings.projects.actions.runIn.project': '상위 체크아웃',
|
||||
'settings.projects.actions.runIn.worktree': '현재 워크트리',
|
||||
'settings.projects.actions.runIn.aria': '이 작업의 작업 디렉터리',
|
||||
'settings.projects.actions.field.autoOpenUrl': 'URL 자동 열기',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': '{title}의 URL 자동 열기',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': '명령 출력에서 감지한 URL 또는 아래의 사용자 정의 URL을 엽니다',
|
||||
|
||||
@@ -1368,6 +1368,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': 'Automatycznie otwieraj URL dla {title}',
|
||||
'settings.projects.actions.field.command': 'Polecenie',
|
||||
'settings.projects.actions.field.commandPlaceholder': 'np. bun run lint',
|
||||
'settings.projects.actions.runIn.label': 'Uruchom w',
|
||||
'settings.projects.actions.runIn.info': 'Wybierz, gdzie uruchamiać tę akcję po uruchomieniu z połączonego worktree.',
|
||||
'settings.projects.actions.runIn.project': 'Nadrzędny checkout',
|
||||
'settings.projects.actions.runIn.worktree': 'Bieżący worktree',
|
||||
'settings.projects.actions.runIn.aria': 'Katalog roboczy tej akcji',
|
||||
'settings.projects.actions.field.desktopSshForward': 'Przekierowanie SSH pulpitu',
|
||||
'settings.projects.actions.field.iconAria': 'Ikona {icon}',
|
||||
'settings.projects.actions.field.overrideUrlPlaceholder': 'Nadpisz URL (opcjonalnie)',
|
||||
|
||||
@@ -438,6 +438,11 @@ export const settingsDict = {
|
||||
"settings.projects.actions.field.actionNamePlaceholder": "Nome da ação",
|
||||
"settings.projects.actions.field.command": "Comando",
|
||||
"settings.projects.actions.field.commandPlaceholder": "ex.: bun run lint",
|
||||
"settings.projects.actions.runIn.label": "Executar em",
|
||||
"settings.projects.actions.runIn.info": "Escolha onde esta ação é executada quando iniciada a partir de um worktree vinculado.",
|
||||
"settings.projects.actions.runIn.project": "Checkout pai",
|
||||
"settings.projects.actions.runIn.worktree": "Worktree atual",
|
||||
"settings.projects.actions.runIn.aria": "Diretório de trabalho desta ação",
|
||||
"settings.projects.actions.field.autoOpenUrl": "Abrir URL automaticamente",
|
||||
"settings.projects.actions.field.autoOpenUrlForAria": "Abrir URL automaticamente para {title}",
|
||||
"settings.projects.actions.field.autoOpenUrlDescription": "Abrir URL da saída ou a URL personalizada abaixo",
|
||||
|
||||
@@ -466,6 +466,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': 'Eylem adı',
|
||||
'settings.projects.actions.field.command': 'Komut',
|
||||
'settings.projects.actions.field.commandPlaceholder': 'örn. bun run lint',
|
||||
'settings.projects.actions.runIn.label': 'Çalıştırma konumu',
|
||||
'settings.projects.actions.runIn.info': 'Bağlı bir worktree\'den başlatıldığında bu eylemin nerede çalışacağını seçin.',
|
||||
'settings.projects.actions.runIn.project': 'Üst checkout',
|
||||
'settings.projects.actions.runIn.worktree': 'Geçerli worktree',
|
||||
'settings.projects.actions.runIn.aria': 'Bu eylemin çalışma dizini',
|
||||
'settings.projects.actions.field.autoOpenUrl': 'URL\'yi otomatik aç',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': '{title} için URL\'yi otomatik aç',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': 'Çıktıdaki URL\'yi veya aşağıdaki özel URL\'yi aç',
|
||||
|
||||
@@ -438,6 +438,11 @@ export const settingsDict = {
|
||||
"settings.projects.actions.field.actionNamePlaceholder": "Назва дії",
|
||||
"settings.projects.actions.field.command": "Команда",
|
||||
"settings.projects.actions.field.commandPlaceholder": "напр. bun run lint",
|
||||
"settings.projects.actions.runIn.label": "Запускати в",
|
||||
"settings.projects.actions.runIn.info": "Виберіть, де запускати цю дію, коли її запущено з пов'язаного worktree.",
|
||||
"settings.projects.actions.runIn.project": "Батьківський checkout",
|
||||
"settings.projects.actions.runIn.worktree": "Поточний worktree",
|
||||
"settings.projects.actions.runIn.aria": "Робоча тека для цієї дії",
|
||||
"settings.projects.actions.field.autoOpenUrl": "Автоматичне відкриття URL",
|
||||
"settings.projects.actions.field.autoOpenUrlForAria": "Автоматичне відкриття URL для {title}",
|
||||
"settings.projects.actions.field.autoOpenUrlDescription": "Відкрити URL із виведення або власний URL нижче",
|
||||
|
||||
@@ -438,6 +438,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': '操作名称',
|
||||
'settings.projects.actions.field.command': '命令',
|
||||
'settings.projects.actions.field.commandPlaceholder': '例如 bun run lint',
|
||||
'settings.projects.actions.runIn.label': '运行位置',
|
||||
'settings.projects.actions.runIn.info': '选择从关联 worktree 启动时此操作的运行位置。',
|
||||
'settings.projects.actions.runIn.project': '父检出目录',
|
||||
'settings.projects.actions.runIn.worktree': '当前 worktree',
|
||||
'settings.projects.actions.runIn.aria': '此操作的工作目录',
|
||||
'settings.projects.actions.field.autoOpenUrl': '自动打开 URL',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': '为 {title} 自动打开 URL',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': '从输出中打开 URL,或使用下面的自定义 URL',
|
||||
|
||||
@@ -435,6 +435,11 @@ export const settingsDict = {
|
||||
'settings.projects.actions.field.actionNamePlaceholder': '操作名稱',
|
||||
'settings.projects.actions.field.command': '命令',
|
||||
'settings.projects.actions.field.commandPlaceholder': '例如 bun run lint',
|
||||
'settings.projects.actions.runIn.label': '執行位置',
|
||||
'settings.projects.actions.runIn.info': '選擇從關聯 worktree 啟動時此動作的執行位置。',
|
||||
'settings.projects.actions.runIn.project': '父檢出目錄',
|
||||
'settings.projects.actions.runIn.worktree': '目前 worktree',
|
||||
'settings.projects.actions.runIn.aria': '此動作的工作目錄',
|
||||
'settings.projects.actions.field.autoOpenUrl': '自動開啟 URL',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': '為 {title} 自動開啟 URL',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': '從輸出中開啟 URL,或使用下面的自訂 URL',
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { createProjectIdFromPath } from './projectId';
|
||||
|
||||
const homeDirectory = '/Users/test';
|
||||
const project = { id: 'openchamber', path: '/workspace/openchamber' };
|
||||
|
||||
let files = new Map<string, string>();
|
||||
|
||||
mock.module('@/contexts/runtimeAPIRegistry', () => ({
|
||||
getRegisteredRuntimeAPIs: mock(() => ({
|
||||
files: {
|
||||
createDirectory: mock(async () => ({ success: true })),
|
||||
readFile: mock(async (path: string) => ({ content: files.get(path) ?? '' })),
|
||||
writeFile: mock(async (path: string, content: string) => {
|
||||
files.set(path, content);
|
||||
return { success: true };
|
||||
}),
|
||||
delete: mock(async (path: string) => {
|
||||
files.delete(path);
|
||||
}),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktop', () => ({
|
||||
getDesktopHomeDirectory: mock(async () => homeDirectory),
|
||||
isVSCodeRuntime: mock(() => false),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: mock(async (url: string) => {
|
||||
if (url.endsWith('/fs/home')) {
|
||||
return new Response(JSON.stringify({ home: homeDirectory }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
getProjectActionsState,
|
||||
saveProjectActionsState,
|
||||
} = await import('./openchamberConfig');
|
||||
|
||||
const getConfigPath = (projectPath: string): string => (
|
||||
`${homeDirectory}/.config/openchamber/projects/${createProjectIdFromPath(projectPath)}.json`
|
||||
);
|
||||
|
||||
describe('project actions config sanitization', () => {
|
||||
beforeEach(() => {
|
||||
files = new Map();
|
||||
});
|
||||
|
||||
test('round-trips runIn parent through saved project actions state', async () => {
|
||||
const saved = await saveProjectActionsState(project, {
|
||||
actions: [{
|
||||
id: 'action-1',
|
||||
name: 'Run action',
|
||||
command: 'pnpm dev',
|
||||
runIn: 'parent',
|
||||
}],
|
||||
primaryActionId: 'action-1',
|
||||
});
|
||||
|
||||
expect(saved).toBe(true);
|
||||
|
||||
const state = await getProjectActionsState(project);
|
||||
|
||||
expect(state).toEqual({
|
||||
actions: [{
|
||||
id: 'action-1',
|
||||
name: 'Run action',
|
||||
command: 'pnpm dev',
|
||||
icon: null,
|
||||
runIn: 'parent',
|
||||
}],
|
||||
primaryActionId: 'action-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps runIn omitted when saving project actions in the current worktree', async () => {
|
||||
const saved = await saveProjectActionsState(project, {
|
||||
actions: [{
|
||||
id: 'action-1',
|
||||
name: 'Run action',
|
||||
command: 'pnpm dev',
|
||||
}],
|
||||
primaryActionId: 'action-1',
|
||||
});
|
||||
|
||||
expect(saved).toBe(true);
|
||||
|
||||
const state = await getProjectActionsState(project);
|
||||
|
||||
expect(state).toEqual({
|
||||
actions: [{
|
||||
id: 'action-1',
|
||||
name: 'Run action',
|
||||
command: 'pnpm dev',
|
||||
icon: null,
|
||||
}],
|
||||
primaryActionId: 'action-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizes runIn worktree to omission when loading project actions state', async () => {
|
||||
files.set(getConfigPath(project.path), JSON.stringify({
|
||||
projectPath: project.path,
|
||||
projectActions: [
|
||||
{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'worktree' },
|
||||
],
|
||||
projectActionsPrimaryId: 'action-1',
|
||||
}));
|
||||
|
||||
const state = await getProjectActionsState(project);
|
||||
|
||||
expect(state).toEqual({
|
||||
actions: [
|
||||
{ id: 'action-1', name: 'Run action', command: 'pnpm dev', icon: null },
|
||||
],
|
||||
primaryActionId: 'action-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('omits unsupported runIn values when loading project actions state', async () => {
|
||||
files.set(getConfigPath(project.path), JSON.stringify({
|
||||
projectPath: project.path,
|
||||
projectActions: [
|
||||
{ id: 'action-project', name: 'Project', command: 'pnpm dev', runIn: 'project' },
|
||||
{ id: 'action-number', name: 'Number', command: 'pnpm test', runIn: 123 },
|
||||
],
|
||||
projectActionsPrimaryId: 'action-project',
|
||||
}));
|
||||
|
||||
const state = await getProjectActionsState(project);
|
||||
|
||||
expect(state).toEqual({
|
||||
actions: [
|
||||
{ id: 'action-project', name: 'Project', command: 'pnpm dev', icon: null },
|
||||
{ id: 'action-number', name: 'Number', command: 'pnpm test', icon: null },
|
||||
],
|
||||
primaryActionId: 'action-project',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export interface OpenChamberProjectAction {
|
||||
name: string;
|
||||
command: string;
|
||||
icon?: string | null;
|
||||
runIn?: 'parent';
|
||||
platforms?: OpenChamberProjectActionPlatform[];
|
||||
autoOpenUrl?: boolean;
|
||||
openUrl?: string;
|
||||
@@ -280,6 +281,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
|
||||
name?: unknown;
|
||||
command?: unknown;
|
||||
icon?: unknown;
|
||||
runIn?: unknown;
|
||||
platforms?: unknown;
|
||||
autoOpenUrl?: unknown;
|
||||
openUrl?: unknown;
|
||||
@@ -296,6 +298,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
|
||||
seenIds.add(id);
|
||||
|
||||
const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : '';
|
||||
const runIn = record.runIn === 'parent' ? 'parent' : undefined;
|
||||
const platforms = sanitizeProjectActionPlatforms(record.platforms);
|
||||
const autoOpenUrl = record.autoOpenUrl === true;
|
||||
const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : '';
|
||||
@@ -308,7 +311,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
|
||||
OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH
|
||||
);
|
||||
|
||||
sanitized.push({
|
||||
const sanitizedAction: OpenChamberProjectAction = {
|
||||
id,
|
||||
name,
|
||||
command,
|
||||
@@ -317,7 +320,11 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
|
||||
...(openUrl ? { openUrl } : {}),
|
||||
...(desktopOpenSshForward ? { desktopOpenSshForward } : {}),
|
||||
...(platforms.length > 0 ? { platforms } : {}),
|
||||
});
|
||||
};
|
||||
if (runIn) {
|
||||
sanitizedAction.runIn = runIn;
|
||||
}
|
||||
sanitized.push(sanitizedAction);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { TerminalAPI, TerminalHandlers } from './api/types';
|
||||
import { waitForTerminalExit } from './projectActionTerminal';
|
||||
import { detectDevServerCommand } from './detectDevServer';
|
||||
import {
|
||||
createProjectActionTerminalSession,
|
||||
normalizeProjectActionCommand,
|
||||
reconcileTerminalSessionAuthority,
|
||||
stopProjectActionTerminalSession,
|
||||
waitForTerminalExit,
|
||||
} from './projectActionTerminal';
|
||||
|
||||
const fakeTerminal = () => {
|
||||
let handlers: TerminalHandlers | null = null;
|
||||
@@ -15,16 +20,6 @@ const fakeTerminal = () => {
|
||||
};
|
||||
|
||||
describe('project action terminal lifecycle', () => {
|
||||
test('preserves a configured dev action preview URL', async () => {
|
||||
const detected = await detectDevServerCommand('/repo', [{
|
||||
id: 'dev',
|
||||
name: 'Dev server',
|
||||
command: 'bun run dev',
|
||||
openUrl: 'http://localhost:4321',
|
||||
}], null);
|
||||
expect(detected?.previewUrlHint).toBe('http://localhost:4321');
|
||||
});
|
||||
|
||||
test('resolves on live exit and closes its temporary subscription', async () => {
|
||||
const fake = fakeTerminal();
|
||||
const result = waitForTerminalExit(fake.terminal, 'term-1', 100);
|
||||
@@ -45,4 +40,256 @@ describe('project action terminal lifecycle', () => {
|
||||
expect(await waitForTerminalExit(fake.terminal, 'term-1', 5)).toBe(false);
|
||||
expect(fake.isClosed()).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes a project action command before create', () => {
|
||||
expect(normalizeProjectActionCommand(' printf "hi"\r\nexit\u0007 ')).toBe('printf "hi"\nexit');
|
||||
});
|
||||
|
||||
test('closes the previous session before creating a command-mode run', async () => {
|
||||
const calls: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async (options) => {
|
||||
calls.push(`create:${JSON.stringify(options)}`);
|
||||
return { sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } };
|
||||
},
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async (sessionId) => {
|
||||
calls.push(`close:${sessionId}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: 'stale-session',
|
||||
createOptions: {
|
||||
cwd: '/repo',
|
||||
sessionId: 'tab-1',
|
||||
},
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => true,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
});
|
||||
|
||||
expect(created).toEqual({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
|
||||
expect(calls).toEqual([
|
||||
'close:stale-session',
|
||||
'create:{"cwd":"/repo","sessionId":"tab-1","mode":"command","command":"echo hello","purpose":{"type":"project-action","actionId":"build","executionId":"exec-1"}}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects and closes a create response that does not echo command mode', async () => {
|
||||
const closed: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running' }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async (sessionId) => {
|
||||
closed.push(sessionId);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: null,
|
||||
createOptions: {
|
||||
cwd: '/repo',
|
||||
sessionId: 'tab-1',
|
||||
},
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => true,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
})).rejects.toThrow('COMMAND_MODE_UNSUPPORTED');
|
||||
expect(closed).toEqual(['tab-1']);
|
||||
});
|
||||
|
||||
test('closes a newly created command session when stop removes the run during create', async () => {
|
||||
const closed: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async (sessionId) => {
|
||||
closed.push(sessionId);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: null,
|
||||
createOptions: {
|
||||
cwd: '/repo',
|
||||
sessionId: 'tab-1',
|
||||
},
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => false,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
|
||||
expect(closed).toEqual(['tab-1']);
|
||||
});
|
||||
|
||||
test('rejects and closes a create response that does not echo project-action purpose', async () => {
|
||||
const closed: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command' }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async (sessionId) => {
|
||||
closed.push(sessionId);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: null,
|
||||
createOptions: { cwd: '/repo', sessionId: 'tab-1' },
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => true,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
})).rejects.toThrow('PROJECT_ACTION_PURPOSE_UNSUPPORTED');
|
||||
expect(closed).toEqual(['tab-1']);
|
||||
});
|
||||
|
||||
test('reuses one in-flight authority listing per directory', async () => {
|
||||
let calls = 0;
|
||||
let resolveSessions: ((value: Array<{ sessionId: string; cwd: string; status: 'running'; createdAt: number | null }>) => void) | undefined;
|
||||
const capturedRevisions: number[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
listSessions: async () => {
|
||||
calls += 1;
|
||||
return await new Promise((resolve) => {
|
||||
resolveSessions = resolve;
|
||||
});
|
||||
},
|
||||
createSession: async () => ({ sessionId: 'ignored', cols: 80, rows: 24, status: 'running' }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
const captureStartedActionMutationRevisions = () => {
|
||||
const revision = capturedRevisions.length + 1;
|
||||
capturedRevisions.push(revision);
|
||||
return new Map([['/repo::build', revision]]);
|
||||
};
|
||||
|
||||
const first = reconcileTerminalSessionAuthority(terminal, '/repo', {
|
||||
captureStartedActionMutationRevisions,
|
||||
});
|
||||
const second = reconcileTerminalSessionAuthority(terminal, '/repo', {
|
||||
captureStartedActionMutationRevisions,
|
||||
});
|
||||
expect(first).toBe(second);
|
||||
const finishListing = resolveSessions;
|
||||
if (!finishListing) {
|
||||
throw new Error('list resolver was not captured');
|
||||
}
|
||||
finishListing([{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 1 }]);
|
||||
expect(await first).toEqual({
|
||||
sessions: [{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 1 }],
|
||||
startedActionMutationRevisions: new Map([['/repo::build', 1]]),
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(capturedRevisions).toEqual([1]);
|
||||
});
|
||||
|
||||
test('does not share an authority listing across runtime adapters', async () => {
|
||||
const calls: string[] = [];
|
||||
const createTerminal = (name: string): TerminalAPI => ({
|
||||
listSessions: async () => {
|
||||
calls.push(name);
|
||||
return [];
|
||||
},
|
||||
createSession: async () => ({ sessionId: 'ignored', cols: 80, rows: 24, status: 'running' }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async () => {},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
reconcileTerminalSessionAuthority(createTerminal('runtime-a'), '/repo'),
|
||||
reconcileTerminalSessionAuthority(createTerminal('runtime-b'), '/repo'),
|
||||
]);
|
||||
|
||||
expect(calls).toEqual(['runtime-a', 'runtime-b']);
|
||||
});
|
||||
|
||||
test('stale stop completion does not interrupt or force-kill a newer execution and cleanup runs once', async () => {
|
||||
const sent: string[] = [];
|
||||
const forceKillCalls: string[] = [];
|
||||
const subscriptions: Array<{ handlers: TerminalHandlers; closed: number }> = [];
|
||||
let current = true;
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'unused', cols: 80, rows: 24, status: 'running' }),
|
||||
connect: (_id, handlers) => {
|
||||
const record = { handlers, closed: 0 };
|
||||
subscriptions.push(record);
|
||||
return { close: () => { record.closed += 1; } };
|
||||
},
|
||||
sendInput: async (sessionId, input) => {
|
||||
sent.push(`${sessionId}:${input}`);
|
||||
current = false;
|
||||
},
|
||||
resize: async () => {},
|
||||
close: async () => {},
|
||||
forceKill: async ({ sessionId }) => {
|
||||
forceKillCalls.push(sessionId ?? '');
|
||||
},
|
||||
};
|
||||
let stopping = 0;
|
||||
let restored = 0;
|
||||
let cleared = 0;
|
||||
let finalized = 0;
|
||||
|
||||
await stopProjectActionTerminalSession({
|
||||
terminal,
|
||||
sessionId: 'srv-1',
|
||||
isExecutionStillCurrent: () => current,
|
||||
markStopping: () => { stopping += 1; },
|
||||
restoreRunning: () => { restored += 1; },
|
||||
clearSession: () => { cleared += 1; },
|
||||
finalizeExit: () => { finalized += 1; },
|
||||
timeoutMs: 1,
|
||||
});
|
||||
|
||||
expect(sent).toEqual(['srv-1:\x03']);
|
||||
expect(forceKillCalls).toEqual([]);
|
||||
expect(stopping).toBe(1);
|
||||
expect(restored).toBe(0);
|
||||
expect(cleared).toBe(0);
|
||||
expect(finalized).toBe(0);
|
||||
expect(subscriptions).toHaveLength(1);
|
||||
expect(subscriptions[0]?.closed).toBe(1);
|
||||
});
|
||||
|
||||
test('stop failure returns the action to a retryable running state', async () => {
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'unused', cols: 80, rows: 24, status: 'running' }),
|
||||
connect: (_id, handlers) => ({ close: () => { handlers.onError?.(new Error('ignored'), false); } }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
close: async () => { throw new Error('close failed'); },
|
||||
};
|
||||
let restored = 0;
|
||||
let finalized = 0;
|
||||
|
||||
await stopProjectActionTerminalSession({
|
||||
terminal,
|
||||
sessionId: 'srv-1',
|
||||
isExecutionStillCurrent: () => true,
|
||||
markStopping: () => undefined,
|
||||
restoreRunning: () => { restored += 1; },
|
||||
clearSession: () => undefined,
|
||||
finalizeExit: () => { finalized += 1; },
|
||||
timeoutMs: 1,
|
||||
});
|
||||
|
||||
expect(restored).toBe(1);
|
||||
expect(finalized).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,129 @@
|
||||
import type { TerminalAPI } from './api/types';
|
||||
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
|
||||
|
||||
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
|
||||
|
||||
const normalizeDirectory = (dir: string): string => {
|
||||
let normalized = dir.trim();
|
||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command'>;
|
||||
|
||||
type CreateProjectActionTerminalSessionOptions = {
|
||||
terminal: TerminalAPI;
|
||||
previousSessionId: string | null;
|
||||
createOptions: ProjectActionTerminalCreateOptions;
|
||||
command: string;
|
||||
isRunStillExpected: () => boolean;
|
||||
purpose: Extract<TerminalSessionPurpose, { type: 'project-action' }>;
|
||||
};
|
||||
|
||||
type StopProjectActionTerminalSessionOptions = {
|
||||
terminal: TerminalAPI;
|
||||
sessionId: string;
|
||||
isExecutionStillCurrent: () => boolean;
|
||||
markStopping: () => void;
|
||||
restoreRunning: () => void;
|
||||
clearSession: () => void;
|
||||
finalizeExit: () => void;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const COMMAND_MODE_UNSUPPORTED_ERROR = 'COMMAND_MODE_UNSUPPORTED';
|
||||
const PROJECT_ACTION_RUN_CANCELLED_ERROR = 'PROJECT_ACTION_RUN_CANCELLED';
|
||||
const PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR = 'PROJECT_ACTION_PURPOSE_UNSUPPORTED';
|
||||
|
||||
const createProjectActionTerminalError = (message: string): Error => new Error(message);
|
||||
|
||||
const closeTerminalSession = async (terminal: TerminalAPI, sessionId: string): Promise<void> => {
|
||||
try {
|
||||
await terminal.close(sessionId);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
const rejectCreatedSession = async (terminal: TerminalAPI, sessionId: string, errorMessage: string): Promise<never> => {
|
||||
await closeTerminalSession(terminal, sessionId);
|
||||
throw createProjectActionTerminalError(errorMessage);
|
||||
};
|
||||
|
||||
export const normalizeProjectActionCommand = (command: string): string => {
|
||||
const normalizedNewlines = command.trim().replace(/\r\n|\r/g, '\n');
|
||||
let next = '';
|
||||
for (let index = 0; index < normalizedNewlines.length; index += 1) {
|
||||
const code = normalizedNewlines.charCodeAt(index);
|
||||
const isControl = (code >= 0 && code <= 8)
|
||||
|| code === 11
|
||||
|| code === 12
|
||||
|| (code >= 14 && code <= 31)
|
||||
|| code === 127;
|
||||
if (!isControl) {
|
||||
next += normalizedNewlines[index];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const isCommandTerminalSession = (session: TerminalSession): boolean => session.mode === 'command';
|
||||
const isProjectActionTerminalPurpose = (
|
||||
purpose: TerminalSessionPurpose | undefined,
|
||||
): purpose is Extract<TerminalSessionPurpose, { type: 'project-action' }> => purpose?.type === 'project-action';
|
||||
|
||||
const isMatchingProjectActionPurpose = (
|
||||
purpose: TerminalSessionPurpose | undefined,
|
||||
actionId: string,
|
||||
): purpose is Extract<TerminalSessionPurpose, { type: 'project-action' }> => (
|
||||
isProjectActionTerminalPurpose(purpose)
|
||||
&& purpose.actionId === actionId
|
||||
&& purpose.executionId.trim().length > 0
|
||||
);
|
||||
|
||||
type ReconcileTerminalSessionAuthorityOptions = {
|
||||
captureStartedActionMutationRevisions?: (directory: string) => TerminalActionMutationRevisions;
|
||||
};
|
||||
|
||||
type ReconcileTerminalSessionAuthorityResult = {
|
||||
sessions: TerminalServerSession[];
|
||||
startedActionMutationRevisions: TerminalActionMutationRevisions;
|
||||
};
|
||||
|
||||
export const createProjectActionTerminalSession = async ({
|
||||
terminal,
|
||||
previousSessionId,
|
||||
createOptions,
|
||||
command,
|
||||
isRunStillExpected,
|
||||
purpose,
|
||||
}: CreateProjectActionTerminalSessionOptions): Promise<TerminalSession> => {
|
||||
if (previousSessionId) {
|
||||
await closeTerminalSession(terminal, previousSessionId);
|
||||
}
|
||||
|
||||
const created = await terminal.createSession({
|
||||
...createOptions,
|
||||
mode: 'command',
|
||||
command: normalizeProjectActionCommand(command),
|
||||
purpose,
|
||||
});
|
||||
|
||||
if (!isCommandTerminalSession(created)) {
|
||||
await rejectCreatedSession(terminal, created.sessionId, COMMAND_MODE_UNSUPPORTED_ERROR);
|
||||
}
|
||||
|
||||
if (!isMatchingProjectActionPurpose(created.purpose, purpose.actionId)) {
|
||||
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR);
|
||||
}
|
||||
|
||||
if (!isRunStillExpected()) {
|
||||
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_RUN_CANCELLED_ERROR);
|
||||
}
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
export const waitForTerminalExit = (
|
||||
terminal: TerminalAPI,
|
||||
@@ -24,3 +149,108 @@ export const waitForTerminalExit = (
|
||||
if (settled) subscription.close();
|
||||
else timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
});
|
||||
|
||||
export const stopProjectActionTerminalSession = async ({
|
||||
terminal,
|
||||
sessionId,
|
||||
isExecutionStillCurrent,
|
||||
markStopping,
|
||||
restoreRunning,
|
||||
clearSession,
|
||||
finalizeExit,
|
||||
timeoutMs = 1000,
|
||||
}: StopProjectActionTerminalSessionOptions): Promise<void> => {
|
||||
markStopping();
|
||||
|
||||
const exitPromise = waitForTerminalExit(terminal, sessionId, timeoutMs);
|
||||
|
||||
try {
|
||||
if (isExecutionStillCurrent()) {
|
||||
await terminal.sendInput(sessionId, '\x03');
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
const exitObserved = await exitPromise;
|
||||
if (!isExecutionStillCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exitObserved) {
|
||||
let terminationFailed = false;
|
||||
if (terminal.forceKill) {
|
||||
try {
|
||||
if (isExecutionStillCurrent()) {
|
||||
await terminal.forceKill({ sessionId });
|
||||
}
|
||||
} catch {
|
||||
terminationFailed = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (isExecutionStillCurrent()) {
|
||||
await terminal.close(sessionId);
|
||||
}
|
||||
} catch {
|
||||
terminationFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExecutionStillCurrent()) {
|
||||
return;
|
||||
}
|
||||
if (terminationFailed) {
|
||||
restoreRunning();
|
||||
return;
|
||||
}
|
||||
clearSession();
|
||||
}
|
||||
|
||||
if (!isExecutionStillCurrent()) {
|
||||
return;
|
||||
}
|
||||
finalizeExit();
|
||||
};
|
||||
|
||||
const reconcileFlightsByTerminal = new WeakMap<
|
||||
TerminalAPI,
|
||||
Map<string, Promise<ReconcileTerminalSessionAuthorityResult | null>>
|
||||
>();
|
||||
|
||||
export const reconcileTerminalSessionAuthority = (
|
||||
terminal: TerminalAPI,
|
||||
directory: string,
|
||||
options: ReconcileTerminalSessionAuthorityOptions = {},
|
||||
): Promise<ReconcileTerminalSessionAuthorityResult | null> => {
|
||||
if (!terminal.listSessions) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const normalizedDirectory = normalizeDirectory(directory);
|
||||
let terminalFlights = reconcileFlightsByTerminal.get(terminal);
|
||||
if (!terminalFlights) {
|
||||
terminalFlights = new Map();
|
||||
reconcileFlightsByTerminal.set(terminal, terminalFlights);
|
||||
}
|
||||
const existing = terminalFlights.get(normalizedDirectory);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const startedActionMutationRevisions = options.captureStartedActionMutationRevisions?.(normalizedDirectory)
|
||||
?? new Map<string, number>();
|
||||
const flight = terminal.listSessions(normalizedDirectory)
|
||||
.then((sessions) => ({ sessions, startedActionMutationRevisions }))
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
if (terminalFlights.get(normalizedDirectory) === flight) {
|
||||
terminalFlights.delete(normalizedDirectory);
|
||||
if (terminalFlights.size === 0) {
|
||||
reconcileFlightsByTerminal.delete(terminal);
|
||||
}
|
||||
}
|
||||
});
|
||||
terminalFlights.set(normalizedDirectory, flight);
|
||||
return flight;
|
||||
};
|
||||
|
||||
@@ -18,4 +18,22 @@ describe('resolveProjectForSessionDirectory', () => {
|
||||
|
||||
expect(resolveProjectForSessionDirectory(projects, worktrees, '/workspace/openchamber-feature')).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('prefers registered worktree ownership over a containing project', () => {
|
||||
const configuredProjects = [
|
||||
{ id: 'home', path: '/Users/elfy', label: 'Home' },
|
||||
{ id: 'infoscan', path: '/Users/elfy/GitRepos/infoscan', label: 'InfoScan' },
|
||||
];
|
||||
const worktreePath = '/Users/elfy/.local/share/opencode/worktree/refactor-self-hosted-runners';
|
||||
const worktrees = new Map([
|
||||
['/Users/elfy/GitRepos/infoscan', [{
|
||||
path: worktreePath,
|
||||
projectDirectory: '/Users/elfy/GitRepos/infoscan',
|
||||
branch: 'refactor/self-hosted-runners',
|
||||
label: 'refactor/self-hosted-runners',
|
||||
}]],
|
||||
]);
|
||||
|
||||
expect(resolveProjectForSessionDirectory(configuredProjects, worktrees, worktreePath)).toEqual(configuredProjects[1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ const resolveProjectFromWorktreeDirectory = (
|
||||
projects: ProjectEntry[],
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||
directory: string | null,
|
||||
): ProjectEntry | null => {
|
||||
): { project: ProjectEntry; matchedWorktreePathLength: number } | null => {
|
||||
const nd = normalizeProjectPath(directory);
|
||||
if (!nd) return null;
|
||||
let matchedWorktree: WorktreeMetadata | null = null;
|
||||
@@ -47,9 +47,9 @@ const resolveProjectFromWorktreeDirectory = (
|
||||
.filter((v): v is string => Boolean(v));
|
||||
for (const c of candidates) {
|
||||
const exact = projects.find((p) => normalizeProjectPath(p.path) === c) ?? null;
|
||||
if (exact) return exact;
|
||||
if (exact) return { project: exact, matchedWorktreePathLength: bestLen };
|
||||
const nested = resolveProjectForDirectory(projects, c);
|
||||
if (nested) return nested;
|
||||
if (nested) return { project: nested, matchedWorktreePathLength: bestLen };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -58,6 +58,14 @@ export const resolveProjectForSessionDirectory = (
|
||||
projects: ProjectEntry[],
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||
directory: string | null,
|
||||
): ProjectEntry | null =>
|
||||
resolveProjectForDirectory(projects, directory) ??
|
||||
resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory);
|
||||
): ProjectEntry | null => {
|
||||
const directProject = resolveProjectForDirectory(projects, directory);
|
||||
const worktreeResolution = resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory);
|
||||
if (!directProject) return worktreeResolution?.project ?? null;
|
||||
if (!worktreeResolution) return directProject;
|
||||
|
||||
const directPathLength = normalizeProjectPath(directProject.path)?.length ?? 0;
|
||||
return worktreeResolution.matchedWorktreePathLength > directPathLength
|
||||
? worktreeResolution.project
|
||||
: directProject;
|
||||
};
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import type { TerminalSessionPurpose, TerminalStreamEvent } from './api/types';
|
||||
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||
import { TerminalTransport } from './terminalApi';
|
||||
|
||||
mock.module('./runtime-fetch', () => ({ runtimeFetch: async () => new Response(null, { status: 500 }) }));
|
||||
mock.module('./runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: () => 'ws://example.test/terminal' }) }));
|
||||
mock.module('./runtime-auth', () => ({
|
||||
clearRuntimeUrlAuthToken: () => undefined,
|
||||
refreshRuntimeUrlAuthToken: async () => undefined,
|
||||
}));
|
||||
mock.module('./relay/runtime-socket', () => ({ openRuntimeWebSocket: () => { throw new Error('not used in tests'); } }));
|
||||
|
||||
const { parseTerminalSession, parseTerminalSessionPurpose, TerminalTransport } = await import('./terminalApi');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const frame = (message: Record<string, unknown>): Uint8Array => {
|
||||
type WireMessage = {
|
||||
t: string;
|
||||
s?: string;
|
||||
q?: number;
|
||||
v?: number;
|
||||
d?: string;
|
||||
r?: string;
|
||||
history?: string;
|
||||
status?: TerminalStreamEvent['status'];
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
code?: string;
|
||||
message?: string;
|
||||
fatal?: boolean;
|
||||
mode?: 'interactive' | 'command';
|
||||
purpose?: TerminalSessionPurpose | { type: 'project-action'; actionId: string };
|
||||
};
|
||||
|
||||
const frame = (message: WireMessage): Uint8Array => {
|
||||
const body = encoder.encode(JSON.stringify(message));
|
||||
const result = new Uint8Array(body.length + 1);
|
||||
result[0] = 1;
|
||||
result.set(body, 1);
|
||||
return result;
|
||||
};
|
||||
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): Record<string, unknown> => {
|
||||
const bytes = typeof value === 'string'
|
||||
? encoder.encode(value)
|
||||
: value instanceof ArrayBuffer
|
||||
? new Uint8Array(value)
|
||||
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
||||
return JSON.parse(decoder.decode(bytes.subarray(1))) as Record<string, unknown>;
|
||||
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): WireMessage => {
|
||||
const bytes = value instanceof ArrayBuffer
|
||||
? new Uint8Array(value)
|
||||
: ArrayBuffer.isView(value)
|
||||
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
: encoder.encode(value);
|
||||
const parsed = JSON.parse(decoder.decode(bytes.subarray(1)));
|
||||
// SAFETY: test frames are encoded from `WireMessage`, so decoding that same frame preserves the wire shape here.
|
||||
return parsed as WireMessage;
|
||||
};
|
||||
|
||||
class FakeSocket implements RelayTunnelWebSocket {
|
||||
@@ -27,12 +57,12 @@ class FakeSocket implements RelayTunnelWebSocket {
|
||||
onmessage: RelayTunnelWebSocket['onmessage'] = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onclose: RelayTunnelWebSocket['onclose'] = null;
|
||||
sent: Record<string, unknown>[] = [];
|
||||
sent: WireMessage[] = [];
|
||||
|
||||
open(): void { this.readyState = 1; this.onopen?.(); }
|
||||
emit(message: Record<string, unknown>): void {
|
||||
emit(message: WireMessage): void {
|
||||
const bytes = frame(message);
|
||||
this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer });
|
||||
this.onmessage?.({ data: bytes.slice().buffer });
|
||||
}
|
||||
send(data: string | ArrayBuffer | ArrayBufferView): void { this.sent.push(parseFrame(data)); }
|
||||
close(): void { this.readyState = 3; this.onclose?.({ code: 1000, reason: '' }); }
|
||||
@@ -41,6 +71,38 @@ class FakeSocket implements RelayTunnelWebSocket {
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe('terminal transport', () => {
|
||||
test('parses the terminal purpose union and rejects malformed payloads', () => {
|
||||
expect(parseTerminalSessionPurpose({ type: 'terminal' })).toEqual({ type: 'terminal' });
|
||||
expect(parseTerminalSessionPurpose({ type: 'project-action', actionId: 'build', executionId: 'exec-1' })).toEqual({
|
||||
type: 'project-action',
|
||||
actionId: 'build',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
expect(parseTerminalSessionPurpose({ type: 'project-action', actionId: 'build' })).toBe(undefined);
|
||||
expect(parseTerminalSession({
|
||||
sessionId: 'term-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
status: 'running',
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
})).toEqual({
|
||||
sessionId: 'term-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
status: 'running',
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
});
|
||||
expect(parseTerminalSession({
|
||||
sessionId: 'term-1',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
status: 'running',
|
||||
purpose: { type: 'project-action', actionId: 'build' },
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => {
|
||||
const socket = new FakeSocket();
|
||||
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
|
||||
@@ -52,7 +114,7 @@ describe('terminal transport', () => {
|
||||
expect(socket.sent.some((message) => message.t === 'attach' && message.s === 'term-1')).toBe(true);
|
||||
expect(socket.sent.filter((message) => message.t === 'attach')).toHaveLength(1);
|
||||
|
||||
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running' });
|
||||
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
|
||||
await tick();
|
||||
const secondEvents: string[] = [];
|
||||
transport.subscribe('term-1', { onEvent: (event) => secondEvents.push(`${event.type}:${event.data ?? ''}`) });
|
||||
@@ -71,8 +133,8 @@ describe('terminal transport', () => {
|
||||
});
|
||||
|
||||
test('recovers when opening the first websocket fails', async () => {
|
||||
if (typeof document !== 'undefined') Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
if (typeof navigator !== 'undefined') Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
|
||||
if (globalThis.document) Object.defineProperty(globalThis.document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, 'onLine', { configurable: true, value: true });
|
||||
const socket = new FakeSocket();
|
||||
let attempts = 0;
|
||||
const events: string[] = [];
|
||||
@@ -165,7 +227,8 @@ describe('terminal transport', () => {
|
||||
|
||||
const unsubscribeFirst = transport.subscribe('term-1', {
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt);
|
||||
if (event.type !== 'reconnecting' || event.attempt == null) return;
|
||||
firstEvents.push(event.attempt);
|
||||
},
|
||||
});
|
||||
await tick();
|
||||
@@ -175,7 +238,8 @@ describe('terminal transport', () => {
|
||||
unsubscribeFirst();
|
||||
const unsubscribeReplacement = transport.subscribe('term-2', {
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt);
|
||||
if (event.type !== 'reconnecting' || event.attempt == null) return;
|
||||
replacementEvents.push(event.attempt);
|
||||
},
|
||||
});
|
||||
await tick();
|
||||
@@ -190,7 +254,7 @@ describe('terminal transport', () => {
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const delays: number[] = [];
|
||||
let transport: TerminalTransport | null = null;
|
||||
let transport: InstanceType<typeof TerminalTransport> | null = null;
|
||||
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
@@ -200,11 +264,16 @@ describe('terminal transport', () => {
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
});
|
||||
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||
delays.push(Number(timeout ?? 0));
|
||||
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout;
|
||||
Object.defineProperty(globalThis, 'setTimeout', {
|
||||
configurable: true,
|
||||
value: (handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
||||
delays.push(Number(timeout ?? 0));
|
||||
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
|
||||
const handle = originalSetTimeout(() => {}, 0);
|
||||
clearTimeout(handle);
|
||||
return handle;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
transport = new TerminalTransport({
|
||||
@@ -218,9 +287,9 @@ describe('terminal transport', () => {
|
||||
expect(delays).toContain(60_000);
|
||||
} finally {
|
||||
transport?.dispose();
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
Object.defineProperty(globalThis, 'setTimeout', { configurable: true, value: originalSetTimeout });
|
||||
if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument);
|
||||
else delete (globalThis as { document?: unknown }).document;
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -283,6 +352,26 @@ describe('terminal transport', () => {
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
test('preserves valid snapshot purpose and safely drops malformed snapshot purpose', async () => {
|
||||
const socket = new FakeSocket();
|
||||
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
|
||||
const purposes: Array<string | null> = [];
|
||||
transport.subscribe('term-1', {
|
||||
onEvent: (event) => {
|
||||
if (event.type !== 'snapshot') return;
|
||||
purposes.push(event.purpose?.type === 'project-action' ? event.purpose.executionId : null);
|
||||
},
|
||||
});
|
||||
await tick();
|
||||
socket.open();
|
||||
await tick();
|
||||
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
|
||||
socket.emit({ t: 'restarted', v: 3, s: 'term-1', q: 2, history: 'prompt 2', purpose: { type: 'project-action', actionId: 'build' } });
|
||||
await tick();
|
||||
expect(purposes).toEqual(['exec-1', 'exec-1']);
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
test('reuses the open socket when switching between terminals', async () => {
|
||||
const sockets: FakeSocket[] = [];
|
||||
let authCalls = 0;
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
||||
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
||||
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
||||
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
|
||||
import { isTerminalShell } from './terminalShell';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Message = Record<string, unknown> & { t: string; s?: string; q?: number };
|
||||
type Message = Record<string, unknown> & {
|
||||
t: string;
|
||||
s?: string;
|
||||
q?: number;
|
||||
d?: string;
|
||||
r?: string;
|
||||
history?: string;
|
||||
status?: TerminalStreamEvent['status'];
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
runtime?: TerminalStreamEvent['runtime'];
|
||||
ptyBackend?: string;
|
||||
mode?: TerminalSession['mode'];
|
||||
purpose?: TerminalSessionPurposeInput;
|
||||
message?: string;
|
||||
code?: string;
|
||||
fatal?: boolean;
|
||||
};
|
||||
type Subscriber = { handlers: TerminalHandlers; lastSequence: number };
|
||||
type TerminalProjection = {
|
||||
sequence: number;
|
||||
history: string;
|
||||
status: TerminalStreamEvent['status'];
|
||||
mode?: TerminalSession['mode'];
|
||||
purpose?: TerminalSessionPurpose;
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
runtime?: TerminalStreamEvent['runtime'];
|
||||
@@ -30,6 +50,42 @@ const SOCKET_OPEN = 1;
|
||||
const IDLE_SOCKET_GRACE_MS = 15_000;
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const terminalModeSchema = z.enum(['interactive', 'command']);
|
||||
const terminalStatusSchema = z.enum(['running', 'exited', 'error']);
|
||||
const terminalRuntimeSchema = z.enum(['node', 'bun']);
|
||||
type TerminalSessionPurposeInput =
|
||||
| TerminalSessionPurpose
|
||||
| { type: 'project-action'; actionId: string; executionId?: string }
|
||||
| null
|
||||
| undefined;
|
||||
type TerminalSessionInput = {
|
||||
sessionId?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
status?: 'running' | 'exited' | 'error';
|
||||
mode?: 'interactive' | 'command';
|
||||
purpose?: TerminalSessionPurposeInput;
|
||||
} | null | undefined;
|
||||
const terminalSessionPurposeSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('terminal') }),
|
||||
z.object({ type: z.literal('project-action'), actionId: z.string(), executionId: z.string() }),
|
||||
]);
|
||||
const terminalSessionSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
cols: z.number(),
|
||||
rows: z.number(),
|
||||
status: terminalStatusSchema,
|
||||
mode: terminalModeSchema.optional(),
|
||||
purpose: terminalSessionPurposeSchema.optional(),
|
||||
});
|
||||
const terminalServerSessionSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
cwd: z.string(),
|
||||
status: z.enum(['running', 'exited']),
|
||||
createdAt: z.number().nullable().optional().transform((value) => value ?? null),
|
||||
mode: terminalModeSchema.optional(),
|
||||
purpose: terminalSessionPurposeSchema.optional(),
|
||||
});
|
||||
|
||||
const encode = (message: Message): Uint8Array => {
|
||||
const payload = encoder.encode(JSON.stringify(message));
|
||||
@@ -63,6 +119,28 @@ const trimProjection = (value: string): string => {
|
||||
return decoder.decode(bytes.subarray(start));
|
||||
};
|
||||
|
||||
const terminalSessionListSchema = z.object({ sessions: z.array(z.unknown()) });
|
||||
|
||||
const parseTerminalMode = (value: TerminalSession['mode'] | null | undefined): TerminalSession['mode'] | undefined => {
|
||||
return terminalModeSchema.safeParse(value).data;
|
||||
};
|
||||
|
||||
const parseTerminalStatus = (value: TerminalStreamEvent['status'] | null | undefined): TerminalStreamEvent['status'] => {
|
||||
return terminalStatusSchema.safeParse(value).data ?? 'running';
|
||||
};
|
||||
|
||||
const parseTerminalRuntime = (value: TerminalStreamEvent['runtime'] | null | undefined): TerminalStreamEvent['runtime'] | undefined => {
|
||||
return terminalRuntimeSchema.safeParse(value).data;
|
||||
};
|
||||
|
||||
export const parseTerminalSessionPurpose = (value: TerminalSessionPurposeInput): TerminalSessionPurpose | undefined => {
|
||||
return terminalSessionPurposeSchema.safeParse(value).data;
|
||||
};
|
||||
|
||||
export const parseTerminalSession = (value: TerminalSessionInput): TerminalSession | null => {
|
||||
return terminalSessionSchema.safeParse(value).data ?? null;
|
||||
};
|
||||
|
||||
type TerminalTransportDependencies = {
|
||||
refreshAuth: () => Promise<unknown>;
|
||||
openSocket: () => RelayTunnelWebSocket;
|
||||
@@ -98,7 +176,7 @@ export class TerminalTransport {
|
||||
const projection = this.projections.get(sessionId);
|
||||
if (projection) {
|
||||
subscriber.lastSequence = projection.sequence;
|
||||
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
}
|
||||
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
|
||||
this.ensureConnected().then(() => {
|
||||
@@ -267,18 +345,20 @@ export class TerminalTransport {
|
||||
if (!subscribers) return;
|
||||
if (message.t === 'snapshot') {
|
||||
const projection: TerminalProjection = {
|
||||
sequence: typeof message.q === 'number' ? message.q : 0,
|
||||
history: typeof message.history === 'string' ? message.history : '',
|
||||
status: message.status as TerminalStreamEvent['status'],
|
||||
exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined,
|
||||
signal: typeof message.signal === 'number' ? message.signal : null,
|
||||
runtime: message.runtime as TerminalStreamEvent['runtime'],
|
||||
ptyBackend: typeof message.ptyBackend === 'string' ? message.ptyBackend : undefined,
|
||||
sequence: message.q ?? 0,
|
||||
history: message.history ?? '',
|
||||
status: parseTerminalStatus(message.status),
|
||||
mode: parseTerminalMode(message.mode),
|
||||
purpose: parseTerminalSessionPurpose(message.purpose),
|
||||
exitCode: message.exitCode,
|
||||
signal: message.signal ?? null,
|
||||
runtime: parseTerminalRuntime(message.runtime),
|
||||
ptyBackend: message.ptyBackend,
|
||||
};
|
||||
this.projections.set(message.s, projection);
|
||||
for (const sub of subscribers) {
|
||||
sub.lastSequence = projection.sequence;
|
||||
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -287,14 +367,14 @@ export class TerminalTransport {
|
||||
if (previous && message.q > previous.sequence) {
|
||||
if (message.t === 'output') this.projections.set(message.s, { ...previous, sequence: message.q, history: trimProjection(previous.history + (typeof message.r === 'string' ? message.r : (typeof message.d === 'string' ? message.d : ''))) });
|
||||
else if (message.t === 'exit') this.projections.set(message.s, { ...previous, sequence: message.q, status: 'exited', exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
|
||||
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: typeof message.history === 'string' ? message.history : '', status: 'running', exitCode: undefined, signal: null });
|
||||
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous.purpose, exitCode: undefined, signal: null });
|
||||
}
|
||||
for (const sub of subscribers) {
|
||||
if (message.q <= sub.lastSequence) continue;
|
||||
sub.lastSequence = message.q;
|
||||
if (message.t === 'output') sub.handlers.onEvent({ type: 'data', sequence: message.q, data: typeof message.d === 'string' ? message.d : '', replayData: typeof message.r === 'string' ? message.r : undefined });
|
||||
else if (message.t === 'exit') sub.handlers.onEvent({ type: 'exit', sequence: message.q, exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
|
||||
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: typeof message.history === 'string' ? message.history : '', status: 'running' });
|
||||
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous?.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous?.purpose });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,27 +434,23 @@ let transport = new TerminalTransport();
|
||||
export async function createTerminalSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const response = await runtimeFetch('/api/terminal/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options) });
|
||||
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
|
||||
return response.json() as Promise<TerminalSession>;
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const parsed = terminalSessionSchema.safeParse(payload).data;
|
||||
if (!parsed) throw new Error('Failed to create terminal session');
|
||||
return parsed;
|
||||
}
|
||||
export async function listTerminalSessions(cwd: string): Promise<TerminalServerSession[]> {
|
||||
const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`);
|
||||
if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions');
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const rawSessions = payload && typeof payload === 'object' && 'sessions' in payload ? payload.sessions : null;
|
||||
const rawSessions = terminalSessionListSchema.safeParse(payload).data?.sessions;
|
||||
if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions');
|
||||
const parsed: TerminalServerSession[] = [];
|
||||
for (const entry of rawSessions as unknown[]) {
|
||||
if (typeof entry !== 'object' || entry === null) continue;
|
||||
// SAFETY: every field is verified below before the value is used.
|
||||
const candidate = entry as Partial<Record<keyof TerminalServerSession, unknown>>;
|
||||
if (typeof candidate.sessionId !== 'string' || typeof candidate.cwd !== 'string') continue;
|
||||
if (candidate.status !== 'running' && candidate.status !== 'exited') continue;
|
||||
parsed.push({
|
||||
sessionId: candidate.sessionId,
|
||||
cwd: candidate.cwd,
|
||||
status: candidate.status,
|
||||
createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : null,
|
||||
});
|
||||
for (const entry of rawSessions) {
|
||||
const session = terminalServerSessionSchema.safeParse(entry).data;
|
||||
if (session) {
|
||||
parsed.push(session);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,10 @@ Invariants to preserve when editing:
|
||||
projection while both are referentially unchanged, and the storage adapter skips a write
|
||||
for an unchanged projection, so streaming output performs no persistence work.
|
||||
- Consumers that react to output must subscribe to `buffers`, not `sessions`.
|
||||
- Server session listings capture the directory's per-action mutation revisions when the
|
||||
request starts. Coalesced callers share that first snapshot. A response cannot replace or
|
||||
remove an action execution mutated after its request began, while a fresh successful empty
|
||||
response still clears an omitted run.
|
||||
|
||||
## Git / PR Stores
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ const deferred = <T,>() => {
|
||||
mock.module('@/stores/utils/safeStorage', () => ({
|
||||
getDeferredSafeStorage: () => makeStorage(),
|
||||
getSafeStorage: () => makeStorage(),
|
||||
getSafeSessionStorage: () => makeStorage(),
|
||||
createDeferredSafeJSONStorage: () => {
|
||||
const testStorage = makeStorage();
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { useTerminalStore } from './useTerminalStore';
|
||||
|
||||
import type { TerminalServerSession } from '@/lib/api/types';
|
||||
|
||||
import { directoryMayHaveActiveProjectAction, useTerminalStore } from './useTerminalStore';
|
||||
|
||||
const setup = () => {
|
||||
useTerminalStore.getState().clearAll();
|
||||
@@ -9,14 +12,35 @@ const setup = () => {
|
||||
|
||||
const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo', tabId);
|
||||
|
||||
const staleBuildSession: TerminalServerSession = {
|
||||
sessionId: 'srv-old',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: 1,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-old' },
|
||||
};
|
||||
|
||||
const captureStartedActionMutationRevisions = (directory: string) => {
|
||||
return useTerminalStore.getState().captureStartedActionMutationRevisions(directory);
|
||||
};
|
||||
|
||||
const reconcileServerSessionsWithStartedRevisions = (
|
||||
directory: string,
|
||||
serverSessions: TerminalServerSession[],
|
||||
startedActionMutationRevisions: ReadonlyMap<string, number>,
|
||||
) => {
|
||||
useTerminalStore.getState().reconcileServerSessions(directory, serverSessions, { startedActionMutationRevisions });
|
||||
};
|
||||
|
||||
describe('terminal state reconciliation', () => {
|
||||
afterEach(() => useTerminalStore.getState().clearAll());
|
||||
|
||||
test('adopts unknown server sessions into the fresh placeholder tab', () => {
|
||||
test('reconciles unknown server sessions into the fresh placeholder tab', () => {
|
||||
setup();
|
||||
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
|
||||
{ sessionId: 'srv-2', status: 'exited', createdAt: null },
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [
|
||||
{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 100 },
|
||||
{ sessionId: 'srv-2', cwd: '/repo', status: 'exited', createdAt: null },
|
||||
]);
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||
expect(state.tabs.map((tab) => tab.id)).toEqual(['srv-1', 'srv-2']);
|
||||
@@ -26,13 +50,13 @@ describe('terminal state reconciliation', () => {
|
||||
expect(state.activeTabId).toBe('srv-1');
|
||||
});
|
||||
|
||||
test('adoption is additive: existing tabs and referenced sessions survive', () => {
|
||||
test('reconciliation keeps existing interactive tabs and adopts unknown sessions', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'output', 1);
|
||||
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-live');
|
||||
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||
{ sessionId: 'srv-live', status: 'running', createdAt: 1 },
|
||||
{ sessionId: 'srv-orphan', status: 'running', createdAt: 2 },
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [
|
||||
{ sessionId: 'srv-live', cwd: '/repo', status: 'running', createdAt: 1 },
|
||||
{ sessionId: 'srv-orphan', cwd: '/repo', status: 'running', createdAt: 2 },
|
||||
]);
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||
expect(state.tabs).toHaveLength(2);
|
||||
@@ -41,18 +65,327 @@ describe('terminal state reconciliation', () => {
|
||||
expect(state.activeTabId).toBe(tabId);
|
||||
});
|
||||
|
||||
test('re-adopting the same sessions changes nothing', () => {
|
||||
test('re-reconciling the same sessions changes nothing', () => {
|
||||
setup();
|
||||
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [
|
||||
{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 100 },
|
||||
]);
|
||||
const before = useTerminalStore.getState().sessions;
|
||||
useTerminalStore.getState().adoptServerSessions('/repo', [
|
||||
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [
|
||||
{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 100 },
|
||||
]);
|
||||
expect(useTerminalStore.getState().sessions).toBe(before);
|
||||
});
|
||||
|
||||
test('successful empty reconciliation with no local directory keeps the original sessions reference', () => {
|
||||
const before = useTerminalStore.getState().sessions;
|
||||
useTerminalStore.getState().reconcileServerSessions('/missing', []);
|
||||
expect(useTerminalStore.getState().sessions).toBe(before);
|
||||
});
|
||||
|
||||
test('hydrates persisted action tabs with live fields reset until server authority returns', () => {
|
||||
const mergePersistedState = useTerminalStore.persist.getOptions().merge;
|
||||
if (!mergePersistedState) {
|
||||
throw new Error('expected persisted merge helper');
|
||||
}
|
||||
const hydrated = mergePersistedState({
|
||||
sessions: [[
|
||||
'/repo',
|
||||
{
|
||||
activeTabId: 'tab-a',
|
||||
tabs: [{ id: 'tab-a', label: 'Build', iconKey: 'build', createdAt: 10, purpose: { type: 'project-action', actionId: 'build' } }],
|
||||
},
|
||||
]],
|
||||
nextTabId: 2,
|
||||
}, useTerminalStore.getState());
|
||||
const tab = hydrated.sessions.get('/repo')?.tabs[0];
|
||||
expect(tab?.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
|
||||
expect(tab?.lifecycle).toBe('idle');
|
||||
expect(tab?.terminalSessionId).toBeNull();
|
||||
});
|
||||
|
||||
test('adopts an existing running project-action session by action id across clients', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: null });
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [{
|
||||
sessionId: 'srv-shared',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: 1,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-2' },
|
||||
}]);
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.id).toBe(tabId);
|
||||
expect(tab.terminalSessionId).toBe('srv-shared');
|
||||
expect(tab.lifecycle).toBe('running');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: 'exec-2' });
|
||||
});
|
||||
|
||||
test('activates a rebound running project-action tab when the current active tab is idle and sessionless', () => {
|
||||
const actionTabId = setup();
|
||||
const shellTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: null });
|
||||
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
|
||||
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [{
|
||||
sessionId: 'srv-build',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: 10,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
}]);
|
||||
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||
expect(state.activeTabId).toBe(actionTabId);
|
||||
});
|
||||
|
||||
test('does not activate a rebound running project-action tab when the current active tab is a live running terminal', () => {
|
||||
const actionTabId = setup();
|
||||
const shellTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: null });
|
||||
useTerminalStore.getState().setTabSessionId('/repo', shellTabId, 'srv-shell');
|
||||
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
|
||||
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [{
|
||||
sessionId: 'srv-build',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: 10,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
}]);
|
||||
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||
expect(state.activeTabId).toBe(shellTabId);
|
||||
});
|
||||
|
||||
test('does not activate an exited adopted project-action tab', () => {
|
||||
const actionTabId = setup();
|
||||
const shellTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: null });
|
||||
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
|
||||
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [{
|
||||
sessionId: 'srv-build',
|
||||
cwd: '/repo',
|
||||
status: 'exited',
|
||||
createdAt: 10,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
}]);
|
||||
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||
expect(state.activeTabId).toBe(shellTabId);
|
||||
});
|
||||
|
||||
test('activates the newest adopted running project action when multiple qualify in one reconciliation', () => {
|
||||
const firstActionTabId = setup();
|
||||
const secondActionTabId = useTerminalStore.getState().createTab('/repo');
|
||||
const shellTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', firstActionTabId, { type: 'project-action', actionId: 'build', executionId: null });
|
||||
useTerminalStore.getState().setTabPurpose('/repo', secondActionTabId, { type: 'project-action', actionId: 'test', executionId: null });
|
||||
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
|
||||
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [
|
||||
{
|
||||
sessionId: 'srv-build',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: 10,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
},
|
||||
{
|
||||
sessionId: 'srv-test',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: 20,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'test', executionId: 'exec-2' },
|
||||
},
|
||||
]);
|
||||
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||
expect(state.activeTabId).toBe(secondActionTabId);
|
||||
});
|
||||
|
||||
// Activation transitions always rewrite the affected tab (session id or
|
||||
// lifecycle changes), so there is no reachable activation without a tab
|
||||
// update; this pins the in-place rebind case where only one tab mutates
|
||||
// and no tab is added, removed, or reordered.
|
||||
test('activates an in-place running rebind of an existing action tab without structural tab changes', () => {
|
||||
const actionTabId = setup();
|
||||
const shellTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', actionTabId, { type: 'project-action', actionId: 'build', executionId: 'exec-1' });
|
||||
useTerminalStore.getState().setTabSessionId('/repo', actionTabId, 'srv-build', { expectedExecutionId: 'exec-1' });
|
||||
useTerminalStore.getState().setActiveTab('/repo', shellTabId);
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', actionTabId, 'idle', { expectedExecutionId: 'exec-1' });
|
||||
|
||||
const beforeTabs = useTerminalStore.getState().getDirectoryState('/repo')!.tabs;
|
||||
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [{
|
||||
sessionId: 'srv-build',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: beforeTabs[0]!.createdAt,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
}]);
|
||||
|
||||
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
|
||||
expect(state.tabs).not.toBe(beforeTabs);
|
||||
expect(state.activeTabId).toBe(actionTabId);
|
||||
});
|
||||
|
||||
test('gives unknown adopted action sessions a generic fallback label and icon', () => {
|
||||
setup();
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [{
|
||||
sessionId: 'srv-build',
|
||||
cwd: '/repo',
|
||||
status: 'running',
|
||||
createdAt: 1,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'unknown-action', executionId: 'exec-1' },
|
||||
}]);
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.label).toBe('unknown-action');
|
||||
expect(tab.iconKey).toBe('play');
|
||||
});
|
||||
|
||||
test('successful empty reconciliation exits known action sessions without touching unrelated directories', () => {
|
||||
const repoTab = setup();
|
||||
useTerminalStore.getState().setTabPurpose('/repo', repoTab, { type: 'project-action', actionId: 'build', executionId: 'exec-1' });
|
||||
useTerminalStore.getState().setTabSessionId('/repo', repoTab, 'srv-build');
|
||||
useTerminalStore.getState().ensureDirectory('/other');
|
||||
const otherBefore = useTerminalStore.getState().getDirectoryState('/other');
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', []);
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.lifecycle).toBe('exited');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
|
||||
expect(useTerminalStore.getState().getDirectoryState('/other')).toBe(otherBefore);
|
||||
});
|
||||
|
||||
test('normalizes executionId to null when adopting an exited project-action session', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: null });
|
||||
|
||||
useTerminalStore.getState().reconcileServerSessions('/repo', [{
|
||||
sessionId: 'srv-build',
|
||||
cwd: '/repo',
|
||||
status: 'exited',
|
||||
createdAt: 10,
|
||||
mode: 'command',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-server' },
|
||||
}]);
|
||||
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
|
||||
});
|
||||
|
||||
test('an old empty snapshot preserves a newer starting action execution', () => {
|
||||
const tabId = setup();
|
||||
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
|
||||
|
||||
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
|
||||
|
||||
expect(executionId).not.toBeNull();
|
||||
|
||||
reconcileServerSessionsWithStartedRevisions('/repo', [], startedActionMutationRevisions);
|
||||
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.lifecycle).toBe('starting');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
|
||||
expect(tab.terminalSessionId).toBeNull();
|
||||
});
|
||||
|
||||
test('an old empty snapshot still preserves the action after it becomes running before apply', () => {
|
||||
const tabId = setup();
|
||||
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
|
||||
|
||||
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
|
||||
if (!executionId) {
|
||||
throw new Error('expected execution id');
|
||||
}
|
||||
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-build', { expectedExecutionId: executionId });
|
||||
|
||||
reconcileServerSessionsWithStartedRevisions('/repo', [], startedActionMutationRevisions);
|
||||
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.lifecycle).toBe('running');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
|
||||
expect(tab.terminalSessionId).toBe('srv-build');
|
||||
});
|
||||
|
||||
test('an old listed action session does not overwrite a newer execution after allocate', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: 'exec-old' });
|
||||
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-old', { expectedExecutionId: 'exec-old' });
|
||||
|
||||
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
|
||||
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
|
||||
|
||||
reconcileServerSessionsWithStartedRevisions('/repo', [staleBuildSession], startedActionMutationRevisions);
|
||||
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.lifecycle).toBe('starting');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
|
||||
expect(tab.terminalSessionId).toBe('srv-old');
|
||||
});
|
||||
|
||||
test('an old listed action session does not overwrite a newer running session after setTabSessionId', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().setTabPurpose('/repo', tabId, { type: 'project-action', actionId: 'build', executionId: 'exec-old' });
|
||||
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-old', { expectedExecutionId: 'exec-old' });
|
||||
|
||||
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
|
||||
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
|
||||
if (!executionId) {
|
||||
throw new Error('expected execution id');
|
||||
}
|
||||
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-new', { expectedExecutionId: executionId });
|
||||
|
||||
reconcileServerSessionsWithStartedRevisions('/repo', [staleBuildSession], startedActionMutationRevisions);
|
||||
|
||||
const tabs = useTerminalStore.getState().getDirectoryState('/repo')!.tabs;
|
||||
expect(tabs).toHaveLength(1);
|
||||
const tab = tabs[0]!;
|
||||
expect(tab.lifecycle).toBe('running');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
|
||||
expect(tab.terminalSessionId).toBe('srv-new');
|
||||
});
|
||||
|
||||
test('a fresh empty snapshot still clears an omitted action execution', () => {
|
||||
const tabId = setup();
|
||||
const executionId = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build');
|
||||
if (!executionId) {
|
||||
throw new Error('expected execution id');
|
||||
}
|
||||
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-build', { expectedExecutionId: executionId });
|
||||
|
||||
const startedActionMutationRevisions = captureStartedActionMutationRevisions('/repo');
|
||||
reconcileServerSessionsWithStartedRevisions('/repo', [], startedActionMutationRevisions);
|
||||
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.lifecycle).toBe('exited');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: null });
|
||||
expect(tab.terminalSessionId).toBe('srv-build');
|
||||
});
|
||||
|
||||
test('execution-guarded transitions ignore stale completions after a rerun', () => {
|
||||
const tabId = setup();
|
||||
const firstExecution = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build')!;
|
||||
const secondExecution = useTerminalStore.getState().allocateActionExecution('/repo', tabId, 'build')!;
|
||||
expect(firstExecution).not.toBe(secondExecution);
|
||||
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-old', { expectedExecutionId: firstExecution });
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', tabId, 'running', { expectedExecutionId: secondExecution });
|
||||
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
|
||||
expect(tab.terminalSessionId).toBeNull();
|
||||
expect(tab.lifecycle).toBe('running');
|
||||
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: secondExecution });
|
||||
});
|
||||
|
||||
test('applies snapshots atomically and deduplicates output by sequence', () => {
|
||||
const tabId = setup();
|
||||
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
|
||||
@@ -199,3 +532,27 @@ describe('default terminal tab labels', () => {
|
||||
expect(labels()).toEqual(['build', 'Terminal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directoryMayHaveActiveProjectAction', () => {
|
||||
test('returns true for any non-exited project-action tab', () => {
|
||||
expect(directoryMayHaveActiveProjectAction({
|
||||
activeTabId: 'tab-1',
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: null },
|
||||
label: 'Build',
|
||||
iconKey: 'play',
|
||||
isConnecting: false,
|
||||
createdAt: 1,
|
||||
previewUrl: null,
|
||||
previewAutoOpened: false,
|
||||
previewUrlLocked: false,
|
||||
},
|
||||
],
|
||||
})).toBe(true);
|
||||
expect(directoryMayHaveActiveProjectAction(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import type { PersistStorage } from 'zustand/middleware';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getSafeSessionStorage } from '@/stores/utils/safeStorage';
|
||||
import type { TerminalServerSession } from '@/lib/api/types';
|
||||
|
||||
export interface TerminalChunk {
|
||||
id: number;
|
||||
@@ -28,12 +30,23 @@ export const EMPTY_TERMINAL_BUFFER: TerminalBuffer = Object.freeze({
|
||||
lastSequence: -1,
|
||||
});
|
||||
|
||||
export type TerminalTabLifecycle = 'idle' | 'running' | 'exited';
|
||||
export type TerminalTabLifecycle = 'idle' | 'starting' | 'running' | 'stopping' | 'exited';
|
||||
|
||||
export const ACTIVE_PROJECT_ACTION_LIFECYCLES: ReadonlySet<TerminalTabLifecycle> = new Set([
|
||||
'starting',
|
||||
'running',
|
||||
'stopping',
|
||||
]);
|
||||
|
||||
export type TerminalTabPurpose =
|
||||
| { type: 'terminal' }
|
||||
| { type: 'project-action'; actionId: string; executionId: string | null };
|
||||
|
||||
export type TerminalTab = {
|
||||
id: string;
|
||||
terminalSessionId: string | null;
|
||||
lifecycle: TerminalTabLifecycle;
|
||||
purpose: TerminalTabPurpose;
|
||||
label: string;
|
||||
iconKey: string | null;
|
||||
isConnecting: boolean;
|
||||
@@ -48,19 +61,20 @@ export type DirectoryTerminalState = {
|
||||
activeTabId: string | null;
|
||||
};
|
||||
|
||||
export type TerminalProjectActionRun = {
|
||||
key: string;
|
||||
directory: string;
|
||||
actionId: string;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
status: 'running' | 'waiting-for-preview' | 'stopping';
|
||||
export const directoryMayHaveActiveProjectAction = (state: DirectoryTerminalState | undefined): boolean =>
|
||||
Boolean(state?.tabs.some((tab) => isProjectActionPurpose(tab.purpose) && tab.lifecycle !== 'exited'));
|
||||
|
||||
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
|
||||
|
||||
type ReconcileServerSessionsOptions = {
|
||||
startedActionMutationRevisions?: TerminalActionMutationRevisions;
|
||||
};
|
||||
|
||||
interface TerminalStore {
|
||||
sessions: Map<string, DirectoryTerminalState>;
|
||||
buffers: Map<string, TerminalBuffer>;
|
||||
projectActionRuns: Record<string, TerminalProjectActionRun>;
|
||||
actionMutationRevisions: Map<string, number>;
|
||||
nextActionMutationRevision: number;
|
||||
nextChunkId: number;
|
||||
nextTabId: number;
|
||||
hasHydrated: boolean;
|
||||
@@ -69,27 +83,25 @@ interface TerminalStore {
|
||||
getDirectoryState: (directory: string) => DirectoryTerminalState | undefined;
|
||||
getActiveTab: (directory: string) => TerminalTab | undefined;
|
||||
getBuffer: (directory: string, tabId: string) => TerminalBuffer;
|
||||
matchesActionExecution: (directory: string, tabId: string, executionId: string | null | undefined) => boolean;
|
||||
captureStartedActionMutationRevisions: (directory: string) => TerminalActionMutationRevisions;
|
||||
|
||||
createTab: (directory: string) => string;
|
||||
adoptServerSessions: (
|
||||
directory: string,
|
||||
serverSessions: Array<{ sessionId: string; status: 'running' | 'exited'; createdAt: number | null }>,
|
||||
) => void;
|
||||
reconcileServerSessions: (directory: string, serverSessions: TerminalServerSession[], options?: ReconcileServerSessionsOptions) => void;
|
||||
setActiveTab: (directory: string, tabId: string) => void;
|
||||
setTabLabel: (directory: string, tabId: string, label: string) => void;
|
||||
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
|
||||
closeTab: (directory: string, tabId: string) => void;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => void;
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
setTabPurpose: (directory: string, tabId: string, purpose: TerminalTabPurpose) => void;
|
||||
allocateActionExecution: (directory: string, tabId: string, actionId: string) => string | null;
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options?: { expectedExecutionId?: string | null }) => void;
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options?: { expectedExecutionId?: string | null }) => void;
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean, options?: { expectedExecutionId?: string | null }) => void;
|
||||
replaceBuffer: (directory: string, tabId: string, content: string, sequence: number) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void;
|
||||
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean }) => void;
|
||||
setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean; expectedExecutionId?: string | null }) => void;
|
||||
markPreviewAutoOpened: (directory: string, tabId: string) => void;
|
||||
setProjectActionRun: (run: TerminalProjectActionRun) => void;
|
||||
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => void;
|
||||
removeProjectActionRun: (runKey: string) => void;
|
||||
|
||||
removeDirectory: (directory: string) => void;
|
||||
clearAll: () => void;
|
||||
@@ -124,6 +136,10 @@ const dropBufferKeys = (
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
let hydrationListenerAttached = false;
|
||||
let fallbackTabId = 0;
|
||||
const persistedProjectActionPurposeSchema = z.object({
|
||||
type: z.literal('project-action'),
|
||||
actionId: z.string(),
|
||||
});
|
||||
|
||||
const createTerminalTabId = (): string => {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') return `tab-${globalThis.crypto.randomUUID()}`;
|
||||
@@ -131,7 +147,13 @@ const createTerminalTabId = (): string => {
|
||||
return `tab-${Date.now().toString(36)}-${fallbackTabId.toString(36)}`;
|
||||
};
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'createdAt'>;
|
||||
type PersistedTerminalTabPurpose =
|
||||
| { type: 'terminal' }
|
||||
| { type: 'project-action'; actionId: string };
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'iconKey' | 'createdAt'> & {
|
||||
purpose?: PersistedTerminalTabPurpose;
|
||||
};
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
@@ -146,6 +168,38 @@ type PersistedTerminalStoreState = {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isProjectActionPurpose = (purpose: TerminalTabPurpose): purpose is Extract<TerminalTabPurpose, { type: 'project-action' }> =>
|
||||
purpose.type === 'project-action';
|
||||
|
||||
const shouldApplyExecutionGuard = (tab: TerminalTab, expectedExecutionId: string | null | undefined): boolean => {
|
||||
if (!isProjectActionPurpose(tab.purpose)) {
|
||||
return expectedExecutionId == null;
|
||||
}
|
||||
if (expectedExecutionId === undefined) {
|
||||
return true;
|
||||
}
|
||||
return tab.purpose.executionId === expectedExecutionId;
|
||||
};
|
||||
|
||||
const createExecutionId = (): string => createTerminalTabId().replace(/^tab-/, 'exec-');
|
||||
|
||||
const toActionLifecycle = (status: TerminalServerSession['status']): TerminalTabLifecycle =>
|
||||
status === 'running' ? 'running' : 'exited';
|
||||
|
||||
const toActionPurposeFromSession = (
|
||||
purpose: Extract<TerminalServerSession['purpose'], { type: 'project-action' }>,
|
||||
status: TerminalServerSession['status'],
|
||||
): Extract<TerminalTabPurpose, { type: 'project-action' }> => ({
|
||||
type: 'project-action',
|
||||
actionId: purpose.actionId,
|
||||
executionId: status === 'running' ? purpose.executionId : null,
|
||||
});
|
||||
|
||||
const toAdoptedActionLabel = (actionId: string): string => {
|
||||
const trimmed = actionId.trim();
|
||||
return trimmed || 'Action';
|
||||
};
|
||||
|
||||
const tabIdNumber = (tabId: string): number | null => {
|
||||
const match = /^tab-(\d+)$/.exec(tabId);
|
||||
if (!match) return null;
|
||||
@@ -161,6 +215,32 @@ function normalizeDirectory(dir: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const actionMutationRevisionKey = (directory: string, actionId: string): string => `${directory}\u0000${actionId}`;
|
||||
|
||||
const updateActionMutationRevision = (
|
||||
revisions: Map<string, number>,
|
||||
directory: string,
|
||||
actionId: string,
|
||||
revision: number,
|
||||
) => {
|
||||
revisions.set(actionMutationRevisionKey(directory, actionId), revision);
|
||||
};
|
||||
|
||||
const hasActionMutatedSinceRequestStarted = (
|
||||
state: Pick<TerminalStore, 'actionMutationRevisions'>,
|
||||
directory: string,
|
||||
actionId: string,
|
||||
startedActionMutationRevisions: TerminalActionMutationRevisions | undefined,
|
||||
): boolean => {
|
||||
if (!startedActionMutationRevisions) {
|
||||
return false;
|
||||
}
|
||||
const revisionKey = actionMutationRevisionKey(directory, actionId);
|
||||
const currentRevision = state.actionMutationRevisions.get(revisionKey) ?? 0;
|
||||
const startedRevision = startedActionMutationRevisions.get(revisionKey) ?? 0;
|
||||
return currentRevision > startedRevision;
|
||||
};
|
||||
|
||||
const DEFAULT_TAB_LABEL_PATTERN = /^Terminal(?: (\d+))?$/;
|
||||
|
||||
/**
|
||||
@@ -182,10 +262,14 @@ const nextDefaultTabLabel = (tabs: readonly TerminalTab[]): string => {
|
||||
return highest === 0 ? 'Terminal' : `Terminal ${highest + 1}`;
|
||||
};
|
||||
|
||||
const isLiveRunningTerminal = (tab: TerminalTab | undefined): boolean =>
|
||||
Boolean(tab && tab.terminalSessionId !== null && tab.lifecycle === 'running');
|
||||
|
||||
const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
id,
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
purpose: { type: 'terminal' },
|
||||
label,
|
||||
iconKey: null,
|
||||
isConnecting: false,
|
||||
@@ -231,6 +315,9 @@ const partializeTerminalStore = (state: TerminalStore): PersistedTerminalStoreSt
|
||||
label: tab.label,
|
||||
iconKey: tab.iconKey,
|
||||
createdAt: tab.createdAt,
|
||||
purpose: tab.purpose.type === 'project-action'
|
||||
? { type: 'project-action', actionId: tab.purpose.actionId }
|
||||
: { type: 'terminal' },
|
||||
})),
|
||||
},
|
||||
]),
|
||||
@@ -267,7 +354,8 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
(set, get) => ({
|
||||
sessions: new Map(),
|
||||
buffers: new Map(),
|
||||
projectActionRuns: {},
|
||||
actionMutationRevisions: new Map(),
|
||||
nextActionMutationRevision: 1,
|
||||
nextChunkId: 1,
|
||||
nextTabId: 1,
|
||||
hasHydrated: typeof window === 'undefined',
|
||||
@@ -307,6 +395,22 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
getBuffer: (directory: string, tabId: string) =>
|
||||
get().buffers.get(bufferKey(normalizeDirectory(directory), tabId)) ?? EMPTY_TERMINAL_BUFFER,
|
||||
|
||||
matchesActionExecution: (directory, tabId, executionId) => {
|
||||
const tab = get().sessions.get(normalizeDirectory(directory))?.tabs.find((entry) => entry.id === tabId);
|
||||
return Boolean(tab && isProjectActionPurpose(tab.purpose) && tab.purpose.executionId === executionId);
|
||||
},
|
||||
|
||||
captureStartedActionMutationRevisions: (directory) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const prefix = actionMutationRevisionKey(key, '');
|
||||
const snapshot = new Map<string, number>();
|
||||
for (const [actionKey, revision] of get().actionMutationRevisions.entries()) {
|
||||
if (!actionKey.startsWith(prefix)) continue;
|
||||
snapshot.set(actionKey, revision);
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
createTab: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
if (!key) {
|
||||
@@ -338,56 +442,171 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
return tabId;
|
||||
},
|
||||
|
||||
/**
|
||||
* The server owns which terminal sessions exist; the local tab list is
|
||||
* only this client's projection. Adoption is strictly additive: server
|
||||
* sessions no local tab references become tabs (id = session id, the
|
||||
* create/attach contract), and nothing is ever removed here, so a
|
||||
* failed or partial listing cannot destroy local tabs.
|
||||
*/
|
||||
adoptServerSessions: (directory, serverSessions) => {
|
||||
reconcileServerSessions: (directory, serverSessions, options) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
if (!key || serverSessions.length === 0) return;
|
||||
if (!key) return;
|
||||
|
||||
set((state) => {
|
||||
const existing = state.sessions.get(key);
|
||||
const knownIds = new Set<string>();
|
||||
for (const tab of existing?.tabs ?? []) {
|
||||
knownIds.add(tab.id);
|
||||
if (tab.terminalSessionId) knownIds.add(tab.terminalSessionId);
|
||||
if (!existing && serverSessions.length === 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newcomers = serverSessions.filter((session) => !knownIds.has(session.sessionId));
|
||||
if (newcomers.length === 0) return state;
|
||||
|
||||
const tabs = [...(existing?.tabs ?? [])];
|
||||
// A single untouched placeholder tab (fresh directory state) is
|
||||
// replaced by the first adopted session instead of sitting next to it.
|
||||
let tabsChanged = false;
|
||||
let activationCandidate: { tabId: string; createdAt: number; index: number } | null = null;
|
||||
const listedActionIds = new Set<string>();
|
||||
const matchedTabIds = new Set<string>();
|
||||
const placeholder = tabs.length === 1
|
||||
&& tabs[0].terminalSessionId === null
|
||||
&& tabs[0].lifecycle === 'idle'
|
||||
&& tabs[0].purpose.type === 'terminal'
|
||||
&& !state.buffers.has(bufferKey(key, tabs[0].id))
|
||||
? tabs[0]
|
||||
: null;
|
||||
if (placeholder) tabs.length = 0;
|
||||
if (placeholder && serverSessions.length > 0) tabs.length = 0;
|
||||
|
||||
for (const session of newcomers) {
|
||||
for (const session of serverSessions) {
|
||||
let matchIndex = tabs.findIndex((tab) => tab.terminalSessionId === session.sessionId || tab.id === session.sessionId);
|
||||
const sessionPurpose = session.purpose;
|
||||
const staleActionAuthority = sessionPurpose?.type === 'project-action'
|
||||
&& hasActionMutatedSinceRequestStarted(state, key, sessionPurpose.actionId, options?.startedActionMutationRevisions);
|
||||
if (sessionPurpose?.type === 'project-action') {
|
||||
listedActionIds.add(sessionPurpose.actionId);
|
||||
const actionMatchIndex = tabs.findIndex((tab) => (
|
||||
isProjectActionPurpose(tab.purpose) && tab.purpose.actionId === sessionPurpose.actionId
|
||||
));
|
||||
if (staleActionAuthority) {
|
||||
if (actionMatchIndex >= 0) {
|
||||
matchedTabIds.add(tabs[actionMatchIndex]!.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (matchIndex < 0) {
|
||||
matchIndex = actionMatchIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const nextPurpose: TerminalTabPurpose = sessionPurpose?.type === 'project-action'
|
||||
? toActionPurposeFromSession(sessionPurpose, session.status)
|
||||
: { type: 'terminal' };
|
||||
|
||||
if (matchIndex >= 0) {
|
||||
const current = tabs[matchIndex]!;
|
||||
const nextLifecycle = current.purpose.type === 'project-action' || sessionPurpose?.type === 'project-action'
|
||||
? toActionLifecycle(session.status)
|
||||
: session.status;
|
||||
const nextCreatedAt = session.createdAt ?? current.createdAt;
|
||||
const activatesRunningAction = sessionPurpose?.type === 'project-action'
|
||||
&& session.status === 'running'
|
||||
&& (current.terminalSessionId !== session.sessionId || current.lifecycle !== 'running');
|
||||
const purposeChanged = current.purpose.type !== nextPurpose.type
|
||||
|| (current.purpose.type === 'project-action' && nextPurpose.type === 'project-action'
|
||||
&& (current.purpose.actionId !== nextPurpose.actionId || current.purpose.executionId !== nextPurpose.executionId));
|
||||
if (
|
||||
current.terminalSessionId !== session.sessionId
|
||||
|| current.lifecycle !== nextLifecycle
|
||||
|| current.isConnecting !== false
|
||||
|| current.createdAt !== nextCreatedAt
|
||||
|| purposeChanged
|
||||
) {
|
||||
tabs[matchIndex] = {
|
||||
...current,
|
||||
terminalSessionId: session.sessionId,
|
||||
lifecycle: nextLifecycle,
|
||||
purpose: nextPurpose,
|
||||
isConnecting: false,
|
||||
createdAt: nextCreatedAt,
|
||||
};
|
||||
tabsChanged = true;
|
||||
}
|
||||
if (activatesRunningAction) {
|
||||
const candidate = { tabId: current.id, createdAt: nextCreatedAt, index: matchIndex };
|
||||
if (
|
||||
!activationCandidate
|
||||
|| candidate.createdAt > activationCandidate.createdAt
|
||||
|| (candidate.createdAt === activationCandidate.createdAt && candidate.index > activationCandidate.index)
|
||||
) {
|
||||
activationCandidate = candidate;
|
||||
}
|
||||
}
|
||||
matchedTabIds.add(current.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = sessionPurpose?.type === 'project-action'
|
||||
? toAdoptedActionLabel(sessionPurpose.actionId)
|
||||
: (placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs));
|
||||
const iconKey = sessionPurpose?.type === 'project-action' ? 'play' : null;
|
||||
const tab: TerminalTab = {
|
||||
...createEmptyTab(session.sessionId, placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)),
|
||||
...createEmptyTab(session.sessionId, label),
|
||||
terminalSessionId: session.sessionId,
|
||||
lifecycle: session.status,
|
||||
lifecycle: sessionPurpose?.type === 'project-action' ? toActionLifecycle(session.status) : session.status,
|
||||
purpose: nextPurpose,
|
||||
iconKey,
|
||||
createdAt: session.createdAt ?? Date.now(),
|
||||
};
|
||||
tabs.push(tab);
|
||||
tabsChanged = true;
|
||||
if (sessionPurpose?.type === 'project-action' && session.status === 'running') {
|
||||
const candidate = { tabId: tab.id, createdAt: tab.createdAt, index: tabs.length - 1 };
|
||||
if (
|
||||
!activationCandidate
|
||||
|| candidate.createdAt > activationCandidate.createdAt
|
||||
|| (candidate.createdAt === activationCandidate.createdAt && candidate.index > activationCandidate.index)
|
||||
) {
|
||||
activationCandidate = candidate;
|
||||
}
|
||||
}
|
||||
matchedTabIds.add(tab.id);
|
||||
}
|
||||
|
||||
let changed = tabsChanged || Boolean(placeholder && serverSessions.length > 0);
|
||||
const reconciledTabs: TerminalTab[] = tabs.map((tab) => {
|
||||
if (!isProjectActionPurpose(tab.purpose)) return tab;
|
||||
if (listedActionIds.has(tab.purpose.actionId) || matchedTabIds.has(tab.id)) return tab;
|
||||
if (
|
||||
tab.lifecycle === 'starting'
|
||||
|| hasActionMutatedSinceRequestStarted(state, key, tab.purpose.actionId, options?.startedActionMutationRevisions)
|
||||
) {
|
||||
return tab;
|
||||
}
|
||||
if (tab.lifecycle === 'exited' && !tab.isConnecting) return tab;
|
||||
changed = true;
|
||||
return {
|
||||
...tab,
|
||||
lifecycle: 'exited',
|
||||
isConnecting: false,
|
||||
purpose: { type: 'project-action', actionId: tab.purpose.actionId, executionId: null },
|
||||
};
|
||||
});
|
||||
|
||||
const previousActive = existing?.activeTabId ?? null;
|
||||
const activeTabId = previousActive && tabs.some((tab) => tab.id === previousActive)
|
||||
const resolvedActiveTabId = previousActive && reconciledTabs.some((tab) => tab.id === previousActive)
|
||||
? previousActive
|
||||
: tabs[0]?.id ?? null;
|
||||
: reconciledTabs[0]?.id ?? null;
|
||||
const resolvedActiveTab = resolvedActiveTabId
|
||||
? reconciledTabs.find((tab) => tab.id === resolvedActiveTabId)
|
||||
: reconciledTabs[0];
|
||||
const activeTabId = activationCandidate && !isLiveRunningTerminal(resolvedActiveTab)
|
||||
? activationCandidate.tabId
|
||||
: resolvedActiveTabId;
|
||||
|
||||
// Activation transitions always rewrite the adopted tab, so today
|
||||
// `changed` is true whenever `activeTabId` moved; the explicit
|
||||
// activeTabId comparison keeps this guard honest if a future
|
||||
// activation path stops touching the tabs array.
|
||||
if (
|
||||
!changed
|
||||
&& existing
|
||||
&& activeTabId === existing.activeTabId
|
||||
&& reconciledTabs.length === existing.tabs.length
|
||||
&& reconciledTabs.every((tab, index) => tab === existing.tabs[index])
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const newSessions = new Map(state.sessions);
|
||||
newSessions.set(key, { tabs, activeTabId });
|
||||
newSessions.set(key, { tabs: reconciledTabs, activeTabId });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
@@ -497,10 +716,6 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
|
||||
const nextRuns = Object.fromEntries(
|
||||
Object.entries(state.projectActionRuns).filter(([, run]) => !(run.directory === key && run.tabId === tabId))
|
||||
);
|
||||
const runsChanged = Object.keys(nextRuns).length !== Object.keys(state.projectActionRuns).length;
|
||||
const closedBufferKey = bufferKey(key, tabId);
|
||||
const nextBuffers = state.buffers.has(closedBufferKey)
|
||||
? dropBufferKeys(state.buffers, (bufferEntryKey) => bufferEntryKey === closedBufferKey)
|
||||
@@ -510,12 +725,14 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
const newTabId = createTerminalTabId();
|
||||
const newTab = createEmptyTab(newTabId, 'Terminal');
|
||||
newSessions.set(key, createEmptyDirectoryState(newTab));
|
||||
return {
|
||||
const nextState: Pick<TerminalStore, 'sessions' | 'nextTabId'> & { buffers?: Map<string, TerminalBuffer> } = {
|
||||
sessions: newSessions,
|
||||
nextTabId: state.nextTabId + 1,
|
||||
...(nextBuffers ? { buffers: nextBuffers } : {}),
|
||||
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
|
||||
};
|
||||
if (nextBuffers) {
|
||||
nextState.buffers = nextBuffers;
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
let nextActive = existing.activeTabId;
|
||||
@@ -530,15 +747,67 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
activeTabId: nextActive,
|
||||
});
|
||||
|
||||
return {
|
||||
const nextState: Pick<TerminalStore, 'sessions'> & { buffers?: Map<string, TerminalBuffer> } = {
|
||||
sessions: newSessions,
|
||||
...(nextBuffers ? { buffers: nextBuffers } : {}),
|
||||
...(runsChanged ? { projectActionRuns: nextRuns } : {}),
|
||||
};
|
||||
if (nextBuffers) {
|
||||
nextState.buffers = nextBuffers;
|
||||
}
|
||||
return nextState;
|
||||
});
|
||||
},
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => {
|
||||
setTabPurpose: (directory, tabId, purpose) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const existing = state.sessions.get(key);
|
||||
if (!existing) return state;
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) return state;
|
||||
const current = existing.tabs[idx]!;
|
||||
if (JSON.stringify(current.purpose) === JSON.stringify(purpose)) {
|
||||
return state;
|
||||
}
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...current, purpose };
|
||||
const sessions = new Map(state.sessions);
|
||||
sessions.set(key, { ...existing, tabs: nextTabs });
|
||||
if (purpose.type !== 'project-action') {
|
||||
return { sessions };
|
||||
}
|
||||
const actionMutationRevisions = new Map(state.actionMutationRevisions);
|
||||
updateActionMutationRevision(actionMutationRevisions, key, purpose.actionId, state.nextActionMutationRevision);
|
||||
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
|
||||
});
|
||||
},
|
||||
|
||||
allocateActionExecution: (directory, tabId, actionId) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
const existing = get().sessions.get(key);
|
||||
if (!existing || findTabIndex(existing, tabId) < 0) return null;
|
||||
const executionId = createExecutionId();
|
||||
set((state) => {
|
||||
const current = state.sessions.get(key);
|
||||
if (!current) return state;
|
||||
const idx = findTabIndex(current, tabId);
|
||||
if (idx < 0) return state;
|
||||
const nextTabs = [...current.tabs];
|
||||
nextTabs[idx] = {
|
||||
...nextTabs[idx]!,
|
||||
purpose: { type: 'project-action', actionId, executionId },
|
||||
lifecycle: 'starting',
|
||||
isConnecting: false,
|
||||
};
|
||||
const sessions = new Map(state.sessions);
|
||||
sessions.set(key, { ...current, tabs: nextTabs });
|
||||
const actionMutationRevisions = new Map(state.actionMutationRevisions);
|
||||
updateActionMutationRevision(actionMutationRevisions, key, actionId, state.nextActionMutationRevision);
|
||||
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
|
||||
});
|
||||
return executionId;
|
||||
},
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
@@ -553,6 +822,9 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
if (!shouldApplyExecutionGuard(tab, options?.expectedExecutionId)) {
|
||||
return state;
|
||||
}
|
||||
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
|
||||
|
||||
const nextLifecycle = sessionId
|
||||
@@ -574,11 +846,27 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = nextTab;
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions, ...(nextBuffers ? { buffers: nextBuffers } : {}) };
|
||||
const nextState: Pick<TerminalStore, 'sessions' | 'nextActionMutationRevision'> & {
|
||||
buffers?: Map<string, TerminalBuffer>;
|
||||
actionMutationRevisions?: Map<string, number>;
|
||||
} = {
|
||||
sessions: newSessions,
|
||||
nextActionMutationRevision: state.nextActionMutationRevision,
|
||||
};
|
||||
if (isProjectActionPurpose(tab.purpose)) {
|
||||
const actionMutationRevisions = new Map(state.actionMutationRevisions);
|
||||
updateActionMutationRevision(actionMutationRevisions, key, tab.purpose.actionId, state.nextActionMutationRevision);
|
||||
nextState.actionMutationRevisions = actionMutationRevisions;
|
||||
nextState.nextActionMutationRevision = state.nextActionMutationRevision + 1;
|
||||
}
|
||||
if (nextBuffers) {
|
||||
nextState.buffers = nextBuffers;
|
||||
}
|
||||
return nextState;
|
||||
});
|
||||
},
|
||||
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => {
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
@@ -592,6 +880,10 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!shouldApplyExecutionGuard(existing.tabs[idx]!, options?.expectedExecutionId)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
@@ -599,7 +891,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean, options) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
@@ -613,6 +905,10 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
return state;
|
||||
}
|
||||
|
||||
if (!shouldApplyExecutionGuard(existing.tabs[idx]!, options?.expectedExecutionId)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...nextTabs[idx], isConnecting };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
@@ -711,6 +1007,9 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
|
||||
const tab = existing.tabs[idx];
|
||||
if (!shouldApplyExecutionGuard(tab, options.expectedExecutionId)) {
|
||||
return state;
|
||||
}
|
||||
const nextPreviewAutoOpened = options.autoOpened ?? tab.previewAutoOpened;
|
||||
const nextPreviewUrlLocked = options.locked ?? tab.previewUrlLocked;
|
||||
if (tab.previewUrl === url && tab.previewAutoOpened === nextPreviewAutoOpened && tab.previewUrlLocked === nextPreviewUrlLocked) {
|
||||
@@ -755,47 +1054,6 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setProjectActionRun: (run: TerminalProjectActionRun) => {
|
||||
set((state) => {
|
||||
const existing = state.projectActionRuns[run.key];
|
||||
if (existing
|
||||
&& existing.directory === run.directory
|
||||
&& existing.actionId === run.actionId
|
||||
&& existing.tabId === run.tabId
|
||||
&& existing.sessionId === run.sessionId
|
||||
&& existing.status === run.status) {
|
||||
return state;
|
||||
}
|
||||
return { projectActionRuns: { ...state.projectActionRuns, [run.key]: run } };
|
||||
});
|
||||
},
|
||||
|
||||
updateProjectActionRunStatus: (runKey: string, status: TerminalProjectActionRun['status']) => {
|
||||
set((state) => {
|
||||
const existing = state.projectActionRuns[runKey];
|
||||
if (!existing || existing.status === status) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
projectActionRuns: {
|
||||
...state.projectActionRuns,
|
||||
[runKey]: { ...existing, status },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeProjectActionRun: (runKey: string) => {
|
||||
set((state) => {
|
||||
if (!state.projectActionRuns[runKey]) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.projectActionRuns };
|
||||
delete next[runKey];
|
||||
return { projectActionRuns: next };
|
||||
});
|
||||
},
|
||||
|
||||
removeDirectory: (directory: string) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
@@ -803,19 +1061,31 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
newSessions.delete(key);
|
||||
const prefix = bufferKey(key, '');
|
||||
const nextBuffers = dropBufferKeys(state.buffers, (entryKey) => entryKey.startsWith(prefix));
|
||||
const nextRuns = Object.fromEntries(
|
||||
Object.entries(state.projectActionRuns).filter(([, run]) => run.directory !== key)
|
||||
);
|
||||
return {
|
||||
const revisionPrefix = actionMutationRevisionKey(key, '');
|
||||
let actionMutationRevisions: Map<string, number> | undefined;
|
||||
for (const actionKey of state.actionMutationRevisions.keys()) {
|
||||
if (!actionKey.startsWith(revisionPrefix)) continue;
|
||||
actionMutationRevisions ??= new Map(state.actionMutationRevisions);
|
||||
actionMutationRevisions.delete(actionKey);
|
||||
}
|
||||
const nextState: Pick<TerminalStore, 'sessions'> & {
|
||||
buffers?: Map<string, TerminalBuffer>;
|
||||
actionMutationRevisions?: Map<string, number>;
|
||||
} = {
|
||||
sessions: newSessions,
|
||||
...(nextBuffers ? { buffers: nextBuffers } : {}),
|
||||
projectActionRuns: nextRuns,
|
||||
};
|
||||
if (nextBuffers) {
|
||||
nextState.buffers = nextBuffers;
|
||||
}
|
||||
if (actionMutationRevisions) {
|
||||
nextState.actionMutationRevisions = actionMutationRevisions;
|
||||
}
|
||||
return nextState;
|
||||
});
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ sessions: new Map(), buffers: new Map(), projectActionRuns: {}, nextChunkId: 1, nextTabId: 1 });
|
||||
set({ sessions: new Map(), buffers: new Map(), actionMutationRevisions: new Map(), nextActionMutationRevision: 1, nextChunkId: 1, nextTabId: 1 });
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -864,6 +1134,10 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
}
|
||||
const id = num === null ? persistedId : createTerminalTabId();
|
||||
migratedTabIds.set(persistedId, id);
|
||||
const persistedPurpose = persistedProjectActionPurposeSchema.safeParse(rawTab.purpose).data;
|
||||
const purpose: TerminalTabPurpose = persistedPurpose
|
||||
? { type: 'project-action', actionId: persistedPurpose.actionId, executionId: null }
|
||||
: { type: 'terminal' };
|
||||
|
||||
tabs.push({
|
||||
id,
|
||||
@@ -871,6 +1145,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
iconKey: typeof rawTab.iconKey === 'string' ? rawTab.iconKey : null,
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
purpose,
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
isConnecting: false,
|
||||
previewUrl: null,
|
||||
@@ -905,6 +1180,8 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
...currentState,
|
||||
sessions,
|
||||
buffers: new Map(),
|
||||
actionMutationRevisions: new Map(),
|
||||
nextActionMutationRevision: 1,
|
||||
nextChunkId: 1,
|
||||
nextTabId,
|
||||
hasHydrated: true,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { CONTEXT_SURFACES, sortContextSurfaces } from '../lib/surfaces/registry';
|
||||
import { useTerminalStore } from './useTerminalStore';
|
||||
import { useUIStore } from './useUIStore';
|
||||
|
||||
const getContextPanelTabs = (directory: string) => useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
|
||||
const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal');
|
||||
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
|
||||
useTerminalStore.getState().clearAll();
|
||||
});
|
||||
|
||||
describe('useUIStore context panel tabs', () => {
|
||||
@@ -205,6 +211,168 @@ describe('useUIStore context panel tabs', () => {
|
||||
const tabs = useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(false);
|
||||
});
|
||||
|
||||
test('stores a terminal target under the host directory without creating a target root', () => {
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree', {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo',
|
||||
});
|
||||
|
||||
const worktreeState = useUIStore.getState().contextPanelByDirectory['/repo-worktree'];
|
||||
const terminalTab = getTerminalTab('/repo-worktree');
|
||||
|
||||
expect(worktreeState?.activeTabId).toBe('terminal');
|
||||
expect(worktreeState?.tabs).toHaveLength(1);
|
||||
expect(terminalTab?.targetDirectory).toBe('/repo');
|
||||
expect(useUIStore.getState().contextPanelByDirectory['/repo']).toBe(undefined);
|
||||
});
|
||||
|
||||
test('normalizes terminal targets and canonicalizes same-host targets to null', () => {
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree//', {
|
||||
mode: 'terminal',
|
||||
targetDirectory: ' \\repo\\nested\\ ',
|
||||
});
|
||||
|
||||
let terminalTab = getTerminalTab('/repo-worktree');
|
||||
expect(terminalTab?.targetDirectory).toBe('/repo/nested');
|
||||
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree//', {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo-worktree',
|
||||
});
|
||||
|
||||
terminalTab = getTerminalTab('/repo-worktree');
|
||||
expect(terminalTab?.targetDirectory).toBe(null);
|
||||
});
|
||||
|
||||
test('reopening a terminal tab with null clears a previous target directory', () => {
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree', {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo',
|
||||
});
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree', {
|
||||
mode: 'terminal',
|
||||
targetDirectory: null,
|
||||
});
|
||||
|
||||
const terminalTab = getTerminalTab('/repo-worktree');
|
||||
expect(terminalTab?.targetDirectory).toBe(null);
|
||||
});
|
||||
|
||||
test('legacy terminal tabs without a target directory sanitize to null on touch', () => {
|
||||
// SAFETY: the object mirrors the persisted context-panel shape exactly;
|
||||
// setState bypasses the persist middleware's typing, not its migration.
|
||||
useUIStore.setState({
|
||||
contextPanelByDirectory: {
|
||||
'/repo-worktree': {
|
||||
isOpen: true,
|
||||
expanded: false,
|
||||
widthByMode: {},
|
||||
touchedAt: 1,
|
||||
activeTabId: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
id: 'terminal',
|
||||
mode: 'terminal',
|
||||
targetPath: null,
|
||||
dedupeKey: 'terminal',
|
||||
label: null,
|
||||
sessionTitleFallback: null,
|
||||
readOnly: false,
|
||||
stagedDiff: false,
|
||||
diffScope: null,
|
||||
touchedAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree', { mode: 'diff' });
|
||||
|
||||
const terminalTab = getTerminalTab('/repo-worktree');
|
||||
expect(terminalTab?.targetDirectory).toBe(null);
|
||||
});
|
||||
|
||||
test('persisted terminal tabs keep a normalized target through a rehydration-like touch', () => {
|
||||
// SAFETY: the object mirrors the persisted context-panel shape exactly;
|
||||
// setState bypasses the persist middleware's typing, not its migration.
|
||||
useUIStore.setState({
|
||||
contextPanelByDirectory: {
|
||||
'/repo-worktree': {
|
||||
isOpen: true,
|
||||
expanded: false,
|
||||
widthByMode: {},
|
||||
touchedAt: 1,
|
||||
activeTabId: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
id: 'terminal',
|
||||
mode: 'terminal',
|
||||
targetPath: null,
|
||||
targetDirectory: ' \\repo\\nested\\ ',
|
||||
dedupeKey: 'terminal',
|
||||
label: null,
|
||||
sessionTitleFallback: null,
|
||||
readOnly: false,
|
||||
stagedDiff: false,
|
||||
diffScope: null,
|
||||
touchedAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree', { mode: 'diff' });
|
||||
|
||||
const terminalTab = getTerminalTab('/repo-worktree');
|
||||
expect(terminalTab?.targetDirectory).toBe('/repo/nested');
|
||||
});
|
||||
|
||||
test('ignores targetDirectory on non-terminal descriptors and sanitized tabs', () => {
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree', {
|
||||
mode: 'diff',
|
||||
targetDirectory: '/repo',
|
||||
});
|
||||
|
||||
const diffTab = getContextPanelTabs('/repo-worktree').find((tab) => tab.mode === 'diff');
|
||||
expect(diffTab?.targetDirectory).toBe(null);
|
||||
|
||||
// SAFETY: the object mirrors the persisted context-panel shape exactly;
|
||||
// setState bypasses the persist middleware's typing, not its migration.
|
||||
useUIStore.setState({
|
||||
contextPanelByDirectory: {
|
||||
'/repo-worktree': {
|
||||
isOpen: true,
|
||||
expanded: false,
|
||||
widthByMode: {},
|
||||
touchedAt: 1,
|
||||
activeTabId: 'diff',
|
||||
tabs: [
|
||||
{
|
||||
id: 'diff',
|
||||
mode: 'diff',
|
||||
targetPath: '/repo/file.ts',
|
||||
targetDirectory: '/stale',
|
||||
dedupeKey: 'diff',
|
||||
label: null,
|
||||
sessionTitleFallback: null,
|
||||
readOnly: false,
|
||||
stagedDiff: false,
|
||||
diffScope: 'working',
|
||||
touchedAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
useUIStore.getState().openContextPanelTab('/repo-worktree', { mode: 'terminal' });
|
||||
|
||||
const sanitizedDiffTab = getContextPanelTabs('/repo-worktree').find((tab) => tab.mode === 'diff');
|
||||
expect(sanitizedDiffTab?.targetDirectory).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUIStore openContextSurface', () => {
|
||||
@@ -272,6 +440,105 @@ describe('useUIStore openContextSurface', () => {
|
||||
expect(activeTab?.mode).toBe('file');
|
||||
expect(activeTab?.targetPath).toBe('/repo/b.ts');
|
||||
});
|
||||
|
||||
test('opening the terminal surface clears a stale target on the singleton tab', () => {
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo-target',
|
||||
});
|
||||
|
||||
useUIStore.getState().openContextSurface(directory, 'terminal');
|
||||
|
||||
const terminalTab = getTerminalTab(directory);
|
||||
expect(terminalTab?.targetDirectory).toBe(null);
|
||||
});
|
||||
|
||||
test('opening the terminal surface retains the target when the target directory still has a running project action', () => {
|
||||
// Revisit design: manual terminal open no longer clears a still-live
|
||||
// project-action target just to force the host shell back into view.
|
||||
useTerminalStore.getState().ensureDirectory('/repo-target');
|
||||
const targetTabId = useTerminalStore.getState().getDirectoryState('/repo-target')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabPurpose('/repo-target', targetTabId, {
|
||||
type: 'project-action',
|
||||
actionId: 'build',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
useTerminalStore.getState().setTabLifecycle('/repo-target', targetTabId, 'running', { expectedExecutionId: 'exec-1' });
|
||||
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo-target',
|
||||
});
|
||||
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
||||
|
||||
useUIStore.getState().openContextSurface(directory, 'terminal');
|
||||
|
||||
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
const terminalTab = getTerminalTab(directory);
|
||||
expect(state?.activeTabId).toBe('terminal');
|
||||
expect(state?.isOpen).toBe(true);
|
||||
expect(terminalTab?.targetDirectory).toBe('/repo-target');
|
||||
});
|
||||
|
||||
test('opening the terminal surface retains the target for a hydrated idle project-action placeholder', () => {
|
||||
useTerminalStore.getState().ensureDirectory('/repo-target');
|
||||
const targetTabId = useTerminalStore.getState().getDirectoryState('/repo-target')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabPurpose('/repo-target', targetTabId, {
|
||||
type: 'project-action',
|
||||
actionId: 'build',
|
||||
executionId: null,
|
||||
});
|
||||
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo-target',
|
||||
});
|
||||
|
||||
useUIStore.getState().openContextSurface(directory, 'terminal');
|
||||
|
||||
const terminalTab = getTerminalTab(directory);
|
||||
expect(terminalTab?.targetDirectory).toBe('/repo-target');
|
||||
});
|
||||
|
||||
test('opening the terminal surface clears the target when every action tab in the target directory is exited', () => {
|
||||
useTerminalStore.getState().ensureDirectory('/repo-target');
|
||||
const firstTargetTabId = useTerminalStore.getState().getDirectoryState('/repo-target')!.tabs[0]!.id;
|
||||
useTerminalStore.getState().setTabPurpose('/repo-target', firstTargetTabId, {
|
||||
type: 'project-action',
|
||||
actionId: 'build',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
useTerminalStore.getState().setTabLifecycle('/repo-target', firstTargetTabId, 'exited', { expectedExecutionId: 'exec-1' });
|
||||
const secondTargetTabId = useTerminalStore.getState().createTab('/repo-target');
|
||||
useTerminalStore.getState().setTabPurpose('/repo-target', secondTargetTabId, {
|
||||
type: 'project-action',
|
||||
actionId: 'test',
|
||||
executionId: 'exec-2',
|
||||
});
|
||||
useTerminalStore.getState().setTabLifecycle('/repo-target', secondTargetTabId, 'exited', { expectedExecutionId: 'exec-2' });
|
||||
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo-target',
|
||||
});
|
||||
|
||||
useUIStore.getState().openContextSurface(directory, 'terminal');
|
||||
|
||||
const terminalTab = getTerminalTab(directory);
|
||||
expect(terminalTab?.targetDirectory).toBe(null);
|
||||
});
|
||||
|
||||
test('opening the terminal surface clears the target when the target directory has no terminal state', () => {
|
||||
useUIStore.getState().openContextPanelTab(directory, {
|
||||
mode: 'terminal',
|
||||
targetDirectory: '/repo-target',
|
||||
});
|
||||
|
||||
useUIStore.getState().openContextSurface(directory, 'terminal');
|
||||
|
||||
const terminalTab = getTerminalTab(directory);
|
||||
expect(terminalTab?.targetDirectory).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUIStore closeContextPanelTab surface stability', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
|
||||
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, TerminalShell } from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/projectContextApi';
|
||||
import { directoryMayHaveActiveProjectAction, useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
|
||||
import { isWindowsArm64 } from '@/lib/platform';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -108,6 +109,7 @@ type ContextPanelTab = {
|
||||
id: string;
|
||||
mode: ContextPanelMode;
|
||||
targetPath: string | null;
|
||||
targetDirectory: string | null;
|
||||
/** Saved project plan this tab shows, for `plan` tabs opened from the notes
|
||||
panel. Project plans are addressed by id because their markdown is
|
||||
server-owned and has no client-visible path. */
|
||||
@@ -128,6 +130,7 @@ type ContextPanelTab = {
|
||||
type ContextPanelTabDescriptor = {
|
||||
mode: ContextPanelMode;
|
||||
targetPath?: string | null;
|
||||
targetDirectory?: string | null;
|
||||
projectPlanId?: string | null;
|
||||
projectPlanRef?: ProjectRef | null;
|
||||
dedupeKey?: string | null;
|
||||
@@ -252,6 +255,15 @@ const normalizeContextTargetPath = (value: string | null | undefined): string |
|
||||
return trimmed.replace(/\\/g, '/');
|
||||
};
|
||||
|
||||
const normalizeContextTargetDirectory = (value: string | null | undefined): string | null => {
|
||||
const normalizedPath = normalizeContextTargetPath(value);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeContextPanelDirectoryKey(normalizedPath) || null;
|
||||
};
|
||||
|
||||
const normalizeContextTabLabel = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
@@ -320,6 +332,9 @@ const buildContextPanelTabID = (mode: ContextPanelMode, dedupeKey: string): stri
|
||||
|
||||
const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPanelTab => {
|
||||
const normalizedTargetPath = normalizeContextTargetPath(descriptor.targetPath);
|
||||
const normalizedTargetDirectory = descriptor.mode === 'terminal'
|
||||
? normalizeContextTargetDirectory(descriptor.targetDirectory)
|
||||
: null;
|
||||
const dedupeKey = normalizeContextPanelTabDedupeKey(
|
||||
descriptor.mode,
|
||||
normalizedTargetPath,
|
||||
@@ -329,6 +344,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
|
||||
id: buildContextPanelTabID(descriptor.mode, dedupeKey),
|
||||
mode: descriptor.mode,
|
||||
targetPath: normalizedTargetPath,
|
||||
targetDirectory: normalizedTargetDirectory,
|
||||
projectPlanId: typeof descriptor.projectPlanId === 'string' && descriptor.projectPlanId.trim()
|
||||
? descriptor.projectPlanId.trim()
|
||||
: null,
|
||||
@@ -392,6 +408,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
const candidate = entry as {
|
||||
mode?: unknown;
|
||||
targetPath?: unknown;
|
||||
targetDirectory?: string | null;
|
||||
projectPlanId?: unknown;
|
||||
projectPlanRef?: unknown;
|
||||
dedupeKey?: unknown;
|
||||
@@ -417,6 +434,9 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
}
|
||||
|
||||
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
|
||||
const targetDirectory = candidate.mode === 'terminal'
|
||||
? normalizeContextTargetDirectory(candidate.targetDirectory)
|
||||
: null;
|
||||
const projectPlanId = typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
|
||||
? candidate.projectPlanId.trim()
|
||||
: null;
|
||||
@@ -445,6 +465,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
id,
|
||||
mode: candidate.mode,
|
||||
targetPath,
|
||||
targetDirectory,
|
||||
projectPlanId,
|
||||
projectPlanRef,
|
||||
dedupeKey,
|
||||
@@ -511,22 +532,23 @@ const upsertContextPanelTab = (
|
||||
const existingIndex = baseTabs.findIndex((tab) => tab.id === nextTab.id);
|
||||
const tabs = existingIndex === -1
|
||||
? [...baseTabs, nextTab]
|
||||
: baseTabs.map((tab, index) => (index === existingIndex
|
||||
? {
|
||||
...tab,
|
||||
mode: nextTab.mode,
|
||||
targetPath: nextTab.targetPath || tab.targetPath,
|
||||
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
|
||||
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
|
||||
dedupeKey: nextTab.dedupeKey,
|
||||
label: nextTab.label,
|
||||
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
|
||||
stagedDiff: nextTab.stagedDiff,
|
||||
diffScope: nextTab.diffScope,
|
||||
readOnly: nextTab.readOnly,
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
: tab));
|
||||
: baseTabs.map((tab, index) => (index === existingIndex
|
||||
? {
|
||||
...tab,
|
||||
mode: nextTab.mode,
|
||||
targetPath: nextTab.targetPath || tab.targetPath,
|
||||
targetDirectory: nextTab.targetDirectory,
|
||||
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
|
||||
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
|
||||
dedupeKey: nextTab.dedupeKey,
|
||||
label: nextTab.label,
|
||||
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
|
||||
stagedDiff: nextTab.stagedDiff,
|
||||
diffScope: nextTab.diffScope,
|
||||
readOnly: nextTab.readOnly,
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
: tab));
|
||||
|
||||
// A background upsert (an agent working a page) keeps the panel exactly as
|
||||
// the user left it: closed stays closed, and whatever tab they were on
|
||||
@@ -652,6 +674,7 @@ const sanitizeContextPanelByDirectory = (
|
||||
touchedAt?: unknown;
|
||||
mode?: unknown;
|
||||
targetPath?: unknown;
|
||||
targetDirectory?: string | null;
|
||||
dedupeKey?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
@@ -663,10 +686,11 @@ const sanitizeContextPanelByDirectory = (
|
||||
// no owner and cannot be migrated into an openable saved-plan tab — that
|
||||
// combination is dropped by sanitize above. A generic filesystem plan tab
|
||||
// (no plan id) revives fine from the descriptor alone.
|
||||
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
|
||||
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat' || candidate.mode === 'terminal')) {
|
||||
tabs = [createContextPanelTab({
|
||||
mode: candidate.mode,
|
||||
targetPath: typeof candidate.targetPath === 'string' ? candidate.targetPath : null,
|
||||
targetDirectory: candidate.targetDirectory,
|
||||
dedupeKey: typeof candidate.dedupeKey === 'string' ? candidate.dedupeKey : null,
|
||||
label: typeof candidate.label === 'string' ? candidate.label : null,
|
||||
})];
|
||||
@@ -1372,14 +1396,29 @@ export const useUIStore = create<UIStore>()(
|
||||
const panelState = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const tabs = panelState?.tabs ?? [];
|
||||
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? null;
|
||||
const clearTerminalTarget = () => {
|
||||
if (mode === 'terminal') {
|
||||
const terminalTab = tabs.find((tab) => tab.mode === 'terminal') ?? null;
|
||||
const targetDirectory = terminalTab?.targetDirectory ?? null;
|
||||
if (targetDirectory) {
|
||||
const targetState = useTerminalStore.getState().getDirectoryState(targetDirectory);
|
||||
if (directoryMayHaveActiveProjectAction(targetState)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
state.openContextPanelTab(normalizedDirectory, { mode: 'terminal', targetDirectory: null }, { reveal: false });
|
||||
}
|
||||
};
|
||||
|
||||
if (panelState?.isOpen && activeTab?.mode === mode) {
|
||||
clearTerminalTarget();
|
||||
state.closeContextPanel(normalizedDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
const tabsOfMode = tabs.filter((tab) => tab.mode === mode);
|
||||
if (tabsOfMode.length > 0) {
|
||||
clearTerminalTarget();
|
||||
// `>=` so equal timestamps (same-millisecond opens) resolve to the
|
||||
// later tab in insertion order.
|
||||
const mostRecent = tabsOfMode.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best));
|
||||
@@ -1403,12 +1442,21 @@ export const useUIStore = create<UIStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTab = tab.mode === 'terminal'
|
||||
? {
|
||||
...tab,
|
||||
targetDirectory: normalizeContextTargetDirectory(tab.targetDirectory) === normalizedDirectory
|
||||
? null
|
||||
: normalizeContextTargetDirectory(tab.targetDirectory),
|
||||
}
|
||||
: tab;
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: upsertContextPanelTab(current, tab, options),
|
||||
[normalizedDirectory]: upsertContextPanelTab(current, nextTab, options),
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
|
||||
@@ -70,6 +70,7 @@ const deferredStorage: Storage = {
|
||||
|
||||
mock.module("@/stores/utils/safeStorage", () => ({
|
||||
getDeferredSafeStorage: () => deferredStorage,
|
||||
getSafeSessionStorage: () => deferredStorage,
|
||||
createDeferredSafeJSONStorage: () => ({
|
||||
getItem: async () => null,
|
||||
setItem: async () => undefined,
|
||||
|
||||
Reference in New Issue
Block a user