diff --git a/packages/core/src/message-editor/paste.test.ts b/packages/core/src/message-editor/paste.test.ts new file mode 100644 index 0000000000..8fc9b97ab8 --- /dev/null +++ b/packages/core/src/message-editor/paste.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { type AutoConvertedPaste, isRepeatOfAutoConvertedPaste } from "./paste"; + +const lastPaste: AutoConvertedPaste = { + clipboardText: "https://github.com/posthog/code/issues/42", + insertText: "https://github.com/posthog/code/issues/42", + chipId: "chip-1", +}; + +describe("isRepeatOfAutoConvertedPaste", () => { + it.each([ + { + name: "same clipboard text as the last conversion", + last: lastPaste, + clipboardText: lastPaste.clipboardText, + expected: true, + }, + { + name: "no prior conversion", + last: null, + clipboardText: lastPaste.clipboardText, + expected: false, + }, + { + name: "different clipboard text", + last: lastPaste, + clipboardText: "something else", + expected: false, + }, + { + name: "clipboard text differing only by whitespace", + last: lastPaste, + clipboardText: `${lastPaste.clipboardText} `, + expected: false, + }, + { + name: "empty clipboard text", + last: lastPaste, + clipboardText: "", + expected: false, + }, + { + name: "undefined clipboard text", + last: lastPaste, + clipboardText: undefined, + expected: false, + }, + ])("returns $expected for $name", ({ last, clipboardText, expected }) => { + expect(isRepeatOfAutoConvertedPaste(last, clipboardText)).toBe(expected); + }); +}); diff --git a/packages/core/src/message-editor/paste.ts b/packages/core/src/message-editor/paste.ts index a9307bcb66..4284a0255f 100644 --- a/packages/core/src/message-editor/paste.ts +++ b/packages/core/src/message-editor/paste.ts @@ -29,3 +29,18 @@ export function buildPastedTextLabel( ): string { return `Pasted text #${pasteNumber} (${lineCount} lines)`; } + +export interface AutoConvertedPaste { + clipboardText: string; + insertText: string; + chipId: string; +} + +export function isRepeatOfAutoConvertedPaste( + last: AutoConvertedPaste | null, + clipboardText: string | null | undefined, +): last is AutoConvertedPaste { + return ( + last !== null && !!clipboardText && clipboardText === last.clipboardText + ); +} diff --git a/packages/ui/src/features/message-editor/hostApi.ts b/packages/ui/src/features/message-editor/hostApi.ts index d4faef532d..6cd6085552 100644 --- a/packages/ui/src/features/message-editor/hostApi.ts +++ b/packages/ui/src/features/message-editor/hostApi.ts @@ -72,12 +72,6 @@ export function getGhStatus(): Promise { return hostClient().git.getGhStatus.query(); } -export function readAbsoluteFile(input: { - filePath: string; -}): Promise { - return hostClient().fs.readAbsoluteFile.query(input); -} - export function selectDirectory(): Promise { return hostClient().os.selectDirectory.query(); } diff --git a/packages/ui/src/features/message-editor/pasteUndoStore.ts b/packages/ui/src/features/message-editor/pasteUndoStore.ts new file mode 100644 index 0000000000..24e1f4b433 --- /dev/null +++ b/packages/ui/src/features/message-editor/pasteUndoStore.ts @@ -0,0 +1,11 @@ +import { create } from "zustand"; + +interface PasteUndoState { + undoableChipId: string | null; + setUndoableChipId: (chipId: string | null) => void; +} + +export const usePasteUndoStore = create((set) => ({ + undoableChipId: null, + setUndoableChipId: (chipId) => set({ undoableChipId: chipId }), +})); diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts index 9f72f163a3..ef4540fe39 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts @@ -1,6 +1,7 @@ import type { UploadableSkillSource } from "@posthog/shared"; import { mergeAttributes, Node } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +import { findChipRangeById } from "./chipRange"; import { MentionChipView } from "./MentionChipView"; export type ChipType = @@ -103,39 +104,22 @@ export const MentionChipNode = Node.create({ replaceMentionChipById: (chipId: string, attrs: Partial) => ({ tr, state, dispatch }) => { - let found = false; - state.doc.descendants((node, pos) => { - if (found) return false; - if (node.type.name !== "mentionChip") return; - if (node.attrs.chipId !== chipId) return; - found = true; - tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs }); - return false; - }); - if (found && dispatch) dispatch(tr); - return found; + const range = findChipRangeById(state.doc, chipId); + if (!range) return false; + const node = state.doc.nodeAt(range.from); + if (!node) return false; + tr.setNodeMarkup(range.from, undefined, { ...node.attrs, ...attrs }); + if (dispatch) dispatch(tr); + return true; }, removeMentionChipById: (chipId: string) => ({ tr, state, dispatch }) => { - let found = false; - state.doc.descendants((node, pos) => { - if (found) return false; - if (node.type.name !== "mentionChip") return; - if (node.attrs.chipId !== chipId) return; - found = true; - const from = pos; - const to = pos + node.nodeSize; - // Also swallow a trailing single space the suggestion adds. - const after = state.doc.textBetween( - to, - Math.min(to + 1, state.doc.content.size), - ); - tr.delete(from, after === " " ? to + 1 : to); - return false; - }); - if (found && dispatch) dispatch(tr); - return found; + const range = findChipRangeById(state.doc, chipId); + if (!range) return false; + tr.delete(range.from, range.to); + if (dispatch) dispatch(tr); + return true; }, }; }, diff --git a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 24f13336a2..5b0e542264 100644 --- a/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -11,12 +11,9 @@ import { XIcon, } from "@phosphor-icons/react"; import { Chip } from "@posthog/quill"; -import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; -import type { Node as PmNode } from "@tiptap/pm/model"; -import type { Editor } from "@tiptap/react"; import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { readAbsoluteFile } from "../hostApi"; +import { usePasteUndoStore } from "../pasteUndoStore"; import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; const chipBase = "group/chip relative top-px active:translate-y-0 pl-1"; @@ -68,15 +65,22 @@ function DefaultChip({ type, id, label, + chipId, + pastedText, selected, onRemove, }: { type: string; id: string; label: string; + chipId: string | null; + pastedText: boolean; selected: boolean; onRemove: () => void; }) { + const undoableChipId = usePasteUndoStore((state) => state.undoableChipId); + const canUndoPaste = + pastedText && chipId !== null && chipId === undoableChipId; const isCommand = type === "command"; const prefix = isCommand ? "/" : "@"; const isFile = type === "file"; @@ -101,69 +105,24 @@ function DefaultChip({ ); if (isFile || isFolder) { - return {chipContent}; + return ( + + {chipContent} + + ); } return chipContent; } -function PastedTextChip({ - label, - filePath, - editor, - node, - getPos, - selected, - onRemove, -}: { - label: string; - filePath: string; - editor: Editor; - node: PmNode; - getPos: () => number | undefined; - selected: boolean; - onRemove: () => void; -}) { - const handleClick = async () => { - useFeatureSettingsStore.getState().markHintLearned("paste-as-file"); - - const content = await readAbsoluteFile({ - filePath, - }); - if (!content) return; - - const pos = getPos(); - if (pos == null) return; - - editor - .chain() - .focus() - .deleteRange({ from: pos, to: pos + node.nodeSize }) - .insertContentAt(pos, content) - .run(); - }; - - return ( - - - @{label} - - - ); -} - export function MentionChipView({ node, getPos, editor, selected, }: NodeViewProps) { - const { type, id, label, pastedText } = node.attrs as MentionChipAttrs; + const { type, id, label, pastedText, chipId } = + node.attrs as MentionChipAttrs; const handleRemove = () => { const pos = getPos(); @@ -177,25 +136,15 @@ export function MentionChipView({ return ( - {pastedText ? ( - - ) : ( - - )} + ); } diff --git a/packages/ui/src/features/message-editor/tiptap/chipRange.test.ts b/packages/ui/src/features/message-editor/tiptap/chipRange.test.ts new file mode 100644 index 0000000000..6a385333b8 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/chipRange.test.ts @@ -0,0 +1,75 @@ +import { getSchema } from "@tiptap/core"; +import { Node as PmNode } from "@tiptap/pm/model"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vitest"; +import { findChipRangeById } from "./chipRange"; +import { MentionChipNode } from "./MentionChipNode"; + +const schema = getSchema([StarterKit, MentionChipNode]); + +function chip(chipId: string | null) { + return { + type: "mentionChip", + attrs: { + type: "file", + id: "/tmp/pasted.txt", + label: "Pasted text #1 (2 lines)", + pastedText: true, + chipId, + }, + }; +} + +function text(value: string) { + return { type: "text", text: value }; +} + +function docOf(...content: object[]): PmNode { + return PmNode.fromJSON(schema, { + type: "doc", + content: [{ type: "paragraph", content }], + }); +} + +describe("findChipRangeById", () => { + it.each([ + { + name: "chip followed by a trailing space swallows the space", + doc: docOf(chip("a"), text(" tail")), + chipId: "a", + expected: { from: 1, to: 3 }, + }, + { + name: "chip at the end of the doc", + doc: docOf(text("hi "), chip("a")), + chipId: "a", + expected: { from: 4, to: 5 }, + }, + { + name: "chip followed by non-space text", + doc: docOf(chip("a"), text("x")), + chipId: "a", + expected: { from: 1, to: 2 }, + }, + { + name: "matching chip among several", + doc: docOf(chip("a"), text(" "), chip("b"), text(" ")), + chipId: "b", + expected: { from: 3, to: 5 }, + }, + { + name: "no chip with the requested id", + doc: docOf(chip("a"), text(" ")), + chipId: "missing", + expected: null, + }, + { + name: "chip without a chipId attribute", + doc: docOf(chip(null), text(" ")), + chipId: "a", + expected: null, + }, + ])("$name", ({ doc, chipId, expected }) => { + expect(findChipRangeById(doc, chipId)).toEqual(expected); + }); +}); diff --git a/packages/ui/src/features/message-editor/tiptap/chipRange.ts b/packages/ui/src/features/message-editor/tiptap/chipRange.ts new file mode 100644 index 0000000000..c9dbf12e17 --- /dev/null +++ b/packages/ui/src/features/message-editor/tiptap/chipRange.ts @@ -0,0 +1,27 @@ +import type { Node as PmNode } from "@tiptap/pm/model"; + +export interface ChipRange { + from: number; + to: number; +} + +export function findChipRangeById( + doc: PmNode, + chipId: string, +): ChipRange | null { + let range: ChipRange | null = null; + doc.descendants((node, pos) => { + if (range) return false; + if (node.type.name !== "mentionChip") return; + if (node.attrs.chipId !== chipId) return; + const nodeEnd = pos + node.nodeSize; + // Also swallow the trailing space the chip insertion added. + const after = doc.textBetween( + nodeEnd, + Math.min(nodeEnd + 1, doc.content.size), + ); + range = { from: pos, to: after === " " ? nodeEnd + 1 : nodeEnd }; + return false; + }); + return range; +} diff --git a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index f370f694ff..b1a0c179b8 100644 --- a/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -11,10 +11,12 @@ import { parseGithubIssueUrl, } from "@posthog/core/message-editor/githubIssueUrl"; import { + type AutoConvertedPaste, buildMarkdownLink, buildPastedTextLabel, extractBashCommand, isBashModeText, + isRepeatOfAutoConvertedPaste, isUrlOnly, shouldAutoConvertLongText, } from "@posthog/core/message-editor/paste"; @@ -27,7 +29,9 @@ import { useEditor } from "@tiptap/react"; import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { getGithubIssue, getGithubPullRequest } from "../hostApi"; +import { usePasteUndoStore } from "../pasteUndoStore"; import { usePromptHistoryStore } from "../promptHistoryStore"; +import { findChipRangeById } from "../tiptap/chipRange"; import { getEditorExtensions } from "../tiptap/extensions"; import { type DraftContext, @@ -69,6 +73,11 @@ export interface UseTiptapEditorOptions { const EDITOR_CLASS = "cli-editor min-h-[1.5em] w-full break-words border-none bg-transparent pr-2 text-[14px] text-[var(--gray-12)] outline-none [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]"; +interface TrackedAutoConvertedPaste extends AutoConvertedPaste { + kind: "file" | "github-ref"; + status: "pending" | "inserted" | "canceled"; +} + function insertChipWithTrailingSpace( view: EditorView, attrs: { @@ -76,6 +85,7 @@ function insertChipWithTrailingSpace( id: string; label: string; pastedText?: boolean; + chipId?: string; }, ): void { const chipNode = view.state.schema.nodes.mentionChip.create({ @@ -92,8 +102,10 @@ async function pasteTextAsFile( view: EditorView, text: string, pasteCountRef: React.MutableRefObject, + tracked?: TrackedAutoConvertedPaste, ): Promise { const result = await persistTextContent(text); + if (tracked?.status === "canceled") return; pasteCountRef.current += 1; const lineCount = text.split("\n").length; insertChipWithTrailingSpace(view, { @@ -101,15 +113,38 @@ async function pasteTextAsFile( id: result.path, label: buildPastedTextLabel(pasteCountRef.current, lineCount), pastedText: true, + chipId: tracked?.chipId, }); + if (tracked) tracked.status = "inserted"; view.focus(); } function insertGithubRefPlaceholder( view: EditorView, parsed: ParsedGithubIssueUrl, + chipId: string, ): void { - insertChipWithTrailingSpace(view, buildGithubRefPlaceholderChip(parsed)); + insertChipWithTrailingSpace(view, { + ...buildGithubRefPlaceholderChip(parsed), + chipId, + }); +} + +function replaceChipWithText( + view: EditorView, + chipId: string, + text: string, +): boolean { + const { doc, selection } = view.state; + const range = findChipRangeById(doc, chipId); + if (!range) return false; + // Only treat it as a double paste while the caret still follows the chip. + if (selection.from !== range.to && selection.from !== range.to - 1) { + return false; + } + view.dispatch(view.state.tr.insertText(text, range.from, range.to)); + view.focus(); + return true; } async function fetchGithubRefTitle( @@ -135,35 +170,25 @@ async function fetchGithubRefTitle( async function resolveGithubRefChip( view: EditorView, parsed: ParsedGithubIssueUrl, + chipId: string, ): Promise { - const chipType = parsed.kind === "pr" ? "github_pr" : "github_issue"; - const placeholderLabel = `#${parsed.number} - Loading...`; const title = await fetchGithubRefTitle(parsed); const resolvedLabel = title !== null ? `#${parsed.number} - ${title}` : `#${parsed.number}`; if (view.isDestroyed) return; - const { doc, tr } = view.state; - let updated = false; - doc.descendants((node, pos) => { - if ( - node.type.name !== "mentionChip" || - node.attrs.type !== chipType || - node.attrs.id !== parsed.normalizedUrl || - node.attrs.label !== placeholderLabel - ) { - return true; - } - tr.setNodeMarkup(pos, undefined, { + const { doc } = view.state; + const range = findChipRangeById(doc, chipId); + if (!range) return; + const node = doc.nodeAt(range.from); + if (!node) return; + view.dispatch( + view.state.tr.setNodeMarkup(range.from, undefined, { ...node.attrs, label: resolvedLabel, - }); - updated = true; - return false; - }); - - if (updated) view.dispatch(tr); + }), + ); } function showPasteHint(message: string, description: string): void { @@ -172,7 +197,10 @@ function showPasteHint(message: string, description: string): void { message === "Pasted as file attachment" ? "paste-as-file" : "paste-inline"; if (!store.shouldShowHint(key)) return; store.recordHintShown(key); - toast.info(message, description); + toast.info(message, { + description, + action: { label: "Got it", onClick: () => store.markHintLearned(key) }, + }); } export function useTiptapEditor(options: UseTiptapEditorOptions) { @@ -234,6 +262,16 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const draftRef = useRef | null>(null); const pasteCountRef = useRef(0); + const lastAutoConvertedPasteRef = useRef( + null, + ); + useEffect(() => { + return () => { + if (lastAutoConvertedPasteRef.current) { + usePasteUndoStore.getState().setUndoableChipId(null); + } + }; + }, []); const historyActions = usePromptHistoryStore.getState(); const [isEmptyState, setIsEmptyState] = useState(true); const [isReady, setIsReady] = useState(false); @@ -376,6 +414,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const clipboardText = event.clipboardData?.getData("text/plain"); const trimmedClipboardText = clipboardText?.trim(); + // Only the immediately-following paste can undo an auto-conversion. + const lastConverted = lastAutoConvertedPasteRef.current; + lastAutoConvertedPasteRef.current = null; + if (lastConverted) { + usePasteUndoStore.getState().setUndoableChipId(null); + } + // Auto-wrap selected text as markdown link when pasting a URL if ( from !== to && @@ -398,13 +443,52 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } + // Pasting the same clipboard again undoes the chip auto-conversion + if ( + from === to && + isRepeatOfAutoConvertedPaste(lastConverted, clipboardText) + ) { + if ( + replaceChipWithText( + view, + lastConverted.chipId, + lastConverted.insertText, + ) + ) { + event.preventDefault(); + if (lastConverted.kind === "file") { + useFeatureSettingsStore + .getState() + .markHintLearned("paste-as-file"); + } + return true; + } + if (lastConverted.status === "pending") { + event.preventDefault(); + lastConverted.status = "canceled"; + useFeatureSettingsStore + .getState() + .markHintLearned("paste-as-file"); + view.dispatch(view.state.tr.insertText(lastConverted.insertText)); + return true; + } + } + // Auto-convert a pasted GitHub issue or PR URL into a chip - if (from === to && trimmedClipboardText) { + if (from === to && clipboardText && trimmedClipboardText) { const parsedRef = parseGithubIssueUrl(trimmedClipboardText); if (parsedRef) { event.preventDefault(); - insertGithubRefPlaceholder(view, parsedRef); - void resolveGithubRefChip(view, parsedRef); + const chipId = crypto.randomUUID(); + insertGithubRefPlaceholder(view, parsedRef, chipId); + lastAutoConvertedPasteRef.current = { + clipboardText, + insertText: clipboardText, + chipId, + kind: "github-ref", + status: "inserted", + }; + void resolveGithubRefChip(view, parsedRef, chipId); return true; } } @@ -458,15 +542,34 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { ) { event.preventDefault(); + const tracked: TrackedAutoConvertedPaste = { + clipboardText: clipboardText || effectiveText, + insertText: effectiveText, + chipId: crypto.randomUUID(), + kind: "file", + status: "pending", + }; + lastAutoConvertedPasteRef.current = tracked; + usePasteUndoStore.getState().setUndoableChipId(tracked.chipId); + (async () => { try { - await pasteTextAsFile(view, effectiveText, pasteCountRef); - showPasteHint( - "Pasted as file attachment", - "Click the chip to convert back to text.", + await pasteTextAsFile( + view, + effectiveText, + pasteCountRef, + tracked, ); + if (tracked.status !== "canceled") { + showPasteHint( + "Pasted as file attachment", + "Paste again to expand as text.", + ); + } } catch (_error) { - toast.error("Failed to convert pasted text to attachment"); + if (tracked.status !== "canceled") { + toast.error("Failed to convert pasted text to attachment"); + } } })();