From a5109e7916d926f1a99191c4bc1789d9ec62a0f0 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 14 Jun 2026 20:18:48 -0400 Subject: [PATCH] feat(editor): enable mobile compose and env editing (#1371) * feat(editor): enable mobile compose and env editing The mobile stack-detail Compose segment was read-only and told users to edit on desktop. Operators need to make small emergency edits from a phone, so the Compose segment now opens a full-screen editor for small, safe compose and .env changes. The editor is a lightweight monospace textarea rather than Monaco, sized for small corrections at common phone widths. It reuses the existing desktop save path from useStackActions and the global overlays, so every protection behaves the same: ETag conflict handling, diff preview when enabled, save-only, save-and-deploy, and the unsaved-changes guard. A compose/.env toggle appears when the stack has an env file, and the env-file picker is locked while edits are unsaved so switching files cannot drop them. Editing is gated by the same stack:edit permission as desktop. A footer note reminds users that mobile editing is for small changes and points large rewrites to desktop. The desktop Monaco editor is unchanged. * fix(editor): keep the mobile editor save target in sync with the shown buffer Two edge cases in the mobile compose/.env editor could silently drop an edit: - When the desktop editor was on the Files tab (or an env tab with no env file) and the viewport crossed into the mobile breakpoint, the editor showed the compose buffer while the shared active tab stayed on files, so a save quietly no-opped. Normalize the active tab to compose on the mobile surface so the visible edit always saves to the visible file. - The textarea stayed writable while an env-file switch was loading, so edits typed during the fetch were overwritten when it resolved. Make the textarea read-only while a file load is in flight. Adds unit tests for both normalizations and the read-only-during-load guard. --- docs/features/editor.mdx | 15 ++ docs/images/editor/editor-mobile.png | Bin 0 -> 40693 bytes e2e/mobile-stack-edit.spec.ts | 125 +++++++++++ frontend/src/components/EditorLayout.tsx | 2 + .../components/EditorLayout/EditorView.tsx | 10 + .../EditorLayout/MobileComposeEditor.tsx | 205 ++++++++++++++++++ .../EditorLayout/MobileStackDetail.test.tsx | 204 ++++++++++++++++- .../EditorLayout/MobileStackDetail.tsx | 81 +++++-- 8 files changed, 608 insertions(+), 34 deletions(-) create mode 100644 docs/images/editor/editor-mobile.png create mode 100644 e2e/mobile-stack-edit.spec.ts create mode 100644 frontend/src/components/EditorLayout/MobileComposeEditor.tsx diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index dc23c792..79566715 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -140,6 +140,18 @@ Review the diff, then: If there are no unsaved changes the modal is skipped and the save proceeds directly. The toggle is off by default and stored per browser, so each device remembers its own setting. +## 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. + + + 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 + + +The mobile editor is a lightweight monospace text field rather than Monaco. Tap **compose** or **.env** at the top to choose the file. The **.env** toggle appears only when the stack has an env file, and the file picker is locked while you have unsaved edits so switching files cannot drop them. The footer carries the same **Save** and **Save & Deploy** actions, and every protection is shared with desktop: the diff preview, save-conflict handling, and the unsaved-changes prompt all behave the same way. **Cancel** leaves the editor and asks before discarding unsaved edits. + +A note at the bottom of the editor is a reminder that mobile editing is meant for small corrections such as bumping an image tag or fixing a value. For large compose rewrites, open the stack on a desktop. Editing requires the `stack:edit` permission. + ## Logs Below the Command Center the left column reserves the rest of its height for the **Logs** stream. Two modes are available, toggled by the buttons at the top right of the section. @@ -189,6 +201,9 @@ 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. + 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/images/editor/editor-mobile.png b/docs/images/editor/editor-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..ff2354c237c29ec3e5b94e8f7bb23f0e585aae6a GIT binary patch literal 40693 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 literal 0 HcmV?d00001 diff --git a/e2e/mobile-stack-edit.spec.ts b/e2e/mobile-stack-edit.spec.ts new file mode 100644 index 00000000..20ffc07d --- /dev/null +++ b/e2e/mobile-stack-edit.spec.ts @@ -0,0 +1,125 @@ +/** + * Mobile compose/.env editing (below the md breakpoint). + * + * Logs in and creates the stack at desktop width (the create dialog and stack + * list are desktop-driven), then resizes to a phone viewport so EditorView + * renders MobileStackDetail. Covers the acceptance-criteria flows: open on + * mobile, edit compose, save, save-and-deploy guard, and discard dirty changes. + * Phone widths exercised: 390px and 430px. The compose/.env toggle and env-file + * save share the same handlers and are covered by MobileStackDetail.test.tsx. + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +const TEST_STACK = 'e2e-mobile-edit-stack'; +const PHONE_390 = { width: 390, height: 844 }; +const PHONE_430 = { width: 430, height: 932 }; + +async function deleteTestStack(page: Page) { + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => { }); + }, TEST_STACK); +} + +async function createTestStack(page: Page) { + await page.getByRole('button', { name: 'Create Stack' }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 }); + await page.locator('#create-stack-name').fill(TEST_STACK); + await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click(); + await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 }); +} + +// Open the freshly created stack in the editor (desktop), then drop to a phone +// viewport so the mobile detail surface renders, and select the Compose segment. +async function openComposeOnPhone(page: Page, viewport = PHONE_390) { + await page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true }).click(); + await page.setViewportSize(viewport); + await page.getByRole('tab', { name: 'Compose' }).click(); +} + +async function openEditor(page: Page) { + await page.getByRole('button', { name: 'edit' }).click(); + await expect(page.getByTestId('mobile-compose-editor')).toBeVisible({ timeout: 5_000 }); +} + +test.describe('mobile stack editing', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + await waitForStacksLoaded(page); + await deleteTestStack(page); + await page.reload(); + await loginAs(page); + await waitForStacksLoaded(page); + await createTestStack(page); + }); + + test.afterEach(async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await deleteTestStack(page); + }); + + test('edits and saves the compose file from a phone (390px)', async ({ page }) => { + await openComposeOnPhone(page); + await openEditor(page); + + await page.getByTestId('mobile-compose-editor').fill('services:\n app:\n image: nginx:1.27\n restart: always\n'); + await page.getByTestId('mobile-editor-save').click(); + + await expect(page.getByText(/file saved successfully/i)).toBeVisible({ timeout: 5_000 }); + + // The edit must actually reach disk, not just flash a toast. + const onDisk = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}`, { credentials: 'include' }); + return res.text(); + }, TEST_STACK); + expect(onDisk).toContain('nginx:1.27'); + }); + + test('does not deploy when the save PUT fails', async ({ page }) => { + await page.route(`**/api/stacks/${TEST_STACK}`, async (route, req) => { + if (req.method() === 'PUT') { + await route.fulfill({ status: 500, body: 'forced save failure' }); + return; + } + await route.continue(); + }); + let deployAttempts = 0; + await page.route(`**/api/stacks/${TEST_STACK}/deploy*`, async (route, req) => { + if (req.method() === 'POST') deployAttempts += 1; + await route.continue(); + }); + + await openComposeOnPhone(page); + await openEditor(page); + await page.getByTestId('mobile-editor-save-deploy').click(); + + await expect(page.getByText(/failed to save file/i)).toBeVisible({ timeout: 5_000 }); + await page.waitForTimeout(1_000); + expect(deployAttempts).toBe(0); + }); + + test('guards a dirty close and discards on confirm', async ({ page }) => { + await openComposeOnPhone(page); + await openEditor(page); + + await page.getByTestId('mobile-compose-editor').fill('services:\n app:\n image: nginx:1.28\n'); + await page.getByTestId('mobile-editor-close').click(); + + const dialog = page.getByRole('alertdialog', { name: /discard unsaved changes/i }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await page.getByRole('button', { name: 'Discard changes' }).click(); + + // The editor closes back to the read-only Compose segment. + await expect(page.getByTestId('mobile-compose-editor')).toBeHidden(); + await expect(page.getByRole('button', { name: 'edit' })).toBeVisible(); + }); + + test('opens a usable editor at 430px', async ({ page }) => { + await openComposeOnPhone(page, PHONE_430); + await openEditor(page); + + await expect(page.getByTestId('mobile-compose-editor')).toBeVisible(); + await expect(page.getByTestId('mobile-editor-save')).toBeVisible(); + await expect(page.getByTestId('mobile-editor-save-deploy')).toBeVisible(); + }); +}); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index dbf8259f..3340101e 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -453,6 +453,8 @@ export default function EditorLayout() { onDismissRecovery={() => { if (selectedFile) dismissActionResult(selectedFile); }} panelStartedAt={panelStartedAt} onMobileBack={goToMobileList} + onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))} + hasUnsavedChanges={stackActions.hasUnsavedChanges} /> ); diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 5f02f687..fb03abc5 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -199,6 +199,16 @@ export interface EditorViewProps { // Mobile-only: back affordance in the detail header returns to the stack list. onMobileBack?: () => void; + // Mobile-only (always supplied by renderEditor): close the full-screen + // compose/.env editor, routed through the unsaved-changes guard so a dirty + // close prompts before discarding. Required, not optional, because the + // fallback would silently discard edits; the desktop EditorView ignores it. + onCloseEditor: () => void; + // Mobile-only (always supplied by renderEditor): true when the compose or env + // buffer differs from disk; gates the env-file selector so switching files + // cannot drop unsaved edits. Required so a wiring gap is a compile error + // rather than a silently-disabled data-loss guard. + hasUnsavedChanges: () => boolean; // Mobile-only: notifications + more-menu cluster for the detail header right // slot (the global TopBar is dropped on the full-screen detail surface). headerActions?: React.ReactNode; diff --git a/frontend/src/components/EditorLayout/MobileComposeEditor.tsx b/frontend/src/components/EditorLayout/MobileComposeEditor.tsx new file mode 100644 index 00000000..8ded336e --- /dev/null +++ b/frontend/src/components/EditorLayout/MobileComposeEditor.tsx @@ -0,0 +1,205 @@ +import { useEffect } from 'react'; +import { ChevronLeft, Save, Rocket } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '../ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { StackAction } from './EditorView'; + +interface MobileComposeEditorProps { + content: string; + envContent: string; + setContent: (next: string) => void; + setEnvContent: (next: string) => void; + activeTab: 'compose' | 'env' | 'files'; + setActiveTab: (tab: 'compose' | 'env' | 'files') => void; + envExists: boolean; + envFiles: string[]; + selectedEnvFile: string; + changeEnvFile: (file: string) => Promise; + isFileLoading: boolean; + loadingAction: StackAction | null; + canEdit: boolean; + requestSave: () => void; + requestSaveAndDeploy: (e: React.MouseEvent) => void; + onClose: () => void; + hasUnsavedChanges: () => boolean; +} + +// Full-screen phone editing surface for the compose file and the selected .env. +// Deliberately a lightweight monospace textarea, not Monaco: the flow is scoped to +// small, safe edits, and the native keyboard plus reliable touch scrolling matter +// more than syntax highlighting here. Every save protection (ETag conflict, diff +// preview, save-and-deploy, dirty-navigation guard) is reused from useStackActions +// via the shared editor state, so this surface only renders the controls. +export function MobileComposeEditor(props: MobileComposeEditorProps) { + const { + content, + envContent, + setContent, + setEnvContent, + activeTab, + setActiveTab, + envExists, + envFiles, + selectedEnvFile, + changeEnvFile, + isFileLoading, + loadingAction, + canEdit, + requestSave, + requestSaveAndDeploy, + onClose, + hasUnsavedChanges, + } = props; + + // The save handlers (saveFile) key off the shared editorState.activeTab, so the + // displayed buffer must match it. The desktop editor can hand off a 'files' tab + // (or 'env' with no env file) when it crosses into the mobile breakpoint; left + // alone the textarea would show compose while a save silently no-ops on 'files'. + // Normalize to 'compose' so the visible edit always saves to the visible file. + useEffect(() => { + if (activeTab === 'files' || (activeTab === 'env' && !envExists)) { + setActiveTab('compose'); + } + }, [activeTab, envExists, setActiveTab]); + + // Mirror of the normalization above for this render: 'env' only when an env + // file exists, else 'compose'. The effect makes the shared activeTab follow. + const tab: 'compose' | 'env' = activeTab === 'env' && envExists ? 'env' : 'compose'; + const value = tab === 'compose' ? content || '' : envContent || ''; + // Switching the env file refetches and overwrites the env buffer, so block it + // while there are unsaved edits (matches the desktop selector being disabled + // mid-edit). The compose <-> .env toggle stays free: both buffers persist. + const envSwitchDisabled = hasUnsavedChanges() || isFileLoading; + const actionsDisabled = isFileLoading || loadingAction === 'deploy'; + // Read-only while an env-file fetch is in flight: changeEnvFile overwrites the + // buffer when it resolves, so edits typed during the load would be silently lost. + const editorReadOnly = !canEdit || isFileLoading; + + return ( +
+ {/* Header: close + file selector */} +
+
+ + {envExists ? ( +
+ {(['compose', 'env'] as const).map(id => { + const on = tab === id; + return ( + + ); + })} +
+ ) : ( + + compose.yaml + + )} +
+ + {tab === 'env' && envFiles.length > 1 && ( + + )} +
+ + {/* Editor */} +
+