Files
windmill/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte

289 lines
8.7 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 } 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'
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('/')
}
}
// Helper function to check if a workspace is forked
function isForkedWorkspace(workspaceId: string): boolean {
if (!$userWorkspaces) return false
return $userWorkspaces.some((w) => w.id === workspaceId && w.parent_workspace_id != null)
}
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)
})
</script>
{#if isForkedWorkspace($workspaceStore ?? '') && !isCollapsed}
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
{@const parentWorkspace = forkedWorkspace
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
: null}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<div class="group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full text-xs">
<Building size={12} class="text-primary" />
<span class="text-xs text-primary"> {parentWorkspace?.name ?? ''} </span>
</div>
{/snippet}
</Menu>
{/if}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
{@const forkedWorkspace = getForkedWorkspace($workspaceStore ?? '')}
{@const parentWorkspace = forkedWorkspace
? getParentWorkspace(forkedWorkspace.parent_workspace_id!)
: null}
{#if forkedWorkspace && parentWorkspace}
<div class={isCollapsed ? '' : 'pl-6'}>
<MenuButton
class="!text-xs"
icon={GitFork}
label={removePrefix($workspaceStore ?? '', 'wm-fork-')}
{isCollapsed}
color={$workspaceColor}
{trigger}
/>
</div>
{:else}
<MenuButton
class="!text-xs"
icon={Building}
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 }}
<MenuItem
class={twMerge(
'text-xs min-w-0 w-full overflow-hidden flex flex-col py-1.5',
workspace.disabled && 'opacity-50 cursor-not-allowed',
$workspaceStore === workspace.id
? 'cursor-default bg-surface-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={twMerge('flex items-center gap-2 min-w-0', 'pl-4')}
style:padding-left={`${4 + depth * 12}px`}
>
{#if isForked}
<GitFork size={12} class="text-primary flex-shrink-0" />
{:else}
<Building size={12} />
{/if}
<div class="min-w-0 flex-1">
<div
class={twMerge(
'truncate text-left text-[1.2em]',
isForked ? 'text-secondary' : 'text-primary'
)}
>
{workspace.name}{workspace.disabled ? ' (user disabled)' : ''}
</div>
<div
class={twMerge(
'font-mono text-2xs whitespace-nowrap truncate text-left',
isForked ? 'text-primary opacity-75' : 'text-primary'
)}
>
{workspace.id}
</div>
{#if isForked && parentName}
<div class="text-primary text-2xs truncate text-left pl-2 min-h-[1rem]">
Fork of {parentName}
</div>
{/if}
</div>
</div>
{#if workspace.color}
<div
class="w-5 h-5 mr-2 rounded border border-gray-300 dark:border-gray-600"
style="background-color: {workspace.color}"
></div>
{/if}
</div>
</MenuItem>
{/each}
</div>
{#if (isCloudHosted() || $superadmin) && !strictWorkspaceSelect}
<div class="py-1" role="none">
<a
href="{base}/user/create_workspace"
class="text-primary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2"
role="menuitem"
tabindex="-1"
>
<Plus size={16} />
Workspace
</a>
</div>
{/if}
{#if !strictWorkspaceSelect && !isCloudHosted()}
<div class="py-1" role="none">
<a
href="{base}/user/fork_workspace"
class="text-primary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2"
role="menuitem"
tabindex="-1"
>
<GitFork size={16} />
Fork current workspace
</a>
</div>
{/if}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem
href="{base}/user/workspaces"
onClick={() => clearWorkspaceFromStorage()}
class={twMerge(
'text-primary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
All workspaces
</MenuItem>
</div>
{/if}
{#if ($userStore?.is_admin || $superadmin) && !strictWorkspaceSelect}
<div class="py-1" role="none">
<MenuItem
href="{base}/workspace_settings"
class={twMerge(
'text-secondary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{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>