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
+15 -2
View File
@@ -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/<node>/stacks/<stack>` (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
+26 -7
View File
@@ -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();
+4
View File
@@ -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}
/>
</div>
);
@@ -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<string, StackUpdateInfo>;
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({
</PaidGate>
);
}
// 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 <ViewSkeleton />;
}
}
if (activeView === 'global-observability') {
return (
@@ -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'
@@ -55,6 +55,8 @@ function makeOpts(over: Partial<UseUrlSyncOptions> = {}): 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');
});
});
@@ -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<RouteStackLoadResult>;
@@ -98,10 +101,17 @@ interface PendingEditorRouteOpts {
async function applyPendingEditorRoute(
optsRef: MutableRefObject<UseUrlSyncOptions>,
pendingEnvRef: MutableRefObject<string | null>,
tab: EditorTab,
tab: EditorTab | null,
routeOpts?: PendingEditorRouteOpts,
): Promise<boolean> {
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,
@@ -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));