From 55ae30a0adcafa8c45bfa832545356a9717cce53 Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Mon, 9 Mar 2026 22:24:28 +0300 Subject: [PATCH 01/40] feat: add branch deployment selector UI for project admins Add a branch selector chip to the project header that lets admins switch between branch deployments directly in the cloud UI. Gated on `readDev` permission. - New `BranchSelector` component (chip + dropdown, modeled after `ViewAsUserChip`) - Read `?branch=X` query param in project layout, pass to `GetProject` API - Warning banner when viewing a non-production branch deployment - Branch param preserved across tab navigation via `branchSearchSuffix` - "Start deployment" button for stopped branch deployments - Deduplicates `ListDeployments` by branch; live status from `GetProject` - "View As" composes with branch context --- .../features/projects/BranchSelector.svelte | 192 ++++++++++++++++++ .../features/projects/ProjectHeader.svelte | 14 ++ .../src/features/projects/ProjectTabs.svelte | 3 +- .../projects/SlimProjectHeader.svelte | 19 +- .../[organization]/[project]/+layout.svelte | 103 +++++++++- 5 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 web-admin/src/features/projects/BranchSelector.svelte diff --git a/web-admin/src/features/projects/BranchSelector.svelte b/web-admin/src/features/projects/BranchSelector.svelte new file mode 100644 index 00000000000..be82d42aa76 --- /dev/null +++ b/web-admin/src/features/projects/BranchSelector.svelte @@ -0,0 +1,192 @@ + + +{#if hasBranchDeployments || isOnBranch} + + + { + void goto(productionHref); + }} + > +
+ + + + {displayLabel} +
+
+
+ + Branch deployments + + {#each sortedDeployments as deployment (deployment.id)} + {@const isProd = deployment.branch === primaryBranch} + {@const isSelected = isProd + ? !isOnBranch + : activeBranch === deployment.branch} + {@const statusLabel = getStatusLabel(deployment.status)} + {@const statusColor = getStatusColor(deployment.status)} + +
+ + + {isProd ? `${deployment.branch} (production)` : deployment.branch} + +
+ {#if statusLabel} + {statusLabel} + {/if} +
+ {/each} +
+
+{/if} diff --git a/web-admin/src/features/projects/ProjectHeader.svelte b/web-admin/src/features/projects/ProjectHeader.svelte index 93ac6c359f5..38954383951 100644 --- a/web-admin/src/features/projects/ProjectHeader.svelte +++ b/web-admin/src/features/projects/ProjectHeader.svelte @@ -17,6 +17,7 @@ import HeaderLogo from "@rilldata/web-common/layout/header/HeaderLogo.svelte"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import type { V1ProjectPermissions } from "../../client"; + import { type V1DeploymentStatus } from "../../client"; import { createAdminServiceGetCurrentUser, createAdminServiceGetDeploymentCredentials, @@ -25,6 +26,7 @@ useBreadcrumbOrgPaths, useBreadcrumbProjectPaths, } from "../navigation/breadcrumb-selectors"; + import BranchSelector from "./BranchSelector.svelte"; import ViewAsUserChip from "../../features/view-as-user/ViewAsUserChip.svelte"; import { viewAsUserStore } from "../../features/view-as-user/viewAsUserStore"; import CreateAlert from "../alerts/CreateAlert.svelte"; @@ -50,6 +52,9 @@ export let readProjects: boolean; export let planDisplayName: string | undefined; export let organizationLogoUrl: string | undefined; + export let activeBranch: string | undefined = undefined; + export let primaryBranch: string | undefined = undefined; + export let activeDeploymentStatus: V1DeploymentStatus | undefined = undefined; const user = createAdminServiceGetCurrentUser(); const runtimeClient = useRuntimeClient(); @@ -200,6 +205,15 @@ {/if}
+ {#if projectPermissions.readDev} + + {/if} {#if $viewAsUserStore} {/if} diff --git a/web-admin/src/features/projects/ProjectTabs.svelte b/web-admin/src/features/projects/ProjectTabs.svelte index dca951e792e..d1a75fdf46c 100644 --- a/web-admin/src/features/projects/ProjectTabs.svelte +++ b/web-admin/src/features/projects/ProjectTabs.svelte @@ -11,6 +11,7 @@ export let organization: string; export let project: string; export let pathname: string; + export let branchSearchSuffix: string = ""; const { chat, reports, alerts } = featureFlags; @@ -77,7 +78,7 @@ {#each tabs as tab, i (tab.route)} {#if tab.hasPermission}
+ {#if showBranchSelector} + + {/if} {#if $user.isSuccess} {#if $user.data?.user} diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 1679a13e62e..02256cf2e75 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -37,6 +37,9 @@ createAdminServiceGetCurrentUser, createAdminServiceGetDeploymentCredentials, createAdminServiceGetProject, + createAdminServiceStartDeployment, + getAdminServiceGetProjectQueryKey, + getAdminServiceListDeploymentsQueryKey, type RpcStatus, type V1GetProjectResponse, } from "@rilldata/web-admin/client"; @@ -60,6 +63,7 @@ import { metricsService } from "@rilldata/web-common/metrics/initMetrics"; import RuntimeProvider from "@rilldata/web-common/runtime-client/v2/RuntimeProvider.svelte"; import { RUNTIME_ACCESS_TOKEN_DEFAULT_TTL } from "@rilldata/web-common/runtime-client/constants"; + import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; import type { HTTPError } from "@rilldata/web-common/lib/errors"; import type { AuthContext } from "@rilldata/web-common/runtime-client/v2/runtime-client"; import type { CreateQueryOptions } from "@tanstack/svelte-query"; @@ -68,6 +72,7 @@ import { onDestroy } from "svelte"; const user = createAdminServiceGetCurrentUser(); + const startDeploymentMutation = createAdminServiceStartDeployment(); $: ({ url: { pathname }, @@ -75,6 +80,9 @@ data: pageData, } = $page); + // Branch selector: read from URL query param + $: activeBranch = $page.url.searchParams.get("branch") ?? undefined; + // Root layout data used by ProjectHeader / SlimProjectHeader $: organizationPermissions = pageData?.organizationPermissions ?? {}; $: planDisplayName = pageData?.planDisplayName; @@ -100,11 +108,18 @@ } }); - // Clear view-as state when unmounting (e.g., navigating to org page) + // Clear view-as state and branch banner when unmounting (e.g., navigating to org page) onDestroy(() => { viewAsUserStore.clear(); + eventBus.emit("remove-banner", "branch-preview"); }); + // Build a search string suffix to append to internal project links. + // This preserves the branch context across tab/breadcrumb navigations. + $: branchSearchSuffix = activeBranch + ? `?branch=${encodeURIComponent(activeBranch)}` + : ""; + $: onProjectPage = isProjectPage($page); $: onPublicURLPage = isPublicURLPage($page); $: onPublicReportOrAlertPage = @@ -117,10 +132,11 @@ * `GetProject` with default cookie-based auth. * This returns the deployment credentials for the current logged-in user. */ + $: branchParams = activeBranch ? { branch: activeBranch } : undefined; $: cookieProjectQuery = createAdminServiceGetProject( organization, project, - undefined, + branchParams, { query: baseGetProjectQueryOptions, }, @@ -152,7 +168,7 @@ createAdminServiceGetDeploymentCredentials( organization, project, - { userId: mockedUserId }, + { userId: mockedUserId, branch: activeBranch }, { query: { enabled: !!mockedUserId, @@ -211,6 +227,31 @@ } } + // Branch banner (must be after projectData is defined) + $: primaryBranch = projectData?.project?.primaryBranch; + $: isOnBranch = !!activeBranch && activeBranch !== primaryBranch; + $: if (isOnBranch) { + const productionUrl = new URL($page.url); + productionUrl.searchParams.delete("branch"); + eventBus.emit("add-banner", { + id: "branch-preview", + priority: 3, + message: { + type: "warning", + iconType: "alert", + message: `Viewing branch deployment: ${activeBranch}`, + includesHtml: true, + cta: { + text: "Back to production", + type: "link", + url: productionUrl.pathname + productionUrl.search, + }, + }, + }); + } else { + eventBus.emit("remove-banner", "branch-preview"); + } + $: error = projectError as HTTPError; $: authContext = ( @@ -254,6 +295,10 @@ readProjects={organizationPermissions?.readProjects} {planDisplayName} {organizationLogoUrl} + {activeBranch} + {primaryBranch} + showBranchSelector={!!effectiveProjectPermissions?.readDev} + activeDeploymentStatus={deploymentStatus} /> {#if onProjectPage && deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING} {/if} @@ -297,6 +346,10 @@ readProjects={organizationPermissions?.readProjects} {planDisplayName} {organizationLogoUrl} + {activeBranch} + {primaryBranch} + showBranchSelector={!!effectiveProjectPermissions?.readDev} + activeDeploymentStatus={deploymentStatus} /> {#if !projectData.deployment} @@ -311,6 +364,50 @@ ? projectData.deployment.statusMessage : "There was an error deploying your project. Please contact support."} /> + {:else if deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPED || deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPING} +
+

Deployment stopped

+

+ This branch deployment is not running. +

+ {#if effectiveProjectPermissions?.manageDev} + + {/if} +
{:else} {/if} From a5bfbb8b7aaac185566c14f79307bd31f03c631b Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Tue, 10 Mar 2026 18:16:17 +0300 Subject: [PATCH 02/40] refactor: move branch selector into avatar dropdown menu Move `BranchSelector` from a standalone header chip into the avatar button dropdown as a submenu (like "View as"), reducing visual clutter when viewing branch deployments. Replace `ProjectAccessControls` wrapper in `AvatarButton` with a `projectPermissions` prop passed from `ProjectHeader`, eliminating a redundant `GetProject` query. Clean up branch-related props from `SlimProjectHeader` and `ProjectHeader`. --- .../authentication/AvatarButton.svelte | 69 +++++++------ .../features/projects/BranchSelector.svelte | 99 ++++++------------- .../features/projects/ProjectHeader.svelte | 16 +-- .../projects/SlimProjectHeader.svelte | 19 +--- .../[organization]/[project]/+layout.svelte | 20 +--- 5 files changed, 74 insertions(+), 149 deletions(-) diff --git a/web-admin/src/features/authentication/AvatarButton.svelte b/web-admin/src/features/authentication/AvatarButton.svelte index 95dc0bb5d04..4078f54fd65 100644 --- a/web-admin/src/features/authentication/AvatarButton.svelte +++ b/web-admin/src/features/authentication/AvatarButton.svelte @@ -22,11 +22,16 @@ type UserLike, } from "@rilldata/web-common/features/help/initPylonChat"; import { posthogIdentify } from "@rilldata/web-common/lib/analytics/posthog"; - import { createAdminServiceGetCurrentUser } from "../../client"; - import ProjectAccessControls from "../projects/ProjectAccessControls.svelte"; + import { + createAdminServiceGetCurrentUser, + type V1ProjectPermissions, + } from "../../client"; + import BranchSelector from "../projects/BranchSelector.svelte"; import ViewAsUserPopover from "../view-as-user/ViewAsUserPopover.svelte"; import ThemeToggle from "@rilldata/web-common/features/themes/ThemeToggle.svelte"; + export let projectPermissions: V1ProjectPermissions | undefined = undefined; + const user = createAdminServiceGetCurrentUser(); let imgContainer: HTMLElement; @@ -83,35 +88,39 @@
- {#if params.organization && params.project} - - - - { - subMenuOpen = !subMenuOpen; + {#if params.organization && params.project && projectPermissions} + {#if projectPermissions.manageProject} + + { + subMenuOpen = !subMenuOpen; + }} + > + View as + + + { + subMenuOpen = false; + primaryMenuOpen = false; }} - > - View as - - - { - subMenuOpen = false; - primaryMenuOpen = false; - }} - /> - - - - + /> + + + {/if} + {#if projectPermissions.readDev} + { + primaryMenuOpen = false; + }} + /> + {/if} {#if params.dashboard} import { goto } from "$app/navigation"; import { page } from "$app/stores"; - import { Chip } from "@rilldata/web-common/components/chip"; import * as DropdownMenu from "@rilldata/web-common/components/dropdown-menu"; import { V1DeploymentStatus, + createAdminServiceGetProject, createAdminServiceListDeployments, type V1Deployment, } from "../../client"; export let organization: string; export let project: string; - export let activeBranch: string | undefined; - export let primaryBranch: string | undefined; - // The live deployment status from GetProject; overrides ListDeployments for the active branch - export let activeDeploymentStatus: V1DeploymentStatus | undefined = undefined; + export let onSelect: () => void = () => {}; - let active = false; + let subMenuOpen = false; + + $: activeBranch = $page.url.searchParams.get("branch") ?? undefined; + + $: projectQuery = createAdminServiceGetProject(organization, project); + $: primaryBranch = $projectQuery.data?.project?.primaryBranch; $: deploymentsQuery = createAdminServiceListDeployments( organization, @@ -32,7 +34,6 @@ $: rawDeployments = $deploymentsQuery.data?.deployments ?? []; // Deduplicate: keep only the most recently updated deployment per branch. - // Override the active branch's status with the live value from GetProject. $: deployments = (() => { const byBranch = new Map(); for (const d of rawDeployments) { @@ -42,35 +43,15 @@ byBranch.set(branch, d); } } - // Patch the active branch with the live status from GetProject - const activeDep = activeBranch - ? byBranch.get(activeBranch) - : byBranch.get(primaryBranch ?? ""); - if (activeDep && activeDeploymentStatus) { - byBranch.set(activeDep.branch ?? "", { - ...activeDep, - status: activeDeploymentStatus, - }); - } return [...byBranch.values()]; })(); - // Only show the selector if there are non-production branch deployments $: hasBranchDeployments = deployments.some( (d) => d.branch && d.branch !== primaryBranch, ); $: isOnBranch = !!activeBranch && activeBranch !== primaryBranch; - $: displayLabel = isOnBranch ? activeBranch : "Branches"; - - // Build the "back to production" href (strip ?branch from current URL) - $: productionHref = (() => { - const url = new URL($page.url); - url.searchParams.delete("branch"); - return url.pathname + url.search; - })(); - // Sort: production first, then alphabetically by branch name $: sortedDeployments = [...deployments].sort((a, b) => { const aIsProd = a.branch === primaryBranch; @@ -80,14 +61,15 @@ return (a.branch ?? "").localeCompare(b.branch ?? ""); }); - function branchHref(deployment: V1Deployment): string { + function handleSelect(deployment: V1Deployment) { const url = new URL($page.url); if (deployment.branch === primaryBranch || !deployment.branch) { url.searchParams.delete("branch"); } else { url.searchParams.set("branch", deployment.branch); } - return url.pathname + url.search; + void goto(url.pathname + url.search); + onSelect(); } function getStatusLabel( @@ -95,7 +77,7 @@ ): string | undefined { switch (status) { case V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING: - return undefined; // Don't show a label for running + return undefined; case V1DeploymentStatus.DEPLOYMENT_STATUS_PENDING: case V1DeploymentStatus.DEPLOYMENT_STATUS_UPDATING: return "pending"; @@ -125,41 +107,15 @@ {#if hasBranchDeployments || isOnBranch} - - - { - void goto(productionHref); - }} - > -
- - - - {displayLabel} -
-
-
- + { + subMenuOpen = !subMenuOpen; + }} > - Branch deployments - + Branch + + {#each sortedDeployments as deployment (deployment.id)} {@const isProd = deployment.branch === primaryBranch} {@const isSelected = isProd @@ -167,16 +123,17 @@ : activeBranch === deployment.branch} {@const statusLabel = getStatusLabel(deployment.status)} {@const statusColor = getStatusColor(deployment.status)} - handleSelect(deployment)} + class="flex items-center gap-x-2" >
- + {isProd ? `${deployment.branch} (production)` : deployment.branch}
@@ -185,8 +142,8 @@ >{statusLabel} {/if} -
+ {/each} -
-
+ + {/if} diff --git a/web-admin/src/features/projects/ProjectHeader.svelte b/web-admin/src/features/projects/ProjectHeader.svelte index 38954383951..4a0ce5a48d2 100644 --- a/web-admin/src/features/projects/ProjectHeader.svelte +++ b/web-admin/src/features/projects/ProjectHeader.svelte @@ -17,7 +17,6 @@ import HeaderLogo from "@rilldata/web-common/layout/header/HeaderLogo.svelte"; import { useRuntimeClient } from "@rilldata/web-common/runtime-client/v2"; import type { V1ProjectPermissions } from "../../client"; - import { type V1DeploymentStatus } from "../../client"; import { createAdminServiceGetCurrentUser, createAdminServiceGetDeploymentCredentials, @@ -26,7 +25,6 @@ useBreadcrumbOrgPaths, useBreadcrumbProjectPaths, } from "../navigation/breadcrumb-selectors"; - import BranchSelector from "./BranchSelector.svelte"; import ViewAsUserChip from "../../features/view-as-user/ViewAsUserChip.svelte"; import { viewAsUserStore } from "../../features/view-as-user/viewAsUserStore"; import CreateAlert from "../alerts/CreateAlert.svelte"; @@ -52,9 +50,6 @@ export let readProjects: boolean; export let planDisplayName: string | undefined; export let organizationLogoUrl: string | undefined; - export let activeBranch: string | undefined = undefined; - export let primaryBranch: string | undefined = undefined; - export let activeDeploymentStatus: V1DeploymentStatus | undefined = undefined; const user = createAdminServiceGetCurrentUser(); const runtimeClient = useRuntimeClient(); @@ -205,15 +200,6 @@ {/if}
- {#if projectPermissions.readDev} - - {/if} {#if $viewAsUserStore} {/if} @@ -271,7 +257,7 @@ {#if $user.isSuccess} {#if $user.data?.user} - + {:else} {/if} diff --git a/web-admin/src/features/projects/SlimProjectHeader.svelte b/web-admin/src/features/projects/SlimProjectHeader.svelte index 4459aeba7c9..db98cc36d41 100644 --- a/web-admin/src/features/projects/SlimProjectHeader.svelte +++ b/web-admin/src/features/projects/SlimProjectHeader.svelte @@ -2,27 +2,19 @@ import Breadcrumbs from "@rilldata/web-common/components/navigation/breadcrumbs/Breadcrumbs.svelte"; import Header from "@rilldata/web-common/layout/header/Header.svelte"; import HeaderLogo from "@rilldata/web-common/layout/header/HeaderLogo.svelte"; - import { - createAdminServiceGetCurrentUser, - type V1DeploymentStatus, - } from "../../client"; + import { createAdminServiceGetCurrentUser } from "../../client"; import { useBreadcrumbOrgPaths, useBreadcrumbProjectPaths, } from "../navigation/breadcrumb-selectors"; import AvatarButton from "../authentication/AvatarButton.svelte"; import SignIn from "../authentication/SignIn.svelte"; - import BranchSelector from "./BranchSelector.svelte"; export let organization: string; export let project: string; export let readProjects: boolean; export let planDisplayName: string | undefined; export let organizationLogoUrl: string | undefined; - export let activeBranch: string | undefined = undefined; - export let primaryBranch: string | undefined = undefined; - export let showBranchSelector: boolean = false; - export let activeDeploymentStatus: V1DeploymentStatus | undefined = undefined; const user = createAdminServiceGetCurrentUser(); @@ -48,15 +40,6 @@
- {#if showBranchSelector} - - {/if} {#if $user.isSuccess} {#if $user.data?.user} diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 02256cf2e75..46651de3430 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -131,12 +131,13 @@ /** * `GetProject` with default cookie-based auth. * This returns the deployment credentials for the current logged-in user. + * When `activeBranch` is set, the branch param is passed to GetProject + * so the API returns the branch deployment instead of production. */ - $: branchParams = activeBranch ? { branch: activeBranch } : undefined; $: cookieProjectQuery = createAdminServiceGetProject( organization, project, - branchParams, + activeBranch ? { branch: activeBranch } : undefined, { query: baseGetProjectQueryOptions, }, @@ -168,7 +169,7 @@ createAdminServiceGetDeploymentCredentials( organization, project, - { userId: mockedUserId, branch: activeBranch }, + { userId: mockedUserId }, { query: { enabled: !!mockedUserId, @@ -295,10 +296,6 @@ readProjects={organizationPermissions?.readProjects} {planDisplayName} {organizationLogoUrl} - {activeBranch} - {primaryBranch} - showBranchSelector={!!effectiveProjectPermissions?.readDev} - activeDeploymentStatus={deploymentStatus} /> {#if onProjectPage && deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_RUNNING} {#if !projectData.deployment} @@ -387,7 +377,7 @@ queryKey: getAdminServiceGetProjectQueryKey( organization, project, - branchParams, + activeBranch ? { branch: activeBranch } : undefined, ), }), queryClient.invalidateQueries({ From 9fe87661e85c45718014145a523590b11adfcef0 Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Tue, 10 Mar 2026 20:50:19 +0300 Subject: [PATCH 03/40] feat: migrate branch selector from query params to `@branch` path segments Replace `?branch=X` query params with `@branch` path segments for branch deployment previews (e.g., `/org/project/@feature-x/explore/dashboard`). - Add `reroute` hook to strip `@branch` before route matching - Add `beforeNavigate` hook to inject `@branch` into branch-unaware links - Add `branch-utils.ts` with path manipulation helpers - Use `~` encoding for `/` in branch names (git disallows `~` in refs) - Branch menu items render as `` tags for href preview on hover - Back/forward browser history works correctly (skip injection on popstate) --- .../features/projects/BranchSelector.svelte | 33 ++++++--- .../features/projects/ProjectHeader.svelte | 4 +- .../src/features/projects/ProjectTabs.svelte | 35 +++++---- web-admin/src/hooks.ts | 14 ++++ web-admin/src/lib/branch-utils.ts | 72 +++++++++++++++++++ .../[organization]/[project]/+layout.svelte | 57 +++++++++++---- 6 files changed, 176 insertions(+), 39 deletions(-) create mode 100644 web-admin/src/hooks.ts create mode 100644 web-admin/src/lib/branch-utils.ts diff --git a/web-admin/src/features/projects/BranchSelector.svelte b/web-admin/src/features/projects/BranchSelector.svelte index bf4822eeaa9..85c03087b30 100644 --- a/web-admin/src/features/projects/BranchSelector.svelte +++ b/web-admin/src/features/projects/BranchSelector.svelte @@ -1,7 +1,12 @@ {#if hasBranchDeployments || isOnBranch} - - { - subMenuOpen = !subMenuOpen; - }} - > - Branch - - - {#each sortedDeployments as deployment (deployment.id)} - {@const isProd = deployment.branch === primaryBranch} - {@const isSelected = isProd - ? !isOnBranch - : activeBranch === deployment.branch} - {@const statusLabel = getStatusLabel(deployment.status)} - {@const statusColor = getStatusColor(deployment.status)} - handleClick(deployment)} - class="flex items-center gap-x-2" - > -
- - - {isProd ? `${deployment.branch} (production)` : deployment.branch} - -
- {#if statusLabel} - {statusLabel} - {/if} -
- {/each} -
-
+
  • + + + + + + {#each sortedDeployments as deployment (deployment.id)} + {@const prod = isProd(deployment)} + {@const isSelected = prod + ? !isOnBranch + : activeBranch === deployment.branch} + handleClick(deployment)} + class="flex items-center gap-x-2" + > +
    + + + {deployment.branch ?? "main"} + + {#if prod} + + production + + {/if} +
    +
    + {/each} +
    +
    +
  • {/if} + + diff --git a/web-admin/src/features/projects/ProjectHeader.svelte b/web-admin/src/features/projects/ProjectHeader.svelte index e8435359da4..8e808b49972 100644 --- a/web-admin/src/features/projects/ProjectHeader.svelte +++ b/web-admin/src/features/projects/ProjectHeader.svelte @@ -31,6 +31,7 @@ import CreateAlert from "../alerts/CreateAlert.svelte"; import { useAlerts } from "../alerts/selectors"; import AvatarButton from "../authentication/AvatarButton.svelte"; + import BranchSelector from "./BranchSelector.svelte"; import SignIn from "../authentication/SignIn.svelte"; import LastRefreshedDate from "../dashboards/listing/LastRefreshedDate.svelte"; import { useDashboards } from "../dashboards/listing/selectors"; @@ -198,7 +199,13 @@ {#if onPublicURLPage} {:else if organization} - + + + {#if !onPublicURLPage && projectPermissions?.readDev} + + {/if} + + {/if}
    diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index b286ba988da..b9154972c0f 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -36,8 +36,6 @@ consumeSkipBranchInjection, extractBranchFromPath, injectBranchIntoPath, - removeBranchFromPath, - requestSkipBranchInjection, } from "@rilldata/web-admin/lib/branch-utils"; import { V1DeploymentStatus, @@ -71,7 +69,6 @@ import { metricsService } from "@rilldata/web-common/metrics/initMetrics"; import RuntimeProvider from "@rilldata/web-common/runtime-client/v2/RuntimeProvider.svelte"; import { RUNTIME_ACCESS_TOKEN_DEFAULT_TTL } from "@rilldata/web-common/runtime-client/constants"; - import { eventBus } from "@rilldata/web-common/lib/event-bus/event-bus"; import type { HTTPError } from "@rilldata/web-common/lib/errors"; import type { AuthContext } from "@rilldata/web-common/runtime-client/v2/runtime-client"; import type { CreateQueryOptions } from "@tanstack/svelte-query"; @@ -138,10 +135,9 @@ ); }); - // Clear view-as state and branch banner when unmounting (e.g., navigating to org page) + // Clear view-as state when unmounting (e.g., navigating to org page) onDestroy(() => { viewAsUserStore.clear(); - eventBus.emit("remove-banner", "branch-preview"); }); $: branchPrefix = branchPathPrefix(activeBranch); @@ -257,33 +253,6 @@ } } - // Branch banner (must be after projectData is defined) - $: primaryBranch = projectData?.project?.primaryBranch; - $: isOnBranch = !!activeBranch && activeBranch !== primaryBranch; - $: if (isOnBranch) { - const productionPathname = removeBranchFromPath($page.url.pathname); - eventBus.emit("add-banner", { - id: "branch-preview", - priority: 3, - message: { - type: "warning", - iconType: "alert", - message: `Viewing branch deployment: ${activeBranch}`, - includesHtml: true, - cta: { - text: "Back to production", - type: "link", - url: productionPathname + $page.url.search, - onClick: () => { - requestSkipBranchInjection(); - }, - }, - }, - }); - } else { - eventBus.emit("remove-banner", "branch-preview"); - } - $: error = projectError as HTTPError; $: authContext = ( diff --git a/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte b/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte index f9f7c17ab9d..d64faced295 100644 --- a/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte +++ b/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte @@ -27,6 +27,9 @@ {currentPath} isCurrentPage={depth === currentPage} /> + {#if depth === 1} + + {/if} {/if} {/each} From 658edce87363d4f5960d60602b3e771d84848866 Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 11 Mar 2026 14:13:38 +0300 Subject: [PATCH 05/40] test: add unit tests for branch-utils --- web-admin/src/lib/branch-utils.spec.ts | 205 +++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 web-admin/src/lib/branch-utils.spec.ts diff --git a/web-admin/src/lib/branch-utils.spec.ts b/web-admin/src/lib/branch-utils.spec.ts new file mode 100644 index 00000000000..8ae0686e4ba --- /dev/null +++ b/web-admin/src/lib/branch-utils.spec.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + extractBranchFromPath, + injectBranchIntoPath, + removeBranchFromPath, + branchPathPrefix, + requestSkipBranchInjection, + consumeSkipBranchInjection, +} from "./branch-utils"; + +describe("branch-utils", () => { + describe("extractBranchFromPath", () => { + it("returns undefined for production paths (no @branch)", () => { + expect(extractBranchFromPath("/acme/analytics")).toBeUndefined(); + expect( + extractBranchFromPath("/acme/analytics/explore/revenue-overview"), + ).toBeUndefined(); + }); + + it("extracts a simple branch name", () => { + expect( + extractBranchFromPath( + "/acme/analytics/@staging/explore/revenue-overview", + ), + ).toBe("staging"); + }); + + it("extracts branch from path with no trailing segments", () => { + expect( + extractBranchFromPath("/acme/analytics/@q4-dashboard-refresh"), + ).toBe("q4-dashboard-refresh"); + }); + + it("decodes branches containing / (encoded as ~)", () => { + expect( + extractBranchFromPath( + "/acme/analytics/@eric~revenue-metrics/explore/revenue-overview", + ), + ).toBe("eric/revenue-metrics"); + }); + + it("decodes percent-encoded characters", () => { + expect( + extractBranchFromPath( + "/acme/analytics/@WIP%20churn%20model/explore/churn", + ), + ).toBe("WIP churn model"); + }); + + it("handles branches with multiple / segments", () => { + expect( + extractBranchFromPath( + "/acme/analytics/@team~maya~funnel-rework/explore/conversion", + ), + ).toBe("team/maya/funnel-rework"); + }); + + it("returns undefined for @ in wrong position", () => { + expect( + extractBranchFromPath("/@staging/analytics/explore"), + ).toBeUndefined(); + expect(extractBranchFromPath("/acme/@staging/explore")).toBeUndefined(); + expect( + extractBranchFromPath("/acme/analytics/explore/@staging"), + ).toBeUndefined(); + }); + + it("returns undefined for empty pathname", () => { + expect(extractBranchFromPath("")).toBeUndefined(); + }); + + it("returns undefined for root path", () => { + expect(extractBranchFromPath("/")).toBeUndefined(); + }); + }); + + describe("injectBranchIntoPath", () => { + it("injects branch after the project segment", () => { + expect( + injectBranchIntoPath( + "/acme/analytics/explore/revenue-overview", + "staging", + ), + ).toBe("/acme/analytics/@staging/explore/revenue-overview"); + }); + + it("injects branch into a path with only org/project", () => { + expect( + injectBranchIntoPath("/acme/analytics", "q4-dashboard-refresh"), + ).toBe("/acme/analytics/@q4-dashboard-refresh"); + }); + + it("encodes / in branch names as ~", () => { + expect( + injectBranchIntoPath( + "/acme/analytics/explore/conversion", + "eric/revenue-metrics", + ), + ).toBe("/acme/analytics/@eric~revenue-metrics/explore/conversion"); + }); + + it("returns original path if fewer than 3 segments", () => { + expect(injectBranchIntoPath("/acme", "staging")).toBe("/acme"); + expect(injectBranchIntoPath("/", "staging")).toBe("/"); + }); + }); + + describe("removeBranchFromPath", () => { + it("removes @branch from the path", () => { + expect( + removeBranchFromPath( + "/acme/analytics/@staging/explore/revenue-overview", + ), + ).toBe("/acme/analytics/explore/revenue-overview"); + }); + + it("removes @branch when it is the last segment", () => { + expect(removeBranchFromPath("/acme/analytics/@staging")).toBe( + "/acme/analytics", + ); + }); + + it("returns the path unchanged if no @branch present", () => { + expect( + removeBranchFromPath("/acme/analytics/explore/revenue-overview"), + ).toBe("/acme/analytics/explore/revenue-overview"); + }); + + it("handles encoded branch names", () => { + expect( + removeBranchFromPath( + "/acme/analytics/@eric~revenue-metrics/explore/conversion", + ), + ).toBe("/acme/analytics/explore/conversion"); + }); + }); + + describe("round-trip: inject then extract", () => { + const branches = [ + "main", + "staging", + "eric/revenue-metrics", + "team/maya/funnel-rework", + "q4-dashboard-refresh", + ]; + const basePath = "/acme/analytics/explore/revenue-overview"; + + for (const branch of branches) { + it(`round-trips "${branch}"`, () => { + const injected = injectBranchIntoPath(basePath, branch); + expect(extractBranchFromPath(injected)).toBe(branch); + }); + } + }); + + describe("round-trip: inject then remove", () => { + it("restores the original path", () => { + const original = "/acme/analytics/explore/revenue-overview"; + const injected = injectBranchIntoPath(original, "eric/revenue-metrics"); + expect(removeBranchFromPath(injected)).toBe(original); + }); + }); + + describe("branchPathPrefix", () => { + it("returns empty string for undefined", () => { + expect(branchPathPrefix(undefined)).toBe(""); + }); + + it("returns empty string for empty string", () => { + expect(branchPathPrefix("")).toBe(""); + }); + + it("returns /@encoded-branch for a simple branch", () => { + expect(branchPathPrefix("staging")).toBe("/@staging"); + }); + + it("encodes / in branch names", () => { + expect(branchPathPrefix("eric/revenue-metrics")).toBe( + "/@eric~revenue-metrics", + ); + }); + }); + + describe("skipBranchInjection flag", () => { + beforeEach(() => { + consumeSkipBranchInjection(); + }); + + it("returns false when not requested", () => { + expect(consumeSkipBranchInjection()).toBe(false); + }); + + it("returns true after request, then resets", () => { + requestSkipBranchInjection(); + expect(consumeSkipBranchInjection()).toBe(true); + expect(consumeSkipBranchInjection()).toBe(false); + }); + + it("only fires once per request", () => { + requestSkipBranchInjection(); + consumeSkipBranchInjection(); + expect(consumeSkipBranchInjection()).toBe(false); + }); + }); +}); From fe65af0ba30c718a7976372336a0fcde9ef6a258 Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 11 Mar 2026 14:15:14 +0300 Subject: [PATCH 06/40] fix: use `isPending` instead of `isLoading` for TanStack Query v5 mutation --- web-admin/src/routes/[organization]/[project]/+layout.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index b9154972c0f..01bed986d44 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -363,7 +363,7 @@ {#if effectiveProjectPermissions?.manageDev} From 33d1ef9149060d7e598f8c47b54b73307d122efb Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 11 Mar 2026 15:23:34 +0300 Subject: [PATCH 07/40] fix: harden branch deployment UI - Add TTL-based auto-expiry (500ms) to skip-branch-injection flag to prevent stale flags from leaking across navigations - Extract stopped deployment UI into `BranchDeploymentStopped` component using the project's Button and CTA layout patterns - Show spinner for STOPPING state instead of the "Start deployment" button - Move `startDeploymentMutation` into the extracted component so it is not instantiated on every project page load - Replace hardcoded "main" fallback with `primaryBranch` from the project query - Add clarifying comments for ISO 8601 string comparison and breadcrumb depth assumption --- .../projects/BranchDeploymentStopped.svelte | 77 +++++++++++++++++++ .../features/projects/BranchSelector.svelte | 5 +- web-admin/src/lib/branch-utils.spec.ts | 13 +++- web-admin/src/lib/branch-utils.ts | 13 +++- .../[organization]/[project]/+layout.svelte | 56 +++----------- .../navigation/breadcrumbs/Breadcrumbs.svelte | 2 +- 6 files changed, 111 insertions(+), 55 deletions(-) create mode 100644 web-admin/src/features/projects/BranchDeploymentStopped.svelte diff --git a/web-admin/src/features/projects/BranchDeploymentStopped.svelte b/web-admin/src/features/projects/BranchDeploymentStopped.svelte new file mode 100644 index 00000000000..6c4499d0c73 --- /dev/null +++ b/web-admin/src/features/projects/BranchDeploymentStopped.svelte @@ -0,0 +1,77 @@ + + + + + {#if isStopping} +
    + +
    + Deployment is stopping... + {:else} + Deployment stopped +

    + This branch deployment is not running. +

    + {#if canManage} + + {/if} + {/if} +
    +
    diff --git a/web-admin/src/features/projects/BranchSelector.svelte b/web-admin/src/features/projects/BranchSelector.svelte index 4a369402c69..cf99f37c577 100644 --- a/web-admin/src/features/projects/BranchSelector.svelte +++ b/web-admin/src/features/projects/BranchSelector.svelte @@ -44,6 +44,7 @@ for (const d of rawDeployments) { const branch = d.branch ?? ""; const existing = byBranch.get(branch); + // updatedOn is an ISO 8601 timestamp; lexicographic comparison is correct. if (!existing || (d.updatedOn ?? "") > (existing.updatedOn ?? "")) { byBranch.set(branch, d); } @@ -72,7 +73,7 @@ : deployments.find((d) => d.branch === primaryBranch); $: triggerLabel = isOnBranch ? truncateBranch(activeBranch ?? "") - : (primaryBranch ?? "main"); + : truncateBranch(primaryBranch ?? ""); function truncateBranch(branch: string): string { if (branch.length <= 12) return branch; @@ -146,7 +147,7 @@ )}" /> - {deployment.branch ?? "main"} + {deployment.branch ?? primaryBranch ?? ""} {#if prod} diff --git a/web-admin/src/lib/branch-utils.spec.ts b/web-admin/src/lib/branch-utils.spec.ts index 8ae0686e4ba..afb6101b8ef 100644 --- a/web-admin/src/lib/branch-utils.spec.ts +++ b/web-admin/src/lib/branch-utils.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { extractBranchFromPath, injectBranchIntoPath, @@ -183,9 +183,14 @@ describe("branch-utils", () => { describe("skipBranchInjection flag", () => { beforeEach(() => { + vi.useFakeTimers(); consumeSkipBranchInjection(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("returns false when not requested", () => { expect(consumeSkipBranchInjection()).toBe(false); }); @@ -201,5 +206,11 @@ describe("branch-utils", () => { consumeSkipBranchInjection(); expect(consumeSkipBranchInjection()).toBe(false); }); + + it("expires after 500ms if not consumed", () => { + requestSkipBranchInjection(); + vi.advanceTimersByTime(501); + expect(consumeSkipBranchInjection()).toBe(false); + }); }); }); diff --git a/web-admin/src/lib/branch-utils.ts b/web-admin/src/lib/branch-utils.ts index 717b6e08f95..b8e36e1d9f7 100644 --- a/web-admin/src/lib/branch-utils.ts +++ b/web-admin/src/lib/branch-utils.ts @@ -58,15 +58,20 @@ export function branchPathPrefix(branch: string | undefined): string { * Shared flag: when set, the next `beforeNavigate` call in the project layout * will skip `@branch` injection. Used by the BranchSelector and the "Back to * production" banner to navigate to production without re-injection. + * + * Auto-expires after 500ms to prevent a stale flag from leaking if the + * expected navigation never fires (e.g., cancelled by another hook). */ -let _skipNext = false; +const SKIP_TTL_MS = 500; +let _skipRequestedAt = 0; export function requestSkipBranchInjection(): void { - _skipNext = true; + _skipRequestedAt = Date.now(); } export function consumeSkipBranchInjection(): boolean { - if (_skipNext) { - _skipNext = false; + if (_skipRequestedAt && Date.now() - _skipRequestedAt < SKIP_TTL_MS) { + _skipRequestedAt = 0; return true; } + _skipRequestedAt = 0; return false; } diff --git a/web-admin/src/routes/[organization]/[project]/+layout.svelte b/web-admin/src/routes/[organization]/[project]/+layout.svelte index 01bed986d44..7966b06d2aa 100644 --- a/web-admin/src/routes/[organization]/[project]/+layout.svelte +++ b/web-admin/src/routes/[organization]/[project]/+layout.svelte @@ -43,9 +43,6 @@ createAdminServiceGetCurrentUser, createAdminServiceGetDeploymentCredentials, createAdminServiceGetProject, - createAdminServiceStartDeployment, - getAdminServiceGetProjectQueryKey, - getAdminServiceListDeploymentsQueryKey, type RpcStatus, type V1GetProjectResponse, } from "@rilldata/web-admin/client"; @@ -55,6 +52,7 @@ isPublicReportPage, isPublicURLPage, } from "@rilldata/web-admin/features/navigation/nav-utils"; + import BranchDeploymentStopped from "@rilldata/web-admin/features/projects/BranchDeploymentStopped.svelte"; import ProjectBuilding from "@rilldata/web-admin/features/projects/ProjectBuilding.svelte"; import ProjectHeader from "@rilldata/web-admin/features/projects/ProjectHeader.svelte"; import ProjectTabs from "@rilldata/web-admin/features/projects/ProjectTabs.svelte"; @@ -77,7 +75,6 @@ import { onDestroy } from "svelte"; const user = createAdminServiceGetCurrentUser(); - const startDeploymentMutation = createAdminServiceStartDeployment(); $: ({ url: { pathname }, @@ -355,49 +352,14 @@ : "There was an error deploying your project. Please contact support."} /> {:else if deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPED || deploymentStatus === V1DeploymentStatus.DEPLOYMENT_STATUS_STOPPING} -
    -

    Deployment stopped

    -

    - This branch deployment is not running. -

    - {#if effectiveProjectPermissions?.manageDev} - - {/if} -
    + {:else} {/if} diff --git a/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte b/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte index d64faced295..bd21b81152a 100644 --- a/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte +++ b/web-common/src/components/navigation/breadcrumbs/Breadcrumbs.svelte @@ -27,7 +27,7 @@ {currentPath} isCurrentPage={depth === currentPage} /> - {#if depth === 1} + {#if depth === 1} {/if} {/if} From 4fa8fb709695179631ff5519d3b47508f2e85ea0 Mon Sep 17 00:00:00 2001 From: Eric P Green Date: Wed, 11 Mar 2026 15:31:29 +0300 Subject: [PATCH 08/40] refactor: consolidate branch code into `features/branches/` Move branch-related files into a dedicated feature directory and centralize logic that was scattered across the codebase: - Move BranchSelector, BranchDeploymentStopped, branch-utils to features/branches/ - Extract `getBranchRedirect` from the project layout's beforeNavigate hook - Reuse `removeBranchFromPath` in hooks.ts reroute (removes duplicate regex) --- .../BranchDeploymentStopped.svelte | 0 .../BranchSelector.svelte | 2 +- .../branches}/branch-utils.spec.ts | 80 +++++++++++++++++++ .../branches}/branch-utils.ts | 22 +++++ .../features/projects/ProjectHeader.svelte | 4 +- .../src/features/projects/ProjectTabs.svelte | 2 +- web-admin/src/hooks.ts | 8 +- .../[organization]/[project]/+layout.svelte | 26 +++--- 8 files changed, 122 insertions(+), 22 deletions(-) rename web-admin/src/features/{projects => branches}/BranchDeploymentStopped.svelte (100%) rename web-admin/src/features/{projects => branches}/BranchSelector.svelte (99%) rename web-admin/src/{lib => features/branches}/branch-utils.spec.ts (78%) rename web-admin/src/{lib => features/branches}/branch-utils.ts (78%) diff --git a/web-admin/src/features/projects/BranchDeploymentStopped.svelte b/web-admin/src/features/branches/BranchDeploymentStopped.svelte similarity index 100% rename from web-admin/src/features/projects/BranchDeploymentStopped.svelte rename to web-admin/src/features/branches/BranchDeploymentStopped.svelte diff --git a/web-admin/src/features/projects/BranchSelector.svelte b/web-admin/src/features/branches/BranchSelector.svelte similarity index 99% rename from web-admin/src/features/projects/BranchSelector.svelte rename to web-admin/src/features/branches/BranchSelector.svelte index cf99f37c577..b6ad0ea2690 100644 --- a/web-admin/src/features/projects/BranchSelector.svelte +++ b/web-admin/src/features/branches/BranchSelector.svelte @@ -7,7 +7,7 @@ injectBranchIntoPath, removeBranchFromPath, requestSkipBranchInjection, - } from "@rilldata/web-admin/lib/branch-utils"; + } from "./branch-utils"; import { V1DeploymentStatus, createAdminServiceGetProject, diff --git a/web-admin/src/lib/branch-utils.spec.ts b/web-admin/src/features/branches/branch-utils.spec.ts similarity index 78% rename from web-admin/src/lib/branch-utils.spec.ts rename to web-admin/src/features/branches/branch-utils.spec.ts index afb6101b8ef..13bade1f022 100644 --- a/web-admin/src/lib/branch-utils.spec.ts +++ b/web-admin/src/features/branches/branch-utils.spec.ts @@ -6,6 +6,7 @@ import { branchPathPrefix, requestSkipBranchInjection, consumeSkipBranchInjection, + getBranchRedirect, } from "./branch-utils"; describe("branch-utils", () => { @@ -181,6 +182,85 @@ describe("branch-utils", () => { }); }); + describe("getBranchRedirect", () => { + const org = "acme"; + const proj = "analytics"; + const branch = "staging"; + + it("returns redirect URL for a project-internal path missing @branch", () => { + expect( + getBranchRedirect( + "/acme/analytics/explore/revenue-overview", + "", + "", + branch, + org, + proj, + ), + ).toBe("/acme/analytics/@staging/explore/revenue-overview"); + }); + + it("preserves search params and hash", () => { + expect( + getBranchRedirect( + "/acme/analytics/explore/revenue-overview", + "?filter=us", + "#section", + branch, + org, + proj, + ), + ).toBe( + "/acme/analytics/@staging/explore/revenue-overview?filter=us#section", + ); + }); + + it("returns null if path already has @branch", () => { + expect( + getBranchRedirect( + "/acme/analytics/@staging/explore/revenue-overview", + "", + "", + branch, + org, + proj, + ), + ).toBeNull(); + }); + + it("returns null for paths outside the project", () => { + expect( + getBranchRedirect( + "/other-org/other-project", + "", + "", + branch, + org, + proj, + ), + ).toBeNull(); + }); + + it("returns null for public share URLs", () => { + expect( + getBranchRedirect( + "/acme/analytics/-/share/abc123", + "", + "", + branch, + org, + proj, + ), + ).toBeNull(); + }); + + it("handles the bare project path", () => { + expect( + getBranchRedirect("/acme/analytics", "", "", branch, org, proj), + ).toBe("/acme/analytics/@staging"); + }); + }); + describe("skipBranchInjection flag", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/web-admin/src/lib/branch-utils.ts b/web-admin/src/features/branches/branch-utils.ts similarity index 78% rename from web-admin/src/lib/branch-utils.ts rename to web-admin/src/features/branches/branch-utils.ts index b8e36e1d9f7..8fcab82e671 100644 --- a/web-admin/src/lib/branch-utils.ts +++ b/web-admin/src/features/branches/branch-utils.ts @@ -54,6 +54,28 @@ export function branchPathPrefix(branch: string | undefined): string { return `/@${encodeBranch(branch)}`; } +/** + * Given a navigation target, returns the branch-injected URL string if + * the navigation should be redirected, or null if no redirect is needed. + * + * Used by the project layout's `beforeNavigate` hook to transparently + * preserve the active `@branch` segment on project-internal navigations. + */ +export function getBranchRedirect( + toPath: string, + toSearch: string, + toHash: string, + activeBranch: string, + organization: string, + project: string, +): string | null { + const prefix = `/${organization}/${project}`; + if (!toPath.startsWith(prefix + "/") && toPath !== prefix) return null; + if (toPath.includes("/-/share/")) return null; + if (extractBranchFromPath(toPath)) return null; + return injectBranchIntoPath(toPath, activeBranch) + toSearch + toHash; +} + /** * Shared flag: when set, the next `beforeNavigate` call in the project layout * will skip `@branch` injection. Used by the BranchSelector and the "Back to diff --git a/web-admin/src/features/projects/ProjectHeader.svelte b/web-admin/src/features/projects/ProjectHeader.svelte index 8e808b49972..0d956f9aab2 100644 --- a/web-admin/src/features/projects/ProjectHeader.svelte +++ b/web-admin/src/features/projects/ProjectHeader.svelte @@ -1,6 +1,6 @@ @@ -119,9 +109,7 @@