Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions apps/desktop/scripts/verify-preload-bundle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const validPreload = `
electron.contextBridge.exposeInMainWorld("desktopBridge", {
getClientPlatform: () => process.platform,
getLocalEnvironmentBootstraps: () => [],
getPathForFile: () => "",
pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
});
`;
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,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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1525,6 +1529,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 +5717,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
if (!inserted) focusComposer();
});
},
addDroppedFolders: (folders: File[]) => {
const target = folderDropTarget({
localEnvironmentDisabled: isLocalEnvironmentDisabled(),
environmentId,
primaryEnvironmentId,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
Expand Down Expand Up @@ -5868,6 +5902,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerPreviewAnnotations,
composerReviewComments,
focusComposer,
environmentId,
primaryEnvironmentId,
isConnecting,
isComposerApprovalState,
isChoiceOnlyPendingQuestion,
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/components/chat/folderDrop.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
24 changes: 24 additions & 0 deletions apps/web/src/components/chat/folderDrop.ts
Original file line number Diff line number Diff line change
@@ -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;
}
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