mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
+12
-12
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/editor/editor-overview.png" alt="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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/editor/anatomy-tabs.png" alt="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" />
|
||||
<img src="/images/editor/anatomy-tabs.png" alt="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" />
|
||||
</Frame>
|
||||
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/editor/editor-edit-mode.png" alt="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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/editor/editor-mobile.png" alt="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
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="The compose editor is read-only and I can't type">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="Save & Deploy is disabled">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="I can't edit compose from my phone">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title='"Container is not running" error when opening a bash session'>
|
||||
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.
|
||||
|
||||
@@ -251,10 +251,10 @@ The right column of the stack view is a tabbed panel. Tabs appear only when the
|
||||
<img src="/images/stack-view/anatomy-panel.png" alt="Anatomy panel showing all eight tabs: Anatomy, Activity, Dossier, Drift, Environment, Networking, Doctor, and Storage, with the Networking tab active" />
|
||||
</Frame>
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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');
|
||||
|
||||
@@ -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