perf(listing): return per-item is_favorite/is_shared, drop client badge fetches
The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.
Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
`PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
which already covers public links as 'token' grants — same membership the
/grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).
Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
badge sets straight from the listing and updates them optimistically on
favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
the single, authoritative, fetch-free source.
Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
@@ -169,6 +169,8 @@ impl FolderHandler {
|
||||
fn compute_listing_etag(
|
||||
folders: &[crate::application::dtos::folder_dto::FolderDto],
|
||||
files: &[crate::application::dtos::file_dto::FileDto],
|
||||
favorite_ids: &[String],
|
||||
shared_ids: &[String],
|
||||
) -> String {
|
||||
let max_mod = folders
|
||||
.iter()
|
||||
@@ -180,6 +182,10 @@ impl FolderHandler {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
max_mod.hash(&mut hasher);
|
||||
count.hash(&mut hasher);
|
||||
// Badge state is part of the representation — fold it in (both slices are
|
||||
// sorted, so the hash is stable) so a favorite/share change busts the ETag.
|
||||
favorite_ids.hash(&mut hasher);
|
||||
shared_ids.hash(&mut hasher);
|
||||
format!("\"{:x}\"", hasher.finish())
|
||||
}
|
||||
|
||||
@@ -205,7 +211,48 @@ impl FolderHandler {
|
||||
|
||||
match (folders_result, files_result) {
|
||||
(Ok(folders), Ok(files)) => {
|
||||
let etag = Self::compute_listing_etag(&folders, &files);
|
||||
// Badge enrichment for this listing: which items the caller has
|
||||
// favorited / shared. Two batched, index-backed queries (run
|
||||
// concurrently) replace the client's old per-navigation global
|
||||
// favorites + outgoing-shares fetches — correct (no 200-item
|
||||
// ceiling) and scoped to just the items on screen.
|
||||
let fav_pairs: Vec<(&str, &str)> = folders
|
||||
.iter()
|
||||
.map(|f| (f.id.as_str(), "folder"))
|
||||
.chain(files.iter().map(|f| (f.id.as_str(), "file")))
|
||||
.collect();
|
||||
let resource_uuids: Vec<uuid::Uuid> = folders
|
||||
.iter()
|
||||
.map(|f| f.id.as_str())
|
||||
.chain(files.iter().map(|f| f.id.as_str()))
|
||||
.filter_map(|s| uuid::Uuid::parse_str(s).ok())
|
||||
.collect();
|
||||
|
||||
let (favorited, shared) = tokio::join!(
|
||||
async {
|
||||
match &state.favorites_service {
|
||||
Some(svc) => svc
|
||||
.favorited_ids(auth_user.id, &fav_pairs)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
None => Default::default(),
|
||||
}
|
||||
},
|
||||
state
|
||||
.authorization
|
||||
.shared_resource_ids(auth_user.id, &resource_uuids)
|
||||
);
|
||||
|
||||
let mut favorite_ids: Vec<String> = favorited.into_iter().collect();
|
||||
favorite_ids.sort();
|
||||
let mut shared_ids: Vec<String> = shared
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect();
|
||||
shared_ids.sort();
|
||||
|
||||
let etag = Self::compute_listing_etag(&folders, &files, &favorite_ids, &shared_ids);
|
||||
|
||||
// 304 Not Modified if the client already has this version
|
||||
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
|
||||
@@ -219,7 +266,12 @@ impl FolderHandler {
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
let listing = FolderListingDto { folders, files };
|
||||
let listing = FolderListingDto {
|
||||
folders,
|
||||
files,
|
||||
favorite_ids,
|
||||
shared_ids,
|
||||
};
|
||||
let mut resp = (StatusCode::OK, Json(listing)).into_response();
|
||||
resp.headers_mut()
|
||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||
|
||||
Reference in New Issue
Block a user