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.
This commit is contained in:
Anso
2026-05-03 18:00:11 -04:00
committed by GitHub
parent ae3cc3f0fd
commit a85fbd5265
3 changed files with 224 additions and 40 deletions
@@ -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();
});
});
});
@@ -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<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>('');
const [envContent, setEnvContent] = useState<string>('');
const [originalEnvContent, setOriginalEnvContent] = useState<string>('');
const [envExists, setEnvExists] = useState<boolean>(false);
const [envFiles, setEnvFiles] = useState<string[]>([]);
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
const [containers, setContainers] = useState<ContainerInfo[]>([]);
const [containerStats, setContainerStats] = useState<Record<string, ContainerStatsEntry>>({});
const [activeTab, setActiveTab] = useState<EditorTab>('compose');
const [logsMode, setLogsMode] = useState<LogsMode>(readLogsMode);
useEffect(() => {
try { localStorage.setItem(LOGS_MODE_STORAGE_KEY, logsMode); } catch { /* ignore */ }
}, [logsMode]);
const [gitSourceOpen, setGitSourceOpen] = useState(false);
const [gitSourcePendingMap, setGitSourcePendingMap] = useState<Record<string, boolean>>({});
const [isFileLoading, setIsFileLoading] = useState(false);
const [backupInfo, setBackupInfo] = useState<BackupInfo>({ 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;
}