mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +00:00
fix(routing): split stack detail URL from compose editor URL (#1605)
Sidebar opens /stacks/:name (anatomy). Monaco uses /compose|/env|/files. Refresh of a detail URL no longer opens the editor, and editor deep links keep a hydration shell instead of flashing the dashboard.
This commit is contained in:
@@ -16,6 +16,7 @@ import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
import type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||
import { isStackEditorDeepLink } from '@/lib/router/readUrlRouteState';
|
||||
|
||||
// Paid-tier views are loaded on demand. Their internal PaidGate /
|
||||
// CapabilityGate wrappers render
|
||||
@@ -101,6 +102,8 @@ export interface ViewRouterProps {
|
||||
// not on every parent render that lands on a different view.
|
||||
renderEditor: () => ReactNode;
|
||||
stackUpdates: Record<string, StackUpdateInfo>;
|
||||
urlHydratingStack: string | null;
|
||||
isFileLoading: boolean;
|
||||
}
|
||||
|
||||
export function ViewRouter({
|
||||
@@ -131,6 +134,8 @@ export function ViewRouter({
|
||||
onFleetActiveTabChange,
|
||||
renderEditor,
|
||||
stackUpdates,
|
||||
urlHydratingStack,
|
||||
isFileLoading,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
if (activeView === 'settings') {
|
||||
@@ -175,12 +180,16 @@ export function ViewRouter({
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
// Fall-through: when activeView === 'editor' but selectedFile is
|
||||
// null or the stack is still loading, drop through to the default
|
||||
// HomeDashboard render below. This matches the pre-extraction
|
||||
// behavior of the conditional ternary chain in EditorLayout.tsx.
|
||||
if (!isLoading && selectedFile && activeView === 'editor') {
|
||||
return renderEditor();
|
||||
// Stack workspace: keep a loading shell while the stack URL hydrates.
|
||||
// Never fall through to HomeDashboard for editor deep links (refresh flash).
|
||||
if (activeView === 'editor') {
|
||||
if (selectedFile) {
|
||||
return renderEditor();
|
||||
}
|
||||
const awaitingStack = urlHydratingStack != null || isFileLoading || isStackEditorDeepLink();
|
||||
if (awaitingStack || isLoading) {
|
||||
return <ViewSkeleton />;
|
||||
}
|
||||
}
|
||||
if (activeView === 'global-observability') {
|
||||
return (
|
||||
|
||||
@@ -1248,6 +1248,10 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
if (targetView !== 'editor' || !targetStack || targetStack !== stackListState.selectedFile) {
|
||||
return isComposeDirty() || isEnvDirty();
|
||||
}
|
||||
// Leaving Monaco for stack detail on the same stack.
|
||||
if (editorState.editingCompose && parsed.editorTab == null) {
|
||||
return isComposeDirty() || isEnvDirty();
|
||||
}
|
||||
if (
|
||||
parsed.editorTab === 'env'
|
||||
&& editorState.activeTab === 'env'
|
||||
|
||||
@@ -55,6 +55,8 @@ function makeOpts(over: Partial<UseUrlSyncOptions> = {}): UseUrlSyncOptions {
|
||||
isFileLoading: false,
|
||||
activeTab: 'compose',
|
||||
setActiveTab: vi.fn(),
|
||||
editingCompose: false,
|
||||
setEditingCompose: vi.fn(),
|
||||
selectedEnvFile: '',
|
||||
envFiles: [],
|
||||
loadFileForRoute: vi.fn().mockResolvedValue({ ok: true, envFiles: [] }),
|
||||
@@ -494,4 +496,86 @@ describe('useUrlSync', () => {
|
||||
|
||||
expect(result.current.routeDetailError).toBeNull();
|
||||
});
|
||||
|
||||
it('hydrates tabless stack URL as detail without opening Monaco', async () => {
|
||||
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] });
|
||||
const applyEditorRouteState = vi.fn();
|
||||
const setEditingCompose = vi.fn();
|
||||
|
||||
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr');
|
||||
|
||||
renderHook(
|
||||
(props) => useUrlSync(props),
|
||||
{
|
||||
initialProps: makeOpts({
|
||||
activeView: 'editor',
|
||||
files: ['radarr'],
|
||||
selectedFile: null,
|
||||
envFiles: [],
|
||||
loadFileForRoute,
|
||||
applyEditorRouteState,
|
||||
setEditingCompose,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(loadFileForRoute).toHaveBeenCalledWith('radarr');
|
||||
expect(applyEditorRouteState).not.toHaveBeenCalled();
|
||||
expect(setEditingCompose).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('writes tabless stack URL when detail is open', () => {
|
||||
const pushSpy = vi.spyOn(window.history, 'pushState');
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(props) => useUrlSync(props),
|
||||
{ initialProps: makeOpts({ activeView: 'dashboard' }) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
rerender(makeOpts({
|
||||
activeView: 'editor',
|
||||
selectedFile: 'radarr',
|
||||
editingCompose: false,
|
||||
activeTab: 'compose',
|
||||
}));
|
||||
});
|
||||
|
||||
const pushed = pushSpy.mock.calls.map((call) => String(call[2] ?? ''));
|
||||
expect(pushed.some((p) => p === '/nodes/local/stacks/radarr')).toBe(true);
|
||||
expect(pushed.some((p) => p.includes('/compose'))).toBe(false);
|
||||
|
||||
pushSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opens Monaco when hydrating /compose deep link', async () => {
|
||||
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] });
|
||||
const applyEditorRouteState = vi.fn();
|
||||
|
||||
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
|
||||
|
||||
renderHook(
|
||||
(props) => useUrlSync(props),
|
||||
{
|
||||
initialProps: makeOpts({
|
||||
activeView: 'editor',
|
||||
files: ['radarr'],
|
||||
selectedFile: null,
|
||||
loadFileForRoute,
|
||||
applyEditorRouteState,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(loadFileForRoute).toHaveBeenCalledWith('radarr');
|
||||
expect(applyEditorRouteState).toHaveBeenCalledWith('compose');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@ interface HistoryState {
|
||||
interface PendingRoute {
|
||||
view: ActiveView;
|
||||
stackName: string | null;
|
||||
editorTab: EditorTab;
|
||||
/** Null = stack detail (anatomy); set = Monaco tab surface. */
|
||||
editorTab: EditorTab | null;
|
||||
envFile: string | null;
|
||||
securityTab: SecurityTab;
|
||||
fleetTab: FleetTab;
|
||||
@@ -58,6 +59,8 @@ export interface UseUrlSyncOptions {
|
||||
isFileLoading: boolean;
|
||||
activeTab: EditorTab;
|
||||
setActiveTab: (tab: EditorTab) => void;
|
||||
editingCompose: boolean;
|
||||
setEditingCompose: (editing: boolean) => void;
|
||||
selectedEnvFile: string;
|
||||
envFiles: string[];
|
||||
loadFileForRoute: (filename: string) => Promise<RouteStackLoadResult>;
|
||||
@@ -98,10 +101,17 @@ interface PendingEditorRouteOpts {
|
||||
async function applyPendingEditorRoute(
|
||||
optsRef: MutableRefObject<UseUrlSyncOptions>,
|
||||
pendingEnvRef: MutableRefObject<string | null>,
|
||||
tab: EditorTab,
|
||||
tab: EditorTab | null,
|
||||
routeOpts?: PendingEditorRouteOpts,
|
||||
): Promise<boolean> {
|
||||
const live = optsRef.current;
|
||||
// Tabless stack URL → detail (anatomy). Do not open Monaco.
|
||||
if (tab == null) {
|
||||
pendingEnvRef.current = null;
|
||||
live.setEditingCompose(false);
|
||||
live.setActiveTab('compose');
|
||||
return true;
|
||||
}
|
||||
if (tab !== 'env') {
|
||||
pendingEnvRef.current = null;
|
||||
live.setActiveTab(tab);
|
||||
@@ -122,6 +132,7 @@ async function applyPendingEditorRoute(
|
||||
pendingEnvRef.current = null;
|
||||
let effectiveTab: EditorTab = tab;
|
||||
if (outcome.target == null && envFiles.length === 0) {
|
||||
// Empty env inventory: stay on Monaco compose tab rather than detail.
|
||||
effectiveTab = 'compose';
|
||||
} else if (outcome.target && outcome.target !== live.selectedEnvFile) {
|
||||
await live.changeEnvFile(outcome.target);
|
||||
@@ -175,7 +186,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
|
||||
nodeSlug: slug,
|
||||
activeView: view,
|
||||
stackName: o.selectedFile,
|
||||
editorTab: o.activeTab,
|
||||
editorTab: o.editingCompose ? o.activeTab : null,
|
||||
envFile: envFileForRouteUrl(o.selectedEnvFile, o.envFiles, o.activeTab),
|
||||
securityTab: o.securityTab,
|
||||
fleetActiveTab: o.fleetActiveTab,
|
||||
@@ -273,7 +284,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
|
||||
// to avoid unmounting the editor (loadFileCore clears selectedFile on
|
||||
// failure, which would hide the recovery chip during a deploy).
|
||||
if (o.selectedFile === match) {
|
||||
const tab = pendingTabRef.current ?? 'compose';
|
||||
const tab = pendingTabRef.current;
|
||||
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, {
|
||||
envFiles: o.envFiles,
|
||||
inventoryReady: !o.isFileLoading,
|
||||
@@ -302,7 +313,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = pendingTabRef.current ?? 'compose';
|
||||
const tab = pendingTabRef.current;
|
||||
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, {
|
||||
envFiles: loadResult.envFiles,
|
||||
inventoryReady: true,
|
||||
@@ -384,7 +395,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
|
||||
pendingRouteRef.current = {
|
||||
view,
|
||||
stackName: parsed.stackName,
|
||||
editorTab: parsed.editorTab ?? 'compose',
|
||||
editorTab: parsed.editorTab,
|
||||
envFile: parsed.envFile,
|
||||
securityTab: parsed.securityTab ?? 'overview',
|
||||
fleetTab,
|
||||
@@ -473,6 +484,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
|
||||
options.activeView,
|
||||
options.selectedFile,
|
||||
options.activeTab,
|
||||
options.editingCompose,
|
||||
options.selectedEnvFile,
|
||||
options.envFiles,
|
||||
options.securityTab,
|
||||
|
||||
Reference in New Issue
Block a user