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

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

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

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

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

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

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

The compose editor env tab routing test was written against the old
'edit' button name. The single-compose-edit-gate branch renamed that
button to 'Edit compose' with test id 'anatomy-edit-compose-btn'.
Update the selector to match.
This commit is contained in:
Anso
2026-07-09 13:53:26 -04:00
committed by GitHub
parent d113004359
commit e2001fed72
16 changed files with 463 additions and 95 deletions
@@ -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(); }}