feat(drive): add drive picker in sidebar

- select by default the home drive
This commit is contained in:
Edouard Vanbelle
2026-06-20 01:28:52 +02:00
parent cf72f8a77b
commit cf7ad87c54
11 changed files with 344 additions and 40 deletions
+12 -7
View File
@@ -618,11 +618,16 @@ accommodates them without schema migration)
| URL | Resolves to |
|---|---|
| `/` (internal user) | Redirect to `/drive/<root-folder-id>` of the caller's default personal drive |
| `/` (external user) | Redirect to `/sharedwithme` (no personal drive exists) |
| `/drive/<folder-id>` | Folder view (root or descendant — drive context is recovered server-side from `folders.drive_id`) |
| `/` (internal user) | Redirect to `/files/<root-folder-id>` of the caller's default personal drive |
| `/` (external user) | Redirect to `/shared-with-me` (no personal drive exists) |
| `/files` | Default browse — shows the caller's home root (back-compat) |
| `/files/<folder-id>` | Folder view at this folder. Drive context is recovered server-side from `folders.drive_id`. Switching drives = navigating to the new drive's root folder id |
| `/files/<a>/<b>/<c>` | Folder `c` (descendant of `b`, descendant of `a`). Each segment is a folder UUID; the prefix chain provides breadcrumbs without a server round-trip |
| `/config/drive/<drive-uuid>` | Drive configuration surface (members, policies, quota). Page is permission-aware: owner sees member management; editor/viewer see a read-only "Drive info" view |
| `/config/user/<user-uuid>` | (Future) User configuration — same shape so the `/config/<resource-type>/<uuid>` pattern is consistent across resources |
| `/drive/<...>` | **Reserved** for future drive-scoped surfaces that aren't covered by `/files/` or `/config/drive/` |
**Why `/files/<folder-id>` and not `/drive/<folder-id>`**: the existing files browser already takes a chain of folder UUIDs (`/files/<id1>/<id2>/<id3>`), with the leaf being the current folder and the prefix providing breadcrumbs. Switching drives just means navigating to a different root folder id under the same prefix — no new route shape required. Reserving `/drive/<...>` for later keeps the door open without forcing a migration now.
**Why folder-id, not drive-uuid + folder-id**: every `storage.folders` row carries `drive_id` after D0, so a single folder UUID recovers the drive context in one cheap lookup. Stable across cross-drive moves (D6): bookmarks keep working when a folder hops drives, because the folder UUID doesn't change.
@@ -1377,7 +1382,7 @@ us a real rollback window while the new model bakes in production.
|---|---|---|
| **D-Prep — role_grants refactor** | `access_grants → role_grants` schema migration with role-bundle semantics. `Manage` Permission added to the enum + role bundle. Engine reads role_grants only; `access_grants` removed (after one dual-write release if compat is needed). API gains `role` parameter on grant endpoints; audit log emits one `role_grant.*` event per role assignment instead of N permission events. **No Drive concept yet.** Sets the foundation that all subsequent PRs build on. **Data shape confirmed**: empirical audit shows >99% of existing `access_grants` rows already cluster into the standard bundles (viewer/editor/owner) — the migration is mechanical for the vast majority of data; the <1% edge cases get absorbed by shipping `commenter` and `contributor` roles on day one or get an explicit per-row migration decision logged. | **Medium** — touches the load-bearing authorisation table, but the data shape removes the main migration risk |
| **D0 — foundation** | `storage.drives` schema (no `drive_members` — uses `role_grants` from D-Prep); `Drive` domain entity; migration creating personal drives + backfilling `drive_id` on every resource; read-only `GET /api/drives` listing the caller's drives (single query: `SELECT … FROM role_grants WHERE subject_id=$caller AND resource_type='drive'`). Dual-write `user_id` alongside `drive_id` for safety. **No new UI.** **Every upload path stamps `drive_id` at insert**: classic multipart (`file_handler::upload`), chunked NC (`uploads_handler`), streaming CDC (`upload_ingest`), delta upload (`delta_upload_service`), instant upload by hash. Tantivy reindex (see §11) is part of this PR. **Provenance columns added** (see §14): `created_by` and `updated_by` on both `storage.folders` and `storage.files`, FK to `auth.users` with `ON DELETE SET NULL`; backfilled from `user_id` so pre-Drive content has provenance from day one; every mutation path that touches `updated_at` also sets `updated_by`. | **High** — every storage query touches, all upload paths touched |
| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/drive/<folder-id>` frontend route (drive context recovered server-side from `folders.drive_id`), `/config/drive/<drive-uuid>` for drive admin. `/` redirects to `/drive/<root-folder-id>` of the caller's default personal drive (internal users) or `/sharedwithme` (external users with no personal drive). WebDAV path dispatcher recognising `drives/<uuid>` as the drive-explicit prefix on `/webdav/` (NC keeps the credential-side scheme — see §9). | Medium |
| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/files/<folder-id>` reused for cross-drive navigation (existing route — drive context recovered server-side from `folders.drive_id`), `/config/drive/<drive-uuid>` new route for drive admin. `/` redirects to `/files/<root-folder-id>` of the caller's default personal drive (internal users) or `/shared-with-me` (external users with no personal drive). WebDAV path dispatcher recognising `drives/<uuid>` as the drive-explicit prefix on `/webdav/` (NC keeps the credential-side scheme — see §9). `/drive/<...>` reserved for future use. | Medium |
| **D2 — drive membership API + per-drive trash auth** | `POST /api/drives/{id}/members`, `DELETE`, `PUT` for role changes — thin handlers that translate to `role_grants` INSERT/DELETE/UPDATE with `resource_type='drive'`. `Resource::Drive(Uuid)` (added in D-Prep at the enum level) gets its specialised handler surface here. Shared-drive last-owner protection. Group-as-subject support reuses the existing `subject_groups` machinery. **Personal-drive guards** (`add_member`, `remove_member`, `delete_drive` refuse on `kind='personal'` — see §2). **Per-drive trash authorisation** (§12): trash listing filters by drive(s) the caller can read; trash mutations (send/restore/permanent-delete) require `role='owner'` on the drive; `storage.trash_items` VIEW updated to surface `drive_id`; orphan/aborted-upload sweep becomes per-drive. | Medium |
| **D3 — group-owned shared drives** | "Create shared drive" flow — admin or group owner triggers, drive created with `kind='shared'`, initial owner row is the group. Group-deletion guard refuses if the group is the last owner of any drive. Drive-rename, drive-delete. | Low |
| **D4 — per-drive quota** | Move storage accounting off `auth.users.storage_used_bytes` onto `storage.drives.used_bytes`. **Re-point the existing per-user incremental CTE** (introduced in v0.7.0 — see `b5b80549`, `d6987329`) at drive rows; don't reinvent the counting logic. Upload paths check `drive.quota_bytes` instead of (or in addition to) the user's quota for the dual-write window. **Per-chunk incremental quota check on the NC chunked path** (see §13): MKCOL refuses when the drive is already over quota; each PUT chunk runs an O(1) `used + session_so_far + chunk_size > quota` test and refuses with 507 within one chunk of wasted upload. Closes a pre-existing wart where NC clients could upload GB before learning they were over quota. Reconciliation job runs once per day to fix drift. | Medium |
@@ -1659,15 +1664,15 @@ test`), **(c)** `cargo fmt && cargo clippy --all-features
username (or app-password binding) lands a sync into the chosen
drive transparently.
- **Manual smoke (internal user)**: open `/`, get redirected to
`/drive/<default-personal-drive-root-folder-id>`. Click sidebar
drive switcher → URL updates to `/drive/<picked-drive-root-folder-id>`,
`/files/<default-personal-drive-root-folder-id>`. Click sidebar
drive switcher → URL updates to `/files/<picked-drive-root-folder-id>`,
listing reloads. Drive picker shows all of the caller's drives
(default first), each with its quota usage. Open
`/config/drive/<personal-drive-uuid>` → owner sees member list +
policies. Open `/config/drive/<shared-drive-uuid>` as a viewer →
read-only "Drive info" surface.
- **Manual smoke (external user)**: open `/`, get redirected to
`/sharedwithme` (no `/drive/...` for an account without a
`/shared-with-me` (no `/files/<id>` for an account without a
personal drive).
- **Playwright**: a new `tests/e2e/drive-switching.spec.ts` exercises
sidebar → URL → listing → cross-drive isolation (folders in
+15
View File
@@ -0,0 +1,15 @@
/**
* Drives endpoints. D0 ships read-only listing; mutations (create / rename /
* member changes) land in D2/D3 and will be added here under the same shape.
*
* Consumers usually go through the `drives` store (`$lib/stores/drives.svelte`)
* which dedupes the request and caches the list — touch this module directly
* only when bypassing the cache is intentional (e.g. an explicit refresh).
*/
import { apiJson } from '$lib/api/client';
import type { Drive } from '$lib/api/types';
/** `GET /api/drives` — every drive the caller can read, default first by convention. */
export function listDrives(): Promise<Drive[]> {
return apiJson<Drive[]>('/api/drives', { credentials: 'same-origin' });
}
@@ -103,11 +103,6 @@ function parseListing(raw: unknown): FolderListing {
};
}
/** Top-level folders for the user; the first entry is the home folder. */
export function listRootFolders(): Promise<FolderItem[]> {
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
}
export async function getFolder(id: string): Promise<FolderItem> {
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
rememberFolderName(folder.id, folder.name);
+21
View File
@@ -196,3 +196,24 @@ export interface SearchResults {
query_time_ms: number;
sort_by: string;
}
export type DriveKind = 'personal' | 'shared';
/**
* One row from `GET /api/drives`. Mirrors `DriveDto` in
* `src/application/dtos/drive_dto.rs`. `default_for_user` is the caller's
* id when present, `null`/undefined otherwise — used to pick the default
* personal drive without hard-coding name conventions.
*/
export interface Drive {
id: string;
name: string;
kind: DriveKind;
default_for_user?: string | null;
root_folder_id: string;
quota_bytes?: number | null;
used_bytes: number;
policies: Record<string, unknown>;
created_at: string;
updated_at: string;
}
@@ -7,6 +7,8 @@
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import DrivePicker from '$lib/components/DrivePicker.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
@@ -281,6 +283,9 @@
<Icon name={link.icon} />
<span>{link.label}</span>
</a>
{#if link.href === '/files' && !session.isExternalUser}
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
{/if}
{/each}
</nav>
@@ -0,0 +1,160 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
import type { Drive } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
import { formatBytes } from '$lib/utils/format';
interface Props {
onnavigate?: () => void;
}
let { onnavigate }: Props = $props();
// URL of `/files/<first>/<second>/...` — the first segment identifies the
// drive root the user navigated through. We use it to highlight the active
// drive in the picker. Deep-linking to a descendant folder of a non-default
// drive bypasses this highlight (the URL's leading segment is the deep
// folder id, not the drive root); that's acceptable — D2 can refine this
// by resolving `folder.drive_id` server-side when the gap matters.
const firstFilesSegment = $derived.by(() => {
const m = /^\/files\/([^/]+)/.exec(page.url.pathname);
return m ? m[1] : null;
});
// Sorting: default-personal drive first, then secondary personals, then
// shared. Within each group, by name. Picker UX puts "home" at the top so
// the common case is one click.
const sortedDrives = $derived(
[...drivesStore.drives].sort((a, b) => {
const rank = (d: Drive) => (d.default_for_user ? 0 : d.kind === 'personal' ? 1 : 2);
const r = rank(a) - rank(b);
return r !== 0 ? r : a.name.localeCompare(b.name);
})
);
function isActive(d: Drive): boolean {
return firstFilesSegment === d.root_folder_id;
}
function pctUsed(d: Drive): number | null {
if (!d.quota_bytes || d.quota_bytes <= 0) return null;
return Math.min(100, (d.used_bytes / d.quota_bytes) * 100);
}
async function open(d: Drive) {
onnavigate?.();
// Remember which drive root the user picked so a later click on the
// sidebar "Files" link (which goes to bare `/files`) returns here
// instead of always bouncing to the default drive.
try {
localStorage.setItem('oxi-last-drive-root', d.root_folder_id);
} catch {
/* private mode / quota — silently fall back to default */
}
await goto(`/files/${d.root_folder_id}`);
}
onMount(() => {
void drivesStore.load();
});
</script>
{#if drivesStore.loaded && drivesStore.drives.length > 0}
<ul class="drive-picker" aria-label={t('drive.picker', 'Drives')}>
{#each sortedDrives as d (d.id)}
<li>
<button
type="button"
class="drive-picker__item"
class:drive-picker__item--active={isActive(d)}
onclick={() => open(d)}
title={pctUsed(d) !== null
? `${d.name} — ${formatBytes(d.used_bytes)} / ${formatBytes(d.quota_bytes ?? 0)}`
: `${d.name} — ${formatBytes(d.used_bytes)}`}
>
<Icon name={driveIcon(d)} />
<span class="drive-picker__name">{d.name}</span>
</button>
{#if pctUsed(d) !== null}
<div
class="drive-picker__bar"
role="progressbar"
aria-valuenow={Math.round(pctUsed(d) ?? 0)}
aria-valuemin="0"
aria-valuemax="100"
aria-label={t('drive.usage_aria', 'Drive usage')}
>
<div class="drive-picker__bar-fill" style:width="{pctUsed(d)}%"></div>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
<style>
/* Rendered as nested children under the "Files" nav item — no own border or
title; visual nesting via left padding aligned to the parent icon. */
.drive-picker {
list-style: none;
padding: 0;
margin: 0 0 0.25rem;
display: flex;
flex-direction: column;
}
.drive-picker__item {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 1rem 0.3rem 2rem;
background: transparent;
border: none;
color: var(--color-sidebar-text);
font: inherit;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.drive-picker__item:hover {
background: var(--color-sidebar-hover-bg);
color: var(--color-sidebar-text-hover);
}
/* Active drive: just a text-color shift. The parent "Files" row already
carries the orange-tinted active bg — anything more on the child
crowds the sidebar. Typography alone reads as "you are here" since
only one drive can be active at a time. */
.drive-picker__item--active {
color: var(--color-sidebar-text-active);
font-weight: var(--weight-semibold);
}
.drive-picker__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Mini usage bar tucked under the row, indented to align with the name. */
.drive-picker__bar {
height: 3px;
background: var(--color-sidebar-storage-bar);
border-radius: 1.5px;
margin: 0 1rem 0.25rem 2rem;
overflow: hidden;
}
.drive-picker__bar-fill {
height: 100%;
background: var(--color-accent);
transition: width 200ms ease;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
/**
* Drives store — caches `GET /api/drives` so the picker, the breadcrumb
* icon, and the session bootstrap all share one fetch. Idempotent `load()`.
*
* Identifying the user's home: always via `default_for_user`, never by
* folder name (users can rename "Personal").
*/
import { listDrives } from '$lib/api/endpoints/drives';
import type { Drive } from '$lib/api/types';
class DrivesStore {
drives = $state<Drive[]>([]);
loaded = $state(false);
private inflight: Promise<Drive[]> | null = null;
async load(): Promise<Drive[]> {
if (this.loaded) return this.drives;
if (this.inflight) return this.inflight;
this.inflight = (async () => {
try {
this.drives = await listDrives();
} catch {
this.drives = [];
} finally {
this.loaded = true;
this.inflight = null;
}
return this.drives;
})();
return this.inflight;
}
/** Force a refresh after a mutation (rename, member change, …). */
invalidate(): void {
this.loaded = false;
this.drives = [];
}
/** Caller's default-personal drive (one per internal user), or null. */
findDefault(): Drive | null {
return this.drives.find((d) => d.default_for_user != null) ?? null;
}
/** Drive whose root folder UUID matches `id`, or null. */
findByRootFolderId(id: string | null | undefined): Drive | null {
if (!id) return null;
return this.drives.find((d) => d.root_folder_id === id) ?? null;
}
}
export const drives = new DrivesStore();
/**
* Picker / breadcrumb icon for a drive:
* home — default-personal (the user's home)
* folder — secondary personal drive
* users — shared / team drive
*/
export function driveIcon(d: Drive): string {
if (d.default_for_user) return 'home';
return d.kind === 'shared' ? 'users' : 'folder';
}
+12 -11
View File
@@ -7,7 +7,7 @@
* folder and land on the shared-with-me view.
*/
import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth';
import { listRootFolders } from '$lib/api/endpoints/folders';
import { drives } from '$lib/stores/drives.svelte';
import type { User } from '$lib/api/types';
class SessionStore {
@@ -41,20 +41,21 @@ class SessionStore {
}
/**
* Resolve the home folder (first entry of GET /api/folders). Externals
* (grant-only) have no home folder, so this is skipped for them.
* Resolve the caller's default personal drive's root folder — the landing
* point for `/files` and the `/` redirect. Externals (grant-only) have no
* personal drive, so this is skipped for them.
*
* Identifies the default via `default_for_user`, not folder name: users
* can rename "Personal" without breaking this lookup.
*/
async loadHomeFolder(): Promise<string | null> {
if (this.homeFolderId) return this.homeFolderId;
if (this.isExternalUser) return null;
try {
const folders = await listRootFolders();
if (folders.length > 0) {
this.homeFolderId = folders[0].id;
this.homeFolderName = folders[0].name;
}
} catch {
/* leave null — caller handles */
await drives.load();
const def = drives.findDefault();
if (def) {
this.homeFolderId = def.root_folder_id;
this.homeFolderName = def.name;
}
return this.homeFolderId;
}
@@ -47,12 +47,14 @@
user-select: none;
}
/* First crumb (drive root) — icon sits inline with the drive name. The home
icon plays the role of the standalone "home" button in earlier designs;
here it visually fuses with the root crumb so the chain reads
"🏠 Personal > Documents" instead of "🏠 > Personal > Documents". */
.breadcrumb-home {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
gap: 0.35em;
border-radius: var(--radius-sm);
}
+10 -2
View File
@@ -1,10 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { session } from '$lib/stores/session.svelte';
// The app root redirects to the primary files view.
// The app root redirects by user kind:
// - external users (magic-link / OIDC-only / OCM recipients) have no
// personal drive, so they land on Shared-with-me;
// - internal users go to the files browser, which resolves the default
// personal drive's root folder via `session.loadHomeFolder()` (post-D0
// this reads `GET /api/drives` and picks the row whose
// `default_for_user` matches the caller).
onMount(() => {
void goto('/files', { replaceState: true });
const target = session.isExternalUser ? '/shared-with-me' : '/files';
void goto(target, { replaceState: true });
});
</script>
@@ -44,6 +44,7 @@
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
@@ -68,6 +69,17 @@
// /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
// First-crumb icon mirrors the drive at pathSegments[0]: `home` for the
// default-personal, `folder` for a secondary personal, `users` for a
// shared drive. Falls back to `home` while the drives list is loading
// or when the URL's leading segment isn't a known drive root (deep-link
// into a sub-folder bypasses drive identification — same limitation as
// the breadcrumb name resolution).
const rootIcon = $derived.by(() => {
const drive = drivesStore.findByRootFolderId(pathSegments[0] ?? null);
return drive ? driveIcon(drive) : 'home';
});
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null);
@@ -170,6 +182,21 @@
return;
}
const home = await session.loadHomeFolder();
// Canonicalize bare `/files` → `/files/<last-chosen-drive-root>` (or
// the default drive's root when there's no memory yet). Keeps the URL
// explicit, the breadcrumb populated, and the drive picker correctly
// highlighted. The DrivePicker writes `oxi-last-drive-root` on click.
if (pathSegments.length === 0) {
const last =
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
const target = last ?? home;
if (target) {
await goto(`/files/${target}`, { replaceState: true });
return;
}
}
const folderId = pathSegments.at(-1) ?? home;
if (!folderId) {
error = t('files.no_home', 'No home folder available.');
@@ -194,6 +221,8 @@
}, 100);
// Breadcrumbs resolve independently so they never block the grid paint.
// Bare `/files` was canonicalized above to `/files/<id>` so pathSegments
// is always non-empty here for internal users.
void buildCrumbs(pathSegments).then((trail) => {
if (seq === loadSeq) crumbs = trail;
});
@@ -1277,26 +1306,27 @@
</ListToolbar>
<nav class="breadcrumb" aria-label="Breadcrumb">
<a
href="/files"
class="breadcrumb-item breadcrumb-home breadcrumb-link"
title={t('breadcrumb.home', 'Home')}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => session.homeFolderId && onCrumbDrop(e, session.homeFolderId)}
>
<Icon name="home" />
</a>
{#each crumbs as c, i (c.id)}
{#if i > 0}
<span class="breadcrumb-separator">&gt;</span>
{/if}
{#if i === crumbs.length - 1}
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
<span class="breadcrumb-item breadcrumb-current" class:breadcrumb-home={i === 0}>
{#if i === 0}<Icon name={rootIcon} />{/if}
{c.name}
</span>
{:else}
<a
href={crumbHref(i)}
class="breadcrumb-item breadcrumb-link"
class:breadcrumb-home={i === 0}
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => onCrumbDrop(e, c.id)}>{c.name}</a
ondrop={(e) => onCrumbDrop(e, c.id)}
>
{#if i === 0}<Icon name={rootIcon} />{/if}
{c.name}
</a>
{/if}
{/each}
</nav>