History

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

Yordis Prieto committed 3 months ago

Commit 8ca4eec1 parent 0936fd26 files · +542 −20Browse files

Unified · Split

6 files
  1. apps/web/src/components/chat/ChatComposer.tsx +70 −20
  2. apps/web/src/components/chat/composerMentionDrag.test.ts +123 −0
  3. apps/web/src/components/chat/composerMentionDrag.ts +98 −0
  4. apps/web/src/components/files/FileBrowserPanel.tsx +44 −0
  5. apps/web/src/components/files/fileTreeDragMention.test.ts +112 −0
  6. apps/web/src/components/files/fileTreeDragMention.ts +95 −0
apps/web/src/components/chat/ChatComposer.tsx+70−20
@@ -44,6 +44,10 @@ import { · 43 unmodified lines
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) · 1828 unmodified lines
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) · 57 unmodified lines
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) · 120 unmodified lines
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+123−0
@@ -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+});
apps/web/src/components/chat/composerMentionDrag.ts+98−0
@@ -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+}
apps/web/src/components/files/FileBrowserPanel.tsx+44−0
@@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; · 15 unmodified lines
1616 import { readLocalApi } from "~/localApi";
1717 import { T3_PIERRE_ICONS } from "~/pierre-icons";
1818
19+import { createFileTreeDragMentionController } from "./fileTreeDragMention";
1920 import { useProjectEntriesQuery } from "./projectFilesQueryState";
2021
2122 interface FileBrowserPanelProps {
@@ -137,6 +138,14 @@ export default function FileBrowserPanel({ · 115 unmodified lines
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({ · 3 unmodified lines
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({ · 16 unmodified lines
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+112−0
@@ -0,0 +1,112 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { COMPOSER_MENTION_DRAG_TYPE } from "~/components/chat/composerMentionDrag";
4+import { createFileTreeDragMentionController } from "./fileTreeDragMention.ts";
5+
6+const 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+
15+const rowNode = (path: string) => ({
16+ getAttribute: (name: string) => (name === "data-item-path" ? path : null),
17+});
18+
19+describe("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+95−0
@@ -0,0 +1,95 @@
1+import {
2+ COMPOSER_MENTION_DRAG_TYPE,
3+ composerMentionFromTreePath,
4+} from "~/components/chat/composerMentionDrag";
5+
6+interface FileTreeDragTransfer {
7+ setData(format: string, data: string): void;
8+}
9+
10+export interface FileTreeDragStartEvent {
11+ readonly dataTransfer: FileTreeDragTransfer | null;
12+ composedPath(): ReadonlyArray<unknown>;
13+}
14+
15+export interface FileTreeDragMentionHost {
16+ /** Drop the tree's gesture-applied selection of the dragged row. */
17+ deselect(treePath: string): void;
18+}
19+
20+export 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+
33+const 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+ */
47+export 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+}