From 1de9f5e4b24ff4d2cedade3275662f0d418cf993 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 8 Jul 2026 21:02:13 -0400 Subject: [PATCH] fix(routing): basename env URLs and defer writes during node hydration (#1598) * fix(routing): basename env URLs and defer writes during node hydration Encode only env file basenames in ?env= and omit the default file. Resolve legacy absolute-path bookmarks on load. Block history writes until the active node matches a cold-loaded remote deep link. * fix(routing): restore env deep links after stack load hydrates file list Defer env selection until envFiles is populated after loadFileForRoute. Apply default env when the URL omits ?env= (Back/popstate). Document env URL rules and legacy basename resolution in deep-links.mdx. --- docs/features/deep-links.mdx | 21 ++++++ .../EditorLayout/hooks/useStackActions.ts | 7 +- .../EditorLayout/hooks/useUrlSync.test.ts | 73 +++++++++++++++++++ .../EditorLayout/hooks/useUrlSync.ts | 63 +++++++++++----- frontend/src/lib/router/envRoute.test.ts | 56 ++++++++++++++ frontend/src/lib/router/envRoute.ts | 52 +++++++++++++ frontend/src/lib/router/senchoRoute.test.ts | 19 +++++ frontend/src/lib/router/senchoRoute.ts | 6 +- 8 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 frontend/src/lib/router/envRoute.test.ts create mode 100644 frontend/src/lib/router/envRoute.ts diff --git a/docs/features/deep-links.mdx b/docs/features/deep-links.mdx index 070af12e..6dd7d0b3 100644 --- a/docs/features/deep-links.mdx +++ b/docs/features/deep-links.mdx @@ -23,6 +23,17 @@ 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. +## Env tab URLs + +Sencho encodes env file selection in the `?env=` query on the Env tab only: + +- **Default file omitted:** when the stack's first env file is selected (usually `.env`), the URL is `/env` with no query parameter. +- **Non-default files use basenames only:** `?env=.env.prod`, never a server filesystem path. +- **Legacy bookmarks:** older links that used an absolute path in `?env=` still open the matching file by basename. Sencho normalizes the URL to basename form when the address bar updates. +- **Unknown basenames:** if `?env=` does not match any file on the stack, Sencho opens the default env file. + +Compose and Files routes never carry `?env=`. + ## Node slugs - The primary local node is always `/nodes/local/...`. @@ -47,8 +58,18 @@ On a phone, Home and the stack list are distinct URLs even though both relate to - `/nodes/local/dashboard` opens the home dashboard. - `/nodes/local/stacks` opens the stack list. +On desktop, opening the stack list at `/nodes/local/stacks` canonicalizes to `/nodes/local/dashboard` because the sidebar stack list is part of the home layout on a wide screen. + Settings follows the same list/detail split: `/nodes/local/settings` is the section list; `/nodes/local/settings/
` opens a section directly. +On a phone, `/nodes/local/stacks//files` opens the compose editor instead. Sencho does not expose a separate file-browser URL on a phone. + +## What is not in the URL + +Some in-app state is intentionally not encoded: + +- **Stack Anatomy sub-tabs** (Anatomy, Activity, Dossier, Drift, Environment, and the rest) stay in memory only. Refresh returns you to the default Anatomy tab for that stack. + ## Tips - Use the global search palette (Ctrl+K) as today; navigation still updates the URL when you pick a page, node, or stack. diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index d1898dd4..0cacfa91 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -9,6 +9,7 @@ import type { OverlayState } from './useOverlayState'; import type { Node } from '@/context/NodeContext'; import type { RunWithLogParams } from '@/context/DeployFeedbackContext'; import { parsePath } from '@/lib/router/senchoRoute'; +import { resolveEnvFilePath } from '@/lib/router/envRoute'; import type { EditorTab } from '@/lib/router/routeTypes'; import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView'; import type { NotificationItem } from '../../dashboard/types'; @@ -1249,9 +1250,11 @@ export function useStackActions(options: UseStackActionsOptions) { parsed.editorTab === 'env' && editorState.activeTab === 'env' && parsed.envFile - && parsed.envFile !== editorState.selectedEnvFile ) { - return isEnvDirty(); + const resolved = resolveEnvFilePath(parsed.envFile, editorState.envFiles); + if (resolved && resolved !== editorState.selectedEnvFile) { + return isEnvDirty(); + } } return false; }; diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts index d9f59451..36606af4 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts @@ -97,6 +97,79 @@ describe('useUrlSync', () => { expect(window.location.pathname).toBe('/nodes/local/security'); }); + it('does not write local node URL while hydrating a remote node deep link', () => { + const remote = makeNode({ id: 2, name: 'nas', type: 'remote', is_default: false }); + const local = makeNode(); + const setActiveNode = vi.fn(); + const pushSpy = vi.spyOn(window.history, 'pushState'); + + window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/nas-2/fleet/snapshots'); + + act(() => { + renderHook( + (props) => useUrlSync(props), + { + initialProps: makeOpts({ + nodes: [local, remote], + activeNode: local, + activeView: 'fleet', + fleetActiveTab: 'snapshots', + setActiveNode, + }), + }, + ); + }); + + const badPush = pushSpy.mock.calls.find((call) => String(call[2]).includes('/nodes/local/')); + expect(badPush).toBeUndefined(); + expect(setActiveNode).toHaveBeenCalledWith(remote); + + pushSpy.mockRestore(); + }); + + it('restores non-default env selection after stack load populates file list', async () => { + const prodPath = '/compose/radarr/.env.prod'; + const fileList = ['/compose/radarr/.env', prodPath]; + const loadFileForRoute = vi.fn().mockResolvedValue(true); + const changeEnvFile = vi.fn().mockResolvedValue(undefined); + + window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/env?env=.env.prod'); + + const { rerender } = renderHook( + (props) => useUrlSync(props), + { + initialProps: makeOpts({ + activeView: 'editor', + files: ['radarr'], + selectedFile: null, + envFiles: [], + loadFileForRoute, + changeEnvFile, + }), + }, + ); + + await act(async () => { + await Promise.resolve(); + }); + expect(loadFileForRoute).toHaveBeenCalledWith('radarr'); + + rerender(makeOpts({ + activeView: 'editor', + files: ['radarr'], + selectedFile: 'radarr', + envFiles: fileList, + loadFileForRoute, + changeEnvFile, + })); + + await act(async () => { + await Promise.resolve(); + }); + + expect(changeEnvFile).toHaveBeenCalledWith(prodPath); + }); + it('pushState increments senchoIdx on user navigation', () => { const pushSpy = vi.spyOn(window.history, 'pushState'); diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts index a8a9fed2..54e8df57 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.ts @@ -1,9 +1,10 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; +import { useEffect, useRef, useCallback, useState, type MutableRefObject } from 'react'; import type { Node } from '@/context/NodeContext'; import type { FleetTab, SecurityTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; import type { ActiveView, EditorTab, MobileRouteSurface } from '@/lib/router/routeTypes'; import { buildPath, parsePath } from '@/lib/router/senchoRoute'; +import { envFileForRouteUrl, resolveEnvRouteTarget } from '@/lib/router/envRoute'; import { nodeIdToSlug, slugToNodeId } from '@/lib/nodeSlug'; import { authzReady, @@ -85,6 +86,29 @@ function readIdx(state: unknown): number | null { return null; } +async function applyPendingEditorRoute( + optsRef: MutableRefObject, + pendingEnvRef: MutableRefObject, + tab: EditorTab, +): Promise { + const live = optsRef.current; + if (tab !== 'env') { + pendingEnvRef.current = null; + live.setActiveTab(tab); + live.applyEditorRouteState(tab); + return true; + } + const outcome = resolveEnvRouteTarget(pendingEnvRef.current, live.envFiles); + if (!outcome.ready) return false; + pendingEnvRef.current = null; + if (outcome.target && outcome.target !== live.selectedEnvFile) { + await live.changeEnvFile(outcome.target); + } + live.setActiveTab(tab); + live.applyEditorRouteState(tab); + return true; +} + export function useUrlSync(options: UseUrlSyncOptions) { const optsRef = useRef(options); optsRef.current = options; @@ -102,6 +126,7 @@ export function useUrlSync(options: UseUrlSyncOptions) { const initialHydratedRef = useRef(false); const routeReplaceRef = useRef(false); const appliedViewRef = useRef(null); + const pendingNodeIdRef = useRef(null); const resolvingRef = useRef(false); const writeHistory = useCallback((path: string, intent: RouteIntent) => { @@ -129,7 +154,7 @@ export function useUrlSync(options: UseUrlSyncOptions) { activeView: view, stackName: o.selectedFile, editorTab: o.activeTab, - envFile: o.selectedEnvFile || null, + envFile: envFileForRouteUrl(o.selectedEnvFile, o.envFiles, o.activeTab), securityTab: o.securityTab, fleetActiveTab: o.fleetActiveTab, settingsSection: o.isMobile ? o.mobileSettingsSection : o.settingsSection, @@ -179,7 +204,9 @@ export function useUrlSync(options: UseUrlSyncOptions) { setUrlHydratingStack(null); setRouteDetailError(null); pendingRouteRef.current = null; - if (o.activeView === view) { + const nodeReady = pendingNodeIdRef.current == null || o.activeNode?.id === pendingNodeIdRef.current; + if (o.activeView === view && nodeReady) { + pendingNodeIdRef.current = null; appliedViewRef.current = null; phaseRef.current = 'settled'; } else { @@ -222,19 +249,15 @@ 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 env = pendingEnvRef.current; const tab = pendingTabRef.current ?? 'compose'; - if (env && o.envFiles.includes(env) && env !== o.envFiles[0]) { - await o.changeEnvFile(env); - } - o.setActiveTab(tab); - o.applyEditorRouteState(tab); + const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab); + if (!applied) return; pendingStackRef.current = null; - pendingEnvRef.current = null; pendingTabRef.current = null; pendingRouteRef.current = null; setUrlHydratingStack(null); setRouteDetailError(null); + pendingNodeIdRef.current = null; phaseRef.current = 'settled'; return; } @@ -252,20 +275,19 @@ export function useUrlSync(options: UseUrlSyncOptions) { return; } - const env = pendingEnvRef.current; const tab = pendingTabRef.current ?? 'compose'; - if (env && o.envFiles.includes(env) && env !== o.envFiles[0]) { - await o.changeEnvFile(env); + const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab); + if (!applied) { + resolvingRef.current = false; + return; } - o.setActiveTab(tab); - o.applyEditorRouteState(tab); pendingStackRef.current = null; - pendingEnvRef.current = null; pendingTabRef.current = null; pendingRouteRef.current = null; setUrlHydratingStack(null); setRouteDetailError(null); + pendingNodeIdRef.current = null; phaseRef.current = 'settled'; resolvingRef.current = false; }, []); @@ -343,8 +365,11 @@ export function useUrlSync(options: UseUrlSyncOptions) { phaseRef.current = 'applying'; if (o.activeNode?.id !== node.id) { + pendingNodeIdRef.current = node.id; + routeReplaceRef.current = true; o.setActiveNode(node); } else { + pendingNodeIdRef.current = null; void applyPendingRoute(); } }, [applyPendingRoute, writeHistory]); @@ -390,17 +415,20 @@ export function useUrlSync(options: UseUrlSyncOptions) { useEffect(() => { if (phaseRef.current !== 'applying') return; if (pendingStackRef.current) return; + if (pendingNodeIdRef.current != null && options.activeNode?.id !== pendingNodeIdRef.current) return; const applied = appliedViewRef.current; if (applied == null) return; if (options.activeView !== applied) return; appliedViewRef.current = null; + pendingNodeIdRef.current = null; phaseRef.current = 'settled'; - }, [options.activeView]); + }, [options.activeView, options.activeNode?.id]); useEffect(() => { if (phaseRef.current !== 'settled') return; if (options.isFileLoading) return; if (pendingStackRef.current) return; + if (pendingNodeIdRef.current != null && options.activeNode?.id !== pendingNodeIdRef.current) return; if (options.activeView === 'editor' && !options.selectedFile) return; const target = buildCurrentPath(); @@ -416,6 +444,7 @@ export function useUrlSync(options: UseUrlSyncOptions) { options.selectedFile, options.activeTab, options.selectedEnvFile, + options.envFiles, options.securityTab, options.fleetActiveTab, options.settingsSection, diff --git a/frontend/src/lib/router/envRoute.test.ts b/frontend/src/lib/router/envRoute.test.ts new file mode 100644 index 00000000..af849341 --- /dev/null +++ b/frontend/src/lib/router/envRoute.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { + envFileBasename, + envFileForRouteUrl, + normalizeEnvFileQuery, + resolveEnvFilePath, + resolveEnvRouteTarget, +} from './envRoute'; + +const envFiles = [ + '/home/user/compose/radarr/.env', + '/home/user/compose/radarr/.env.prod', +]; + +describe('envRoute', () => { + it('extracts basename from absolute paths', () => { + expect(envFileBasename('/home/user/compose/radarr/.env.prod')).toBe('.env.prod'); + expect(envFileBasename('C:\\compose\\stack\\.env')).toBe('.env'); + }); + + it('resolves basename and legacy absolute URL tokens to full paths', () => { + expect(resolveEnvFilePath('.env.prod', envFiles)).toBe('/home/user/compose/radarr/.env.prod'); + expect(resolveEnvFilePath('/home/user/compose/radarr/.env.prod', envFiles)).toBe( + '/home/user/compose/radarr/.env.prod', + ); + expect(resolveEnvFilePath('.env.missing', envFiles)).toBeNull(); + }); + + it('omits default env file from route URLs', () => { + expect(envFileForRouteUrl('/home/user/compose/radarr/.env', envFiles, 'env')).toBeNull(); + expect(envFileForRouteUrl('/home/user/compose/radarr/.env.prod', envFiles, 'env')).toBe('.env.prod'); + expect(envFileForRouteUrl('/home/user/compose/radarr/.env.prod', envFiles, 'compose')).toBeNull(); + }); + + it('normalizes legacy absolute env query values to basenames', () => { + expect(normalizeEnvFileQuery('/home/user/compose/radarr/.env.prod')).toBe('.env.prod'); + expect(normalizeEnvFileQuery('.env.prod')).toBe('.env.prod'); + expect(normalizeEnvFileQuery('')).toBeNull(); + }); + + it('resolveEnvRouteTarget waits for envFiles and falls back to default', () => { + expect(resolveEnvRouteTarget('.env.prod', [])).toEqual({ ready: false }); + expect(resolveEnvRouteTarget('.env.prod', envFiles)).toEqual({ + ready: true, + target: '/home/user/compose/radarr/.env.prod', + }); + expect(resolveEnvRouteTarget(null, envFiles)).toEqual({ + ready: true, + target: '/home/user/compose/radarr/.env', + }); + expect(resolveEnvRouteTarget('.env.missing', envFiles)).toEqual({ + ready: true, + target: '/home/user/compose/radarr/.env', + }); + }); +}); diff --git a/frontend/src/lib/router/envRoute.ts b/frontend/src/lib/router/envRoute.ts new file mode 100644 index 00000000..fcaa7007 --- /dev/null +++ b/frontend/src/lib/router/envRoute.ts @@ -0,0 +1,52 @@ +import type { EditorTab } from './routeTypes'; + +/** Leaf name of an env file path (handles Windows separators). */ +export function envFileBasename(file: string): string { + const normalized = file.replace(/\\/g, '/'); + const leaf = normalized.slice(normalized.lastIndexOf('/') + 1); + return leaf || file; +} + +/** Map a URL env token (basename or legacy absolute path) to a full env file path. */ +export function resolveEnvFilePath(requested: string | null, envFiles: string[]): string | null { + if (!requested || envFiles.length === 0) return null; + if (envFiles.includes(requested)) return requested; + const want = envFileBasename(requested); + if (!want) return null; + return envFiles.find((f) => envFileBasename(f) === want) ?? null; +} + +/** Env query value for the URL: basename only, omitted when the default file is selected. */ +export function envFileForRouteUrl( + selectedEnvFile: string, + envFiles: string[], + activeTab: EditorTab, +): string | null { + if (activeTab !== 'env' || !selectedEnvFile) return null; + const first = envFiles[0]; + if (first && selectedEnvFile === first) return null; + const basename = envFileBasename(selectedEnvFile); + if (!basename || basename.includes('/')) return null; + return basename; +} + +/** Normalize a parsed env query to a basename (backward compat for bookmarked absolute paths). */ +export function normalizeEnvFileQuery(raw: string | null): string | null { + if (!raw) return null; + const basename = envFileBasename(raw); + if (!basename || basename.length > 256) return null; + return basename; +} + +export type EnvRouteTarget = + | { ready: false } + | { ready: true; target: string | null }; + +/** Pick the env file to open from a route token once envFiles are loaded. */ +export function resolveEnvRouteTarget(requested: string | null, envFiles: string[]): EnvRouteTarget { + if (envFiles.length === 0) return { ready: false }; + const defaultFile = envFiles[0]; + if (!requested) return { ready: true, target: defaultFile }; + const resolved = resolveEnvFilePath(requested, envFiles); + return { ready: true, target: resolved ?? defaultFile }; +} diff --git a/frontend/src/lib/router/senchoRoute.test.ts b/frontend/src/lib/router/senchoRoute.test.ts index f8a5ad18..96e9a2b6 100644 --- a/frontend/src/lib/router/senchoRoute.test.ts +++ b/frontend/src/lib/router/senchoRoute.test.ts @@ -96,6 +96,25 @@ describe('senchoRoute', () => { expect(path).toBe('/nodes/local/settings/appearance'); }); + it('parses legacy absolute env query as basename', () => { + const parsed = parsePath( + '/nodes/local/stacks/radarr/env', + '?env=%2Fhome%2Fuser%2Fcompose%2Fradarr%2F.env.prod', + ); + expect(parsed.envFile).toBe('.env.prod'); + }); + + it('rejects absolute paths in buildPath env query', () => { + const path = buildPath({ + ...base, + activeView: 'editor', + stackName: 'radarr', + editorTab: 'env', + envFile: '/home/user/compose/radarr/.env.prod', + }); + expect(path).toBe('/nodes/local/stacks/radarr/env'); + }); + it('parses stack list path as mobile list surface', () => { const parsed = parsePath('/nodes/local/stacks', ''); expect(parsed.isStackList).toBe(true); diff --git a/frontend/src/lib/router/senchoRoute.ts b/frontend/src/lib/router/senchoRoute.ts index 53a0afca..343f3552 100644 --- a/frontend/src/lib/router/senchoRoute.ts +++ b/frontend/src/lib/router/senchoRoute.ts @@ -1,6 +1,7 @@ import type { FleetTab, SecurityTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; import type { ActiveView, EditorTab, ParsedRoute, RouteState } from './routeTypes'; +import { normalizeEnvFileQuery } from './envRoute'; const VIEW_SEGMENTS = { dashboard: 'dashboard', @@ -85,7 +86,8 @@ export function parsePath(pathname: string, search: string): ParsedRoute { } const filterNodeId = parseBoundedPositiveInt(params.get('node')); - const envFile = safeDecodeQueryValue(params.get('env') ?? ''); + const envRaw = safeDecodeQueryValue(params.get('env') ?? ''); + const envFile = normalizeEnvFileQuery(envRaw); if (segment === 'stacks') { if (parts.length === 3) { @@ -138,7 +140,7 @@ export function buildPath(state: RouteState): string { if (state.activeView === 'editor' && state.stackName) { const tab = state.editorTab || 'compose'; url.pathname = `${base}/stacks/${encodeURIComponent(state.stackName)}/${tab}`; - if (tab === 'env' && state.envFile) { + if (tab === 'env' && state.envFile && !state.envFile.includes('/')) { url.searchParams.set('env', state.envFile); } if (state.filterNodeId != null) {