| author | |
| committer | |
| log | c7b21ff172f8e64318ec681e8839ee60ca660fa4 |
| tree | 574a417219253d1d7df9d2dd84147096bea1e870 |
| parent | 4e09cddb40eb1bb1e111a0374b46e73b38ffbb29 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>5 files changed, 169 insertions(+), 5 deletions(-)
apps/web/src/components/ComposerPromptEditor.test.ts+79| ... | @@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => { | ... | @@ -70,4 +70,83 @@ describe("registerComposerInlineTokenPaste", () => { |
| 70 | "<mention:.changeset/improve-deploy-error-logging.md> ", | 70 | "<mention:.changeset/improve-deploy-error-logging.md> ", |
| 71 | ); | 71 | ); |
| 72 | }); | 72 | }); |
| 73 | |||
| 74 | it.each([ | ||
| 75 | "yarn expo install @expo/ui", | ||
| 76 | "npm install @jane/foo.js", | ||
| 77 | "import '@scope/pkg/sub/path'", | ||
| 78 | ])("leaves scoped package command %s to the plain-text paste fallback", (command) => { | ||
| 79 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 80 | const editor = createEditor(); | ||
| 81 | const plainTextFallback = vi.fn((event: ClipboardEvent) => { | ||
| 82 | const selection = $getSelection(); | ||
| 83 | if (!$isRangeSelection(selection)) return false; | ||
| 84 | selection.insertText(event.clipboardData?.getData("text/plain") ?? ""); | ||
| 85 | return true; | ||
| 86 | }); | ||
| 87 | |||
| 88 | editor.update( | ||
| 89 | () => { | ||
| 90 | const paragraph = $createParagraphNode(); | ||
| 91 | $getRoot().append(paragraph); | ||
| 92 | paragraph.selectEnd(); | ||
| 93 | }, | ||
| 94 | { discrete: true }, | ||
| 95 | ); | ||
| 96 | registerComposerInlineTokenPaste(editor, { | ||
| 97 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 98 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 99 | }); | ||
| 100 | editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); | ||
| 101 | |||
| 102 | const event = new TestClipboardEvent(command); | ||
| 103 | let handled = false; | ||
| 104 | editor.update( | ||
| 105 | () => { | ||
| 106 | handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 107 | }, | ||
| 108 | { discrete: true }, | ||
| 109 | ); | ||
| 110 | |||
| 111 | expect(handled).toBe(true); | ||
| 112 | expect(plainTextFallback).toHaveBeenCalledOnce(); | ||
| 113 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe(command); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("pastes a canonical scoped folder link as a mention", () => { | ||
| 117 | vi.stubGlobal("ClipboardEvent", TestClipboardEvent); | ||
| 118 | const editor = createEditor(); | ||
| 119 | const mention = "[sub](@scope/pkg/sub)"; | ||
| 120 | const plainTextFallback = vi.fn(() => true); | ||
| 121 | |||
| 122 | editor.update( | ||
| 123 | () => { | ||
| 124 | const paragraph = $createParagraphNode(); | ||
| 125 | $getRoot().append(paragraph); | ||
| 126 | paragraph.selectEnd(); | ||
| 127 | }, | ||
| 128 | { discrete: true }, | ||
| 129 | ); | ||
| 130 | registerComposerInlineTokenPaste(editor, { | ||
| 131 | createMentionNode: (path) => $createTextNode(`<mention:${path}>`), | ||
| 132 | getExpandedAbsoluteOffsetForPoint: () => 0, | ||
| 133 | }); | ||
| 134 | editor.registerCommand(PASTE_COMMAND, plainTextFallback, COMMAND_PRIORITY_EDITOR); | ||
| 135 | |||
| 136 | const event = new TestClipboardEvent(mention); | ||
| 137 | let handled = false; | ||
| 138 | editor.update( | ||
| 139 | () => { | ||
| 140 | handled = editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); | ||
| 141 | }, | ||
| 142 | { discrete: true }, | ||
| 143 | ); | ||
| 144 | |||
| 145 | expect(handled).toBe(true); | ||
| 146 | expect(plainTextFallback).not.toHaveBeenCalled(); | ||
| 147 | expect(event.defaultPrevented).toBe(true); | ||
| 148 | expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( | ||
| 149 | "<mention:@scope/pkg/sub> ", | ||
| 150 | ); | ||
| 151 | }); | ||
| 73 | }); | 152 | }); |
apps/web/src/composer-editor-mentions.test.ts+27-2| ... | @@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => { | ... | @@ -22,9 +22,9 @@ describe("splitPromptIntoComposerSegments", () => { |
| 22 | }); | 22 | }); |
| 23 | 23 | ||
| 24 | it("keeps newlines around mention tokens", () => { | 24 | it("keeps newlines around mention tokens", () => { |
| 25 | expect(splitPromptIntoComposerSegments("one\n@src/index.ts \ntwo")).toEqual([ | 25 | expect(splitPromptIntoComposerSegments("one\n@AGENTS.md \ntwo")).toEqual([ |
| 26 | { type: "text", text: "one\n" }, | 26 | { type: "text", text: "one\n" }, |
| 27 | { type: "mention", path: "src/index.ts", source: "@src/index.ts" }, | 27 | { type: "mention", path: "AGENTS.md", source: "@AGENTS.md" }, |
| 28 | { type: "text", text: " \ntwo" }, | 28 | { type: "text", text: " \ntwo" }, |
| 29 | ]); | 29 | ]); |
| 30 | }); | 30 | }); |
| ... | @@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => { | ... | @@ -71,6 +71,31 @@ describe("splitPromptIntoComposerSegments", () => { |
| 71 | ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]); | 71 | ).toEqual([{ type: "text", text: "Read [the docs](https://example.com/docs) first" }]); |
| 72 | }); | 72 | }); |
| 73 | 73 | ||
| 74 | it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( | ||
| 75 | "does not turn scoped package reference %s into file mention segments", | ||
| 76 | (reference) => { | ||
| 77 | const prompt = `Install ${reference} next`; | ||
| 78 | expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); | ||
| 79 | }, | ||
| 80 | ); | ||
| 81 | |||
| 82 | it("keeps IME-composed text containing a scoped package reference as text", () => { | ||
| 83 | const prompt = "入力 @expo/ui を追加"; | ||
| 84 | expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); | ||
| 85 | }); | ||
| 86 | |||
| 87 | it("turns canonical scoped folder links into file mention segments", () => { | ||
| 88 | expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([ | ||
| 89 | { type: "text", text: "Inspect " }, | ||
| 90 | { | ||
| 91 | type: "mention", | ||
| 92 | path: "@scope/pkg/sub", | ||
| 93 | source: "[sub](@scope/pkg/sub)", | ||
| 94 | }, | ||
| 95 | { type: "text", text: " next" }, | ||
| 96 | ]); | ||
| 97 | }); | ||
| 98 | |||
| 74 | it("decodes reserved path characters from generated links", () => { | 99 | it("decodes reserved path characters from generated links", () => { |
| 75 | expect( | 100 | expect( |
| 76 | splitPromptIntoComposerSegments( | 101 | splitPromptIntoComposerSegments( |
apps/web/src/composer-logic.test.ts+11-2| ... | @@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => { | ... | @@ -246,12 +246,21 @@ describe("collapseExpandedComposerCursor", () => { |
| 246 | ); | 246 | ); |
| 247 | }); | 247 | }); |
| 248 | 248 | ||
| 249 | it("keeps replacement cursors aligned when another mention already exists earlier", () => { | 249 | it("keeps package-like text expanded when another mention already exists earlier", () => { |
| 250 | const text = "open @AGENTS.md then @src/index.ts "; | 250 | const text = "open @AGENTS.md then @src/index.ts "; |
| 251 | const expandedCursor = text.length; | 251 | const expandedCursor = text.length; |
| 252 | const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); | 252 | const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); |
| 253 | 253 | ||
| 254 | expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); | 254 | expect(collapsedCursor).toBe("open ".length + 1 + " then @src/index.ts ".length); |
| 255 | expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); | ||
| 256 | }); | ||
| 257 | |||
| 258 | it("collapses only genuine mentions when package-like text exists earlier", () => { | ||
| 259 | const text = "install @scope/pkg then @README.md "; | ||
| 260 | const expandedCursor = text.length; | ||
| 261 | const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); | ||
| 262 | |||
| 263 | expect(collapsedCursor).toBe("install @scope/pkg then ".length + 1 + " ".length); | ||
| 255 | expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); | 264 | expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); |
| 256 | }); | 265 | }); |
| 257 | 266 |
packages/shared/src/composerInlineTokens.test.ts+48| ... | @@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => { | ... | @@ -81,4 +81,52 @@ describe("collectComposerInlineTokens", () => { |
| 81 | it("ignores normal web links", () => { | 81 | it("ignores normal web links", () => { |
| 82 | expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); | 82 | expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); |
| 83 | }); | 83 | }); |
| 84 | |||
| 85 | it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( | ||
| 86 | "keeps scoped package reference %s as plain text", | ||
| 87 | (reference) => { | ||
| 88 | expect(collectComposerInlineTokens(`Install ${reference} next`)).toEqual([]); | ||
| 89 | }, | ||
| 90 | ); | ||
| 91 | |||
| 92 | it("keeps scoped package references plain across incomplete input and IME whitespace", () => { | ||
| 93 | expect(collectComposerInlineTokens("Install @expo/ui")).toEqual([]); | ||
| 94 | expect(collectComposerInlineTokens("入力 @expo/ui を追加")).toEqual([]); | ||
| 95 | }); | ||
| 96 | |||
| 97 | it("keeps bare non-scoped file paths as mentions", () => { | ||
| 98 | expect(collectComposerInlineTokens("Inspect @README.md next")).toEqual([ | ||
| 99 | { | ||
| 100 | type: "mention", | ||
| 101 | value: "README.md", | ||
| 102 | source: "@README.md", | ||
| 103 | start: 8, | ||
| 104 | end: 18, | ||
| 105 | }, | ||
| 106 | ]); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("keeps canonical file links for scoped paths as mentions", () => { | ||
| 110 | expect(collectComposerInlineTokens("Inspect [sub](@scope/pkg/sub) next")).toEqual([ | ||
| 111 | { | ||
| 112 | type: "mention", | ||
| 113 | value: "@scope/pkg/sub", | ||
| 114 | source: "[sub](@scope/pkg/sub)", | ||
| 115 | start: 8, | ||
| 116 | end: 29, | ||
| 117 | }, | ||
| 118 | ]); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("allows ambiguous scoped paths through explicit quoted mentions", () => { | ||
| 122 | expect(collectComposerInlineTokens('Inspect @"expo/ui" next')).toEqual([ | ||
| 123 | { | ||
| 124 | type: "mention", | ||
| 125 | value: "expo/ui", | ||
| 126 | source: '@"expo/ui"', | ||
| 127 | start: 8, | ||
| 128 | end: 18, | ||
| 129 | }, | ||
| 130 | ]); | ||
| 131 | }); | ||
| 84 | }); | 132 | }); |
packages/shared/src/composerInlineTokens.ts+4-1| ... | @@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; | ... | @@ -23,6 +23,9 @@ const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; |
| 23 | const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; | 23 | const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; |
| 24 | const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; | 24 | const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; |
| 25 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | 25 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; |
| 26 | // Autocomplete emits canonical file links, so ambiguous bare @scope/package text stays a package. | ||
| 27 | const SCOPED_PACKAGE_REFERENCE_REGEX = | ||
| 28 | /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[^\s@"]+)*$/; | ||
| 26 | 29 | ||
| 27 | function collectMentionTokens(text: string): ComposerInlineToken[] { | 30 | function collectMentionTokens(text: string): ComposerInlineToken[] { |
| 28 | const matches: ComposerInlineToken[] = []; | 31 | const matches: ComposerInlineToken[] = []; |
| ... | @@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { | ... | @@ -60,7 +63,7 @@ function collectMentionTokens(text: string): ComposerInlineToken[] { |
| 60 | const prefix = match[1] ?? ""; | 63 | const prefix = match[1] ?? ""; |
| 61 | const quotedPath = match[2]; | 64 | const quotedPath = match[2]; |
| 62 | const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); | 65 | const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); |
| 63 | if (!path) { | 66 | if (!path || (quotedPath === undefined && SCOPED_PACKAGE_REFERENCE_REGEX.test(path))) { |
| 64 | continue; | 67 | continue; |
| 65 | } | 68 | } |
| 66 | const start = (match.index ?? 0) + prefix.length; | 69 | const start = (match.index ?? 0) + prefix.length; |