Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list

feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
Dionisio Pozo
2026-07-22 06:51:28 +02:00
committed by GitHub
50 changed files with 1140 additions and 287 deletions
+4
View File
@@ -154,6 +154,8 @@ mod before {
etag,
created_by: parts.created_by,
updated_by: parts.updated_by,
is_favorite: false,
is_shared: false,
}
}
@@ -179,6 +181,8 @@ mod before {
etag,
created_by: folder.created_by(),
updated_by: folder.updated_by(),
is_favorite: false,
is_shared: false,
}
}
}
+2
View File
@@ -111,6 +111,8 @@ fn fixture_folder() -> FolderDto {
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}
}
+20
View File
@@ -97,6 +97,8 @@ fn rows(n: usize) -> Vec<FolderResourceRow> {
},
created_by: Some(Uuid::new_v4()),
updated_by: Some(Uuid::new_v4()),
is_favorite: false,
is_shared: false,
sort_str: format!("row {i}"),
type_order: 0,
folder_first: if is_folder { 0 } else { 1 },
@@ -130,6 +132,8 @@ fn map_before(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
category: intern_display("Folder"),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(dto.name, dto.icon_class, dto.category)
} else {
@@ -163,6 +167,8 @@ fn map_before(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
etag,
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(dto.name, dto.icon_class, dto.category)
}
@@ -191,6 +197,8 @@ fn map_after(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
category: intern_display("Folder"),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(dto.name, dto.icon_class, dto.category)
} else {
@@ -227,6 +235,8 @@ fn map_after(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
etag,
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(dto.name, dto.icon_class, dto.category)
}
@@ -266,6 +276,8 @@ fn fav_rows(n: usize) -> Vec<FavoriteResourceRow> {
},
created_by: Some(Uuid::new_v4()),
updated_by: Some(Uuid::new_v4()),
is_favorite: true,
is_shared: false,
is_owner: true,
favorited_at: ts,
path: Some(format!("Documents/Work/item-{i:05}")),
@@ -314,6 +326,8 @@ fn fav_map_before(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
category: intern_display("Folder"),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(
dto.name,
@@ -353,6 +367,8 @@ fn fav_map_before(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
etag,
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(
dto.name,
@@ -393,6 +409,8 @@ fn fav_map_after(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
category: intern_display("Folder"),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(
dto.name,
@@ -435,6 +453,8 @@ fn fav_map_after(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
etag,
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
};
(
dto.name,
+4
View File
@@ -242,6 +242,8 @@ mod before {
etag,
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}
}
}
@@ -297,6 +299,8 @@ fn folder_dtos(n: usize) -> Vec<FolderDto> {
category: Arc::from("Folder"),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
})
.collect()
}
@@ -29,9 +29,7 @@ function fakeRes(opts: { status: number; body?: ResourcePage }): Response {
const emptyListing = (): FolderListing => ({
folders: [],
files: [],
favoriteIds: [],
sharedIds: []
files: []
});
beforeEach(() => {
@@ -56,7 +54,6 @@ describe('fetchFolderListing (cursor-paginated /resources)', () => {
expect(r.status).toBe(200);
expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']);
expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']);
expect(r.listing?.favoriteIds).toEqual([]);
expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources');
});
@@ -124,9 +121,7 @@ describe('folder name cache (breadcrumbs)', () => {
it("learns its children's names from a cached listing", () => {
cacheFolder('nc-parent', {
folders: [folder('nc-a', 'Alpha'), folder('nc-b', 'Beta')],
files: [],
favoriteIds: [],
sharedIds: []
files: []
});
expect(getFolderName('nc-a')).toBe('Alpha');
expect(getFolderName('nc-b')).toBe('Beta');
+5 -6
View File
@@ -13,10 +13,9 @@ const NO_CACHE: RequestInit = {
export interface FolderListing {
folders: FolderItem[];
files: FileItem[];
/** Ids in this listing the caller has favorited (server-computed badge set). */
favoriteIds: string[];
/** Ids in this listing the caller has an outgoing share/grant on. */
sharedIds: string[];
// Legacy `favoriteIds` / `sharedIds` fields are gone — the same
// signals now live inline on every `FileItem` / `FolderItem`
// (`is_favorite`, `is_shared`). Consumers read those directly.
}
/** Result of a (possibly conditional) listing fetch. */
@@ -213,13 +212,13 @@ export async function fetchFolderListing(
files.push(...page.files);
cursor = page.nextCursor;
} while (cursor);
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
return { status: 200, listing: { folders, files } };
}
/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
const res = await fetchFolderListing(folderId, { forceRefresh });
return res.listing ?? { folders: [], files: [], favoriteIds: [], sharedIds: [] };
return res.listing ?? { folders: [], files: [] };
}
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
+15
View File
@@ -89,6 +89,21 @@ export function expiryToIso(date: string | null | undefined): string | null {
return date ? new Date(`${date}T00:00:00Z`).toISOString() : null;
}
/**
* Today's date in YYYY-MM-DD form (local time zone). Used as the `min`
* attribute on grant / share expiry date inputs so the native picker
* refuses to select a past date. Callers should also validate the
* changed value in their `onchange` handler as a belt-and-braces guard
* (some browsers still let scripted / paste input bypass `min`).
*/
export function todayIso(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function fetchGrantsForResource(type: GrantResourceType, id: string): Promise<Grant[]> {
const params = new URLSearchParams({ resource_type: type, resource_id: id });
return apiJson<Grant[]>(`/api/grants?${params}`, { credentials: 'same-origin' });
+19
View File
@@ -48,6 +48,21 @@ export interface FolderItem {
* leading segment being a drive-root folder id.
*/
drive_id: string;
/**
* Caller-scoped: `true` when the requesting user has favorited
* this folder. Always present on the wire — never null, never
* absent — per the backend enrichment contract. `ResourceList`
* renders the fav-star chip natively from this field.
*/
is_favorite: boolean;
/**
* Resource-scoped: `true` when the folder has any
* `storage.role_grants` entry (link share via `subject_type =
* 'token'`, user grant, group grant, any role). "Someone was
* given access to this beyond drive membership." Always present
* on the wire.
*/
is_shared: boolean;
}
export interface FileItem {
@@ -70,6 +85,10 @@ export interface FileItem {
sort_date: number;
etag: string;
content_hash: string;
/** See `FolderItem.is_favorite` — same wire contract. */
is_favorite: boolean;
/** See `FolderItem.is_shared` — same wire contract. */
is_shared: boolean;
/** Search-only: plain-text fragment around a content match. */
snippet?: string;
/** Search-only: "name" or "content". */
@@ -69,9 +69,7 @@ beforeEach(() => {
vi.clearAllMocks();
m(listFolder).mockResolvedValue({
folders: [folder('sub1', 'Sub')],
files: [],
favoriteIds: [],
sharedIds: []
files: []
});
});
+55 -27
View File
@@ -119,13 +119,6 @@
* fields (`modified_at`, `created_by`).
*/
contextMap?: Map<string, ItemContext>;
/**
* Set of item ids the caller considers "favorite". When
* provided, the star widget renders next to each row and
* `onfavorite` is invoked on click. Kept as an external Set so
* the page owns the source of truth (e.g. the favorites store).
*/
favoriteIds?: Set<string>;
/**
* Resolve `userId → display name`. Optional; when absent
* `UserVignette` falls back to its own internal resolution.
@@ -213,6 +206,14 @@
onopen?: (item: FileItem | FolderItem) => void;
/** Per-item favorite star toggle. */
onfavorite?: (item: FileItem | FolderItem) => void;
/**
* Per-item share affordance — opens the page's ShareDialog for
* this row. Wired symmetrically to `onfavorite`: the button lives
* in `.action-cell`, its `.active` styling tracks `item.is_shared`,
* and rows that are shared keep the button visible in list view
* even when the row isn't hovered.
*/
onshared?: (item: FileItem | FolderItem) => void;
/** Selection changed (set of selected item ids). */
onselectionchange?: (ids: Set<string>) => void;
/**
@@ -353,7 +354,6 @@
title,
items,
contextMap,
favoriteIds,
resolveOwnerName,
loading = false,
error = null,
@@ -383,6 +383,7 @@
onreload,
onopen,
onfavorite,
onshared,
onselectionchange,
oncontextmenu: onContextMenuOverride,
menuPrepare,
@@ -463,7 +464,11 @@
// gate below. Feeds both the list-view column track and the header
// row's trailing placeholder so the layout stays in sync.
const hasActionCell = $derived(
!!onfavorite || !!itemActions || !!onContextMenuOverride || !!contextActions?.length
!!onfavorite ||
!!onshared ||
!!itemActions ||
!!onContextMenuOverride ||
!!contextActions?.length
);
// Build the list-view column track from the enabled cells.
@@ -978,7 +983,6 @@
{#snippet row(item: FileItem | FolderItem)}
{@const kind = isFile(item) ? 'file' : 'folder'}
{@const iconName = kind === 'folder' ? 'folder' : iconNameFromClass(iconClassOf(item))}
{@const isFav = favoriteIds?.has(item.id) ?? false}
{@const ctx = ctxOf(item.id)}
{@const ownerId = ownerIdOf(item)}
{@const dateVal = dateOf(item)}
@@ -1060,11 +1064,11 @@
}}
/>
{/if}
<!-- Row badge (e.g. /trash's expiration chip) sits absolutely
inside `.file-icon` — one DOM location, one render, works
for both views. Because it's positioned absolutely it
never affects the row/card height; the surrounding
layout can't stretch to accommodate it. -->
<!-- `rowBadge` snippet — page-specific extension slot (e.g.
`/trash`'s expiration chip). `is_favorite` / `is_shared`
are NOT rendered here — both are surfaced as buttons in
`.action-cell` so the two flags share a single visual
grammar (button whose `.active` styling tracks the flag). -->
{#if rowBadge}
<span class="file-icon__badge">{@render rowBadge(item, ctx)}</span>
{/if}
@@ -1106,31 +1110,55 @@
</div>
<!--
Every row that surfaces an action puts everything into a single
`.action-cell` — the shared `ported/resourceList.css` styles both
the favorite-star and the `.file-actions` kebab expecting them to
live inside `.action-cell` (grid view uses the corner-overlay CSS
to float `.file-actions` into the top-right; list view flexes them
inline). The cell renders when ANY of favorite / itemActions /
context-menu is enabled; a row with none of those still lays out
cleanly because the columns collapse via the grid track.
`.action-cell` — the shared `ported/resourceList.css` styles the
favorite-star, the shared-button, and the `.file-actions` kebab
expecting them to live inside `.action-cell` (grid view uses the
corner-overlay CSS to float them into the top-right; list view
flexes them inline). The cell renders when ANY of favorite /
shared / itemActions / context-menu is enabled; a row with none
of those still lays out cleanly because the columns collapse via
the grid track.
Fav-star and shared-button share the same interaction grammar:
each toggles/opens the corresponding affordance, each carries an
`.active` class that tracks its DTO flag, and (via CSS in
`ported/resourceList.css`) each stays visible in list view even
without a row hover when its `.active` flag is set — so a
favorited or shared row is discoverable at a glance without the
user having to mouse over it.
-->
{#if onfavorite || itemActions || onContextMenuOverride || contextActions?.length}
{#if hasActionCell}
<div class="action-cell">
{#if onshared}
<button
class="shared-button"
class:active={item.is_shared}
data-testid={`resource-list-shared-${item.id}-btn`}
title={item.is_shared ? t('files.shared', 'Shared') : t('files.share', 'Share')}
aria-pressed={item.is_shared}
onclick={(e) => {
e.stopPropagation();
onshared(item);
}}
>
<Icon name="oxiexport" />
</button>
{/if}
{#if onfavorite}
<button
class="favorite-star"
class:active={isFav}
class:active={item.is_favorite}
data-testid={`resource-list-favorite-${item.id}-btn`}
title={isFav
title={item.is_favorite
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={isFav}
aria-pressed={item.is_favorite}
onclick={(e) => {
e.stopPropagation();
onfavorite(item);
}}
>
<Icon name={isFav ? 'star' : 'star-outline'} />
<Icon name={item.is_favorite ? 'star' : 'star-outline'} />
</button>
{/if}
{#if itemActions}{@render itemActions(item)}{/if}
+82 -15
View File
@@ -14,6 +14,7 @@
fetchGrantsForResource,
notifyGrantRecipient,
revokeGrant,
todayIso,
updateGrantRole,
type Grant,
type GrantSubject,
@@ -378,15 +379,33 @@
});
</script>
<!-- ── Reusable expiry chip ─────────────────────────────────────────────── -->
<!-- ── Reusable expiry chip ───────────────────────────────────────────────
Both branches drive a `<input type="date">` and open the native
picker via `HTMLInputElement.showPicker()`. The previous invisible-
overlay trick (an `opacity: 0` input covering a chip label) was
unreliable — some browsers refuse to open the picker for a hidden
input, which is why "Set expiry" appeared inert. `showPicker()`
is the modern, explicit path and works from a button click.
`min={todayIso()}` (from `../../api/endpoints/grants`) hard-caps
the picker to today-or-later so a past date can't be selected.
The `onchange` handler mirrors the same check as a belt-and-braces
guard against the min attribute being ignored. -->
{#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)}
{@const today = todayIso()}
<span class="chip-edit">
{#if value}
<input
class="chip-edit__date"
type="date"
value={value ?? ''}
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
min={today}
onchange={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
if (v && v < today) return;
onchange(v || null);
}}
aria-label={t('share.expiry', 'Expiry')}
/>
<button
@@ -396,16 +415,34 @@
aria-label={t('actions.clear', 'Clear')}>×</button
>
{:else}
<label class="chip chip--ghost">
<Icon name="infinity" />
<span>{t('share.noExpiry', 'No expiry')}</span>
<input
class="chip-edit__date chip-edit__date--hidden"
type="date"
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
<span class="chip-edit__ghost">
<button
type="button"
class="chip chip--ghost"
aria-label={t('share.set_expiry', 'Set expiry')}
onclick={(e) => {
const picker = (e.currentTarget as HTMLElement)
.nextElementSibling as HTMLInputElement | null;
picker?.showPicker?.();
picker?.focus();
}}
>
<Icon name="infinity" />
<span>{t('share.noExpiry', 'No expiry')}</span>
</button>
<input
class="chip-edit__date chip-edit__date--offscreen"
type="date"
min={today}
aria-hidden="true"
tabindex="-1"
onchange={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
if (v && v < today) return;
onchange(v || null);
}}
/>
</label>
</span>
{/if}
</span>
{/snippet}
@@ -584,7 +621,12 @@
type="date"
data-testid="share-dialog-link-expires-input"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
min={todayIso()}
onchange={(e) => {
const v = e.currentTarget.value;
if (v && v < todayIso()) return;
expiresAt = v || null;
}}
/>
</label>
</div>
@@ -892,7 +934,19 @@
.chip--ghost {
border-style: dashed;
color: var(--color-text-muted);
/* WCAG-friendly foreground on both light and dark surfaces —
`--color-text-muted` was under the minimum AA contrast ratio,
making "No expiry" hard to read. Use the subtle-but-not-muted
text token instead, and give the ghost chip a low-tint
background so it visually separates from the modal body. */
color: var(--color-text-subtle);
background: var(--color-bg-input);
}
.chip--ghost:hover,
.chip--ghost:focus-visible {
color: var(--color-text);
background: var(--color-border-subtle);
}
.chip-edit__date {
@@ -904,11 +958,24 @@
font-size: var(--text-sm);
}
.chip-edit__date--hidden {
/* Positions the hidden `<input type="date">` off-screen (no `display:
none` — `showPicker()` refuses to open on a display:none input in
several browsers). The button next to it invokes `showPicker()`
programmatically. */
.chip-edit__ghost {
position: relative;
display: inline-flex;
align-items: center;
}
.chip-edit__date--offscreen {
position: absolute;
inset: 0;
width: 1px;
height: 1px;
left: 0;
bottom: 0;
opacity: 0;
cursor: pointer;
pointer-events: none;
}
.chip-edit__clear {
@@ -18,6 +18,7 @@ vi.mock('$lib/api/endpoints/grants', () => ({
fetchGrantsForResource: vi.fn(),
notifyGrantRecipient: vi.fn(),
revokeGrant: vi.fn(),
todayIso: () => '2026-07-22',
updateGrantRole: vi.fn()
}));
vi.mock('$lib/api/endpoints/recipients', () => ({
@@ -43,6 +43,8 @@ interface TestFile {
sort_date: number;
etag: string;
content_hash: string;
is_favorite: boolean;
is_shared: boolean;
}
function fileItem(i: number): TestFile {
@@ -63,7 +65,9 @@ function fileItem(i: number): TestFile {
size_formatted: '4 B',
sort_date: 0,
etag: 'e',
content_hash: 'h'
content_hash: 'h',
is_favorite: false,
is_shared: false
};
}
+147 -53
View File
@@ -178,10 +178,6 @@
background-color: var(--color-warning-ring);
}
.file-badge-shared {
color: var(--color-badge-blue-text);
}
.file-item .file-icon > i,
.file-item .file-icon > svg {
position: absolute;
@@ -460,7 +456,17 @@
}
.files-list-view .file-item .action-cell {
/* Right-justified flex row: gives every list-view action button
(shared, fav, broom, kebab, trash's restore/delete, …) a
predictable horizontal cluster with a stable gap. The legacy
`display: inline` on individual buttons ignored `width`/`height`,
so once /recent grew a 4th button the mix of `inline` + `inline-
flex` (`.btn-action`) started wrapping onto pseudo-rows that
read as a broken grid. Flex flattens the mix into one line. */
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-1);
text-align: right;
}
@@ -493,27 +499,44 @@
color: var(--color-text-dark);
}
/* could be visible if we want */
.files-list-view .file-item .action-cell button.favorite-star {
display: none;
/* Fav-star + shared-button share the same visibility rule: hidden on
quiet rows, visible on row hover, and — crucially — always visible
when their `.active` class is set. That's what lets a favorited or
shared row be discoverable at a glance in list view without the
user having to mouse over it.
We use `visibility: hidden` (not `display: none`) so a hidden
button still reserves its slot in the action cell. Otherwise a row
that's shared-but-not-favorited would slide its shared icon into
the fav-star's column, breaking vertical alignment across rows. */
.files-list-view .file-item .action-cell button.favorite-star,
.files-list-view .file-item .action-cell button.shared-button {
visibility: hidden;
border: none;
}
.files-list-view .file-item:hover .action-cell button.favorite-star {
display: inline;
.files-list-view .file-item:hover .action-cell button.favorite-star,
.files-list-view .file-item:hover .action-cell button.shared-button,
.files-list-view .file-item .action-cell button.favorite-star.active,
.files-list-view .file-item .action-cell button.shared-button.active {
visibility: visible;
}
/* Reveal the kebab on hover for cleaner rows — but only on hover-capable
devices, so touch users (no hover) keep it always tappable. Stays visible
on keyboard focus within the row. */
on keyboard focus within the row. Applies to both list and grid views
because both keep the kebab inside `.action-cell`. */
@media (hover: hover) {
.files-list-view .file-item .action-cell button.file-actions {
.files-list-view .file-item .action-cell button.file-actions,
.files-grid-view .file-item .action-cell button.file-actions {
opacity: 0;
transition: opacity var(--motion-fast) var(--ease-standard);
}
.files-list-view .file-item:hover .action-cell button.file-actions,
.files-list-view .file-item:focus-within .action-cell button.file-actions {
.files-list-view .file-item:focus-within .action-cell button.file-actions,
.files-grid-view .file-item:hover .action-cell button.file-actions,
.files-grid-view .file-item:focus-within .action-cell button.file-actions {
opacity: 1;
}
}
@@ -737,26 +760,95 @@
z-index: 10;
display: flex;
gap: var(--space-1);
opacity: 0;
transition: opacity var(--motion-fast) var(--ease-standard);
}
.files-grid-view .file-item:hover .action-cell,
.files-grid-view .file-item:focus-within .action-cell,
.files-grid-view .file-item .action-cell:has(.favorite-star.active) {
opacity: 1;
/* Full-width top action bar — only applies when the row surfaces at
least one state chip (shared or favorite). Trash (no shared / no
favorite) falls through to the horizontal corner cluster above,
preserving its Restore / Delete / kebab layout.
Layout target:
┌────────────────────────────────────────────┐
│[chk] [kebab] [btn-action…] [shared] [fav] │ ← single row
│ │
│ (thumbnail) │
│ │
└────────────────────────────────────────────┘
The checkbox stays where it is (its own `.checkbox-cell` absolute at
top-left); the action-cell spans horizontally next to it so the two
clusters read as one row. `justify-content: space-between` + `order`
splits the flex row into a left group (kebab + btn-actions) and a
right group (shared + favorite) without fighting DOM order — the
markup still has shared, favorite, itemActions, kebab in that
sequence so list-view's inline right-aligned flow is unchanged. */
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) {
/* Start right after the checkbox column (26px chip + inline gap)
so the left group aligns visually with the checkbox row. */
left: calc(var(--space-3) + 8px + 26px + var(--space-2));
right: calc(var(--space-3) + 8px);
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-1);
}
/* The favorite state is already shown by the corner star button, so the inline
name-cell favorite badge is redundant on grid cards. */
.files-grid-view .file-item .name-cell .item-badge {
display: none;
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .file-actions {
order: 1;
}
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .btn-action {
order: 2;
}
/* The first "right group" item eats all remaining horizontal space via
`margin-left: auto`, which is the flexbox idiom for splitting a row
into left+right clusters without wrapping the two groups in extra
containers. When both buttons are wired, shared is first (`order: 3`)
and takes the push; fav follows with the normal gap. When only fav
is wired (`/shared-with-me` — recipient side, no share affordance),
fav is the sole right-group item and takes the push instead. */
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .shared-button {
order: 3;
margin-left: auto;
}
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .favorite-star {
order: 4;
}
.files-grid-view
.file-item
.action-cell:has(.favorite-star):not(:has(.shared-button))
.favorite-star {
margin-left: auto;
}
/* Per-button visibility (mirrors list view): each button is independently
gated by its own `.active` flag OR row-hover. This prevents a favorited
row from also lighting up the shared button (and vice versa) — the cell
used to reveal all its children together via a single opacity toggle. */
.files-grid-view .file-item .action-cell .favorite-star,
.files-grid-view .file-item .action-cell .shared-button {
visibility: hidden;
transition: visibility var(--motion-fast) var(--ease-standard);
}
.files-grid-view .file-item:hover .action-cell .favorite-star,
.files-grid-view .file-item:hover .action-cell .shared-button,
.files-grid-view .file-item:focus-within .action-cell .favorite-star,
.files-grid-view .file-item:focus-within .action-cell .shared-button,
.files-grid-view .file-item .action-cell .favorite-star.active,
.files-grid-view .file-item .action-cell .shared-button.active {
visibility: visible;
}
/* Chip visuals for anything inside the corner cluster — the kebab, the star,
any `.btn-action`. Uniform 30x30 scrim pill so they line up in the flex row. */
the shared button, any `.btn-action`. Uniform 30x30 scrim pill so they
line up in the flex row. */
.files-grid-view .file-item .action-cell .file-actions,
.files-grid-view .file-item .action-cell .favorite-star,
.files-grid-view .file-item .action-cell .shared-button,
.files-grid-view .file-item .action-cell .btn-action {
position: static;
width: 30px;
@@ -792,36 +884,15 @@
border: 2px dashed var(--color-warning-border);
}
/* "Shared" indicator — top-left of the thumbnail. Sits below the checkbox
(which only appears on hover), so the two never both compete for the eye. */
.files-grid-view .file-item .file-badge-shared {
position: absolute;
top: calc(var(--space-3) + 8px);
left: calc(var(--space-3) + 8px);
width: 24px;
height: 24px;
border-radius: var(--radius-full);
border: none;
background: var(--color-scrim-control);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
box-shadow: 0 1px 3px var(--color-shadow-sm);
display: flex;
align-items: center;
justify-content: center;
z-index: 9;
font-size: var(--text-2xs);
padding: 0;
line-height: var(--leading-none);
}
/* Favorite star — visual overrides only. Position, hover-reveal, chip
geometry all come from the shared corner-cluster rule on
`.files-grid-view .file-item .action-cell`. What's left here is just
the star's per-state colour: subtle at rest, active-gold when the
item is a favorite. `.active` still bumps the parent cluster's
opacity so an unhovered card can still show its star. */
.files-grid-view .file-item button.favorite-star {
/* Favorite star + shared button — visual overrides only. Position,
hover-reveal, chip geometry all come from the shared corner-cluster
rule on `.files-grid-view .file-item .action-cell`. What's left
here is just the per-state colour: subtle at rest, saturated when
the item's flag is set. `.active` on either button also bumps the
parent cluster's opacity (via `:has()` above) so an unhovered card
still shows its favorited/shared state. */
.files-grid-view .file-item button.favorite-star,
.files-grid-view .file-item button.shared-button {
color: var(--color-text-subtle);
font-size: 15px;
line-height: var(--leading-none);
@@ -839,6 +910,11 @@
color: var(--color-star-active);
}
.files-grid-view .file-item button.shared-button:hover,
.files-grid-view .file-item button.shared-button.active {
color: var(--color-badge-blue-text);
}
.files-grid-view .file-item .name-cell {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
@@ -1201,6 +1277,24 @@
color: var(--color-text-dark);
}
/* Opt-in modifier: hide the button until the row is hovered / focused.
Used by `/recent`'s per-row broom (a history-management action that
shouldn't distract from the row content at rest). Trash's Restore /
Delete stay on the plain `.btn-action` — those are the reason the
user opened trash, and hiding them would fail Fitts' law. */
.files-list-view .file-item .action-cell .btn-action--hover,
.files-grid-view .file-item .action-cell .btn-action--hover {
visibility: hidden;
transition: visibility var(--motion-fast) var(--ease-standard);
}
.files-list-view .file-item:hover .action-cell .btn-action--hover,
.files-list-view .file-item:focus-within .action-cell .btn-action--hover,
.files-grid-view .file-item:hover .action-cell .btn-action--hover,
.files-grid-view .file-item:focus-within .action-cell .btn-action--hover {
visibility: visible;
}
/* Legacy: a margin-top on `.btn-action` in grid view for the era when
these buttons flowed at the bottom of the card. Kept for any
free-standing use outside the corner cluster; reset inside
+5 -1
View File
@@ -38,6 +38,10 @@ export function minimalPhotoItem(id: string): FileItem {
size_formatted: '',
sort_date: 0,
etag: '',
content_hash: ''
content_hash: '',
// Stub item — never wired to a live server response, so the
// two required wire flags default to the safe "not set" value.
is_favorite: false,
is_shared: false
};
}
+10 -19
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteMap } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext';
import Button from '$lib/components/Button.svelte';
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
@@ -50,22 +50,15 @@
// action surfaces don't filter, algorithmic surfaces do.
//
// ResourceList consumes raw `FileItem | FolderItem`; the favorites
// envelope contributes `favorited_at` via `date` in contextMap. All
// items on this page are favorites — pass every id in `favoriteIds`
// so the star widget lights up universally.
// envelope contributes `favorited_at` via `date` in contextMap.
// Every item on this page has `is_favorite: true` by construction
// (the listing SQL hardcodes `TRUE AS is_favorite`), so the star
// widget lights up universally without any shadow set here.
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
// on every infinite-scroll page.
const contextMap = new SvelteMap<string, ItemContext>();
// Persistent reactive set, updated in place per page (add the fresh page's
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
// whole accumulated list on every infinite-scroll page — that was O(N²)
// across a drain and, being a new instance each page, invalidated every
// mounted star reader. Every item on this page is a favorite, and removed
// items are no longer rendered, so the set only needs to be a superset of
// the displayed ids (benches/ROUND14.md §F2, mirrors recent's shipped shape).
const favoriteIds = new SvelteSet<string>();
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
@@ -114,10 +107,6 @@
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
// Keep the persistent favoriteIds set in sync incrementally: clear on
// reset, then add only this page's ids (benches/ROUND14.md §F2).
if (reset) favoriteIds.clear();
for (const it of page.items) favoriteIds.add(it.resource.id);
primeContextPage(contextMap, reset, page.items, (it) => [
it.resource.id,
{ date: it.favorited_at }
@@ -164,7 +153,6 @@
try {
await removeFavorite(kind, item.id);
raw = raw.filter((i) => i.resource.id !== item.id);
favoriteIds.delete(item.id);
} catch (e) {
errorToast(e);
}
@@ -317,7 +305,6 @@
title={t('nav.favorites', 'Favorites')}
{items}
{contextMap}
{favoriteIds}
resolveOwnerName={(id) => owners.name(id)}
{loading}
{error}
@@ -328,6 +315,10 @@
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={unfavorite}
onshared={(item) => {
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
shareOpen = true;
}}
showOwner
showPath
dateLabel={t('files.col_added', 'Added')}
@@ -108,7 +108,7 @@
return drivesStore.findByRootFolderId(pathSegments[0] ?? null);
});
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
let listing = $state<FolderListing>({ folders: [], files: [] });
// Server-order accumulator — items in the exact sequence the backend
// returned across pages, honouring `sortField`+`reversed` on the wire.
// Under order_by=name/type/size the server puts folders first then files;
@@ -176,14 +176,13 @@
let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite + shared badge sets for the current folder, seeded directly from
// the listing response (server-computed, scoped to these items — no extra
// per-navigation fetch) and updated optimistically on mutation.
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying
// the whole set, and every other present-key `.has()` reader is spared
// (measured in selectionPatterns.bench.test.ts).
const favoriteIds = new SvelteSet<string>();
const sharedIds = new SvelteSet<string>();
// Favorite / shared state now lives inline on every `FileItem` /
// `FolderItem` DTO (`is_favorite`, `is_shared` — see
// `frontend/src/lib/api/types.ts`). Populated by the backend
// listing SQL (per-row `EXISTS`) and single-item enrichment
// helper. The row-badge snippet and star gate read these
// fields directly; the toggle path mutates the item in place
// inside `orderedItems`. No more `SvelteSet` shadowing.
function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind };
@@ -203,17 +202,20 @@
}
async function toggleFavorite(kind: ItemType, id: string) {
const isFav = favoriteIds.has(id);
// Optimistic toggle, reverted on failure.
if (isFav) favoriteIds.delete(id);
else favoriteIds.add(id);
// Server-authoritative `is_favorite` lives on every
// `FileItem`/`FolderItem` DTO. Read → optimistic flip → server
// call → revert on failure. All state changes happen in-place
// on the item inside `orderedItems`; there is no shadow set.
const item = orderedItems.find((it) => it.id === id);
if (!item) return; // row scrolled off / navigated away mid-click
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try {
if (isFav) await removeFavorite(kind, id);
if (wasFav) await removeFavorite(kind, id);
else await addFavorite(kind, id);
} catch (e) {
errorToast(e);
if (isFav) favoriteIds.add(id);
else favoriteIds.delete(id);
item.is_favorite = wasFav;
}
}
@@ -295,7 +297,7 @@
// Reset paging state: previous folder's cursor is meaningless here,
// and mixing its rows with the new folder's would flash a wrong list.
pageCursor = undefined;
listing = { folders: [], files: [], favoriteIds: [], sharedIds: [] };
listing = { folders: [], files: [] };
orderedItems = [];
loading = true;
@@ -336,17 +338,13 @@
if (reset) {
listing = {
folders: page.folders,
files: page.files,
favoriteIds: [],
sharedIds: []
files: page.files
};
orderedItems = page.items;
} else {
listing = {
folders: [...listing.folders, ...page.folders],
files: [...listing.files, ...page.files],
favoriteIds: listing.favoriteIds,
sharedIds: listing.sharedIds
files: [...listing.files, ...page.files]
};
orderedItems = [...orderedItems, ...page.items];
}
@@ -1009,7 +1007,13 @@
/** Batch add the selection to favorites — single /api/favorites/batch call. */
async function batchFavorites() {
const items = selectionTargets().filter((it) => !favoriteIds.has(it.id));
// Build an id → item index so the "already favorite" filter is
// O(1) per selection member instead of an O(N·M) scan. Reused
// after success to flip `is_favorite` in place on each row.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, FileItem | FolderItem>();
for (const it of orderedItems) byId.set(it.id, it);
const items = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false));
if (items.length === 0) {
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
clearSelection();
@@ -1025,7 +1029,10 @@
})
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
for (const it of items) favoriteIds.add(it.id);
for (const it of items) {
const row = byId.get(it.id);
if (row) row.is_favorite = true;
}
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection();
} catch (e) {
@@ -1779,7 +1786,6 @@
<ResourceList
title={t('nav.files', 'Files')}
items={rlItems}
{favoriteIds}
emptyText={hiddenCount > 0
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
: t('files.empty_title', 'This folder is empty')}
@@ -1812,6 +1818,7 @@
}}
onopen={rlOnOpen}
onfavorite={rlOnFavorite}
onshared={(item) => openShare(isFile(item) ? 'file' : 'folder', item.id, item.name)}
oncontextmenu={rlOnContextMenu}
onselectionchange={(ids) => replaceSet(selected, ids)}
isDraggable={rlIsDraggable}
@@ -1993,19 +2000,6 @@
<span>{t('common.delete', 'Delete')}</span>
</button>
{/snippet}
{#snippet rowBadge(item)}
{#if favoriteIds.has(item.id)}
<span class="item-badge item-badge--fav" title={t('files.favorited', 'Favorite')}>
<Icon name="star" />
</span>
{/if}
{#if sharedIds.has(item.id)}
<span class="file-badge file-badge-shared" title={t('files.shared', 'Shared')}>
<Icon name="oxiexport" />
</span>
{/if}
{/snippet}
</ResourceList>
</div>
@@ -2024,7 +2018,16 @@
{/if}
{#if shareDialog.component}
{@const ShareDialog = shareDialog.component}
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} />
<ShareDialog
bind:open={shareOpen}
item={actionTarget}
onshared={(id) => {
// Optimistic in-place flip so the shared chip appears on
// the row without waiting for the next listing refetch.
const row = orderedItems.find((it) => it.id === id);
if (row) row.is_shared = true;
}}
/>
{/if}
{#if fileViewer.component}
{@const FileViewer = fileViewer.component}
@@ -2182,7 +2185,7 @@
}}
>
<Icon name="star" />
{favoriteIds.has(ctxTarget.id)
{orderedItems.find((it) => it.id === ctxTarget!.id)?.is_favorite
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
</button>
@@ -2214,27 +2217,21 @@
min-height: 100%;
}
.item-badge {
display: inline-flex;
align-items: center;
margin-left: var(--space-1);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.item-badge--fav {
color: var(--color-warning-text, var(--color-accent));
}
/* Scrim + menu must sit ABOVE `.page-sticky-header` (breadcrumb +
action bar, `z-index: var(--z-sticky)` = 100) so a click on the
breadcrumb or the action bar closes the menu. Otherwise the
sticky header covers the scrim and swallows the outside-click.
Aligns with ResourceList's built-in menu (`.rl-ctx-scrim` uses
`1000` / `.rl-ctx-menu` uses `1001`). */
.ctx-scrim {
position: fixed;
inset: 0;
z-index: 90;
z-index: 1000;
}
.ctx-menu {
position: fixed;
z-index: 100;
z-index: 1001;
min-width: 12rem;
padding: var(--space-1);
background: var(--color-bg-surface);
+30 -12
View File
@@ -16,6 +16,7 @@
import {
addFavorite,
dateBucket,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel
@@ -314,24 +315,36 @@
}
},
{
// "Add to favorites" — /recent doesn't track per-row favorite
// state (the star widget was replaced by the broom), so the
// entry always reads "Add" and the backend swallows duplicate
// adds idempotently. If the user wants to un-favorite, they
// navigate to /favorites and use the row menu there. Placed
// between Move and Rename to match the canonical context-menu
// order on `/files`.
key: 'favorite',
key: 'favorite_add',
label: t('files.favorite', 'Add favorite'),
icon: 'star',
run: (item) => {
void addFavorite(kindOf(item), item.id).catch(errorToast);
}
visible: (item) => !item.is_favorite,
run: toggleFavorite
},
{
key: 'favorite_remove',
label: t('files.unfavorite', 'Remove favorite'),
icon: 'star',
visible: (item) => item.is_favorite,
run: toggleFavorite
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
async function toggleFavorite(item: FileItem | FolderItem) {
const kind = kindOf(item);
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try {
if (wasFav) await removeFavorite(kind, item.id);
else await addFavorite(kind, item.id);
} catch (e) {
errorToast(e);
item.is_favorite = wasFav;
}
}
// ── Selection + batch ─────────────────────────────────────────────────────
// Selected items arrive via the batchActions snippet param —
// ResourceList already derives them (O(selection), not O(N)); a
@@ -372,6 +385,11 @@
hasMore={!!cursor}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
onshared={(item) => {
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
shareOpen = true;
}}
showOwner
showPath
dateLabel={t('files.col_opened', 'Opened')}
@@ -436,7 +454,7 @@
buttons at the row's action-cell.
-->
<button
class="btn-action"
class="btn-action btn-action--hover"
data-testid={`recent-remove-btn-${item.id}`}
title={t('recent.remove_item', 'Remove from recent')}
aria-label={t('recent.remove_item', 'Remove from recent')}
@@ -8,6 +8,7 @@
import {
addFavorite,
dateBucket,
removeFavorite,
resolveOwnerName,
typeLabel
} from '$lib/api/endpoints/favorites';
@@ -161,11 +162,23 @@
// folders show "Download as ZIP" (server-side archive). Two entries
// with `visible?` predicates rather than one label that changes,
// so the `.icon` reads correctly per kind too.
//
// The favorite entry stays "Add to favorites" only: un-favoriting
// from here would need per-row favorite-state tracking which this
// view doesn't carry — users toggle off from /favorites' own row
// menu. Backend swallows duplicate `addFavorite` calls idempotently.
async function toggleFavorite(item: FileItem | FolderItem) {
const kind = isFile(item) ? 'file' : 'folder';
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try {
if (wasFav) await removeFavorite(kind, item.id);
else await addFavorite(kind, item.id);
} catch (e) {
errorToast(e);
item.is_favorite = wasFav;
}
}
// Favorite entry mirrors the star toggle in the row action cell —
// same wording, same behavior. Context-menu label flips based on
// `item.is_favorite` so keyboard users get the same state read as
// the button-hovering ones.
const contextActions: ContextAction[] = [
{
key: 'download',
@@ -182,12 +195,18 @@
run: downloadItem
},
{
key: 'favorite',
key: 'favorite_add',
label: t('files.favorite', 'Add favorite'),
icon: 'star',
run: (item) => {
void addFavorite(isFile(item) ? 'file' : 'folder', item.id).catch(errorToast);
}
visible: (item) => !item.is_favorite,
run: toggleFavorite
},
{
key: 'favorite_remove',
label: t('files.unfavorite', 'Remove favorite'),
icon: 'star',
visible: (item) => item.is_favorite,
run: toggleFavorite
}
];
@@ -235,6 +254,7 @@
bind:reversed
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);
+8
View File
@@ -132,6 +132,14 @@ pub struct FavoriteResourceRow {
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>,
/// Caller-scoped favorite state — `TRUE` by construction on this
/// listing (every row IS a favorite). The listing SQL hardcodes
/// `TRUE AS is_favorite`; this field stays here so the DTO builder
/// signature stays symmetric with the other listings.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any `storage.role_grants`
/// entry. Computed by a per-row `EXISTS` in the listing SQL.
pub is_shared: bool,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub favorited_at: DateTime<Utc>,
+26
View File
@@ -83,6 +83,25 @@ pub struct FileDto {
/// stub/legacy files.
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this file. **Wire contract: always present**, never null and
/// never absent — the SPA reads it as a required `boolean` with no
/// nullish branch. Every emission path (listing endpoints inline
/// via a per-row `EXISTS` in the listing SQL; single-item endpoints
/// via the shared `caller_flags` helper on the favorites port) is
/// responsible for populating this before the DTO reaches the
/// wire. WebDAV/CalDAV/CardDAV DTOs default to `false` — the XML
/// property serializer drops the field entirely, so a stale
/// default is never observable on those surfaces.
pub is_favorite: bool,
/// Resource-scoped: `true` when the file has ANY explicit
/// role-grant on it (link share via `subject_type = 'token'`,
/// user/group grant, any role). "Someone was given access to
/// this beyond drive membership." Same wire contract as
/// `is_favorite` — always present.
pub is_shared: bool,
}
impl From<File> for FileDto {
@@ -132,6 +151,11 @@ impl From<File> for FileDto {
etag,
created_by: parts.created_by,
updated_by: parts.updated_by,
// `From<File>` has no caller context. Callers that will
// emit the DTO to the SPA MUST override these before
// Json emission via the `caller_flags` helper.
is_favorite: false,
is_shared: false,
}
}
}
@@ -189,6 +213,8 @@ impl FileDto {
sort_date: None,
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}
}
}
+28
View File
@@ -96,6 +96,17 @@ pub struct FolderDto {
/// stub/legacy folders.
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this folder. See `FileDto::is_favorite` for the full wire
/// contract note (always present, never null; enrichment path
/// covers listing rows via inline `EXISTS` and single-item
/// endpoints via the `caller_flags` helper).
pub is_favorite: bool,
/// Resource-scoped: `true` when the folder has ANY explicit
/// role-grant on it. Same wire contract as `is_favorite`.
pub is_shared: bool,
}
impl From<Folder> for FolderDto {
@@ -127,6 +138,11 @@ impl From<Folder> for FolderDto {
etag,
created_by: parts.created_by,
updated_by: parts.updated_by,
// `From<Folder>` has no caller context. Handlers that
// emit to the SPA MUST override via `caller_flags` before
// Json response.
is_favorite: false,
is_shared: false,
}
}
}
@@ -179,6 +195,8 @@ impl FolderDto {
etag: String::new(),
created_by: None,
updated_by: None,
is_favorite: false,
is_shared: false,
}
}
}
@@ -226,6 +244,16 @@ pub struct FolderResourceRow {
pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this row. Populates `FileDto::is_favorite` / `FolderDto::is_favorite`
/// on the listing without a follow-up query. Computed by the
/// per-row `EXISTS` in `list_resources_paged`.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any `storage.role_grants`
/// entry — link share (`subject_type = 'token'`), user grant, group
/// grant, or any role. Populates `FileDto::is_shared` /
/// `FolderDto::is_shared`.
pub is_shared: bool,
// Pre-computed sort fields — returned by the SQL for cursor construction.
/// `LOWER(name)` used by `name`/`type` sorts.
pub sort_str: String,
+8
View File
@@ -122,6 +122,14 @@ pub struct RecentResourceRow {
/// consumed by the UI but surfaced for API parity with the other
/// listing endpoints.
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this row. Populates `FileDto::is_favorite` /
/// `FolderDto::is_favorite` on this listing via a per-row
/// `EXISTS` in the SQL.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any
/// `storage.role_grants` entry.
pub is_shared: bool,
/// `true` when `owner_id == requesting user_id`.
pub is_owner: bool,
pub accessed_at: DateTime<Utc>,
+7
View File
@@ -77,6 +77,13 @@ pub struct TrashResourceRow {
/// §14 provenance — who last touched the row (includes the trash
/// action itself, which stamps `updated_by = caller_id`).
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the caller has favorited this
/// trashed item.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any
/// `storage.role_grants` entry (surviving trash — grants are
/// GC'd by `purge_expired_grants` on the 15-day grace).
pub is_shared: bool,
pub trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
+22
View File
@@ -90,4 +90,26 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<Vec<FavoriteResourceRow>>;
/// Caller-scoped inline state flags for a single resource — the
/// shared enrichment helper called by every single-item handler
/// that emits `FileDto` / `FolderDto` to the SPA (get / rename /
/// move / upload / delta upload / photos / bulk get by ids).
///
/// Runs one SQL round trip with two `EXISTS` in the SELECT:
/// * `is_favorite` — `EXISTS on auth.user_favorites` for the
/// `(caller_id, resource_id, resource_type)` triple.
/// * `is_shared` — `EXISTS on storage.role_grants` for the
/// `(resource_id, resource_type)` pair, regardless of role /
/// subject_type / granter. Link shares live in `role_grants`
/// as `subject_type = 'token'` under the unified model, so
/// one EXISTS covers link shares + user grants + group grants.
///
/// `resource_type` MUST be `"file"` or `"folder"`.
async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)>;
}
@@ -50,6 +50,26 @@ impl FavoritesService {
) -> Result<HashSet<String>> {
self.repo.batch_check_favorites(user_id, items).await
}
/// Shared enrichment helper — computes `is_favorite` + `is_shared`
/// for a single resource so single-item handlers (get / rename /
/// move / upload / delta upload / photos / bulk get by ids) can
/// populate the two wire-contract flags on FileDto / FolderDto
/// before Json emission. Delegates straight to the repository
/// port; kept on `FavoritesService` because the port already
/// lives on that service and callers already hold it in DI.
///
/// `resource_type` MUST be `"file"` or `"folder"`.
pub async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)> {
self.repo
.caller_flags(caller_id, resource_type, resource_id)
.await
}
}
impl FavoritesUseCase for FavoritesService {
+9 -1
View File
@@ -828,7 +828,15 @@ impl FolderService {
// 2. Fetch limit+1 rows so we can detect has_next
let mut rows = self
.folder_storage
.list_resources_paged(pid, limit + 1, cursor.as_ref(), order_by, kinds, reverse)
.list_resources_paged(
pid,
caller_id,
limit + 1,
cursor.as_ref(),
order_by,
kinds,
reverse,
)
.await?;
// 3. Detect has_next, build encoded next cursor
@@ -824,6 +824,7 @@ impl TrashService {
.trash_repository
.list_resources_paged(
&drive_ids,
user_id,
limit + 1,
cursor.as_ref(),
order_by,
@@ -890,6 +891,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -933,6 +936,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -289,6 +289,53 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
}
async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)> {
// One round trip, two per-row EXISTS in the SELECT. Both hit
// covering indexes: `auth.user_favorites` UNIQUE on
// `(user_id, item_id, item_type)` and
// `idx_role_grants_resource` on `(resource_type, resource_id)`.
// Sub-millisecond on hot data.
//
// Positional tuple decode (Dio's pattern) — no per-column
// name lookup, no HashMap. Sqlx's 2-tuple `query_as` decodes
// by index, matching the perf shape used in the
// folder_db_repository listing hot path.
let (is_favorite, is_shared): (bool, bool) = sqlx::query_as(
"SELECT \
EXISTS ( \
SELECT 1 FROM auth.user_favorites \
WHERE user_id = $1 \
AND item_id = $2::text \
AND item_type = $3 \
), \
EXISTS ( \
SELECT 1 FROM storage.role_grants \
WHERE resource_id = $2 \
AND resource_type = $3 \
)",
)
.bind(caller_id)
.bind(resource_id)
.bind(resource_type)
.fetch_one(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error running caller_flags: {}", e);
DomainError::new(
ErrorKind::InternalError,
"CallerFlags",
format!("Failed to compute caller flags: {}", e),
)
})?;
Ok((is_favorite, is_shared))
}
async fn list_resources_paged(
&self,
user_id: Uuid,
@@ -319,6 +366,14 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
-- Every row on this feed IS a favorite by construction —
-- hardcode the flag to skip a per-row EXISTS.
TRUE AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -352,6 +407,13 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
-- Every row on this feed IS a favorite by construction.
TRUE AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -532,6 +594,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
@@ -607,6 +670,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(true),
is_shared: row.try_get("is_shared").unwrap_or(false),
is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(),
@@ -22,6 +22,8 @@ type MediaFileRow = (
String, // blob_hash
Option<Uuid>, // created_by (§14 provenance)
Option<Uuid>, // updated_by (§14 provenance)
bool, // is_favorite (caller-scoped EXISTS on user_favorites)
bool, // is_shared (resource-scoped EXISTS on role_grants)
i64, // sort_date
Option<i32>, // width
Option<i32>, // height
@@ -522,7 +524,19 @@ impl FileBlobReadRepository {
caller_id: Uuid,
before: Option<i64>,
limit: i64,
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> {
) -> Result<
(
Vec<File>,
Vec<i64>,
Vec<(Option<i32>, Option<i32>)>,
// Per-row caller flags (is_favorite, is_shared). Aligned
// with `files` — zip 1:1. Kept parallel to `sort_dates` /
// `dims` instead of on the File entity so the domain
// stays caller-agnostic.
Vec<(bool, bool)>,
),
DomainError,
> {
// Sargable keyset cursor: compare the RAW `media_sort_date` column
// against a timestamptz bind so the planner can use the cursor as
// an index boundary condition on `idx_files_media_timeline_by_drive`.
@@ -562,6 +576,17 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM top.updated_at)::bigint,
top.blob_hash,
top.created_by, top.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1
AND uf.item_id = top.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = top.id
AND g.resource_type = 'file'
) AS is_shared,
EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date,
fm.width, fm.height
FROM (
@@ -596,16 +621,36 @@ impl FileBlobReadRepository {
let mut files = Vec::with_capacity(rows.len());
let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len());
let mut flags = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, sd, w, h) in rows {
for (
id,
name,
fid,
fpath,
size,
mime,
ca,
ma,
blob_hash,
cb,
ub,
is_fav,
is_shr,
sd,
w,
h,
) in rows
{
files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
)?);
sort_dates.push(sd);
dims.push((w, h));
flags.push((is_fav, is_shr));
}
Ok((files, sort_dates, dims))
Ok((files, sort_dates, dims, flags))
}
/// Aggregate the caller's geotagged photos into grid cells of side `cell`
@@ -1417,9 +1417,11 @@ impl FolderDbRepository {
/// Fetches `limit` rows (caller should pass `desired_page_size + 1` to
/// detect the existence of a next page). Returns raw [`FolderResourceRow`]
/// values; the handler / service layer converts them to DTOs.
#[allow(clippy::too_many_arguments)]
pub async fn list_resources_paged(
&self,
parent_id: Uuid,
caller_id: Uuid,
limit: usize,
cursor: Option<&FolderResourceCursor>,
order_by: &str,
@@ -1448,6 +1450,17 @@ impl FolderDbRepository {
NULL::text AS blob_hash,
f.created_by,
f.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $7::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'folder'
) AS is_shared,
LOWER(f.name) AS sort_str,
0::bigint AS type_order,
0::int AS folder_first
@@ -1469,6 +1482,17 @@ impl FolderDbRepository {
fm.blob_hash,
fm.created_by,
fm.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $7::uuid
AND uf.item_id = fm.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fm.id
AND g.resource_type = 'file'
) AS is_shared,
LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order,
1::int AS folder_first
@@ -1660,66 +1684,71 @@ impl FolderDbRepository {
"SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, drive_id, blob_hash, \
created_by, updated_by, \
is_favorite, is_shared, \
sort_str, type_order, folder_first \
FROM ({inner}) r \
{outer_order} \
LIMIT $6"
);
// Row: (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, drive_id, blob_hash,
// created_by, updated_by,
// sort_str, type_order, folder_first)
type Row = (
String,
Uuid,
String,
Option<Uuid>,
Option<String>,
i64,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
Uuid, // drive_id
Option<String>,
Option<Uuid>, // created_by
Option<Uuid>, // updated_by
String,
i64,
i32,
);
let rows = sqlx::query_as::<_, Row>(&sql)
// 17 columns exceed sqlx's 16-element tuple `FromRow` limit,
// so we can't use `query_as::<_, (T1..T17)>` directly. Instead
// we fetch raw `PgRow`s and decode each column positionally
// into `FolderResourceRow` via `try_get_unchecked(idx)`. This
// matches Dio's positional shape (index decode, no per-row
// column-name HashMap) AND skips the intermediate tuple
// struct + row-to-struct move that a manual `FromRow` impl
// would introduce — one construction, one destination.
//
// Column indices below MUST match the outer `SELECT` list
// above (resource_type, id, name, folder_id, mime_type, size,
// created_at, modified_at, drive_id, blob_hash, created_by,
// updated_by, is_favorite, is_shared, sort_str, type_order,
// folder_first).
let rows = sqlx::query(&sql)
.bind(parent_id)
.bind(cursor_str)
.bind(cursor_int)
.bind(cursor_ts)
.bind(cursor_id)
.bind(limit as i64)
.bind(caller_id)
.fetch_all(self.pool())
.await
.map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}"))
})?;
Ok(rows
.into_iter()
.map(|r| FolderResourceRow {
resource_type: r.0,
id: r.1,
name: r.2,
parent_id: r.3,
mime_type: r.4,
size: r.5,
created_at: r.6,
modified_at: r.7,
drive_id: r.8,
blob_hash: r.9,
created_by: r.10,
updated_by: r.11,
sort_str: r.12,
type_order: r.13,
folder_first: r.14,
use sqlx::Row as _;
let decode_err = |col: usize, e: sqlx::Error| -> DomainError {
DomainError::internal_error(
"FolderDb",
format!("list_resources_paged decode col {col}: {e}"),
)
};
rows.into_iter()
.map(|r| {
Ok(FolderResourceRow {
resource_type: r.try_get_unchecked(0).map_err(|e| decode_err(0, e))?,
id: r.try_get_unchecked(1).map_err(|e| decode_err(1, e))?,
name: r.try_get_unchecked(2).map_err(|e| decode_err(2, e))?,
parent_id: r.try_get_unchecked(3).map_err(|e| decode_err(3, e))?,
mime_type: r.try_get_unchecked(4).map_err(|e| decode_err(4, e))?,
size: r.try_get_unchecked(5).map_err(|e| decode_err(5, e))?,
created_at: r.try_get_unchecked(6).map_err(|e| decode_err(6, e))?,
modified_at: r.try_get_unchecked(7).map_err(|e| decode_err(7, e))?,
drive_id: r.try_get_unchecked(8).map_err(|e| decode_err(8, e))?,
blob_hash: r.try_get_unchecked(9).map_err(|e| decode_err(9, e))?,
created_by: r.try_get_unchecked(10).map_err(|e| decode_err(10, e))?,
updated_by: r.try_get_unchecked(11).map_err(|e| decode_err(11, e))?,
is_favorite: r.try_get_unchecked(12).map_err(|e| decode_err(12, e))?,
is_shared: r.try_get_unchecked(13).map_err(|e| decode_err(13, e))?,
sort_str: r.try_get_unchecked(14).map_err(|e| decode_err(14, e))?,
type_order: r.try_get_unchecked(15).map_err(|e| decode_err(15, e))?,
folder_first: r.try_get_unchecked(16).map_err(|e| decode_err(16, e))?,
})
})
.collect())
.collect()
}
}
@@ -245,6 +245,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1::uuid
AND uf.item_id = fld.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -278,6 +289,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive'
@@ -457,6 +479,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r
@@ -536,6 +559,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(false),
is_shared: row.try_get("is_shared").unwrap_or(false),
is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(),
@@ -333,9 +333,11 @@ impl TrashDbRepository {
/// Returns rows in caller-requested sort order. The caller is expected to
/// fetch `limit + 1` to detect end-of-results. Empty `drive_ids` returns
/// an empty page without hitting PG.
#[allow(clippy::too_many_arguments)]
pub async fn list_resources_paged(
&self,
drive_ids: &[Uuid],
caller_id: Uuid,
limit: usize,
cursor: Option<&TrashCursor>,
order_by: &str,
@@ -369,6 +371,17 @@ impl TrashDbRepository {
NULL::text AS blob_hash,
fld.created_by AS created_by,
fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $8::uuid
AND uf.item_id = fld.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path,
@@ -397,6 +410,17 @@ impl TrashDbRepository {
f.blob_hash,
f.created_by AS created_by,
f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $8::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -527,6 +551,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
@@ -543,6 +568,7 @@ LIMIT $6"
.bind(cur_id) // $5
.bind(limit as i64) // $6
.bind(self.retention_days as i32) // $7
.bind(caller_id) // $8 — favorites EXISTS on the branch SELECTs
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| {
@@ -588,6 +614,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(false),
is_shared: row.try_get("is_shared").unwrap_or(false),
trashed_at,
deletion_date,
path: row.try_get("resource_path").ok(),
@@ -189,6 +189,14 @@ impl PathResolverService {
// reload through the repo.
created_by: None,
updated_by: None,
// Caller state flags not looked up here — the
// resolver is an internal utility that answers
// existence/type questions, not a wire emission
// path. Callers that emit to the SPA reload
// through the listing repo or the caller_flags
// helper.
is_favorite: false,
is_shared: false,
}))
}
_ => {
@@ -217,6 +225,10 @@ impl PathResolverService {
// §14 provenance not selected by this resolver path
created_by: None,
updated_by: None,
// Caller state flags not looked up here — see
// the folder branch above for rationale.
is_favorite: false,
is_shared: false,
}))
}
}
@@ -0,0 +1,88 @@
//! Shared enrichment helpers that populate the `is_favorite` and
//! `is_shared` wire-contract flags on `FileDto` / `FolderDto` before
//! Json emission.
//!
//! The two functions live here so single-item handlers across
//! `folder_handler`, `file_handler`, `delta_upload_handler`,
//! `photos_handler`, etc. all go through the same path — one place
//! to change if the enrichment strategy ever moves (e.g. batch
//! lookups, background prefetch).
//!
//! Every handler that returns a `FileDto` or `FolderDto` to the SPA
//! MUST call one of these helpers. Handlers that emit only to
//! WebDAV / NextCloud DAV surfaces (which drop these fields via the
//! XML property serializer) can skip enrichment — the default `false`
//! is never observable on those wires.
use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::common::di::AppState as GlobalAppState;
/// Populate the `is_favorite` + `is_shared` flags on a `FolderDto`.
///
/// Silently leaves the flags at their default `false` when the
/// favorites service isn't wired (feature-off) or when the resource
/// id doesn't parse as a UUID — the DTO stays valid on the wire and
/// the misleading-`false` window closes as soon as the next listing
/// refetch runs.
pub async fn enrich_folder_flags(
state: &Arc<GlobalAppState>,
dto: &mut FolderDto,
caller_id: Uuid,
) {
let Some(favs) = state.favorites_service.as_ref() else {
return;
};
let Ok(resource_id) = Uuid::parse_str(&dto.id) else {
return;
};
if let Ok((fav, shr)) = favs.caller_flags(caller_id, "folder", resource_id).await {
dto.is_favorite = fav;
dto.is_shared = shr;
}
}
/// File counterpart of [`enrich_folder_flags`] — see that doc.
pub async fn enrich_file_flags(state: &Arc<GlobalAppState>, dto: &mut FileDto, caller_id: Uuid) {
let Some(favs) = state.favorites_service.as_ref() else {
return;
};
let Ok(resource_id) = Uuid::parse_str(&dto.id) else {
return;
};
if let Ok((fav, shr)) = favs.caller_flags(caller_id, "file", resource_id).await {
dto.is_favorite = fav;
dto.is_shared = shr;
}
}
/// Batch variant: enrich every `FileDto` in a slice with per-item
/// `caller_flags`. Runs the lookups sequentially — for the bulk
/// endpoints (`get_files_by_ids`, `photos_handler`) this is one
/// round trip per item; if that becomes hot on a large fetch, the
/// callsite can be replaced with a single SQL query returning the
/// pairs. Kept simple for now; the DTO is `&mut`, no clones.
pub async fn enrich_file_flags_batch(
state: &Arc<GlobalAppState>,
dtos: &mut [FileDto],
caller_id: Uuid,
) {
for dto in dtos.iter_mut() {
enrich_file_flags(state, dto, caller_id).await;
}
}
/// Batch variant for folders — mirror of [`enrich_file_flags_batch`].
pub async fn enrich_folder_flags_batch(
state: &Arc<GlobalAppState>,
dtos: &mut [FolderDto],
caller_id: Uuid,
) {
for dto in dtos.iter_mut() {
enrich_folder_flags(state, dto, caller_id).await;
}
}
@@ -235,12 +235,18 @@ pub async fn delta_commit(
.await
.map_err(AppError::from)?;
Ok(match outcome {
DeltaCommitOutcome::Done { file, created } => {
DeltaCommitOutcome::Done { mut file, created } => {
let status = if created {
StatusCode::CREATED
} else {
StatusCode::OK
};
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
(status, Json(file)).into_response()
}
DeltaCommitOutcome::StillMissing(still_missing) => (
@@ -219,6 +219,11 @@ pub async fn list_favorites_resources(
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
// Every row on this listing is a favorite by
// construction; the listing repo returns
// `TRUE AS is_favorite` unconditionally.
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -267,6 +272,8 @@ pub async fn list_favorites_resources(
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File,
+34 -5
View File
@@ -64,7 +64,15 @@ impl FileHandler {
multipart: Multipart,
) -> impl IntoResponse {
match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok((file, _blob_hash)) => Self::created_json_response(&file).into_response(),
Ok((mut file, _blob_hash)) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
Err(response) => response.into_response(),
}
}
@@ -115,7 +123,15 @@ impl FileHandler {
)
.await
{
Ok(file) => Self::created_json_response(&file).into_response(),
Ok(mut file) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
Err(err) => {
// Anti-enumeration shape: every "caller cannot reach this
// hash" outcome collapses into the same 404 with an
@@ -890,11 +906,16 @@ impl FileHandler {
auth_user: AuthUser,
multipart: Multipart,
) -> impl IntoResponse {
let (file, _) = match Self::upload_file_inner(&state, &auth_user, multipart).await {
let (mut file, _) = match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok(pair) => pair,
Err(response) => return response.into_response(),
};
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
@@ -1026,7 +1047,15 @@ impl FileHandler {
.move_file_with_perms(&id, auth_user.id, payload.folder_id)
.await
{
Ok(file) => (StatusCode::OK, Json(file)).into_response(),
Ok(mut file) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
(StatusCode::OK, Json(file)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
+34 -12
View File
@@ -25,6 +25,8 @@ use crate::interfaces::middleware::auth::AuthUser;
type AppState = Arc<FolderService>;
use crate::interfaces::api::handlers::caller_flags::enrich_folder_flags;
/// Handler for folder-related API endpoints
pub struct FolderHandler;
@@ -40,10 +42,11 @@ impl FolderHandler {
/// When parent_id is not provided, the folder is created inside the
/// authenticated user's home folder rather than at the storage root.
pub(super) async fn create_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Json(mut dto): Json<CreateFolderDto>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
// If no parent_id was supplied, resolve the user's home folder as
// the default parent so the new folder is nested correctly.
if dto.parent_id.is_none() {
@@ -77,7 +80,10 @@ impl FolderHandler {
}
match service.create_folder_with_perms(dto, auth_user.id).await {
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::CREATED, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
@@ -85,12 +91,16 @@ impl FolderHandler {
/// Gets a folder by ID.
/// Validates that the authenticated user owns the folder.
pub(super) async fn get_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.get_folder_with_perms(&id, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
@@ -126,29 +136,37 @@ impl FolderHandler {
/// Renames a folder (ownership enforced).
pub(super) async fn rename_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<RenameFolderDto>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service
.rename_folder_with_perms(&id, dto, auth_user.id)
.await
{
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
/// Moves a folder to a new parent (ownership enforced).
pub(super) async fn move_folder_impl(
State(service): State<AppState>,
State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser,
Path(id): Path<String>,
Json(dto): Json<MoveFolderDto>,
) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.move_folder_with_perms(&id, dto, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(),
Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(),
}
}
@@ -300,7 +318,7 @@ impl FolderHandler {
tag = "folders"
)]
pub async fn create_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
json: Json<CreateFolderDto>,
) -> impl IntoResponse {
@@ -319,7 +337,7 @@ pub async fn create_folder(
tag = "folders"
)]
pub async fn get_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
) -> impl IntoResponse {
@@ -355,7 +373,7 @@ pub async fn list_root_folders(
tag = "folders"
)]
pub async fn rename_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
json: Json<RenameFolderDto>,
@@ -376,7 +394,7 @@ pub async fn rename_folder(
tag = "folders"
)]
pub async fn move_folder(
state: State<AppState>,
state: State<Arc<GlobalAppState>>,
auth_user: AuthUser,
path: Path<String>,
json: Json<MoveFolderDto>,
@@ -490,6 +508,8 @@ pub async fn list_folder_resources(
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -541,6 +561,8 @@ pub async fn list_folder_resources(
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
FolderResourceItemDto {
resource_type: ResourceTypeDto::File,
+34 -8
View File
@@ -933,19 +933,28 @@ pub async fn list_shared_with_me(
// looking each resolved resource up by id.
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
// Enrich caller flags on every returned resource DTO. Incoming
// grants pages are typically small (10-50 items), so N sequential
// helper calls is acceptable; the folded-into-SQL treatment
// photos got is overkill here. Follow-up path if this grows
// hot: same LATERAL EXISTS shape in `get_files_by_ids` /
// `get_folders_by_ids`.
for summary in &summaries {
let rid = summary.resource_id.to_string();
match summary.resource_type {
ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => {
let mut dto = file_dto.clone().without_hierarchy_info();
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state, &mut dto, caller_id,
)
.await;
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::File(
file_dto.clone().without_hierarchy_info(),
),
resource: ResourceContentDto::File(dto),
});
}
None => warn!(
@@ -955,14 +964,17 @@ pub async fn list_shared_with_me(
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
let mut dto = folder_dto.clone().without_hierarchy_info();
crate::interfaces::api::handlers::caller_flags::enrich_folder_flags(
&state, &mut dto, caller_id,
)
.await;
items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at,
granted_by: summary.granted_by,
resource: ResourceContentDto::Folder(
folder_dto.clone().without_hierarchy_info(),
),
resource: ResourceContentDto::Folder(dto),
});
}
None => warn!(
@@ -1209,10 +1221,18 @@ pub async fn list_my_shares(
// Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me).
let mut dto = file_dto.clone();
// is_shared: TRUE by construction on this feed.
// is_favorite: real EXISTS via the shared helper.
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state, &mut dto, caller_id,
)
.await;
dto.is_shared = true;
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()),
resource: ResourceContentDto::File(dto),
grants,
});
}
@@ -1223,10 +1243,16 @@ pub async fn list_my_shares(
},
ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => {
let mut dto = folder_dto.clone();
crate::interfaces::api::handlers::caller_flags::enrich_folder_flags(
&state, &mut dto, caller_id,
)
.await;
dto.is_shared = true;
items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()),
resource: ResourceContentDto::Folder(dto),
grants,
});
}
+1
View File
@@ -3,6 +3,7 @@ pub mod app_password_handler;
pub mod auth_handler;
pub mod batch_handler;
pub mod caldav_handler;
pub mod caller_flags;
pub mod carddav_handler;
pub mod chunked_upload_handler;
pub mod contacts_handler;
+11 -3
View File
@@ -75,7 +75,7 @@ pub async fn list_photos(
.list_media_files(caller_id, params.before, limit)
.await
{
Ok((files, sort_dates, dims)) => {
Ok((files, sort_dates, dims, flags)) => {
// Lightweight revalidation ETag: page identity (cursor + limit) plus a
// freshness signal (max modified_at + row count over the page),
// mirroring the file-list endpoint. With `Cache-Control: no-cache` the
@@ -106,14 +106,22 @@ pub async fn list_photos(
info!("Photos: returned {} media files for user", count);
// Convert to DTOs with sort_date + pixel dimensions populated.
// Convert to DTOs with sort_date + pixel dimensions + inline
// caller flags populated. `list_media_files` computes
// `is_favorite` / `is_shared` via two per-row `EXISTS`
// columns in its SELECT — the same pattern the four
// `list_resources_paged` repos use — so this stays a
// single round trip regardless of page size.
let dtos: Vec<PhotoDto> = files
.into_iter()
.zip(sort_dates.iter())
.zip(dims.iter())
.map(|((file, &sd), &(w, h))| {
.zip(flags.iter())
.map(|(((file, &sd), &(w, h)), &(is_fav, is_shr))| {
let mut dto = FileDto::from(file);
dto.sort_date = Some(sd as u64);
dto.is_favorite = is_fav;
dto.is_shared = is_shr;
PhotoDto {
file: dto,
width: w.map(|v| v.max(0) as u32),
@@ -239,6 +239,8 @@ pub async fn list_recent_resources(
category: intern_display("Folder"),
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder,
@@ -285,6 +287,8 @@ pub async fn list_recent_resources(
etag,
created_by: row.created_by,
updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
};
RecentResourceItemDto {
resource_type: ResourceTypeDto::File,
@@ -565,6 +565,9 @@ async fn handle_propfind(
category: intern_display("Folder"),
created_by: None,
updated_by: None,
// Synthetic root, not on the SPA path — safe default.
is_favorite: false,
is_shared: false,
};
// Skip the 2-query quota resolution when the request's prop list
// never mentions quota (benches/QUOTA-PATH.md).
+13 -4
View File
@@ -206,15 +206,23 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
batch_service: batch_service.clone(),
};
// Create the basic folders router with service operations
// Basic folder listing (no caller-flag enrichment needed — flags
// come from the per-row SQL EXISTS in the listing repo).
let folders_basic_router = Router::new()
.route("/", post(create_folder))
.route("/", get(list_root_folders))
.route("/{id}", get(get_folder))
.route("/{id}/resources", get(list_folder_resources))
.with_state(folder_service.clone());
// Single-item CRUD emits a FolderDto to the SPA and MUST carry
// authoritative `is_favorite` / `is_shared` flags. The impls call
// the `caller_flags` enrichment helper via
// `state.favorites_service`, so the full `AppState` is required.
let folders_crud_router = Router::new()
.route("/", post(create_folder))
.route("/{id}", get(get_folder))
.route("/{id}/rename", put(rename_folder))
.route("/{id}/move", put(move_folder))
.with_state(folder_service.clone());
.with_state(app_state.clone());
// Special route for ZIP download that requires AppState instead of just FolderService
let folder_zip_router = Router::new()
@@ -226,6 +234,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Merge the routers
let folders_router = folders_basic_router
.merge(folders_crud_router)
.merge(folders_ops_router)
.merge(folder_zip_router);
@@ -462,6 +462,10 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
// §14 provenance not selected by the search result DTO.
created_by: None,
updated_by: None,
// NextCloud search doesn't render the SPA badges; safe
// defaults, dropped by the WebDAV/NC XML property serializer.
is_favorite: false,
is_shared: false,
}
}
@@ -495,6 +499,10 @@ fn folder_dto_from_search(
// §14 provenance not selected by search results.
created_by: None,
updated_by: None,
// See file_dto_from_search: NC/DAV property serializer drops
// these; safe default.
is_favorite: false,
is_shared: false,
}
}
@@ -2401,6 +2401,10 @@ mod tests {
// §14 provenance not relevant to path-mapper tests.
created_by: None,
updated_by: None,
// Caller state flags not read by the WebDAV path mapper;
// Nextcloud DAV surfaces don't render the SPA badges.
is_favorite: false,
is_shared: false,
}
}
+5
View File
@@ -94,6 +94,11 @@ jsonpath "$.items" count == 1
jsonpath "$.items[0].resource.id" == {{file_id}}
jsonpath "$.items[0].resource_type" == "file"
jsonpath "$.items[0].resource.name" == "hello-renamed.txt"
# Caller-flag contract — every row on /api/favorites/resources IS
# favorited by construction (the listing SQL hardcodes
# `TRUE AS is_favorite`). `is_shared` is a real per-row EXISTS.
jsonpath "$.items[0].resource.is_favorite" == true
jsonpath "$.items[0].resource.is_shared" == false
# ─────────────────────────────────────────────────────────────
+7
View File
@@ -107,6 +107,10 @@ jsonpath "$.parent_id" == {{home_folder_id}}
# D0 §14 provenance — self-creation: both fields stamp the caller.
jsonpath "$.created_by" == "{{admin_user_id}}"
jsonpath "$.updated_by" == "{{admin_user_id}}"
# Caller-flag contract — fresh folder: not favorited, no grants on it.
# Wire contract is "always present bool" — never null, never absent.
jsonpath "$.is_favorite" == false
jsonpath "$.is_shared" == false
# ─────────────────────────────────────────────────────────────
@@ -176,6 +180,9 @@ jsonpath "$.size" == 32
jsonpath "$.mime_type" == "text/plain"
# D0 §14 provenance — uploader's id stamps both fields on a fresh upload.
jsonpath "$.created_by" == "{{admin_user_id}}"
# Caller-flag contract — fresh upload: not favorited, no grants on it.
jsonpath "$.is_favorite" == false
jsonpath "$.is_shared" == false
jsonpath "$.updated_by" == "{{admin_user_id}}"
+10
View File
@@ -738,6 +738,16 @@ jsonpath "$.items[0].resource.updated_by" == "{{alice_user_id}}"
jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg"
jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}"
jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}"
# Caller-flag contract on the listing endpoint. Neither row
# favorited by Alice → is_favorite = false. The `perm_folder_id`
# tree carries a role_grant on the FOLDER (Adam as Editor), not
# on `perm-test-child` or `adam-renamed-logo.jpg` — so both
# child rows should carry is_shared = false; the grant on the
# parent doesn't cascade to per-child EXISTS.
jsonpath "$.items[0].resource.is_favorite" == false
jsonpath "$.items[0].resource.is_shared" == false
jsonpath "$.items[1].resource.is_favorite" == false
jsonpath "$.items[1].resource.is_shared" == false
# ── Thumbnail push (Update) succeeds ────────────────────────
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview