authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-07-20 08:26:46-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 14:26:46+02:00
log8ca4eec9ca46e1dd2f2427ae2405f5f417e8f95d
tree2a13560e141873512de445f9fdd0c8ebc04795c6
parent0936fd271f866f5f58cce067865c8d0262b9ea20
signature Signed by PGP key B5690EEEBB952194

feat(web): drag files from the explorer into the chat composer (#4140)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

6 files changed, 542 insertions(+), 20 deletions(-)

apps/web/src/components/chat/ChatComposer.tsx+70-20
......@@ -44,6 +44,10 @@ import {
4444 shouldSubmitComposerOnEnter,
4545} from "../../composer-logic";
4646import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
47import {
48 dataTransferHasComposerMention,
49 makeComposerMentionDragHandlers,
50} from "./composerMentionDrag";
4751import {
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}
apps/web/src/components/chat/composerMentionDrag.test.ts created+123
......@@ -0,0 +1,123 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 COMPOSER_MENTION_DRAG_TYPE,
5 type ComposerMentionDropHost,
6 composerMentionFromTreePath,
7 dataTransferHasComposerMention,
8 makeComposerMentionDragHandlers,
9} from "./composerMentionDrag.ts";
10
11const 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
29const 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
42describe("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
59describe("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
67describe("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});
apps/web/src/components/chat/composerMentionDrag.ts created+98
......@@ -0,0 +1,98 @@
1import { 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 */
8export const COMPOSER_MENTION_DRAG_TYPE = "application/x-t3code-composer-mention";
9
10export 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
18export function dataTransferHasComposerMention(types: ReadonlyArray<string>): boolean {
19 return types.includes(COMPOSER_MENTION_DRAG_TYPE);
20}
21
22export interface ComposerMentionDragTransfer {
23 readonly types: ReadonlyArray<string>;
24 getData(format: string): string;
25 dropEffect: string;
26}
27
28export 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 */
42export interface ComposerMentionDropHost {
43 insertMentionAtEnd(text: string): boolean;
44 setDragActive(active: boolean): void;
45 onInsertRejected(): void;
46}
47
48export interface ComposerMentionDragHandlers {
49 onDragEnter(event: ComposerMentionDragEvent): void;
50 onDragOver(event: ComposerMentionDragEvent): void;
51 onDrop(event: ComposerMentionDragEvent): void;
52}
53
54export 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}
apps/web/src/components/files/FileBrowserPanel.tsx+44
......@@ -16,6 +16,7 @@ import { cn } from "~/lib/utils";
1616import { readLocalApi } from "~/localApi";
1717import { T3_PIERRE_ICONS } from "~/pierre-icons";
1818
19import { createFileTreeDragMentionController } from "./fileTreeDragMention";
1920import { useProjectEntriesQuery } from "./projectFilesQueryState";
2021
2122interface FileBrowserPanelProps {
......@@ -137,6 +138,14 @@ export default function FileBrowserPanel({
137138 showEntryContextMenuRef.current = showEntryContextMenu;
138139 });
139140
141 const treeModelRef = useRef<ReturnType<typeof useFileTree>["model"] | null>(null);
142 const dragMention = useMemo(
143 () =>
144 createFileTreeDragMentionController({
145 deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(),
146 }),
147 [],
148 );
140149 const { model } = useFileTree({
141150 composition: {
142151 contextMenu: {
......@@ -146,12 +155,21 @@ export default function FileBrowserPanel({
146155 },
147156 },
148157 },
158 // Rows only need to be draggable so entries can be dropped into the chat
159 // composer; rearranging files inside the tree stays off.
160 dragAndDrop: { canDrop: () => false },
149161 density: "compact",
150162 fileTreeSearchMode: "hide-non-matches",
151163 flattenEmptyDirectories: true,
152164 initialExpansion: 1,
153165 icons: T3_PIERRE_ICONS,
154166 onSelectionChange: (selectedPaths) => {
167 dragMention.handleSelectionChange(selectedPaths);
168 // Starting a drag selects the dragged row; that selection is a side
169 // effect of the gesture, not a request to open the file.
170 if (dragMention.isDragInProgress()) {
171 return;
172 }
155173 const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, "");
156174 if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") {
157175 onOpenFile(selectedPath);
......@@ -174,8 +192,34 @@ export default function FileBrowserPanel({
174192 [entries],
175193 );
176194
195 // Tag tree drags with the composer mention payload. The row is read from
196 // the composed event path (the tree's shadow root is open), so this does
197 // not depend on running after the tree's own dragstart handler; the drag
198 // data store is writable for every dragstart listener in the dispatch.
199 // The capture phase runs before the tree's own dragstart handler selects
200 // the dragged row, so the drag flag is up before that selection emits.
201 const panelRef = useRef<HTMLDivElement | null>(null);
202 useEffect(() => {
203 treeModelRef.current = model;
204 }, [model]);
205 useEffect(() => {
206 const panel = panelRef.current;
207 if (panel === null) {
208 return;
209 }
210 const handleDragStart = (event: DragEvent) => dragMention.handleDragStart(event);
211 const handleDragEnd = () => dragMention.handleDragEnd();
212 panel.addEventListener("dragstart", handleDragStart, true);
213 panel.addEventListener("dragend", handleDragEnd);
214 return () => {
215 panel.removeEventListener("dragstart", handleDragStart, true);
216 panel.removeEventListener("dragend", handleDragEnd);
217 };
218 }, [dragMention]);
219
177220 return (
178221 <div
222 ref={panelRef}
179223 className="flex min-h-0 flex-1 flex-col bg-background"
180224 data-file-browser-panel={`${environmentId}:${cwd}`}
181225 >
apps/web/src/components/files/fileTreeDragMention.test.ts created+112
......@@ -0,0 +1,112 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { COMPOSER_MENTION_DRAG_TYPE } from "~/components/chat/composerMentionDrag";
4import { createFileTreeDragMentionController } from "./fileTreeDragMention.ts";
5
6const makeTransfer = (plainText = "") => {
7 const data = new Map<string, string>([["text/plain", plainText]]);
8 return {
9 setData: (format: string, value: string) => void data.set(format, value),
10 getData: (format: string) => data.get(format) ?? "",
11 data,
12 };
13};
14
15const rowNode = (path: string) => ({
16 getAttribute: (name: string) => (name === "data-item-path" ? path : null),
17});
18
19describe("createFileTreeDragMentionController", () => {
20 it("tags a row drag with the mention payload and flags the drag", () => {
21 const controller = createFileTreeDragMentionController({ deselect: () => {} });
22 const transfer = makeTransfer();
23 controller.handleDragStart({
24 dataTransfer: transfer,
25 composedPath: () => [{}, rowNode("docs/index.md"), {}],
26 });
27 expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[index.md](docs/index.md)");
28 expect(controller.isDragInProgress()).toBe(true);
29 });
30
31 it("strips the trailing slash from directory rows", () => {
32 const controller = createFileTreeDragMentionController({ deselect: () => {} });
33 const transfer = makeTransfer();
34 controller.handleDragStart({
35 dataTransfer: transfer,
36 composedPath: () => [rowNode("docs/architecture/")],
37 });
38 expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[architecture](docs/architecture)");
39 });
40
41 it("does not tag drags of selected text from the panel chrome", () => {
42 // Only a drag that originates on a tree row is a mention; dragging a text
43 // selection also carries text/plain, and tagging it would drop an invalid
44 // pill into the composer.
45 const controller = createFileTreeDragMentionController({ deselect: () => {} });
46 const transfer = makeTransfer("selected text");
47 controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] });
48 expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false);
49 expect(controller.isDragInProgress()).toBe(false);
50 });
51
52 it("ignores drags that carry no row path", () => {
53 const controller = createFileTreeDragMentionController({ deselect: () => {} });
54 const transfer = makeTransfer();
55 controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] });
56 expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false);
57 expect(controller.isDragInProgress()).toBe(false);
58 });
59
60 it("deselects the dragged row exactly once when the drag ends", () => {
61 const deselected: Array<string> = [];
62 const controller = createFileTreeDragMentionController({
63 deselect: (path) => deselected.push(path),
64 });
65 controller.handleDragStart({
66 dataTransfer: makeTransfer(),
67 composedPath: () => [rowNode("src/app.ts")],
68 });
69 controller.handleDragEnd();
70 controller.handleDragEnd();
71 expect(deselected).toEqual(["src/app.ts"]);
72 expect(controller.isDragInProgress()).toBe(false);
73 });
74
75 it("drags the whole selection when the dragged row is part of it", () => {
76 const deselected: Array<string> = [];
77 const controller = createFileTreeDragMentionController({
78 deselect: (path) => deselected.push(path),
79 });
80 controller.handleSelectionChange(["docs/index.md", "docs/api.md", "src/app.ts"]);
81 const transfer = makeTransfer();
82 controller.handleDragStart({
83 dataTransfer: transfer,
84 composedPath: () => [rowNode("docs/api.md")],
85 });
86 expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe(
87 "[index.md](docs/index.md) [api.md](docs/api.md) [app.ts](src/app.ts)",
88 );
89 controller.handleDragEnd();
90 expect(deselected).toEqual(["docs/index.md", "docs/api.md", "src/app.ts"]);
91 });
92
93 it("drags only the row under the cursor when it is outside the selection", () => {
94 const controller = createFileTreeDragMentionController({ deselect: () => {} });
95 controller.handleSelectionChange(["docs/index.md"]);
96 const transfer = makeTransfer();
97 controller.handleDragStart({
98 dataTransfer: transfer,
99 composedPath: () => [rowNode("src/app.ts")],
100 });
101 expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[app.ts](src/app.ts)");
102 });
103
104 it("does not deselect anything when no drag was started", () => {
105 const deselected: Array<string> = [];
106 const controller = createFileTreeDragMentionController({
107 deselect: (path) => deselected.push(path),
108 });
109 controller.handleDragEnd();
110 expect(deselected).toEqual([]);
111 });
112});
apps/web/src/components/files/fileTreeDragMention.ts created+95
......@@ -0,0 +1,95 @@
1import {
2 COMPOSER_MENTION_DRAG_TYPE,
3 composerMentionFromTreePath,
4} from "~/components/chat/composerMentionDrag";
5
6interface FileTreeDragTransfer {
7 setData(format: string, data: string): void;
8}
9
10export interface FileTreeDragStartEvent {
11 readonly dataTransfer: FileTreeDragTransfer | null;
12 composedPath(): ReadonlyArray<unknown>;
13}
14
15export interface FileTreeDragMentionHost {
16 /** Drop the tree's gesture-applied selection of the dragged row. */
17 deselect(treePath: string): void;
18}
19
20export interface FileTreeDragMentionController {
21 /**
22 * True from the moment a row drag starts until it ends. The tree selects
23 * the dragged row as part of the gesture; selection changes made while
24 * this is set are gesture side effects, not requests to open a file.
25 */
26 isDragInProgress(): boolean;
27 /** Mirror of the tree's current selection, needed for multi-row drags. */
28 handleSelectionChange(selectedPaths: ReadonlyArray<string>): void;
29 handleDragStart(event: FileTreeDragStartEvent): void;
30 handleDragEnd(): void;
31}
32
33const itemPathOf = (node: unknown): string | null => {
34 if (typeof node !== "object" || node === null) {
35 return null;
36 }
37 const element = node as { getAttribute?: (name: string) => string | null };
38 return typeof element.getAttribute === "function" ? element.getAttribute("data-item-path") : null;
39};
40
41/**
42 * Tags file-tree drags with the composer mention payload and keeps the drag
43 * from acting like a click: while the drag runs, selection changes are
44 * suppressed, and when it ends the dragged rows are deselected so nothing is
45 * left highlighted and a later click on them still fires a selection change.
46 */
47export function createFileTreeDragMentionController(
48 host: FileTreeDragMentionHost,
49): FileTreeDragMentionController {
50 let selection: ReadonlyArray<string> = [];
51 let draggedPaths: ReadonlyArray<string> = [];
52 return {
53 isDragInProgress: () => draggedPaths.length > 0,
54 handleSelectionChange(selectedPaths) {
55 selection = selectedPaths;
56 },
57 handleDragStart(event) {
58 if (event.dataTransfer === null) {
59 return;
60 }
61 // Only drags that originate on a tree row are mentions; a text/plain
62 // fallback would also tag drags of selected text from the panel chrome.
63 let itemPath: string | null = null;
64 for (const node of event.composedPath()) {
65 itemPath = itemPathOf(node);
66 if (itemPath !== null) {
67 break;
68 }
69 }
70 if (itemPath === null) {
71 return;
72 }
73 // Same rule the tree applies to the drag itself: dragging a row that is
74 // part of the current selection drags the whole selection.
75 const dragged = selection.includes(itemPath) ? selection : [itemPath];
76 const mentions = dragged
77 .map((path) => composerMentionFromTreePath(path))
78 .filter((mention): mention is string => mention !== null);
79 if (mentions.length === 0) {
80 return;
81 }
82 draggedPaths = dragged;
83 event.dataTransfer.setData(COMPOSER_MENTION_DRAG_TYPE, mentions.join(" "));
84 },
85 handleDragEnd() {
86 if (draggedPaths.length === 0) {
87 return;
88 }
89 for (const path of draggedPaths) {
90 host.deselect(path);
91 }
92 draggedPaths = [];
93 },
94 };
95}