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:
Anso
2026-07-09 12:05:36 -04:00
committed by GitHub
parent 7517a4f49c
commit 296ddff2a0
11 changed files with 216 additions and 28 deletions
@@ -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;
+5 -1
View File
@@ -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;
@@ -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();
});
});
+12 -6
View File
@@ -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));