feat(frontend): hide empty folders/users in home tree view

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UomUzAvrC2cto1T2QUBNnT
This commit is contained in:
Diego Imbert
2026-07-27 09:15:57 +02:00
parent 907141152e
commit 56d645ec24
4 changed files with 85 additions and 17 deletions
@@ -129,11 +129,17 @@ async fn list_folders(
Ok(Json(rows))
}
#[derive(Deserialize)]
pub struct ListFoldernamesQuery {
pub non_empty: Option<bool>,
}
async fn list_foldernames(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListFoldernamesQuery>,
) -> JsonResult<Vec<String>> {
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 } => {
+24 -2
View File
@@ -545,10 +545,16 @@ async fn update_tutorial_progress(
Ok("tutorial progress updated".to_string())
}
#[derive(Deserialize)]
pub struct ListUsernamesQuery {
pub non_empty: Option<bool>,
}
async fn list_usernames(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(lq): Query<ListUsernamesQuery>,
) -> JsonResult<Vec<String>> {
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))
}
+14
View File
@@ -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
@@ -124,12 +124,17 @@
new Set<string>([...(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/<user>/`).
// 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/<user>/`). 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'