mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user