| 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 | 9 | const expectedDesktopBridgeApis = [ |
| 10 | 10 | "getClientPlatform", |
| 11 | 11 | "getLocalEnvironmentBootstraps", |
| 12 | "getPathForFile", | |
| 12 | 13 | "pickFolder", |
| 13 | 14 | ]; |
| 14 | 15 | const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; |
| ... | ... | @@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => { |
| 68 | 69 | exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), |
| 69 | 70 | }, |
| 70 | 71 | ipcRenderer, |
| 72 | webUtils: { | |
| 73 | getPathForFile: () => "", | |
| 74 | }, | |
| 71 | 75 | }; |
| 72 | 76 | |
| 73 | 77 | return new Map([ |
apps/desktop/scripts/verify-preload-bundle.test.mjs+1| ... | ... | @@ -9,6 +9,7 @@ const validPreload = ` |
| 9 | 9 | electron.contextBridge.exposeInMainWorld("desktopBridge", { |
| 10 | 10 | getClientPlatform: () => process.platform, |
| 11 | 11 | getLocalEnvironmentBootstraps: () => [], |
| 12 | getPathForFile: () => "", | |
| 12 | 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 | 6 | DesktopSnapShotEvent, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 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 | 11 | import * as IpcChannels from "./ipc/channels.ts"; |
| 12 | 12 | |
| ... | ... | @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 69 | 69 | } |
| 70 | 70 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 71 | 71 | }, |
| 72 | getPathForFile: (file: File) => webUtils.getPathForFile(file), | |
| 72 | 73 | getClientPlatform: () => clientPlatform, |
| 73 | 74 | setNotificationBadge: (badge) => |
| 74 | 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 | 9318 | const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ |
| 9319 | 9319 | setDragActive: setIsWorkspaceFileDragActive, |
| 9320 | 9320 | addFiles: (files) => composerRef.current?.addDroppedFiles(files), |
| 9321 | addFolders: (folders) => composerRef.current?.addDroppedFolders(folders), | |
| 9321 | 9322 | }); |
| 9322 | 9323 | |
| 9323 | 9324 | return ( |
apps/web/src/components/LegacySidebar.tsx+1| ... | ... | @@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 391 | 391 | addFiles: (files) => { |
| 392 | 392 | onFileDropThreads(threadRef, files); |
| 393 | 393 | }, |
| 394 | addFolders: () => {}, | |
| 394 | 395 | }), |
| 395 | 396 | [onFileDropThreads, threadRef], |
| 396 | 397 | ); |
apps/web/src/components/Sidebar.tsx+2| ... | ... | @@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1278 | 1278 | addFiles: (files) => { |
| 1279 | 1279 | onFileDropThreads(threadRef, files); |
| 1280 | 1280 | }, |
| 1281 | addFolders: () => {}, | |
| 1281 | 1282 | }) |
| 1282 | 1283 | : null, |
| 1283 | 1284 | [onFileDropThreads, threadRef], |
| ... | ... | @@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2057 | 2058 | addFiles: (files) => { |
| 2058 | 2059 | props.onFileDropThreads(threadRef, files); |
| 2059 | 2060 | }, |
| 2061 | addFolders: () => {}, | |
| 2060 | 2062 | }), |
| 2061 | 2063 | [props.onFileDropThreads, threadRef], |
| 2062 | 2064 | ); |
apps/web/src/components/chat/ChatComposer.tsx+36| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; |
| 2 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | |
| 3 | import { usePrimaryEnvironmentId } from "../../state/environments"; | |
| 2 | 4 | import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; |
| 3 | 5 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 4 | 6 | import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; |
| ... | ... | @@ -47,6 +49,7 @@ import { |
| 47 | 49 | wouldTextPasteExceedLimit, |
| 48 | 50 | } from "@t3tools/client-runtime/text-paste"; |
| 49 | 51 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 52 | import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; | |
| 50 | 53 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 51 | 54 | import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; |
| 52 | 55 | import { |
| ... | ... | @@ -1220,6 +1223,7 @@ export interface ChatComposerHandle { |
| 1220 | 1223 | restoreAfterTimelineReachedEnd: () => void; |
| 1221 | 1224 | collapseForTimelineScrollKey: (key: string) => void; |
| 1222 | 1225 | addDroppedFiles: (files: File[]) => void; |
| 1226 | addDroppedFolders: (folders: File[]) => void; | |
| 1223 | 1227 | hasPendingAttachments: () => boolean; |
| 1224 | 1228 | insertTextAtEnd: ( |
| 1225 | 1229 | text: string, |
| ... | ... | @@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1525 | 1529 | onExpandImage, |
| 1526 | 1530 | onFileOpen, |
| 1527 | 1531 | } = props; |
| 1532 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | |
| 1528 | 1533 | const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; |
| 1529 | 1534 | const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; |
| 1530 | 1535 | // ------------------------------------------------------------------ |
| ... | ... | @@ -5715,6 +5720,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5715 | 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 | 5752 | hasPendingAttachments: () => |
| 5719 | 5753 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, |
| 5720 | 5754 | insertTextAtEnd: insertComposerTextAtEnd, |
| ... | ... | @@ -5871,6 +5905,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5871 | 5905 | composerPreviewAnnotations, |
| 5872 | 5906 | composerReviewComments, |
| 5873 | 5907 | focusComposer, |
| 5908 | environmentId, | |
| 5909 | primaryEnvironmentId, | |
| 5874 | 5910 | isConnecting, |
| 5875 | 5911 | isComposerApprovalState, |
| 5876 | 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 | 8 | function makeDragEvent(options?: { |
| 9 | 9 | types?: string[]; |
| 10 | 10 | files?: File[]; |
| 11 | items?: NonNullable<WorkspaceFileDragEvent["dataTransfer"]["items"]>; | |
| 11 | 12 | movedWithinTarget?: boolean; |
| 12 | 13 | }) { |
| 13 | 14 | const preventDefault = vi.fn(); |
| ... | ... | @@ -16,6 +17,7 @@ function makeDragEvent(options?: { |
| 16 | 17 | types: options?.types ?? ["Files"], |
| 17 | 18 | files: options?.files ?? [], |
| 18 | 19 | dropEffect: "none", |
| 20 | ...(options?.items === undefined ? {} : { items: options.items }), | |
| 19 | 21 | }, |
| 20 | 22 | relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, |
| 21 | 23 | currentTarget: { |
| ... | ... | @@ -29,8 +31,9 @@ function makeDragEvent(options?: { |
| 29 | 31 | function makeHost() { |
| 30 | 32 | const setDragActive = vi.fn(); |
| 31 | 33 | const addFiles = vi.fn(); |
| 32 | const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; | |
| 33 | return { host, setDragActive, addFiles }; | |
| 34 | const addFolders = vi.fn(); | |
| 35 | const host = { setDragActive, addFiles, addFolders } satisfies WorkspaceFileDropHost; | |
| 36 | return { host, setDragActive, addFiles, addFolders }; | |
| 34 | 37 | } |
| 35 | 38 | |
| 36 | 39 | describe("makeWorkspaceFileDropHandlers", () => { |
| ... | ... | @@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => { |
| 75 | 78 | expect(setDragActive).toHaveBeenCalledWith(false); |
| 76 | 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 | 2 | readonly dataTransfer: { |
| 3 | 3 | readonly types: ReadonlyArray<string>; |
| 4 | 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 | 10 | dropEffect: string; |
| 6 | 11 | }; |
| 7 | 12 | readonly relatedTarget: EventTarget | null; |
| ... | ... | @@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent { |
| 14 | 19 | export interface WorkspaceFileDropHost { |
| 15 | 20 | setDragActive(active: boolean): void; |
| 16 | 21 | addFiles(files: File[]): void; |
| 22 | addFolders(folders: File[]): void; | |
| 17 | 23 | } |
| 18 | 24 | |
| 19 | 25 | function isFileDrag(event: WorkspaceFileDragEvent): boolean { |
| ... | ... | @@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { |
| 24 | 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 | 55 | export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { |
| 28 | 56 | return { |
| 29 | 57 | onDragEnter(event: WorkspaceFileDragEvent) { |
| ... | ... | @@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { |
| 48 | 76 | if (!isFileDrag(event)) return; |
| 49 | 77 | event.preventDefault(); |
| 50 | 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 | 1213 | |
| 1214 | 1214 | export interface DesktopBridge { |
| 1215 | 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 | 1218 | /** The desktop client's OS platform, read from Electron's preload process. */ |
| 1217 | 1219 | getClientPlatform?: () => string; |
| 1218 | 1220 | setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>; |