Skip to content

Commit 8ca4eec

Browse files
feat(web): drag files from the explorer into the chat composer (pingdotgg#4140)
Co-authored-by: Julius Marminge <julius0216@outlook.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 0936fd2 commit 8ca4eec

6 files changed

Lines changed: 542 additions & 20 deletions

File tree

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ import {
4444
shouldSubmitComposerOnEnter,
4545
} from "../../composer-logic";
4646
import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
47+
import {
48+
dataTransferHasComposerMention,
49+
makeComposerMentionDragHandlers,
50+
} from "./composerMentionDrag";
4751
import {
4852
type ComposerImageAttachment,
4953
type DraftId,
@@ -1878,6 +1882,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18781882
addComposerImages(files);
18791883
focusComposer();
18801884
};
1885+
1886+
const insertComposerTextAtEnd = (
1887+
text: string,
1888+
options?: { ensureLeadingBoundary?: boolean },
1889+
): boolean => {
1890+
if (
1891+
text.length === 0 ||
1892+
isConnecting ||
1893+
isComposerApprovalState ||
1894+
pendingUserInputs.length > 0 ||
1895+
projectSelectionRequired ||
1896+
(environmentUnavailable !== null && activePendingProgress === null)
1897+
) {
1898+
return false;
1899+
}
1900+
const prompt = promptRef.current;
1901+
const needsLeadingSpace =
1902+
(options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt);
1903+
return applyPromptReplacement(
1904+
prompt.length,
1905+
prompt.length,
1906+
needsLeadingSpace ? ` ${text}` : text,
1907+
);
1908+
};
1909+
1910+
// File-tree drags land as mentions. Handled in the capture phase so the
1911+
// editor never sees the drop; the load-bearing rules (native stop, "move"
1912+
// effect, no eager focus) live in makeComposerMentionDragHandlers.
1913+
const composerMentionDragHandlers = makeComposerMentionDragHandlers({
1914+
insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }),
1915+
setDragActive: setIsDragOverComposer,
1916+
onInsertRejected: () => {
1917+
toastManager.add({
1918+
type: "error",
1919+
title: "Unable to add to chat",
1920+
description: "The composer is busy; try again once it is ready.",
1921+
});
1922+
},
1923+
});
1924+
1925+
const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLDivElement>) => {
1926+
if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;
1927+
event.stopPropagation();
1928+
const nextTarget = event.relatedTarget;
1929+
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
1930+
setIsDragOverComposer(false);
1931+
};
1932+
1933+
// A cancelled drag (Escape) can end without a dragleave on the hovered
1934+
// target, which would leave the drop highlight stuck. dragend always fires
1935+
// on the in-page drag source and bubbles to window, so it is the reset of
1936+
// last resort while the highlight is up.
1937+
useEffect(() => {
1938+
if (!isDragOverComposer) return;
1939+
const onWindowDragEnd = () => {
1940+
dragDepthRef.current = 0;
1941+
setIsDragOverComposer(false);
1942+
};
1943+
window.addEventListener("dragend", onWindowDragEnd);
1944+
return () => window.removeEventListener("dragend", onWindowDragEnd);
1945+
}, [isDragOverComposer]);
18811946
const handleInterruptPrimaryAction = useCallback(() => {
18821947
void onInterrupt();
18831948
}, [onInterrupt]);
@@ -1941,26 +2006,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19412006
focusAt: (cursor: number) => {
19422007
composerEditorRef.current?.focusAt(cursor);
19432008
},
1944-
insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => {
1945-
if (
1946-
text.length === 0 ||
1947-
isConnecting ||
1948-
isComposerApprovalState ||
1949-
pendingUserInputs.length > 0 ||
1950-
projectSelectionRequired ||
1951-
(environmentUnavailable !== null && activePendingProgress === null)
1952-
) {
1953-
return false;
1954-
}
1955-
const prompt = promptRef.current;
1956-
const needsLeadingSpace =
1957-
(options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt);
1958-
return applyPromptReplacement(
1959-
prompt.length,
1960-
prompt.length,
1961-
needsLeadingSpace ? ` ${text}` : text,
1962-
);
1963-
},
2009+
insertTextAtEnd: insertComposerTextAtEnd,
19642010
openModelPicker: () => {
19652011
setIsComposerModelPickerOpen(true);
19662012
},
@@ -2087,6 +2133,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20872133
onDragOver={onComposerDragOver}
20882134
onDragLeave={onComposerDragLeave}
20892135
onDrop={onComposerDrop}
2136+
onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
2137+
onDragOverCapture={composerMentionDragHandlers.onDragOver}
2138+
onDragLeaveCapture={onComposerMentionDragLeaveCapture}
2139+
onDropCapture={composerMentionDragHandlers.onDrop}
20902140
>
20912141
<div
20922142
ref={composerSurfaceRef}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import {
4+
COMPOSER_MENTION_DRAG_TYPE,
5+
type ComposerMentionDropHost,
6+
composerMentionFromTreePath,
7+
dataTransferHasComposerMention,
8+
makeComposerMentionDragHandlers,
9+
} from "./composerMentionDrag.ts";
10+
11+
const makeDragEvent = (options?: { mention?: string; types?: ReadonlyArray<string> }) => {
12+
const mention = options?.mention ?? "[index.md](docs/index.md)";
13+
const calls: Array<string> = [];
14+
const event = {
15+
dataTransfer: {
16+
types: options?.types ?? [COMPOSER_MENTION_DRAG_TYPE, "text/plain"],
17+
getData: (format: string) => (format === COMPOSER_MENTION_DRAG_TYPE ? mention : ""),
18+
dropEffect: "none",
19+
},
20+
nativeEvent: {
21+
stopPropagation: () => void calls.push("nativeStopPropagation"),
22+
},
23+
preventDefault: () => void calls.push("preventDefault"),
24+
stopPropagation: () => void calls.push("stopPropagation"),
25+
};
26+
return { event, calls };
27+
};
28+
29+
const makeHost = (insertResult = true) => {
30+
const log: Array<string> = [];
31+
const host: ComposerMentionDropHost = {
32+
insertMentionAtEnd: (text) => {
33+
log.push(`insert:${text}`);
34+
return insertResult;
35+
},
36+
setDragActive: (active) => void log.push(`active:${active}`),
37+
onInsertRejected: () => void log.push("rejected"),
38+
};
39+
return { host, log };
40+
};
41+
42+
describe("composerMentionFromTreePath", () => {
43+
it("serializes a file path into a mention", () => {
44+
expect(composerMentionFromTreePath("docs/index.md")).toBe("[index.md](docs/index.md)");
45+
});
46+
47+
it("strips the trailing slash directory rows carry", () => {
48+
expect(composerMentionFromTreePath("docs/architecture/")).toBe(
49+
"[architecture](docs/architecture)",
50+
);
51+
});
52+
53+
it("rejects drags that carry no path", () => {
54+
expect(composerMentionFromTreePath("")).toBeNull();
55+
expect(composerMentionFromTreePath("/")).toBeNull();
56+
});
57+
});
58+
59+
describe("dataTransferHasComposerMention", () => {
60+
it("detects the mention payload among drag types", () => {
61+
expect(dataTransferHasComposerMention([COMPOSER_MENTION_DRAG_TYPE, "text/plain"])).toBe(true);
62+
expect(dataTransferHasComposerMention(["Files"])).toBe(false);
63+
expect(dataTransferHasComposerMention([])).toBe(false);
64+
});
65+
});
66+
67+
describe("makeComposerMentionDragHandlers", () => {
68+
it("leaves drags without the mention payload alone", () => {
69+
const { host, log } = makeHost();
70+
const handlers = makeComposerMentionDragHandlers(host);
71+
const { event, calls } = makeDragEvent({ types: ["Files"] });
72+
handlers.onDragEnter(event);
73+
handlers.onDragOver(event);
74+
handlers.onDrop(event);
75+
expect(calls).toEqual([]);
76+
expect(log).toEqual([]);
77+
});
78+
79+
it("stops the native event too, not just the synthetic one", () => {
80+
// React's stopPropagation only halts synthetic dispatch; without the
81+
// native stop, the editor's own DOM listeners process the drop and sync
82+
// their stale state back over the inserted mention.
83+
const { host } = makeHost();
84+
const handlers = makeComposerMentionDragHandlers(host);
85+
const { event, calls } = makeDragEvent();
86+
handlers.onDrop(event);
87+
expect(calls).toContain("preventDefault");
88+
expect(calls).toContain("stopPropagation");
89+
expect(calls).toContain("nativeStopPropagation");
90+
});
91+
92+
it('answers dragover with the "move" effect the tree allows', () => {
93+
// Naming an effect outside the source's effectAllowed makes the browser
94+
// cancel the drop without ever firing it.
95+
const { host } = makeHost();
96+
const handlers = makeComposerMentionDragHandlers(host);
97+
const { event } = makeDragEvent();
98+
handlers.onDragOver(event);
99+
expect(event.dataTransfer.dropEffect).toBe("move");
100+
});
101+
102+
it("inserts the mention with its trailing space and clears the highlight", () => {
103+
const { host, log } = makeHost();
104+
const handlers = makeComposerMentionDragHandlers(host);
105+
handlers.onDragEnter(makeDragEvent().event);
106+
handlers.onDrop(makeDragEvent().event);
107+
expect(log).toEqual(["active:true", "active:false", "insert:[index.md](docs/index.md) "]);
108+
});
109+
110+
it("reports a rejected insert instead of failing silently", () => {
111+
const { host, log } = makeHost(false);
112+
const handlers = makeComposerMentionDragHandlers(host);
113+
handlers.onDrop(makeDragEvent().event);
114+
expect(log).toContain("rejected");
115+
});
116+
117+
it("ignores a drop whose payload is empty", () => {
118+
const { host, log } = makeHost();
119+
const handlers = makeComposerMentionDragHandlers(host);
120+
handlers.onDrop(makeDragEvent({ mention: "" }).event);
121+
expect(log).toEqual(["active:false"]);
122+
});
123+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
2+
3+
/**
4+
* Drag payload type carrying a serialized composer mention. Set on drags that
5+
* start in the workspace file tree so the composer can tell them apart from
6+
* OS file drags and plain text selections.
7+
*/
8+
export const COMPOSER_MENTION_DRAG_TYPE = "application/x-t3code-composer-mention";
9+
10+
export function composerMentionFromTreePath(treePath: string): string | null {
11+
const relativePath = treePath.replace(/\/+$/, "");
12+
if (relativePath.length === 0) {
13+
return null;
14+
}
15+
return serializeComposerFileLink(relativePath);
16+
}
17+
18+
export function dataTransferHasComposerMention(types: ReadonlyArray<string>): boolean {
19+
return types.includes(COMPOSER_MENTION_DRAG_TYPE);
20+
}
21+
22+
export interface ComposerMentionDragTransfer {
23+
readonly types: ReadonlyArray<string>;
24+
getData(format: string): string;
25+
dropEffect: string;
26+
}
27+
28+
export interface ComposerMentionDragEvent {
29+
readonly dataTransfer: ComposerMentionDragTransfer;
30+
readonly nativeEvent: { stopPropagation(): void };
31+
preventDefault(): void;
32+
stopPropagation(): void;
33+
}
34+
35+
/**
36+
* What a mention drop is allowed to do to the composer. Deliberately narrow:
37+
* there is no way to focus the editor from here. Focusing it synchronously
38+
* during the drop makes the not-yet-reconciled editor sync its stale empty
39+
* state back over the inserted mention; the insert path already focuses on
40+
* the next frame, after the editor has caught up.
41+
*/
42+
export interface ComposerMentionDropHost {
43+
insertMentionAtEnd(text: string): boolean;
44+
setDragActive(active: boolean): void;
45+
onInsertRejected(): void;
46+
}
47+
48+
export interface ComposerMentionDragHandlers {
49+
onDragEnter(event: ComposerMentionDragEvent): void;
50+
onDragOver(event: ComposerMentionDragEvent): void;
51+
onDrop(event: ComposerMentionDragEvent): void;
52+
}
53+
54+
export function makeComposerMentionDragHandlers(
55+
host: ComposerMentionDropHost,
56+
): ComposerMentionDragHandlers {
57+
// Claim the event for the composer: React's stopPropagation only halts the
58+
// synthetic dispatch, so the native event must be stopped too or the
59+
// editor's own DOM listeners still process the drag.
60+
const claim = (event: ComposerMentionDragEvent): boolean => {
61+
if (!dataTransferHasComposerMention(event.dataTransfer.types)) {
62+
return false;
63+
}
64+
event.preventDefault();
65+
event.stopPropagation();
66+
event.nativeEvent.stopPropagation();
67+
return true;
68+
};
69+
return {
70+
onDragEnter(event) {
71+
if (claim(event)) {
72+
host.setDragActive(true);
73+
}
74+
},
75+
onDragOver(event) {
76+
if (!claim(event)) {
77+
return;
78+
}
79+
// The tree constrains its drags to effectAllowed "move"; naming any
80+
// other effect makes the browser cancel the drop without firing it.
81+
event.dataTransfer.dropEffect = "move";
82+
host.setDragActive(true);
83+
},
84+
onDrop(event) {
85+
if (!claim(event)) {
86+
return;
87+
}
88+
host.setDragActive(false);
89+
const mention = event.dataTransfer.getData(COMPOSER_MENTION_DRAG_TYPE);
90+
if (mention.length === 0) {
91+
return;
92+
}
93+
if (!host.insertMentionAtEnd(`${mention} `)) {
94+
host.onInsertRejected();
95+
}
96+
},
97+
};
98+
}

0 commit comments

Comments
 (0)