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 | | URL | Resolves to |
|---|---| |---|---|
| `/` (internal user) | Redirect to `/drive/<root-folder-id>` of the caller's default personal drive | | `/` (internal user) | Redirect to `/files/<root-folder-id>` of the caller's default personal drive |
| `/` (external user) | Redirect to `/sharedwithme` (no personal drive exists) | | `/` (external user) | Redirect to `/shared-with-me` (no personal drive exists) |
| `/drive/<folder-id>` | Folder view (root or descendant — drive context is recovered server-side from `folders.drive_id`) | | `/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/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 | | `/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. **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 | | **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 | | **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 | | **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 | | **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 | | **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 username (or app-password binding) lands a sync into the chosen
drive transparently. drive transparently.
- **Manual smoke (internal user)**: open `/`, get redirected to - **Manual smoke (internal user)**: open `/`, get redirected to
`/drive/<default-personal-drive-root-folder-id>`. Click sidebar `/files/<default-personal-drive-root-folder-id>`. Click sidebar
drive switcher → URL updates to `/drive/<picked-drive-root-folder-id>`, drive switcher → URL updates to `/files/<picked-drive-root-folder-id>`,
listing reloads. Drive picker shows all of the caller's drives listing reloads. Drive picker shows all of the caller's drives
(default first), each with its quota usage. Open (default first), each with its quota usage. Open
`/config/drive/<personal-drive-uuid>` → owner sees member list + `/config/drive/<personal-drive-uuid>` → owner sees member list +
policies. Open `/config/drive/<shared-drive-uuid>` as a viewer → policies. Open `/config/drive/<shared-drive-uuid>` as a viewer →
read-only "Drive info" surface. read-only "Drive info" surface.
- **Manual smoke (external user)**: open `/`, get redirected to - **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). personal drive).
- **Playwright**: a new `tests/e2e/drive-switching.spec.ts` exercises - **Playwright**: a new `tests/e2e/drive-switching.spec.ts` exercises
sidebar → URL → listing → cross-drive isolation (folders in 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> { export async function getFolder(id: string): Promise<FolderItem> {
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE); const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
rememberFolderName(folder.id, folder.name); rememberFolderName(folder.id, folder.name);
+21
View File
@@ -196,3 +196,24 @@ export interface SearchResults {
query_time_ms: number; query_time_ms: number;
sort_by: string; 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 { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem } from '$lib/api/types'; import type { FileItem, FolderItem } from '$lib/api/types';
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; 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 Icon from '$lib/icons/Icon.svelte';
import { iconNameFromClass } from '$lib/utils/display'; import { iconNameFromClass } from '$lib/utils/display';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar'; import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
@@ -281,6 +283,9 @@
<Icon name={link.icon} /> <Icon name={link.icon} />
<span>{link.label}</span> <span>{link.label}</span>
</a> </a>
{#if link.href === '/files' && !session.isExternalUser}
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
{/if}
{/each} {/each}
</nav> </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. * folder and land on the shared-with-me view.
*/ */
import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; 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'; import type { User } from '$lib/api/types';
class SessionStore { class SessionStore {
@@ -41,20 +41,21 @@ class SessionStore {
} }
/** /**
* Resolve the home folder (first entry of GET /api/folders). Externals * Resolve the caller's default personal drive's root folder — the landing
* (grant-only) have no home folder, so this is skipped for them. * 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> { async loadHomeFolder(): Promise<string | null> {
if (this.homeFolderId) return this.homeFolderId; if (this.homeFolderId) return this.homeFolderId;
if (this.isExternalUser) return null; if (this.isExternalUser) return null;
try { await drives.load();
const folders = await listRootFolders(); const def = drives.findDefault();
if (folders.length > 0) { if (def) {
this.homeFolderId = folders[0].id; this.homeFolderId = def.root_folder_id;
this.homeFolderName = folders[0].name; this.homeFolderName = def.name;
}
} catch {
/* leave null — caller handles */
} }
return this.homeFolderId; return this.homeFolderId;
} }
@@ -47,12 +47,14 @@
user-select: none; 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 { .breadcrumb-home {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; gap: 0.35em;
width: 24px;
height: 24px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
+10 -2
View File
@@ -1,10 +1,18 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; 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(() => { onMount(() => {
void goto('/files', { replaceState: true }); const target = session.isExternalUser ? '/shared-with-me' : '/files';
void goto(target, { replaceState: true });
}); });
</script> </script>
@@ -44,6 +44,7 @@
import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
import { t } from '$lib/i18n/index.svelte'; import { t } from '$lib/i18n/index.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.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 { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
@@ -68,6 +69,17 @@
// /files → home root; /files/a/b → folder b inside a inside home. // /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0)); 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 listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
let crumbs = $state<Array<{ id: string; name: string }>>([]); let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null); let currentId = $state<string | null>(null);
@@ -170,6 +182,21 @@
return; return;
} }
const home = await session.loadHomeFolder(); 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; const folderId = pathSegments.at(-1) ?? home;
if (!folderId) { if (!folderId) {
error = t('files.no_home', 'No home folder available.'); error = t('files.no_home', 'No home folder available.');
@@ -194,6 +221,8 @@
}, 100); }, 100);
// Breadcrumbs resolve independently so they never block the grid paint. // 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) => { void buildCrumbs(pathSegments).then((trail) => {
if (seq === loadSeq) crumbs = trail; if (seq === loadSeq) crumbs = trail;
}); });
@@ -1277,26 +1306,27 @@
</ListToolbar> </ListToolbar>
<nav class="breadcrumb" aria-label="Breadcrumb"> <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)} {#each crumbs as c, i (c.id)}
{#if i > 0}
<span class="breadcrumb-separator">&gt;</span> <span class="breadcrumb-separator">&gt;</span>
{/if}
{#if i === crumbs.length - 1} {#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} {:else}
<a <a
href={crumbHref(i)} href={crumbHref(i)}
class="breadcrumb-item breadcrumb-link" 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()} 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} {/if}
{/each} {/each}
</nav> </nav>