Просмотр исходного кода

File Manager: move a folder to root or into another folder (#3306)

Rojo 2 дней назад
Родитель
Сommit
97f79282d3

+ 14 - 2
backend/app/api/routes/library.py

@@ -1558,12 +1558,24 @@ async def update_folder(
         folder.name = data.name
 
     if data.parent_id is not None:
+        new_parent_id = data.parent_id or None  # 0 means move to root
+        # An external folder mirrors a path on disk, so it stays where the scan
+        # put it; sending its current parent back is not a move (#3305).
+        if folder.is_external and new_parent_id != folder.parent_id:
+            raise HTTPException(status_code=400, detail="External folders cannot be moved")
+
         # Prevent circular reference
         if data.parent_id == folder_id:
             raise HTTPException(status_code=400, detail="Folder cannot be its own parent")
 
-        # Check for circular reference in ancestors
-        if data.parent_id != 0:  # 0 means move to root
+        if new_parent_id is not None:
+            parent = await db.get(LibraryFolder, new_parent_id)
+            if parent is None:
+                raise HTTPException(status_code=404, detail="Parent folder not found")
+            if parent.is_external and new_parent_id != folder.parent_id:
+                raise HTTPException(status_code=400, detail="Cannot move a folder into an external folder")
+
+            # Check for circular reference in ancestors
             current_id = data.parent_id
             while current_id is not None:
                 if current_id == folder_id:

+ 62 - 0
backend/tests/integration/test_library_api.py

@@ -162,6 +162,68 @@ class TestLibraryFoldersAPI:
         result = response.json()
         assert result["name"] == "New Name"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_folder_into_another_folder(self, async_client: AsyncClient, folder_factory, db_session):
+        """A folder moves under another folder with parent_id (#3305)."""
+        projects = await folder_factory(name="Projects")
+        archive = await folder_factory(name="Archive")
+        response = await async_client.put(f"/api/v1/library/folders/{archive.id}", json={"parent_id": projects.id})
+        assert response.status_code == 200
+        assert response.json()["parent_id"] == projects.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_folder_to_root(self, async_client: AsyncClient, folder_factory, db_session):
+        """parent_id 0 lifts a nested folder to the top level (#3305)."""
+        projects = await folder_factory(name="Projects")
+        nested = await folder_factory(name="Nested", parent_id=projects.id)
+        response = await async_client.put(f"/api/v1/library/folders/{nested.id}", json={"parent_id": 0})
+        assert response.status_code == 200
+        assert response.json()["parent_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_folder_into_missing_parent_is_404(self, async_client: AsyncClient, folder_factory, db_session):
+        """A parent_id that does not exist is refused before anything changes (#3305)."""
+        folder = await folder_factory(name="Projects")
+        response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json={"parent_id": 9999})
+        assert response.status_code == 404
+        assert response.json()["detail"] == "Parent folder not found"
+        await db_session.refresh(folder)
+        assert folder.parent_id is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_external_folder_is_refused(self, async_client: AsyncClient, folder_factory, db_session):
+        """An external folder mirrors a path on disk and cannot be moved (#3305)."""
+        target = await folder_factory(name="Projects")
+        external = await folder_factory(name="Mount", is_external=True, external_path="/data/mount")
+        for parent_id in (target.id, 0):
+            response = await async_client.put(f"/api/v1/library/folders/{external.id}", json={"parent_id": parent_id})
+            if parent_id == 0:
+                # Already at the root: not a move, so it goes through.
+                assert response.status_code == 200
+            else:
+                assert response.status_code == 400
+                assert response.json()["detail"] == "External folders cannot be moved"
+        await db_session.refresh(external)
+        assert external.parent_id is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_move_folder_into_external_folder_is_refused(
+        self, async_client: AsyncClient, folder_factory, db_session
+    ):
+        """Nothing can be moved into an external folder (#3305)."""
+        external = await folder_factory(name="Mount", is_external=True, external_path="/data/mount")
+        folder = await folder_factory(name="Projects")
+        response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json={"parent_id": external.id})
+        assert response.status_code == 400
+        assert response.json()["detail"] == "Cannot move a folder into an external folder"
+        await db_session.refresh(folder)
+        assert folder.parent_id is None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session):

+ 431 - 0
frontend/src/__tests__/pages/FileManagerMoveFolder.test.tsx

@@ -0,0 +1,431 @@
+/**
+ * File Manager: move a folder to root or into another folder (#3305).
+ *
+ * The kebab gets a "Move folder…" entry (not for external folders, disabled
+ * without the right to change the folder). The picker offers Root and every
+ * folder except the one being moved, its subtree and external folders, and
+ * the move goes through PUT /library/folders/{id} with parent_id (0 = root).
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { setAuthToken } from '../../api/client';
+import { FOLDER_DRAG_TYPE } from '../../utils/libraryFolders';
+
+function folder(overrides: Record<string, unknown>) {
+  return {
+    parent_id: null,
+    file_count: 0,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    external_path: null,
+    external_readonly: false,
+    latest_activity_at: null,
+    created_by_id: 1,
+    shared: false,
+    can_write: true,
+    can_rename: true,
+    can_delete: true,
+    children: [],
+    ...overrides,
+  };
+}
+
+// Parent
+// └── Child
+//     └── Grandchild
+// Other
+// NAS (external)
+const folders = [
+  folder({
+    id: 1,
+    name: 'Parent',
+    children: [
+      folder({
+        id: 2,
+        name: 'Child',
+        parent_id: 1,
+        children: [folder({ id: 3, name: 'Grandchild', parent_id: 2 })],
+      }),
+    ],
+  }),
+  folder({ id: 4, name: 'Other' }),
+  folder({ id: 9, name: 'NAS', is_external: true, external_path: '/mnt/nas' }),
+];
+
+function mockAuthUser(permissions: string[]) {
+  setAuthToken('test-token', 'session');
+  server.use(
+    http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({ id: 1, username: 'admin', is_admin: true, permissions }),
+    ),
+  );
+}
+
+async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
+  const row = screen.getByText(folderName).closest('div.group')!;
+  const buttons = within(row).getAllByRole('button');
+  await user.click(buttons[buttons.length - 1]);
+  return row;
+}
+
+describe('FileManager move folder (#3305)', () => {
+  let putBodies: { id: number; body: unknown }[] = [];
+
+  beforeEach(() => {
+    localStorage.clear();
+    // The tree is expanded by default, so Child and Grandchild are rendered.
+    putBodies = [];
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json(folders)),
+      http.get('/api/v1/library/files', () => HttpResponse.json([])),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: 0,
+          total_folders: 5,
+          total_size_bytes: 0,
+          disk_free_bytes: 10737418240,
+          disk_total_bytes: 107374182400,
+        }),
+      ),
+      http.put('/api/v1/library/folders/:id', async ({ request, params }) => {
+        putBodies.push({ id: Number(params.id), body: await request.json() });
+        return HttpResponse.json(folder({ id: Number(params.id), name: 'x', created_at: '', updated_at: '' }));
+      }),
+    );
+  });
+
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  it('moves a nested folder to the root with parent_id 0', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'Child');
+    await user.click(within(row).getByRole('button', { name: 'Move folder…' }));
+
+    const dialog = screen.getByText('Move "Child" to').closest('.fixed')!;
+    // Root is offered; the folder itself, its subtree and the external folder are not.
+    expect(within(dialog).getByRole('button', { name: 'Root' })).not.toBeDisabled();
+    expect(within(dialog).getByRole('button', { name: /^Child$/ })).toBeDisabled();
+    expect(within(dialog).getByRole('button', { name: /^Grandchild$/ })).toBeDisabled();
+    expect(within(dialog).getByRole('button', { name: /^NAS$/ })).toBeDisabled();
+    expect(within(dialog).getByRole('button', { name: /^Other$/ })).not.toBeDisabled();
+    // Its current parent is marked and cannot be picked again.
+    expect(within(dialog).getByRole('button', { name: /^Parent/ })).toBeDisabled();
+    expect(within(dialog).getByText('(current)')).toBeInTheDocument();
+
+    // Nothing selected yet: Move stays disabled.
+    expect(within(dialog).getByRole('button', { name: 'Move' })).toBeDisabled();
+    await user.click(within(dialog).getByRole('button', { name: 'Root' }));
+    await user.click(within(dialog).getByRole('button', { name: 'Move' }));
+
+    await waitFor(() => expect(putBodies).toEqual([{ id: 2, body: { parent_id: 0 } }]));
+    await waitFor(() => expect(screen.getByText('Folder moved')).toBeInTheDocument());
+  });
+
+  it('moves a folder into another folder', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('Other')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'Other');
+    await user.click(within(row).getByRole('button', { name: 'Move folder…' }));
+
+    const dialog = screen.getByText('Move "Other" to').closest('.fixed')!;
+    await user.click(within(dialog).getByRole('button', { name: /^Child$/ }));
+    await user.click(within(dialog).getByRole('button', { name: 'Move' }));
+
+    await waitFor(() => expect(putBodies).toEqual([{ id: 4, body: { parent_id: 2 } }]));
+  });
+
+  it('does not offer to move an external folder', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('NAS')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'NAS');
+    expect(within(row).getByRole('button', { name: 'Rename' })).toBeInTheDocument();
+    expect(within(row).queryByRole('button', { name: 'Move folder…' })).not.toBeInTheDocument();
+  });
+
+  it('disables the action for a user who may not change the folder', async () => {
+    mockAuthUser(['library:read_all']);
+    server.use(
+      http.get('/api/v1/library/folders', () =>
+        HttpResponse.json(folders.map((f) => ({ ...f, can_rename: false, children: [] }))),
+      ),
+    );
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('Other')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'Other');
+    const move = within(row).getByRole('button', { name: 'Move folder…' });
+    expect(move).toBeDisabled();
+    expect(move).toHaveAttribute('title', 'You do not have permission to move folders');
+  });
+
+  // jsdom has no DataTransfer; this is the slice of it the handlers read.
+  function fakeDataTransfer() {
+    const data: Record<string, string> = {};
+    return {
+      types: [] as string[],
+      files: [] as File[],
+      effectAllowed: 'uninitialized',
+      dropEffect: 'none',
+      setData(type: string, value: string) {
+        data[type] = value;
+        this.types = Object.keys(data);
+      },
+      getData(type: string) {
+        return data[type] ?? '';
+      },
+    };
+  }
+
+  function treeRow(name: string) {
+    return screen.getByText(name).closest('div.group')!;
+  }
+
+  it('moves a dragged folder onto the folder it is dropped on', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
+
+    const dataTransfer = fakeDataTransfer();
+    const source = treeRow('Child');
+    expect(source).toHaveAttribute('draggable', 'true');
+    fireEvent.dragStart(source, { dataTransfer });
+    expect(dataTransfer.getData(FOLDER_DRAG_TYPE)).toBe('2');
+
+    const target = treeRow('Other');
+    fireEvent.dragOver(target, { dataTransfer });
+    fireEvent.drop(target, { dataTransfer });
+
+    await waitFor(() => expect(putBodies).toEqual([{ id: 2, body: { parent_id: 4 } }]));
+  });
+
+  it('moves a dragged folder to the root when dropped on All Files', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('Grandchild')).toBeInTheDocument());
+
+    const dataTransfer = fakeDataTransfer();
+    fireEvent.dragStart(treeRow('Grandchild'), { dataTransfer });
+    const allFiles = screen.getByText('All Files', { selector: 'span' }).parentElement!;
+    fireEvent.dragOver(allFiles, { dataTransfer });
+    fireEvent.drop(allFiles, { dataTransfer });
+
+    await waitFor(() => expect(putBodies).toEqual([{ id: 3, body: { parent_id: 0 } }]));
+  });
+
+  it('ignores a drop onto the folder itself, a descendant, its parent or an external folder', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('Grandchild')).toBeInTheDocument());
+
+    const dataTransfer = fakeDataTransfer();
+    fireEvent.dragStart(treeRow('Child'), { dataTransfer });
+    for (const name of ['Child', 'Grandchild', 'Parent', 'NAS']) {
+      const target = treeRow(name);
+      fireEvent.dragOver(target, { dataTransfer });
+      fireEvent.drop(target, { dataTransfer });
+    }
+    // Give a stray request time to show up before asserting none did.
+    await new Promise((r) => setTimeout(r, 50));
+    expect(putBodies).toEqual([]);
+  });
+
+  it('does not let an external folder be dragged', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('NAS')).toBeInTheDocument());
+    expect(treeRow('NAS')).not.toHaveAttribute('draggable');
+  });
+
+  it('refuses a drop onto a folder the user may not write to', async () => {
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    server.use(
+      http.get('/api/v1/library/folders', () =>
+        HttpResponse.json([...folders, folder({ id: 6, name: 'Shared', shared: true, can_write: false })]),
+      ),
+    );
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('Shared')).toBeInTheDocument());
+
+    // The picker greys it out ...
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'Other');
+    await user.click(within(row).getByRole('button', { name: 'Move folder…' }));
+    const dialog = screen.getByText('Move "Other" to').closest('.fixed')!;
+    expect(within(dialog).getByRole('button', { name: /^Shared$/ })).toBeDisabled();
+    await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
+
+    // ... and the drop target refuses it the same way.
+    const dataTransfer = fakeDataTransfer();
+    fireEvent.dragStart(treeRow('Other'), { dataTransfer });
+    const target = treeRow('Shared');
+    expect(fireEvent.dragOver(target, { dataTransfer })).toBe(true);
+    expect(dataTransfer.dropEffect).toBe('none');
+    fireEvent.drop(target, { dataTransfer });
+    await new Promise((r) => setTimeout(r, 50));
+    expect(putBodies).toEqual([]);
+  });
+});
+
+// Columns view: every column is itself a drop target for its own folder, and
+// its rows sit inside it. A drop a row refuses must stay on that row.
+describe('FileManager move folder in the columns view (#3305)', () => {
+  let putBodies: { id: number; body: unknown }[] = [];
+
+  // A
+  // ├── B
+  // │   └── X
+  // └── C
+  // Other
+  const columnFolders = [
+    folder({
+      id: 1,
+      name: 'A',
+      children: [
+        folder({ id: 2, name: 'B', parent_id: 1, children: [folder({ id: 3, name: 'X', parent_id: 2 })] }),
+        folder({ id: 5, name: 'C', parent_id: 1 }),
+      ],
+    }),
+    folder({ id: 4, name: 'Other' }),
+  ];
+
+  beforeEach(() => {
+    localStorage.clear();
+    putBodies = [];
+    mockAuthUser(['library:read_all', 'library:update_all']);
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json(columnFolders)),
+      http.get('/api/v1/library/files', () => HttpResponse.json([])),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: 0,
+          total_folders: 5,
+          total_size_bytes: 0,
+          disk_free_bytes: 10737418240,
+          disk_total_bytes: 107374182400,
+        }),
+      ),
+      http.put('/api/v1/library/folders/:id', async ({ request, params }) => {
+        putBodies.push({ id: Number(params.id), body: await request.json() });
+        return HttpResponse.json(folder({ id: Number(params.id), name: 'x', created_at: '', updated_at: '' }));
+      }),
+    );
+  });
+
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  function fakeDataTransfer() {
+    const data: Record<string, string> = {};
+    return {
+      types: [] as string[],
+      files: [] as File[],
+      effectAllowed: 'uninitialized',
+      dropEffect: 'none',
+      setData(type: string, value: string) {
+        data[type] = value;
+        this.types = Object.keys(data);
+      },
+      getData(type: string) {
+        return data[type] ?? '';
+      },
+    };
+  }
+
+  // Opens the columns view down to A > B, so the columns are root, A and B.
+  async function openColumnsAtB() {
+    render(<FileManagerPage />);
+    const user = userEvent.setup();
+    await user.click(await screen.findByTitle('Column view'));
+    await user.click(within(screen.getByTestId('columns-level-root')).getByText('A'));
+    await user.click(await within(screen.getByTestId('columns-level-folder-1')).findByText('B'));
+    await within(screen.getByTestId('columns-level-folder-2')).findByText('X');
+  }
+
+  function column(id: 'root' | number) {
+    return screen.getByTestId(id === 'root' ? 'columns-level-root' : `columns-level-folder-${id}`);
+  }
+
+  function columnRow(columnId: 'root' | number, name: string) {
+    return within(column(columnId)).getByText(name).closest('[data-folder-id]') as HTMLElement;
+  }
+
+  function dropOn(target: HTMLElement, dataTransfer: ReturnType<typeof fakeDataTransfer>) {
+    fireEvent.dragEnter(target, { dataTransfer });
+    const notCancelled = fireEvent.dragOver(target, { dataTransfer });
+    fireEvent.drop(target, { dataTransfer });
+    return notCancelled;
+  }
+
+  it('does not hand a drop on the parent row to the column around it', async () => {
+    await openColumnsAtB();
+    const dataTransfer = fakeDataTransfer();
+    fireEvent.dragStart(columnRow(2, 'X'), { dataTransfer });
+
+    // X onto B, its parent: before the fix the A column took it and X moved into A.
+    expect(dropOn(columnRow(1, 'B'), dataTransfer)).toBe(true);
+    expect(dataTransfer.dropEffect).toBe('none');
+    expect(column(1).className).not.toContain('ring-bambu-green');
+
+    await new Promise((r) => setTimeout(r, 50));
+    expect(putBodies).toEqual([]);
+  });
+
+  it('does not hand a drop on the parent row to the root column', async () => {
+    await openColumnsAtB();
+    const dataTransfer = fakeDataTransfer();
+    fireEvent.dragStart(columnRow(1, 'B'), { dataTransfer });
+
+    // B onto A, its parent: before the fix the root column took it and B moved to the root.
+    expect(dropOn(columnRow('root', 'A'), dataTransfer)).toBe(true);
+    expect(column('root').className).not.toContain('ring-bambu-green');
+
+    await new Promise((r) => setTimeout(r, 50));
+    expect(putBodies).toEqual([]);
+  });
+
+  it('clears the column highlight when a row inside it takes the drop', async () => {
+    await openColumnsAtB();
+    const dataTransfer = fakeDataTransfer();
+    fireEvent.dragStart(columnRow(2, 'X'), { dataTransfer });
+
+    // Over the A column's empty space: the column is the target.
+    fireEvent.dragOver(column(1), { dataTransfer });
+    await waitFor(() => expect(column(1).className).toContain('ring-bambu-green'));
+
+    // Over the C row in it: only the row is lit.
+    const c = columnRow(1, 'C');
+    fireEvent.dragEnter(c, { dataTransfer });
+    fireEvent.dragOver(c, { dataTransfer });
+    await waitFor(() => expect(c.className).toContain('ring-bambu-green'));
+    expect(column(1).className).not.toContain('ring-bambu-green');
+
+    fireEvent.drop(c, { dataTransfer });
+    await waitFor(() => expect(putBodies).toEqual([{ id: 3, body: { parent_id: 5 } }]));
+    expect(column(1).className).not.toContain('ring-bambu-green');
+    expect(columnRow(1, 'C').className).not.toContain('ring-bambu-green');
+  });
+});

+ 120 - 0
frontend/src/__tests__/utils/libraryFolders.test.ts

@@ -0,0 +1,120 @@
+/**
+ * Which folders may a folder be moved into (#3305)?
+ *
+ * The picker and the drop targets both lean on these: the folder itself and
+ * its subtree are out (the server would 400 them anyway), external folders are
+ * out (they mirror a path on disk), and the parent it already has is a no-op.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { LibraryFolderTree } from '../../api/client';
+import { isValidFolderMove, isWritableFolder, moveDisallowedFolderIds } from '../../utils/libraryFolders';
+
+function folder(id: number, overrides: Partial<LibraryFolderTree> = {}): LibraryFolderTree {
+  return {
+    id,
+    name: `Folder ${id}`,
+    parent_id: null,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    external_path: null,
+    external_readonly: false,
+    file_count: 0,
+    latest_activity_at: null,
+    created_by_id: null,
+    shared: false,
+    can_write: true,
+    can_rename: true,
+    can_delete: true,
+    children: [],
+    ...overrides,
+  };
+}
+
+// 1
+// ├── 2
+// │   └── 3
+// └── 4
+// 5
+// 9 (external)
+// └── 10
+const tree: LibraryFolderTree[] = [
+  folder(1, {
+    children: [
+      folder(2, { parent_id: 1, children: [folder(3, { parent_id: 2 })] }),
+      folder(4, { parent_id: 1 }),
+    ],
+  }),
+  folder(5),
+  folder(9, {
+    is_external: true,
+    external_path: '/mnt/nas',
+    children: [folder(10, { parent_id: 9, is_external: true, external_path: '/mnt/nas/sub' })],
+  }),
+];
+
+describe('moveDisallowedFolderIds', () => {
+  it('excludes the folder itself, its whole subtree and every external folder', () => {
+    expect([...moveDisallowedFolderIds(tree, 1)].sort()).toEqual([1, 10, 2, 3, 4, 9].sort());
+  });
+
+  it('leaves ancestors and siblings available', () => {
+    const disallowed = moveDisallowedFolderIds(tree, 3);
+    expect(disallowed.has(3)).toBe(true);
+    expect(disallowed.has(2)).toBe(false);
+    expect(disallowed.has(1)).toBe(false);
+    expect(disallowed.has(4)).toBe(false);
+    expect(disallowed.has(5)).toBe(false);
+  });
+
+  it('still rules out external folders for an id that is not in the tree', () => {
+    expect([...moveDisallowedFolderIds(tree, 999)].sort()).toEqual([10, 9].sort());
+  });
+});
+
+describe('isValidFolderMove', () => {
+  const two = tree[0].children[0];
+  const five = tree[1];
+
+  it('allows a nested folder to go to the root', () => {
+    expect(isValidFolderMove(tree, two, null)).toBe(true);
+  });
+
+  it('treats the current parent as a no-op', () => {
+    expect(isValidFolderMove(tree, two, 1)).toBe(false);
+    expect(isValidFolderMove(tree, five, null)).toBe(false);
+  });
+
+  it('refuses itself, a descendant and an external destination', () => {
+    expect(isValidFolderMove(tree, two, 2)).toBe(false);
+    expect(isValidFolderMove(tree, two, 3)).toBe(false);
+    expect(isValidFolderMove(tree, two, 9)).toBe(false);
+    expect(isValidFolderMove(tree, two, 10)).toBe(false);
+  });
+
+  it('allows a sibling and an unrelated folder', () => {
+    expect(isValidFolderMove(tree, two, 4)).toBe(true);
+    expect(isValidFolderMove(tree, two, 5)).toBe(true);
+  });
+
+  it('never moves an external folder', () => {
+    expect(isValidFolderMove(tree, tree[2], 5)).toBe(false);
+    expect(isValidFolderMove(tree, tree[2].children[0], null)).toBe(false);
+  });
+
+  it('refuses a destination the user may not write to, and one that is not in the tree', () => {
+    const readOnly = [...tree, folder(6, { can_write: false })];
+    expect(isValidFolderMove(readOnly, two, 6)).toBe(false);
+    expect(isValidFolderMove(tree, two, 999)).toBe(false);
+  });
+});
+
+describe('isWritableFolder', () => {
+  it('follows the can_write flag the server sends', () => {
+    expect(isWritableFolder({ can_write: true })).toBe(true);
+    expect(isWritableFolder({ can_write: false })).toBe(false);
+  });
+});

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -4226,6 +4226,10 @@ export default {
     invalidFilenameChar: 'Das Zeichen "{{char}}" ist in Druck-Dateinamen nicht erlaubt. Die SD-Karte des Druckers lehnt folgende Zeichen ab: < > : " / \\ | ? *',
     moveFiles: '{{count}} Datei(en) verschieben',
     rootNoFolder: 'Stammverzeichnis (Kein Ordner)',
+    moveFolder: 'Ordner verschieben…',
+    moveFolderTo: '„{{name}}“ verschieben nach',
+    moveToRoot: 'Stammverzeichnis',
+    moveFolderInvalidTarget: 'Ein Ordner kann nicht in sich selbst, seine Unterordner oder einen externen Ordner verschoben werden',
     current: 'aktuell',
     linkFolder: 'Ordner verknüpfen',
     linkFolderDescription: '"{{name}}" mit einem Projekt oder Archiv verknüpfen für schnellen Zugriff.',
@@ -4435,6 +4439,7 @@ export default {
     noPermissionCreateFolder: 'Sie haben keine Berechtigung, Ordner zu erstellen',
     noPermissionUpload: 'Sie haben keine Berechtigung, Dateien hochzuladen',
     noPermissionMoveFiles: 'Sie haben keine Berechtigung, Dateien zu verschieben',
+    noPermissionMoveFolder: 'Sie haben keine Berechtigung, Ordner zu verschieben',
     noPermissionDeleteFiles: 'Sie haben keine Berechtigung, Dateien zu löschen',
     // External folder
     linkExternal: 'Extern verknüpfen',
@@ -4455,6 +4460,7 @@ export default {
       fileDeleted: 'Datei gelöscht',
       filesDeleted: '{{count}} Dateien gelöscht',
       filesMoved: 'Dateien verschoben',
+      folderMoved: 'Ordner verschoben',
       folderLinked: 'Ordner verknüpft',
       folderUnlinked: 'Ordnerverknüpfung aufgehoben',
       externalFolderLinked: 'Externer Ordner verknüpft und gescannt',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -4258,6 +4258,10 @@ export default {
     invalidFilenameChar: 'The character "{{char}}" is not allowed in print filenames. The printer SD card rejects: < > : " / \\ | ? *',
     moveFiles: 'Move {{count}} File(s)',
     rootNoFolder: 'Root (No Folder)',
+    moveFolder: 'Move folder…',
+    moveFolderTo: 'Move "{{name}}" to',
+    moveToRoot: 'Root',
+    moveFolderInvalidTarget: 'A folder cannot be moved into itself, its own subfolders or an external folder',
     current: 'current',
     linkFolder: 'Link Folder',
     linkFolderDescription: 'Link "{{name}}" to a project or archive for quick access.',
@@ -4467,6 +4471,7 @@ export default {
     noPermissionCreateFolder: 'You do not have permission to create folders',
     noPermissionUpload: 'You do not have permission to upload files',
     noPermissionMoveFiles: 'You do not have permission to move files',
+    noPermissionMoveFolder: 'You do not have permission to move folders',
     noPermissionDeleteFiles: 'You do not have permission to delete files',
     // External folder
     linkExternal: 'Link External',
@@ -4487,6 +4492,7 @@ export default {
       fileDeleted: 'File deleted',
       filesDeleted: 'Deleted {{count}} files',
       filesMoved: 'Files moved',
+      folderMoved: 'Folder moved',
       folderLinked: 'Folder linked',
       folderUnlinked: 'Folder unlinked',
       externalFolderLinked: 'External folder linked and scanned',

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -4228,6 +4228,10 @@ export default {
     invalidFilenameChar: 'El carácter "{{char}}" no está permitido en nombres de archivos de impresión. La tarjeta SD de la impresora rechaza: < > : " / \\ | ? *',
     moveFiles: 'Mover {{count}} archivo(s)',
     rootNoFolder: 'Raíz (sin carpeta)',
+    moveFolder: 'Mover carpeta…',
+    moveFolderTo: 'Mover "{{name}}" a',
+    moveToRoot: 'Raíz',
+    moveFolderInvalidTarget: 'Una carpeta no se puede mover dentro de sí misma, de sus subcarpetas ni de una carpeta externa',
     current: 'actual',
     linkFolder: 'Vincular carpeta',
     linkFolderDescription: 'Vincule "{{name}}" a un proyecto o archivo para un acceso rápido.',
@@ -4437,6 +4441,7 @@ export default {
     noPermissionCreateFolder: 'No tiene permiso para crear carpetas',
     noPermissionUpload: 'No tiene permiso para subir archivos',
     noPermissionMoveFiles: 'No tiene permiso para mover archivos',
+    noPermissionMoveFolder: 'No tiene permiso para mover carpetas',
     noPermissionDeleteFiles: 'No tiene permiso para eliminar archivos',
     // External folder
     linkExternal: 'Vincular externa',
@@ -4457,6 +4462,7 @@ export default {
       fileDeleted: 'Archivo eliminado',
       filesDeleted: 'Se eliminaron {{count}} archivos',
       filesMoved: 'Archivos movidos',
+      folderMoved: 'Carpeta movida',
       folderLinked: 'Carpeta vinculada',
       folderUnlinked: 'Carpeta desvinculada',
       externalFolderLinked: 'Carpeta externa vinculada y escaneada',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -4214,6 +4214,10 @@ export default {
     invalidFilenameChar: 'Le caractère "{{char}}" n\'est pas autorisé dans les noms de fichier d\'impression. La carte SD de l\'imprimante refuse : < > : " / \\ | ? *',
     moveFiles: 'Déplacer {{count}} fichier(s)',
     rootNoFolder: 'Racine (aucun dossier)',
+    moveFolder: 'Déplacer le dossier…',
+    moveFolderTo: 'Déplacer « {{name}} » vers',
+    moveToRoot: 'Racine',
+    moveFolderInvalidTarget: 'Un dossier ne peut pas être déplacé dans lui-même, dans ses sous-dossiers ni dans un dossier externe',
     current: 'actuel',
     linkFolder: 'Lier le dossier',
     linkFolderDescription: 'Lier "{{name}}" à un projet ou archive.',
@@ -4423,6 +4427,7 @@ export default {
     noPermissionCreateFolder: 'Pas d\'autorisation nouveau dossier',
     noPermissionUpload: 'Pas d\'autorisation téléversement',
     noPermissionMoveFiles: 'Pas d\'autorisation déplacement',
+    noPermissionMoveFolder: 'Pas d\'autorisation pour déplacer les dossiers',
     noPermissionDeleteFiles: 'Pas d\'autorisation suppression groupée',
     // External folder
     linkExternal: 'Lier externe',
@@ -4443,6 +4448,7 @@ export default {
       fileDeleted: 'Fichier supprimé',
       filesDeleted: '{{count}} fichiers supprimés',
       filesMoved: 'Fichiers déplacés',
+      folderMoved: 'Dossier déplacé',
       folderLinked: 'Dossier lié',
       folderUnlinked: 'Dossier délié',
       externalFolderLinked: 'Dossier externe lié et scanné',

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -4213,6 +4213,10 @@ export default {
     invalidFilenameChar: 'Il carattere "{{char}}" non è consentito nei nomi dei file di stampa. La scheda SD della stampante rifiuta: < > : " / \\ | ? *',
     moveFiles: 'Sposta {{count}} file',
     rootNoFolder: 'Root (nessuna cartella)',
+    moveFolder: 'Sposta cartella…',
+    moveFolderTo: 'Sposta "{{name}}" in',
+    moveToRoot: 'Radice',
+    moveFolderInvalidTarget: 'Una cartella non può essere spostata in se stessa, nelle sue sottocartelle o in una cartella esterna',
     current: 'corrente',
     linkFolder: 'Collega cartella',
     linkFolderDescription: 'Collega "{{name}}" a un progetto o archivio per accesso rapido.',
@@ -4422,6 +4426,7 @@ export default {
     noPermissionCreateFolder: 'Non hai il permesso di creare cartelle',
     noPermissionUpload: 'Non hai il permesso di caricare file',
     noPermissionMoveFiles: 'Non hai il permesso di spostare file',
+    noPermissionMoveFolder: 'Non hai il permesso di spostare cartelle',
     noPermissionDeleteFiles: 'Non hai il permesso di eliminare file',
     // External folder
     linkExternal: 'Collega esterno',
@@ -4442,6 +4447,7 @@ export default {
       fileDeleted: 'File eliminato',
       filesDeleted: 'Eliminati {{count}} file',
       filesMoved: 'File spostati',
+      folderMoved: 'Cartella spostata',
       folderLinked: 'Cartella collegata',
       folderUnlinked: 'Cartella scollegata',
       externalFolderLinked: 'Cartella esterna collegata e scansionata',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -4226,6 +4226,10 @@ export default {
     invalidFilenameChar: '印刷ファイル名に "{{char}}" は使用できません。プリンターのSDカードは次の文字を拒否します: < > : " / \\ | ? *',
     moveFiles: '{{count}}件のファイルを移動',
     rootNoFolder: 'ルート(フォルダなし)',
+    moveFolder: 'フォルダを移動…',
+    moveFolderTo: '「{{name}}」の移動先',
+    moveToRoot: 'ルート',
+    moveFolderInvalidTarget: 'フォルダを自身、そのサブフォルダ、または外部フォルダに移動することはできません',
     current: '(現在)',
     linkFolder: 'フォルダをリンク',
     linkFolderDescription: '「{{name}}」をプロジェクトまたはアーカイブにリンクしてすばやくアクセス。',
@@ -4435,6 +4439,7 @@ export default {
     noPermissionCreateFolder: 'フォルダーを作成する権限がありません',
     noPermissionUpload: 'ファイルをアップロードする権限がありません',
     noPermissionMoveFiles: 'ファイルを移動する権限がありません',
+    noPermissionMoveFolder: 'フォルダを移動する権限がありません',
     noPermissionDeleteFiles: 'ファイルを削除する権限がありません',
     // External folder
     linkExternal: '外部リンク',
@@ -4455,6 +4460,7 @@ export default {
       fileDeleted: 'ファイルを削除しました',
       filesDeleted: '{{count}}件のファイルを削除しました',
       filesMoved: 'ファイルを移動しました',
+      folderMoved: 'フォルダを移動しました',
       folderLinked: 'フォルダをリンクしました',
       folderUnlinked: 'フォルダのリンクを解除しました',
       externalFolderLinked: '外部フォルダがリンクされスキャンされました',

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -4037,6 +4037,10 @@ export default {
     invalidFilenameChar: '"{{char}}" 문자는 인쇄 파일 이름에 허용되지 않습니다. 프린터 SD 카드가 거부하는 문자: < > : " / \\ | ? *',
     moveFiles: '{{count}}개 파일 이동',
     rootNoFolder: '루트 (폴더 없음)',
+    moveFolder: '폴더 이동…',
+    moveFolderTo: '"{{name}}" 이동 위치',
+    moveToRoot: '루트',
+    moveFolderInvalidTarget: '폴더는 자신, 자신의 하위 폴더 또는 외부 폴더로 이동할 수 없습니다',
     current: '현재',
     linkFolder: '폴더 연결',
     linkFolderDescription: '"{{name}}"을(를) 프로젝트 또는 아카이브에 연결하여 빠른 접근.',
@@ -4246,6 +4250,7 @@ export default {
     noPermissionCreateFolder: '폴더 생성 권한이 없습니다',
     noPermissionUpload: '파일 업로드 권한이 없습니다',
     noPermissionMoveFiles: '파일 이동 권한이 없습니다',
+    noPermissionMoveFolder: '폴더 이동 권한이 없습니다',
     noPermissionDeleteFiles: '파일 삭제 권한이 없습니다',
     linkExternal: '외부 연결',
     linkExternalFolder: '외부 폴더 연결',
@@ -4265,6 +4270,7 @@ export default {
       fileDeleted: '파일 삭제됨',
       filesDeleted: '{{count}}개 파일 삭제됨',
       filesMoved: '파일 이동됨',
+      folderMoved: '폴더 이동됨',
       folderLinked: '폴더 연결됨',
       folderUnlinked: '폴더 연결 해제됨',
       externalFolderLinked: '외부 폴더 연결 및 스캔됨',

+ 6 - 0
frontend/src/i18n/locales/nl.ts

@@ -4256,6 +4256,10 @@ export default {
     invalidFilenameChar: 'Het teken "{{char}}" is niet toegestaan in bestandsnamen voor afdrukken. De SD-kaart van de printer weigert: < > : " / \\ | ? *',
     moveFiles: '{{count}} bestand(en) verplaatsen',
     rootNoFolder: 'Hoofdmap (geen map)',
+    moveFolder: 'Map verplaatsen…',
+    moveFolderTo: '"{{name}}" verplaatsen naar',
+    moveToRoot: 'Hoofdmap',
+    moveFolderInvalidTarget: 'Een map kan niet naar zichzelf, zijn eigen submappen of een externe map worden verplaatst',
     current: 'huidig',
     linkFolder: 'Map koppelen',
     linkFolderDescription: 'Koppel "{{name}}" aan een project of archief voor snelle toegang.',
@@ -4465,6 +4469,7 @@ export default {
     noPermissionCreateFolder: 'Je hebt geen toestemming om mappen te maken',
     noPermissionUpload: 'Je hebt geen toestemming om bestanden te uploaden',
     noPermissionMoveFiles: 'Je hebt geen toestemming om bestanden te verplaatsen',
+    noPermissionMoveFolder: 'Je hebt geen toestemming om mappen te verplaatsen',
     noPermissionDeleteFiles: 'Je hebt geen toestemming om bestanden te verwijderen',
     // External folder
     linkExternal: 'Extern koppelen',
@@ -4485,6 +4490,7 @@ export default {
       fileDeleted: 'Bestand verwijderd',
       filesDeleted: '{{count}} bestanden verwijderd',
       filesMoved: 'Bestanden verplaatst',
+      folderMoved: 'Map verplaatst',
       folderLinked: 'Map gekoppeld',
       folderUnlinked: 'Map ontkoppeld',
       externalFolderLinked: 'Externe map gekoppeld en gescand',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4213,6 +4213,10 @@ export default {
     invalidFilenameChar: 'O caractere "{{char}}" não é permitido em nomes de arquivos de impressão. O cartão SD da impressora rejeita: < > : " / \\ | ? *',
     moveFiles: 'Mover {{count}} Arquivo(s)',
     rootNoFolder: 'Raiz (Sem Pasta)',
+    moveFolder: 'Mover pasta…',
+    moveFolderTo: 'Mover "{{name}}" para',
+    moveToRoot: 'Raiz',
+    moveFolderInvalidTarget: 'Uma pasta não pode ser movida para dentro dela mesma, de suas subpastas ou de uma pasta externa',
     current: 'Atual',
     linkFolder: 'Vincular Pasta',
     linkFolderDescription: 'Vincular "{{name}}" a um projeto ou arquivo para acesso rápido.',
@@ -4422,6 +4426,7 @@ export default {
     noPermissionCreateFolder: 'Você não tem permissão para criar pastas',
     noPermissionUpload: 'Você não tem permissão para enviar arquivos',
     noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',
+    noPermissionMoveFolder: 'Você não tem permissão para mover pastas',
     noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',
     // External folder
     linkExternal: 'Vincular externo',
@@ -4442,6 +4447,7 @@ export default {
       fileDeleted: 'Arquivo excluído',
       filesDeleted: 'Excluídos {{count}} arquivos',
       filesMoved: 'Arquivos movidos',
+      folderMoved: 'Pasta movida',
       folderLinked: 'Pasta vinculada',
       folderUnlinked: 'Pasta desvinculada',
       externalFolderLinked: 'Pasta externa vinculada e escaneada',

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -4029,6 +4029,10 @@ export default {
     invalidFilenameChar: "Символ «{{char}}» нельзя использовать в именах файлов печати. SD-карта принтера не принимает: < > : \" / \\ | ? *",
     moveFiles: "Переместить файлы ({{count}})",
     rootNoFolder: "Корень (без папки)",
+    moveFolder: "Переместить папку…",
+    moveFolderTo: "Переместить «{{name}}» в",
+    moveToRoot: "Корень",
+    moveFolderInvalidTarget: "Папку нельзя переместить в саму себя, в её подпапки или во внешнюю папку",
     current: "текущая",
     linkFolder: "Связать папку",
     linkFolderDescription: "Связать «{{name}}» с проектом или архивом для быстрого доступа.",
@@ -4238,6 +4242,7 @@ export default {
     noPermissionCreateFolder: "У вас нет прав на создание папок",
     noPermissionUpload: "У вас нет прав на загрузку файлов",
     noPermissionMoveFiles: "У вас нет прав на перемещение файлов",
+    noPermissionMoveFolder: "У вас нет прав на перемещение папок",
     noPermissionDeleteFiles: "У вас нет прав на удаление файлов",
     linkExternal: "Подключить внешнюю папку",
     linkExternalFolder: "Подключить внешнюю папку",
@@ -4257,6 +4262,7 @@ export default {
       fileDeleted: "Файл удалён",
       filesDeleted: "Удалено файлов: {{count}}",
       filesMoved: "Файлы перемещены",
+      folderMoved: "Папка перемещена",
       folderLinked: "Папка связана",
       folderUnlinked: "Связь с папкой удалена",
       externalFolderLinked: "Внешняя папка подключена и просканирована",

+ 6 - 0
frontend/src/i18n/locales/sv.ts

@@ -4255,6 +4255,10 @@ errors: {
     invalidFilenameChar: 'Tecknet "{{char}}" är inte tillåtet i utskriftsfilnamn. Skrivarens SD-kort avvisar: < > : " / \\ | ? *',
     moveFiles: 'Flytta {{count}} fil(er)',
     rootNoFolder: 'Rot (ingen mapp)',
+    moveFolder: 'Flytta mapp…',
+    moveFolderTo: 'Flytta "{{name}}" till',
+    moveToRoot: 'Rot',
+    moveFolderInvalidTarget: 'En mapp kan inte flyttas till sig själv, sina egna undermappar eller en extern mapp',
     current: 'nuvarande',
     linkFolder: 'Länka mapp',
     linkFolderDescription: 'Länka "{{name}}" till ett projekt eller arkiv för snabb åtkomst.',
@@ -4464,6 +4468,7 @@ errors: {
     noPermissionCreateFolder: 'Du har inte behörighet att skapa mappar',
     noPermissionUpload: 'Du har inte behörighet att ladda upp filer',
     noPermissionMoveFiles: 'Du har inte behörighet att flytta filer',
+    noPermissionMoveFolder: 'Du har inte behörighet att flytta mappar',
     noPermissionDeleteFiles: 'Du har inte behörighet att ta bort filer',
     // External folder
     linkExternal: 'Länka extern',
@@ -4484,6 +4489,7 @@ errors: {
       fileDeleted: 'Fil borttagen',
       filesDeleted: 'Tog bort {{count}} filer',
       filesMoved: 'Filer flyttade',
+      folderMoved: 'Mapp flyttad',
       folderLinked: 'Mapp länkad',
       folderUnlinked: 'Mapp avlänkad',
       externalFolderLinked: 'Extern mapp länkad och skannad',

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -4221,6 +4221,10 @@ export default {
     invalidFilenameChar: 'Baskı dosya adlarında "{{char}}" karakterine izin verilmiyor. Yazıcının SD kartı şu karakterleri reddeder: < > : " / \\ | ? *',
     moveFiles: '{{count}} Dosyayı Taşı',
     rootNoFolder: 'Kök (Klasör Yok)',
+    moveFolder: 'Klasörü taşı…',
+    moveFolderTo: '"{{name}}" şuraya taşı',
+    moveToRoot: 'Kök',
+    moveFolderInvalidTarget: 'Bir klasör kendisine, kendi alt klasörlerine veya harici bir klasöre taşınamaz',
     current: 'mevcut',
     linkFolder: 'Klasörü Bağla',
     linkFolderDescription: 'Hızlı erişim için "{{name}}" klasörünü bir projeye veya arşive bağla.',
@@ -4430,6 +4434,7 @@ export default {
     noPermissionCreateFolder: 'Klasör oluşturma izniniz yok',
     noPermissionUpload: 'Dosya yükleme izniniz yok',
     noPermissionMoveFiles: 'Dosyaları taşıma izniniz yok',
+    noPermissionMoveFolder: 'Klasörleri taşıma izniniz yok',
     noPermissionDeleteFiles: 'Dosyaları silme izniniz yok',
     // Harici klasör
     linkExternal: 'Harici Bağla',
@@ -4450,6 +4455,7 @@ export default {
       fileDeleted: 'Dosya silindi',
       filesDeleted: '{{count}} dosya silindi',
       filesMoved: 'Dosyalar taşındı',
+      folderMoved: 'Klasör taşındı',
       folderLinked: 'Klasör bağlandı',
       folderUnlinked: 'Klasör bağlantısı kaldırıldı',
       externalFolderLinked: 'Harici klasör bağlandı ve tarandı',

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -4253,6 +4253,10 @@ export default {
     invalidFilenameChar: "Символ «{{char}}» не можна використовувати в назвах файлів для друку. SD-картка принтера не приймає такі символи: < > : \" / \\ | ? *",
     moveFiles: "Перемістити файли: {{count}}",
     rootNoFolder: "Корінь (без папки)",
+    moveFolder: "Перемістити папку…",
+    moveFolderTo: "Перемістити «{{name}}» до",
+    moveToRoot: "Корінь",
+    moveFolderInvalidTarget: "Папку не можна перемістити в саму себе, у її підпапки або у зовнішню папку",
     current: "поточний",
     linkFolder: "Пов’язати папку",
     linkFolderDescription: "Пов’язати «{{name}}» із проєктом або архівом для швидкого доступу.",
@@ -4462,6 +4466,7 @@ export default {
     noPermissionCreateFolder: "Ви не маєте дозволу на створення папок",
     noPermissionUpload: "Ви не маєте дозволу на вивантаження файлів",
     noPermissionMoveFiles: "Ви не маєте дозволу на переміщення файлів",
+    noPermissionMoveFolder: "Ви не маєте дозволу на переміщення папок",
     noPermissionDeleteFiles: "Ви не маєте дозволу на видалення файлів",
     // External folder
     linkExternal: "Зовнішнє посилання",
@@ -4482,6 +4487,7 @@ export default {
       fileDeleted: "Файл видалено",
       filesDeleted: "Видалені файли {{count}}.",
       filesMoved: "Файли переміщено",
+      folderMoved: "Папку переміщено",
       folderLinked: "Папку пов’язано",
       folderUnlinked: "Папку від’єднано",
       externalFolderLinked: "Зовнішня папка пов’язана та просканована",

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4213,6 +4213,10 @@ export default {
     invalidFilenameChar: '打印文件名中不允许使用字符 "{{char}}"。打印机 SD 卡拒绝以下字符: < > : " / \\ | ? *',
     moveFiles: '移动 {{count}} 个文件',
     rootNoFolder: '根目录(无文件夹)',
+    moveFolder: '移动文件夹…',
+    moveFolderTo: '将“{{name}}”移动到',
+    moveToRoot: '根目录',
+    moveFolderInvalidTarget: '文件夹不能移动到其自身、其子文件夹或外部文件夹中',
     current: '当前',
     linkFolder: '链接文件夹',
     linkFolderDescription: '将"{{name}}"链接到项目或归档以便快速访问。',
@@ -4422,6 +4426,7 @@ export default {
     noPermissionCreateFolder: '您没有创建文件夹的权限',
     noPermissionUpload: '您没有上传文件的权限',
     noPermissionMoveFiles: '您没有移动文件的权限',
+    noPermissionMoveFolder: '您没有移动文件夹的权限',
     noPermissionDeleteFiles: '您没有删除文件的权限',
     // External folder
     linkExternal: '链接外部',
@@ -4442,6 +4447,7 @@ export default {
       fileDeleted: '文件已删除',
       filesDeleted: '已删除 {{count}} 个文件',
       filesMoved: '文件已移动',
+      folderMoved: '文件夹已移动',
       folderLinked: '文件夹已链接',
       folderUnlinked: '文件夹已取消链接',
       externalFolderLinked: '外部文件夹已链接并扫描',

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4213,6 +4213,10 @@ export default {
     invalidFilenameChar: '列印檔案名稱中不允許使用字元 "{{char}}"。印表機 SD 卡拒絕以下字元: < > : " / \\ | ? *',
     moveFiles: '移動 {{count}} 個檔案',
     rootNoFolder: '根目錄(無資料夾)',
+    moveFolder: '移動資料夾…',
+    moveFolderTo: '將「{{name}}」移動到',
+    moveToRoot: '根目錄',
+    moveFolderInvalidTarget: '資料夾不能移動到其自身、其子資料夾或外部資料夾中',
     current: '目前',
     linkFolder: '連結資料夾',
     linkFolderDescription: '將"{{name}}"連結到專案或歸檔以便快速存取。',
@@ -4422,6 +4426,7 @@ export default {
     noPermissionCreateFolder: '您沒有建立資料夾的權限',
     noPermissionUpload: '您沒有上傳檔案的權限',
     noPermissionMoveFiles: '您沒有移動檔案的權限',
+    noPermissionMoveFolder: '您沒有移動資料夾的權限',
     noPermissionDeleteFiles: '您沒有刪除檔案的權限',
     // External folder
     linkExternal: '連結外部',
@@ -4442,6 +4447,7 @@ export default {
       fileDeleted: '檔案已刪除',
       filesDeleted: '已刪除 {{count}} 個檔案',
       filesMoved: '檔案已移動',
+      folderMoved: '資料夾已移動',
       folderLinked: '資料夾已連結',
       folderUnlinked: '資料夾已取消連結',
       externalFolderLinked: '外部資料夾已連結並掃描',

+ 322 - 45
frontend/src/pages/FileManagerPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback, useMemo, useEffect, lazy, Suspense } from 'react';
+import { useState, useRef, useCallback, useMemo, useEffect, useId, lazy, Suspense, type DragEvent } from 'react';
 import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueries, useMutation, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -44,6 +44,7 @@ import {
   RefreshCw,
   Lock,
   FolderSymlink,
+  FolderInput,
   Tag as TagIcon,
   FileText,
   FileSpreadsheet,
@@ -91,6 +92,7 @@ import { assignableProjects } from '../utils/projectTree';
 import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { isSlicedLibraryFile, isSliceableLibraryFile } from '../utils/libraryFiles';
 import { useUiSettings } from '../hooks/useUiSettings';
+import { FOLDER_DRAG_TYPE, isValidFolderMove, isWritableFolder, moveDisallowedFolderIds } from '../utils/libraryFolders';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -525,27 +527,31 @@ function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: Renam
   );
 }
 
-// Move Files Modal
-interface MoveFilesModalProps {
+// Destination picker shared by "Move files" and "Move folder" (#3305): the
+// root entry, then the tree flattened with indentation. `disabledIds` greys a
+// folder out with `disabledTitle`; `currentId` marks (and disables) the one
+// the items already live in.
+type MoveTarget = { id: number | null; name: string; depth: number; canWrite: boolean };
+
+interface FolderTargetListProps {
   folders: LibraryFolderTree[];
-  selectedFiles: number[];
-  currentFolderId: number | null;
-  onClose: () => void;
-  onMove: (folderId: number | null) => void;
-  isLoading: boolean;
+  rootLabel: string;
+  currentId: number | null;
+  disabledIds?: Set<number>;
+  disabledTitle?: string;
+  // undefined = nothing picked yet (null is the root entry).
+  value: number | null | undefined;
+  onChange: (id: number | null) => void;
   t: TFunction;
 }
 
-function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) {
-  const [targetFolder, setTargetFolder] = useState<number | null>(null);
-
+function FolderTargetList({ folders, rootLabel, currentId, disabledIds, disabledTitle, value, onChange, t }: FolderTargetListProps) {
   // can_write: a library:read_own user moves files only into their own and
   // shared folders (#3201); the rest stay listed so the tree keeps its shape.
-  type MoveTarget = { id: number | null; name: string; depth: number; canWrite: boolean };
   const flattenFolders = (items: LibraryFolderTree[], depth = 0): MoveTarget[] => {
     const result: MoveTarget[] = [];
     for (const item of items) {
-      result.push({ id: item.id, name: item.name, depth, canWrite: item.can_write !== false });
+      result.push({ id: item.id, name: item.name, depth, canWrite: isWritableFolder(item) });
       if (item.children.length > 0) {
         result.push(...flattenFolders(item.children, depth + 1));
       }
@@ -553,7 +559,52 @@ function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMo
     return result;
   };
 
-  const flatFolders: MoveTarget[] = [{ id: null, name: t('fileManager.rootNoFolder'), depth: 0, canWrite: true }, ...flattenFolders(folders)];
+  const flatFolders: MoveTarget[] = [{ id: null, name: rootLabel, depth: 0, canWrite: true }, ...flattenFolders(folders)];
+
+  return (
+    <div className="max-h-64 overflow-y-auto space-y-1">
+      {flatFolders.map((folder) => {
+        const isCurrent = folder.id === currentId;
+        const isExcluded = folder.id !== null && disabledIds?.has(folder.id) === true;
+        const disabled = isCurrent || isExcluded || !folder.canWrite;
+        return (
+          <button
+            key={folder.id ?? 'root'}
+            onClick={() => onChange(folder.id)}
+            disabled={disabled}
+            title={isExcluded ? disabledTitle : !folder.canWrite ? t('fileManager.folderNotWritable') : undefined}
+            className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
+              value === folder.id
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : disabled
+                ? 'opacity-50 cursor-not-allowed text-bambu-gray'
+                : 'hover:bg-bambu-dark text-white'
+            }`}
+            style={{ paddingLeft: `${12 + folder.depth * 16}px` }}
+          >
+            <FolderOpen className="w-4 h-4" />
+            {folder.name}
+            {isCurrent && <span className="text-xs text-bambu-gray ml-auto">({t('fileManager.current')})</span>}
+          </button>
+        );
+      })}
+    </div>
+  );
+}
+
+// Move Files Modal
+interface MoveFilesModalProps {
+  folders: LibraryFolderTree[];
+  selectedFiles: number[];
+  currentFolderId: number | null;
+  onClose: () => void;
+  onMove: (folderId: number | null) => void;
+  isLoading: boolean;
+  t: TFunction;
+}
+
+function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading, t }: MoveFilesModalProps) {
+  const [targetFolder, setTargetFolder] = useState<number | null>(null);
 
   return (
     <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
@@ -562,28 +613,14 @@ function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMo
           <h2 className="text-lg font-semibold text-white">{t('fileManager.moveFiles', { count: selectedFiles.length })}</h2>
         </div>
         <div className="p-4 space-y-4">
-          <div className="max-h-64 overflow-y-auto space-y-1">
-            {flatFolders.map((folder) => (
-              <button
-                key={folder.id ?? 'root'}
-                onClick={() => setTargetFolder(folder.id)}
-                disabled={folder.id === currentFolderId || !folder.canWrite}
-                title={!folder.canWrite ? t('fileManager.folderNotWritable') : undefined}
-                className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
-                  targetFolder === folder.id
-                    ? 'bg-bambu-green/20 text-bambu-green'
-                    : folder.id === currentFolderId || !folder.canWrite
-                    ? 'opacity-50 cursor-not-allowed text-bambu-gray'
-                    : 'hover:bg-bambu-dark text-white'
-                }`}
-                style={{ paddingLeft: `${12 + folder.depth * 16}px` }}
-              >
-                <FolderOpen className="w-4 h-4" />
-                {folder.name}
-                {folder.id === currentFolderId && <span className="text-xs text-bambu-gray ml-auto">({t('fileManager.current')})</span>}
-              </button>
-            ))}
-          </div>
+          <FolderTargetList
+            folders={folders}
+            rootLabel={t('fileManager.rootNoFolder')}
+            currentId={currentFolderId}
+            value={targetFolder}
+            onChange={setTargetFolder}
+            t={t}
+          />
           <div className="flex justify-end gap-2 pt-2">
             <Button type="button" variant="secondary" onClick={onClose}>
               {t('common.cancel')}
@@ -598,6 +635,53 @@ function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMo
   );
 }
 
+// Move Folder Modal (#3305). Same picker as files, minus the folder itself,
+// its subtree and every external folder; "Root" lifts it to the top level.
+interface MoveFolderModalProps {
+  folders: LibraryFolderTree[];
+  folder: LibraryFolderTree;
+  onClose: () => void;
+  onMove: (parentId: number | null) => void;
+  isLoading: boolean;
+  t: TFunction;
+}
+
+function MoveFolderModal({ folders, folder, onClose, onMove, isLoading, t }: MoveFolderModalProps) {
+  const [targetFolder, setTargetFolder] = useState<number | null | undefined>(undefined);
+  const disabledIds = useMemo(() => moveDisallowedFolderIds(folders, folder.id), [folders, folder.id]);
+  const canMove = targetFolder !== undefined && isValidFolderMove(folders, folder, targetFolder);
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white truncate">{t('fileManager.moveFolderTo', { name: folder.name })}</h2>
+        </div>
+        <div className="p-4 space-y-4">
+          <FolderTargetList
+            folders={folders}
+            rootLabel={t('fileManager.moveToRoot')}
+            currentId={folder.parent_id}
+            disabledIds={disabledIds}
+            disabledTitle={t('fileManager.moveFolderInvalidTarget')}
+            value={targetFolder}
+            onChange={setTargetFolder}
+            t={t}
+          />
+          <div className="flex justify-end gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              {t('common.cancel')}
+            </Button>
+            <Button onClick={() => targetFolder !== undefined && onMove(targetFolder)} disabled={isLoading || !canMove}>
+              {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.move')}
+            </Button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
 // Link Folder Modal
 interface LinkFolderModalProps {
   folder: LibraryFolderTree;
@@ -768,6 +852,92 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo
   );
 }
 
+// Folder drag and drop (#3305). The page owns the dragged folder (so drop
+// targets can decide validity during dragover, when the payload is not yet
+// readable), the one highlighted target and the mutation; rows and columns
+// only report where it landed.
+interface FolderDragProps {
+  canDrag: (folder: LibraryFolderTree) => boolean;
+  onDragStart: (folder: LibraryFolderTree, e: DragEvent) => void;
+  onDragEnd: () => void;
+  // Would dropping the folder being dragged onto this destination (null =
+  // root) be a real, allowed move? False while nothing is being dragged.
+  canDropOn: (destinationId: number | null) => boolean;
+  onDrop: (destinationId: number | null) => void;
+  // The drop target under the pointer (a useId key), or null. Shared so a
+  // column stops highlighting the moment a row inside it takes over.
+  activeTarget: string | null;
+  setActiveTarget: (key: string | null) => void;
+}
+
+// Drop-target behaviour for one destination: accepts the drag only when the
+// page says the move is valid and highlights while it is the active target.
+// A folder drag never travels past the innermost target, accepted or not:
+// a row that refuses the drop (e.g. the folder's own parent) must not hand it
+// to the column around it, which would move the folder somewhere else. It
+// also keeps the drag away from the page-wide upload zone.
+function useFolderDropTarget(folderDrag: FolderDragProps | undefined, destinationId: number | null) {
+  const key = useId();
+  if (!folderDrag) return { isOver: false, handlers: {} };
+  const isFolderDrag = (e: DragEvent) => e.dataTransfer.types.includes(FOLDER_DRAG_TYPE);
+  const claim = (e: DragEvent) => {
+    e.stopPropagation();
+    const accepted = folderDrag.canDropOn(destinationId);
+    if (accepted) {
+      e.preventDefault();
+      e.dataTransfer.dropEffect = 'move';
+    } else {
+      // Not cancelling the event is what makes the browser show "no drop".
+      e.dataTransfer.dropEffect = 'none';
+    }
+    const next = accepted ? key : null;
+    if (folderDrag.activeTarget !== next) folderDrag.setActiveTarget(next);
+  };
+  const handlers = {
+    onDragEnter: (e: DragEvent) => {
+      if (isFolderDrag(e)) claim(e);
+    },
+    onDragOver: (e: DragEvent) => {
+      if (isFolderDrag(e)) claim(e);
+    },
+    onDragLeave: (e: DragEvent) => {
+      const next = e.relatedTarget as Node | null;
+      if ((!next || !(e.currentTarget as Node).contains(next)) && folderDrag.activeTarget === key) {
+        folderDrag.setActiveTarget(null);
+      }
+    },
+    onDrop: (e: DragEvent) => {
+      if (!isFolderDrag(e)) return;
+      e.stopPropagation();
+      folderDrag.setActiveTarget(null);
+      if (!folderDrag.canDropOn(destinationId)) return;
+      e.preventDefault();
+      folderDrag.onDrop(destinationId);
+    },
+  };
+  return { isOver: folderDrag.activeTarget === key, handlers };
+}
+
+const FOLDER_DROP_TARGET_CLASS = 'ring-1 ring-inset ring-bambu-green bg-bambu-green/10';
+
+// A plain div that is also a folder drop target — for hosts that render rows
+// inside a `.map` and so cannot call the hook themselves.
+type FolderDropZoneProps = React.HTMLAttributes<HTMLDivElement> & {
+  folderDrag?: FolderDragProps;
+  destinationId: number | null;
+  // Accept drops at all; false renders a plain div (e.g. the external bucket).
+  enabled?: boolean;
+};
+
+function FolderDropZone({ folderDrag, destinationId, enabled = true, className = '', children, ...rest }: FolderDropZoneProps) {
+  const { isOver, handlers } = useFolderDropTarget(enabled ? folderDrag : undefined, destinationId);
+  return (
+    <div {...rest} {...handlers} className={`${className} ${isOver ? FOLDER_DROP_TARGET_CLASS : ''}`}>
+      {children}
+    </div>
+  );
+}
+
 // Folder Tree Item
 interface FolderTreeItemProps {
   folder: LibraryFolderTree;
@@ -776,7 +946,9 @@ interface FolderTreeItemProps {
   onDelete: (id: number) => void;
   onLink: (folder: LibraryFolderTree) => void;
   onRename: (folder: LibraryFolderTree) => void;
+  onMove?: (folder: LibraryFolderTree) => void;
   onToggleShared?: (folder: LibraryFolderTree) => void;
+  folderDrag?: FolderDragProps;
   depth?: number;
   wrapNames?: boolean;
   defaultExpanded?: boolean;
@@ -795,6 +967,8 @@ interface FolderActionsMenuProps {
   onDelete: (id: number) => void;
   onLink: (folder: LibraryFolderTree) => void;
   onRename: (folder: LibraryFolderTree) => void;
+  // Move to root or into another folder (#3305). Not offered for external folders.
+  onMove?: (folder: LibraryFolderTree) => void;
   // Share with every library:read_own user, or stop (#3201). library:update_all only.
   onToggleShared?: (folder: LibraryFolderTree) => void;
   hasPermission: (permission: Permission) => boolean;
@@ -807,7 +981,7 @@ interface FolderActionsMenuProps {
   t: TFunction;
 }
 
-function FolderActionsMenu({ folder, onDelete, onLink, onRename, onToggleShared, hasPermission, revealOnHover = false, tabIndex, t }: FolderActionsMenuProps) {
+function FolderActionsMenu({ folder, onDelete, onLink, onRename, onMove, onToggleShared, hasPermission, revealOnHover = false, tabIndex, t }: FolderActionsMenuProps) {
   const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null);
   const rootRef = useRef<HTMLDivElement>(null);
   const buttonRef = useRef<HTMLButtonElement>(null);
@@ -834,6 +1008,9 @@ function FolderActionsMenu({ folder, onDelete, onLink, onRename, onToggleShared,
       ? t('fileManager.onlyOwnFoldersDeletable')
       : t('fileManager.noPermissionDeleteFolder');
   const canRename = folder.can_rename ?? hasPermission('library:update_all');
+  // Moving is the same backend update as renaming (#3305), so it follows the
+  // same gate; the picker handles where the folder may go.
+  const canMove = canRename;
   const canLink = hasPermission('library:update_all');
 
   const items: ContextMenuItem[] = [
@@ -844,6 +1021,17 @@ function FolderActionsMenu({ folder, onDelete, onLink, onRename, onToggleShared,
       disabled: !canRename,
       title: !canRename ? t('fileManager.noPermissionRenameFolder') : undefined,
     },
+    ...(onMove && !isExternal
+      ? [
+          {
+            label: t('fileManager.moveFolder'),
+            icon: <FolderInput className="w-3.5 h-3.5" />,
+            onClick: () => onMove(folder),
+            disabled: !canMove,
+            title: !canMove ? t('fileManager.noPermissionMoveFolder') : undefined,
+          },
+        ]
+      : []),
     {
       label: isLinked ? t('fileManager.changeLink') : t('fileManager.linkTo'),
       icon: <Link2 className="w-3.5 h-3.5" />,
@@ -908,11 +1096,13 @@ function FolderActionsMenu({ folder, onDelete, onLink, onRename, onToggleShared,
   );
 }
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, onToggleShared, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, onMove, onToggleShared, folderDrag, depth = 0, wrapNames = false, defaultExpanded = true, showModified = false, hasPermission, t }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(defaultExpanded);
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
   const isExternal = folder.is_external;
+  const canDrag = folderDrag?.canDrag(folder) === true;
+  const { isOver, handlers: dropHandlers } = useFolderDropTarget(folderDrag, folder.id);
 
   return (
     <div>
@@ -921,9 +1111,13 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
           selectedFolderId === folder.id
             ? 'bg-bambu-green/20 text-bambu-green'
             : 'hover:bg-bambu-dark text-white'
-        }`}
+        } ${isOver ? FOLDER_DROP_TARGET_CLASS : ''}`}
         style={{ paddingLeft: `${8 + depth * 12}px` }}
         onClick={() => onSelect(folder.id)}
+        draggable={canDrag || undefined}
+        onDragStart={canDrag ? (e) => folderDrag?.onDragStart(folder, e) : undefined}
+        onDragEnd={canDrag ? folderDrag?.onDragEnd : undefined}
+        {...dropHandlers}
       >
         {hasChildren ? (
           <button
@@ -1001,6 +1195,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
           onDelete={onDelete}
           onLink={onLink}
           onRename={onRename}
+          onMove={onMove}
           onToggleShared={onToggleShared}
           hasPermission={hasPermission}
           revealOnHover={!wrapNames}
@@ -1018,7 +1213,9 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onDelete={onDelete}
               onLink={onLink}
               onRename={onRename}
+              onMove={onMove}
               onToggleShared={onToggleShared}
+              folderDrag={folderDrag}
               depth={depth + 1}
               wrapNames={wrapNames}
               defaultExpanded={defaultExpanded}
@@ -1642,6 +1839,13 @@ export function FileManagerPage() {
   const [showBulkTagsModal, setShowBulkTagsModal] = useState(false);
   const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
+  // Folder being moved via the context menu picker / by drag (#3305).
+  const [moveFolder, setMoveFolder] = useState<LibraryFolderTree | null>(null);
+  const [draggingFolder, setDraggingFolder] = useState<LibraryFolderTree | null>(null);
+  // The one folder drop target under the pointer. A single page-level value
+  // (not one flag per target) so a column can never stay lit after a row
+  // inside it took the drag or the drop (#3305 review).
+  const [folderDropTarget, setFolderDropTarget] = useState<string | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [sliceFile, setSliceFile] = useState<LibraryFileListItem | null>(null);
@@ -2154,6 +2358,51 @@ export function FileManagerPage() {
     onError: (error: Error) => showToast(error.message, 'error'),
   });
 
+  // Move a folder to root (parent_id 0) or under another folder (#3305). The
+  // server repeats the cycle check and 400s a move into the folder's own
+  // subtree, which the toast then shows verbatim.
+  const moveFolderMutation = useMutation({
+    mutationFn: ({ id, parentId }: { id: number; parentId: number | null }) =>
+      api.updateLibraryFolder(id, { parent_id: parentId ?? 0 }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['project-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['archive-folders'] });
+      setMoveFolder(null);
+      showToast(t('fileManager.toast.folderMoved'), 'success');
+    },
+    onError: (error: Error) => {
+      setMoveFolder(null);
+      showToast(error.message, 'error');
+    },
+  });
+
+  // Drag a folder onto another folder row, a column, or "All Files" (= root).
+  // Drops that would change nothing or that the server would refuse are not
+  // accepted, so no request leaves for them.
+  const folderDrag: FolderDragProps = {
+    canDrag: (folder) => !folder.is_external && (folder.can_rename ?? hasPermission('library:update_all')),
+    onDragStart: (folder, e) => {
+      e.dataTransfer.setData(FOLDER_DRAG_TYPE, String(folder.id));
+      e.dataTransfer.effectAllowed = 'move';
+      setDraggingFolder(folder);
+    },
+    onDragEnd: () => {
+      setDraggingFolder(null);
+      setFolderDropTarget(null);
+    },
+    canDropOn: (destinationId) =>
+      draggingFolder !== null && !!folders && isValidFolderMove(folders, draggingFolder, destinationId),
+    onDrop: (destinationId) => {
+      if (!draggingFolder || !folders || !isValidFolderMove(folders, draggingFolder, destinationId)) return;
+      moveFolderMutation.mutate({ id: draggingFolder.id, parentId: destinationId });
+      setDraggingFolder(null);
+    },
+    activeTarget: folderDropTarget,
+    setActiveTarget: setFolderDropTarget,
+  };
+
   const renameFileMutation = useMutation({
     mutationFn: ({ id, filename }: { id: number; filename: string }) =>
       api.updateLibraryFile(id, { filename }),
@@ -3084,7 +3333,9 @@ export function FileManagerPage() {
             {/* All Files = the user's own uploaded / managed-storage files
                 only. External folders are surfaced separately below to keep
                 a linked NAS from drowning the user's own uploads (#1621). */}
-            <div
+            <FolderDropZone
+              folderDrag={folderDrag}
+              destinationId={null}
               className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
                 selectedFolderId === null && topLevelView === 'internal'
                   ? 'bg-bambu-green/20 text-bambu-green'
@@ -3097,7 +3348,7 @@ export function FileManagerPage() {
             >
               <FileBox className="w-4 h-4" />
               <span className="text-sm">{t('fileManager.allFiles')}</span>
-            </div>
+            </FolderDropZone>
 
             {/* External (combined) — only shown when at least one external
                 folder is linked. Single folder users don't need a combined
@@ -3131,7 +3382,9 @@ export function FileManagerPage() {
                 onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                 onLink={setLinkFolder}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
+                onMove={setMoveFolder}
                 onToggleShared={toggleFolderShared}
+                folderDrag={folderDrag}
                 wrapNames={wrapFolderNames}
                 defaultExpanded={!collapseFoldersByDefault}
                 showModified={showModified}
@@ -3497,19 +3750,31 @@ export function FileManagerPage() {
                   // column that only lists folders keeps that narrow width.
                   const wide = Boolean(level && (level.loading || level.files.length > 0));
                   return (
-                  <div
+                  /* Dropping a dragged folder on a column's empty space moves
+                     it into that column's folder (#3305); the external bucket's
+                     root column is not a destination. */
+                  <FolderDropZone
                     key={col.key}
+                    folderDrag={folderDrag}
+                    destinationId={col.folderId}
+                    enabled={!(col.folderId === null && currentBucketIsExternal)}
                     data-testid={`columns-level-${col.key}`}
                     className={`${wide ? 'w-[26rem]' : 'w-64'} flex-shrink-0 overflow-y-auto py-1`}
                   >
                     {col.items.map((folder) => {
                       const isSelectedFolder = selectedFolderId === folder.id;
+                      const canDragFolder = folderDrag.canDrag(folder);
                       return (
                         /* The row is a div, not the button itself: the kebab
                            is a button of its own and buttons don't nest. */
-                        <div
+                        <FolderDropZone
                           key={folder.id}
+                          folderDrag={folderDrag}
+                          destinationId={folder.id}
                           data-folder-id={folder.id}
+                          draggable={canDragFolder || undefined}
+                          onDragStart={canDragFolder ? (e) => folderDrag.onDragStart(folder, e) : undefined}
+                          onDragEnd={canDragFolder ? folderDrag.onDragEnd : undefined}
                           className={`group flex items-center pr-1.5 transition-colors ${
                             col.activeId === folder.id
                               ? 'bg-bambu-green/20 text-bambu-green'
@@ -3567,13 +3832,14 @@ export function FileManagerPage() {
                             onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
                             onLink={setLinkFolder}
                             onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
+                            onMove={setMoveFolder}
                             onToggleShared={toggleFolderShared}
                             hasPermission={hasPermission}
                             revealOnHover={!isSelectedFolder}
                             tabIndex={isSelectedFolder ? 0 : -1}
                             t={t}
                           />
-                        </div>
+                        </FolderDropZone>
                       );
                     })}
                     {level?.loading && (
@@ -3599,7 +3865,7 @@ export function FileManagerPage() {
                         t={t}
                       />
                     ))}
-                  </div>
+                  </FolderDropZone>
                   );
                 })}
                 {/* Files pane — still the selected folder's contents, so a
@@ -3935,6 +4201,17 @@ export function FileManagerPage() {
         />
       )}
 
+      {moveFolder && folders && (
+        <MoveFolderModal
+          folders={folders}
+          folder={moveFolder}
+          onClose={() => setMoveFolder(null)}
+          onMove={(parentId) => moveFolderMutation.mutate({ id: moveFolder.id, parentId })}
+          isLoading={moveFolderMutation.isPending}
+          t={t}
+        />
+      )}
+
       {showUploadModal && (
         <FileUploadModal
           folderId={selectedFolderId}

+ 72 - 0
frontend/src/utils/libraryFolders.ts

@@ -0,0 +1,72 @@
+import type { LibraryFolderTree } from '../api/client';
+
+/**
+ * Drag payload type for a File Manager folder being moved (#3305). Distinct
+ * from the browser's `Files` type so a folder drag never trips the page-wide
+ * upload overlay, and from any future file-row drag type.
+ */
+export const FOLDER_DRAG_TYPE = 'application/x-bambuddy-folder';
+
+function collectSubtreeIds(folder: LibraryFolderTree, into: Set<number>): void {
+  into.add(folder.id);
+  for (const child of folder.children) collectSubtreeIds(child, into);
+}
+
+function findFolder(folders: LibraryFolderTree[], id: number): LibraryFolderTree | null {
+  for (const folder of folders) {
+    if (folder.id === id) return folder;
+    const hit = findFolder(folder.children, id);
+    if (hit) return hit;
+  }
+  return null;
+}
+
+/**
+ * Whether the current user may put things into `folder`. A library:*_own user
+ * sees shared folders they cannot write to (#3201); those stay listed in the
+ * pickers but are never a destination. The picker's disabled state and the
+ * folder drop targets both use this, so the two cannot drift apart.
+ */
+export function isWritableFolder(folder: Pick<LibraryFolderTree, 'can_write'>): boolean {
+  return folder.can_write !== false;
+}
+
+/**
+ * Folder ids that may NOT become the new parent of `folderId` (#3305): the
+ * folder itself, everything below it (the server rejects those with 400 too,
+ * this just keeps them out of the picker), and every external folder with its
+ * subtree — external folders mirror a path on disk, so nothing can be moved
+ * into one.
+ */
+export function moveDisallowedFolderIds(folders: LibraryFolderTree[], folderId: number): Set<number> {
+  const disallowed = new Set<number>();
+  const self = findFolder(folders, folderId);
+  if (self) collectSubtreeIds(self, disallowed);
+  const walk = (items: LibraryFolderTree[]) => {
+    for (const item of items) {
+      if (item.is_external) collectSubtreeIds(item, disallowed);
+      else walk(item.children);
+    }
+  };
+  walk(folders);
+  return disallowed;
+}
+
+/**
+ * Whether dropping / moving `folder` onto `destinationId` (null = root) would
+ * actually change anything the server accepts. False for the folder itself,
+ * its descendants, external destinations, destinations the user may not write
+ * to, and the parent it already has.
+ */
+export function isValidFolderMove(
+  folders: LibraryFolderTree[],
+  folder: Pick<LibraryFolderTree, 'id' | 'parent_id' | 'is_external'>,
+  destinationId: number | null,
+): boolean {
+  if (folder.is_external) return false;
+  if ((folder.parent_id ?? null) === destinationId) return false;
+  if (destinationId === null) return true;
+  const destination = findFolder(folders, destinationId);
+  if (!destination || !isWritableFolder(destination)) return false;
+  return !moveDisallowedFolderIds(folders, folder.id).has(destinationId);
+}