diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs index 34d39fb8e3e4..f6976220d07f 100644 --- a/apps/desktop/scripts/verify-preload-bundle.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.mjs @@ -9,6 +9,7 @@ import { parse } from "acorn"; const expectedDesktopBridgeApis = [ "getClientPlatform", "getLocalEnvironmentBootstraps", + "getPathForFile", "pickFolder", ]; const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys"; @@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => { exposeInMainWorld: (name, api) => exposedGlobals.set(name, api), }, ipcRenderer, + webUtils: { + getPathForFile: () => "", + }, }; return new Map([ diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs index a80a6d0964c1..d2055e2c6817 100644 --- a/apps/desktop/scripts/verify-preload-bundle.test.mjs +++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs @@ -9,6 +9,7 @@ const validPreload = ` electron.contextBridge.exposeInMainWorld("desktopBridge", { getClientPlatform: () => process.platform, getLocalEnvironmentBootstraps: () => [], + getPathForFile: () => "", pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), }); `; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d4339c21e5fc..885b31a64d6b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,7 +6,7 @@ import type { DesktopSnapShotEvent, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer, webFrame } from "electron"; +import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, setNotificationBadge: (badge) => ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 38f773cf037e..09c283ca4b25 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9316,6 +9316,7 @@ export default function ChatView(props: ChatViewProps) { const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), + addFolders: (folders) => composerRef.current?.addDroppedFolders(folders), }); return ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 81fd6047f13e..233df8667871 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP addFiles: (files) => { onFileDropThreads(threadRef, files); }, + addFolders: () => {}, }), [onFileDropThreads, threadRef], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e5a86ff98a3c..b11032987fd4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { addFiles: (files) => { onFileDropThreads(threadRef, files); }, + addFolders: () => {}, }) : null, [onFileDropThreads, threadRef], @@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { addFiles: (files) => { props.onFileDropThreads(threadRef, files); }, + addFolders: () => {}, }), [props.onFileDropThreads, threadRef], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f7d3ac540ccc..228ee43f6018 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,4 +1,6 @@ import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; +import { usePrimaryEnvironmentId } from "../../state/environments"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; import { useRightPanelStore } from "~/rightPanelStore"; import { AttachmentFilePreview } from "../files/AttachmentFilePreview"; @@ -47,6 +49,7 @@ import { wouldTextPasteExceedLimit, } from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { @@ -1220,6 +1223,7 @@ export interface ChatComposerHandle { restoreAfterTimelineReachedEnd: () => void; collapseForTimelineScrollKey: (key: string) => void; addDroppedFiles: (files: File[]) => void; + addDroppedFolders: (folders: File[]) => void; hasPendingAttachments: () => boolean; insertTextAtEnd: ( text: string, @@ -1525,6 +1529,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onExpandImage, onFileOpen, } = props; + const primaryEnvironmentId = usePrimaryEnvironmentId(); const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; // ------------------------------------------------------------------ @@ -5712,6 +5717,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!inserted) focusComposer(); }); }, + addDroppedFolders: (folders: File[]) => { + const target = folderDropTarget({ + localEnvironmentDisabled: isLocalEnvironmentDisabled(), + environmentId, + primaryEnvironmentId, + }); + if (target === "remote") { + toastManager.add({ + type: "error", + title: "Folders can't be dropped into remote environments", + }); + return; + } + for (const folder of folders) { + const path = resolveDroppedFolderPath(folder, window.desktopBridge?.getPathForFile); + if (path === null) { + toastManager.add({ + type: "error", + title: `Couldn't get the path of "${folder.name}"`, + description: "Type the folder path with @ instead.", + }); + continue; + } + insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, { + ensureLeadingBoundary: true, + }); + } + focusComposer(); + }, hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, insertTextAtEnd: insertComposerTextAtEnd, @@ -5868,6 +5902,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations, composerReviewComments, focusComposer, + environmentId, + primaryEnvironmentId, isConnecting, isComposerApprovalState, isChoiceOnlyPendingQuestion, diff --git a/apps/web/src/components/chat/folderDrop.test.ts b/apps/web/src/components/chat/folderDrop.test.ts new file mode 100644 index 000000000000..872dd210fb79 --- /dev/null +++ b/apps/web/src/components/chat/folderDrop.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; + +const environmentId = EnvironmentId.make("environment-1"); + +describe("folderDropTarget", () => { + it("targets local when the thread is on the primary environment", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: false, + environmentId, + primaryEnvironmentId: environmentId, + }), + ).toBe("local"); + }); + + it("targets remote when Electron has no local environment", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: true, + environmentId, + primaryEnvironmentId: environmentId, + }), + ).toBe("remote"); + }); + + it("targets remote when the thread lives on another environment", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: false, + environmentId: EnvironmentId.make("environment-2"), + primaryEnvironmentId: environmentId, + }), + ).toBe("remote"); + }); + + it("targets remote when no primary environment is known", () => { + expect( + folderDropTarget({ + localEnvironmentDisabled: false, + environmentId, + primaryEnvironmentId: null, + }), + ).toBe("remote"); + }); +}); + +describe("resolveDroppedFolderPath", () => { + it("returns the native path when the bridge provides it", () => { + const folder = new File([], "contracts"); + expect(resolveDroppedFolderPath(folder, () => "/tmp/project/contracts")).toBe( + "/tmp/project/contracts", + ); + }); + + it("returns null for an outside-folder drop even when the folder name matches a project directory", () => { + const folder = new File([], "contracts"); + expect(resolveDroppedFolderPath(folder, undefined)).toBeNull(); + }); + + it("returns null when the bridge returns an empty path", () => { + const folder = new File([], "contracts"); + expect(resolveDroppedFolderPath(folder, () => "")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/folderDrop.ts b/apps/web/src/components/chat/folderDrop.ts new file mode 100644 index 000000000000..8f462394e8fa --- /dev/null +++ b/apps/web/src/components/chat/folderDrop.ts @@ -0,0 +1,24 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export function folderDropTarget(input: { + localEnvironmentDisabled: boolean; + environmentId: EnvironmentId; + primaryEnvironmentId: EnvironmentId | null; +}): "local" | "remote" { + if ( + input.localEnvironmentDisabled || + input.primaryEnvironmentId === null || + input.environmentId !== input.primaryEnvironmentId + ) { + return "remote"; + } + return "local"; +} + +export function resolveDroppedFolderPath( + folder: File, + getPathForFile: ((file: File) => string) | undefined, +): string | null { + const path = getPathForFile?.(folder); + return typeof path === "string" && path.length > 0 ? path : null; +} diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts index ec5d074a3eb7..e7efc2864f25 100644 --- a/apps/web/src/components/chat/workspaceFileDrop.test.ts +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -8,6 +8,7 @@ import { function makeDragEvent(options?: { types?: string[]; files?: File[]; + items?: NonNullable; movedWithinTarget?: boolean; }) { const preventDefault = vi.fn(); @@ -16,6 +17,7 @@ function makeDragEvent(options?: { types: options?.types ?? ["Files"], files: options?.files ?? [], dropEffect: "none", + ...(options?.items === undefined ? {} : { items: options.items }), }, relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, currentTarget: { @@ -29,8 +31,9 @@ function makeDragEvent(options?: { function makeHost() { const setDragActive = vi.fn(); const addFiles = vi.fn(); - const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; - return { host, setDragActive, addFiles }; + const addFolders = vi.fn(); + const host = { setDragActive, addFiles, addFolders } satisfies WorkspaceFileDropHost; + return { host, setDragActive, addFiles, addFolders }; } describe("makeWorkspaceFileDropHandlers", () => { @@ -75,4 +78,57 @@ describe("makeWorkspaceFileDropHandlers", () => { expect(setDragActive).toHaveBeenCalledWith(false); expect(addFiles).toHaveBeenCalledWith([file]); }); + + it("routes mixed drops to files and folders", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const folder = new File([], "project", { type: "" }); + const directory = { + kind: "file", + getAsFile: () => folder, + webkitGetAsEntry: () => ({ isDirectory: true }), + }; + const { host, addFiles, addFolders } = makeHost(); + const { event } = makeDragEvent({ + items: [ + directory, + { + kind: "file", + getAsFile: () => file, + webkitGetAsEntry: () => ({ isDirectory: false }), + }, + ], + }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(addFiles).toHaveBeenCalledWith([file]); + expect(addFolders).toHaveBeenCalledWith([folder]); + }); + + it("routes a folder-only drop without attaching files", () => { + const folder = new File([], "project", { type: "" }); + const directory = { + kind: "file", + getAsFile: () => folder, + webkitGetAsEntry: () => ({ isDirectory: true }), + }; + const { host, addFiles, addFolders } = makeHost(); + const { event } = makeDragEvent({ items: [directory] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(addFiles).not.toHaveBeenCalled(); + expect(addFolders).toHaveBeenCalledWith([folder]); + }); + + it("uses files when the browser does not expose drag items", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const { host, addFiles, addFolders } = makeHost(); + const { event } = makeDragEvent({ files: [file] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(addFiles).toHaveBeenCalledWith([file]); + expect(addFolders).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts index 132a8051e159..9ac85419f178 100644 --- a/apps/web/src/components/chat/workspaceFileDrop.ts +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent { readonly dataTransfer: { readonly types: ReadonlyArray; readonly files: Iterable; + readonly items?: Iterable<{ + readonly kind: string; + getAsFile(): File | null; + webkitGetAsEntry(): { readonly isDirectory: boolean } | null; + }>; dropEffect: string; }; readonly relatedTarget: EventTarget | null; @@ -14,6 +19,7 @@ export interface WorkspaceFileDragEvent { export interface WorkspaceFileDropHost { setDragActive(active: boolean): void; addFiles(files: File[]): void; + addFolders(folders: File[]): void; } function isFileDrag(event: WorkspaceFileDragEvent): boolean { @@ -24,6 +30,28 @@ function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); } +function splitDroppedItems(dataTransfer: WorkspaceFileDragEvent["dataTransfer"]): { + files: File[]; + folders: File[]; +} { + if (dataTransfer.items === undefined) + return { files: Array.from(dataTransfer.files), folders: [] }; + + const files: File[] = []; + const folders: File[] = []; + for (const item of dataTransfer.items) { + if (item.kind !== "file") continue; + const file = item.getAsFile(); + if (file === null) continue; + if (item.webkitGetAsEntry()?.isDirectory === true) { + folders.push(file); + } else { + files.push(file); + } + } + return { files, folders }; +} + export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { return { onDragEnter(event: WorkspaceFileDragEvent) { @@ -48,7 +76,9 @@ export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { if (!isFileDrag(event)) return; event.preventDefault(); host.setDragActive(false); - host.addFiles(Array.from(event.dataTransfer.files)); + const { files, folders } = splitDroppedItems(event.dataTransfer); + if (files.length > 0) host.addFiles(files); + if (folders.length > 0) host.addFolders(folders); }, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dd972b7aa816..e1af4f936141 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1213,6 +1213,8 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + /** Absolute path of a dropped or picked file; absent on desktop builds predating it. */ + getPathForFile?: (file: File) => string; /** The desktop client's OS platform, read from Electron's preload process. */ getClientPlatform?: () => string; setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise;