feat: impl multi file/dir moving

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2025-09-21 17:03:50 +00:00
parent a331276c43
commit 29eab87c71
10 changed files with 147 additions and 53 deletions

View File

@@ -16,4 +16,4 @@
"@types/bun": "latest",
"convex": "^1.27.0"
}
}
}

View File

@@ -14,6 +14,7 @@ import type {
FunctionReference,
} from "convex/server";
import type * as files from "../files.js";
import type * as filesystem from "../filesystem.js";
import type * as functions from "../functions.js";
import type * as model_directories from "../model/directories.js";
import type * as model_error from "../model/error.js";
@@ -32,6 +33,7 @@ import type * as users from "../users.js";
*/
declare const fullApi: ApiFromModules<{
files: typeof files;
filesystem: typeof filesystem;
functions: typeof functions;
"model/directories": typeof model_directories;
"model/error": typeof model_error;

View File

@@ -141,13 +141,3 @@ export const moveToTrash = authenticatedMutation({
return itemId
},
})
export const moveFiles = authenticatedMutation({
args: {
targetDirectoryId: v.id("directories"),
items: v.array(v.id("files")),
},
handler: async (ctx, { targetDirectoryId, items }) => {
return await Files.moveFiles(ctx, { targetDirectoryId, items })
},
})

View File

@@ -0,0 +1,62 @@
import { v } from "convex/values"
import { authenticatedMutation } from "./functions"
import * as Directories from "./model/directories"
import * as Err from "./model/error"
import * as Files from "./model/files"
import type { DirectoryHandle, FileHandle } from "./model/filesystem"
const VDirectoryHandle = v.object({
kind: v.literal("directory"),
id: v.id("directories"),
})
const VFileHandle = v.object({
kind: v.literal("file"),
id: v.id("files"),
})
const VFileSystemHandle = v.union(VFileHandle, VDirectoryHandle)
export const moveItems = authenticatedMutation({
args: {
targetDirectory: VDirectoryHandle,
items: v.array(VFileSystemHandle),
},
handler: async (ctx, { targetDirectory: targetDirectoryHandle, items }) => {
const targetDirectory = await Directories.fetchHandle(
ctx,
targetDirectoryHandle,
)
if (!targetDirectory) {
throw Err.create(
Err.Code.DirectoryNotFound,
`Directory ${targetDirectoryHandle.id} not found`,
)
}
const directoryHandles: DirectoryHandle[] = []
const fileHandles: FileHandle[] = []
for (const item of items) {
switch (item.kind) {
case "directory":
directoryHandles.push(item)
break
case "file":
fileHandles.push(item)
break
}
}
await Promise.all([
Files.move(ctx, {
targetDirectory: targetDirectoryHandle,
items: fileHandles,
}),
Directories.move(ctx, {
targetDirectory: targetDirectoryHandle,
sourceDirectories: directoryHandles,
}),
])
return { items, targetDirectory }
},
})

View File

@@ -4,7 +4,7 @@ import type {
AuthenticatedQueryCtx,
} from "../functions"
import * as Err from "./error"
import type { FilePath, ReverseFilePath } from "./filesystem"
import type { DirectoryHandle, FilePath, ReverseFilePath } from "./filesystem"
import { newDirectoryHandle } from "./filesystem"
type Directory = {
@@ -31,6 +31,20 @@ export async function fetchRoot(ctx: AuthenticatedQueryCtx) {
.first()
}
export async function fetchHandle(
ctx: AuthenticatedQueryCtx,
handle: DirectoryHandle,
): Promise<Doc<"directories">> {
const directory = await ctx.db.get(handle.id)
if (!directory || directory.userId !== ctx.user._id) {
throw Err.create(
Err.Code.DirectoryNotFound,
`Directory ${handle.id} not found`,
)
}
return directory
}
export async function fetch(
ctx: AuthenticatedQueryCtx,
{ directoryId }: { directoryId: Id<"directories"> },
@@ -147,6 +161,23 @@ export async function create(
})
}
export async function move(
ctx: AuthenticatedMutationCtx,
{
targetDirectory,
sourceDirectories,
}: {
targetDirectory: DirectoryHandle
sourceDirectories: DirectoryHandle[]
},
): Promise<void> {
await Promise.all(
sourceDirectories.map((directory) =>
ctx.db.patch(directory.id, { parentId: targetDirectory.id }),
),
)
}
export async function moveToTrashRecursive(
ctx: AuthenticatedMutationCtx,
directoryId: Id<"directories">,

View File

@@ -1,6 +1,7 @@
import type { Id } from "../_generated/dataModel"
import type { AuthenticatedMutationCtx } from "../functions"
import * as Err from "./error"
import type { DirectoryHandle, FileHandle } from "./filesystem"
export async function renameFile(
ctx: AuthenticatedMutationCtx,
@@ -35,27 +36,19 @@ export async function renameFile(
await ctx.db.patch(itemId, { name: newName })
}
export async function moveFiles(
export async function move(
ctx: AuthenticatedMutationCtx,
{
targetDirectoryId,
targetDirectory: targetDirectoryHandle,
items,
}: {
targetDirectoryId: Id<"directories">
items: Id<"files">[]
targetDirectory: DirectoryHandle
items: FileHandle[]
},
) {
const targetDirectory = await ctx.db.get(targetDirectoryId)
if (!targetDirectory) {
throw Err.create(
Err.Code.DirectoryNotFound,
"Target directory not found",
)
}
await Promise.all(
items.map((itemId) =>
ctx.db.patch(itemId, { directoryId: targetDirectoryId }),
items.map((item) =>
ctx.db.patch(item.id, { directoryId: targetDirectoryHandle.id }),
),
)
return { items, targetDirectory }
}

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "bun --hot src/server.tsx",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx"
"start": "NODE_ENV=production bun src/index.tsx",
"format": "biome format --write"
},
"dependencies": {
"@convex-dev/workos": "^0.0.1",

View File

@@ -2,6 +2,7 @@ import { api } from "@fileone/convex/_generated/api"
import type { Doc } from "@fileone/convex/_generated/dataModel"
import type { DirectoryItem } from "@fileone/convex/model/directories"
import {
type FileSystemHandle,
newDirectoryHandle,
newFileHandle,
} from "@fileone/convex/model/filesystem"
@@ -12,6 +13,7 @@ import {
flexRender,
getCoreRowModel,
type Row,
type Table as TableType,
useReactTable,
} from "@tanstack/react-table"
import { useMutation as useContextMutation } from "convex/react"
@@ -302,6 +304,7 @@ export function DirectoryContentTableContent() {
table.getRowModel().rows.map((row) => (
<FileItemRow
key={row.id}
table={table}
row={row}
onClick={() => selectRow(row)}
onContextMenu={(e) =>
@@ -439,11 +442,13 @@ function NewItemRow() {
}
function FileItemRow({
table,
row,
onClick,
onContextMenu,
onDoubleClick,
}: {
table: TableType<DirectoryItem>
row: Row<DirectoryItem>
onClick: () => void
onContextMenu: (e: React.MouseEvent) => void
@@ -461,17 +466,30 @@ function FileItemRow({
})
const handleDragStart = (e: React.DragEvent) => {
if (row.original.kind === "file") {
e.dataTransfer.setData(
"application/x-internal",
JSON.stringify(row.original),
)
const fileHandle = newFileHandle(row.original.doc._id)
setDragInfo({
source: fileHandle,
items: [fileHandle],
})
let source: FileSystemHandle
switch (row.original.kind) {
case "file":
source = newFileHandle(row.original.doc._id)
break
case "directory":
source = newDirectoryHandle(row.original.doc._id)
break
}
// biome-ignore lint/suspicious/useIterableCallbackReturn: the switch statement is exhaustive
const draggedItems = table.getSelectedRowModel().rows.map((row) => {
switch (row.original.kind) {
case "file":
return newFileHandle(row.original.doc._id)
case "directory":
return newDirectoryHandle(row.original.doc._id)
}
})
setDragInfo({
source,
items: draggedItems,
})
}
const handleDragEnd = () => {

View File

@@ -2,7 +2,6 @@ import { api } from "@fileone/convex/_generated/api"
import type { Doc, Id } from "@fileone/convex/_generated/dataModel"
import type {
DirectoryHandle,
FileHandle,
FileSystemHandle,
} from "@fileone/convex/model/filesystem"
import { useMutation } from "@tanstack/react-query"
@@ -14,7 +13,7 @@ import { toast } from "sonner"
export interface FileDragInfo {
source: FileSystemHandle
items: FileHandle[]
items: FileSystemHandle[]
}
export interface UseFileDropOptions {
@@ -38,33 +37,31 @@ export interface UseFileDropReturn {
export function useFileDrop({
item,
dragInfoAtom,
onDropSuccess,
}: UseFileDropOptions): UseFileDropReturn {
const [isDraggedOver, setIsDraggedOver] = useState(false)
const store = useStore()
const { mutate: moveFiles } = useMutation({
mutationFn: useContextMutation(api.files.moveFiles),
const { mutate: moveDroppedItems } = useMutation({
mutationFn: useContextMutation(api.filesystem.moveItems),
onSuccess: ({
items,
targetDirectory,
}: {
items: Id<"files">[]
items: FileSystemHandle[]
targetDirectory: Doc<"directories">
}) => {
toast.success(
`${items.length} files moved to ${targetDirectory.name}`,
`${items.length} items moved to ${targetDirectory.name}`,
)
onDropSuccess?.(items, targetDirectory)
},
})
const handleDrop = (_e: React.DragEvent) => {
const dragInfo = store.get(dragInfoAtom)
if (dragInfo && item) {
moveFiles({
targetDirectoryId: item.id,
items: dragInfo.items.map((item) => item.id),
moveDroppedItems({
targetDirectory: item,
items: dragInfo.items,
})
}
setIsDraggedOver(false)

View File

@@ -1,9 +1,9 @@
export enum Platform {
Windows = 'windows',
MacOS = 'macos',
Linux = 'linux',
Android = 'android',
iOS = 'ios'
Windows = "windows",
MacOS = "macos",
Linux = "linux",
Android = "android",
iOS = "ios",
}
// Internal global variables (computed once at module load)