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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user