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}