mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user