mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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<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 & Deploy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,34 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { MobileStackDetail } from './MobileStackDetail';
|
||||
import type { EditorViewProps } from './EditorView';
|
||||
|
||||
// The detail's heavy children stream logs, parse compose, and render container
|
||||
// stats; stub them with markers so this test focuses on the segmented-control
|
||||
// behavior (default segment + switching).
|
||||
// stats; stub them with markers so this test focuses on segment behavior and the
|
||||
// mobile editing flow.
|
||||
vi.mock('./editor-view-blocks', () => ({
|
||||
StackIdentityHeader: () => <div>identity-header</div>,
|
||||
ContainersHealth: () => <div>health-pane</div>,
|
||||
StackLogsSection: () => <div>logs-pane</div>,
|
||||
}));
|
||||
vi.mock('../StackAnatomyPanel', () => ({ default: () => <div>compose-pane</div> }));
|
||||
// Prop-aware so the edit affordance (canEdit + onEditCompose) is exercised, not
|
||||
// just the read-only marker.
|
||||
vi.mock('../StackAnatomyPanel', () => ({
|
||||
default: ({ canEdit, onEditCompose }: { canEdit: boolean; onEditCompose: () => void }) => (
|
||||
<div>
|
||||
compose-pane
|
||||
{canEdit && (
|
||||
<button type="button" onClick={onEditCompose}>
|
||||
edit-compose
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../ErrorBoundary', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}</> }));
|
||||
// The inline operation banner pulls in the deploy-feedback context; this suite
|
||||
// covers the segmented-control behavior, so stub it (it renders nothing in the
|
||||
// covers segment + editor behavior, so stub it (it renders nothing in the
|
||||
// default Modal style anyway).
|
||||
vi.mock('./StackOperationBanner', () => ({ StackOperationBanner: () => null }));
|
||||
|
||||
@@ -28,7 +41,10 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
||||
containerStatsError: null,
|
||||
content: '',
|
||||
envContent: '',
|
||||
envExists: false,
|
||||
envFiles: [],
|
||||
selectedEnvFile: '',
|
||||
isFileLoading: false,
|
||||
gitSourcePendingMap: {},
|
||||
notifications: [],
|
||||
copiedDigest: null,
|
||||
@@ -39,6 +55,8 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
||||
trivy: { available: false },
|
||||
backupInfo: { exists: false, timestamp: null },
|
||||
logsMode: 'structured',
|
||||
activeTab: 'compose',
|
||||
editingCompose: false,
|
||||
copiedDigestTimerRef: { current: null },
|
||||
deployStack: vi.fn(),
|
||||
restartStack: vi.fn(),
|
||||
@@ -46,19 +64,46 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
|
||||
updateStack: vi.fn(),
|
||||
rollbackStack: vi.fn(),
|
||||
scanStackConfig: vi.fn(),
|
||||
requestSave: vi.fn(),
|
||||
requestSaveAndDeploy: vi.fn(),
|
||||
setContent: vi.fn(),
|
||||
setEnvContent: vi.fn(),
|
||||
changeEnvFile: vi.fn(),
|
||||
openLogViewer: vi.fn(),
|
||||
openBashModal: vi.fn(),
|
||||
serviceAction: vi.fn(),
|
||||
setLogsMode: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
setEditingCompose: vi.fn(),
|
||||
setGitSourceOpen: vi.fn(),
|
||||
setCopiedDigest: vi.fn(),
|
||||
requestDeleteStack: vi.fn(),
|
||||
onMobileBack: vi.fn(),
|
||||
onCloseEditor: vi.fn(),
|
||||
hasUnsavedChanges: () => false,
|
||||
...over,
|
||||
} as unknown as EditorViewProps;
|
||||
}
|
||||
|
||||
describe('MobileStackDetail', () => {
|
||||
// Controlled wrapper so clicking the edit affordance and the compose/.env toggle
|
||||
// actually flips the editingCompose / activeTab state the parent owns.
|
||||
function ControlledDetail({ over = {} }: { over?: Partial<EditorViewProps> }) {
|
||||
const [editingCompose, setEditingCompose] = useState(Boolean(over.editingCompose));
|
||||
const [activeTab, setActiveTab] = useState<'compose' | 'env' | 'files'>(over.activeTab ?? 'compose');
|
||||
return (
|
||||
<MobileStackDetail
|
||||
{...makeProps({
|
||||
...over,
|
||||
editingCompose,
|
||||
setEditingCompose,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('MobileStackDetail segments', () => {
|
||||
it('defaults to the Logs segment', () => {
|
||||
render(<MobileStackDetail {...makeProps()} />);
|
||||
expect(screen.getByText('logs-pane')).toBeInTheDocument();
|
||||
@@ -94,16 +139,151 @@ describe('MobileStackDetail', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to stacks' }));
|
||||
expect(onMobileBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the edit-on-desktop nudge in Compose when the user can edit', () => {
|
||||
render(<MobileStackDetail {...makeProps({ can: () => true })} />);
|
||||
describe('MobileStackDetail mobile editing', () => {
|
||||
it('exposes the edit affordance in Compose only when the user can edit', () => {
|
||||
const { rerender } = render(<MobileStackDetail {...makeProps({ can: () => true })} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Compose' }));
|
||||
expect(screen.getByText(/Editing compose is available on a larger screen/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'edit-compose' })).toBeInTheDocument();
|
||||
|
||||
rerender(<MobileStackDetail {...makeProps({ can: () => false })} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Compose' }));
|
||||
expect(screen.queryByRole('button', { name: 'edit-compose' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the nudge when the user cannot edit', () => {
|
||||
render(<MobileStackDetail {...makeProps({ can: () => false })} />);
|
||||
it('opens the full-screen editor from the Compose edit affordance', () => {
|
||||
render(<ControlledDetail />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Compose' }));
|
||||
expect(screen.queryByText(/Editing compose is available/i)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit-compose' }));
|
||||
expect(screen.getByTestId('mobile-compose-editor')).toBeInTheDocument();
|
||||
// The full-screen editor replaces the segmented detail.
|
||||
expect(screen.queryByText('logs-pane')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the read-only detail when editingCompose is set but the user cannot edit', () => {
|
||||
render(<MobileStackDetail {...makeProps({ editingCompose: true, can: () => false })} />);
|
||||
expect(screen.queryByTestId('mobile-compose-editor')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('logs-pane')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the compose buffer and routes edits to the right setter per tab', () => {
|
||||
const setContent = vi.fn();
|
||||
const setEnvContent = vi.fn();
|
||||
render(
|
||||
<ControlledDetail
|
||||
over={{
|
||||
editingCompose: true,
|
||||
content: 'compose-body',
|
||||
envContent: 'env-body',
|
||||
envExists: true,
|
||||
envFiles: ['.env'],
|
||||
selectedEnvFile: '.env',
|
||||
setContent,
|
||||
setEnvContent,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByTestId('mobile-compose-editor') as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe('compose-body');
|
||||
fireEvent.change(textarea, { target: { value: 'compose-edited' } });
|
||||
expect(setContent).toHaveBeenCalledWith('compose-edited');
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '.env' }));
|
||||
const envArea = screen.getByTestId('mobile-compose-editor') as HTMLTextAreaElement;
|
||||
expect(envArea.value).toBe('env-body');
|
||||
fireEvent.change(envArea, { target: { value: 'env-edited' } });
|
||||
expect(setEnvContent).toHaveBeenCalledWith('env-edited');
|
||||
});
|
||||
|
||||
it('disables the env-file selector while there are unsaved changes', () => {
|
||||
render(
|
||||
<ControlledDetail
|
||||
over={{
|
||||
editingCompose: true,
|
||||
activeTab: 'env',
|
||||
envExists: true,
|
||||
envFiles: ['.env', '.env.prod'],
|
||||
selectedEnvFile: '.env',
|
||||
hasUnsavedChanges: () => true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables save actions while a deploy is running', () => {
|
||||
render(<MobileStackDetail {...makeProps({ editingCompose: true, loadingAction: 'deploy' })} />);
|
||||
expect(screen.getByTestId('mobile-editor-save')).toBeDisabled();
|
||||
expect(screen.getByTestId('mobile-editor-save-deploy')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('routes Cancel through the close handler even after a save and a fresh edit', () => {
|
||||
const requestSave = vi.fn();
|
||||
const onCloseEditor = vi.fn();
|
||||
render(
|
||||
<ControlledDetail
|
||||
over={{
|
||||
editingCompose: true,
|
||||
content: 'compose-body',
|
||||
requestSave,
|
||||
onCloseEditor,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByTestId('mobile-compose-editor');
|
||||
fireEvent.change(textarea, { target: { value: 'first-edit' } });
|
||||
fireEvent.click(screen.getByTestId('mobile-editor-save'));
|
||||
expect(requestSave).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Save clears isEditing on the real hook; Cancel must not depend on it.
|
||||
fireEvent.change(textarea, { target: { value: 'second-edit' } });
|
||||
fireEvent.click(screen.getByTestId('mobile-editor-close'));
|
||||
expect(onCloseEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('triggers save-and-deploy from the deploy action', () => {
|
||||
const requestSaveAndDeploy = vi.fn();
|
||||
render(<MobileStackDetail {...makeProps({ editingCompose: true, requestSaveAndDeploy })} />);
|
||||
fireEvent.click(screen.getByTestId('mobile-editor-save-deploy'));
|
||||
expect(requestSaveAndDeploy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes a files tab to compose so the visible edit saves to the visible file', () => {
|
||||
// Desktop can hand off activeTab='files' when it crosses into the mobile
|
||||
// breakpoint; the editor shows compose, so the shared tab must follow or
|
||||
// saveFile silently no-ops on 'files'.
|
||||
const setActiveTab = vi.fn();
|
||||
render(<MobileStackDetail {...makeProps({ editingCompose: true, activeTab: 'files', setActiveTab })} />);
|
||||
expect(setActiveTab).toHaveBeenCalledWith('compose');
|
||||
});
|
||||
|
||||
it('normalizes an env tab to compose when the stack has no env file', () => {
|
||||
const setActiveTab = vi.fn();
|
||||
render(<MobileStackDetail {...makeProps({ editingCompose: true, activeTab: 'env', envExists: false, setActiveTab })} />);
|
||||
expect(setActiveTab).toHaveBeenCalledWith('compose');
|
||||
});
|
||||
|
||||
it('blocks textarea edits while an env-file load is in flight', () => {
|
||||
const setContent = vi.fn();
|
||||
const setEnvContent = vi.fn();
|
||||
render(
|
||||
<MobileStackDetail
|
||||
{...makeProps({
|
||||
editingCompose: true,
|
||||
activeTab: 'env',
|
||||
envExists: true,
|
||||
envFiles: ['.env'],
|
||||
isFileLoading: true,
|
||||
setContent,
|
||||
setEnvContent,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const editor = screen.getByTestId('mobile-compose-editor');
|
||||
expect(editor).toHaveAttribute('readonly');
|
||||
fireEvent.change(editor, { target: { value: 'late-edit' } });
|
||||
expect(setEnvContent).not.toHaveBeenCalled();
|
||||
expect(setContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user