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.
@@ -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.
-
+
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.
@@ -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.
@@ -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
-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 && (
-