From 296ddff2a0b0a521ad717c40d43c22a85054a5ac Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 9 Jul 2026 12:05:36 -0400 Subject: [PATCH] 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. --- docs/features/deep-links.mdx | 17 +++- e2e/routing.spec.ts | 33 ++++++-- frontend/src/components/EditorLayout.tsx | 4 + .../components/EditorLayout/ViewRouter.tsx | 21 +++-- .../EditorLayout/hooks/useStackActions.ts | 4 + .../EditorLayout/hooks/useUrlSync.test.ts | 84 +++++++++++++++++++ .../EditorLayout/hooks/useUrlSync.ts | 24 ++++-- frontend/src/lib/router/readUrlRouteState.ts | 7 ++ frontend/src/lib/router/routeTypes.ts | 6 +- frontend/src/lib/router/senchoRoute.test.ts | 26 ++++++ frontend/src/lib/router/senchoRoute.ts | 18 ++-- 11 files changed, 216 insertions(+), 28 deletions(-) diff --git a/docs/features/deep-links.mdx b/docs/features/deep-links.mdx index 6dd7d0b3..5b7cd94e 100644 --- a/docs/features/deep-links.mdx +++ b/docs/features/deep-links.mdx @@ -13,7 +13,8 @@ Sencho encodes the active node, the view you are on, and the deep state that vie |-------------|--------------|---------------| | Home dashboard | `/nodes/local/dashboard` | The home dashboard for the `local` node | | Stack list (phone) | `/nodes/local/stacks` | The full-width stack list on a phone | -| Stack editor | `/nodes/local/stacks/radarr/compose` | Radarr's compose tab | +| Stack detail | `/nodes/local/stacks/radarr` | Radarr's stack detail (anatomy). Sidebar entry point | +| Compose editor | `/nodes/local/stacks/radarr/compose` | Radarr's compose.yaml Monaco editor | | Env tab + file | `/nodes/local/stacks/radarr/env?env=.env.prod` | Radarr's env tab with a specific env file selected | | Resources | `/nodes/local/resources` | Resources for the active node | | Security tab | `/nodes/local/security/images` | Security view on the Images tab | @@ -23,6 +24,18 @@ Sencho encodes the active node, the view you are on, and the deep state that vie Remote nodes use a slug derived from the node name and id (for example `/nodes/nas-box-42/dashboard`). The default local node keeps the short `local` slug. +## Stack detail vs compose editor + +Clicking a stack in the sidebar opens **stack detail** at `/nodes//stacks/` (anatomy on the right). Opening the compose, env, or files editor appends that tab to the path: + +- `/stacks/radarr` — detail +- `/stacks/radarr/compose` — compose.yaml editor +- `/stacks/radarr/env` — env editor +- `/stacks/radarr/files` — file browser (desktop) + +Closing the Monaco editor returns you to the detail URL for that stack. + + ## Env tab URLs Sencho encodes env file selection in the `?env=` query on the Env tab only: @@ -48,7 +61,7 @@ If a link points at a stack that cannot be loaded (for example the node is offli ## Back, Forward, and refresh - **Back / Forward** walk through the views you opened in order, including stack editor tabs where applicable. -- **Refresh** reloads the current URL and restores the same node, view, stack, and tab when the underlying data is available. +- **Refresh** reloads the current URL and restores the same node, view, stack, and surface (detail vs compose/env/files editor) when the underlying data is available. - **Unsaved edits** still block navigation. Sencho prompts before you leave a dirty compose or env buffer via Back, a sidebar link, or another stack. ## Phone layout diff --git a/e2e/routing.spec.ts b/e2e/routing.spec.ts index 7d140a4c..a28c7767 100644 --- a/e2e/routing.spec.ts +++ b/e2e/routing.spec.ts @@ -49,39 +49,58 @@ test.describe('URL routing', () => { await expect(page).toHaveURL(/\/nodes\/local\/dashboard/); }); - test('opening a stack writes a stack editor URL', async ({ page }) => { + test('opening a stack writes a stack detail URL', async ({ page }) => { const stackName = await firstStackName(page); test.skip(!stackName, 'No stacks available to open'); await page.locator('[role="listbox"]').getByText(stackName!, { exact: true }).click(); const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); - await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`)); + const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/?$`)); + await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible(); }); - test('refresh preserves a stack editor deep link', async ({ page }) => { + test('refresh preserves a stack detail deep link', async ({ page }) => { const stackName = await firstStackName(page); test.skip(!stackName, 'No stacks available to open'); const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); - await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`); + const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}`); await waitForStacksLoaded(page); - await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${slug}|${stackName}`)); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/?$`)); await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible(); await page.reload(); await waitForStacksLoaded(page); - await expect(page).toHaveURL(/\/nodes\/local\/stacks\//); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/?$`)); await expect(page.getByRole('tab', { name: 'Anatomy' })).toBeVisible(); }); + test('refresh preserves a compose editor deep link', async ({ page }) => { + const stackName = await firstStackName(page); + test.skip(!stackName, 'No stacks available to open'); + + const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); + const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/compose`); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/compose`)); + await page.reload(); + await waitForStacksLoaded(page); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/${escaped}/compose`)); + }); + test('compose editor env tab updates the URL', async ({ page }) => { const stackName = await firstStackName(page); test.skip(!stackName, 'No stacks available to open'); const slug = stackName!.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); await page.locator('[role="listbox"]').getByText(stackName!, { exact: true }).click(); - await expect(page).toHaveURL(/\/compose/); + await expect(page).toHaveURL(new RegExp(`/nodes/local/stacks/`)); + await expect(page).not.toHaveURL(/\/compose$/); await page.getByRole('button', { name: 'edit', exact: true }).click(); + await expect(page).toHaveURL(/\/compose/); const envTab = page.getByRole('tab', { name: '.env' }); test.skip(!(await envTab.isEnabled()), 'Stack has no .env file'); await envTab.click(); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 291b18cc..876755ee 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -349,6 +349,8 @@ export default function EditorLayout() { isFileLoading, activeTab, setActiveTab, + editingCompose, + setEditingCompose, selectedEnvFile, envFiles, loadFileForRoute: stackActions.loadFileForRoute, @@ -843,6 +845,8 @@ export default function EditorLayout() { onFleetActiveTabChange={setFleetActiveTab} renderEditor={renderEditor} stackUpdates={stackUpdates} + urlHydratingStack={urlHydratingStack} + isFileLoading={isFileLoading} /> ); diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 450e773c..27f1dd37 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -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; + 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({ ); } - // 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 ; + } } if (activeView === 'global-observability') { return ( diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 9cc07095..151eb2dc 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -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' diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts index d0eca702..e56853bd 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts @@ -55,6 +55,8 @@ function makeOpts(over: Partial = {}): 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'); + }); }); diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts index 512157af..846f8eda 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts @@ -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; @@ -98,10 +101,17 @@ interface PendingEditorRouteOpts { async function applyPendingEditorRoute( optsRef: MutableRefObject, pendingEnvRef: MutableRefObject, - tab: EditorTab, + tab: EditorTab | null, routeOpts?: PendingEditorRouteOpts, ): Promise { 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, diff --git a/frontend/src/lib/router/readUrlRouteState.ts b/frontend/src/lib/router/readUrlRouteState.ts index 9cc2cdaa..4d190751 100644 --- a/frontend/src/lib/router/readUrlRouteState.ts +++ b/frontend/src/lib/router/readUrlRouteState.ts @@ -19,6 +19,13 @@ const DEFAULT: UrlRouteState = { filterNodeId: null, }; +/** True when the current URL is a stack workspace deep link (detail or editor). */ +export function isStackEditorDeepLink(): boolean { + if (typeof window === 'undefined') return false; + const parsed = parsePath(window.location.pathname, window.location.search); + return parsed.view === 'editor' && parsed.stackName != null; +} + /** Read shell navigation fields from the current browser URL (cold-load bootstrap). */ export function readUrlRouteState(): UrlRouteState { if (typeof window === 'undefined') return DEFAULT; diff --git a/frontend/src/lib/router/routeTypes.ts b/frontend/src/lib/router/routeTypes.ts index 8e5d05df..6f822550 100644 --- a/frontend/src/lib/router/routeTypes.ts +++ b/frontend/src/lib/router/routeTypes.ts @@ -34,7 +34,11 @@ export interface RouteState { activeView: ActiveView; /** Stack directory name when activeView is editor or host-console. */ stackName: string | null; - editorTab: EditorTab; + /** + * Monaco editor tab when the compose/env/files surface is open. + * Null means stack detail (anatomy) at `/stacks/:name` with no tab segment. + */ + editorTab: EditorTab | null; envFile: string | null; securityTab: SecurityTab; fleetActiveTab: FleetTab; diff --git a/frontend/src/lib/router/senchoRoute.test.ts b/frontend/src/lib/router/senchoRoute.test.ts index bfee0ae5..aff603fe 100644 --- a/frontend/src/lib/router/senchoRoute.test.ts +++ b/frontend/src/lib/router/senchoRoute.test.ts @@ -131,4 +131,30 @@ describe('senchoRoute', () => { expect(parsed.isStackList).toBe(true); expect(parsed.stackName).toBeNull(); }); + + it('round-trips stack detail without a tab segment', () => { + const path = buildPath({ + ...base, + activeView: 'editor', + stackName: 'radarr', + editorTab: null, + }); + expect(path).toBe('/nodes/local/stacks/radarr'); + const parsed = parsePath(path, ''); + expect(parsed.view).toBe('editor'); + expect(parsed.stackName).toBe('radarr'); + expect(parsed.editorTab).toBeNull(); + }); + + it('parses compose tab as Monaco editor, not detail', () => { + const parsed = parsePath('/nodes/local/stacks/radarr/compose', ''); + expect(parsed.view).toBe('editor'); + expect(parsed.editorTab).toBe('compose'); + }); + + it('treats unknown stack tab segment as detail', () => { + const parsed = parsePath('/nodes/local/stacks/radarr/anatomy', ''); + expect(parsed.view).toBe('editor'); + expect(parsed.editorTab).toBeNull(); + }); }); diff --git a/frontend/src/lib/router/senchoRoute.ts b/frontend/src/lib/router/senchoRoute.ts index 34fdbb89..2a8fb737 100644 --- a/frontend/src/lib/router/senchoRoute.ts +++ b/frontend/src/lib/router/senchoRoute.ts @@ -95,16 +95,18 @@ export function parsePath(pathname: string, search: string): ParsedRoute { } const stackName = parts[3]; const tabRaw = parts[4]?.toLowerCase(); + // No tab segment → stack detail (anatomy). A valid tab opens Monaco. + // Unknown fifth segments are treated as detail, not as compose. const editorTab = tabRaw && EDITOR_TABS.has(tabRaw as EditorTab) ? (tabRaw as EditorTab) - : 'compose'; + : null; return { ...empty, nodeSlug, view: 'editor', stackName, editorTab, - envFile: envFile || null, + envFile: editorTab === 'env' ? (envFile || null) : null, filterNodeId, }; } @@ -138,10 +140,14 @@ export function buildPath(state: RouteState): string { const url = new URL('http://local'); if (state.activeView === 'editor' && state.stackName) { - const tab = state.editorTab || 'compose'; - url.pathname = `${base}/stacks/${encodeURIComponent(state.stackName)}/${tab}`; - if (tab === 'env' && state.envFile && !/[\\/]/.test(state.envFile)) { - url.searchParams.set('env', state.envFile); + const stackBase = `${base}/stacks/${encodeURIComponent(state.stackName)}`; + if (state.editorTab == null) { + url.pathname = stackBase; + } else { + url.pathname = `${stackBase}/${state.editorTab}`; + if (state.editorTab === 'env' && state.envFile && !/[\\/]/.test(state.envFile)) { + url.searchParams.set('env', state.envFile); + } } if (state.filterNodeId != null) { url.searchParams.set('node', String(state.filterNodeId));