| author | |
| committer | |
| log | 8ca4eec9ca46e1dd2f2427ae2405f5f417e8f95d |
| tree | 2a13560e141873512de445f9fdd0c8ebc04795c6 |
| parent | 0936fd271f866f5f58cce067865c8d0262b9ea20 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 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) |
| 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) |
| 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) |
| 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} |
apps/web/src/components/chat/composerMentionDrag.test.ts created+123| ... | ... | @@ -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 created+98| ... | ... | @@ -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| ... | ... | @@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; |
| 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({ |
| 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({ |
| 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({ |
| 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 | > |
apps/web/src/components/files/fileTreeDragMention.test.ts created+112| ... | ... | @@ -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 created+95| ... | ... | @@ -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 | } |