Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => {
"<mention:.changeset/improve-deploy-error-logging.md> ",
);
});

it.each([
"yarn expo install @expo/ui",
"npm install @jane/foo.js",
"import '@scope/pkg/sub/path'",
])("leaves scoped package command %s to the plain-text paste fallback", (command) => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createEditor();
const plainTextFallback = vi.fn((event: ClipboardEvent) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) return false;
selection.insertText(event.clipboardData?.getData("text/plain") ?? "");
return true;
});

editor.update(
() => {
const paragraph = $createParagraphNode();
$getRoot().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
registerComposerInlineTokenPaste(editor, {
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
getExpandedAbsoluteOffsetForPoint: () => 0,
});
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);

const event = new TestClipboardEvent(command);
let handled = false;
editor.update(
() => {
handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
},
{ discrete: true },
);

expect(handled).toBe(true);
expect(plainTextFallback).toHaveBeenCalledOnce();
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command);
});

it("pastes a canonical scoped folder link as a mention", () => {
vi.stubGlobal("ClipboardEvent", TestClipboardEvent);
const editor = createEditor();
const mention = "[sub](@scope/pkg/sub)";
const plainTextFallback = vi.fn(() => true);

editor.update(
() => {
const paragraph = $createParagraphNode();
$getRoot().append(paragraph);
paragraph.selectEnd();
},
{ discrete: true },
);
registerComposerInlineTokenPaste(editor, {
createMentionNode: (path) => $createTextNode(`<mention:${path}>`),
getExpandedAbsoluteOffsetForPoint: () => 0,
});
editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR);

const event = new TestClipboardEvent(mention);
let handled = false;
editor.update(
() => {
handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent);
},
{ discrete: true },
);

expect(handled).toBe(true);
expect(plainTextFallback).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(
"<mention:@scope/pkg/sub> ",
);
});
});
29 changes: 27 additions & 2 deletions apps/web/src/composer-editor-mentions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => {
});

it("keeps newlines around mention tokens", () => {
expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([
expect(splitPromptIntoComposerSegments("one\n@AGENTS.md \ntwo")).toEqual([
{ type: "text", text: "one\n" },
{ type: "mention", path: "src/index.ts", source: "@src/index.ts" },
{ type: "mention", path: "AGENTS.md", source: "@AGENTS.md" },
{ type: "text", text: " \ntwo" },
]);
});
Expand Down Expand Up @@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => {
).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]);
});

it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])(
"does not turn scoped package reference %s into file mention segments",
(reference) => {
const prompt = `Install ${reference} next`;
expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
},
);

it("keeps IME-composed text containing a scoped package reference as text", () => {
const prompt = "入力 @expo/ui を追加";
expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]);
});

it("turns canonical scoped folder links into file mention segments", () => {
expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([
{ type: "text", text: "Inspect " },
{
type: "mention",
path: "@scope/pkg/sub",
source: "[sub](@scope/pkg/sub)",
},
{ type: "text", text: " next" },
]);
});

it("decodes reserved path characters from generated links", () => {
expect(
splitPromptIntoComposerSegments(
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => {
);
});

it("keeps replacement cursors aligned when another mention already exists earlier", () => {
it("keeps package-like text expanded when another mention already exists earlier", () => {
const text = "open @AGENTS.md then @src/index.ts ";
const expandedCursor = text.length;
const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);

expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2);
expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length);
expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
});

it("collapses only genuine mentions when package-like text exists earlier", () => {
const text = "install @scope/pkg then @README.md ";
const expandedCursor = text.length;
const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);

expect(collapsedCursor).toBe("install @scope/pkg then ".length + 1 + " ".length);
expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
});

Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/composerInlineTokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => {
it("ignores normal web links", () => {
expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]);
});

it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])(
"keeps scoped package reference %s as plain text",
(reference) => {
expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]);
},
);

it("keeps scoped package references plain across incomplete input and IME whitespace", () => {
expect(collectComposerInlineTokens("Install @expo/ui")).toEqual([]);
expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]);
});

it("keeps bare non-scoped file paths as mentions", () => {
expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([
{
type: "mention",
value: "README.md",
source: "@README.md",
start: 8,
end: 18,
},
]);
});

it("keeps canonical file links for scoped paths as mentions", () => {
expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([
{
type: "mention",
value: "@scope/pkg/sub",
source: "[sub](@scope/pkg/sub)",
start: 8,
end: 29,
},
]);
});

it("allows ambiguous scoped paths through explicit quoted mentions", () => {
expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([
{
type: "mention",
value: "expo/ui",
source: '@"expo/ui"',
start: 8,
end: 18,
},
]);
});
});
5 changes: 4 additions & 1 deletion packages/shared/src/composerInlineTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g;
const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
// Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package.
const SCOPED_PACKAGE_REFERENCE_REGEX =
/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/;

function collectMentionTokens(text: string): ComposerInlineToken[] {
const matches: ComposerInlineToken[] = [];
Expand Down Expand Up @@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] {
const prefix = match[1] ?? "";
const quotedPath = match[2];
const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? "");
if (!path) {
if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) {
Comment thread
cursor[bot] marked this conversation as resolved.
continue;
}
const start = (match.index ?? 0) + prefix.length;
Expand Down
Loading