mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user