Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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.
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
62 changes: 62 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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, matchDroppedFolderEntry } from "./folderDrop";
import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits";
import {
Expand Down Expand Up @@ -231,6 +234,7 @@ import { encodeComposerContextFragment } from "@t3tools/shared/composerContextCl
import type { ComposerContextClipboardFragment, ComposerContextRecord } from "@t3tools/contracts";
import { resolveAssetUrl } from "~/assets/assetUrls";
import { assetEnvironment } from "~/state/assets";
import { projectEnvironment } from "~/state/projects";
import { readPreparedConnection } from "~/state/session";
import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
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,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onExpandImage,
onFileOpen,
} = props;
const primaryEnvironmentId = usePrimaryEnvironmentId();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
reportFailure: false,
});
const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
// ------------------------------------------------------------------
Expand Down Expand Up @@ -5681,6 +5690,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
setIsComposerModelPickerOpen(true);
}, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]);

const resolveDroppedFolderPath = useCallback(
async (folder: File): Promise<string | null> => {
const nativePath = window.desktopBridge?.getPathForFile?.(folder);
if (typeof nativePath === "string" && nativePath.length > 0) return nativePath;
if (gitCwd === null) return null;
const result = await searchProjectEntries({
environmentId,
input: { cwd: gitCwd, query: folder.name, limit: 50, kind: "directory" },
});
return result._tag === "Success"
? matchDroppedFolderEntry(folder.name, result.value.entries)
: null;
},
[environmentId, gitCwd, searchProjectEntries],
);

useImperativeHandle(
composerRef,
() => ({
Expand Down Expand Up @@ -5712,6 +5737,38 @@ 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.
Resolved
if (target === "remote") {
toastManager.add({
type: "error",
title: "Folder drops aren't supported on remote environments",
description: "Type the folder path with @ instead.",
});
return;
}
void (async () => {
for (const folder of folders) {
const path = await resolveDroppedFolderPath(folder);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (path === null) {
toastManager.add({
type: "error",
title: `Couldn't find "${folder.name}" in this project`,
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 +5925,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
composerPreviewAnnotations,
composerReviewComments,
focusComposer,
environmentId,
gitCwd,
primaryEnvironmentId,
resolveDroppedFolderPath,
searchProjectEntries,
isConnecting,
isComposerApprovalState,
isChoiceOnlyPendingQuestion,
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/folderDrop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from "@effect/vitest";
import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts";
import { folderDropTarget, matchDroppedFolderEntry } 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("matchDroppedFolderEntry", () => {
it("returns the path for a unique directory match", () => {
const entries: ReadonlyArray<ProjectEntry> = [
{ path: "packages/contracts", kind: "directory" },
{ path: "src/contracts.ts", kind: "file" },
];
expect(matchDroppedFolderEntry("contracts", entries)).toBe("packages/contracts");
});

it("returns null when there is no match", () => {
const entries: ReadonlyArray<ProjectEntry> = [{ path: "packages/shared", kind: "directory" }];
expect(matchDroppedFolderEntry("contracts", entries)).toBeNull();
});

it("returns null when directory matches are ambiguous", () => {
const entries: ReadonlyArray<ProjectEntry> = [
{ path: "packages/contracts", kind: "directory" },
{ path: "vendor/contracts", kind: "directory" },
];
expect(matchDroppedFolderEntry("contracts", entries)).toBeNull();
});

it("ignores files with the matching name", () => {
const entries: ReadonlyArray<ProjectEntry> = [{ path: "src/contracts", kind: "file" }];
expect(matchDroppedFolderEntry("contracts", entries)).toBeNull();
});
});
32 changes: 32 additions & 0 deletions apps/web/src/components/chat/folderDrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { EnvironmentId, ProjectEntry } 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";
}

/** Project-relative path of the one directory entry named like the dropped folder, or null when none/ambiguous. */
export function matchDroppedFolderEntry(
folderName: string,
entries: ReadonlyArray<ProjectEntry>,
): string | null {
const matches = entries.filter(
(entry) =>
entry.kind === "directory" &&
entry.path
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.pop() === folderName,
);
return matches.length === 1 ? (matches[0]?.path ?? null) : 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();
});
});
Loading
Loading