feat: add cosmetic dev/staging label for dev workspaces (#9959)

* feat: add cosmetic dev/staging label for dev workspaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: prefill dev fork name and use a link to switch its label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: reword the dev/staging label link copy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: preview the dev/staging label as a badge in the switch link

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: show the dev/staging badge in the session diff drawer header

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-07-06 20:43:17 +02:00
committed by GitHub
parent 6587019d26
commit fd8e64d11f
23 changed files with 472 additions and 32 deletions
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": [
false
]
},
"hash": "63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "dev_workspace_label",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,\n CASE WHEN $5 THEN dev_workspace_label ELSE NULL END\n FROM workspace WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01"
}
@@ -0,0 +1,70 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_dev_workspace",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "dev_workspace_label",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "operator_settings",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
null,
false
]
},
"hash": "af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322"
}
@@ -0,0 +1 @@
ALTER TABLE workspace DROP COLUMN dev_workspace_label;
@@ -0,0 +1,4 @@
-- Cosmetic display label for a dev workspace: NULL/'dev' render as "dev", 'staging' renders as "stg".
-- Only meaningful when is_dev_workspace = true; changes nothing about behavior (locking, promote and
-- compare all key off is_dev_workspace / parent_workspace_id). The value is validated in the handler.
ALTER TABLE workspace ADD COLUMN dev_workspace_label VARCHAR;
@@ -156,6 +156,7 @@ pub fn workspaced_service() -> Router {
.route("/create_fork", post(create_workspace_fork))
.route("/attach_dev_workspace", post(attach_dev_workspace))
.route("/detach_dev_workspace", post(detach_dev_workspace))
.route("/set_dev_workspace_label", post(set_dev_workspace_label))
.route("/get_dev_workspace", get(get_dev_workspace))
.route("/change_workspace_name", post(change_workspace_name))
.route("/change_workspace_color", post(change_workspace_color))
@@ -472,6 +473,10 @@ struct CreateWorkspaceFork {
/// the team can work in it. Defaults off; the dev-workspace UI defaults it on.
#[serde(default)]
copy_members: bool,
/// Cosmetic display label for the dev workspace: 'dev' | 'staging'. Purely visual (badge text +
/// wording); ignored for non-dev forks. None defaults to 'dev'.
#[serde(default)]
dev_workspace_label: Option<String>,
}
#[derive(Deserialize)]
@@ -501,6 +506,7 @@ struct UserWorkspace {
pub operator_settings: Option<Option<serde_json::Value>>,
pub parent_workspace_id: Option<String>,
pub is_dev_workspace: bool,
pub dev_workspace_label: Option<String>,
pub disabled: bool,
}
@@ -678,6 +684,20 @@ async fn exists_workspace(
struct DevWorkspaceInfo {
id: String,
name: String,
dev_workspace_label: Option<String>,
}
/// Normalize/validate the cosmetic dev-workspace display label. None or 'dev' both render as "dev";
/// 'staging' renders as "stg". Anything else is rejected. Stored explicitly ('dev'/'staging') so it
/// round-trips, but a NULL column is treated as 'dev' on the read side too.
fn normalize_dev_workspace_label(label: Option<String>) -> Result<Option<String>> {
match label.as_deref() {
None | Some("dev") => Ok(Some("dev".to_string())),
Some("staging") => Ok(Some("staging".to_string())),
Some(other) => Err(Error::BadRequest(format!(
"invalid dev workspace label '{other}' (expected 'dev' or 'staging')"
))),
}
}
/// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace
@@ -691,7 +711,7 @@ async fn get_dev_workspace(
) -> JsonResult<Option<DevWorkspaceInfo>> {
let dev = sqlx::query_as!(
DevWorkspaceInfo,
"SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
"SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
&w_id
)
.fetch_optional(&db)
@@ -3697,7 +3717,7 @@ async fn user_workspaces(
let workspaces = sqlx::query_as!(
UserWorkspace,
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,
workspace.is_dev_workspace,
workspace.is_dev_workspace, workspace.dev_workspace_label,
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
usr.disabled
FROM workspace
@@ -5187,6 +5207,8 @@ async fn create_workspace_fork_branch(
// that second call. Validating early lets a bad request fail before any branch is created.
if nw.is_dev_workspace {
validate_dev_workspace_id(&nw.id)?;
// Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork).
normalize_dev_workspace_label(nw.dev_workspace_label.clone())?;
ensure_dev_parent_is_root(&db, &w_id).await?;
// Reject before creating any git branch if the parent already has a dev workspace,
// otherwise the deferred branch-creation job leaves a dangling branch on the synced repos.
@@ -5418,6 +5440,12 @@ async fn create_workspace_fork(
validate_fork_workspace_id(&nw.id)?;
}
validate_workspace_name(&nw.name)?;
// Cosmetic label only applies to dev workspaces; a non-dev fork stores NULL.
let dev_workspace_label = if nw.is_dev_workspace {
normalize_dev_workspace_label(nw.dev_workspace_label.clone())?
} else {
None
};
// Check the id conflict before the CE workspace-count limit so that
// re-using a taken (possibly archived) fork id reports the actual
// conflict instead of a misleading "maximum number of workspaces" error.
@@ -5495,13 +5523,14 @@ async fn create_workspace_fork(
sqlx::query!(
"INSERT INTO workspace
(id, name, owner, parent_workspace_id, is_dev_workspace)
VALUES ($1, $2, $3, $4, $5)",
(id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)
VALUES ($1, $2, $3, $4, $5, $6)",
forked_id,
nw.name,
authed.email,
parent_workspace_id,
nw.is_dev_workspace,
dev_workspace_label,
)
.execute(&mut *tx)
.await?;
@@ -5634,6 +5663,9 @@ struct AttachDevWorkspace {
lock_prod_deploy: bool,
#[serde(default)]
lock_prod_forking: bool,
/// Cosmetic display label for the attached dev workspace: 'dev' | 'staging'. None defaults to 'dev'.
#[serde(default)]
dev_workspace_label: Option<String>,
}
#[derive(Deserialize)]
@@ -5687,6 +5719,7 @@ async fn attach_dev_workspace(
// The id is interpolated into a `wm-fork/<branch>/<id>` branch name like any fork.
validate_dev_workspace_id(&dev_w_id)?;
let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?;
let dev = sqlx::query!(
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
@@ -5754,9 +5787,10 @@ async fn attach_dev_workspace(
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2",
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
&prod_w_id,
&dev_w_id
&dev_w_id,
dev_workspace_label,
)
.execute(&mut *tx)
.await?;
@@ -5822,6 +5856,51 @@ async fn attach_dev_workspace(
))
}
#[derive(Deserialize)]
struct SetDevWorkspaceLabel {
#[serde(default)]
dev_workspace_label: Option<String>,
}
/// Change the cosmetic display label ('dev' | 'staging') of the current workspace, which must itself
/// be a dev workspace. Purely visual (badge text + wording); requires admin of the dev workspace.
async fn set_dev_workspace_label(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<SetDevWorkspaceLabel>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let label = normalize_dev_workspace_label(req.dev_workspace_label)?;
let mut tx = db.begin().await?;
let updated = sqlx::query_scalar!(
"UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id",
label,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
if updated.is_none() {
return Err(Error::BadRequest(format!(
"Workspace '{w_id}' is not a dev workspace"
)));
}
audit_log(
&mut *tx,
&authed,
"workspaces.set_dev_workspace_label",
ActionKind::Update,
&w_id,
label.as_deref(),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Updated dev workspace label for {w_id}"))
}
/// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the
/// prod lock. The workspace keeps its `parent_workspace_id` (it remains an ordinary fork).
async fn detach_dev_workspace(
@@ -91,9 +91,10 @@ pub(crate) async fn change_workspace_id(
.await?;
}
sqlx::query!(
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace)
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)
SELECT $1, $2, owner, false, premium,
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,
CASE WHEN $5 THEN dev_workspace_label ELSE NULL END
FROM workspace WHERE id = $3",
&rw.new_id,
&rw.new_name,
@@ -1095,7 +1096,10 @@ pub(crate) async fn delete_workspace(
// effort: failures are logged — the workspace row is already gone, and broken storage
// credentials must not have made it undeletable.
for e in cleanup_fork_ducklake_namespaces(&db, &w_id, fork_ducklake_cleanups).await {
tracing::warn!("deleted workspace {w_id}: ducklake namespace cleanup: {}", e.msg);
tracing::warn!(
"deleted workspace {w_id}: ducklake namespace cleanup: {}",
e.msg
);
}
if let Some(parent) = dev_lock_parent {
+41
View File
@@ -1205,6 +1205,9 @@ paths:
type: boolean
lock_prod_forking:
type: boolean
dev_workspace_label:
type: string
enum: [dev, staging]
required:
- dev_workspace_id
responses:
@@ -1263,10 +1266,40 @@ paths:
type: string
name:
type: string
dev_workspace_label:
type: string
nullable: true
description: "Cosmetic display label ('dev' | 'staging'); null defaults to 'dev'"
required:
- id
- name
/w/{workspace}/workspaces/set_dev_workspace_label:
post:
summary: set the cosmetic display label (dev/staging) of this dev workspace
operationId: setDevWorkspaceLabel
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
dev_workspace_label:
type: string
enum: [dev, staging]
responses:
"200":
description: dev workspace label updated
content:
text/plain:
schema:
type: string
/workspaces/exists:
post:
summary: exists workspace
@@ -28180,6 +28213,10 @@ components:
nullable: true
is_dev_workspace:
type: boolean
dev_workspace_label:
type: string
nullable: true
description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'"
created_by:
type: string
nullable: true
@@ -28249,6 +28286,10 @@ components:
copy_members:
type: boolean
description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it"
dev_workspace_label:
type: string
enum: [dev, staging]
description: "Cosmetic display label for the dev workspace (badge text + wording only); ignored for non-dev forks"
required:
- id
- name
@@ -1,7 +1,7 @@
<script lang="ts">
import { workspaceStore, userWorkspaces, usersWorkspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { Button } from '$lib/components/common'
import { Badge, Button } from '$lib/components/common'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { sendUserToast } from '$lib/toast'
@@ -9,12 +9,14 @@
import { goto } from '$app/navigation'
import { base } from '$lib/base'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { GitFork, ExternalLink } from 'lucide-svelte'
import { resource } from 'runed'
let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let isDev = $derived(currentWs?.is_dev_workspace ?? false)
let currentLabel = $derived(devLabelKey(currentWs?.dev_workspace_label))
let parentId = $derived(currentWs?.parent_workspace_id ?? undefined)
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
@@ -29,12 +31,18 @@
// else the server result (pairing + detach still available to a prod admin).
let pairedDev = $derived(
canonicalDev
? { id: canonicalDev.id, name: canonicalDev.name, isMember: true }
? {
id: canonicalDev.id,
name: canonicalDev.name,
isMember: true,
label: canonicalDev.dev_workspace_label
}
: devWorkspaceResource.current
? {
id: devWorkspaceResource.current.id,
name: devWorkspaceResource.current.name,
isMember: false
isMember: false,
label: devWorkspaceResource.current.dev_workspace_label
}
: undefined
)
@@ -42,7 +50,10 @@
let selectedDevId = $state<string | undefined>(undefined)
let lockProdDeploy = $state(true)
let lockProdForking = $state(true)
// Cosmetic display label chosen when attaching an existing workspace as dev.
let attachLabel = $state<'dev' | 'staging'>('dev')
let busy = $state(false)
let labelBusy = $state(false)
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
@@ -82,10 +93,11 @@
requestBody: {
dev_workspace_id: selectedDevId,
lock_prod_deploy: lockProdDeploy,
lock_prod_forking: lockProdForking
lock_prod_forking: lockProdForking,
dev_workspace_label: attachLabel
}
})
sendUserToast(`Attached ${selectedDevId} as dev workspace`)
sendUserToast(`Attached ${selectedDevId} as ${attachLabel} workspace`)
selectedDevId = undefined
await refresh()
} catch (e: any) {
@@ -95,6 +107,22 @@
}
}
async function setLabel(label: 'dev' | 'staging') {
if (!$workspaceStore || label === devLabelKey(currentWs?.dev_workspace_label)) return
labelBusy = true
try {
await WorkspaceService.setDevWorkspaceLabel({
workspace: $workspaceStore,
requestBody: { dev_workspace_label: label }
})
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e: any) {
sendUserToast(`Failed to update display label: ${e?.body ?? e}`, true)
} finally {
labelBusy = false
}
}
async function detach(devId: string) {
if (!$workspaceStore) return
busy = true
@@ -116,9 +144,20 @@
{#if isDev && parentId}
<div class="flex flex-col gap-3 max-w-2xl">
<p class="text-sm">
This is a <b>dev workspace</b> paired with root workspace <b>{parentId}</b>. Promote changes
from the home page banner or the Compare &amp; Deploy page.
This is a <b>{devLabelNoun(currentWs?.dev_workspace_label)}</b> paired with root workspace
<b>{parentId}</b>. Promote changes from the home page banner or the Compare &amp; Deploy page.
</p>
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(currentLabel)}</Badge>
<button
type="button"
disabled={labelBusy}
class="text-secondary hover:text-primary hover:underline disabled:opacity-50"
onclick={() => setLabel(currentLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {currentLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<div>
<Button
variant="default"
@@ -132,8 +171,8 @@
{:else if pairedDev}
<div class="flex flex-col gap-3 max-w-2xl">
<p class="text-sm">
This workspace's dev workspace is <b>{pairedDev.name}</b> ({pairedDev.id}). Edits to this
workspace are redirected there.
This workspace's {devLabelNoun(pairedDev.label)} is <b>{pairedDev.name}</b> ({pairedDev.id}).
Edits to this workspace are redirected there.
</p>
<div class="flex gap-2">
{#if pairedDev.isMember}
@@ -142,7 +181,7 @@
startIcon={{ icon: GitFork }}
onclick={() => switchWorkspace(pairedDev.id)}
>
Go to dev workspace
Go to {devLabelNoun(pairedDev.label)}
</Button>
{/if}
<Button color="red" disabled={busy} onclick={() => detach(pairedDev.id)}>Detach</Button>
@@ -168,6 +207,16 @@
clearable
/>
</div>
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(attachLabel)}</Badge>
<button
type="button"
class="text-secondary hover:text-primary hover:underline"
onclick={() => (attachLabel = attachLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {attachLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<Toggle
bind:checked={lockProdDeploy}
options={{
@@ -7,6 +7,7 @@
import { goto } from '$app/navigation'
import { onMount, untrack } from 'svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
let loading = $state(false)
let comparison: WorkspaceComparison | undefined = $state(undefined)
@@ -170,7 +171,9 @@
<GitFork class="w-4 h-4 text-accent" />
<div class="text-sm">
<span class="font-medium text-blue-900 dark:text-blue-100">
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
{isDevWorkspace
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
: 'Fork of'}
<b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
</span>
</div>
@@ -6,6 +6,7 @@
isRuleActive
} from '$lib/workspaceProtectionRules.svelte'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { canCreateFork } from '$lib/utils/editInFork'
import { switchWorkspace } from '$lib/storeUtils'
import { Alert, Button } from './common'
@@ -40,12 +41,14 @@
<div class="my-2">
<Alert
type="info"
title={canonicalDev ? 'Edits happen in the dev workspace' : 'Workspace protection active'}
title={canonicalDev
? `Edits happen in the ${devLabelNoun(canonicalDev.dev_workspace_label)}`
: 'Workspace protection active'}
>
<div class="flex flex-col gap-2">
{#if canonicalDev}
<p>
Edits to this workspace are made in its dev workspace
Edits to this workspace are made in its {devLabelNoun(canonicalDev.dev_workspace_label)}
<b>{canonicalDev.name}</b> ({canonicalDev.id}) and promoted here.
</p>
<div>
@@ -58,7 +61,7 @@
if (canonicalDev) switchWorkspace(canonicalDev.id)
}}
>
Go to dev workspace
Go to {devLabelNoun(canonicalDev.dev_workspace_label)}
</Button>
</div>
{:else}
@@ -2,6 +2,8 @@
import WorkspaceDiffDrawer from './WorkspaceDiffDrawer.svelte'
import { ArrowRight, GitFork, Pencil } from 'lucide-svelte'
import { userWorkspaces } from '$lib/stores'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import { useSessionDeployModel } from './sessionDeployModel.svelte'
import type { DeployItem } from './sessionDeployModel'
@@ -92,6 +94,9 @@
<span class="font-medium truncate" title={ws?.name ?? workspaceId}>
{ws?.name ?? workspaceId}
</span>
{#if ws?.is_dev_workspace}
<Badge color="indigo" small>{devBadgeText(ws.dev_workspace_label)}</Badge>
{/if}
<ArrowRight class="w-3 h-3 shrink-0 text-tertiary" />
<span class="font-medium truncate" title={parentWs?.name ?? parentWorkspaceId}>
{parentWs?.name ?? parentWorkspaceId}
@@ -9,6 +9,7 @@
} from './sessionState.svelte'
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
import { Badge } from '$lib/components/common'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import { Building, ChevronDown, GitFork } from 'lucide-svelte'
let { session }: { session: Session } = $props()
@@ -67,7 +68,7 @@
{pendingFork?.name ?? currentWs?.name ?? effectiveId ?? 'Pick workspace'}
</span>
{#if !pendingFork && currentWs?.is_dev_workspace}
<Badge color="indigo" small>dev</Badge>
<Badge color="indigo" small>{devBadgeText(currentWs.dev_workspace_label)}</Badge>
{/if}
{#if pendingFork}
<span class="text-2xs text-tertiary italic shrink-0">(new)</span>
@@ -26,6 +26,7 @@
import InputError from '$lib/components/InputError.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Badge } from '$lib/components/common'
import { devBadgeText, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { Building, Check, GitFork, Plus } from 'lucide-svelte'
type PendingFork = { parent_workspace_id: string; id: string; name: string }
@@ -414,7 +415,7 @@
disabled={rootDisabled}
title={rootDisabled
? devOfRoot
? `${root.name} is locked. Run in its dev workspace instead.`
? `${root.name} is locked. Run in its ${devLabelNoun(devOfRoot.dev_workspace_label)} instead.`
: `${root.name} is locked for direct deploys.`
: undefined}
class={`${rowBase} ${rootDisabled ? 'opacity-50 cursor-not-allowed' : ''} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${!rootDisabled && keyArrowPos === rootIdx ? 'bg-surface-hover' : !rootDisabled ? 'hover:bg-surface-hover' : ''}`}
@@ -440,7 +441,7 @@
<GitFork size={14} class="shrink-0 text-tertiary" />
<span class="truncate">{f.name}</span>
{#if f.is_dev_workspace}
<Badge color="indigo" small>dev</Badge>
<Badge color="indigo" small>{devBadgeText(f.dev_workspace_label)}</Badge>
{/if}
</button>
{/each}
@@ -29,6 +29,7 @@
import type { MenubarBuilders } from '@melt-ui/svelte'
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
import { getContrastTextColor } from '$lib/utils'
import { devBadgeText, devLabelWord } from '$lib/utils/devWorkspaceLabel'
interface Props {
isCollapsed?: boolean
@@ -133,7 +134,7 @@
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
sublabel={parentWorkspace?.name
? `${forkedWorkspace.is_dev_workspace ? 'Dev workspace of' : 'Fork of'} ${parentWorkspace.name}`
? `${forkedWorkspace.is_dev_workspace ? `${devLabelWord(forkedWorkspace.dev_workspace_label)} workspace of` : 'Fork of'} ${parentWorkspace.name}`
: undefined}
{isCollapsed}
color={$workspaceColor}
@@ -177,6 +178,7 @@
workspaceColor={workspace.color}
{isForked}
isDevWorkspace={workspace.is_dev_workspace}
devWorkspaceLabel={workspace.dev_workspace_label}
{parentName}
/>
<div class="min-w-0 flex-1">
@@ -191,7 +193,7 @@
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
</div>
{#if workspace.is_dev_workspace}
<Badge color="indigo">dev</Badge>
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
{/if}
</div>
<div
@@ -11,6 +11,7 @@
import WorkspaceIcon from './WorkspaceIcon.svelte'
import WorkspaceCard from './WorkspaceCard.svelte'
import { twMerge } from 'tailwind-merge'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
interface ExtendedWorkspace extends UserWorkspace {
_children?: ExtendedWorkspace[]
@@ -120,6 +121,7 @@
workspaceColor={workspace.color}
{isForked}
isDevWorkspace={workspace.is_dev_workspace}
devWorkspaceLabel={workspace.dev_workspace_label}
parentName={workspace.parent_workspace_id ?? undefined}
size={12}
/>
@@ -135,7 +137,7 @@
{/if}
</span>
{#if workspace.is_dev_workspace}
<Badge color="indigo">dev</Badge>
<Badge color="indigo">{devBadgeText(workspace.dev_workspace_label)}</Badge>
{/if}
<span class="text-secondary text-xs">-</span>
{#if workspace.id === 'admins'}
@@ -2,11 +2,13 @@
import { Building, GitFork } from 'lucide-svelte'
import { Tooltip } from '$lib/components/meltComponents'
import { getContrastTextColor } from '$lib/utils'
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
interface Props {
workspaceColor?: string
isForked?: boolean
isDevWorkspace?: boolean
devWorkspaceLabel?: string | null
parentName?: string
size?: number
}
@@ -15,6 +17,7 @@
workspaceColor,
isForked = false,
isDevWorkspace = false,
devWorkspaceLabel,
parentName,
size = 14
}: Props = $props()
@@ -27,7 +30,7 @@
<Tooltip>
{#snippet text()}
{#if isForked && parentName}
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
{isDevWorkspace ? `${devLabelWord(devWorkspaceLabel)} workspace of` : 'Fork of'}
{parentName}
{/if}
{/snippet}
@@ -17,7 +17,8 @@
import { usersWorkspaceStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { workspaceIsFork } from '$lib/utils/workspaceHierarchy'
import { resource } from 'runed'
import { Button } from '$lib/components/common'
import { Badge, Button } from '$lib/components/common'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { onMount } from 'svelte'
@@ -58,6 +59,12 @@
$effect(() => {
copyMembers = createAsDevWorkspace
})
// Cosmetic display label for the new dev workspace: 'dev' | 'staging'. Purely visual (badge text +
// wording); reset when the dev toggle is turned off.
let devWorkspaceLabel = $state<'dev' | 'staging'>('dev')
$effect(() => {
if (!createAsDevWorkspace) devWorkspaceLabel = 'dev'
})
// The dev-workspace option is only offered when forking a root workspace that doesn't already
// have one: a workspace gets at most one dev, and dev workspaces don't nest (a dev of a dev).
@@ -239,6 +246,7 @@
name,
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
is_dev_workspace: createAsDevWorkspace,
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
// Send the lock intent in this first phase too so the backend can reject a non-admin's
// locked-dev request before any branch is created (avoids dangling branches).
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
@@ -308,6 +316,7 @@
forked_datatables: forkedDatatables,
shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [],
is_dev_workspace: createAsDevWorkspace,
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
lock_prod_forking: createAsDevWorkspace && lockProdForking,
copy_members: copyMembers
@@ -324,7 +333,7 @@
forkCreationLoading = false
sendUserToast(
createAsDevWorkspace
? `Created dev workspace ${effectiveForkId} for ${$workspaceStore}`
? `Created ${devWorkspaceLabel === 'staging' ? 'staging' : 'dev'} workspace ${effectiveForkId} for ${$workspaceStore}`
: `Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`
)
@@ -467,6 +476,21 @@
run(() => {
id = name.toLowerCase().replace(/\s/gi, '-')
})
// When creating a dev workspace, prefill the fork name with `<root>-dev` / `<root>-stg` (the effect
// above slugifies it into the id). Only fill an empty field or one still holding a prior suggestion,
// so a user-typed name is never overwritten; flipping Dev<->Staging updates the suffix, and turning
// the dev toggle back off clears the suggestion.
let lastAutoDevName = $state<string | undefined>(undefined)
$effect(() => {
const target =
createAsDevWorkspace && $workspaceStore
? `${$workspaceStore}-${devWorkspaceLabel === 'staging' ? 'stg' : 'dev'}`
: ''
if (name === '' || name === lastAutoDevName) {
name = target
lastAutoDevName = target === '' ? undefined : target
}
})
run(() => {
validateName(id)
})
@@ -598,6 +622,18 @@
<div class="flex flex-col gap-2 pt-1">
<Toggle bind:checked={createAsDevWorkspace} options={{ right: 'Dev workspace' }} />
{#if createAsDevWorkspace}
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(devWorkspaceLabel)}</Badge
>
<button
type="button"
class="text-secondary hover:text-primary hover:underline"
onclick={() =>
(devWorkspaceLabel = devWorkspaceLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {devWorkspaceLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<div class="flex flex-col gap-2 rounded-md border bg-surface-secondary p-3">
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-emphasis"
+1
View File
@@ -43,6 +43,7 @@ export interface UserWorkspace {
operator_settings?: OperatorSettings
parent_workspace_id?: string | null
is_dev_workspace?: boolean
dev_workspace_label?: string | null
disabled: boolean
}
@@ -0,0 +1,25 @@
// Cosmetic display label for a dev workspace. The paired-fork machinery is unchanged; this only
// swaps the badge text and identity wording so a team can present the environment as "staging"
// instead of "dev". A null/unknown stored value renders as "dev" (the default).
export type DevWorkspaceLabelKey = 'dev' | 'staging'
/** Resolve the stored `dev_workspace_label` to a known key; anything unset/unknown is 'dev'. */
export function devLabelKey(label: string | null | undefined): DevWorkspaceLabelKey {
return label === 'staging' ? 'staging' : 'dev'
}
/** Short badge text: 'dev' or 'stg'. */
export function devBadgeText(label: string | null | undefined): string {
return devLabelKey(label) === 'staging' ? 'stg' : 'dev'
}
/** Capitalized word for identity wording, e.g. `${devLabelWord(l)} workspace of X`. */
export function devLabelWord(label: string | null | undefined): string {
return devLabelKey(label) === 'staging' ? 'Staging' : 'Dev'
}
/** Lowercase noun phrase for prose, e.g. "made in its ${devLabelNoun(l)}". */
export function devLabelNoun(label: string | null | undefined): string {
return devLabelKey(label) === 'staging' ? 'staging workspace' : 'dev workspace'
}