fix(editor): remove misleading image line above stack actions (#1584)

Closes #1580. The Command Center header showed the first container's
image directly above stack-wide Start/Stop/Update controls, which implied
those buttons targeted one image. Remove the header image/digest row;
per-container ImageSourceMenu on each row remains.
This commit is contained in:
Anso
2026-07-07 14:16:32 -04:00
committed by GitHub
parent 9385720ef0
commit dbe230eef3
12 changed files with 82 additions and 129 deletions
@@ -142,7 +142,6 @@ export interface EditorViewProps {
isEditing: boolean;
editingCompose: boolean;
logsMode: 'structured' | 'raw';
copiedDigest: string | null;
loadingAction: StackAction | null;
stackMisconfigScanning: boolean;
@@ -152,9 +151,6 @@ export interface EditorViewProps {
trivy: { available: boolean };
activeNode: Node | null;
// Refs
copiedDigestTimerRef: React.MutableRefObject<number | null>;
// Stack actions
deployStack: (e: React.MouseEvent) => Promise<void>;
restartStack: (e: React.MouseEvent) => Promise<void>;
@@ -185,7 +181,6 @@ export interface EditorViewProps {
setLogsMode: (mode: 'structured' | 'raw') => void;
setEditingCompose: (open: boolean) => void;
setGitSourceOpen: (open: boolean) => void;
setCopiedDigest: React.Dispatch<React.SetStateAction<string | null>>;
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
requestDeleteStack: () => void;
@@ -242,14 +237,12 @@ export function EditorView(props: EditorViewProps) {
isEditing,
editingCompose,
logsMode,
copiedDigest,
loadingAction,
stackMisconfigScanning,
can,
isAdmin,
trivy,
activeNode,
copiedDigestTimerRef,
deployStack,
restartStack,
stopStack,
@@ -270,7 +263,6 @@ export function EditorView(props: EditorViewProps) {
setLogsMode,
setEditingCompose,
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
isSelfStack,
recoveryResult,
@@ -385,9 +377,6 @@ export function EditorView(props: EditorViewProps) {
activeNode={activeNode}
safeContainers={safeContainers}
isRunning={isRunning}
copiedDigest={copiedDigest}
setCopiedDigest={setCopiedDigest}
copiedDigestTimerRef={copiedDigestTimerRef}
can={can}
isAdmin={isAdmin}
trivy={trivy}
@@ -47,7 +47,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
isFileLoading: false,
gitSourcePendingMap: {},
notifications: [],
copiedDigest: null,
loadingAction: null,
stackMisconfigScanning: false,
can: () => true,
@@ -57,7 +56,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
logsMode: 'structured',
activeTab: 'compose',
editingCompose: false,
copiedDigestTimerRef: { current: null },
deployStack: vi.fn(),
restartStack: vi.fn(),
stopStack: vi.fn(),
@@ -76,7 +74,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
setActiveTab: vi.fn(),
setEditingCompose: vi.fn(),
setGitSourceOpen: vi.fn(),
setCopiedDigest: vi.fn(),
requestDeleteStack: vi.fn(),
onMobileBack: vi.fn(),
onCloseEditor: vi.fn(),
@@ -39,7 +39,6 @@ export function MobileStackDetail(props: EditorViewProps) {
isFileLoading,
gitSourcePendingMap,
notifications,
copiedDigest,
loadingAction,
stackMisconfigScanning,
can,
@@ -49,7 +48,6 @@ export function MobileStackDetail(props: EditorViewProps) {
logsMode,
activeTab,
editingCompose,
copiedDigestTimerRef,
deployStack,
restartStack,
stopStack,
@@ -68,7 +66,6 @@ export function MobileStackDetail(props: EditorViewProps) {
setActiveTab,
setEditingCompose,
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
isSelfStack = false,
onMobileBack,
@@ -138,9 +135,6 @@ export function MobileStackDetail(props: EditorViewProps) {
activeNode={activeNode}
safeContainers={safeContainers}
isRunning={isRunning}
copiedDigest={copiedDigest}
setCopiedDigest={setCopiedDigest}
copiedDigestTimerRef={copiedDigestTimerRef}
can={can}
isAdmin={isAdmin}
trivy={trivy}
@@ -45,7 +45,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
backupInfo: { exists: false, timestamp: null },
gitSourcePendingMap: {},
notifications: [],
copiedDigest: null,
loadingAction: null,
stackMisconfigScanning: false,
activeTab: 'compose',
@@ -55,7 +54,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
can: () => true,
isAdmin: false,
trivy: { available: false },
copiedDigestTimerRef: { current: null },
deployStack: vi.fn(),
restartStack: vi.fn(),
stopStack: vi.fn(),
@@ -76,7 +74,6 @@ function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
setLogsMode: vi.fn(),
setEditingCompose: vi.fn(),
setGitSourceOpen: vi.fn(),
setCopiedDigest: vi.fn(),
requestDeleteStack: vi.fn(),
onRefreshState: vi.fn(),
onDismissRecovery: vi.fn(),
@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { StackIdentityHeader } from '../editor-view-blocks';
import type { ContainerInfo } from '../EditorView';
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../Terminal', () => ({ default: () => null }));
vi.mock('../../StructuredLogViewer', () => ({ default: () => null }));
vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => null }));
const CONTAINERS: ContainerInfo[] = [
{
Id: 'abc123',
Names: ['/web'],
State: 'running',
Status: 'Up 2 hours',
Image: 'nginx:alpine',
ImageID: 'sha256:deadbeef',
healthStatus: 'healthy',
} as ContainerInfo,
{
Id: 'def456',
Names: ['/api'],
State: 'running',
Status: 'Up 2 hours',
Image: 'node:20',
ImageID: 'sha256:cafebabe',
healthStatus: 'healthy',
} as ContainerInfo,
];
function renderHeader(over: Partial<ComponentProps<typeof StackIdentityHeader>> = {}) {
return render(
<StackIdentityHeader
stackName="plex"
activeNode={{ id: 1, name: 'local', type: 'local' } as never}
safeContainers={CONTAINERS}
isRunning
can={() => true}
isAdmin
trivy={{ available: false }}
backupInfo={{ exists: false, timestamp: null }}
loadingAction={null}
stackMisconfigScanning={false}
deployStack={vi.fn()}
restartStack={vi.fn()}
stopStack={vi.fn()}
updateStack={vi.fn()}
rollbackStack={vi.fn()}
scanStackConfig={vi.fn()}
requestDeleteStack={vi.fn()}
{...over}
/>,
);
}
describe('StackIdentityHeader', () => {
it('renders stack identity and stack-wide actions without a header image line', () => {
renderHeader();
expect(screen.getByText('plex')).toBeInTheDocument();
expect(screen.getByText(/running · healthy/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Restart' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument();
expect(screen.queryByText(/^image$/i)).not.toBeInTheDocument();
expect(screen.queryByText('nginx:alpine')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Copy digest' })).not.toBeInTheDocument();
});
});
@@ -35,12 +35,6 @@ describe('useEditorViewState', () => {
expect(result.current.gitSourcePendingMap).toEqual({});
});
it('defaults nullable fields to null', () => {
const { result } = renderHook(() => useEditorViewState());
expect(result.current.copiedDigest).toBeNull();
expect(result.current.copiedDigestTimerRef.current).toBeNull();
});
it('defaults activeTab to compose', () => {
const { result } = renderHook(() => useEditorViewState());
expect(result.current.activeTab).toBe('compose');
@@ -103,21 +97,4 @@ describe('useEditorViewState', () => {
expect(window.localStorage.getItem(LOGS_MODE_STORAGE_KEY)).toBe('raw');
});
});
describe('copiedDigestTimerRef cleanup', () => {
it('clears a pending timer on unmount', () => {
const clearSpy = vi.spyOn(window, 'clearTimeout');
const { result, unmount } = renderHook(() => useEditorViewState());
result.current.copiedDigestTimerRef.current = 4242;
unmount();
expect(clearSpy).toHaveBeenCalledWith(4242);
});
it('does nothing on unmount when no timer is pending', () => {
const clearSpy = vi.spyOn(window, 'clearTimeout');
const { unmount } = renderHook(() => useEditorViewState());
unmount();
expect(clearSpy).not.toHaveBeenCalled();
});
});
});
@@ -120,9 +120,6 @@ export interface StackIdentityHeaderProps {
activeNode: Node | null;
safeContainers: ContainerInfo[];
isRunning: boolean;
copiedDigest: string | null;
setCopiedDigest: React.Dispatch<React.SetStateAction<string | null>>;
copiedDigestTimerRef: React.MutableRefObject<number | null>;
can: ReturnType<typeof useAuth>['can'];
isAdmin: boolean;
trivy: { available: boolean };
@@ -141,16 +138,13 @@ export interface StackIdentityHeaderProps {
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
// Breadcrumb + serif title + state pill + image ref + action bar. The action
// buttons grow to a 44px touch target below md without changing desktop.
// Breadcrumb + serif title + state pill + action bar. The action buttons grow
// to a 44px touch target below md without changing desktop.
export function StackIdentityHeader({
stackName,
activeNode,
safeContainers,
isRunning,
copiedDigest,
setCopiedDigest,
copiedDigestTimerRef,
can,
isAdmin,
trivy,
@@ -191,54 +185,6 @@ export function StackIdentityHeader({
);
})()}
</div>
{(() => {
const first = safeContainers[0];
if (!first?.Image) return null;
const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : '';
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
<span>image <span className="text-muted-foreground/60">·</span> <span className="text-foreground/90">{first.Image}</span></span>
{digest && first.ImageID && (
<>
<span className="text-muted-foreground/60">·</span>
<span>digest <span className="text-foreground/90">{digest}</span></span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
onClick={() => {
const id = first.ImageID as string;
void copyToClipboard(id).then(() => {
setCopiedDigest(id);
if (copiedDigestTimerRef.current !== null) {
window.clearTimeout(copiedDigestTimerRef.current);
}
copiedDigestTimerRef.current = window.setTimeout(() => {
setCopiedDigest(prev => (prev === id ? null : prev));
copiedDigestTimerRef.current = null;
}, 1500);
}).catch(() => { /* clipboard unavailable */ });
}}
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copiedDigest === first.ImageID ? (
<Check className="h-3 w-3" strokeWidth={2} />
) : (
<Copy className="h-3 w-3" strokeWidth={1.5} />
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy digest</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
<ImageSourceMenu imageRef={first.Image} imageId={first.ImageID} />
</div>
);
})()}
</div>
{/* Action Bar: deploy and delete affordances render against their own
backend permissions so a delete-only or deploy-only persona sees
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import type { ContainerInfo } from '../EditorView';
export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode';
@@ -19,15 +19,6 @@ function readLogsMode(): LogsMode {
export function useEditorViewState() {
const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false);
const [copiedDigest, setCopiedDigest] = useState<string | null>(null);
const copiedDigestTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (copiedDigestTimerRef.current !== null) {
window.clearTimeout(copiedDigestTimerRef.current);
}
};
}, []);
const [content, setContent] = useState<string>('');
const [originalContent, setOriginalContent] = useState<string>('');
@@ -55,8 +46,6 @@ export function useEditorViewState() {
return {
stackMisconfigScanning, setStackMisconfigScanning,
copiedDigest, setCopiedDigest,
copiedDigestTimerRef,
content, setContent,
originalContent, setOriginalContent,
composeEtag, setComposeEtag,