Files
windmill/frontend/src/lib/components/workspace/WorkspaceTreeView.svelte
T
Guilhem 69b44f3b68 fix(frontend): improve workspace page (#7502)
* nit

* Improve workspace view by showing a tree

* implement search for workspaces

* Add collapse expand button

* improve unarchive button

* nit

* move search

* nit

* add max h

* Add keyboard navigation

* clean code

* Show admin workspaces with other workspaces

* Update frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* nit

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-01-06 18:06:42 +00:00

413 lines
13 KiB
Svelte

<script lang="ts">
import { Building2 } from 'lucide-svelte'
import { SvelteMap } from 'svelte/reactivity'
import WorkspaceCard from './WorkspaceCard.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import type { UserWorkspace } from '$lib/stores'
interface ExtendedWorkspace extends UserWorkspace {
_children?: ExtendedWorkspace[]
marked?: string
}
interface Props {
workspaces: UserWorkspace[]
onEnterWorkspace: (workspaceId: string) => Promise<void>
onUnarchive?: (workspaceId: string) => Promise<void>
searchFilter?: string
onExpandCollapseAll?: () => void
allExpanded?: boolean
hasForks?: boolean
}
let {
workspaces,
onEnterWorkspace,
onUnarchive,
searchFilter = $bindable(''),
onExpandCollapseAll = $bindable(),
allExpanded = $bindable(false),
hasForks = $bindable(false)
}: Props = $props()
// State for manually toggled expansion status
let manualExpansionStates = $state<Record<string, boolean>>({})
let filteredWorkspaces: (UserWorkspace & { marked?: string })[] | undefined = $state()
// Keyboard navigation state
let selectedWorkspaceId = $state<string | null>(null)
let isKeyboardNavigation = $state(false)
let scrollContainer: HTMLElement
// Computed expansion states that include auto-expansion for search results
let expansionStates = $derived.by(() => {
if (!searchFilter || !filteredWorkspaces || !workspaces) {
return manualExpansionStates
}
const matchedWorkspaceIds = new Set(filteredWorkspaces.map((w) => w.id))
const autoExpanded: Record<string, boolean> = {}
// Build children map for descendant checking
const childrenMap = new Map<string, string[]>()
workspaces.forEach((workspace) => {
if (workspace.parent_workspace_id) {
if (!childrenMap.has(workspace.parent_workspace_id)) {
childrenMap.set(workspace.parent_workspace_id, [])
}
childrenMap.get(workspace.parent_workspace_id)!.push(workspace.id)
}
})
// Function to check if workspace has matching descendants (not itself)
function hasMatchingDescendant(workspaceId: string): boolean {
const children = childrenMap.get(workspaceId) || []
return children.some(
(childId) => matchedWorkspaceIds.has(childId) || hasMatchingDescendant(childId)
)
}
// Auto-expand workspaces only if they have matching descendants
// (not if the workspace itself matches - user can manually expand to see children)
workspaces.forEach((workspace) => {
if (hasMatchingDescendant(workspace.id)) {
autoExpanded[workspace.id] = true
}
})
// Combine manual and auto-expanded states
return { ...manualExpansionStates, ...autoExpanded }
})
// Build nested hierarchy correctly - always use full workspace list for hierarchy
let rootWorkspaces = $derived.by(() => {
if (!workspaces) return []
// Create a map of children for each parent workspace using ALL workspaces
const childrenMap = new SvelteMap<string, ExtendedWorkspace[]>()
// Build children mapping - this correctly handles nested relationships
workspaces.forEach((workspace) => {
if (workspace.parent_workspace_id) {
if (!childrenMap.has(workspace.parent_workspace_id)) {
childrenMap.set(workspace.parent_workspace_id, [])
}
// Find marked version from filtered results if available
const filteredWorkspace = filteredWorkspaces?.find((fw) => fw.id === workspace.id)
const extendedWorkspace = {
...workspace,
marked: filteredWorkspace?.marked
} as ExtendedWorkspace
childrenMap.get(workspace.parent_workspace_id)!.push(extendedWorkspace)
}
})
// Get IDs of workspaces that match the search
const matchedWorkspaceIds = new Set(filteredWorkspaces?.map((w) => w.id) || [])
// Function to check if a workspace or its descendants match the search
function hasMatchingDescendant(workspaceId: string): boolean {
if (matchedWorkspaceIds.has(workspaceId)) return true
const children = childrenMap.get(workspaceId) || []
return children.some((child) => hasMatchingDescendant(child.id))
}
// Recursive function to build full nested hierarchy
// parentMatched: if true, include all children regardless of search match
function buildWorkspaceWithChildren(
workspace: UserWorkspace,
parentMatched: boolean = false
): ExtendedWorkspace {
const directChildren = childrenMap.get(workspace.id) || []
const thisWorkspaceMatches = matchedWorkspaceIds.has(workspace.id)
// If this workspace or a parent matches, show all children
// Otherwise, only show children that match or have matching descendants
const visibleChildren =
searchFilter && !parentMatched && !thisWorkspaceMatches
? directChildren.filter((child) => hasMatchingDescendant(child.id))
: directChildren
const childrenWithNestedStructure = visibleChildren.map((child) =>
buildWorkspaceWithChildren(child, parentMatched || thisWorkspaceMatches)
)
// Find marked version from filtered results if available
const filteredWorkspace = filteredWorkspaces?.find((fw) => fw.id === workspace.id)
return {
...workspace,
marked: filteredWorkspace?.marked,
_children: childrenWithNestedStructure
}
}
// Return only root workspaces - filter based on search if active
const rootCandidates = workspaces.filter(
(workspace) =>
!workspace.parent_workspace_id ||
!workspaces.find((w) => w.id === workspace.parent_workspace_id)
)
const visibleRoots = searchFilter
? rootCandidates.filter((workspace) => hasMatchingDescendant(workspace.id))
: rootCandidates
return visibleRoots
.map((workspace) => buildWorkspaceWithChildren(workspace))
.sort((a, b) => {
// Admin workspace always goes first
if (a.id === 'admins') return -1
if (b.id === 'admins') return 1
// Then sort alphabetically by name
return a.name.localeCompare(b.name)
})
})
function handleToggleExpand(workspaceId: string) {
const currentState = expansionStates[workspaceId] ?? false
manualExpansionStates = { ...manualExpansionStates, [workspaceId]: !currentState }
}
// Get IDs of workspaces that have children (can be expanded)
let workspacesWithChildren = $derived.by(() => {
if (!workspaces) return []
const parentIds = new Set(
workspaces.filter((w) => w.parent_workspace_id).map((w) => w.parent_workspace_id)
)
return workspaces.filter((w) => parentIds.has(w.id)).map((w) => w.id)
})
// Check if all expandable workspaces are currently expanded
let allExpandedInternal = $derived(
workspacesWithChildren.length > 0 &&
workspacesWithChildren.every((id) => expansionStates[id] === true)
)
// Sync internal state to bindable props
$effect(() => {
allExpanded = allExpandedInternal
})
$effect(() => {
hasForks = workspacesWithChildren.length > 0
})
export function handleExpandCollapseAll() {
const newState = !allExpandedInternal
const newExpansionStates: Record<string, boolean> = {}
workspacesWithChildren.forEach((id) => {
newExpansionStates[id] = newState
})
manualExpansionStates = newExpansionStates
}
// Generate flattened navigation order for keyboard navigation
const flatNavigationOrder = $derived.by(() => {
const result: string[] = []
function addWorkspaceAndChildren(workspace: ExtendedWorkspace) {
result.push(workspace.id)
if (workspace._children && expansionStates[workspace.id]) {
workspace._children.forEach((child) => addWorkspaceAndChildren(child))
}
}
rootWorkspaces.forEach((workspace) => addWorkspaceAndChildren(workspace))
return result
})
// Keyboard navigation handlers
function handleKeyDown(event: KeyboardEvent) {
// Allow navigation keys even when search input has focus
const navigationKeys = [
'ArrowDown',
'ArrowUp',
'Home',
'End',
'ArrowLeft',
'ArrowRight',
'Enter',
' ',
'Escape'
]
const activeElement = document.activeElement
// Skip navigation only if user is typing in textarea or non-search inputs
if (
activeElement?.tagName === 'TEXTAREA' ||
(activeElement?.tagName === 'INPUT' && !navigationKeys.includes(event.key))
) {
return
}
// Enable keyboard navigation on arrow keys
if (['ArrowDown', 'ArrowUp', 'Home', 'End', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
enableKeyboardNavigation()
}
// Only handle navigation if keyboard navigation is active
if (!isKeyboardNavigation) {
return
}
if (flatNavigationOrder.length === 0) return
const currentIndex = selectedWorkspaceId ? flatNavigationOrder.indexOf(selectedWorkspaceId) : -1
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
const nextIndex = currentIndex < flatNavigationOrder.length - 1 ? currentIndex + 1 : 0
selectedWorkspaceId = flatNavigationOrder[nextIndex]
break
}
case 'ArrowUp': {
event.preventDefault()
const prevIndex = currentIndex > 0 ? currentIndex - 1 : flatNavigationOrder.length - 1
selectedWorkspaceId = flatNavigationOrder[prevIndex]
break
}
case 'Home': {
event.preventDefault()
selectedWorkspaceId = flatNavigationOrder[0]
break
}
case 'End': {
event.preventDefault()
selectedWorkspaceId = flatNavigationOrder[flatNavigationOrder.length - 1]
break
}
case 'ArrowRight': {
if (selectedWorkspaceId && workspacesWithChildren.includes(selectedWorkspaceId)) {
event.preventDefault()
if (!expansionStates[selectedWorkspaceId]) {
handleToggleExpand(selectedWorkspaceId)
}
}
break
}
case 'ArrowLeft': {
if (selectedWorkspaceId && workspacesWithChildren.includes(selectedWorkspaceId)) {
event.preventDefault()
if (expansionStates[selectedWorkspaceId]) {
handleToggleExpand(selectedWorkspaceId)
}
}
break
}
case 'Enter':
case ' ': {
if (selectedWorkspaceId) {
event.preventDefault()
const workspace = workspaces.find((w) => w.id === selectedWorkspaceId)
if (workspace && !workspace.disabled) {
onEnterWorkspace(selectedWorkspaceId)
}
}
break
}
case 'Escape': {
selectedWorkspaceId = null
isKeyboardNavigation = false
break
}
}
}
// Reset selection when workspaces change or search filter changes
$effect(() => {
if (rootWorkspaces.length === 0) {
selectedWorkspaceId = null
} else if (selectedWorkspaceId && !flatNavigationOrder.includes(selectedWorkspaceId)) {
// If currently selected workspace is no longer visible, reset to first visible
selectedWorkspaceId = flatNavigationOrder[0] || null
}
})
// Enable keyboard navigation when user starts navigating
function enableKeyboardNavigation() {
if (!isKeyboardNavigation && rootWorkspaces.length > 0) {
isKeyboardNavigation = true
if (!selectedWorkspaceId) {
selectedWorkspaceId = flatNavigationOrder[0] || null
}
}
}
// Handle mouse interactions - disable keyboard mode when mouse is used
function handleMouseEnter(workspaceId: string) {
if (isKeyboardNavigation) {
selectedWorkspaceId = workspaceId
}
}
function handleMouseClick() {
isKeyboardNavigation = false
selectedWorkspaceId = null
}
// Scroll selected workspace into view
function scrollToSelectedWorkspace() {
if (!selectedWorkspaceId || !scrollContainer) return
// Find the workspace card element by data attribute
const selectedElement = scrollContainer.querySelector(
`[data-workspace-id="${selectedWorkspaceId}"]`
)
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
// Auto-scroll when selection changes
$effect(() => {
if (selectedWorkspaceId && isKeyboardNavigation) {
scrollToSelectedWorkspace()
}
})
</script>
<svelte:window onkeydown={handleKeyDown} />
<!-- Search Items Component for fuzzy search with highlighting -->
<SearchItems
filter={searchFilter}
items={workspaces}
bind:filteredItems={filteredWorkspaces}
f={(workspace) => workspace.name + ' (' + workspace.id + ')'}
/>
<div class="space-y-4 max-h-[50vh] overflow-auto" bind:this={scrollContainer}>
<!-- Workspace Tree -->
<div class="space-y-2">
{#each rootWorkspaces as workspace (workspace.id)}
<WorkspaceCard
{workspace}
children={workspace._children || []}
isExpanded={expansionStates[workspace.id] ?? false}
{expansionStates}
{onEnterWorkspace}
{onUnarchive}
onToggleExpand={handleToggleExpand}
{selectedWorkspaceId}
onMouseEnter={handleMouseEnter}
onMouseClick={handleMouseClick}
onKeyboardNavigation={enableKeyboardNavigation}
/>
{/each}
{#if rootWorkspaces.length === 0}
<div class="text-center py-8">
<Building2 size={48} class="text-secondary mx-auto mb-3" />
<p class="text-sm text-secondary">
{searchFilter ? 'No workspaces match your search' : 'No workspaces available'}
</p>
</div>
{/if}
</div>
</div>