feat(drive): can move|copy to other drives
This commit is contained in:
@@ -3,13 +3,28 @@
|
|||||||
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
||||||
import { moveFile } from '$lib/api/endpoints/files';
|
import { moveFile } from '$lib/api/endpoints/files';
|
||||||
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
|
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 Icon from '$lib/icons/Icon.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import { t } from '$lib/i18n/index.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';
|
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 {
|
interface Target {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -34,9 +49,21 @@
|
|||||||
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
let crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||||
let folders = $state<FolderItem[]>([]);
|
let folders = $state<FolderItem[]>([]);
|
||||||
let currentId = $state<string | null>(null);
|
let currentId = $state<string | null>(null);
|
||||||
|
let selectedDriveId = $state<string | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let working = $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) {
|
async function loadInto(id: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -50,10 +77,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const home = await session.loadHomeFolder();
|
await drivesStore.load();
|
||||||
if (!home) return;
|
const home = drivesStore.findDefault();
|
||||||
crumbs = [{ id: home, name: session.homeFolderName ?? t('nav.files', 'Files') }];
|
// Prefer the user's home drive when it's writable (covers the
|
||||||
await loadInto(home);
|
// 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) {
|
function enter(f: FolderItem) {
|
||||||
@@ -124,6 +164,30 @@
|
|||||||
|
|
||||||
<Modal bind:open title={moveTitle}>
|
<Modal bind:open title={moveTitle}>
|
||||||
<div data-testid="move-dialog">
|
<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">
|
<div class="mv-nav">
|
||||||
<button
|
<button
|
||||||
class="mv-nav-btn"
|
class="mv-nav-btn"
|
||||||
@@ -196,6 +260,49 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.mv-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
import { it, expect, vi, beforeEach } from 'vitest';
|
import { it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
|
||||||
const { session, ui } = vi.hoisted(() => ({
|
// The dialog now sources its starting folder from the drives store
|
||||||
session: { loadHomeFolder: vi.fn(async () => 'home'), homeFolderName: 'Files' },
|
// (D6 drive switcher), not from `session.loadHomeFolder`. We mock a
|
||||||
ui: { notify: vi.fn() }
|
// single default-personal drive whose `root_folder_id` is 'home' so
|
||||||
}));
|
// the existing assertions (listFolder('home'), moveFile('f1', 'home'))
|
||||||
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
// 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/stores/ui.svelte', () => ({ ui }));
|
||||||
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
vi.mock('$lib/utils/errors', () => ({ errorToast: vi.fn() }));
|
||||||
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));
|
vi.mock('$lib/api/endpoints/folders', () => ({ listFolder: vi.fn(), moveFolder: vi.fn() }));
|
||||||
|
|||||||
Reference in New Issue
Block a user