feat(breadcrumb): build breadcrumb in 1 API call
add /api/folders/{id}/ancestors
this API to iterate parent up to the drive root or the shared folder
this will help UI to build the breadcrumb in 1 API call
and to identify the root element (is it a drive users has access to or
a shared folder ?)
ui: now only 1 API call is now required to build the breadcrumb
This commit is contained in:
@@ -368,3 +368,108 @@ pub struct FolderResourceItemDto {
|
||||
|
||||
/// Response envelope for `GET /api/folders/{id}/resources`.
|
||||
pub type FolderResourcesDto = CursorListResponse<FolderResourceItemDto>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Folder ancestor chain (`GET /api/folders/{id}/ancestors`)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Serves the shared breadcrumb component on `/files` (and, when re-wired,
|
||||
// `/search`). One round-trip returns the whole caller-visible parent chain
|
||||
// plus an `access_source` describing HOW the caller reached the topmost
|
||||
// accessible ancestor (own drive / shared drive / direct folder share).
|
||||
// See docs/plan/… — added 2026-07-26.
|
||||
|
||||
/// Single crumb in the walk from the drive root (or share-boundary) down
|
||||
/// to the leaf. Present only for ancestors the caller has Read on; the
|
||||
/// walk stops at the first inaccessible parent.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct FolderAncestorDto {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// `None` on the drive-root folder. On boundary crumbs it's the id
|
||||
/// of the (invisible-to-caller) parent — clients don't render it
|
||||
/// but the field is preserved for debugging.
|
||||
pub parent_id: Option<Uuid>,
|
||||
/// Drive the folder belongs to. Always populated (every folder has
|
||||
/// a drive_id in the D0+ schema). Lets clients derive the current
|
||||
/// drive from `ancestors.at(-1).drive_id` without a second
|
||||
/// `GET /api/folders/{id}` round-trip — the ancestors response is
|
||||
/// the authoritative "everything I need for the folder-context
|
||||
/// header" call. See 2026-07-26 UX pass on /files load traffic.
|
||||
pub drive_id: Uuid,
|
||||
}
|
||||
|
||||
/// How the caller reached the topmost accessible ancestor. Drives the
|
||||
/// breadcrumb's root icon + tooltip.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccessSourceKind {
|
||||
/// Caller reached the topmost ancestor via drive membership (own
|
||||
/// personal drive OR a shared drive they are a member of). The
|
||||
/// `drive` field carries the drive info; render its `kind`-specific
|
||||
/// icon + name.
|
||||
Drive,
|
||||
/// Caller reached the topmost ancestor via a direct folder-level
|
||||
/// `role_grants` row (share). No drive-membership Read on any
|
||||
/// ancestor. The `subject` field (if known) says who was granted
|
||||
/// (self or a group); render the share icon.
|
||||
DirectShare,
|
||||
/// Reserved for public/token access. Not emitted by the MVP
|
||||
/// endpoint — no live UI code path drives an authenticated /files
|
||||
/// request via token yet.
|
||||
#[allow(dead_code)]
|
||||
Token,
|
||||
}
|
||||
|
||||
/// Drive info for `AccessSourceKind::Drive`. Split out so serde can drop
|
||||
/// it (`skip_serializing_if = "Option::is_none"`) when the kind isn't drive.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct AccessSourceDriveDto {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub kind: crate::application::dtos::drive_dto::DriveKindDto,
|
||||
}
|
||||
|
||||
/// Access-source detail returned alongside the ancestors chain.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct AccessSourceDto {
|
||||
pub kind: AccessSourceKind,
|
||||
/// Populated when `kind == Drive`. Null otherwise.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub drive: Option<AccessSourceDriveDto>,
|
||||
/// Populated when a `role_grants` row identifies the grantee (self
|
||||
/// or a group). MVP leaves this null — subject enrichment (grantor
|
||||
/// name / group name lookup) is a follow-up. Once populated the FE
|
||||
/// tooltip becomes "shared with **your team**" / "shared with **you
|
||||
/// by X**" instead of the generic "shared with you".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<AccessSourceSubjectDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccessSourceSubjectKind {
|
||||
User,
|
||||
Group,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct AccessSourceSubjectDto {
|
||||
pub kind: AccessSourceSubjectKind,
|
||||
pub id: Uuid,
|
||||
/// Display name (username / group name). MVP leaves this out — the
|
||||
/// endpoint returns `subject: None` entirely rather than emitting a
|
||||
/// half-populated `{id, name: null}`.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Response envelope for `GET /api/folders/{id}/ancestors`.
|
||||
///
|
||||
/// `ancestors` is root-first (drive root or share boundary as element
|
||||
/// 0), leaf-last. Length ≥ 1 (the leaf itself is always included).
|
||||
/// `access_source` describes the boundary at element 0.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct FolderAncestorsDto {
|
||||
pub ancestors: Vec<FolderAncestorDto>,
|
||||
pub access_source: AccessSourceDto,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::drive_dto::DriveKindDto;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
|
||||
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, CreateFolderDto, FolderAncestorDto,
|
||||
FolderAncestorsDto, FolderDto, FolderResourceCursor, FolderResourceRow, ListResourcesOptions,
|
||||
MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
@@ -1004,6 +1006,121 @@ fn cross_boundary_move_err() -> DomainError {
|
||||
// ── FolderService — cursor-paginated resource listing ────────────────────────
|
||||
|
||||
impl FolderService {
|
||||
/// Ancestor chain for the shared breadcrumb component. Returns the
|
||||
/// list of folders from the caller-visible root (drive root or
|
||||
/// share boundary) down to the leaf, plus an `access_source`
|
||||
/// describing HOW the caller reached that topmost ancestor.
|
||||
///
|
||||
/// AuthZ: requires `Read` on the leaf. Anti-enum via `NotFound` on
|
||||
/// denial (the `require` helper turns denials into 404 to match
|
||||
/// listing endpoints — same pattern used by `get_folder_with_perms`).
|
||||
///
|
||||
/// Boundary detection: the recursive SQL walks all the way to the
|
||||
/// drive root and reports two Read predicates per ancestor
|
||||
/// (`has_folder_grant`, `has_drive_grant`). We drop ancestors that
|
||||
/// have NEITHER — that's a folder the caller can't Read, which by
|
||||
/// definition means everything above it is also invisible to them.
|
||||
/// The last surviving ancestor is the "root of this caller's view."
|
||||
///
|
||||
/// Access-source kind: `Drive` when the topmost accessible ancestor's
|
||||
/// Read came (even in part) from drive-membership; `DirectShare`
|
||||
/// otherwise. `Token` is reserved for future public-link callers.
|
||||
/// Subject enrichment (grantor / group name) is deferred — MVP
|
||||
/// returns `subject: None` and the FE renders a generic tooltip.
|
||||
pub async fn get_ancestors_with_perms(
|
||||
&self,
|
||||
leaf_id: &str,
|
||||
caller_id: Uuid,
|
||||
) -> Result<FolderAncestorsDto, DomainError> {
|
||||
// Gate: caller must have Read on the leaf. Denial → 404 (anti-enum).
|
||||
self.authz
|
||||
.require(
|
||||
Subject::User(caller_id),
|
||||
Permission::Read,
|
||||
Self::folder_resource(leaf_id)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let leaf_uuid =
|
||||
Uuid::parse_str(leaf_id).map_err(|_| DomainError::not_found("Folder", leaf_id))?;
|
||||
|
||||
let mut rows = self
|
||||
.folder_storage
|
||||
.fetch_ancestor_walk(caller_id, leaf_uuid)
|
||||
.await?;
|
||||
if rows.is_empty() {
|
||||
return Err(DomainError::not_found("Folder", leaf_id));
|
||||
}
|
||||
// Repo returns root-first (ORDER BY depth DESC). Walk from index 0
|
||||
// (topmost) and drop entries with NO Read grant — that's the
|
||||
// share/drive boundary, everything above is invisible.
|
||||
let boundary = rows
|
||||
.iter()
|
||||
.position(|r| r.has_folder_grant || r.has_drive_grant)
|
||||
.unwrap_or(rows.len());
|
||||
rows.drain(..boundary);
|
||||
if rows.is_empty() {
|
||||
// Shouldn't happen: `authz.require(Read, leaf)` above passed,
|
||||
// so at least the leaf must have some Read source. Defensive
|
||||
// 404 rather than emit an empty chain.
|
||||
return Err(DomainError::not_found("Folder", leaf_id));
|
||||
}
|
||||
|
||||
// The topmost surviving row is the root of the caller's view.
|
||||
// Its grant profile drives `AccessSource`.
|
||||
let top = &rows[0];
|
||||
let access_source = if top.has_drive_grant {
|
||||
// Drive-membership Read — even if a direct folder grant also
|
||||
// exists, the drive channel is the more useful "how did I
|
||||
// get here" signal (it names the drive the caller sees in
|
||||
// their picker). Fetch the drive header for id/name/kind.
|
||||
// `.map` (not `match`) — the drive-vanished-mid-query fallback
|
||||
// is a straight `None`, no side effects; clippy's manual_map
|
||||
// lint prefers this shape.
|
||||
let drive = self
|
||||
.folder_storage
|
||||
.fetch_drive_header(top.drive_id)
|
||||
.await?
|
||||
.map(|(id, name, kind_str)| AccessSourceDriveDto {
|
||||
id,
|
||||
name,
|
||||
kind: match kind_str.as_str() {
|
||||
"personal" => DriveKindDto::Personal,
|
||||
_ => DriveKindDto::Shared,
|
||||
},
|
||||
});
|
||||
AccessSourceDto {
|
||||
kind: AccessSourceKind::Drive,
|
||||
drive,
|
||||
subject: None,
|
||||
}
|
||||
} else {
|
||||
// Direct folder-level grant (share). Subject enrichment is a
|
||||
// follow-up (see the DTO comment) — MVP surfaces the kind and
|
||||
// lets the FE render a generic "shared with you" tooltip.
|
||||
AccessSourceDto {
|
||||
kind: AccessSourceKind::DirectShare,
|
||||
drive: None,
|
||||
subject: None,
|
||||
}
|
||||
};
|
||||
|
||||
let ancestors = rows
|
||||
.into_iter()
|
||||
.map(|r| FolderAncestorDto {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
parent_id: r.parent_id,
|
||||
drive_id: r.drive_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(FolderAncestorsDto {
|
||||
ancestors,
|
||||
access_source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cursor-paginated listing of sub-folders **and** files inside `parent_id`.
|
||||
///
|
||||
/// Enforces `Permission::Read` on the parent folder before querying.
|
||||
|
||||
@@ -91,6 +91,24 @@ fn build_folders_with_flags(
|
||||
Ok((folders, flags))
|
||||
}
|
||||
|
||||
/// Row projected by `fetch_ancestor_walk`. One per folder in the
|
||||
/// leaf→root walk (root order is reversed to root-first by the caller).
|
||||
/// `has_folder_grant` = caller has a `role_grants` row on THIS folder;
|
||||
/// `has_drive_grant` = caller has drive-membership on the containing drive.
|
||||
/// Either grant satisfies Read; the split lets the service pick the right
|
||||
/// `AccessSource` kind (`Drive` vs `DirectShare`).
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct AncestorRow {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub drive_id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
pub depth: i32,
|
||||
pub has_folder_grant: bool,
|
||||
pub has_drive_grant: bool,
|
||||
}
|
||||
|
||||
/// Type alias for paginated folder rows (includes total_count as
|
||||
/// the last element after the §14 provenance columns). Same
|
||||
/// column set as [`FolderRow`] plus the trailing count.
|
||||
@@ -1467,6 +1485,94 @@ impl FolderDbRepository {
|
||||
.ok_or_else(|| DomainError::not_found("Folder", folder_id))
|
||||
}
|
||||
|
||||
/// Raw ancestor row returned by the recursive walk. `depth` is 0 at
|
||||
/// the leaf, growing as we move up. `has_drive_grant` / `has_folder_grant`
|
||||
/// are the two Read predicates the service uses to identify the
|
||||
/// share/drive boundary and choose the access-source kind.
|
||||
pub async fn fetch_ancestor_walk(
|
||||
&self,
|
||||
caller_id: Uuid,
|
||||
leaf_id: Uuid,
|
||||
) -> Result<Vec<AncestorRow>, DomainError> {
|
||||
// Recursive CTE walks `parent_id` from the leaf upward. Group ids
|
||||
// are hoisted into a one-row CTE so `caller_group_ids($1)` fires
|
||||
// once per query instead of per ancestor row (perf: the function
|
||||
// is `RECURSIVE` and non-trivial). Grant EXISTS are unions over
|
||||
// user + group subjects; the drive-grant subquery matches the
|
||||
// ambient `CALLER_CAN_READ_DRIVE` predicate used elsewhere so
|
||||
// access decisions stay consistent across the repo.
|
||||
let sql = r#"
|
||||
WITH RECURSIVE
|
||||
groups AS (
|
||||
SELECT ARRAY(SELECT storage.caller_group_ids($1)) AS ids
|
||||
),
|
||||
chain AS (
|
||||
SELECT id, name, parent_id, drive_id, 0::int AS depth
|
||||
FROM storage.folders WHERE id = $2::uuid
|
||||
UNION ALL
|
||||
SELECT f.id, f.name, f.parent_id, f.drive_id, c.depth + 1
|
||||
FROM storage.folders f
|
||||
JOIN chain c ON f.id = c.parent_id
|
||||
WHERE c.parent_id IS NOT NULL
|
||||
AND c.depth < 64
|
||||
)
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.parent_id,
|
||||
c.drive_id,
|
||||
c.depth,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g, groups
|
||||
WHERE g.resource_type = 'folder'
|
||||
AND g.resource_id = c.id
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND ((g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
|
||||
) AS has_folder_grant,
|
||||
EXISTS (
|
||||
SELECT 1 FROM storage.role_grants g, groups
|
||||
WHERE g.resource_type = 'drive'
|
||||
AND g.resource_id = c.drive_id
|
||||
AND (g.expires_at IS NULL OR g.expires_at > NOW())
|
||||
AND ((g.subject_type = 'user' AND g.subject_id = $1)
|
||||
OR (g.subject_type = 'group' AND g.subject_id = ANY(groups.ids)))
|
||||
) AS has_drive_grant
|
||||
FROM chain c
|
||||
ORDER BY c.depth DESC
|
||||
"#;
|
||||
sqlx::query_as::<_, AncestorRow>(sql)
|
||||
.bind(caller_id)
|
||||
.bind(leaf_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("ancestor walk: {e}")))
|
||||
}
|
||||
|
||||
/// Drive header (`id + name + kind`) for the drive-source arm of
|
||||
/// `AccessSourceDto`. Read-only; no authz gate — the caller already
|
||||
/// proved drive-membership via the ancestor walk before invoking.
|
||||
///
|
||||
/// Drive name lives on the drive's root folder, not the drive row
|
||||
/// itself (`docs/plan/drive.md §3`). The JOIN resolves it; a drive
|
||||
/// with a NULL `root_folder_id` returns None (backfill invariant
|
||||
/// violation — surfaced as "drive vanished mid-query" in the caller).
|
||||
pub async fn fetch_drive_header(
|
||||
&self,
|
||||
drive_id: Uuid,
|
||||
) -> Result<Option<(Uuid, String, String)>, DomainError> {
|
||||
sqlx::query_as::<_, (Uuid, String, String)>(
|
||||
"SELECT d.id, fo.name, d.kind::text \
|
||||
FROM storage.drives d \
|
||||
JOIN storage.folders fo ON fo.id = d.root_folder_id \
|
||||
WHERE d.id = $1::uuid",
|
||||
)
|
||||
.bind(drive_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("drive header lookup: {e}")))
|
||||
}
|
||||
|
||||
/// Cursor-paginated combined listing of sub-folders and files inside
|
||||
/// `parent_id`, sorted by `order_by`.
|
||||
///
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::application::dtos::display_helpers::{
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, FolderResourceItemDto, FolderResourcesDto, FolderResourcesQuery,
|
||||
ListResourcesOptions, MoveFolderDto, RenameFolderDto,
|
||||
CreateFolderDto, FolderAncestorsDto, FolderDto, FolderResourceItemDto, FolderResourcesDto,
|
||||
FolderResourcesQuery, ListResourcesOptions, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
|
||||
use crate::application::ports::external_mount_ports::MountEntry;
|
||||
@@ -112,6 +112,21 @@ impl FolderHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/folders/{id}/ancestors` — parent-chain + access-source
|
||||
/// for the shared breadcrumb component. See `FolderAncestorsDto`
|
||||
/// for the response shape. Anti-enum via `NotFound` on Read denial.
|
||||
pub(super) async fn get_folder_ancestors_impl(
|
||||
State(state): State<Arc<GlobalAppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let service = &state.applications.folder_service_concrete;
|
||||
match service.get_ancestors_with_perms(&id, auth_user.id).await {
|
||||
Ok(dto) => (StatusCode::OK, Json(dto)).into_response(),
|
||||
Err(err) => AppError::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists root folders for the authenticated user.
|
||||
/// Only returns folders owned by this user — no information disclosure.
|
||||
pub(super) async fn list_root_folders_impl(
|
||||
@@ -367,6 +382,25 @@ pub async fn get_folder(
|
||||
FolderHandler::get_folder_impl(state, auth_user, path).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders/{id}/ancestors",
|
||||
params(("id" = String, Path, description = "Leaf folder ID — the walk starts here and climbs the parent chain up to the drive root or the caller's share/drive-membership boundary.")),
|
||||
responses(
|
||||
(status = 200, description = "Ancestor chain + access-source. `ancestors` is root-first, leaf-last (length ≥ 1). See `FolderAncestorsDto`.", body = FolderAncestorsDto),
|
||||
(status = 404, description = "Folder not found or caller lacks Read (anti-enum)"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "folders"
|
||||
)]
|
||||
pub async fn get_folder_ancestors(
|
||||
state: State<Arc<GlobalAppState>>,
|
||||
auth_user: AuthUser,
|
||||
path: Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
FolderHandler::get_folder_ancestors_impl(state, auth_user, path).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/folders",
|
||||
|
||||
@@ -20,7 +20,9 @@ use crate::application::dtos::favorites_dto::{
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::{
|
||||
CreateFolderDto, FolderDto, FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
|
||||
AccessSourceDriveDto, AccessSourceDto, AccessSourceKind, AccessSourceSubjectDto,
|
||||
AccessSourceSubjectKind, CreateFolderDto, FolderAncestorDto, FolderAncestorsDto, FolderDto,
|
||||
FolderResourceItemDto, MoveFolderDto, RenameFolderDto,
|
||||
};
|
||||
use crate::application::dtos::folder_listing_dto::FolderListingDto;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
@@ -96,6 +98,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
// Folder handlers (free functions — see folder_handler.rs for why)
|
||||
handlers::folder_handler::create_folder,
|
||||
handlers::folder_handler::get_folder,
|
||||
handlers::folder_handler::get_folder_ancestors,
|
||||
handlers::folder_handler::list_root_folders,
|
||||
handlers::folder_handler::list_folder_resources,
|
||||
handlers::folder_handler::rename_folder,
|
||||
@@ -264,6 +267,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
FolderListingDto,
|
||||
FolderResourceItemDto,
|
||||
ResourceContentDto,
|
||||
// Folder ancestor chain (breadcrumb endpoint)
|
||||
FolderAncestorsDto,
|
||||
FolderAncestorDto,
|
||||
AccessSourceDto,
|
||||
AccessSourceKind,
|
||||
AccessSourceDriveDto,
|
||||
AccessSourceSubjectDto,
|
||||
AccessSourceSubjectKind,
|
||||
// File schemas
|
||||
FileDto,
|
||||
// Delta-upload schemas
|
||||
|
||||
@@ -82,7 +82,7 @@ use crate::interfaces::api::handlers::file_handler::{
|
||||
list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail,
|
||||
};
|
||||
use crate::interfaces::api::handlers::folder_handler::{
|
||||
create_folder, delete_folder_with_trash, download_folder_zip, get_folder,
|
||||
create_folder, delete_folder_with_trash, download_folder_zip, get_folder, get_folder_ancestors,
|
||||
list_folder_resources, list_root_folders, move_folder, rename_folder,
|
||||
};
|
||||
use crate::interfaces::api::handlers::i18n_handler::{
|
||||
@@ -218,6 +218,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let folders_crud_router = Router::new()
|
||||
.route("/", post(create_folder))
|
||||
.route("/{id}", get(get_folder))
|
||||
// Ancestor chain for the shared breadcrumb component — one
|
||||
// round-trip vs the pre-2026-07-26 per-segment `getFolder` walk
|
||||
// on the /files client. Returns caller-visible parents (walk
|
||||
// stops at share/drive-membership boundary) + access_source.
|
||||
.route("/{id}/ancestors", get(get_folder_ancestors))
|
||||
.route("/{id}/rename", put(rename_folder))
|
||||
.route("/{id}/move", put(move_folder))
|
||||
.with_state(app_state.clone());
|
||||
|
||||
Reference in New Issue
Block a user