diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index dc3b8dff48..95e5aa916e 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -108,6 +108,7 @@ function restorePathScripts(flowValue: any) { type WmPathItem = { path: string; kind: "flow" | "script" | "raw_app"; + summary?: string; }; const FLOW_SUFFIXES = [".flow", "__flow"] as const; @@ -121,7 +122,9 @@ function stripFolderSuffix(rel: string, suffixes: readonly string[]): string { } async function listWorkspacePaths(): Promise { - const items: WmPathItem[] = []; + // Walk first, capturing each item's metadata file path. Then read summaries in + // parallel — one tree pass plus N file reads is faster than a serialized walk. + const items: (WmPathItem & { _metaPath?: string })[] = []; async function walk(dir: string, rel: string) { let entries; try { @@ -132,30 +135,52 @@ async function listWorkspacePaths(): Promise { for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; const childRel = rel ? `${rel}/${entry.name}` : entry.name; + const childAbs = path.join(dir, entry.name); if (entry.isDirectory()) { if (FLOW_SUFFIXES.some((s) => entry.name.endsWith(s))) { - items.push({ path: stripFolderSuffix(childRel, FLOW_SUFFIXES), kind: "flow" }); + items.push({ + path: stripFolderSuffix(childRel, FLOW_SUFFIXES), + kind: "flow", + _metaPath: path.join(childAbs, "flow.yaml"), + }); continue; } if (APP_SUFFIXES.some((s) => entry.name.endsWith(s))) { items.push({ path: stripFolderSuffix(childRel, APP_SUFFIXES), kind: "raw_app" }); continue; } - await walk(path.join(dir, entry.name), childRel); + await walk(childAbs, childRel); } else if (entry.isFile()) { const matchedExt = exts.find((ext) => entry.name.endsWith(ext)); if (matchedExt) { + const noExtAbs = childAbs.slice(0, -matchedExt.length); items.push({ path: childRel.slice(0, -matchedExt.length), kind: "script", + _metaPath: noExtAbs + ".script.yaml", }); } } } } await walk(process.cwd(), ""); + + await Promise.all( + items.map(async (item) => { + if (!item._metaPath) return; + try { + const meta: any = await yamlParseFile(item._metaPath); + if (typeof meta?.summary === "string" && meta.summary.length > 0) { + item.summary = meta.summary; + } + } catch { + // No metadata file or unparseable — leave summary undefined + } + }) + ); + items.sort((a, b) => a.path.localeCompare(b.path)); - return items; + return items.map(({ _metaPath, ...item }) => item); } export interface DevOpts { diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 59882fb43c..beab7eaf1c 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -18,7 +18,13 @@ } from '$lib/gen' import { inferArgs } from '$lib/infer' import { userStore, workspaceStore } from '$lib/stores' - import { emptySchema, readFieldsRecursively, sendUserToast, type StateStore } from '$lib/utils' + import { + emptySchema, + pluralize, + readFieldsRecursively, + sendUserToast, + type StateStore + } from '$lib/utils' import { Pane, Splitpanes } from 'svelte-splitpanes' import { onDestroy, onMount, setContext, untrack } from 'svelte' import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte' @@ -38,7 +44,31 @@ import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { dfs } from './flows/dfs' import { loadSchemaFromModule } from './flows/flowInfers' - import { CornerDownLeft, Play, Workflow, Code, Layout } from 'lucide-svelte' + import { + CornerDownLeft, + Play, + Folder, + FolderTree, + User, + Search, + ChevronDown, + ChevronUp, + Code2, + LayoutDashboard + } from 'lucide-svelte' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import FlowIcon from '$lib/components/home/FlowIcon.svelte' + import { + groupItems, + type ItemType, + type FolderItem, + type UserItem + } from '$lib/components/home/treeViewUtils' + import SearchItems from '$lib/components/SearchItems.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import Row from '$lib/components/common/table/Row.svelte' + import { HOME_SEARCH_PLACEHOLDER } from '$lib/consts' import Toggle from './Toggle.svelte' import { setLicense } from '$lib/enterpriseUtils' import type { FlowCopilotContext } from './copilot/flow' @@ -165,7 +195,11 @@ const searchParams = indexQ > -1 ? new URLSearchParams(href.substring(indexQ)) : undefined let relativePaths: any[] = $state([]) - type WmPathItem = { path: string; kind: 'flow' | 'script' | 'raw_app' } + type WmPathItem = { + path: string + kind: 'flow' | 'script' | 'raw_app' + summary?: string + } function parseWatchPath(): string | undefined { const i = window.location.href.indexOf('?') if (i < 0) return undefined @@ -175,6 +209,30 @@ let watchPath = $state(parseWatchPath()?.replace(PATH_SUFFIX_RE, '')) let pickerItems: WmPathItem[] = $state([]) const pickerMode = $derived(!watchPath) + let pickerFilter = $state('') + let pickerKind: 'all' | 'flow' | 'script' | 'raw_app' = $state('all') + // Shape pickerItems into the homepage's ItemType so we can reuse `groupItems` + // for the folder/user tree structure. `kind` ('script'|'flow'|'raw_app') maps 1:1 + // onto ItemType['type']; missing fields (canWrite, edited_at, etc.) default to safe values. + const pickerTreeItems = $derived( + pickerItems.map( + (item) => + ({ + path: item.path, + summary: item.summary ?? '', + type: item.kind, + canWrite: true, + extra_perms: {}, + starred: false, + edited_at: '' + }) as unknown as ItemType + ) + ) + const pickerKindFilteredItems = $derived( + pickerKind === 'all' ? pickerTreeItems : pickerTreeItems.filter((i) => i.type === pickerKind) + ) + let pickerFilteredItems: (ItemType & { marked?: string })[] | undefined = $state(undefined) + const pickerGroups = $derived(groupItems(pickerFilteredItems ?? pickerKindFilteredItems)) if (searchParams?.has('local')) { connectWs() @@ -789,6 +847,92 @@
+ {#snippet itemRow(item: ItemType & { marked?: string }, depth: number)} + {@const wmItem = pickerItems.find((p) => p.path === item.path)} + + +
wmItem && pickPath(wmItem)} class="cursor-pointer border-b last:border-b-0"> + +
+ {/snippet} + {#snippet treeNode(node: ItemType | FolderItem | UserItem, depth: number)} + {#if 'folderName' in node} +
+ +
0 ? `padding-left: ${depth * 16}px;` : ''} + > +
+ {#if depth === 0} + + {:else} + + {/if} +
+
+ {#if depth === 0}f/{/if}{node.folderName} +
+ ({pluralize(node.items.length, ' item')}) +
+
+
+
+
+
+ {#each node.items as child ('folderName' in child ? `f__${child.folderName}` : 'username' in child ? `u__${child.username}` : `i__${child.type}__${child.path}`)} + {@render treeNode(child, depth + 1)} + {/each} +
+ {:else if 'username' in node} +
+ +
0 ? `padding-left: ${depth * 16}px;` : ''} + > +
+ +
+
+ u/{node.username} +
+ ({pluralize(node.items.length, ' item')}) +
+
+
+
+
+
+ {#each node.items as child ('folderName' in child ? `f__${child.folderName}` : 'username' in child ? `u__${child.username}` : `i__${child.type}__${child.path}`)} + {@render treeNode(child, depth + 1)} + {/each} +
+ {:else} + {@render itemRow(node as ItemType & { marked?: string }, depth)} + {/if} + {/snippet} + {#if pickerMode}
@@ -802,73 +946,72 @@ {/if}
-

Pick a file to preview

-

- Click a flow or script to load it in the dev editor. The URL will update so you can - bookmark or share it. -

+

+ {$workspaceStore} + (local) +

+

Click a flow or a script to preview it.

+ + `${item.path} ${item.summary ?? ''}`} + bind:filteredItems={pickerFilteredItems} + /> + +
+ + {#snippet children({ item })} + + + + + {/snippet} + + +
+ + + +
+
+ {#if pickerItems.length === 0}
No flows, scripts, or apps detected in this workspace.
+ {:else if pickerGroups.length === 0} +
No items match the search.
{:else} - {@const flows = pickerItems.filter((i) => i.kind === 'flow')} - {@const scripts = pickerItems.filter((i) => i.kind === 'script')} - {@const apps = pickerItems.filter((i) => i.kind === 'raw_app')} - {#if flows.length > 0} -

- Flows -

-
- {#each flows as item (item.path)} - - {/each} -
- {/if} - {#if scripts.length > 0} -

- Scripts -

-
- {#each scripts as item (item.path)} - - {/each} -
- {/if} - {#if apps.length > 0} -

- Apps - (use wmill app dev) -

-
- {#each apps as item (item.path)} - - {/each} -
- {/if} +
+ {#each pickerGroups as group ('folderName' in group ? `f__${group.folderName}` : 'username' in group ? `u__${group.username}` : `i__${group.type}__${group.path}`)} + {@render treeNode(group, 0)} + {/each} +
{/if}