fix: show folder item counts on the homepage tree before expansion

This commit is contained in:
Guilhem Lemouel
2026-07-27 10:10:38 +02:00
parent 907141152e
commit e18407de2d
5 changed files with 287 additions and 43 deletions
+41
View File
@@ -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/<folder> or u/<user>)
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/<folder> or u/<user>"
count:
type: integer
/w/{workspace}/flows/list:
get:
summary: list all flows
+165 -30
View File
@@ -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<Cursor, Error> {
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<String>,
fixed_params: usize,
) -> Option<String> {
match filter {
ScopePathFilter::AllowAll => None,
ScopePathFilter::Restricted { exact, prefix } => {
let mut terms: Vec<String> = 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<String>| -> Option<String> {
match filter {
ScopePathFilter::AllowAll => None,
ScopePathFilter::Restricted { exact, prefix } => {
let mut terms: Vec<String> = 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<String>,
show_archived: Option<bool>,
/// Include library scripts (no runnable main). Ignored for flows/apps.
include_without_main: Option<bool>,
}
#[derive(Serialize, sqlx::FromRow)]
struct OwnerCount {
/// Top-level owner prefix: `f/<folder>` or `u/<user>`.
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<UserDB>,
Path(w_id): Path<String>,
Query(q): Query<CountByOwnerQuery>,
) -> JsonResult<Vec<OwnerCount>> {
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<String> = vec![];
let mut branches: Vec<String> = vec![];
for kind in &kinds {
let mut w: Vec<String> = 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))
}
@@ -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 !== ''}
@@ -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<string, number>
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 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOwner}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
onclick={isEmptyOwner ? undefined : toggleOwner}
class={twMerge(
'px-4 py-2 border-b w-full flex flex-row items-center justify-between',
isEmptyOwner ? '' : 'cursor-pointer'
)}
>
<div
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
@@ -166,8 +188,13 @@
{#if isUser(item)}u/{item.username}{:else}{#if depth === 0}f/{/if}{item.folderName}{/if}
</span>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
{#if isLazyOwner && !ownerState?.loaded}
<!-- Lazy owner not expanded yet: its true item count is unknown until
{#if isLazyOwner && totalCount != undefined}
<!-- Lazy owner with a known server total: show it in every state (collapsed
or expanded) so the number doesn't jump on expand — item.items.length
counts only direct children (a subfolder collapses to one entry). -->
({pluralize(totalCount, ' item')})
{:else if isLazyOwner && !ownerState?.loaded}
<!-- Lazy owner, counts not available: its item count is unknown until
loaded, so showing "(0 items)" would be misleading. -->
&nbsp;
{:else if isLazyOwner && ownerState?.hasMore}
@@ -178,13 +205,15 @@
</div>
</div>
</div>
<button class="w-full flex flex-row-reverse">
{#if opened}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if !isEmptyOwner}
<button class="w-full flex flex-row-reverse">
{#if opened}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{/if}
</div>
{#if opened || isSearching}
<div>
@@ -229,10 +258,12 @@
</div>
{/if}
{#if ownerKey != undefined}
{#if ownerState?.loading && item.items.length === 0}
{#if ownerState?.loading && item.items.length === 0 && totalCount !== 0}
<!-- Show the spinner only on the first load, when there's nothing yet. A
re-sort/re-filter re-fetch keeps the old rows visible and swaps them
in place, so flashing "Loading…" under them would just be noise. -->
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. -->
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
{:else if !ownerState?.loading && ownerState?.hasMore && effectiveMax >= item.items.length}
<!-- Fetch the next server page only once every already-loaded row is shown
@@ -28,6 +28,9 @@
string,
{ cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean; count: number }
>
// Total visible items per owner prefix (server-side count), so a not-yet-loaded
// owner's header can still show its item count.
ownerCounts?: Record<string, number>
onExpandOwner?: (owner: string, more?: boolean) => void
onCollapseOwner?: (owner: string) => void
}
@@ -46,6 +49,7 @@
allFolders = [],
allUsers = [],
ownerLoad,
ownerCounts,
onExpandOwner,
onCollapseOwner
}: Props = $props()
@@ -142,6 +146,7 @@
{item}
{pipelineFolders}
{ownerLoad}
{ownerCounts}
{onExpandOwner}
{onCollapseOwner}
on:scriptChanged