mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
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:
@@ -21,6 +21,7 @@ import { CreateStackDialog } from './EditorLayout/CreateStackDialog';
|
|||||||
import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog';
|
import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog';
|
||||||
import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog';
|
import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog';
|
||||||
import { EditorView, type ContainerInfo, type StackAction } from './EditorLayout/EditorView';
|
import { EditorView, type ContainerInfo, type StackAction } from './EditorLayout/EditorView';
|
||||||
|
import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState';
|
||||||
import { StackAlertSheet } from './StackAlertSheet';
|
import { StackAlertSheet } from './StackAlertSheet';
|
||||||
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
|
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
|
||||||
import { GitSourcePanel } from './stack/GitSourcePanel';
|
import { GitSourcePanel } from './stack/GitSourcePanel';
|
||||||
@@ -84,19 +85,31 @@ export default function EditorLayout() {
|
|||||||
const { isPaid, license } = useLicense();
|
const { isPaid, license } = useLicense();
|
||||||
const { status: trivy } = useTrivyStatus();
|
const { status: trivy } = useTrivyStatus();
|
||||||
const { runWithLog } = useDeployFeedback();
|
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<number | null>(null);
|
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
|
||||||
const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null);
|
const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null);
|
||||||
const [policyBypassing, setPolicyBypassing] = useState(false);
|
const [policyBypassing, setPolicyBypassing] = 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 { nodes, activeNode, setActiveNode } = useNodes();
|
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||||
// Stable ref so notification callbacks always read the latest nodes list
|
// Stable ref so notification callbacks always read the latest nodes list
|
||||||
// without needing nodes in their dependency arrays (which would cause loops).
|
// without needing nodes in their dependency arrays (which would cause loops).
|
||||||
@@ -106,22 +119,6 @@ export default function EditorLayout() {
|
|||||||
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
|
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
|
||||||
const [files, setFiles] = useState<string[]>([]);
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
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, {
|
|
||||||
cpu: string;
|
|
||||||
ram: string;
|
|
||||||
net: string;
|
|
||||||
lastRx?: number;
|
|
||||||
lastTx?: number;
|
|
||||||
history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] };
|
|
||||||
}>>({});
|
|
||||||
// Incoming WebSocket stats are written here first (no re-render), then flushed
|
// Incoming WebSocket stats are written here first (no re-render), then flushed
|
||||||
// to React state in one batched update every 1.5 s.
|
// to React state in one batched update every 1.5 s.
|
||||||
const pendingStatsRef = useRef<Record<string, {
|
const pendingStatsRef = useRef<Record<string, {
|
||||||
@@ -139,16 +136,6 @@ export default function EditorLayout() {
|
|||||||
// the delta is always computed against the most recent known value, avoiding
|
// the delta is always computed against the most recent known value, avoiding
|
||||||
// the stale-closure bug that occurs when reading containerStats directly.
|
// the stale-closure bug that occurs when reading containerStats directly.
|
||||||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||||||
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<Record<string, boolean>>({});
|
|
||||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||||
const pendingStackLoadRef = useRef<string | null>(null);
|
const pendingStackLoadRef = useRef<string | null>(null);
|
||||||
const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(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 loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null;
|
||||||
|
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
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<Theme>(() => {
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||||||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||||||
@@ -214,7 +199,6 @@ export default function EditorLayout() {
|
|||||||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||||
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
||||||
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
|
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [diffPreview, setDiffPreview] = useState<{
|
const [diffPreview, setDiffPreview] = useState<{
|
||||||
mode: 'save' | 'save-and-deploy';
|
mode: 'save' | 'save-and-deploy';
|
||||||
language: 'yaml' | 'ini';
|
language: 'yaml' | 'ini';
|
||||||
@@ -223,7 +207,6 @@ export default function EditorLayout() {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [diffPreviewConfirming, setDiffPreviewConfirming] = useState(false);
|
const [diffPreviewConfirming, setDiffPreviewConfirming] = useState(false);
|
||||||
const [editingCompose, setEditingCompose] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||||
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
|
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user