Files
windmill/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte
Guilhem ca285a189f fix(frontend): improve workspace picker menu UI (#7491)
* Improve sidebar workspace picker

* nit

* nit
2026-01-05 22:23:51 +00:00

262 lines
7.9 KiB
Svelte

<script lang="ts">
import {
isPremiumStore,
superadmin,
userStore,
userWorkspaces,
workspaceStore,
workspaceUsageStore,
workspaceColor,
clearWorkspaceFromStorage
} from '$lib/stores'
import { Building, Plus, Settings, GitFork } from 'lucide-svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { Menu, MenuItem, Tooltip } from '$lib/components/meltComponents'
import { goto } from '$lib/navigation'
import { base } from '$lib/base'
import { page } from '$app/stores'
import { switchWorkspace } from '$lib/storeUtils'
import MultiplayerMenu from './MultiplayerMenu.svelte'
import { enterpriseLicense } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import { workspaceAIClients } from '../copilot/lib'
import { twMerge } from 'tailwind-merge'
import type { MenubarBuilders } from '@melt-ui/svelte'
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
import { getContrastTextColor } from '$lib/utils'
interface Props {
isCollapsed?: boolean
createMenu: MenubarBuilders['createMenu']
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
strictWorkspaceSelect?: boolean
}
function removePrefix(str: string, prefix: string): string {
if (str.startsWith(prefix)) {
return str.substring(prefix.length)
}
return str
}
let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props()
async function toggleSwitchWorkspace(id: string) {
if ($workspaceStore === id) {
return
}
workspaceAIClients.init(id)
const editPages = [
'/scripts/edit/',
'/flows/edit/',
'/apps/edit/',
'/scripts/get/',
'/flows/get/',
'/apps/get/'
]
const isOnEditPage = editPages.some((editPage) => $page.route.id?.includes(editPage) ?? false)
if (!isOnEditPage) {
switchWorkspace(id)
if ($page.url.searchParams.get('workspace')) {
$page.url.searchParams.set('workspace', id)
}
} else {
switchWorkspace(id)
await goto('/')
}
}
function getForkedWorkspace(workspaceId: string) {
if (!$userWorkspaces) return undefined
return $userWorkspaces.find((w) => w.id === workspaceId && w.parent_workspace_id != null)
}
function getParentWorkspace(parentId: string) {
if (!$userWorkspaces) return undefined
return $userWorkspaces.find((w) => w.id === parentId)
}
// Group workspaces into parent-child hierarchy using Svelte 5 derived and the new utility
const groupedWorkspaces = $derived(() => {
if (!$userWorkspaces) return []
return buildWorkspaceHierarchy($userWorkspaces)
})
const itemClass =
'text-primary flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
</script>
{#snippet workspaceIcon(
workspaceColor: string | undefined,
isForked: boolean,
parentName: string | undefined
)}
{@const iconColor = getContrastTextColor(workspaceColor)}
<div style="background-color: {workspaceColor}" class="rounded-full p-1.5 center-center">
{#if isForked}
<Tooltip>
{#snippet text()}
{#if isForked && parentName}
Fork of {parentName}
{/if}
{/snippet}
<GitFork size={14} class="flex-shrink-0" style="color: {iconColor}" />
</Tooltip>
{:else}
<Building size={14} style="color: {iconColor}" />
{/if}
</div>
{/snippet}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
{@const parentWorkspace = forkedWorkspace
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
: null}
{@const iconColor = getContrastTextColor($workspaceColor)}
{#if forkedWorkspace && parentWorkspace}
<MenuButton
class="!text-xs"
icon={GitFork}
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
sublabel={parentWorkspace?.name ? `Fork of ${parentWorkspace.name}` : undefined}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
{:else}
<MenuButton
class="!text-xs"
icon={Building}
iconProps={iconColor ? { style: `color: ${iconColor}` } : undefined}
label={$workspaceStore ?? ''}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
{/if}
{/snippet}
{#snippet children({ item })}
<div class="divide-y" role="none">
<div class="py-1">
{#each groupedWorkspaces() as { workspace, depth, isForked, parentName }}
{@const isSelected = $workspaceStore === workspace.id}
<MenuItem
class={twMerge(
'text-xs min-w-0 w-full overflow-hidden flex flex-col py-2 px-3',
workspace.disabled && 'opacity-50 cursor-not-allowed',
isSelected
? 'cursor-default bg-surface-accent-selected'
: workspace.disabled
? ''
: 'cursor-pointer hover:bg-surface-hover data-[highlighted]:bg-surface-hover'
)}
onClick={async () => {
if (!workspace.disabled) {
await toggleSwitchWorkspace(workspace.id)
}
}}
{item}
>
<div class="flex items-center justify-between min-w-0 w-full">
<div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}>
{@render workspaceIcon(workspace.color, isForked, parentName)}
<div class="min-w-0 flex-1">
<div
class={twMerge(
'truncate text-left text-xs font-normal',
isSelected ? 'text-accent' : 'text-primary'
)}
title={workspace.name}
>
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
</div>
<div
class={twMerge(
'font-mono text-2xs whitespace-nowrap truncate text-left font-normal',
isSelected ? 'text-accent/80' : 'text-hint'
)}
title={workspace.id}
>
{workspace.id}
</div>
</div>
</div>
</div>
</MenuItem>
{/each}
</div>
{#if (isCloudHosted() || $superadmin) && !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem href="{base}/user/create_workspace" class={itemClass} {item}>
<Plus size={16} />
Workspace
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect && !isCloudHosted()}
<div class="py-1" role="none">
<MenuItem href="{base}/user/fork_workspace" class={itemClass} {item}>
<GitFork size={16} />
Fork current workspace
</MenuItem>
</div>
{/if}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem
href="{base}/user/workspaces"
onClick={() => clearWorkspaceFromStorage()}
class={itemClass}
{item}
>
All workspaces
</MenuItem>
</div>
{/if}
{#if ($userStore?.is_admin || $superadmin) && !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem href="{base}/workspace_settings" class={itemClass} {item}>
<Settings size={16} />
Workspace settings
</MenuItem>
</div>
{/if}
</div>
{#if isCloudHosted() && !$isPremiumStore && !strictWorkspaceSelect}
<div class="py-1" role="none">
{#if $workspaceStore != 'demo'}
<span class="text-secondary block w-full text-left px-4 py-2 text-xs"
>{$workspaceUsageStore}/1000 free workspace execs</span
>
<div class="w-full bg-gray-200 h-1">
<div class="bg-blue-400 h-1" style="width: {Math.min($workspaceUsageStore, 1000) / 10}%"
></div>
</div>
{/if}
{#if $userStore?.is_admin}
<MenuItem
class={twMerge(
'text-secondary block font-normal w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900',
'data-[highlighted]:bg-gray-100 data-[highlighted]:text-gray-900'
)}
onClick={() => {
goto('/workspace_settings?tab=premium')
}}
{item}
>
Upgrade
</MenuItem>
{/if}
</div>
{/if}
{#if $enterpriseLicense && !strictWorkspaceSelect}
<MultiplayerMenu />
{/if}
{/snippet}
</Menu>