From e18407de2d924234e436732c58b99d4e21027707 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 27 Jul 2026 10:10:38 +0200 Subject: [PATCH] fix: show folder item counts on the homepage tree before expansion --- backend/windmill-api/openapi.yaml | 41 ++++ backend/windmill-api/src/runnables.rs | 195 +++++++++++++++--- .../src/lib/components/home/ItemsList.svelte | 32 +++ .../src/lib/components/home/TreeView.svelte | 57 +++-- .../lib/components/home/TreeViewRoot.svelte | 5 + 5 files changed, 287 insertions(+), 43 deletions(-) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 15bc74920c..3629fe19d5 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10548,6 +10548,47 @@ paths: $ref: "#/components/schemas/RunnableItem" next_cursor: type: string + + /w/{workspace}/runnables/count_by_owner: + get: + summary: count visible runnables per top-level owner (f/ or u/) + operationId: countRunnablesByOwner + tags: + - script + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kinds + in: query + description: comma-separated subset of script,flow,app (default all) + schema: + type: string + - name: show_archived + in: query + schema: + type: boolean + - name: include_without_main + in: query + description: include library scripts (no runnable main) + schema: + type: boolean + responses: + "200": + description: total runnables per owner prefix (owners with no visible items are omitted) + content: + application/json: + schema: + type: array + items: + type: object + required: + - owner + - count + properties: + owner: + type: string + description: "top-level owner prefix: f/ or u/" + count: + type: integer /w/{workspace}/flows/list: get: summary: list all flows diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 02d28f06e9..8898ccef55 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -38,7 +38,9 @@ use windmill_types::scripts::ScriptHash; use windmill_types::user_drafts::DraftUserRef; pub fn workspaced_service() -> Router { - Router::new().route("/list", get(list_runnables)) + Router::new() + .route("/list", get(list_runnables)) + .route("/count_by_owner", get(count_runnables_by_owner)) } #[derive(Deserialize)] @@ -161,6 +163,40 @@ fn decode_cursor(raw: &str) -> Result { serde_json::from_slice(&bytes).map_err(|_| Error::BadRequest("invalid cursor".to_string())) } +/// Fine-grained scoped tokens (e.g. `scripts:read:f/foo/*`) must be confined to +/// their granted paths. RLS alone doesn't honor token scopes, so push the +/// per-domain path grant into SQL (empty grant -> the branch matches nothing). +/// Unscoped sessions -> AllowAll -> no predicate. `fixed_params` is the number +/// of placeholders bound before `binds` (binds[0] becomes `$fixed_params+1`). +fn scope_where( + filter: ScopePathFilter, + binds: &mut Vec, + fixed_params: usize, +) -> Option { + match filter { + ScopePathFilter::AllowAll => None, + ScopePathFilter::Restricted { exact, prefix } => { + let mut terms: Vec = vec![]; + for e in exact { + binds.push(e); + terms.push(format!("o.path = ${}", fixed_params + binds.len())); + } + for pre in prefix { + binds.push(pre.clone()); + let pe = format!("${}", fixed_params + binds.len()); + binds.push(format!("{}/%", escape_like(&pre))); + let pl = format!("${}", fixed_params + binds.len()); + terms.push(format!("(o.path = {} OR o.path LIKE {})", pe, pl)); + } + Some(if terms.is_empty() { + "false".to_string() + } else { + format!("({})", terms.join(" OR ")) + }) + } + } +} + /// Escape LIKE/ILIKE wildcards so a caller value (search term, path/scope /// prefix) matches literally. Relies on the default `\` escape character. fn escape_like(s: &str) -> String { @@ -329,34 +365,6 @@ async fn list_runnables( None => None, }; - // Fine-grained scoped tokens (e.g. `scripts:read:f/foo/*`) must be confined to - // their granted paths. RLS alone doesn't honor token scopes, so push the - // per-domain path grant into SQL (empty grant -> the branch matches nothing). - // Unscoped sessions -> AllowAll -> no predicate. - let scope_where = |filter: ScopePathFilter, binds: &mut Vec| -> Option { - match filter { - ScopePathFilter::AllowAll => None, - ScopePathFilter::Restricted { exact, prefix } => { - let mut terms: Vec = vec![]; - for e in exact { - binds.push(e); - terms.push(format!("o.path = ${}", 3 + binds.len())); - } - for pre in prefix { - binds.push(pre.clone()); - let pe = format!("${}", 3 + binds.len()); - binds.push(format!("{}/%", escape_like(&pre))); - let pl = format!("${}", 3 + binds.len()); - terms.push(format!("(o.path = {} OR o.path LIKE {})", pe, pl)); - } - Some(if terms.is_empty() { - "false".to_string() - } else { - format!("({})", terms.join(" OR ")) - }) - } - } - }; // Only push scope binds for kinds whose branch is actually included: a scoped token // with e.g. `kinds=script` omits the flow/app branches, so binding their scope values // (which no SQL references) would make the parameter count mismatch and 500. @@ -364,6 +372,7 @@ async fn list_runnables( scope_where( build_scope_path_filter(&authed, "scripts", "read"), &mut binds, + 3, ) } else { None @@ -372,12 +381,17 @@ async fn list_runnables( scope_where( build_scope_path_filter(&authed, "flows", "read"), &mut binds, + 3, ) } else { None }; let app_scope = if kinds.contains(&"app") { - scope_where(build_scope_path_filter(&authed, "apps", "read"), &mut binds) + scope_where( + build_scope_path_filter(&authed, "apps", "read"), + &mut binds, + 3, + ) } else { None }; @@ -535,3 +549,124 @@ async fn list_runnables( Ok(Json(ListRunnablesResponse { items, next_cursor })) } + +#[derive(Deserialize)] +struct CountByOwnerQuery { + /// Comma-separated subset of `script,flow,app`; omitted means all. + kinds: Option, + show_archived: Option, + /// Include library scripts (no runnable main). Ignored for flows/apps. + include_without_main: Option, +} + +#[derive(Serialize, sqlx::FromRow)] +struct OwnerCount { + /// Top-level owner prefix: `f/` or `u/`. + owner: String, + count: i64, +} + +/// Total visible runnables per top-level owner, under the same visibility +/// predicates as `list_runnables` (RLS, token scopes, archived/library/kind +/// filters). The homepage tree loads owners lazily, so this is what lets a +/// collapsed folder/user show its item count before ever being expanded. +async fn count_runnables_by_owner( + authed: ApiAuthed, + Extension(user_db): Extension, + Path(w_id): Path, + Query(q): Query, +) -> JsonResult> { + let show_archived = q.show_archived.unwrap_or(false); + let mut kinds: Vec<&str> = match q.kinds.as_deref() { + None | Some("") => vec!["script", "flow", "app"], + Some(csv) => csv + .split(',') + .map(|s| s.trim()) + .filter(|s| ["script", "flow", "app"].contains(s)) + .collect(), + }; + // Apps carry no `archived` column and are never listed as archived. + if show_archived { + kinds.retain(|k| *k != "app"); + } + let archived_pred = if show_archived { + "o.archived = true" + } else { + "o.archived = false" + }; + + let mut binds: Vec = vec![]; + let mut branches: Vec = vec![]; + for kind in &kinds { + let mut w: Vec = vec!["o.workspace_id = $1".to_string()]; + match *kind { + "script" => { + if !q.include_without_main.unwrap_or(false) || authed.is_operator { + w.push("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')".to_string()); + } + w.push(archived_pred.to_string()); + if show_archived { + // Same as list_runnables: only a path whose LATEST version row is + // archived belongs in the archived view (superseded versions of an + // active path are archived=true too and must not be counted). + w.push( + "o.ctid = (SELECT ctid FROM script s2 WHERE s2.path = o.path \ + AND s2.workspace_id = o.workspace_id ORDER BY s2.created_at DESC LIMIT 1)" + .to_string(), + ); + } + if let Some(s) = scope_where( + build_scope_path_filter(&authed, "scripts", "read"), + &mut binds, + 1, + ) { + w.push(s); + } + } + "flow" => { + w.push(archived_pred.to_string()); + if let Some(s) = scope_where( + build_scope_path_filter(&authed, "flows", "read"), + &mut binds, + 1, + ) { + w.push(s); + } + } + "app" => { + if let Some(s) = scope_where( + build_scope_path_filter(&authed, "apps", "read"), + &mut binds, + 1, + ) { + w.push(s); + } + } + _ => continue, + } + branches.push(format!( + "SELECT o.path FROM {kind} o WHERE {}", + w.join(" AND ") + )); + } + if branches.is_empty() { + return Ok(Json(vec![])); + } + + let sql = format!( + "SELECT split_part(path, '/', 1) || '/' || split_part(path, '/', 2) AS owner, \ + COUNT(*) AS count \ + FROM ({}) q GROUP BY 1", + branches.join(" UNION ALL ") + ); + + let mut tx = user_db.begin(&authed).await?; + let mut query = sqlx::query_as::<_, OwnerCount>(&sql).bind(&w_id); + for b in &binds { + query = query.bind(b); + } + let rows = query.fetch_all(&mut *tx).await?; + tx.commit().await?; + + Ok(Json(rows)) +} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 41de70108e..ad096b1356 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -477,6 +477,8 @@ // swap it in place (loadOwnerItems replaces each owner's rows atomically — the // old rows stay visible until the new ones arrive, so nothing blanks mid-reorder). for (const o of toReload) loadOwnerItems(o, false, true) + // Row mutations (create/archive/delete/move) change per-owner totals too. + if (treeLazyMode) ownerCountsRes.refetch() } function filterItemsPathsBaseOnUserFilters( @@ -716,6 +718,35 @@ // and each folder paginates within itself; users past the window are reached via // their owner chip. let treeGlobalHasMore = $derived(ownerFilter != undefined && !searching ? hasMoreServer : false) + // Per-owner totals for the lazy tree, fetched up front (one grouped COUNT, same + // visibility filters as the stream) so a collapsed owner's header can show its + // item count before it's ever expanded — its rows only load on expand. + // `undefined` (pre-load or on failure) keeps headers blank rather than showing + // a misleading 0. + let ownerCountsRes = resource( + () => ({ + ws: $workspaceStore, + lazy: treeLazyMode, + archived, + includeWithoutMain, + itemKind + }), + async ({ ws, lazy, archived, includeWithoutMain, itemKind }) => { + if (!ws || !lazy) return undefined + try { + const counts = await ScriptService.countRunnablesByOwner({ + workspace: ws, + showArchived: archived ? true : undefined, + includeWithoutMain: includeWithoutMain ? true : undefined, + kinds: itemKind !== 'all' ? itemKind : undefined + }) + return Object.fromEntries(counts.map((c) => [c.owner, c.count])) + } catch { + return undefined // best-effort: headers fall back to blank until expanded + } + } + ) + let ownerCounts = $derived(ownerCountsRes.current) $effect(() => { if ($userStore && $workspaceStore) { ;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind] @@ -1466,6 +1497,7 @@ allFolders={treeInjectFolders} allUsers={treeInjectUsers} ownerLoad={treeLazyMode ? ownerLoad : undefined} + ownerCounts={treeLazyMode ? ownerCounts : undefined} onExpandOwner={treeLazyMode ? loadOwnerItems : undefined} onCollapseOwner={treeLazyMode ? collapseOwner : undefined} isSearching={filter !== ''} diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index e437ccddf1..9b2cfe274a 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -23,6 +23,9 @@ string, { cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean; count: number } > + // Server-side total of visible items per owner prefix; lets a collapsed, + // not-yet-loaded owner show its item count. + ownerCounts?: Record onExpandOwner?: (owner: string, more?: boolean) => void onCollapseOwner?: (owner: string) => void // Position of this node among the rendered root nodes; "expand all" only @@ -38,6 +41,7 @@ isSearching = false, pipelineFolders, ownerLoad, + ownerCounts, onExpandOwner, onCollapseOwner, rootIndex = 0 @@ -79,6 +83,21 @@ // whose items are already grouped from the loaded window. let isLazyOwner = $derived(ownerKey != undefined && ownerLoad != undefined) let ownerState = $derived(ownerKey != undefined ? ownerLoad?.[ownerKey] : undefined) + // Server total for this owner (all descendants). A loaded counts map with no + // entry for this owner means it has no visible items — a real 0, not unknown. + let totalCount = $derived( + ownerKey != undefined && ownerCounts != undefined ? (ownerCounts[ownerKey] ?? 0) : undefined + ) + // Known to have nothing to reveal: zero server-counted items, none loaded, and + // no Pipeline entry (a pipeline folder shows a Pipeline row despite 0 counted + // items). Such a node is inert — no toggle, no chevron. + let isEmptyOwner = $derived( + isLazyOwner && + totalCount === 0 && + (isFolder(item) || isUser(item)) && + item.items.length === 0 && + !hasPipeline + ) let showMax = $state(15) // A lazy owner paginates server-side ("Load more"), so when opened on its own it @@ -144,8 +163,11 @@
- {#if isLazyOwner && !ownerState?.loaded} - + ({pluralize(totalCount, ' item')}) + {:else if isLazyOwner && !ownerState?.loaded} +   {:else if isLazyOwner && ownerState?.hasMore} @@ -178,13 +205,15 @@
- + {#if !isEmptyOwner} + + {/if} {#if opened || isSearching}
@@ -229,10 +258,12 @@
{/if} {#if ownerKey != undefined} - {#if ownerState?.loading && item.items.length === 0} + {#if ownerState?.loading && item.items.length === 0 && totalCount !== 0} + in place, so flashing "Loading…" under them would just be noise. + Same for an owner the server counted as empty: its fetch can only + come back empty, so "Loading…" then nothing reads as a glitch. -->
Loading…
{:else if !ownerState?.loading && ownerState?.hasMore && effectiveMax >= item.items.length}