Attach dropped files in every composer (#1750)

* fix(app): attach dropped files in composer

Composer parents had to expose attachment callbacks, so New Workspace only wired image drops and JSON files never became file attachments. Centralize dropped-file ingestion in Composer so all composer surfaces share the same image and file path.

* fix(app): handle desktop dropped attachment paths
This commit is contained in:
Mohamed Boudra
2026-06-27 09:03:04 +08:00
committed by GitHub
parent f275275074
commit 65f1143e6a
12 changed files with 488 additions and 388 deletions
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { droppedItemsToPickedFiles } from "./drop";
describe("composer dropped attachments", () => {
it("turns non-image browser files into picked files and leaves raster images for image handling", async () => {
const jsonFile = new File([JSON.stringify({ ok: true })], "config.json", {
type: "application/json",
});
const imageFile = new File([new Uint8Array([0])], "screen.png", { type: "image/png" });
const files = await droppedItemsToPickedFiles([
{ kind: "web-file", file: jsonFile },
{ kind: "web-file", file: imageFile },
]);
expect(files).toEqual([
{
fileName: "config.json",
mimeType: "application/json",
bytes: new Uint8Array(await jsonFile.arrayBuffer()),
},
]);
});
it("turns non-image desktop paths into picked files and leaves raster images for image handling", async () => {
const windowsPath = "C:\\Users\\alice\\config.json";
const posixPath = "/Users/alice/notes/readme.txt";
const imagePath = "C:\\Users\\alice\\screen.png";
const bytesByPath = new Map([
[windowsPath, new Uint8Array([1, 2, 3])],
[posixPath, new Uint8Array([4, 5])],
]);
const readPaths: string[] = [];
const files = await droppedItemsToPickedFiles(
[
{ kind: "desktop-path", path: windowsPath },
{ kind: "desktop-path", path: imagePath },
{ kind: "desktop-path", path: posixPath },
],
{
readDesktopFileBytes: async (path) => {
readPaths.push(path);
const bytes = bytesByPath.get(path);
if (!bytes) {
throw new Error(`Unexpected desktop read: ${path}`);
}
return bytes;
},
},
);
expect(readPaths).toEqual([windowsPath, posixPath]);
expect(files).toEqual([
{
fileName: "config.json",
mimeType: "application/octet-stream",
bytes: new Uint8Array([1, 2, 3]),
},
{
fileName: "readme.txt",
mimeType: "application/octet-stream",
bytes: new Uint8Array([4, 5]),
},
]);
});
});
@@ -0,0 +1,58 @@
import {
getMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
} from "@/attachments/file-types";
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
interface DroppedAttachmentsRuntime {
readDesktopFileBytes(path: string): Promise<Uint8Array>;
}
const defaultRuntime: DroppedAttachmentsRuntime = {
readDesktopFileBytes,
};
function fileNameFromPath(path: string): string {
const segments = path.split(/[/\\]/);
for (let index = segments.length - 1; index >= 0; index -= 1) {
const segment = segments[index];
if (segment) {
return segment;
}
}
return path;
}
export async function droppedItemsToPickedFiles(
items: DroppedItem[],
runtime: DroppedAttachmentsRuntime = defaultRuntime,
): Promise<PickedFile[]> {
const files: PickedFile[] = [];
for (const item of items) {
if (item.kind === "web-file") {
if (isRasterImageFile(item.file)) {
continue;
}
files.push({
fileName: item.file.name,
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
bytes: new Uint8Array(await item.file.arrayBuffer()),
});
continue;
}
if (isRasterImagePath(item.path)) {
continue;
}
files.push({
fileName: fileNameFromPath(item.path),
mimeType: getMimeTypeFromPath(item.path),
bytes: await runtime.readDesktopFileBytes(item.path),
});
}
return files;
}