Unified · Split
| @@ -44,6 +44,10 @@ import { · 43 unmodified lines | ||
| 44 | 44 | shouldSubmitComposerOnEnter, |
| 45 | 45 | } from "../../composer-logic"; |
| 46 | 46 | import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; |
| 47 | +import { | |
| 48 | + dataTransferHasComposerMention, | |
| 49 | + makeComposerMentionDragHandlers, | |
| 50 | +} from "./composerMentionDrag"; | |
| 47 | 51 | import { |
| 48 | 52 | type ComposerImageAttachment, |
| 49 | 53 | type DraftId, |
| @@ -1878,6 +1882,67 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1828 unmodified lines | ||
| 1878 | 1882 | addComposerImages(files); |
| 1879 | 1883 | focusComposer(); |
| 1880 | 1884 | }; |
| 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]); | |
| 1881 | 1946 | const handleInterruptPrimaryAction = useCallback(() => { |
| 1882 | 1947 | void onInterrupt(); |
| 1883 | 1948 | }, [onInterrupt]); |
| @@ -1941,26 +2006,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 57 unmodified lines | ||
| 1941 | 2006 | focusAt: (cursor: number) => { |
| 1942 | 2007 | composerEditorRef.current?.focusAt(cursor); |
| 1943 | 2008 | }, |
| 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, | |
| 1964 | 2010 | openModelPicker: () => { |
| 1965 | 2011 | setIsComposerModelPickerOpen(true); |
| 1966 | 2012 | }, |
| @@ -2087,6 +2133,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 120 unmodified lines | ||
| 2087 | 2133 | onDragOver={onComposerDragOver} |
| 2088 | 2134 | onDragLeave={onComposerDragLeave} |
| 2089 | 2135 | onDrop={onComposerDrop} |
| 2136 | + onDragEnterCapture={composerMentionDragHandlers.onDragEnter} | |
| 2137 | + onDragOverCapture={composerMentionDragHandlers.onDragOver} | |
| 2138 | + onDragLeaveCapture={onComposerMentionDragLeaveCapture} | |
| 2139 | + onDropCapture={composerMentionDragHandlers.onDrop} | |
| 2090 | 2140 | > |
| 2091 | 2141 | <div |
| 2092 | 2142 | ref={composerSurfaceRef} |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; · 15 unmodified lines | ||
| 16 | 16 | import { readLocalApi } from "~/localApi"; |
| 17 | 17 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 18 | 18 | |
| 19 | +import { createFileTreeDragMentionController } from "./fileTreeDragMention"; | |
| 19 | 20 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; |
| 20 | 21 | |
| 21 | 22 | interface FileBrowserPanelProps { |
| @@ -137,6 +138,14 @@ export default function FileBrowserPanel({ · 115 unmodified lines | ||
| 137 | 138 | showEntryContextMenuRef.current = showEntryContextMenu; |
| 138 | 139 | }); |
| 139 | 140 | |
| 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 | + ); | |
| 140 | 149 | const { model } = useFileTree({ |
| 141 | 150 | composition: { |
| 142 | 151 | contextMenu: { |
| @@ -146,12 +155,21 @@ export default function FileBrowserPanel({ · 3 unmodified lines | ||
| 146 | 155 | }, |
| 147 | 156 | }, |
| 148 | 157 | }, |
| 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 }, | |
| 149 | 161 | density: "compact", |
| 150 | 162 | fileTreeSearchMode: "hide-non-matches", |
| 151 | 163 | flattenEmptyDirectories: true, |
| 152 | 164 | initialExpansion: 1, |
| 153 | 165 | icons: T3_PIERRE_ICONS, |
| 154 | 166 | 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 | + } | |
| 155 | 173 | const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); |
| 156 | 174 | if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { |
| 157 | 175 | onOpenFile(selectedPath); |
| @@ -174,8 +192,34 @@ export default function FileBrowserPanel({ · 16 unmodified lines | ||
| 174 | 192 | [entries], |
| 175 | 193 | ); |
| 176 | 194 | |
| 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 | + | |
| 177 | 220 | return ( |
| 178 | 221 | <div |
| 222 | + ref={panelRef} | |
| 179 | 223 | className="flex min-h-0 flex-1 flex-col bg-background" |
| 180 | 224 | data-file-browser-panel={`${environmentId}:${cwd}`} |
| 181 | 225 | > |
| @@ -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 | +}); | |
| @@ -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 | +} | |