| author | |
| committer | |
| log | d1a644897119300688cbd8bf26e0b64aeb92c382 |
| tree | 64ec7e49d0db70f8aa4a01dda30d8b2eaec2f1a1 |
| parent | eff44be433dc9683da7764a20334598e47b127b2 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>12 files changed, 228 insertions(+), 4 deletions(-)
apps/desktop/scripts/verify-preload-bundle.mjs+4| ... | @@ -9,6 +9,7 @@ import { parse } from "acorn"; | ... | @@ -9,6 +9,7 @@ import { parse } from "acorn"; |
| 9 | const expectedDesktopBridgeApis = [ | 9 | const expectedDesktopBridgeApis = [ |
| 10 | "getClientPlatform", | 10 | "getClientPlatform", |
| 11 | "getLocalEnvironmentBootstraps", | 11 | "getLocalEnvironmentBootstraps", |
| 12 | "getPathForFile", | ||
| 12 | "pickFolder", | 13 | "pickFolder", |
| 13 | ]; | 14 | ]; |
| 14 | const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; | 15 | const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; |
| ... | @@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => { | ... | @@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => { |
| 68 | exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), | 69 | exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), |
| 69 | }, | 70 | }, |
| 70 | ipcRenderer, | 71 | ipcRenderer, |
| 72 | webUtils: { | ||
| 73 | getPathForFile: () => "", | ||
| 74 | }, | ||
| 71 | }; | 75 | }; |
| 72 | 76 | ||
| 73 | return new Map([ | 77 | return new Map([ |
apps/desktop/scripts/verify-preload-bundle.test.mjs+1| ... | @@ -9,6 +9,7 @@ const validPreload = ` | ... | @@ -9,6 +9,7 @@ const validPreload = ` |
| 9 | electron.contextBridge.exposeInMainWorld("desktopBridge", { | 9 | electron.contextBridge.exposeInMainWorld("desktopBridge", { |
| 10 | getClientPlatform: () => process.platform, | 10 | getClientPlatform: () => process.platform, |
| 11 | getLocalEnvironmentBootstraps: () => [], | 11 | getLocalEnvironmentBootstraps: () => [], |
| 12 | getPathForFile: () => "", | ||
| 12 | pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), | 13 | pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), |
| 13 | }); | 14 | }); |
| 14 | `; | 15 | `; |
apps/desktop/src/preload.ts+2-1| ... | @@ -6,7 +6,7 @@ import type { | ... | @@ -6,7 +6,7 @@ import type { |
| 6 | DesktopSnapShotEvent, | 6 | DesktopSnapShotEvent, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { exposeClerkBridge } from "@clerk/electron/preload"; | 8 | import { exposeClerkBridge } from "@clerk/electron/preload"; |
| 9 | import { contextBridge, ipcRenderer, webFrame } from "electron"; | 9 | import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; |
| 10 | 10 | ||
| 11 | import * as IpcChannels from "./ipc/channels.ts"; | 11 | import * as IpcChannels from "./ipc/channels.ts"; |
| 12 | 12 | ||
| ... | @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 69 | } | 69 | } |
| 70 | return result as ReturnType<DesktopBridge["getAppBranding"]>; | 70 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 71 | }, | 71 | }, |
| 72 | getPathForFile: (file: File) => webUtils.getPathForFile(file), | ||
| 72 | getClientPlatform: () => clientPlatform, | 73 | getClientPlatform: () => clientPlatform, |
| 73 | setNotificationBadge: (badge) => | 74 | setNotificationBadge: (badge) => |
| 74 | ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), | 75 | ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), |
apps/web/src/components/ChatView.tsx+1| ... | @@ -9318,6 +9318,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -9318,6 +9318,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9318 | const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ | 9318 | const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ |
| 9319 | setDragActive: setIsWorkspaceFileDragActive, | 9319 | setDragActive: setIsWorkspaceFileDragActive, |
| 9320 | addFiles: (files) => composerRef.current?.addDroppedFiles(files), | 9320 | addFiles: (files) => composerRef.current?.addDroppedFiles(files), |
| 9321 | addFolders: (folders) => composerRef.current?.addDroppedFolders(folders), | ||
| 9321 | }); | 9322 | }); |
| 9322 | 9323 | ||
| 9323 | return ( | 9324 | return ( |
apps/web/src/components/LegacySidebar.tsx+1| ... | @@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 391 | addFiles: (files) => { | 391 | addFiles: (files) => { |
| 392 | onFileDropThreads(threadRef, files); | 392 | onFileDropThreads(threadRef, files); |
| 393 | }, | 393 | }, |
| 394 | addFolders: () => {}, | ||
| 394 | }), | 395 | }), |
| 395 | [onFileDropThreads, threadRef], | 396 | [onFileDropThreads, threadRef], |
| 396 | ); | 397 | ); |
apps/web/src/components/Sidebar.tsx+2| ... | @@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1278 | addFiles: (files) => { | 1278 | addFiles: (files) => { |
| 1279 | onFileDropThreads(threadRef, files); | 1279 | onFileDropThreads(threadRef, files); |
| 1280 | }, | 1280 | }, |
| 1281 | addFolders: () => {}, | ||
| 1281 | }) | 1282 | }) |
| 1282 | : null, | 1283 | : null, |
| 1283 | [onFileDropThreads, threadRef], | 1284 | [onFileDropThreads, threadRef], |
| ... | @@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2057 | addFiles: (files) => { | 2058 | addFiles: (files) => { |
| 2058 | props.onFileDropThreads(threadRef, files); | 2059 | props.onFileDropThreads(threadRef, files); |
| 2059 | }, | 2060 | }, |
| 2061 | addFolders: () => {}, | ||
| 2060 | }), | 2062 | }), |
| 2061 | [props.onFileDropThreads, threadRef], | 2063 | [props.onFileDropThreads, threadRef], |
| 2062 | ); | 2064 | ); |
apps/web/src/components/chat/ChatComposer.tsx+36| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; | 1 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; |
| 2 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | ||
| 3 | import { usePrimaryEnvironmentId } from "../../state/environments"; | ||
| 2 | import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; | 4 | import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; |
| 3 | import { useRightPanelStore } from "~/rightPanelStore"; | 5 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 4 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; | 6 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; |
| ... | @@ -47,6 +49,7 @@ import { | ... | @@ -47,6 +49,7 @@ import { |
| 47 | wouldTextPasteExceedLimit, | 49 | wouldTextPasteExceedLimit, |
| 48 | } from "@t3tools/client-runtime/text-paste"; | 50 | } from "@t3tools/client-runtime/text-paste"; |
| 49 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | 51 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 52 | import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; | ||
| 50 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | 53 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 51 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; | 54 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; |
| 52 | import { | 55 | import { |
| ... | @@ -1220,6 +1223,7 @@ export interface ChatComposerHandle { | ... | @@ -1220,6 +1223,7 @@ export interface ChatComposerHandle { |
| 1220 | restoreAfterTimelineReachedEnd: () => void; | 1223 | restoreAfterTimelineReachedEnd: () => void; |
| 1221 | collapseForTimelineScrollKey: (key: string) => void; | 1224 | collapseForTimelineScrollKey: (key: string) => void; |
| 1222 | addDroppedFiles: (files: File[]) => void; | 1225 | addDroppedFiles: (files: File[]) => void; |
| 1226 | addDroppedFolders: (folders: File[]) => void; | ||
| 1223 | hasPendingAttachments: () => boolean; | 1227 | hasPendingAttachments: () => boolean; |
| 1224 | insertTextAtEnd: ( | 1228 | insertTextAtEnd: ( |
| 1225 | text: string, | 1229 | text: string, |
| ... | @@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1525 | onExpandImage, | 1529 | onExpandImage, |
| 1526 | onFileOpen, | 1530 | onFileOpen, |
| 1527 | } = props; | 1531 | } = props; |
| 1532 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 1528 | const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; | 1533 | const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; |
| 1529 | const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; | 1534 | const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; |
| 1530 | // ------------------------------------------------------------------ | 1535 | // ------------------------------------------------------------------ |
| ... | @@ -5715,6 +5720,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5715,6 +5720,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5715 | if (!inserted) focusComposer(); | 5720 | if (!inserted) focusComposer(); |
| 5716 | }); | 5721 | }); |
| 5717 | }, | 5722 | }, |
| 5723 | addDroppedFolders: (folders: File[]) => { | ||
| 5724 | const target = folderDropTarget({ | ||
| 5725 | localEnvironmentDisabled: isLocalEnvironmentDisabled(), | ||
| 5726 | environmentId, | ||
| 5727 | primaryEnvironmentId, | ||
| 5728 | }); | ||
| 5729 | if (target === "remote") { | ||
| 5730 | toastManager.add({ | ||
| 5731 | type: "error", | ||
| 5732 | title: "Folders can't be dropped into remote environments", | ||
| 5733 | }); | ||
| 5734 | return; | ||
| 5735 | } | ||
| 5736 | for (const folder of folders) { | ||
| 5737 | const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile); | ||
| 5738 | if (path === null) { | ||
| 5739 | toastManager.add({ | ||
| 5740 | type: "error", | ||
| 5741 | title: `Couldn't get the path of "${folder.name}"`, | ||
| 5742 | description: "Type the folder path with @ instead.", | ||
| 5743 | }); | ||
| 5744 | continue; | ||
| 5745 | } | ||
| 5746 | insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, { | ||
| 5747 | ensureLeadingBoundary: true, | ||
| 5748 | }); | ||
| 5749 | } | ||
| 5750 | focusComposer(); | ||
| 5751 | }, | ||
| 5718 | hasPendingAttachments: () => | 5752 | hasPendingAttachments: () => |
| 5719 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, | 5753 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, |
| 5720 | insertTextAtEnd: insertComposerTextAtEnd, | 5754 | insertTextAtEnd: insertComposerTextAtEnd, |
| ... | @@ -5871,6 +5905,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) | ... | @@ -5871,6 +5905,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5871 | composerPreviewAnnotations, | 5905 | composerPreviewAnnotations, |
| 5872 | composerReviewComments, | 5906 | composerReviewComments, |
| 5873 | focusComposer, | 5907 | focusComposer, |
| 5908 | environmentId, | ||
| 5909 | primaryEnvironmentId, | ||
| 5874 | isConnecting, | 5910 | isConnecting, |
| 5875 | isComposerApprovalState, | 5911 | isComposerApprovalState, |
| 5876 | isChoiceOnlyPendingQuestion, | 5912 | isChoiceOnlyPendingQuestion, |
apps/web/src/components/chat/folderDrop.test.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; | ||
| 4 | |||
| 5 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 6 | |||
| 7 | describe("folderDropTarget", () => { | ||
| 8 | it("targets local when the thread is on the primary environment", () => { | ||
| 9 | expect( | ||
| 10 | folderDropTarget({ | ||
| 11 | localEnvironmentDisabled: false, | ||
| 12 | environmentId, | ||
| 13 | primaryEnvironmentId: environmentId, | ||
| 14 | }), | ||
| 15 | ).toBe("local"); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("targets remote when Electron has no local environment", () => { | ||
| 19 | expect( | ||
| 20 | folderDropTarget({ | ||
| 21 | localEnvironmentDisabled: true, | ||
| 22 | environmentId, | ||
| 23 | primaryEnvironmentId: environmentId, | ||
| 24 | }), | ||
| 25 | ).toBe("remote"); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("targets remote when the thread lives on another environment", () => { | ||
| 29 | expect( | ||
| 30 | folderDropTarget({ | ||
| 31 | localEnvironmentDisabled: false, | ||
| 32 | environmentId: EnvironmentId.make("environment-2"), | ||
| 33 | primaryEnvironmentId: environmentId, | ||
| 34 | }), | ||
| 35 | ).toBe("remote"); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("targets remote when no primary environment is known", () => { | ||
| 39 | expect( | ||
| 40 | folderDropTarget({ | ||
| 41 | localEnvironmentDisabled: false, | ||
| 42 | environmentId, | ||
| 43 | primaryEnvironmentId: null, | ||
| 44 | }), | ||
| 45 | ).toBe("remote"); | ||
| 46 | }); | ||
| 47 | }); | ||
| 48 | |||
| 49 | describe("resolveDroppedFolderPath", () => { | ||
| 50 | it("returns the native path when the bridge provides it", () => { | ||
| 51 | const folder = new File([], "contracts"); | ||
| 52 | expect(resolveDroppedFolderPath(folder, () => "/tmp/project/contracts")).toBe( | ||
| 53 | "/tmp/project/contracts", | ||
| 54 | ); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("returns null for an outside-folder drop even when the folder name matches a project directory", () => { | ||
| 58 | const folder = new File([], "contracts"); | ||
| 59 | expect(resolveDroppedFolderPath(folder, undefined)).toBeNull(); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("returns null when the bridge returns an empty path", () => { | ||
| 63 | const folder = new File([], "contracts"); | ||
| 64 | expect(resolveDroppedFolderPath(folder, () => "")).toBeNull(); | ||
| 65 | }); | ||
| 66 | }); | ||
apps/web/src/components/chat/folderDrop.ts created+24| ... | @@ -0,0 +1,24 @@ | ||
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export function folderDropTarget(input: { | ||
| 4 | localEnvironmentDisabled: boolean; | ||
| 5 | environmentId: EnvironmentId; | ||
| 6 | primaryEnvironmentId: EnvironmentId | null; | ||
| 7 | }): "local" | "remote" { | ||
| 8 | if ( | ||
| 9 | input.localEnvironmentDisabled || | ||
| 10 | input.primaryEnvironmentId === null || | ||
| 11 | input.environmentId !== input.primaryEnvironmentId | ||
| 12 | ) { | ||
| 13 | return "remote"; | ||
| 14 | } | ||
| 15 | return "local"; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function resolveDroppedFolderPath( | ||
| 19 | folder: File, | ||
| 20 | getPathForFile: ((file: File) => string) | undefined, | ||
| 21 | ): string | null { | ||
| 22 | const path = getPathForFile?.(folder); | ||
| 23 | return typeof path === "string" && path.length > 0 ? path : null; | ||
| 24 | } | ||
apps/web/src/components/chat/workspaceFileDrop.test.ts+58-2| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | function makeDragEvent(options?: { | 8 | function makeDragEvent(options?: { |
| 9 | types?: string[]; | 9 | types?: string[]; |
| 10 | files?: File[]; | 10 | files?: File[]; |
| 11 | items?: NonNullable<WorkspaceFileDragEvent["dataTransfer"]["items"]>; | ||
| 11 | movedWithinTarget?: boolean; | 12 | movedWithinTarget?: boolean; |
| 12 | }) { | 13 | }) { |
| 13 | const preventDefault = vi.fn(); | 14 | const preventDefault = vi.fn(); |
| ... | @@ -16,6 +17,7 @@ function makeDragEvent(options?: { | ... | @@ -16,6 +17,7 @@ function makeDragEvent(options?: { |
| 16 | types: options?.types ?? ["Files"], | 17 | types: options?.types ?? ["Files"], |
| 17 | files: options?.files ?? [], | 18 | files: options?.files ?? [], |
| 18 | dropEffect: "none", | 19 | dropEffect: "none", |
| 20 | ...(options?.items === undefined ? {} : { items: options.items }), | ||
| 19 | }, | 21 | }, |
| 20 | relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, | 22 | relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, |
| 21 | currentTarget: { | 23 | currentTarget: { |
| ... | @@ -29,8 +31,9 @@ function makeDragEvent(options?: { | ... | @@ -29,8 +31,9 @@ function makeDragEvent(options?: { |
| 29 | function makeHost() { | 31 | function makeHost() { |
| 30 | const setDragActive = vi.fn(); | 32 | const setDragActive = vi.fn(); |
| 31 | const addFiles = vi.fn(); | 33 | const addFiles = vi.fn(); |
| 32 | const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; | 34 | const addFolders = vi.fn(); |
| 33 | return { host, setDragActive, addFiles }; | 35 | const host = { setDragActive, addFiles, addFolders } satisfies WorkspaceFileDropHost; |
| 36 | return { host, setDragActive, addFiles, addFolders }; | ||
| 34 | } | 37 | } |
| 35 | 38 | ||
| 36 | describe("makeWorkspaceFileDropHandlers", () => { | 39 | describe("makeWorkspaceFileDropHandlers", () => { |
| ... | @@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => { | ... | @@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => { |
| 75 | expect(setDragActive).toHaveBeenCalledWith(false); | 78 | expect(setDragActive).toHaveBeenCalledWith(false); |
| 76 | expect(addFiles).toHaveBeenCalledWith([file]); | 79 | expect(addFiles).toHaveBeenCalledWith([file]); |
| 77 | }); | 80 | }); |
| 81 | |||
| 82 | it("routes mixed drops to files and folders", () => { | ||
| 83 | const file = new File(["contents"], "example.txt", { type: "text/plain" }); | ||
| 84 | const folder = new File([], "project", { type: "" }); | ||
| 85 | const directory = { | ||
| 86 | kind: "file", | ||
| 87 | getAsFile: () => folder, | ||
| 88 | webkitGetAsEntry: () => ({ isDirectory: true }), | ||
| 89 | }; | ||
| 90 | const { host, addFiles, addFolders } = makeHost(); | ||
| 91 | const { event } = makeDragEvent({ | ||
| 92 | items: [ | ||
| 93 | directory, | ||
| 94 | { | ||
| 95 | kind: "file", | ||
| 96 | getAsFile: () => file, | ||
| 97 | webkitGetAsEntry: () => ({ isDirectory: false }), | ||
| 98 | }, | ||
| 99 | ], | ||
| 100 | }); | ||
| 101 | |||
| 102 | makeWorkspaceFileDropHandlers(host).onDrop(event); | ||
| 103 | |||
| 104 | expect(addFiles).toHaveBeenCalledWith([file]); | ||
| 105 | expect(addFolders).toHaveBeenCalledWith([folder]); | ||
| 106 | }); | ||
| 107 | |||
| 108 | it("routes a folder-only drop without attaching files", () => { | ||
| 109 | const folder = new File([], "project", { type: "" }); | ||
| 110 | const directory = { | ||
| 111 | kind: "file", | ||
| 112 | getAsFile: () => folder, | ||
| 113 | webkitGetAsEntry: () => ({ isDirectory: true }), | ||
| 114 | }; | ||
| 115 | const { host, addFiles, addFolders } = makeHost(); | ||
| 116 | const { event } = makeDragEvent({ items: [directory] }); | ||
| 117 | |||
| 118 | makeWorkspaceFileDropHandlers(host).onDrop(event); | ||
| 119 | |||
| 120 | expect(addFiles).not.toHaveBeenCalled(); | ||
| 121 | expect(addFolders).toHaveBeenCalledWith([folder]); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("uses files when the browser does not expose drag items", () => { | ||
| 125 | const file = new File(["contents"], "example.txt", { type: "text/plain" }); | ||
| 126 | const { host, addFiles, addFolders } = makeHost(); | ||
| 127 | const { event } = makeDragEvent({ files: [file] }); | ||
| 128 | |||
| 129 | makeWorkspaceFileDropHandlers(host).onDrop(event); | ||
| 130 | |||
| 131 | expect(addFiles).toHaveBeenCalledWith([file]); | ||
| 132 | expect(addFolders).not.toHaveBeenCalled(); | ||
| 133 | }); | ||
| 78 | }); | 134 | }); |
apps/web/src/components/chat/workspaceFileDrop.ts+31-1| ... | @@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent { | ... | @@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent { |
| 2 | readonly dataTransfer: { | 2 | readonly dataTransfer: { |
| 3 | readonly types: ReadonlyArray<string>; | 3 | readonly types: ReadonlyArray<string>; |
| 4 | readonly files: Iterable<File>; | 4 | readonly files: Iterable<File>; |
| 5 | readonly items?: Iterable<{ | ||
| 6 | readonly kind: string; | ||
| 7 | getAsFile(): File | null; | ||
| 8 | webkitGetAsEntry(): { readonly isDirectory: boolean } | null; | ||
| 9 | }>; | ||
| 5 | dropEffect: string; | 10 | dropEffect: string; |
| 6 | }; | 11 | }; |
| 7 | readonly relatedTarget: EventTarget | null; | 12 | readonly relatedTarget: EventTarget | null; |
| ... | @@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent { | ... | @@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent { |
| 14 | export interface WorkspaceFileDropHost { | 19 | export interface WorkspaceFileDropHost { |
| 15 | setDragActive(active: boolean): void; | 20 | setDragActive(active: boolean): void; |
| 16 | addFiles(files: File[]): void; | 21 | addFiles(files: File[]): void; |
| 22 | addFolders(folders: File[]): void; | ||
| 17 | } | 23 | } |
| 18 | 24 | ||
| 19 | function isFileDrag(event: WorkspaceFileDragEvent): boolean { | 25 | function isFileDrag(event: WorkspaceFileDragEvent): boolean { |
| ... | @@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { | ... | @@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { |
| 24 | return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); | 30 | return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); |
| 25 | } | 31 | } |
| 26 | 32 | ||
| 33 | function splitDroppedItems(dataTransfer: WorkspaceFileDragEvent["dataTransfer"]): { | ||
| 34 | files: File[]; | ||
| 35 | folders: File[]; | ||
| 36 | } { | ||
| 37 | if (dataTransfer.items === undefined) | ||
| 38 | return { files: Array.from(dataTransfer.files), folders: [] }; | ||
| 39 | |||
| 40 | const files: File[] = []; | ||
| 41 | const folders: File[] = []; | ||
| 42 | for (const item of dataTransfer.items) { | ||
| 43 | if (item.kind !== "file") continue; | ||
| 44 | const file = item.getAsFile(); | ||
| 45 | if (file === null) continue; | ||
| 46 | if (item.webkitGetAsEntry()?.isDirectory === true) { | ||
| 47 | folders.push(file); | ||
| 48 | } else { | ||
| 49 | files.push(file); | ||
| 50 | } | ||
| 51 | } | ||
| 52 | return { files, folders }; | ||
| 53 | } | ||
| 54 | |||
| 27 | export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { | 55 | export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { |
| 28 | return { | 56 | return { |
| 29 | onDragEnter(event: WorkspaceFileDragEvent) { | 57 | onDragEnter(event: WorkspaceFileDragEvent) { |
| ... | @@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { | ... | @@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { |
| 48 | if (!isFileDrag(event)) return; | 76 | if (!isFileDrag(event)) return; |
| 49 | event.preventDefault(); | 77 | event.preventDefault(); |
| 50 | host.setDragActive(false); | 78 | host.setDragActive(false); |
| 51 | host.addFiles(Array.from(event.dataTransfer.files)); | 79 | const { files, folders } = splitDroppedItems(event.dataTransfer); |
| 80 | if (files.length > 0) host.addFiles(files); | ||
| 81 | if (folders.length > 0) host.addFolders(folders); | ||
| 52 | }, | 82 | }, |
| 53 | }; | 83 | }; |
| 54 | } | 84 | } |
packages/contracts/src/ipc.ts+2| ... | @@ -1213,6 +1213,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; | ... | @@ -1213,6 +1213,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; |
| 1213 | 1213 | ||
| 1214 | export interface DesktopBridge { | 1214 | export interface DesktopBridge { |
| 1215 | getAppBranding: () => DesktopAppBranding | null; | 1215 | getAppBranding: () => DesktopAppBranding | null; |
| 1216 | /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ | ||
| 1217 | getPathForFile?: (file: File) => string; | ||
| 1216 | /** The desktop client's OS platform, read from Electron's preload process. */ | 1218 | /** The desktop client's OS platform, read from Electron's preload process. */ |
| 1217 | getClientPlatform?: () => string; | 1219 | getClientPlatform?: () => string; |
| 1218 | setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>; | 1220 | setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>; |