Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/scripts/verify-preload-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { parse } from "acorn";
const expectedDesktopBridgeApis = [
"getClientPlatform",
"getLocalEnvironmentBootstraps",
"getPathForFile",
"pickFolder",
];
const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";
Expand Down Expand Up @@ -68,6 +69,9 @@ const createSandboxModules = (exposedGlobals) => {
exposeInMainWorld: (name, api) => exposedGlobals.set(name, api),
},
ipcRenderer,
webUtils: {
getPathForFile: () => "",
},
};

return new Map([
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -69,6 +69,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
getPathForFile: (file: File) => webUtils.getPathForFile(file),
getClientPlatform: () => clientPlatform,
setNotificationBadge: (badge) =>
ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment thread
SunkenInTime marked this conversation as resolved.
});

return (
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
addFiles: (files) => {
onFileDropThreads(threadRef, files);
},
addFolders: () => {},
}),
[onFileDropThreads, threadRef],
);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
addFiles: (files) => {
onFileDropThreads(threadRef, files);
},
addFolders: () => {},
})
: null,
[onFileDropThreads, threadRef],
Expand Down Expand Up @@ -2057,6 +2058,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
addFiles: (files) => {
props.onFileDropThreads(threadRef, files);
},
addFolders: () => {},
}),
[props.onFileDropThreads, threadRef],
);
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
import { isElectron } from "../../env";
import { isLocalEnvironmentDisabled } from "../../localEnvironment";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
import { useRightPanelStore } from "~/rightPanelStore";
import { AttachmentFilePreview } from "../files/AttachmentFilePreview";
Expand Down Expand Up @@ -47,6 +50,7 @@ import {
wouldTextPasteExceedLimit,
} from "@t3tools/client-runtime/text-paste";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { folderDropTarget } from "./folderDrop";
import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
import {
Expand Down Expand Up @@ -1220,6 +1224,7 @@ export interface ChatComposerHandle {
restoreAfterTimelineReachedEnd: () => void;
collapseForTimelineScrollKey: (key: string) => void;
addDroppedFiles: (files: File[]) => void;
addDroppedFolders: (folders: File[]) => void;
hasPendingAttachments: () => boolean;
insertTextAtEnd: (
text: string,
Expand Down Expand Up @@ -1525,6 +1530,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onExpandImage,
onFileOpen,
} = props;
const primaryEnvironmentId = usePrimaryEnvironmentId();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
// ------------------------------------------------------------------
Expand Down Expand Up @@ -5712,6 +5718,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
if (!inserted) focusComposer();
});
},
addDroppedFolders: (folders: File[]) => {
const target = folderDropTarget({
isElectron,
localEnvironmentDisabled: isLocalEnvironmentDisabled(),
environmentId,
primaryEnvironmentId,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (target === "local") {
for (const folder of folders) {
const path = window.desktopBridge?.getPathForFile?.(folder);
if (typeof path === "string" && path.length > 0) {
insertComposerTextAtEnd(serializeComposerFileLink(path), {
ensureLeadingBoundary: true,
});
}
}
focusComposer();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} else if (target === "browser") {
toastManager.add({
type: "error",
title: "Folder drops are only supported in the desktop app",
});
} else {
toastManager.add({
type: "error",
title: "Folder drops aren't supported on remote environments",
description: "Type the folder path with @ instead.",
});
}
},
hasPendingAttachments: () =>
(pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0,
insertTextAtEnd: insertComposerTextAtEnd,
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/components/chat/folderDrop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "@effect/vitest";
import { EnvironmentId } from "@t3tools/contracts";
import { folderDropTarget } from "./folderDrop";

const environmentId = EnvironmentId.make("environment-1");

describe("folderDropTarget", () => {
it("targets the local environment in Electron", () => {
expect(
folderDropTarget({
isElectron: true,
localEnvironmentDisabled: false,
environmentId,
primaryEnvironmentId: environmentId,
}),
).toBe("local");
});

it("targets remote when Electron has no local environment", () => {
expect(
folderDropTarget({
isElectron: true,
localEnvironmentDisabled: true,
environmentId,
primaryEnvironmentId: environmentId,
}),
).toBe("remote");
});

it("targets browser outside Electron", () => {
expect(
folderDropTarget({
isElectron: false,
localEnvironmentDisabled: false,
environmentId,
primaryEnvironmentId: environmentId,
}),
).toBe("browser");
});
});
18 changes: 18 additions & 0 deletions apps/web/src/components/chat/folderDrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { EnvironmentId } from "@t3tools/contracts";

export function folderDropTarget(input: {
isElectron: boolean;
localEnvironmentDisabled: boolean;
environmentId: EnvironmentId;
primaryEnvironmentId: EnvironmentId | null;
}): "local" | "remote" | "browser" {
if (!input.isElectron) return "browser";
if (
input.localEnvironmentDisabled ||
input.primaryEnvironmentId === null ||
input.environmentId !== input.primaryEnvironmentId
) {
return "remote";
}
return "local";
}
60 changes: 58 additions & 2 deletions apps/web/src/components/chat/workspaceFileDrop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
function makeDragEvent(options?: {
types?: string[];
files?: File[];
items?: NonNullable<WorkspaceFileDragEvent["dataTransfer"]["items"]>;
movedWithinTarget?: boolean;
}) {
const preventDefault = vi.fn();
Expand All @@ -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: {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
32 changes: 31 additions & 1 deletion apps/web/src/components/chat/workspaceFileDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ export interface WorkspaceFileDragEvent {
readonly dataTransfer: {
readonly types: ReadonlyArray<string>;
readonly files: Iterable<File>;
readonly items?: Iterable<{
readonly kind: string;
getAsFile(): File | null;
webkitGetAsEntry(): { readonly isDirectory: boolean } | null;
}>;
dropEffect: string;
};
readonly relatedTarget: EventTarget | null;
Expand All @@ -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 {
Expand All @@ -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) {
Expand All @@ -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);
},
};
}
2 changes: 2 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down
Loading