feat(drive): can move|copy to other drives

This commit is contained in:
Edouard Vanbelle
2026-06-29 21:19:51 +02:00
parent ee92d365b9
commit e81b297f68
2 changed files with 145 additions and 11 deletions
+113 -6
View File
@@ -3,13 +3,28 @@
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
import { moveFile } from '$lib/api/endpoints/files';
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
import type { FolderItem } from '$lib/api/types';
import type { Drive, DriveRole, FolderItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
import { ui } from '$lib/stores/ui.svelte';
// A drive accepts new items only if the caller can Create on its root.
// Owner / Editor / Contributor cover that; Commenter + Viewer cannot.
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const;
function isWritable(d: Drive): boolean {
return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role);
}
// Default-personal first, then secondary personals, then shared; within
// a group, alphabetical. Mirrors DrivePicker so the sidebar and this
// dialog rank drives identically.
function driveRank(d: Drive): number {
if (d.default_for_user) return 0;
return d.kind === 'personal' ? 1 : 2;
}
interface Target {
id: string;
name: string;
@@ -34,9 +49,21 @@
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let folders = $state<FolderItem[]>([]);
let currentId = $state<string | null>(null);
let selectedDriveId = $state<string | null>(null);
let loading = $state(false);
let working = $state(false);
const writableDrives = $derived(
[...drivesStore.drives].filter(isWritable).sort((a, b) => {
const r = driveRank(a) - driveRank(b);
return r !== 0 ? r : a.name.localeCompare(b.name);
})
);
// The chip strip only earns its vertical space when there's a real
// choice. One writable drive → identical to the single-drive UI.
const showDriveSwitcher = $derived(writableDrives.length > 1);
async function loadInto(id: string) {
loading = true;
try {
@@ -50,10 +77,23 @@
}
async function init() {
const home = await session.loadHomeFolder();
if (!home) return;
crumbs = [{ id: home, name: session.homeFolderName ?? t('nav.files', 'Files') }];
await loadInto(home);
await drivesStore.load();
const home = drivesStore.findDefault();
// Prefer the user's home drive when it's writable (covers the
// common case: moving stuff around inside Personal). Otherwise
// fall back to the first writable drive, sorted as above.
const start = home && isWritable(home) ? home : writableDrives[0];
if (!start) return;
selectedDriveId = start.id;
crumbs = [{ id: start.root_folder_id, name: start.name }];
await loadInto(start.root_folder_id);
}
async function switchDrive(d: Drive) {
if (d.id === selectedDriveId) return;
selectedDriveId = d.id;
crumbs = [{ id: d.root_folder_id, name: d.name }];
await loadInto(d.root_folder_id);
}
function enter(f: FolderItem) {
@@ -124,6 +164,30 @@
<Modal bind:open title={moveTitle}>
<div data-testid="move-dialog">
{#if showDriveSwitcher}
<div
class="mv-drives"
role="tablist"
aria-label={t('drive.picker', 'Drives')}
data-testid="move-dialog-drives"
>
{#each writableDrives as d (d.id)}
<button
type="button"
role="tab"
aria-selected={d.id === selectedDriveId}
class="mv-drive"
class:mv-drive--active={d.id === selectedDriveId}
data-testid={`move-dialog-drive-${d.id}`}
onclick={() => switchDrive(d)}
>
<Icon name={driveIcon(d)} />
<span>{d.name}</span>
</button>
{/each}
</div>
{/if}
<div class="mv-nav">
<button
class="mv-nav-btn"
@@ -196,6 +260,49 @@
</Modal>
<style>
/* Drive switcher: a chip strip across the top of the dialog. Hidden
when only one writable drive is in scope (single-drive UX). */
.mv-drives {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.mv-drive {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
max-width: 14rem;
}
.mv-drive:hover:not(.mv-drive--active) {
background: var(--color-bg-hover);
}
.mv-drive--active {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
cursor: default;
}
.mv-drive span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mv-nav {
display: flex;
align-items: center;
+32 -5
View File
@@ -1,11 +1,38 @@
import { it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
const { session, ui } = vi.hoisted(() => ({
session: { loadHomeFolder: vi.fn(async () => 'home'), homeFolderName: 'Files' },
ui: { notify: vi.fn() }
}));
vi.mock('$lib/stores/session.svelte', () => ({ session }));
// The dialog now sources its starting folder from the drives store
// (D6 drive switcher), not from `session.loadHomeFolder`. We mock a
// single default-personal drive whose `root_folder_id` is 'home' so
// the existing assertions (listFolder('home'), moveFile('f1', 'home'))
// stay valid without test churn.
const { ui, drives, driveIcon } = vi.hoisted(() => {
const homeDrive = {
id: 'drive-home',
root_folder_id: 'home',
name: 'Personal',
kind: 'personal' as const,
default_for_user: 'user-1',
caller_role: 'owner' as const,
used_bytes: 0,
quota_bytes: null
};
return {
ui: { notify: vi.fn() },
drives: {
drives: [homeDrive],
loaded: true,
load: vi.fn(async () => [homeDrive]),
findDefault: vi.fn(() => homeDrive),
findById: vi.fn((id: string) => (id === homeDrive.id ? homeDrive : null)),
findByRootFolderId: vi.fn((id: string) =>
id === homeDrive.root_folder_id ? homeDrive : null
)
},
driveIcon: vi.fn(() => 'home')
};
});
vi.mock('$lib/stores/drives.svelte', () => ({ drives, driveIcon }));
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));