Files
windmill/frontend/src/lib/components/home/treeViewUtils.ts
T
hugocasa e2503078a8 Merge remote-tracking branch 'origin/main' into agents-home-plus-menu
# Conflicts:
#	backend/tests/jobs_read_auth.rs
#	frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte
#	frontend/src/lib/components/flows/conversations/FlowChat.svelte
2026-09-29 21:56:44 +02:00

197 lines
6.1 KiB
TypeScript

import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
canWrite: boolean
marked?: string
type?: U
time?: number
starred?: boolean
// Server fetch ordinal (see ItemsList) — the tree sorts leaves by it to preserve
// the endpoint's order rather than re-deriving it.
ord?: number
}
type TableScript = TableItem<Script, 'script'>
type TableFlow = TableItem<Flow, 'flow'>
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
/** A saved agent: an `ai_agent` resource the home page lists as a kind of its own. Its
* description stands in as the summary every other kind carries. */
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp | TableAgent
export interface FolderItem {
folderName: string
items: (ItemType | FolderItem)[]
}
export type UserItem = {
username: string
items: (ItemType | FolderItem)[]
}
/**
* Where an item belongs: its owner, folder, and the name it is filtered and searched by.
* A draft-only item is parked at a generated `u/<you>/draft_<uuid>` but names the path it
* will deploy to, and that is what the row shows and what the server lists, filters and
* counts it under — so every categorization has to follow it. `path` stays the storage
* identity that the editor link and the row key resolve.
*/
export function effectivePath(item: {
path: string
draft_only?: boolean | null
draft_path?: string | null
}): string {
return (item.draft_only && item.draft_path) || item.path
}
/** Rows loaded under a node, counting nested subfolders' rows rather than the subfolder
* as one child — what "N items are on screen here" means to someone reading the tree. */
export function countLeaves(node: FolderItem | UserItem): number {
let n = 0
for (const child of node.items) n += 'items' in child ? countLeaves(child) : 1
return n
}
function insertItemInFolder(
root: (ItemType | FolderItem | UserItem)[],
item: ItemType,
path: string[]
) {
let currentLevel = root
path.forEach((folderName, index) => {
if (index === path.length - 1) {
currentLevel.push(item)
} else {
let folder = currentLevel.find((f) => 'folderName' in f && f.folderName === folderName) as
| FolderItem
| undefined
if (!folder) {
folder = { folderName: folderName, items: [] }
currentLevel.push(folder)
}
currentLevel = folder.items
}
})
}
// Default leaf ordering when the caller doesn't impose one: starred first, then
// most recently modified. Folders/users always sort alphabetically regardless.
const defaultLeafCompare = (a: ItemType, b: ItemType): number => {
if (a.starred && !b.starred) return -1
if (!a.starred && b.starred) return 1
return getModifiedAt(b) - getModifiedAt(a)
}
export function groupItems(
items: ItemType[] | undefined,
leafCompare: (a: ItemType, b: ItemType) => number = defaultLeafCompare,
// Folders/users have only a name, so the sort key is always name; `groupDesc`
// flips its direction (Z-A) to follow a name-descending sort, like a file explorer
// reordering folders when you reverse the name sort. Time sorts pass false (no
// folder timestamp to order by, so folders stay alphabetical).
groupDesc: boolean = false
): (ItemType | FolderItem | UserItem)[] {
if (!items) {
return []
}
const root: (ItemType | FolderItem | UserItem)[] = []
items.forEach((item) => {
const pathSplit = effectivePath(item).split('/')
if (pathSplit[0] === 'u') {
const username = pathSplit[1]
let userItem = root.find((f): f is UserItem => 'username' in f && f.username === username) as
| UserItem
| undefined
if (!userItem) {
userItem = { username, items: [] }
root.push(userItem)
}
if (pathSplit.length > 2) {
insertItemInFolder(userItem.items, item, pathSplit.slice(2))
} else {
userItem.items.push(item)
}
} else if (pathSplit[0] === 'f') {
insertItemInFolder(root, item, pathSplit.slice(1))
} else {
// Any other owner (e.g. a schedule at `g/<group>/...`) has no node kind, so it
// stays a top-level leaf rather than vanishing from the tree.
root.push(item)
}
})
const dir = groupDesc ? -1 : 1
// Users, then folders, then ungrouped leaves regardless of direction; only the name
// comparison within users and folders follows `groupDesc`.
const rank = (n: ItemType | FolderItem | UserItem) =>
'username' in n ? 0 : 'folderName' in n ? 1 : 2
root.sort((a, b) => {
const r = rank(a) - rank(b)
if (r !== 0) return r
if ('username' in a && 'username' in b) return dir * a.username.localeCompare(b.username)
if ('folderName' in a && 'folderName' in b)
return dir * a.folderName.localeCompare(b.folderName)
return 0
})
sortGroup(root, leafCompare, dir)
return root
}
function sortGroup(
group: (ItemType | FolderItem | UserItem)[],
leafCompare: (a: ItemType, b: ItemType) => number,
dir: number = 1
) {
group.forEach((item) => {
if ('items' in item) {
item.items.sort((a, b) => {
// Nested subfolders sort before leaves and follow the group direction.
if ('folderName' in a && 'folderName' in b) {
return dir * a.folderName.localeCompare(b.folderName)
}
if ('folderName' in a) {
return -1
}
if ('folderName' in b) {
return 1
}
if (isItemType(a) && isItemType(b)) {
return leafCompare(a, b)
}
return 0
})
sortGroup(item.items, leafCompare, dir)
}
})
}
function isItemType(item: ItemType | FolderItem | UserItem): item is ItemType {
return 'type' in item
}
function getModifiedAt(item: ItemType): number {
if (item.type === 'app') {
return new Date(item.edited_at).getTime() || 0
} else if (item.type === 'script') {
return new Date(item.created_at).getTime() || 0
} else if (item.type === 'flow') {
return new Date(item.edited_at).getTime() || 0
} else if (item.type === 'raw_app') {
return new Date(item.edited_at).getTime() || 0
} else if (item.type === 'agent') {
return new Date(item.edited_at ?? 0).getTime() || 0
}
return 0
}