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.
This commit is contained in:
Anso
2026-06-14 20:18:48 -04:00
committed by GitHub
parent 49f1b49ac6
commit a5109e7916
8 changed files with 608 additions and 34 deletions
@@ -3,6 +3,7 @@ import { ChevronLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
import ErrorBoundary from '../ErrorBoundary';
import StackAnatomyPanel from '../StackAnatomyPanel';
import { MobileComposeEditor } from './MobileComposeEditor';
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
import { RecoveryPanel } from './RecoveryPanel';
import { StackOperationBanner } from './StackOperationBanner';
@@ -21,7 +22,8 @@ type Segment = (typeof SEGMENTS)[number]['id'];
// two-pane grid does not fit a phone, so the same identity header, container
// health, logs, and anatomy are reorganized into a tracked-mono segmented
// control. Logs is the default segment (first-read, without copying Dockge's
// layout). Compose is read-only on mobile: full file editing stays on desktop.
// layout). From the Compose segment, an editor with stack:edit can open the
// full-screen MobileComposeEditor for small, safe compose/.env edits.
export function MobileStackDetail(props: EditorViewProps) {
const {
stackName,
@@ -31,7 +33,10 @@ export function MobileStackDetail(props: EditorViewProps) {
containerStatsError,
content,
envContent,
envExists,
envFiles,
selectedEnvFile,
isFileLoading,
gitSourcePendingMap,
notifications,
copiedDigest,
@@ -42,6 +47,8 @@ export function MobileStackDetail(props: EditorViewProps) {
trivy,
backupInfo,
logsMode,
activeTab,
editingCompose,
copiedDigestTimerRef,
deployStack,
restartStack,
@@ -49,14 +56,23 @@ export function MobileStackDetail(props: EditorViewProps) {
updateStack,
rollbackStack,
scanStackConfig,
requestSave,
requestSaveAndDeploy,
setContent,
setEnvContent,
changeEnvFile,
openLogViewer,
openBashModal,
serviceAction,
setLogsMode,
setActiveTab,
setEditingCompose,
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
onMobileBack,
onCloseEditor,
hasUnsavedChanges,
headerActions,
recoveryResult,
onRefreshState,
@@ -70,6 +86,34 @@ export function MobileStackDetail(props: EditorViewProps) {
const isRunning = safeContainers.some(c => c.State === 'running');
const canEditStack = can('stack:edit', 'stack', stackName);
// The writable editor layer renders only for an editor; a stale editingCompose
// while the user lacks stack:edit falls back to the read-only Compose segment.
if (editingCompose && canEditStack) {
return (
<ErrorBoundary>
<MobileComposeEditor
content={content}
envContent={envContent}
setContent={setContent}
setEnvContent={setEnvContent}
activeTab={activeTab}
setActiveTab={setActiveTab}
envExists={envExists}
envFiles={envFiles}
selectedEnvFile={selectedEnvFile}
changeEnvFile={changeEnvFile}
isFileLoading={isFileLoading}
loadingAction={loadingAction}
canEdit={canEditStack}
requestSave={requestSave}
requestSaveAndDeploy={requestSaveAndDeploy}
onClose={onCloseEditor}
hasUnsavedChanges={hasUnsavedChanges}
/>
</ErrorBoundary>
);
}
return (
<ErrorBoundary>
<div className="flex h-full min-h-0 flex-col">
@@ -186,27 +230,20 @@ export function MobileStackDetail(props: EditorViewProps) {
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
)}
{segment === 'compose' && (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<div className="min-h-0 flex-1">
<StackAnatomyPanel
stackName={stackName}
content={content}
envContent={envContent}
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => {}}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
applying={loadingAction === 'update'}
canEdit={false}
notifications={notifications}
/>
</div>
{canEditStack && (
<div className="shrink-0 rounded-lg border border-card-border bg-card px-3 py-2.5 font-mono text-[11px] text-stat-subtitle">
Editing compose is available on a larger screen. Open this stack on desktop to edit the file.
</div>
)}
<div className="min-h-0 flex-1">
<StackAnatomyPanel
stackName={stackName}
content={content}
envContent={envContent}
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => { setActiveTab('compose'); setEditingCompose(true); }}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
applying={loadingAction === 'update'}
canEdit={canEditStack}
notifications={notifications}
/>
</div>
)}
</div>