mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +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:
@@ -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