Skip to content

Commit c9ad48b

Browse files
authored
feat(message-editor): double paste to undo chip auto-conversion (#3454)
1 parent 52317ca commit c9ad48b

9 files changed

Lines changed: 349 additions & 140 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import { type AutoConvertedPaste, isRepeatOfAutoConvertedPaste } from "./paste";
3+
4+
const lastPaste: AutoConvertedPaste = {
5+
clipboardText: "https://github.com/posthog/code/issues/42",
6+
insertText: "https://github.com/posthog/code/issues/42",
7+
chipId: "chip-1",
8+
};
9+
10+
describe("isRepeatOfAutoConvertedPaste", () => {
11+
it.each([
12+
{
13+
name: "same clipboard text as the last conversion",
14+
last: lastPaste,
15+
clipboardText: lastPaste.clipboardText,
16+
expected: true,
17+
},
18+
{
19+
name: "no prior conversion",
20+
last: null,
21+
clipboardText: lastPaste.clipboardText,
22+
expected: false,
23+
},
24+
{
25+
name: "different clipboard text",
26+
last: lastPaste,
27+
clipboardText: "something else",
28+
expected: false,
29+
},
30+
{
31+
name: "clipboard text differing only by whitespace",
32+
last: lastPaste,
33+
clipboardText: `${lastPaste.clipboardText} `,
34+
expected: false,
35+
},
36+
{
37+
name: "empty clipboard text",
38+
last: lastPaste,
39+
clipboardText: "",
40+
expected: false,
41+
},
42+
{
43+
name: "undefined clipboard text",
44+
last: lastPaste,
45+
clipboardText: undefined,
46+
expected: false,
47+
},
48+
])("returns $expected for $name", ({ last, clipboardText, expected }) => {
49+
expect(isRepeatOfAutoConvertedPaste(last, clipboardText)).toBe(expected);
50+
});
51+
});

packages/core/src/message-editor/paste.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,18 @@ export function buildPastedTextLabel(
2929
): string {
3030
return `Pasted text #${pasteNumber} (${lineCount} lines)`;
3131
}
32+
33+
export interface AutoConvertedPaste {
34+
clipboardText: string;
35+
insertText: string;
36+
chipId: string;
37+
}
38+
39+
export function isRepeatOfAutoConvertedPaste(
40+
last: AutoConvertedPaste | null,
41+
clipboardText: string | null | undefined,
42+
): last is AutoConvertedPaste {
43+
return (
44+
last !== null && !!clipboardText && clipboardText === last.clipboardText
45+
);
46+
}

packages/ui/src/features/message-editor/hostApi.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,6 @@ export function getGhStatus(): Promise<GhStatus> {
7272
return hostClient().git.getGhStatus.query();
7373
}
7474

75-
export function readAbsoluteFile(input: {
76-
filePath: string;
77-
}): Promise<string | null> {
78-
return hostClient().fs.readAbsoluteFile.query(input);
79-
}
80-
8175
export function selectDirectory(): Promise<string | null> {
8276
return hostClient().os.selectDirectory.query();
8377
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { create } from "zustand";
2+
3+
interface PasteUndoState {
4+
undoableChipId: string | null;
5+
setUndoableChipId: (chipId: string | null) => void;
6+
}
7+
8+
export const usePasteUndoStore = create<PasteUndoState>((set) => ({
9+
undoableChipId: null,
10+
setUndoableChipId: (chipId) => set({ undoableChipId: chipId }),
11+
}));

packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { UploadableSkillSource } from "@posthog/shared";
22
import { mergeAttributes, Node } from "@tiptap/core";
33
import { ReactNodeViewRenderer } from "@tiptap/react";
4+
import { findChipRangeById } from "./chipRange";
45
import { MentionChipView } from "./MentionChipView";
56

67
export type ChipType =
@@ -103,39 +104,22 @@ export const MentionChipNode = Node.create({
103104
replaceMentionChipById:
104105
(chipId: string, attrs: Partial<MentionChipAttrs>) =>
105106
({ tr, state, dispatch }) => {
106-
let found = false;
107-
state.doc.descendants((node, pos) => {
108-
if (found) return false;
109-
if (node.type.name !== "mentionChip") return;
110-
if (node.attrs.chipId !== chipId) return;
111-
found = true;
112-
tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs });
113-
return false;
114-
});
115-
if (found && dispatch) dispatch(tr);
116-
return found;
107+
const range = findChipRangeById(state.doc, chipId);
108+
if (!range) return false;
109+
const node = state.doc.nodeAt(range.from);
110+
if (!node) return false;
111+
tr.setNodeMarkup(range.from, undefined, { ...node.attrs, ...attrs });
112+
if (dispatch) dispatch(tr);
113+
return true;
117114
},
118115
removeMentionChipById:
119116
(chipId: string) =>
120117
({ tr, state, dispatch }) => {
121-
let found = false;
122-
state.doc.descendants((node, pos) => {
123-
if (found) return false;
124-
if (node.type.name !== "mentionChip") return;
125-
if (node.attrs.chipId !== chipId) return;
126-
found = true;
127-
const from = pos;
128-
const to = pos + node.nodeSize;
129-
// Also swallow a trailing single space the suggestion adds.
130-
const after = state.doc.textBetween(
131-
to,
132-
Math.min(to + 1, state.doc.content.size),
133-
);
134-
tr.delete(from, after === " " ? to + 1 : to);
135-
return false;
136-
});
137-
if (found && dispatch) dispatch(tr);
138-
return found;
118+
const range = findChipRangeById(state.doc, chipId);
119+
if (!range) return false;
120+
tr.delete(range.from, range.to);
121+
if (dispatch) dispatch(tr);
122+
return true;
139123
},
140124
};
141125
},

packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx

Lines changed: 24 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,9 @@ import {
1111
XIcon,
1212
} from "@phosphor-icons/react";
1313
import { Chip } from "@posthog/quill";
14-
import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore";
1514
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
16-
import type { Node as PmNode } from "@tiptap/pm/model";
17-
import type { Editor } from "@tiptap/react";
1815
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
19-
import { readAbsoluteFile } from "../hostApi";
16+
import { usePasteUndoStore } from "../pasteUndoStore";
2017
import type { ChipType, MentionChipAttrs } from "./MentionChipNode";
2118

2219
const chipBase = "group/chip relative top-px active:translate-y-0 pl-1";
@@ -68,15 +65,22 @@ function DefaultChip({
6865
type,
6966
id,
7067
label,
68+
chipId,
69+
pastedText,
7170
selected,
7271
onRemove,
7372
}: {
7473
type: string;
7574
id: string;
7675
label: string;
76+
chipId: string | null;
77+
pastedText: boolean;
7778
selected: boolean;
7879
onRemove: () => void;
7980
}) {
81+
const undoableChipId = usePasteUndoStore((state) => state.undoableChipId);
82+
const canUndoPaste =
83+
pastedText && chipId !== null && chipId === undoableChipId;
8084
const isCommand = type === "command";
8185
const prefix = isCommand ? "/" : "@";
8286
const isFile = type === "file";
@@ -101,69 +105,24 @@ function DefaultChip({
101105
);
102106

103107
if (isFile || isFolder) {
104-
return <Tooltip content={id}>{chipContent}</Tooltip>;
108+
return (
109+
<Tooltip content={canUndoPaste ? "Paste again to expand as text" : id}>
110+
{chipContent}
111+
</Tooltip>
112+
);
105113
}
106114

107115
return chipContent;
108116
}
109117

110-
function PastedTextChip({
111-
label,
112-
filePath,
113-
editor,
114-
node,
115-
getPos,
116-
selected,
117-
onRemove,
118-
}: {
119-
label: string;
120-
filePath: string;
121-
editor: Editor;
122-
node: PmNode;
123-
getPos: () => number | undefined;
124-
selected: boolean;
125-
onRemove: () => void;
126-
}) {
127-
const handleClick = async () => {
128-
useFeatureSettingsStore.getState().markHintLearned("paste-as-file");
129-
130-
const content = await readAbsoluteFile({
131-
filePath,
132-
});
133-
if (!content) return;
134-
135-
const pos = getPos();
136-
if (pos == null) return;
137-
138-
editor
139-
.chain()
140-
.focus()
141-
.deleteRange({ from: pos, to: pos + node.nodeSize })
142-
.insertContentAt(pos, content)
143-
.run();
144-
};
145-
146-
return (
147-
<Tooltip content="Click to paste as text instead">
148-
<Chip
149-
size="xs"
150-
contentEditable={false}
151-
onClick={handleClick}
152-
className={`${chipBase} cli-file-mention cursor-pointer! ${selected ? selectedRing : ""}`}
153-
>
154-
<IconCloseButton type="file" onRemove={onRemove} />@{label}
155-
</Chip>
156-
</Tooltip>
157-
);
158-
}
159-
160118
export function MentionChipView({
161119
node,
162120
getPos,
163121
editor,
164122
selected,
165123
}: NodeViewProps) {
166-
const { type, id, label, pastedText } = node.attrs as MentionChipAttrs;
124+
const { type, id, label, pastedText, chipId } =
125+
node.attrs as MentionChipAttrs;
167126

168127
const handleRemove = () => {
169128
const pos = getPos();
@@ -177,25 +136,15 @@ export function MentionChipView({
177136

178137
return (
179138
<NodeViewWrapper as="span" className="inline">
180-
{pastedText ? (
181-
<PastedTextChip
182-
label={label}
183-
filePath={id}
184-
editor={editor}
185-
node={node}
186-
getPos={getPos}
187-
selected={selected}
188-
onRemove={handleRemove}
189-
/>
190-
) : (
191-
<DefaultChip
192-
type={type}
193-
id={id}
194-
label={label}
195-
selected={selected}
196-
onRemove={handleRemove}
197-
/>
198-
)}
139+
<DefaultChip
140+
type={type}
141+
id={id}
142+
label={label}
143+
chipId={chipId ?? null}
144+
pastedText={pastedText}
145+
selected={selected}
146+
onRemove={handleRemove}
147+
/>
199148
</NodeViewWrapper>
200149
);
201150
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { getSchema } from "@tiptap/core";
2+
import { Node as PmNode } from "@tiptap/pm/model";
3+
import StarterKit from "@tiptap/starter-kit";
4+
import { describe, expect, it } from "vitest";
5+
import { findChipRangeById } from "./chipRange";
6+
import { MentionChipNode } from "./MentionChipNode";
7+
8+
const schema = getSchema([StarterKit, MentionChipNode]);
9+
10+
function chip(chipId: string | null) {
11+
return {
12+
type: "mentionChip",
13+
attrs: {
14+
type: "file",
15+
id: "/tmp/pasted.txt",
16+
label: "Pasted text #1 (2 lines)",
17+
pastedText: true,
18+
chipId,
19+
},
20+
};
21+
}
22+
23+
function text(value: string) {
24+
return { type: "text", text: value };
25+
}
26+
27+
function docOf(...content: object[]): PmNode {
28+
return PmNode.fromJSON(schema, {
29+
type: "doc",
30+
content: [{ type: "paragraph", content }],
31+
});
32+
}
33+
34+
describe("findChipRangeById", () => {
35+
it.each([
36+
{
37+
name: "chip followed by a trailing space swallows the space",
38+
doc: docOf(chip("a"), text(" tail")),
39+
chipId: "a",
40+
expected: { from: 1, to: 3 },
41+
},
42+
{
43+
name: "chip at the end of the doc",
44+
doc: docOf(text("hi "), chip("a")),
45+
chipId: "a",
46+
expected: { from: 4, to: 5 },
47+
},
48+
{
49+
name: "chip followed by non-space text",
50+
doc: docOf(chip("a"), text("x")),
51+
chipId: "a",
52+
expected: { from: 1, to: 2 },
53+
},
54+
{
55+
name: "matching chip among several",
56+
doc: docOf(chip("a"), text(" "), chip("b"), text(" ")),
57+
chipId: "b",
58+
expected: { from: 3, to: 5 },
59+
},
60+
{
61+
name: "no chip with the requested id",
62+
doc: docOf(chip("a"), text(" ")),
63+
chipId: "missing",
64+
expected: null,
65+
},
66+
{
67+
name: "chip without a chipId attribute",
68+
doc: docOf(chip(null), text(" ")),
69+
chipId: "a",
70+
expected: null,
71+
},
72+
])("$name", ({ doc, chipId, expected }) => {
73+
expect(findChipRangeById(doc, chipId)).toEqual(expected);
74+
});
75+
});

0 commit comments

Comments
 (0)