diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6ec2e631d19..5ca3e2508d2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -44,6 +44,10 @@ import { shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + dataTransferHasComposerMention, + makeComposerMentionDragHandlers, +} from "./composerMentionDrag"; import { type ComposerImageAttachment, type DraftId, @@ -1878,6 +1882,53 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerImages(files); focusComposer(); }; + + const insertComposerTextAtEnd = ( + text: string, + options?: { ensureLeadingBoundary?: boolean }, + ): boolean => { + if ( + text.length === 0 || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + (environmentUnavailable !== null && activePendingProgress === null) + ) { + return false; + } + const prompt = promptRef.current; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); + return applyPromptReplacement( + prompt.length, + prompt.length, + needsLeadingSpace ? ` ${text}` : text, + ); + }; + + // File-tree drags land as mentions. Handled in the capture phase so the + // editor never sees the drop; the load-bearing rules (native stop, "move" + // effect, no eager focus) live in makeComposerMentionDragHandlers. + const composerMentionDragHandlers = makeComposerMentionDragHandlers({ + insertMentionAtEnd: (text) => insertComposerTextAtEnd(text, { ensureLeadingBoundary: true }), + setDragActive: setIsDragOverComposer, + onInsertRejected: () => { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + }, + }); + + const onComposerMentionDragLeaveCapture = (event: React.DragEvent) => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) return; + event.stopPropagation(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; + setIsDragOverComposer(false); + }; const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); @@ -1941,26 +1992,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => { - if ( - text.length === 0 || - isConnecting || - isComposerApprovalState || - pendingUserInputs.length > 0 || - projectSelectionRequired || - (environmentUnavailable !== null && activePendingProgress === null) - ) { - return false; - } - const prompt = promptRef.current; - const needsLeadingSpace = - (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); - return applyPromptReplacement( - prompt.length, - prompt.length, - needsLeadingSpace ? ` ${text}` : text, - ); - }, + insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); }, @@ -2087,6 +2119,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onDragOver={onComposerDragOver} onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} + onDragEnterCapture={composerMentionDragHandlers.onDragEnter} + onDragOverCapture={composerMentionDragHandlers.onDragOver} + onDragLeaveCapture={onComposerMentionDragLeaveCapture} + onDropCapture={composerMentionDragHandlers.onDrop} >
}) => { + const mention = options?.mention ?? "[index.md](docs/index.md)"; + const calls: Array = []; + const event = { + dataTransfer: { + types: options?.types ?? [COMPOSER_MENTION_DRAG_TYPE, "text/plain"], + getData: (format: string) => (format === COMPOSER_MENTION_DRAG_TYPE ? mention : ""), + dropEffect: "none", + }, + nativeEvent: { + stopPropagation: () => void calls.push("nativeStopPropagation"), + }, + preventDefault: () => void calls.push("preventDefault"), + stopPropagation: () => void calls.push("stopPropagation"), + }; + return { event, calls }; +}; + +const makeHost = (insertResult = true) => { + const log: Array = []; + const host: ComposerMentionDropHost = { + insertMentionAtEnd: (text) => { + log.push(`insert:${text}`); + return insertResult; + }, + setDragActive: (active) => void log.push(`active:${active}`), + onInsertRejected: () => void log.push("rejected"), + }; + return { host, log }; +}; + +describe("composerMentionFromTreePath", () => { + it("serializes a file path into a mention", () => { + expect(composerMentionFromTreePath("docs/index.md")).toBe("[index.md](docs/index.md)"); + }); + + it("strips the trailing slash directory rows carry", () => { + expect(composerMentionFromTreePath("docs/architecture/")).toBe( + "[architecture](docs/architecture)", + ); + }); + + it("rejects drags that carry no path", () => { + expect(composerMentionFromTreePath("")).toBeNull(); + expect(composerMentionFromTreePath("/")).toBeNull(); + }); +}); + +describe("dataTransferHasComposerMention", () => { + it("detects the mention payload among drag types", () => { + expect(dataTransferHasComposerMention([COMPOSER_MENTION_DRAG_TYPE, "text/plain"])).toBe(true); + expect(dataTransferHasComposerMention(["Files"])).toBe(false); + expect(dataTransferHasComposerMention([])).toBe(false); + }); +}); + +describe("makeComposerMentionDragHandlers", () => { + it("leaves drags without the mention payload alone", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event, calls } = makeDragEvent({ types: ["Files"] }); + handlers.onDragEnter(event); + handlers.onDragOver(event); + handlers.onDrop(event); + expect(calls).toEqual([]); + expect(log).toEqual([]); + }); + + it("stops the native event too, not just the synthetic one", () => { + // React's stopPropagation only halts synthetic dispatch; without the + // native stop, the editor's own DOM listeners process the drop and sync + // their stale state back over the inserted mention. + const { host } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event, calls } = makeDragEvent(); + handlers.onDrop(event); + expect(calls).toContain("preventDefault"); + expect(calls).toContain("stopPropagation"); + expect(calls).toContain("nativeStopPropagation"); + }); + + it('answers dragover with the "move" effect the tree allows', () => { + // Naming an effect outside the source's effectAllowed makes the browser + // cancel the drop without ever firing it. + const { host } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + const { event } = makeDragEvent(); + handlers.onDragOver(event); + expect(event.dataTransfer.dropEffect).toBe("move"); + }); + + it("inserts the mention with its trailing space and clears the highlight", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDragEnter(makeDragEvent().event); + handlers.onDrop(makeDragEvent().event); + expect(log).toEqual(["active:true", "active:false", "insert:[index.md](docs/index.md) "]); + }); + + it("reports a rejected insert instead of failing silently", () => { + const { host, log } = makeHost(false); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDrop(makeDragEvent().event); + expect(log).toContain("rejected"); + }); + + it("ignores a drop whose payload is empty", () => { + const { host, log } = makeHost(); + const handlers = makeComposerMentionDragHandlers(host); + handlers.onDrop(makeDragEvent({ mention: "" }).event); + expect(log).toEqual(["active:false"]); + }); +}); diff --git a/apps/web/src/components/chat/composerMentionDrag.ts b/apps/web/src/components/chat/composerMentionDrag.ts new file mode 100644 index 00000000000..43b1bfd800d --- /dev/null +++ b/apps/web/src/components/chat/composerMentionDrag.ts @@ -0,0 +1,98 @@ +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; + +/** + * Drag payload type carrying a serialized composer mention. Set on drags that + * start in the workspace file tree so the composer can tell them apart from + * OS file drags and plain text selections. + */ +export const COMPOSER_MENTION_DRAG_TYPE = "application/x-t3code-composer-mention"; + +export function composerMentionFromTreePath(treePath: string): string | null { + const relativePath = treePath.replace(/\/+$/, ""); + if (relativePath.length === 0) { + return null; + } + return serializeComposerFileLink(relativePath); +} + +export function dataTransferHasComposerMention(types: ReadonlyArray): boolean { + return types.includes(COMPOSER_MENTION_DRAG_TYPE); +} + +export interface ComposerMentionDragTransfer { + readonly types: ReadonlyArray; + getData(format: string): string; + dropEffect: string; +} + +export interface ComposerMentionDragEvent { + readonly dataTransfer: ComposerMentionDragTransfer; + readonly nativeEvent: { stopPropagation(): void }; + preventDefault(): void; + stopPropagation(): void; +} + +/** + * What a mention drop is allowed to do to the composer. Deliberately narrow: + * there is no way to focus the editor from here. Focusing it synchronously + * during the drop makes the not-yet-reconciled editor sync its stale empty + * state back over the inserted mention; the insert path already focuses on + * the next frame, after the editor has caught up. + */ +export interface ComposerMentionDropHost { + insertMentionAtEnd(text: string): boolean; + setDragActive(active: boolean): void; + onInsertRejected(): void; +} + +export interface ComposerMentionDragHandlers { + onDragEnter(event: ComposerMentionDragEvent): void; + onDragOver(event: ComposerMentionDragEvent): void; + onDrop(event: ComposerMentionDragEvent): void; +} + +export function makeComposerMentionDragHandlers( + host: ComposerMentionDropHost, +): ComposerMentionDragHandlers { + // Claim the event for the composer: React's stopPropagation only halts the + // synthetic dispatch, so the native event must be stopped too or the + // editor's own DOM listeners still process the drag. + const claim = (event: ComposerMentionDragEvent): boolean => { + if (!dataTransferHasComposerMention(event.dataTransfer.types)) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopPropagation(); + return true; + }; + return { + onDragEnter(event) { + if (claim(event)) { + host.setDragActive(true); + } + }, + onDragOver(event) { + if (!claim(event)) { + return; + } + // The tree constrains its drags to effectAllowed "move"; naming any + // other effect makes the browser cancel the drop without firing it. + event.dataTransfer.dropEffect = "move"; + host.setDragActive(true); + }, + onDrop(event) { + if (!claim(event)) { + return; + } + host.setDragActive(false); + const mention = event.dataTransfer.getData(COMPOSER_MENTION_DRAG_TYPE); + if (mention.length === 0) { + return; + } + if (!host.insertMentionAtEnd(`${mention} `)) { + host.onInsertRejected(); + } + }, + }; +} diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index f95022c3424..3f53d65533d 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -16,6 +16,7 @@ import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; interface FileBrowserPanelProps { @@ -137,6 +138,14 @@ export default function FileBrowserPanel({ showEntryContextMenuRef.current = showEntryContextMenu; }); + const treeModelRef = useRef["model"] | null>(null); + const dragMention = useMemo( + () => + createFileTreeDragMentionController({ + deselect: (path) => treeModelRef.current?.getItem(path)?.deselect(), + }), + [], + ); const { model } = useFileTree({ composition: { contextMenu: { @@ -146,12 +155,21 @@ export default function FileBrowserPanel({ }, }, }, + // Rows only need to be draggable so entries can be dropped into the chat + // composer; rearranging files inside the tree stays off. + dragAndDrop: { canDrop: () => false }, density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true, initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + dragMention.handleSelectionChange(selectedPaths); + // Starting a drag selects the dragged row; that selection is a side + // effect of the gesture, not a request to open the file. + if (dragMention.isDragInProgress()) { + return; + } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { onOpenFile(selectedPath); @@ -174,8 +192,34 @@ export default function FileBrowserPanel({ [entries], ); + // Tag tree drags with the composer mention payload. The row is read from + // the composed event path (the tree's shadow root is open), so this does + // not depend on running after the tree's own dragstart handler; the drag + // data store is writable for every dragstart listener in the dispatch. + // The capture phase runs before the tree's own dragstart handler selects + // the dragged row, so the drag flag is up before that selection emits. + const panelRef = useRef(null); + useEffect(() => { + treeModelRef.current = model; + }, [model]); + useEffect(() => { + const panel = panelRef.current; + if (panel === null) { + return; + } + const handleDragStart = (event: DragEvent) => dragMention.handleDragStart(event); + const handleDragEnd = () => dragMention.handleDragEnd(); + panel.addEventListener("dragstart", handleDragStart, true); + panel.addEventListener("dragend", handleDragEnd); + return () => { + panel.removeEventListener("dragstart", handleDragStart, true); + panel.removeEventListener("dragend", handleDragEnd); + }; + }, [dragMention]); + return (
diff --git a/apps/web/src/components/files/fileTreeDragMention.test.ts b/apps/web/src/components/files/fileTreeDragMention.test.ts new file mode 100644 index 00000000000..812501ab5d1 --- /dev/null +++ b/apps/web/src/components/files/fileTreeDragMention.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { COMPOSER_MENTION_DRAG_TYPE } from "~/components/chat/composerMentionDrag"; +import { createFileTreeDragMentionController } from "./fileTreeDragMention.ts"; + +const makeTransfer = (plainText = "") => { + const data = new Map([["text/plain", plainText]]); + return { + setData: (format: string, value: string) => void data.set(format, value), + getData: (format: string) => data.get(format) ?? "", + data, + }; +}; + +const rowNode = (path: string) => ({ + getAttribute: (name: string) => (name === "data-item-path" ? path : null), +}); + +describe("createFileTreeDragMentionController", () => { + it("tags a row drag with the mention payload and flags the drag", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [{}, rowNode("docs/index.md"), {}], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[index.md](docs/index.md)"); + expect(controller.isDragInProgress()).toBe(true); + }); + + it("strips the trailing slash from directory rows", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("docs/architecture/")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[architecture](docs/architecture)"); + }); + + it("does not tag drags of selected text from the panel chrome", () => { + // Only a drag that originates on a tree row is a mention; dragging a text + // selection also carries text/plain, and tagging it would drop an invalid + // pill into the composer. + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer("selected text"); + controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] }); + expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("ignores drags that carry no row path", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + const transfer = makeTransfer(); + controller.handleDragStart({ dataTransfer: transfer, composedPath: () => [{}] }); + expect(transfer.data.has(COMPOSER_MENTION_DRAG_TYPE)).toBe(false); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("deselects the dragged row exactly once when the drag ends", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleDragStart({ + dataTransfer: makeTransfer(), + composedPath: () => [rowNode("src/app.ts")], + }); + controller.handleDragEnd(); + controller.handleDragEnd(); + expect(deselected).toEqual(["src/app.ts"]); + expect(controller.isDragInProgress()).toBe(false); + }); + + it("drags the whole selection when the dragged row is part of it", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleSelectionChange(["docs/index.md", "docs/api.md", "src/app.ts"]); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("docs/api.md")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe( + "[index.md](docs/index.md) [api.md](docs/api.md) [app.ts](src/app.ts)", + ); + controller.handleDragEnd(); + expect(deselected).toEqual(["docs/index.md", "docs/api.md", "src/app.ts"]); + }); + + it("drags only the row under the cursor when it is outside the selection", () => { + const controller = createFileTreeDragMentionController({ deselect: () => {} }); + controller.handleSelectionChange(["docs/index.md"]); + const transfer = makeTransfer(); + controller.handleDragStart({ + dataTransfer: transfer, + composedPath: () => [rowNode("src/app.ts")], + }); + expect(transfer.getData(COMPOSER_MENTION_DRAG_TYPE)).toBe("[app.ts](src/app.ts)"); + }); + + it("does not deselect anything when no drag was started", () => { + const deselected: Array = []; + const controller = createFileTreeDragMentionController({ + deselect: (path) => deselected.push(path), + }); + controller.handleDragEnd(); + expect(deselected).toEqual([]); + }); +}); diff --git a/apps/web/src/components/files/fileTreeDragMention.ts b/apps/web/src/components/files/fileTreeDragMention.ts new file mode 100644 index 00000000000..7c17639a649 --- /dev/null +++ b/apps/web/src/components/files/fileTreeDragMention.ts @@ -0,0 +1,95 @@ +import { + COMPOSER_MENTION_DRAG_TYPE, + composerMentionFromTreePath, +} from "~/components/chat/composerMentionDrag"; + +interface FileTreeDragTransfer { + setData(format: string, data: string): void; +} + +export interface FileTreeDragStartEvent { + readonly dataTransfer: FileTreeDragTransfer | null; + composedPath(): ReadonlyArray; +} + +export interface FileTreeDragMentionHost { + /** Drop the tree's gesture-applied selection of the dragged row. */ + deselect(treePath: string): void; +} + +export interface FileTreeDragMentionController { + /** + * True from the moment a row drag starts until it ends. The tree selects + * the dragged row as part of the gesture; selection changes made while + * this is set are gesture side effects, not requests to open a file. + */ + isDragInProgress(): boolean; + /** Mirror of the tree's current selection, needed for multi-row drags. */ + handleSelectionChange(selectedPaths: ReadonlyArray): void; + handleDragStart(event: FileTreeDragStartEvent): void; + handleDragEnd(): void; +} + +const itemPathOf = (node: unknown): string | null => { + if (typeof node !== "object" || node === null) { + return null; + } + const element = node as { getAttribute?: (name: string) => string | null }; + return typeof element.getAttribute === "function" ? element.getAttribute("data-item-path") : null; +}; + +/** + * Tags file-tree drags with the composer mention payload and keeps the drag + * from acting like a click: while the drag runs, selection changes are + * suppressed, and when it ends the dragged rows are deselected so nothing is + * left highlighted and a later click on them still fires a selection change. + */ +export function createFileTreeDragMentionController( + host: FileTreeDragMentionHost, +): FileTreeDragMentionController { + let selection: ReadonlyArray = []; + let draggedPaths: ReadonlyArray = []; + return { + isDragInProgress: () => draggedPaths.length > 0, + handleSelectionChange(selectedPaths) { + selection = selectedPaths; + }, + handleDragStart(event) { + if (event.dataTransfer === null) { + return; + } + // Only drags that originate on a tree row are mentions; a text/plain + // fallback would also tag drags of selected text from the panel chrome. + let itemPath: string | null = null; + for (const node of event.composedPath()) { + itemPath = itemPathOf(node); + if (itemPath !== null) { + break; + } + } + if (itemPath === null) { + return; + } + // Same rule the tree applies to the drag itself: dragging a row that is + // part of the current selection drags the whole selection. + const dragged = selection.includes(itemPath) ? selection : [itemPath]; + const mentions = dragged + .map((path) => composerMentionFromTreePath(path)) + .filter((mention): mention is string => mention !== null); + if (mentions.length === 0) { + return; + } + draggedPaths = dragged; + event.dataTransfer.setData(COMPOSER_MENTION_DRAG_TYPE, mentions.join(" ")); + }, + handleDragEnd() { + if (draggedPaths.length === 0) { + return; + } + for (const path of draggedPaths) { + host.deselect(path); + } + draggedPaths = []; + }, + }; +}