Files
sencho/frontend/src/components/EditorLayout/MobileComposeEditor.tsx
T
Anso a5109e7916 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.
2026-06-14 20:18:48 -04:00

206 lines
9.4 KiB
TypeScript

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<void>;
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 (
<div className="flex h-full min-h-0 flex-col">
{/* Header: close + file selector */}
<div className="shrink-0 border-b border-hairline px-4 pb-3 pt-3">
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={onClose}
aria-label="Close editor"
data-testid="mobile-editor-close"
className="inline-flex min-h-11 items-center gap-1 pr-3 font-mono text-xs text-brand"
>
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
Cancel
</button>
{envExists ? (
<div
role="tablist"
aria-label="File to edit"
className="flex gap-1 rounded-lg border border-card-border bg-well p-1 shadow-[var(--shadow-well)]"
>
{(['compose', 'env'] as const).map(id => {
const on = tab === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={on}
onClick={() => setActiveTab(id)}
className={cn(
'rounded-md px-3 py-1.5 font-mono text-[11px] lowercase tracking-[0.08em] transition-colors',
on
? 'bg-card text-stat-value shadow-card-bevel'
: 'text-stat-subtitle hover:text-foreground',
)}
>
{id === 'compose' ? 'compose' : '.env'}
</button>
);
})}
</div>
) : (
<span className="font-mono text-[11px] lowercase tracking-[0.08em] text-stat-subtitle">
compose.yaml
</span>
)}
</div>
{tab === 'env' && envFiles.length > 1 && (
<Select value={selectedEnvFile} onValueChange={changeEnvFile} disabled={envSwitchDisabled}>
<SelectTrigger className="mt-2 h-9 min-h-11 w-full border-card-border bg-input text-xs">
<SelectValue placeholder="Select environment file" />
</SelectTrigger>
<SelectContent>
{envFiles.map(file => (
<SelectItem key={file} value={file} className="text-xs">
{file.split('/').pop()}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Editor */}
<div className="min-h-0 flex-1 overflow-hidden p-3">
<textarea
data-testid="mobile-compose-editor"
value={value}
onChange={e => {
if (editorReadOnly) return;
if (tab === 'compose') setContent(e.target.value);
else setEnvContent(e.target.value);
}}
readOnly={editorReadOnly}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
wrap="off"
aria-label={tab === 'compose' ? 'Compose file content' : 'Environment file content'}
className="h-full w-full resize-none overflow-auto whitespace-pre rounded-lg border border-card-border bg-input px-3 py-2.5 font-mono text-[13px] leading-relaxed text-foreground shadow-card-bevel focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* Safety note + save actions */}
<div className="shrink-0 space-y-2.5 border-t border-hairline px-4 py-3">
<p className="font-mono text-[11px] leading-snug text-stat-subtitle">
Mobile editing is for small, safe changes. For large rewrites, open this stack on desktop.
</p>
{canEdit && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={requestSave}
disabled={actionsDisabled}
data-testid="mobile-editor-save"
className="h-11 flex-1 rounded-lg"
>
<Save className="mr-2 h-4 w-4" strokeWidth={1.5} />
Save
</Button>
<Button
type="button"
variant="default"
onClick={requestSaveAndDeploy}
disabled={actionsDisabled}
data-testid="mobile-editor-save-deploy"
className="h-11 flex-1 rounded-lg"
>
<Rocket className="mr-2 h-4 w-4" strokeWidth={1.5} />
Save &amp; Deploy
</Button>
</div>
)}
</div>
</div>
);
}