diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index cb7ed794..e62b9470 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -3,7 +3,7 @@ title: Editor description: The per-stack cockpit. Run, restart, update, and update compose files; stream logs; exec into a container. --- -Selecting a stack in the sidebar opens the editor cockpit, a two-column workspace for everything you do to a single stack. The left column is the **Command Center** with the stack's identity, action bar, container list, and log stream. The right column starts on the **Anatomy panel**, a read-only summary of the compose file; clicking **edit** swaps it for the Monaco editor with `compose.yaml`, `.env`, and **Files** tabs. +Selecting a stack in the sidebar opens the editor cockpit, a two-column workspace for everything you do to a single stack. The left column is the **Command Center** with the stack's identity, action bar, container list, and log stream. The right column starts on the **Anatomy panel**, a read-only summary of the compose file; clicking **Edit compose** swaps it for an editable Monaco workspace with `compose.yaml`, `.env`, and **Files** tabs. Creating an empty stack lands you there automatically. Editor cockpit showing the Command Center and live logs on the left and the Anatomy panel on the right @@ -68,7 +68,7 @@ When the container has a compose service name attached, an extra `⋮` button ap The right column shows the **Anatomy panel** by default: a read-only summary of the compose file alongside a scrollable tab row for other stack views. - Anatomy panel header strip showing the Anatomy, Activity, Dossier, Drift, Environment, Networking, Doctor, and Storage tab row with Files and Edit shortcuts on the right + Anatomy panel header strip showing the Anatomy, Activity, Dossier, Drift, Environment, Networking, Doctor, and Storage tab row with Files and Edit compose shortcuts on the right The tab row always shows four tabs: **Anatomy**, **Activity**, **Dossier**, and **Drift**. Four more tabs appear when the active node advertises the matching capability. @@ -85,7 +85,7 @@ The tab row always shows four tabs: **Anatomy**, **Activity**, **Dossier**, and | **Doctor** | When `compose-doctor` capability is present | Preflight check results grouped by severity. The tab gains a red dot for blocker findings and an amber dot for high-risk findings. See [Compose Doctor](/features/compose-doctor). | | **Storage** | When `compose-storage` capability is present | Mount inventory with portability assessment and snapshot coverage. See [Compose Storage](/features/compose-storage). | -The **Files** shortcut and the **Edit** button sit at the right end of the strip and stay available regardless of which tab is active. +The **Files** shortcut and the **Edit compose** button sit at the right end of the strip and stay available regardless of which tab is active. The Anatomy tab lists: @@ -101,7 +101,7 @@ When an image update is available, or the stack declares services with a local ` ## Editor mode -Clicking **edit** in the Anatomy strip swaps the right column for the Monaco editor card. +Clicking **Edit compose** in the Anatomy strip opens an editable Monaco workspace. Creating a new empty stack does the same automatically. Editor in edit mode with the compose.yaml tab selected, the Git Source button, the Save and Deploy split button, and the close button visible @@ -118,22 +118,22 @@ The editor card has three tabs: Above the editor area, the toolbar carries: - **Git Source** opens the Git source dialog. A pulsing brand-color dot appears next to it when an upstream change is waiting to be applied. -- **Edit** (when read-only) flips the editor into write mode. Replaced by the save split-button while editing. -- **Close editor** (`X`) returns the right column to the Anatomy panel. +- **Save & Deploy** split button (shown as soon as the compose editor opens for operators with `stack:edit`). +- **Close editor** (`X`) discards any unsaved compose and env edits and returns the right column to the Anatomy panel. -When you switch to the `.env` tab and the stack defines more than one env file, a dropdown appears next to the tab strip so you can pick which file to edit. Sencho discovers env files from the `env_file:` entries in `compose.yaml`; if none are declared, it falls back to a `.env` in the stack directory. +When you switch to the `.env` tab and the stack defines more than one env file, a dropdown appears next to the tab strip so you can pick which file to edit. The dropdown locks while you have unsaved edits so switching files cannot drop them. Sencho discovers env files from the `env_file:` entries in `compose.yaml`; if none are declared, it falls back to a `.env` in the stack directory. The `.env` editor renders a teal banner above the textarea reminding you that variables defined there are available for substitution in `compose.yaml` (`${VAR}`), and that passing them into containers requires an `env_file:` entry on the service. ### Save options -In edit mode the toolbar shows a split button. The primary action is **Save & Deploy**; the dropdown chevron reveals two more. +As soon as the compose editor opens, the toolbar shows a split button. The primary action is **Save & Deploy**; the dropdown chevron reveals two more. | Action | Effect | |--------|--------| | **Save & Deploy** | Writes the file to disk, then runs `docker compose up -d` to apply changes. | | **Save Only** | Writes the file to disk without restarting any containers. Changes take effect on the next deploy. | -| **Discard Changes** | Reverts the editor to the last saved version. Unsaved edits are lost. | +| **Discard Changes** | Reverts the active file (compose or env) to the last saved version. Unsaved edits in that file are lost. | The same controls apply to the `compose.yaml` and `.env` editors. @@ -154,7 +154,7 @@ If there are no unsaved changes the modal is skipped and the save proceeds direc ## On a phone -On a narrow screen the stack opens as a full-screen detail with **Health**, **Logs**, and **Compose** segments instead of the two-column cockpit. The **Compose** segment shows the read-only Anatomy summary; tap **edit** to open a full-screen editor for small, safe changes. +On a narrow screen the stack opens as a full-screen detail with **Health**, **Logs**, and **Compose** segments instead of the two-column cockpit. The **Compose** segment shows the read-only Anatomy summary; tap **Edit compose** to open a full-screen editor for small, safe changes. Mobile compose editor with a Cancel button, the compose.yaml label, a monospace text field showing the compose file, a small-edits note, and Save and Save and Deploy buttons @@ -205,7 +205,7 @@ Sencho tries `/bin/bash` first and transparently falls back to `/bin/sh` if bash - You have not entered edit mode yet. Click **edit** in the Anatomy panel header (or the **Edit** button at the top of the editor card) to enable typing. If the **Edit** button is missing entirely, your role lacks the `stack:edit` permission; ask an admin to grant it. + Click **Edit compose** in the Anatomy panel header to open an editable workspace. If that control is missing, your role lacks the `stack:edit` permission; ask an admin to grant it. A deploy is already running for this stack. Watch the action bar for the spinning state to clear, then save again. @@ -214,7 +214,7 @@ Sencho tries `/bin/bash` first and transparently falls back to `/bin/sh` if bash The stack has no env file to edit. Add an `env_file:` entry to a service in `compose.yaml` and save, or create a `.env` file in the stack directory through the **Files** tab. - Open the **Compose** segment in the stack detail and tap **edit**. If the **edit** affordance is missing, your role lacks the `stack:edit` permission; ask an admin to grant it. The phone editor is intended for small corrections; for large compose rewrites, open the stack on a desktop. + Open the **Compose** segment in the stack detail and tap **Edit compose**. If that affordance is missing, your role lacks the `stack:edit` permission; ask an admin to grant it. The phone editor is intended for small corrections; for large compose rewrites, open the stack on a desktop. The container stopped between clicking the button and the exec starting. Start the container from the action bar (or the row's **Start service** option) and try again. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 690b526e..0337efaf 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -251,10 +251,10 @@ The right column of the stack view is a tabbed panel. Tabs appear only when the Anatomy panel showing all eight tabs: Anatomy, Activity, Dossier, Drift, Environment, Networking, Doctor, and Storage, with the Networking tab active -The **Files** and **Edit** buttons sit to the right of the tab row. +The **Files** and **Edit compose** buttons sit to the right of the tab row. - **Files**: opens the in-stack file explorer; see [Stack File Explorer](/features/stack-file-explorer). -- **Edit**: slides the Monaco editor over the panel for inline compose and env edits. +- **Edit compose**: opens an editable Monaco workspace for compose and env edits. ### Anatomy @@ -327,7 +327,7 @@ Each mount row shows its type (bind, named, anonymous, tmpfs, socket), permissio ### Editing compose.yaml -Click **Edit** in the panel header to open the Monaco editor. Tabs at the top switch between `compose.yaml`, `.env` (when one exists), and **Files** (the stack directory tree). The save split-button reads **Save & Deploy** by default; its dropdown also offers **Save Only** and **Discard Changes**. Close the editor to return to the anatomy view; unsaved changes are kept in the buffer until you discard them. See [Compose Editor](/features/editor) for autocomplete, schema validation, and Git Source workflow. +Click **Edit compose** in the panel header to open an editable Monaco workspace. Tabs at the top switch between `compose.yaml`, `.env` (when one exists), and **Files** (the stack directory tree). The save split-button reads **Save & Deploy** by default; its dropdown also offers **Save Only** and **Discard Changes**. Close the editor to return to the anatomy view; closing discards any unsaved compose and env edits. See [Compose Editor](/features/editor) for autocomplete, schema validation, and Git Source workflow. ## Deploying a stack diff --git a/e2e/editor-save-deploy.spec.ts b/e2e/editor-save-deploy.spec.ts index fe39b4a8..f2cc3589 100644 --- a/e2e/editor-save-deploy.spec.ts +++ b/e2e/editor-save-deploy.spec.ts @@ -57,11 +57,10 @@ test.describe('EditorView save-and-deploy', () => { await route.continue(); }); - // The editor surface has two distinct edit affordances. First click swaps - // the right panel from Anatomy to the editor tabs (Monaco mounts read-only); - // second click flips Monaco into edit mode and reveals Save & Deploy. - await page.getByRole('button', { name: /^edit$/ }).click(); - await page.getByRole('button', { name: /^Edit$/ }).click(); + // One click on Anatomy "Edit compose" opens an immediately editable Monaco + // workspace with Save & Deploy visible (no second Edit gate). + await page.getByTestId('anatomy-edit-compose-btn').click(); + await expect(page.getByRole('button', { name: 'Save & Deploy', exact: true })).toBeVisible({ timeout: 5_000 }); // No need to modify Monaco content: saveFile fires the PUT regardless of // dirty state. The route interceptor forces it to 500; the gated handler diff --git a/e2e/routing.spec.ts b/e2e/routing.spec.ts index a28c7767..ee3fe950 100644 --- a/e2e/routing.spec.ts +++ b/e2e/routing.spec.ts @@ -99,7 +99,7 @@ test.describe('URL routing', () => { await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/`)); await expect(page).not.toHaveURL(/\/compose$/); - await page.getByRole('button', { name: 'edit', exact: true }).click(); + await page.getByTestId('anatomy-edit-compose-btn').click(); await expect(page).toHaveURL(/\/compose/); const envTab = page.getByRole('tab', { name: '.env' }); test.skip(!(await envTab.isEnabled()), 'Stack has no .env file'); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 4c8e2ec8..057af619 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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(); }} /> diff --git a/frontend/src/components/EditorLayout/CreateStackDialog.tsx b/frontend/src/components/EditorLayout/CreateStackDialog.tsx index 76f816bc..b850c154 100644 --- a/frontend/src/components/EditorLayout/CreateStackDialog.tsx +++ b/frontend/src/components/EditorLayout/CreateStackDialog.tsx @@ -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; + onStackCreated: ( + stackName: string, + sourceNodeId: number | null | undefined, + meta?: { mode: CreateMode }, + ) => void | Promise; onStacksChanged: () => void | Promise; // 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.'); diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index facb5f73..50461fab 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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; // 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(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) { {activeTab === 'env' && envFiles.length > 1 && ( - @@ -528,7 +540,7 @@ export function EditorView(props: EditorViewProps) { )}
- {activeTab !== 'files' && can('stack:edit', 'stack', stackName) && ( + {activeTab !== 'files' && canEditCompose && ( <> - {!isEditing ? ( - - ) : ( -
- - - - - - - - - Save Only - - - - Discard Changes - - - -
- )} + + + + + + + + Save Only + + + + Discard Changes + + + +
)} {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" > @@ -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, }} /> @@ -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(); }} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx index 75507382..1a94442f 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx @@ -62,8 +62,11 @@ function makeProps(over: Partial = {}): 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 { 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 }) { setEditingCompose, activeTab, setActiveTab, + openComposeEditor: () => { + setActiveTab('compose'); + setEditingCompose(true); + }, })} /> ); diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 94078faf..b9be3b56 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -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('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'} diff --git a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx index 8807dd5d..90a95a30 100644 --- a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx @@ -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
; }, })); @@ -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: () =>
, +})); function makeProps(over: Partial = {}): EditorViewProps { return { @@ -60,7 +65,8 @@ function makeProps(over: Partial = {}): 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(); + 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( + 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( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Close editor' })); + expect(closeComposeEditor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts index 4fd76cb8..8438a881 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.test.ts @@ -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(); + }); }); diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 7baf4303..25b4c332 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -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(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(null); const [pendingUnsavedNode, setPendingUnsavedNode] = useState(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, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 058055f1..2933533f 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -32,6 +32,8 @@ function makeEditorState(over: Partial = {}): 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 { 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 function makeOverlay(over: Partial = {}): 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; getLastDeployOutputLine?: (stackName: string) => string | undefined; hasUpdateGuard?: boolean; + canEditStack?: (stackNameOrFilename: string) => boolean; activeNode?: Parameters[0]['activeNode']; + setActiveNode?: Parameters[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[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[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[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 }); }); }); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index b361fbea..11a49ee0 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -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>(async () => {}); + const loadFileRef = useRef<(filename: string, options?: LoadFileOptions) => Promise>(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 => { + const loadFileCore = async (filename: string, options?: LoadFileOptions): Promise => { 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 => { @@ -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, diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index aabe20d7..db17ca16 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -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( + , + ); + 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(); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 3891551c..7e6577d9 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -447,11 +447,12 @@ export default function StackAnatomyPanel({ {canEdit && ( )}