refactor(frontend): EditorLayout final shell (B4-7) (#906)

* refactor(frontend): extract useOverlayState hook from EditorLayout

* refactor(frontend): extract useStackActions hook and wire useOverlayState into EditorLayout

* refactor(frontend): fix quality issues in useStackActions post-review

* fix(frontend): fix interval leak, RunResult contract, yml hardcode, and loadFile length in useStackActions

* refactor(frontend): extract useSidebarContextMenu hook from EditorLayout

* refactor(frontend): extract ShellOverlays component from EditorLayout

* refactor(frontend): relocate Monaco layout effect and log-viewer event listener out of EditorLayout

The Monaco tab-switch layout effect is now self-contained in EditorView,
alongside its monacoEditorRef. The SENCHO_OPEN_LOGS_EVENT listener moves
into useOverlayState, where openLogViewer lives. EditorLayout is left with
the two coordination effects that depend on cross-hook state.
This commit is contained in:
Anso
2026-05-04 07:43:02 -04:00
committed by GitHub
parent 34fcb2591f
commit 1cf996142b
7 changed files with 1491 additions and 1102 deletions
@@ -0,0 +1,144 @@
import { useCallback } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
import type { Label as StackLabel, LabelColor } from '../../label-types';
import type { OverlayState } from './useOverlayState';
import type { StackActionsHook } from './useStackActions';
import type { useStackListState } from './useStackListState';
import type { useViewNavigationState } from './useViewNavigationState';
import type { Node } from '@/context/NodeContext';
import type { PermissionAction } from '@/context/AuthContext';
type StackListState = ReturnType<typeof useStackListState>;
type NavState = ReturnType<typeof useViewNavigationState>;
interface UseSidebarContextMenuOptions {
stackListState: StackListState;
navState: NavState;
overlayState: OverlayState;
stackActions: StackActionsHook;
activeNode: Node | null | undefined;
isPaid: boolean;
isAdmiral: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
}
export function useSidebarContextMenu({
stackListState,
navState,
overlayState,
stackActions,
activeNode,
isPaid,
isAdmiral,
can,
}: UseSidebarContextMenuOptions) {
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
const sName = file.replace(/\.(yml|yaml)$/, '');
return {
stackStatus: (stackListState.stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown',
hasPort: Boolean(stackListState.stackPorts[file]),
isBusy: stackListState.isStackBusy(file),
isPaid,
isAdmiral,
canDelete: can('stack:delete', 'stack', sName),
isPinned: stackListState.isPinned(file),
labels: stackListState.labels,
assignedLabelIds: (stackListState.stackLabelMap[file] ?? []).map(l => l.id),
menuVisibility: stackActions.getStackMenuVisibility(file),
autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true,
openAlertSheet: () => overlayState.openAlertSheet(file),
openAutoHeal: () => overlayState.setAutoHealStackName(file),
checkUpdates: () => stackActions.checkUpdatesForStack(),
openStackApp: () => stackActions.openStackApp(file),
deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'),
stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'),
restart: () => stackActions.executeStackActionByFile(file, 'restart', 'restart'),
update: () => stackActions.executeStackActionByFile(file, 'update', 'update'),
remove: () => overlayState.openDeleteDialog(sName),
pin: () => stackListState.pin(file),
unpin: () => stackListState.unpin(file),
setAutoUpdateEnabled: async (enabled: boolean) => {
stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: enabled }));
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(sName)}/auto-update`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string })?.error || 'Failed to update auto-update setting.');
}
} catch (err: unknown) {
stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: !enabled }));
toast.error((err as Error)?.message || 'Failed to update auto-update setting.');
}
},
toggleLabel: async (labelId: number) => {
const currentIds = (stackListState.stackLabelMap[file] ?? []).map(l => l.id);
const assigned = currentIds.includes(labelId);
const newIds = assigned ? currentIds.filter(id => id !== labelId) : [...currentIds, labelId];
const loadingId = toast.loading('Updating labels...');
try {
const res = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, {
method: 'PUT',
body: JSON.stringify({ labelIds: newIds }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string })?.error || 'Failed to update labels.');
}
stackListState.refreshLabels();
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Failed to update labels.');
} finally {
toast.dismiss(loadingId);
}
},
createAndAssignLabel: async (name: string, color: LabelColor) => {
const loadingId = toast.loading('Creating label...');
try {
const createRes = await apiFetch('/labels', {
method: 'POST',
body: JSON.stringify({ name, color }),
});
if (!createRes.ok) {
const data = await createRes.json().catch(() => ({}));
throw new Error((data as { error?: string })?.error || 'Failed to create label.');
}
const created: StackLabel = await createRes.json();
const currentIds = (stackListState.stackLabelMap[file] ?? []).map(l => l.id);
const newIds = [...currentIds, created.id];
const assignRes = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, {
method: 'PUT',
body: JSON.stringify({ labelIds: newIds }),
});
if (!assignRes.ok) {
const data = await assignRes.json().catch(() => ({}));
throw new Error((data as { error?: string })?.error || 'Failed to assign label.');
}
toast.success(`Label "${created.name}" created.`);
stackListState.refreshLabels();
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Failed to create label.');
throw err;
} finally {
toast.dismiss(loadingId);
}
},
openLabelManager: () => navState.handleOpenSettings('labels'),
openScheduleTask: () => {
navState.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null });
navState.setActiveView('scheduled-ops');
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral,
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
stackListState.autoUpdateSettings, stackListState.pin, stackListState.unpin,
]);
return buildMenuCtx;
}