diff --git a/docs/plan/drive.md b/docs/plan/drive.md index c3f94a0f..8967a206 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -618,11 +618,16 @@ accommodates them without schema migration) | URL | Resolves to | |---|---| -| `/` (internal user) | Redirect to `/drive/` of the caller's default personal drive | -| `/` (external user) | Redirect to `/sharedwithme` (no personal drive exists) | -| `/drive/` | Folder view (root or descendant — drive context is recovered server-side from `folders.drive_id`) | +| `/` (internal user) | Redirect to `/files/` 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 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///` | 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 configuration surface (members, policies, quota). Page is permission-aware: owner sees member management; editor/viewer see a read-only "Drive info" view | | `/config/user/` | (Future) User configuration — same shape so the `/config//` pattern is consistent across resources | +| `/drive/<...>` | **Reserved** for future drive-scoped surfaces that aren't covered by `/files/` or `/config/drive/` | + +**Why `/files/` and not `/drive/`**: the existing files browser already takes a chain of folder UUIDs (`/files///`), 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/` frontend route (drive context recovered server-side from `folders.drive_id`), `/config/drive/` for drive admin. `/` redirects to `/drive/` of the caller's default personal drive (internal users) or `/sharedwithme` (external users with no personal drive). WebDAV path dispatcher recognising `drives/` 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/` reused for cross-drive navigation (existing route — drive context recovered server-side from `folders.drive_id`), `/config/drive/` new route for drive admin. `/` redirects to `/files/` of the caller's default personal drive (internal users) or `/shared-with-me` (external users with no personal drive). WebDAV path dispatcher recognising `drives/` 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/`. Click sidebar - drive switcher → URL updates to `/drive/`, + `/files/`. Click sidebar + drive switcher → URL updates to `/files/`, listing reloads. Drive picker shows all of the caller's drives (default first), each with its quota usage. Open `/config/drive/` → owner sees member list + policies. Open `/config/drive/` 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/` 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 diff --git a/frontend/src/lib/api/endpoints/drives.ts b/frontend/src/lib/api/endpoints/drives.ts new file mode 100644 index 00000000..4d0d1e4d --- /dev/null +++ b/frontend/src/lib/api/endpoints/drives.ts @@ -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 { + return apiJson('/api/drives', { credentials: 'same-origin' }); +} diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index 0aa00b8b..3880c6c7 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -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 { - return apiJson('/api/folders', { credentials: 'same-origin' }); -} - export async function getFolder(id: string): Promise { const folder = await apiJson(`/api/folders/${id}`, NO_CACHE); rememberFolderName(folder.id, folder.name); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index c1aa8bd2..ef7577a0 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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; + created_at: string; + updated_at: string; +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index a6088ae9..1a61464b 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -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 @@ {link.label} + {#if link.href === '/files' && !session.isExternalUser} + (sidebarOpen = false)} /> + {/if} {/each} diff --git a/frontend/src/lib/components/DrivePicker.svelte b/frontend/src/lib/components/DrivePicker.svelte new file mode 100644 index 00000000..d15075fb --- /dev/null +++ b/frontend/src/lib/components/DrivePicker.svelte @@ -0,0 +1,160 @@ + + +{#if drivesStore.loaded && drivesStore.drives.length > 0} +
    + {#each sortedDrives as d (d.id)} +
  • + + {#if pctUsed(d) !== null} +
    +
    +
    + {/if} +
  • + {/each} +
+{/if} + + diff --git a/frontend/src/lib/stores/drives.svelte.ts b/frontend/src/lib/stores/drives.svelte.ts new file mode 100644 index 00000000..7596239a --- /dev/null +++ b/frontend/src/lib/stores/drives.svelte.ts @@ -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([]); + loaded = $state(false); + private inflight: Promise | null = null; + + async load(): Promise { + 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'; +} diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index c702c882..d93d9b4c 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -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 { 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; } diff --git a/frontend/src/lib/styles/ported/breadcrumb.css b/frontend/src/lib/styles/ported/breadcrumb.css index 7f6e2237..e7e9b60b 100644 --- a/frontend/src/lib/styles/ported/breadcrumb.css +++ b/frontend/src/lib/styles/ported/breadcrumb.css @@ -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); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 5bb4a4b0..1ecc7e35 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,10 +1,18 @@ diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index c9d3a223..b2232ddb 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -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({ folders: [], files: [], favoriteIds: [], sharedIds: [] }); let crumbs = $state>([]); let currentId = $state(null); @@ -170,6 +182,21 @@ return; } const home = await session.loadHomeFolder(); + + // Canonicalize bare `/files` → `/files/` (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/` so pathSegments + // is always non-empty here for internal users. void buildCrumbs(pathSegments).then((trail) => { if (seq === loadSeq) crumbs = trail; }); @@ -1277,26 +1306,27 @@