mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -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/<section>` opens a section directly.
|
||||
|
||||
On a phone, `/nodes/local/stacks/<stack>/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 (<kbd>Ctrl</kbd>+<kbd>K</kbd>) as today; navigation still updates the URL when you pick a page, node, or stack.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<UseUrlSyncOptions>,
|
||||
pendingEnvRef: MutableRefObject<string | null>,
|
||||
tab: EditorTab,
|
||||
): Promise<boolean> {
|
||||
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<ActiveView | null>(null);
|
||||
const pendingNodeIdRef = useRef<number | null>(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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user