From 56d645ec24dec2b30975b1ca838b66cdf08f4467 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 27 Jul 2026 09:15:57 +0200 Subject: [PATCH] feat(frontend): hide empty folders/users in home tree view Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UomUzAvrC2cto1T2QUBNnT --- backend/windmill-api-groups/src/folders.rs | 18 ++++++++ backend/windmill-api-users/src/users.rs | 26 ++++++++++- backend/windmill-api/openapi.yaml | 14 ++++++ .../src/lib/components/home/ItemsList.svelte | 44 ++++++++++++------- 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 8736f7b60f..1965f47fe5 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -129,11 +129,17 @@ async fn list_folders( Ok(Json(rows)) } +#[derive(Deserialize)] +pub struct ListFoldernamesQuery { + pub non_empty: Option, +} + async fn list_foldernames( authed: ApiAuthed, Extension(user_db): Extension, Path(w_id): Path, Query(pagination): Query, + Query(lq): Query, ) -> JsonResult> { let (per_page, offset) = paginate(pagination); let mut tx = user_db.begin(&authed).await?; @@ -144,6 +150,18 @@ async fn list_foldernames( // LIMIT would let a page return fewer than per_page while authorized folders remain // on later DB pages, stopping such a caller early.) let mut sql = String::from("SELECT name FROM folder WHERE workspace_id = $1"); + if lq.non_empty.unwrap_or(false) { + // Only folders holding at least one non-archived script/flow/app (the kinds the + // homepage lists) — a single scan per table semi-joined on the owner segment, + // rather than a correlated probe per folder. Runs under the user's RLS, so a + // folder whose items the caller cannot read counts as empty for them. + sql.push_str( + " AND name IN ( \ + SELECT split_part(path, '/', 2) FROM script WHERE workspace_id = $1 AND path LIKE 'f/%' AND archived = false \ + UNION SELECT split_part(path, '/', 2) FROM flow WHERE workspace_id = $1 AND path LIKE 'f/%' AND archived = false \ + UNION SELECT split_part(path, '/', 2) FROM app WHERE workspace_id = $1 AND path LIKE 'f/%')", + ); + } let restricted = match build_scope_path_filter(&authed, "folders", "read") { ScopePathFilter::AllowAll => None, ScopePathFilter::Restricted { exact, prefix } => { diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 0e22f4a09d..60c9a32335 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -545,10 +545,16 @@ async fn update_tutorial_progress( Ok("tutorial progress updated".to_string()) } +#[derive(Deserialize)] +pub struct ListUsernamesQuery { + pub non_empty: Option, +} + async fn list_usernames( authed: ApiAuthed, Extension(user_db): Extension, Path(w_id): Path, + Query(lq): Query, ) -> JsonResult> { if *CLOUD_HOSTED && w_id == "demo" { return Ok(Json(vec![ @@ -557,9 +563,25 @@ async fn list_usernames( ])); } let mut tx = user_db.begin(&authed).await?; - let rows = sqlx::query_scalar!("SELECT username from usr WHERE workspace_id = $1", &w_id) + let rows = if lq.non_empty.unwrap_or(false) { + // Only users whose personal space holds at least one non-archived + // script/flow/app (the kinds the homepage lists) — a single scan per table + // semi-joined on the owner segment. Runs under the user's RLS, so a user whose + // items the caller cannot read counts as empty for them. + sqlx::query_scalar::<_, String>( + "SELECT username FROM usr WHERE workspace_id = $1 AND username IN ( \ + SELECT split_part(path, '/', 2) FROM script WHERE workspace_id = $1 AND path LIKE 'u/%' AND archived = false \ + UNION SELECT split_part(path, '/', 2) FROM flow WHERE workspace_id = $1 AND path LIKE 'u/%' AND archived = false \ + UNION SELECT split_part(path, '/', 2) FROM app WHERE workspace_id = $1 AND path LIKE 'u/%')", + ) + .bind(&w_id) .fetch_all(&mut *tx) - .await?; + .await? + } else { + sqlx::query_scalar!("SELECT username from usr WHERE workspace_id = $1", &w_id) + .fetch_all(&mut *tx) + .await? + }; tx.commit().await?; Ok(Json(rows)) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 15bc74920c..c8b4638e99 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5707,6 +5707,13 @@ paths: - user parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: non_empty + in: query + description: + only list usernames whose user space contains at least one non-archived + script, flow or app (default false) + schema: + type: boolean responses: "200": description: user @@ -19583,6 +19590,13 @@ paths: description: only list the folders the user is member of (default false) schema: type: boolean + - name: non_empty + in: query + description: + only list folders containing at least one non-archived script, flow + or app (default false) + schema: + type: boolean responses: "200": description: folder list diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 41de70108e..706278972c 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -124,12 +124,17 @@ new Set([...(pipelineFoldersRes.current ?? []), ...pipelineMemberFolders]) ) - // The workspace's full folder list, independent of which items are paged in, - // so the owner facet and tree show every folder even when its items sit far - // down the sorted stream. Cheap and cached per workspace. + let archived = $state(false) + + // The workspace's folder list, independent of which items are paged in, so the + // owner facet and tree show a folder even when its items sit far down the sorted + // stream. Folders with no listable runnable are dropped server-side (non_empty) + // so auto-created/unused folders don't clutter the tree — except in the + // archived-only view, where a folder whose items are all archived would count as + // empty yet must stay reachable. let folderNamesRes = resource( - () => $workspaceStore, - async (ws) => { + [() => $workspaceStore, () => archived], + async ([ws, archivedOnly]) => { if (!ws) return [] as string[] // Page to exhaustion: listFolderNames is capped per page, so a workspace // with more folders than the cap would otherwise be truncated. @@ -137,7 +142,12 @@ const all: string[] = [] try { for (let page = 1; ; page++) { - const batch = await FolderService.listFolderNames({ workspace: ws, page, perPage }) + const batch = await FolderService.listFolderNames({ + workspace: ws, + page, + perPage, + nonEmpty: archivedOnly ? undefined : true + }) all.push(...batch) if (batch.length < perPage) break } @@ -148,16 +158,22 @@ } ) let allFolderOwners = $derived((folderNamesRes.current ?? []).map((f) => `f/${f}`)) - // Every workspace username, so a user whose items sit beyond the loaded browse - // window is still a selectable owner chip (scoping the stream to `u//`). - // Without this, user owners would derive only from loaded rows and a user past - // the first page would be unreachable in the tree without searching. + // Every workspace username with listable runnables, so a user whose items sit + // beyond the loaded browse window is still a selectable owner chip (scoping the + // stream to `u//`). Without this, user owners would derive only from + // loaded rows and a user past the first page would be unreachable in the tree + // without searching. Users with no runnable are dropped server-side (non_empty) + // to keep the tree uncluttered — except in the archived-only view, same as the + // folder list above. let usernamesRes = resource( - () => $workspaceStore, - async (ws) => { + [() => $workspaceStore, () => archived], + async ([ws, archivedOnly]) => { if (!ws) return [] as string[] try { - return await UserService.listUsernames({ workspace: ws }) + return await UserService.listUsernames({ + workspace: ws, + nonEmpty: archivedOnly ? undefined : true + }) } catch { return [] as string[] // best-effort facet } @@ -587,8 +603,6 @@ } } - let archived = $state(false) - const TREE_VIEW_SETTING_NAME = 'treeView' const FILTER_USER_FOLDER_SETTING_NAME = 'filterUserFolders' const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'