feat: open compose editor in edit mode with one click (#1592)

* feat: open compose editor in edit mode with one click

Remove the desktop double edit gate so Anatomy "Edit compose" and empty-stack
create land in an immediately editable workspace. Close discards both compose
and env buffers; deferred loads keep startInComposeEdit through unsaved confirm.

* docs: align compose editor close behavior and Edit compose labels

Update stack-management and editor docs for one-click edit mode.

Close discards unsaved compose/env edits. Anatomy shortcuts use Edit compose.

* fix(e2e): update routing test to use anatomy-edit-compose-btn test id

The compose editor env tab routing test was written against the old
'edit' button name. The single-compose-edit-gate branch renamed that
button to 'Edit compose' with test id 'anatomy-edit-compose-btn'.
Update the selector to match.
This commit is contained in:
Anso
2026-07-09 13:53:26 -04:00
committed by GitHub
parent d113004359
commit e2001fed72
16 changed files with 463 additions and 95 deletions
+15 -4
View File
@@ -233,6 +233,10 @@ export default function EditorLayout() {
getLastDeployOutputLine,
diffPreviewEnabled,
hasUpdateGuard: hasCapability('update-guard'),
canEditStack: (stackNameOrFilename) => {
const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, '');
return can('stack:edit', 'stack', stackName);
},
canOfferVolumeRemoval,
});
@@ -543,7 +547,8 @@ export default function EditorLayout() {
updateStack={stackActions.updateStack}
rollbackStack={stackActions.rollbackStack}
scanStackConfig={stackActions.scanStackConfig}
enterEditMode={stackActions.enterEditMode}
openComposeEditor={stackActions.openComposeEditor}
closeComposeEditor={stackActions.closeComposeEditor}
requestSave={stackActions.requestSave}
requestSaveAndDeploy={stackActions.requestSaveAndDeploy}
discardChanges={stackActions.discardChanges}
@@ -573,7 +578,7 @@ export default function EditorLayout() {
onDismissRecovery={() => { if (selectedFile) dismissActionResult(selectedFile); }}
panelStartedAt={panelStartedAt}
onMobileBack={goToMobileList}
onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))}
onCloseEditor={() => stackActions.attemptLeaveEditor(() => stackActions.closeComposeEditor())}
hasUnsavedChanges={stackActions.hasUnsavedChanges}
stackMuteActions={selectedFile ? stackMuteActions : undefined}
/>
@@ -619,6 +624,7 @@ export default function EditorLayout() {
// effect fire a no-op so dirty content survives.
overlayState.setPendingUnsavedNode(activeNode);
overlayState.setPendingUnsavedLoad(NODE_SWITCH_PENDING_TOKEN);
overlayState.setPendingLoadOptions(null);
revertingNodeSwitchRef.current = true;
setActiveNode(previousNode);
return;
@@ -685,7 +691,7 @@ export default function EditorLayout() {
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
initialMode={createDialogInitialMode}
onStackCreated={async (sName, sourceNodeId) => {
onStackCreated={async (sName, sourceNodeId, meta) => {
await refreshStacks();
// loadFile keeps its own unsaved-changes overlay (intentional safety,
// shared with every other "switch to a different stack" code path).
@@ -695,7 +701,12 @@ export default function EditorLayout() {
toast.info(`Stack "${sName}" created on the previous node.`);
return;
}
await stackActions.loadFile(sName);
// Empty creates land in an editable compose workspace. Other modes
// (git, docker-run, import) stay browse-first on Anatomy.
await stackActions.loadFile(
sName,
meta?.mode === 'empty' ? { startInComposeEdit: true } : undefined,
);
}}
onStacksChanged={async () => { await refreshStacks(); }}
/>
@@ -20,7 +20,11 @@ export interface CreateStackDialogProps {
// sourceNodeId is the active node ID captured at the moment the user clicked
// Create. Parent compares against the current active node before navigating
// so a mid-flight node switch does not land the user on a 404.
onStackCreated: (stackName: string, sourceNodeId: number | null | undefined) => void | Promise<void>;
onStackCreated: (
stackName: string,
sourceNodeId: number | null | undefined,
meta?: { mode: CreateMode },
) => void | Promise<void>;
onStacksChanged: () => void | Promise<void>;
// Mode the dialog opens on. The empty-state entry opens directly on 'import';
// the toolbar Create button opens on 'empty'.
@@ -153,7 +157,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
setNewStackName('');
setCreateMode('empty');
toast.success(`Stack "${stackName}" created.`);
await onStackCreated(stackName, sourceNodeId);
await onStackCreated(stackName, sourceNodeId, { mode: 'empty' });
} catch (error) {
console.error('Failed to create stack:', error);
toast.error((error as Error).message || 'Failed to create stack.');
@@ -2,7 +2,6 @@ import { Suspense, useRef, useEffect, useState } from 'react';
import { Editor } from '@/lib/monacoLoader';
import {
Save,
Pencil,
X,
Rocket,
ChevronDown,
@@ -161,7 +160,8 @@ export interface EditorViewProps {
scanStackConfig: () => Promise<void>;
// Edit lifecycle
enterEditMode: () => void;
openComposeEditor: () => void;
closeComposeEditor: () => void;
requestSave: () => void;
requestSaveAndDeploy: (e: React.MouseEvent) => void;
discardChanges: () => void;
@@ -237,7 +237,6 @@ export function EditorView(props: EditorViewProps) {
gitSourcePendingMap,
notifications,
activeTab,
isEditing,
editingCompose,
logsMode,
loadingAction,
@@ -252,7 +251,8 @@ export function EditorView(props: EditorViewProps) {
updateStack,
rollbackStack,
scanStackConfig,
enterEditMode,
openComposeEditor,
closeComposeEditor,
requestSave,
requestSaveAndDeploy,
discardChanges,
@@ -275,8 +275,10 @@ export function EditorView(props: EditorViewProps) {
onDismissRecovery,
panelStartedAt,
stackMuteActions,
hasUnsavedChanges,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
const canEditCompose = can('stack:edit', 'stack', stackName);
// Dispose the underlying Monaco model when EditorView unmounts. The
// @monaco-editor/react wrapper reuses a single model per editor instance
@@ -309,6 +311,16 @@ export function EditorView(props: EditorViewProps) {
return () => cancelAnimationFrame(id);
}, [activeTab]);
// Focus Monaco when entering the editable compose workspace. Lazy Monaco
// can mount after this effect, so onMount also focuses (dual strategy).
useEffect(() => {
if (!editingCompose || activeTab !== 'compose' || !canEditCompose) return;
const id = requestAnimationFrame(() => {
monacoEditorRef.current?.focus();
});
return () => cancelAnimationFrame(id);
}, [editingCompose, activeTab, canEditCompose]);
const safeContainers = containers || [];
const safeContent = content || '';
const safeEnvContent = envContent || '';
@@ -513,7 +525,7 @@ export function EditorView(props: EditorViewProps) {
</Tabs>
{activeTab === 'env' && envFiles.length > 1 && (
<Select value={selectedEnvFile} onValueChange={changeEnvFile} disabled={isEditing || isFileLoading}>
<Select value={selectedEnvFile} onValueChange={changeEnvFile} disabled={hasUnsavedChanges() || isFileLoading}>
<SelectTrigger className="h-9 text-xs bg-muted border-none min-w-[200px]">
<SelectValue placeholder="Select environment file" />
</SelectTrigger>
@@ -528,7 +540,7 @@ export function EditorView(props: EditorViewProps) {
)}
</div>
<div className="flex items-center gap-2">
{activeTab !== 'files' && can('stack:edit', 'stack', stackName) && (
{activeTab !== 'files' && canEditCompose && (
<>
<Button
size="sm"
@@ -542,36 +554,29 @@ export function EditorView(props: EditorViewProps) {
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-brand animate-pulse" />
)}
</Button>
{!isEditing ? (
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
<Pencil className="w-4 h-4 mr-2" />
Edit
<div className="flex items-center">
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={requestSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save & Deploy
</Button>
) : (
<div className="flex items-center">
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={requestSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save & Deploy
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default" className="rounded-r-lg rounded-l-none border-l border-primary-foreground/20 px-1.5" disabled={loadingAction === 'deploy'}>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={requestSave}>
<Save className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save Only
</DropdownMenuItem>
<DropdownMenuItem onClick={discardChanges} className="text-destructive/80 focus:text-destructive">
<X className="w-4 h-4 mr-2" strokeWidth={1.5} />
Discard Changes
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default" className="rounded-r-lg rounded-l-none border-l border-primary-foreground/20 px-1.5" disabled={loadingAction === 'deploy'}>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={requestSave}>
<Save className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save Only
</DropdownMenuItem>
<DropdownMenuItem onClick={discardChanges} className="text-destructive/80 focus:text-destructive">
<X className="w-4 h-4 mr-2" strokeWidth={1.5} />
Discard Changes
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
{activeTab === 'files' && (
@@ -592,12 +597,7 @@ export function EditorView(props: EditorViewProps) {
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8 p-0"
onClick={() => {
if (isEditing) {
discardChanges();
}
setEditingCompose(false);
}}
onClick={closeComposeEditor}
aria-label="Close editor"
>
<X className="w-4 h-4" strokeWidth={1.5} />
@@ -630,9 +630,14 @@ export function EditorView(props: EditorViewProps) {
language={activeTab === 'compose' ? 'yaml' : 'ini'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { monacoEditorRef.current = editor; }}
onMount={(editor) => {
monacoEditorRef.current = editor;
if (editingCompose && activeTab === 'compose' && canEditCompose) {
editor.focus();
}
}}
onChange={(value) => {
if (!isEditing) return; // Prevent changes in view mode
if (!canEditCompose) return;
if (activeTab === 'compose') {
setContent(value || '');
} else {
@@ -645,7 +650,7 @@ export function EditorView(props: EditorViewProps) {
fontSize: 14,
padding: { top: 10 },
scrollBeyondLastLine: false,
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
readOnly: !canEditCompose,
}}
/>
</Suspense>
@@ -667,7 +672,7 @@ export function EditorView(props: EditorViewProps) {
envContent={envContent}
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => { setEditingCompose(true); setActiveTab('compose'); }}
onEditCompose={openComposeEditor}
onOpenFiles={canRead ? () => { setEditingCompose(true); setActiveTab('files'); } : undefined}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
@@ -62,8 +62,11 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
updateStack: vi.fn(),
rollbackStack: vi.fn(),
scanStackConfig: vi.fn(),
openComposeEditor: vi.fn(),
closeComposeEditor: vi.fn(),
requestSave: vi.fn(),
requestSaveAndDeploy: vi.fn(),
discardChanges: vi.fn(),
setContent: vi.fn(),
setEnvContent: vi.fn(),
changeEnvFile: vi.fn(),
@@ -80,6 +83,11 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
onMobileBack: vi.fn(),
onCloseEditor: vi.fn(),
hasUnsavedChanges: () => false,
isEditing: false,
isDarkMode: false,
copiedDigest: null,
copiedDigestTimerRef: { current: null },
setCopiedDigest: vi.fn(),
...over,
} as unknown as EditorViewProps;
}
@@ -97,6 +105,10 @@ function ControlledDetail({ over = {} }: { over?: Partial<EditorViewProps> }) {
setEditingCompose,
activeTab,
setActiveTab,
openComposeEditor: () => {
setActiveTab('compose');
setEditingCompose(true);
},
})}
/>
);
@@ -64,7 +64,6 @@ export function MobileStackDetail(props: EditorViewProps) {
serviceAction,
setLogsMode,
setActiveTab,
setEditingCompose,
setGitSourceOpen,
requestDeleteStack,
requestTakeDownStack,
@@ -79,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) {
onDismissRecovery,
panelStartedAt,
stackMuteActions,
openComposeEditor,
} = props;
const [segment, setSegment] = useState<Segment>('logs');
@@ -240,7 +240,7 @@ export function MobileStackDetail(props: EditorViewProps) {
envContent={envContent}
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => { setActiveTab('compose'); setEditingCompose(true); }}
onEditCompose={openComposeEditor}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
applying={loadingAction === 'update'}
@@ -1,16 +1,18 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import type { ReactNode } from 'react';
import { EditorView } from '../EditorView';
import type { EditorViewProps } from '../EditorView';
// Capture Monaco language/value props to assert env tab gets 'ini', compose gets 'yaml'.
// Capture Monaco language/value/readOnly props.
let lastLanguage: string | undefined;
let lastValue: string | undefined;
let lastReadOnly: boolean | undefined;
vi.mock('@/lib/monacoLoader', () => ({
Editor: ({ language, value }: { language?: string; value?: string }) => {
Editor: ({ language, value, options }: { language?: string; value?: string; options?: { readOnly?: boolean } }) => {
lastLanguage = language;
lastValue = value;
lastReadOnly = options?.readOnly;
return <div data-testid="monaco-editor" />;
},
}));
@@ -27,6 +29,9 @@ vi.mock('../../StackAnatomyPanel', () => ({
vi.mock('../StackOperationBanner', () => ({ StackOperationBanner: () => null }));
vi.mock('../../ErrorBoundary', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}</> }));
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
vi.mock('../../files/StackFileExplorer', () => ({
StackFileExplorer: () => <div data-testid="file-explorer" />,
}));
function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
return {
@@ -60,7 +65,8 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
updateStack: vi.fn(),
rollbackStack: vi.fn(),
scanStackConfig: vi.fn(),
enterEditMode: vi.fn(),
openComposeEditor: vi.fn(),
closeComposeEditor: vi.fn(),
requestSave: vi.fn(),
requestSaveAndDeploy: vi.fn(),
discardChanges: vi.fn(),
@@ -91,6 +97,7 @@ describe('EditorView Monaco language prop', () => {
afterEach(() => {
lastLanguage = undefined;
lastValue = undefined;
lastReadOnly = undefined;
});
it('passes language="ini" when the env tab is active', () => {
@@ -136,3 +143,51 @@ describe('EditorView Monaco language prop', () => {
expect(lastValue).toBeUndefined();
});
});
describe('EditorView single edit gate', () => {
afterEach(() => {
lastLanguage = undefined;
lastValue = undefined;
lastReadOnly = undefined;
});
it('shows Save & Deploy immediately without an Edit button when compose editor is open', () => {
render(<EditorView {...makeProps({ editingCompose: true, activeTab: 'compose' })} />);
expect(screen.getByRole('button', { name: 'Save & Deploy' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Edit$/ })).not.toBeInTheDocument();
expect(lastReadOnly).toBe(false);
});
it('disables the env file selector when hasUnsavedChanges is true', () => {
render(
<EditorView
{...makeProps({
editingCompose: true,
activeTab: 'env',
envExists: true,
envFiles: ['.env', '.env.prod'],
selectedEnvFile: '.env',
hasUnsavedChanges: () => true,
})}
/>,
);
// Radix Select trigger is a button; disabled when dirty.
const trigger = screen.getByRole('combobox');
expect(trigger).toBeDisabled();
});
it('routes Close through closeComposeEditor', () => {
const closeComposeEditor = vi.fn();
render(
<EditorView
{...makeProps({
editingCompose: true,
activeTab: 'compose',
closeComposeEditor,
})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Close editor' }));
expect(closeComposeEditor).toHaveBeenCalledTimes(1);
});
});
@@ -9,6 +9,7 @@ describe('useOverlayState', () => {
expect(result.current.deleteDialogOpen).toBe(false);
expect(result.current.stackToDelete).toBeNull();
expect(result.current.pendingUnsavedLoad).toBeNull();
expect(result.current.pendingLoadOptions).toBeNull();
expect(result.current.pendingUnsavedNode).toBeNull();
expect(result.current.bashModalOpen).toBe(false);
expect(result.current.selectedContainer).toBeNull();
@@ -92,4 +93,21 @@ describe('useOverlayState', () => {
act(() => result.current.closeStackMonitor());
expect(result.current.stackMonitor).toBeNull();
});
it('pendingLoadOptions starts null, can be set, and clears independently', () => {
const { result } = renderHook(() => useOverlayState());
expect(result.current.pendingLoadOptions).toBeNull();
act(() => {
result.current.setPendingUnsavedLoad('web.yml');
result.current.setPendingLoadOptions({ startInComposeEdit: true });
});
expect(result.current.pendingUnsavedLoad).toBe('web.yml');
expect(result.current.pendingLoadOptions).toEqual({ startInComposeEdit: true });
act(() => {
result.current.setPendingUnsavedLoad(null);
result.current.setPendingLoadOptions(null);
});
expect(result.current.pendingUnsavedLoad).toBeNull();
expect(result.current.pendingLoadOptions).toBeNull();
});
});
@@ -25,6 +25,16 @@ type PolicyBlock = {
};
type Container = { id: string; name: string };
// Kept here (not in useStackActions) so overlay state can hold load options
// without a circular type import between the two hooks.
export type LoadFileOptions = {
startInComposeEdit?: boolean;
// Skip the unsaved-changes deferral. Used by discardAndLoadPending after
// buffers have been reverted via setters that have not re-rendered yet, so
// hasUnsavedChanges() would still see the pre-discard values.
skipUnsavedCheck?: boolean;
};
export function useOverlayState() {
const [createDialogOpen, setCreateDialogOpen] = useState(false);
@@ -51,6 +61,10 @@ export function useOverlayState() {
}, []);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
// Parallel to pendingUnsavedLoad: options for the deferred stack load after
// the unsaved-changes dialog. Cleared on cancel and on node-switch paths
// (which use NODE_SWITCH_PENDING_TOKEN and never carry load options).
const [pendingLoadOptions, setPendingLoadOptions] = useState<LoadFileOptions | null>(null);
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
// A deferred "leave the dirty editor" navigation (back to the list, Home, a
// bottom-tab / hamburger destination). Wrapped in an object so the state
@@ -143,6 +157,7 @@ export function useOverlayState() {
deleteDialogOpen, stackToDelete, openDeleteDialog, closeDeleteDialog,
takeDownDialogOpen, stackToTakeDown, openTakeDownDialog, closeTakeDownDialog,
pendingUnsavedLoad, setPendingUnsavedLoad,
pendingLoadOptions, setPendingLoadOptions,
pendingUnsavedNode, setPendingUnsavedNode,
pendingLeaveAction, setPendingLeaveAction,
bashModalOpen, selectedContainer, openBashModal, closeBashModal,
@@ -32,6 +32,8 @@ function makeEditorState(over: Partial<EditorState> = {}): EditorState {
originalEnvContent: '',
activeTab: 'compose' as const,
selectedEnvFile: '',
composeEtag: null as string | null,
envEtag: null as string | null,
setContent: vi.fn(),
setOriginalContent: vi.fn(),
setEnvContent: vi.fn(),
@@ -46,6 +48,8 @@ function makeEditorState(over: Partial<EditorState> = {}): EditorState {
setBackupInfo: vi.fn(),
setIsFileLoading: vi.fn(),
setGitSourcePendingMap: vi.fn(),
setComposeEtag: vi.fn(),
setEnvEtag: vi.fn(),
};
return { ...base, ...over } as unknown as EditorState;
}
@@ -77,9 +81,11 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
return {
setPendingUnsavedLoad: vi.fn(),
setPendingLoadOptions: vi.fn(),
setPendingUnsavedNode: vi.fn(),
setPendingLeaveAction: vi.fn(),
pendingUnsavedLoad: null,
pendingLoadOptions: null,
pendingUnsavedNode: null,
pendingLeaveAction: null,
policyBlock: null,
@@ -107,12 +113,15 @@ function setup(over: {
stackList?: Partial<StackListState>;
getLastDeployOutputLine?: (stackName: string) => string | undefined;
hasUpdateGuard?: boolean;
canEditStack?: (stackNameOrFilename: string) => boolean;
activeNode?: Parameters<typeof useStackActions>[0]['activeNode'];
setActiveNode?: Parameters<typeof useStackActions>[0]['setActiveNode'];
} = {}) {
const editorState = makeEditorState(over.editorState);
const stackListState = makeStackListState(over.stackList);
const navState = { setActiveView: vi.fn() } as unknown as NavState;
const overlayState = makeOverlay(over.overlay);
const setActiveNode = over.setActiveNode ?? vi.fn();
const { result } = renderHook(() =>
useStackActions({
@@ -121,15 +130,16 @@ function setup(over: {
navState,
overlayState,
activeNode: over.activeNode ?? ({ id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode']),
setActiveNode: vi.fn(),
setActiveNode,
nodes: [],
runWithLog,
getLastDeployOutputLine: over.getLastDeployOutputLine ?? (() => undefined),
diffPreviewEnabled: false,
hasUpdateGuard: over.hasUpdateGuard ?? false,
canEditStack: over.canEditStack ?? (() => true),
}),
);
return { result, editorState, stackListState, overlayState };
return { result, editorState, stackListState, overlayState, setActiveNode };
}
describe('useStackActions.saveFile', () => {
@@ -145,6 +155,8 @@ describe('useStackActions.saveFile', () => {
const ok = await result.current.saveFile();
expect(ok).toBe(true);
expect(editorState.setOriginalContent).toHaveBeenCalledWith('new');
// Stay editable after save (no re-gate into read-only Monaco).
expect(editorState.setIsEditing).not.toHaveBeenCalledWith(false);
});
it('returns false and leaves dirty state intact when the PUT fails', async () => {
@@ -173,6 +185,7 @@ describe('useStackActions.saveFile', () => {
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
canEditStack: () => true,
}),
);
const ok = await result.current.saveFile();
@@ -581,6 +594,174 @@ describe('useStackActions.attemptLeaveEditor (mobile back / nav guard)', () => {
const { result, overlayState } = setup({ overlay: { pendingLeaveAction: { run: vi.fn() } } });
result.current.cancelPendingUnsavedLoad();
expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith(null);
expect(overlayState.setPendingLoadOptions).toHaveBeenCalledWith(null);
});
});
describe('useStackActions open/close compose editor', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
it('openComposeEditor sets editingCompose, compose tab, and isEditing when permitted', () => {
const { result, editorState } = setup();
result.current.openComposeEditor();
expect(editorState.setEditingCompose).toHaveBeenCalledWith(true);
expect(editorState.setActiveTab).toHaveBeenCalledWith('compose');
expect(editorState.setIsEditing).toHaveBeenCalledWith(true);
});
it('openComposeEditor is a no-op when canEditStack returns false', () => {
const { result, editorState } = setup({ canEditStack: () => false });
result.current.openComposeEditor();
expect(editorState.setEditingCompose).not.toHaveBeenCalled();
});
it('closeComposeEditor reverts both buffers when .env is dirty and compose tab is active', () => {
const { result, editorState } = setup({
editorState: {
activeTab: 'compose',
content: 'compose-clean',
originalContent: 'compose-clean',
envContent: 'env-dirty',
originalEnvContent: 'env-clean',
},
});
result.current.closeComposeEditor();
expect(editorState.setContent).toHaveBeenCalledWith('compose-clean');
expect(editorState.setEnvContent).toHaveBeenCalledWith('env-clean');
expect(editorState.setEditingCompose).toHaveBeenCalledWith(false);
});
it('closeComposeEditor reverts both buffers when compose is dirty and files tab is active', () => {
const { result, editorState } = setup({
editorState: {
activeTab: 'files',
content: 'compose-dirty',
originalContent: 'compose-clean',
envContent: 'env-clean',
originalEnvContent: 'env-clean',
},
});
result.current.closeComposeEditor();
expect(editorState.setContent).toHaveBeenCalledWith('compose-clean');
expect(editorState.setEnvContent).toHaveBeenCalledWith('env-clean');
expect(editorState.setEditingCompose).toHaveBeenCalledWith(false);
});
});
describe('useStackActions loadFile startInComposeEdit + pending options', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
it('defers a dirty load and stores pendingLoadOptions', async () => {
const { result, overlayState } = setup({
editorState: { content: 'dirty', originalContent: 'clean' },
stackList: { selectedFile: 'web.yml' },
});
await result.current.loadFile('new.yml', { startInComposeEdit: true });
expect(overlayState.setPendingUnsavedLoad).toHaveBeenCalledWith('new.yml');
expect(overlayState.setPendingLoadOptions).toHaveBeenCalledWith({ startInComposeEdit: true });
expect(apiFetch).not.toHaveBeenCalled();
});
it('opens compose edit after load when startInComposeEdit and canEditStack(target) both pass', async () => {
const canEditStack = vi.fn((id: string) => id === 'target.yml' || id === 'target');
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u === '/stacks/target.yml') return Promise.resolve(new Response('services: {}', { status: 200 }));
if (u.includes('/envs')) return Promise.resolve(new Response(JSON.stringify({ envFiles: [] }), { status: 200 }));
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
if (u.includes('/backup')) return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 }));
return Promise.resolve(new Response('', { status: 404 }));
});
const { result, editorState } = setup({
editorState: { content: 'same', originalContent: 'same' },
stackList: { selectedFile: null },
canEditStack,
});
await result.current.loadFile('target.yml', { startInComposeEdit: true });
expect(canEditStack).toHaveBeenCalledWith('target.yml');
expect(editorState.setEditingCompose).toHaveBeenCalledWith(true);
expect(editorState.setIsEditing).toHaveBeenCalledWith(true);
});
it('does not auto-edit when canEditStack rejects the loaded target', async () => {
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u === '/stacks/target.yml') return Promise.resolve(new Response('services: {}', { status: 200 }));
if (u.includes('/envs')) return Promise.resolve(new Response(JSON.stringify({ envFiles: [] }), { status: 200 }));
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
if (u.includes('/backup')) return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 }));
return Promise.resolve(new Response('', { status: 404 }));
});
const { result, editorState } = setup({
editorState: { content: 'same', originalContent: 'same' },
stackList: { selectedFile: null },
canEditStack: () => false,
});
await result.current.loadFile('target.yml', { startInComposeEdit: true });
// loadFile always clears editingCompose at start; never re-opens.
expect(editorState.setEditingCompose).toHaveBeenCalledWith(false);
expect(editorState.setEditingCompose).not.toHaveBeenCalledWith(true);
});
it('forwards pendingLoadOptions through discardAndLoadPending for a dirty stack', async () => {
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u === '/stacks/other.yml') return Promise.resolve(new Response('services: {}', { status: 200 }));
if (u.includes('/envs')) return Promise.resolve(new Response(JSON.stringify({ envFiles: [] }), { status: 200 }));
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
if (u.includes('/backup')) return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 }));
return Promise.resolve(new Response('', { status: 404 }));
});
// Buffers stay dirty in the mock (setContent does not update content), so
// discardAndLoadPending must resume with skipUnsavedCheck or loadFile
// would re-defer.
const { result, editorState, overlayState } = setup({
editorState: { content: 'dirty', originalContent: 'clean', envContent: '', originalEnvContent: '' },
overlay: {
pendingUnsavedLoad: 'other.yml',
pendingLoadOptions: { startInComposeEdit: true },
},
stackList: { selectedFile: 'web.yml' },
});
result.current.discardAndLoadPending();
await vi.waitFor(() => {
expect(editorState.setEditingCompose).toHaveBeenCalledWith(true);
});
// Must not re-stash the pending load.
expect(overlayState.setPendingUnsavedLoad).toHaveBeenCalledWith(null);
expect(overlayState.setPendingUnsavedLoad).not.toHaveBeenCalledWith('other.yml');
});
it('node-switch pending load sets active node and never calls loadFile with the token', async () => {
const setActiveNode = vi.fn();
const targetNode = { id: 9, type: 'remote' } as Parameters<typeof useStackActions>[0]['activeNode'];
const { result } = setup({
overlay: {
pendingUnsavedLoad: '__node-switch-pending__',
pendingUnsavedNode: targetNode as never,
pendingLoadOptions: { startInComposeEdit: true },
},
setActiveNode,
});
result.current.discardAndLoadPending();
expect(setActiveNode).toHaveBeenCalledWith(targetNode);
expect(apiFetch).not.toHaveBeenCalled();
});
it('loadFileOnNode defers with options when dirty', async () => {
const node = { id: 2, type: 'remote' } as Parameters<typeof useStackActions>[0]['activeNode'];
const { result, overlayState } = setup({
editorState: { content: 'dirty', originalContent: 'clean' },
stackList: { selectedFile: 'web.yml' },
});
await result.current.loadFileOnNode(node!, 'other.yml', { startInComposeEdit: true });
expect(overlayState.setPendingUnsavedNode).toHaveBeenCalledWith(node);
expect(overlayState.setPendingUnsavedLoad).toHaveBeenCalledWith('other.yml');
expect(overlayState.setPendingLoadOptions).toHaveBeenCalledWith({ startInComposeEdit: true });
});
});
@@ -5,7 +5,7 @@ import { buildServiceUrl, openServiceUrl } from '@/lib/serviceUrl';
import type { useEditorViewState } from './useEditorViewState';
import type { useStackListState } from './useStackListState';
import type { useViewNavigationState } from './useViewNavigationState';
import type { OverlayState } from './useOverlayState';
import type { OverlayState, LoadFileOptions } from './useOverlayState';
import type { Node } from '@/context/NodeContext';
import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
import { parsePath } from '@/lib/router/senchoRoute';
@@ -129,6 +129,11 @@ interface UseStackActionsOptions {
// the pre-update readiness dialog. Defaults to false: without the
// capability, updates run directly with no dialog.
hasUpdateGuard?: boolean;
// Target-aware stack:edit check. Pass the loaded stack identity (folder name
// or compose path); callers strip extensions when comparing to RBAC stack
// names. Evaluated against the load target so post-load auto-edit is not
// gated by a stale previously-selected stack.
canEditStack: (stackNameOrFilename: string) => boolean;
/** Fail-closed: true only when active node meta explicitly lists stack-down-remove-volumes. */
canOfferVolumeRemoval?: boolean;
}
@@ -247,6 +252,7 @@ export function useStackActions(options: UseStackActionsOptions) {
getLastDeployOutputLine,
diffPreviewEnabled,
hasUpdateGuard = false,
canEditStack,
canOfferVolumeRemoval = false,
} = options;
@@ -400,22 +406,24 @@ export function useStackActions(options: UseStackActionsOptions) {
// loadFile and loadFileOnNode call each other (loadFileOnNode -> loadFile, navigateToNotification
// -> loadFileOnNode or loadFile). A ref breaks the mutual-recursion hoisting constraint without
// needing to hoist both functions or restructure the call graph.
const loadFileRef = useRef<(filename: string) => Promise<void>>(async () => {});
const loadFileRef = useRef<(filename: string, options?: LoadFileOptions) => Promise<void>>(async () => {});
const loadFileOnNode = async (node: Node, filename: string) => {
const loadFileOnNode = async (node: Node, filename: string, options?: LoadFileOptions) => {
if (!filename) return;
if (
!options?.skipUnsavedCheck &&
stackListState.selectedFile &&
filename !== stackListState.selectedFile &&
hasUnsavedChanges()
) {
overlayState.setPendingUnsavedNode(node);
overlayState.setPendingUnsavedLoad(filename);
overlayState.setPendingLoadOptions(options ?? null);
return;
}
setActiveNode(node);
stackListState.setSearchQuery('');
await loadFileRef.current(filename);
await loadFileRef.current(filename, options);
};
const clearEnvState = () => {
@@ -499,14 +507,16 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setIsEditing(false);
};
const loadFileCore = async (filename: string): Promise<RouteStackLoadResult> => {
const loadFileCore = async (filename: string, options?: LoadFileOptions): Promise<RouteStackLoadResult> => {
if (!filename) return { ok: false };
if (
!options?.skipUnsavedCheck &&
stackListState.selectedFile &&
filename !== stackListState.selectedFile &&
hasUnsavedChanges()
) {
overlayState.setPendingUnsavedLoad(filename);
overlayState.setPendingLoadOptions(options ?? null);
return { ok: false };
}
loadFileAbortRef.current?.abort();
@@ -534,6 +544,13 @@ export function useStackActions(options: UseStackActionsOptions) {
const envFiles = await loadEnvState(filename, signal);
await loadContainerState(filename, signal);
await loadBackupState(filename, signal);
// Post-load auto-edit evaluates permission for the loaded target, not
// the previously selected stack (selectedFile was just updated above).
if (options?.startInComposeEdit && canEditStack(filename)) {
editorState.setEditingCompose(true);
editorState.setActiveTab('compose');
editorState.setIsEditing(true);
}
return { ok: true, envFiles };
} catch (error) {
if (isAbortError(error) || signal.aborted) return { ok: false };
@@ -555,8 +572,8 @@ export function useStackActions(options: UseStackActionsOptions) {
}
};
const loadFile = async (filename: string) => {
await loadFileCore(filename);
const loadFile = async (filename: string, options?: LoadFileOptions) => {
await loadFileCore(filename, options);
};
const loadFileForRoute = async (filename: string): Promise<RouteStackLoadResult> => {
@@ -651,7 +668,6 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setOriginalEnvContent(currentRemoteContent);
editorState.setEnvEtag(response.headers.get('etag'));
}
editorState.setIsEditing(false);
toast.success('Reloaded the latest version of the file.');
return false;
}
@@ -666,7 +682,6 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setOriginalEnvContent(editorState.envContent);
if (newEtag) editorState.setEnvEtag(newEtag);
}
editorState.setIsEditing(false);
toast.success('File saved successfully!');
return true;
} catch (error) {
@@ -984,13 +999,31 @@ export function useStackActions(options: UseStackActionsOptions) {
} else {
editorState.setEnvContent(editorState.originalEnvContent);
}
editorState.setIsEditing(false);
};
const enterEditMode = () => {
// Revert both compose and env buffers. Used by closeComposeEditor so a dirty
// buffer on a non-active tab is not left hidden behind the Anatomy panel.
// Keep discardChanges for the Save-dropdown current-tab discard.
const discardAllChanges = () => {
editorState.setContent(editorState.originalContent);
editorState.setEnvContent(editorState.originalEnvContent);
};
const openComposeEditor = () => {
const selected = stackListState.selectedFile;
if (!selected || !canEditStack(selected)) return;
editorState.setEditingCompose(true);
editorState.setActiveTab('compose');
editorState.setIsEditing(true);
};
const closeComposeEditor = () => {
if (hasUnsavedChanges()) {
discardAllChanges();
}
editorState.setEditingCompose(false);
};
const scanStackConfig = async () => {
if (!stackListState.selectedFile || editorState.stackMisconfigScanning) return;
const stackName = stackListState.selectedFile.replace(/\.(yml|yaml)$/, '');
@@ -1359,6 +1392,7 @@ export function useStackActions(options: UseStackActionsOptions) {
const cancelPendingUnsavedLoad = () => {
const cancel = overlayState.pendingLeaveAction?.onCancel;
overlayState.setPendingUnsavedLoad(null);
overlayState.setPendingLoadOptions(null);
overlayState.setPendingUnsavedNode(null);
overlayState.setPendingLeaveAction(null);
cancel?.();
@@ -1368,9 +1402,11 @@ export function useStackActions(options: UseStackActionsOptions) {
const leave = overlayState.pendingLeaveAction;
const target = overlayState.pendingUnsavedLoad;
const targetNode = overlayState.pendingUnsavedNode;
const loadOptions = overlayState.pendingLoadOptions;
editorState.setContent(editorState.originalContent);
editorState.setEnvContent(editorState.originalEnvContent);
overlayState.setPendingUnsavedLoad(null);
overlayState.setPendingLoadOptions(null);
overlayState.setPendingUnsavedNode(null);
overlayState.setPendingLeaveAction(null);
// A stashed "leave editor" navigation takes precedence; it already knows
@@ -1384,8 +1420,12 @@ export function useStackActions(options: UseStackActionsOptions) {
return;
}
if (target) {
if (targetNode) void loadFileOnNode(targetNode, target);
else void loadFile(target);
const resumeOptions: LoadFileOptions = {
...(loadOptions ?? {}),
skipUnsavedCheck: true,
};
if (targetNode) void loadFileOnNode(targetNode, target, resumeOptions);
else void loadFile(target, resumeOptions);
}
};
@@ -1550,7 +1590,8 @@ export function useStackActions(options: UseStackActionsOptions) {
handleSaveAndDeploy,
rollbackStack,
discardChanges,
enterEditMode,
openComposeEditor,
closeComposeEditor,
scanStackConfig,
runDeploy,
deployStack,
@@ -91,6 +91,32 @@ function panel(applying: boolean, onApplyUpdate: () => void = vi.fn(), stackName
);
}
describe('StackAnatomyPanel edit affordance', () => {
it('renders Edit compose with a stable test id when canEdit is true', () => {
render(panel(false));
expect(screen.getByTestId('anatomy-edit-compose-btn')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit compose/i })).toBeInTheDocument();
});
it('hides Edit compose when canEdit is false', () => {
render(
<StackAnatomyPanel
stackName="web"
content={COMPOSE}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={vi.fn()}
canEdit={false}
applying={false}
/>,
);
expect(screen.queryByTestId('anatomy-edit-compose-btn')).not.toBeInTheDocument();
});
});
describe('StackAnatomyPanel update banner', () => {
it('shows the apply button and fires onApplyUpdate when clicked', async () => {
const onApply = vi.fn();
@@ -447,11 +447,12 @@ export default function StackAnatomyPanel({
{canEdit && (
<button
type="button"
data-testid="anatomy-edit-compose-btn"
onClick={onEditCompose}
className="inline-flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<Pencil className="h-3 w-3" strokeWidth={1.5} />
edit
Edit compose
</button>
)}
</div>