From a85fbd52659db9ce5e667f3b2119e7a463e73e8a Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 3 May 2026 18:00:11 -0400 Subject: [PATCH] refactor(frontend): extract useEditorViewState hook from EditorLayout (#900) Bundles 19 editor-view useState calls, 1 useRef, and 2 useEffects (copiedDigest timer cleanup, logsMode localStorage persist) into a co-located hook at EditorLayout/hooks/useEditorViewState.ts. EditorLayout destructures the result so existing reference sites keep their bare names. EditorView's prop interface is unchanged. EditorLayout.tsx: 61 -> 42 useState, 21 -> 19 useEffect. Adds 16 unit tests covering defaults, setters, logsMode hydrate/persist, and copiedDigestTimerRef cleanup on unmount. Behavior unchanged. Verified end-to-end in browser: stack switch populates content/env/containers, edit mode toggles, .env tab swap shows env content, Raw terminal logs button persists logsMode to localStorage. --- frontend/src/components/EditorLayout.tsx | 63 ++++----- .../__tests__/useEditorViewState.test.tsx | 124 ++++++++++++++++++ .../EditorLayout/hooks/useEditorViewState.ts | 77 +++++++++++ 3 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx create mode 100644 frontend/src/components/EditorLayout/hooks/useEditorViewState.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 78387109..7d01909f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -21,6 +21,7 @@ import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog'; import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog'; import { EditorView, type ContainerInfo, type StackAction } from './EditorLayout/EditorView'; +import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; @@ -84,19 +85,31 @@ export default function EditorLayout() { const { isPaid, license } = useLicense(); const { status: trivy } = useTrivyStatus(); const { runWithLog } = useDeployFeedback(); - const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false); + const { + stackMisconfigScanning, setStackMisconfigScanning, + copiedDigest, setCopiedDigest, + copiedDigestTimerRef, + content, setContent, + originalContent, setOriginalContent, + envContent, setEnvContent, + originalEnvContent, setOriginalEnvContent, + envExists, setEnvExists, + envFiles, setEnvFiles, + selectedEnvFile, setSelectedEnvFile, + containers, setContainers, + containerStats, setContainerStats, + activeTab, setActiveTab, + logsMode, setLogsMode, + gitSourceOpen, setGitSourceOpen, + gitSourcePendingMap, setGitSourcePendingMap, + isFileLoading, setIsFileLoading, + backupInfo, setBackupInfo, + isEditing, setIsEditing, + editingCompose, setEditingCompose, + } = useEditorViewState(); const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null); const [policyBypassing, setPolicyBypassing] = useState(false); - const [copiedDigest, setCopiedDigest] = useState(null); - const copiedDigestTimerRef = useRef(null); - useEffect(() => { - return () => { - if (copiedDigestTimerRef.current !== null) { - window.clearTimeout(copiedDigestTimerRef.current); - } - }; - }, []); const { nodes, activeNode, setActiveNode } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). @@ -106,22 +119,6 @@ export default function EditorLayout() { const remoteNotifWsRef = useRef void>>(new Map()); const [files, setFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(null); - const [content, setContent] = useState(''); - const [originalContent, setOriginalContent] = useState(''); - const [envContent, setEnvContent] = useState(''); - const [originalEnvContent, setOriginalEnvContent] = useState(''); - const [envExists, setEnvExists] = useState(false); - const [envFiles, setEnvFiles] = useState([]); - const [selectedEnvFile, setSelectedEnvFile] = useState(''); - const [containers, setContainers] = useState([]); - const [containerStats, setContainerStats] = useState>({}); // Incoming WebSocket stats are written here first (no re-render), then flushed // to React state in one batched update every 1.5 s. const pendingStatsRef = useRef>({}); - const [activeTab, setActiveTab] = useState<'compose' | 'env' | 'files'>('compose'); - const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => { - if (typeof window === 'undefined') return 'structured'; - return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured'; - }); - useEffect(() => { - try { localStorage.setItem('sencho.stackView.logsMode', logsMode); } catch { /* ignore */ } - }, [logsMode]); - const [gitSourceOpen, setGitSourceOpen] = useState(false); - const [gitSourcePendingMap, setGitSourcePendingMap] = useState>({}); const monacoEditorRef = useRef(null); const pendingStackLoadRef = useRef(null); const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); @@ -196,8 +183,6 @@ export default function EditorLayout() { const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null; const [isScanning, setIsScanning] = useState(false); - const [isFileLoading, setIsFileLoading] = useState(false); - const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null }); const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('sencho-theme') as Theme | null; if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved; @@ -214,7 +199,6 @@ export default function EditorLayout() { const [filterNodeId, setFilterNodeId] = useState(null); const [schedulePrefill, setSchedulePrefill] = useState(null); const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []); - const [isEditing, setIsEditing] = useState(false); const [diffPreview, setDiffPreview] = useState<{ mode: 'save' | 'save-and-deploy'; language: 'yaml' | 'ini'; @@ -223,7 +207,6 @@ export default function EditorLayout() { fileName: string; } | null>(null); const [diffPreviewConfirming, setDiffPreviewConfirming] = useState(false); - const [editingCompose, setEditingCompose] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); const [stackPorts, setStackPorts] = useState>({}); diff --git a/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx new file mode 100644 index 00000000..74d60d60 --- /dev/null +++ b/frontend/src/components/EditorLayout/__tests__/useEditorViewState.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useEditorViewState, LOGS_MODE_STORAGE_KEY } from '../hooks/useEditorViewState'; + +describe('useEditorViewState', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('defaults all string fields to empty string', () => { + const { result } = renderHook(() => useEditorViewState()); + expect(result.current.content).toBe(''); + expect(result.current.originalContent).toBe(''); + expect(result.current.envContent).toBe(''); + expect(result.current.originalEnvContent).toBe(''); + expect(result.current.selectedEnvFile).toBe(''); + }); + + it('defaults all boolean fields to false', () => { + const { result } = renderHook(() => useEditorViewState()); + expect(result.current.stackMisconfigScanning).toBe(false); + expect(result.current.envExists).toBe(false); + expect(result.current.gitSourceOpen).toBe(false); + expect(result.current.isFileLoading).toBe(false); + expect(result.current.isEditing).toBe(false); + expect(result.current.editingCompose).toBe(false); + }); + + it('defaults all collection fields to empty', () => { + const { result } = renderHook(() => useEditorViewState()); + expect(result.current.envFiles).toEqual([]); + expect(result.current.containers).toEqual([]); + expect(result.current.containerStats).toEqual({}); + 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'); + }); + + it('defaults backupInfo to absent', () => { + const { result } = renderHook(() => useEditorViewState()); + expect(result.current.backupInfo).toEqual({ exists: false, timestamp: null }); + }); + }); + + describe('setters', () => { + it('setContent updates content', () => { + const { result } = renderHook(() => useEditorViewState()); + act(() => result.current.setContent('services:\n web:\n image: nginx')); + expect(result.current.content).toBe('services:\n web:\n image: nginx'); + }); + + it('setActiveTab updates activeTab', () => { + const { result } = renderHook(() => useEditorViewState()); + act(() => result.current.setActiveTab('env')); + expect(result.current.activeTab).toBe('env'); + }); + + it('setIsEditing toggles edit mode', () => { + const { result } = renderHook(() => useEditorViewState()); + act(() => result.current.setIsEditing(true)); + expect(result.current.isEditing).toBe(true); + }); + + it('setBackupInfo replaces the whole object', () => { + const { result } = renderHook(() => useEditorViewState()); + act(() => result.current.setBackupInfo({ exists: true, timestamp: 1234567890 })); + expect(result.current.backupInfo).toEqual({ exists: true, timestamp: 1234567890 }); + }); + + it('setContainers accepts a new container array', () => { + const { result } = renderHook(() => useEditorViewState()); + act(() => result.current.setContainers([{ Id: 'abc', Names: ['/web'], State: 'running' }])); + expect(result.current.containers).toHaveLength(1); + expect(result.current.containers[0].Id).toBe('abc'); + }); + }); + + describe('logsMode persistence', () => { + it('hydrates from localStorage on mount', () => { + window.localStorage.setItem(LOGS_MODE_STORAGE_KEY, 'raw'); + const { result } = renderHook(() => useEditorViewState()); + expect(result.current.logsMode).toBe('raw'); + }); + + it('defaults to structured when storage is empty', () => { + const { result } = renderHook(() => useEditorViewState()); + expect(result.current.logsMode).toBe('structured'); + }); + + it('persists to localStorage on change', () => { + const { result } = renderHook(() => useEditorViewState()); + act(() => result.current.setLogsMode('raw')); + 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(); + }); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts new file mode 100644 index 00000000..16ddba09 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ContainerInfo, ContainerStatsEntry } from '../EditorView'; + +export const LOGS_MODE_STORAGE_KEY = 'sencho.stackView.logsMode'; + +type LogsMode = 'structured' | 'raw'; + +type EditorTab = 'compose' | 'env' | 'files'; + +interface BackupInfo { + exists: boolean; + timestamp: number | null; +} + +function readLogsMode(): LogsMode { + if (typeof window === 'undefined') return 'structured'; + return (localStorage.getItem(LOGS_MODE_STORAGE_KEY) as LogsMode | null) ?? 'structured'; +} + +export function useEditorViewState() { + const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false); + const [copiedDigest, setCopiedDigest] = useState(null); + const copiedDigestTimerRef = useRef(null); + useEffect(() => { + return () => { + if (copiedDigestTimerRef.current !== null) { + window.clearTimeout(copiedDigestTimerRef.current); + } + }; + }, []); + + const [content, setContent] = useState(''); + const [originalContent, setOriginalContent] = useState(''); + const [envContent, setEnvContent] = useState(''); + const [originalEnvContent, setOriginalEnvContent] = useState(''); + const [envExists, setEnvExists] = useState(false); + const [envFiles, setEnvFiles] = useState([]); + const [selectedEnvFile, setSelectedEnvFile] = useState(''); + const [containers, setContainers] = useState([]); + const [containerStats, setContainerStats] = useState>({}); + + const [activeTab, setActiveTab] = useState('compose'); + const [logsMode, setLogsMode] = useState(readLogsMode); + useEffect(() => { + try { localStorage.setItem(LOGS_MODE_STORAGE_KEY, logsMode); } catch { /* ignore */ } + }, [logsMode]); + + const [gitSourceOpen, setGitSourceOpen] = useState(false); + const [gitSourcePendingMap, setGitSourcePendingMap] = useState>({}); + const [isFileLoading, setIsFileLoading] = useState(false); + const [backupInfo, setBackupInfo] = useState({ exists: false, timestamp: null }); + const [isEditing, setIsEditing] = useState(false); + const [editingCompose, setEditingCompose] = useState(false); + + return { + stackMisconfigScanning, setStackMisconfigScanning, + copiedDigest, setCopiedDigest, + copiedDigestTimerRef, + content, setContent, + originalContent, setOriginalContent, + envContent, setEnvContent, + originalEnvContent, setOriginalEnvContent, + envExists, setEnvExists, + envFiles, setEnvFiles, + selectedEnvFile, setSelectedEnvFile, + containers, setContainers, + containerStats, setContainerStats, + activeTab, setActiveTab, + logsMode, setLogsMode, + gitSourceOpen, setGitSourceOpen, + gitSourcePendingMap, setGitSourcePendingMap, + isFileLoading, setIsFileLoading, + backupInfo, setBackupInfo, + isEditing, setIsEditing, + editingCompose, setEditingCompose, + } as const; +}