| author | |
| committer | |
| log | 4cfec8c128084a0abde67853d10e996f5b2aac05 |
| tree | 073bab4704348888185841bd3ea33aeba1455ec1 |
| parent | 1bd2fa25e2214e0318a8a0d4cd4c4c513ddd800e |
| signature | Signed by PGP key B5690EEEBB952194 |
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>5 files changed, 227 insertions(+), 9 deletions(-)
apps/desktop/src/electron/ElectronMenu.test.ts+12-2| ... | ... | @@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe( |
| 31 | 31 | Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), |
| 32 | 32 | ); |
| 33 | 33 | |
| 34 | const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => | |
| 35 | ({ | |
| 36 | id: 7, | |
| 37 | webContents: { getZoomFactor: () => zoomFactor }, | |
| 38 | }) as unknown as Electron.BrowserWindow; | |
| 39 | ||
| 34 | 40 | describe("ElectronMenu", () => { |
| 35 | 41 | beforeEach(() => { |
| 36 | 42 | buildFromTemplateMock.mockReset(); |
| ... | ... | @@ -70,7 +76,7 @@ describe("ElectronMenu", () => { |
| 70 | 76 | |
| 71 | 77 | const electronMenu = yield* ElectronMenu.ElectronMenu; |
| 72 | 78 | const selectedItemId = yield* electronMenu.showContextMenu({ |
| 73 | window: {} as Electron.BrowserWindow, | |
| 79 | window: makeWindow(), | |
| 74 | 80 | items: [{ id: "copy", label: "Copy" }], |
| 75 | 81 | position: Option.none(), |
| 76 | 82 | }); |
| ... | ... | @@ -81,20 +87,24 @@ describe("ElectronMenu", () => { |
| 81 | 87 | |
| 82 | 88 | it.effect("resolves with none when the menu closes without a click", () => |
| 83 | 89 | Effect.gen(function* () { |
| 90 | let popupOptions: Electron.PopupOptions | undefined; | |
| 84 | 91 | buildFromTemplateMock.mockImplementation(() => ({ |
| 85 | 92 | popup: (options: Electron.PopupOptions) => { |
| 93 | popupOptions = options; | |
| 86 | 94 | options.callback?.(); |
| 87 | 95 | }, |
| 88 | 96 | })); |
| 89 | 97 | |
| 90 | 98 | const electronMenu = yield* ElectronMenu.ElectronMenu; |
| 91 | 99 | const selectedItemId = yield* electronMenu.showContextMenu({ |
| 92 | window: {} as Electron.BrowserWindow, | |
| 100 | window: makeWindow(2), | |
| 93 | 101 | items: [{ id: "copy", label: "Copy" }], |
| 94 | 102 | position: Option.some({ x: 10.8, y: 20.2 }), |
| 95 | 103 | }); |
| 96 | 104 | |
| 97 | 105 | assert.isTrue(Option.isNone(selectedItemId)); |
| 106 | assert.equal(popupOptions?.x, 21); | |
| 107 | assert.equal(popupOptions?.y, 40); | |
| 98 | 108 | assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { |
| 99 | 109 | label: "Copy", |
| 100 | 110 | enabled: true, |
apps/desktop/src/electron/ElectronMenu.ts+13-3| ... | ... | @@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM |
| 94 | 94 | return normalizedItems; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | // Renderer positions arrive in CSS pixels; popup() expects window points, so | |
| 98 | // page zoom must be factored in or menus drift proportionally to their | |
| 99 | // distance from the window origin. | |
| 97 | 100 | const normalizePosition = ( |
| 98 | 101 | position: Option.Option<ElectronMenuPosition>, |
| 102 | zoomFactor: number, | |
| 99 | 103 | ): Option.Option<ElectronMenuPosition> => |
| 100 | 104 | Option.filter( |
| 101 | 105 | position, |
| 102 | ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, | |
| 103 | ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); | |
| 106 | ({ x, y }) => | |
| 107 | Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor), | |
| 108 | ).pipe( | |
| 109 | Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), | |
| 110 | ); | |
| 104 | 111 | |
| 105 | 112 | export const make = Effect.gen(function* () { |
| 106 | 113 | const platform = yield* HostProcessPlatform; |
| ... | ... | @@ -214,7 +221,10 @@ export const make = Effect.gen(function* () { |
| 214 | 221 | |
| 215 | 222 | try { |
| 216 | 223 | const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); |
| 217 | const popupPosition = normalizePosition(input.position); | |
| 224 | const popupPosition = normalizePosition( | |
| 225 | input.position, | |
| 226 | input.window.webContents.getZoomFactor(), | |
| 227 | ); | |
| 218 | 228 | const popupOptions = Option.match(popupPosition, { |
| 219 | 229 | onNone: (): Electron.PopupOptions => ({ |
| 220 | 230 | window: input.window, |
apps/web/src/components/ComposerPromptEditor.tsx+96| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | KEY_TAB_COMMAND, |
| 28 | 28 | COMMAND_PRIORITY_HIGH, |
| 29 | 29 | KEY_BACKSPACE_COMMAND, |
| 30 | PASTE_COMMAND, | |
| 30 | 31 | $getRoot, |
| 31 | 32 | HISTORY_MERGE_TAG, |
| 32 | 33 | DecoratorNode, |
| ... | ... | @@ -55,6 +56,7 @@ import { |
| 55 | 56 | expandCollapsedComposerCursor, |
| 56 | 57 | isCollapsedCursorAdjacentToInlineToken, |
| 57 | 58 | } from "~/composer-logic"; |
| 59 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | |
| 58 | 60 | import { |
| 59 | 61 | selectionTouchesMentionBoundary, |
| 60 | 62 | splitPromptIntoComposerSegments, |
| ... | ... | @@ -1117,6 +1119,99 @@ function ComposerInlineTokenBackspacePlugin() { |
| 1117 | 1119 | return null; |
| 1118 | 1120 | } |
| 1119 | 1121 | |
| 1122 | function ComposerInlineTokenPastePlugin() { | |
| 1123 | const [editor] = useLexicalComposerContext(); | |
| 1124 | ||
| 1125 | useEffect(() => { | |
| 1126 | return editor.registerCommand( | |
| 1127 | PASTE_COMMAND, | |
| 1128 | (event) => { | |
| 1129 | if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { | |
| 1130 | return false; | |
| 1131 | } | |
| 1132 | if (event.clipboardData.files.length > 0) { | |
| 1133 | return false; | |
| 1134 | } | |
| 1135 | const text = event.clipboardData.getData("text/plain"); | |
| 1136 | if (text.length === 0) { | |
| 1137 | return false; | |
| 1138 | } | |
| 1139 | // Token grammar requires trailing whitespace; a virtual newline lets a | |
| 1140 | // mention at the very end of the pasted text still parse. | |
| 1141 | const mentions = collectComposerInlineTokens(`${text}\n`).filter( | |
| 1142 | (token) => token.type === "mention" && token.end <= text.length, | |
| 1143 | ); | |
| 1144 | if (mentions.length === 0) { | |
| 1145 | return false; | |
| 1146 | } | |
| 1147 | let inserted = false; | |
| 1148 | editor.update(() => { | |
| 1149 | const selection = $getSelection(); | |
| 1150 | if (!$isRangeSelection(selection)) { | |
| 1151 | return; | |
| 1152 | } | |
| 1153 | const nodes: LexicalNode[] = []; | |
| 1154 | const appendText = (value: string) => { | |
| 1155 | const lines = value.split("\n"); | |
| 1156 | for (let index = 0; index < lines.length; index += 1) { | |
| 1157 | const line = lines[index] ?? ""; | |
| 1158 | if (line.length > 0) { | |
| 1159 | nodes.push($createTextNode(line)); | |
| 1160 | } | |
| 1161 | if (index < lines.length - 1) { | |
| 1162 | nodes.push($createLineBreakNode()); | |
| 1163 | } | |
| 1164 | } | |
| 1165 | }; | |
| 1166 | const firstMention = mentions[0]; | |
| 1167 | if (firstMention && firstMention.start === 0) { | |
| 1168 | const startPoint = selection.isBackward() ? selection.focus : selection.anchor; | |
| 1169 | const insertionOffset = getExpandedAbsoluteOffsetForPoint( | |
| 1170 | startPoint.getNode(), | |
| 1171 | startPoint.offset, | |
| 1172 | ); | |
| 1173 | const precedingChar = $getRoot() | |
| 1174 | .getTextContent() | |
| 1175 | .slice(insertionOffset - 1, insertionOffset); | |
| 1176 | if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { | |
| 1177 | nodes.push($createTextNode(" ")); | |
| 1178 | } | |
| 1179 | } | |
| 1180 | let cursor = 0; | |
| 1181 | for (const mention of mentions) { | |
| 1182 | if (mention.start < cursor) { | |
| 1183 | continue; | |
| 1184 | } | |
| 1185 | if (mention.start > cursor) { | |
| 1186 | appendText(text.slice(cursor, mention.start)); | |
| 1187 | } | |
| 1188 | nodes.push($createComposerMentionNode(mention.value)); | |
| 1189 | cursor = mention.end; | |
| 1190 | } | |
| 1191 | if (cursor < text.length) { | |
| 1192 | appendText(text.slice(cursor)); | |
| 1193 | } else { | |
| 1194 | // Keep the serialized prompt valid: mention tokens need trailing | |
| 1195 | // whitespace, so a paste ending in a mention gets the same | |
| 1196 | // trailing space the autocomplete inserts. | |
| 1197 | nodes.push($createTextNode(" ")); | |
| 1198 | } | |
| 1199 | selection.insertNodes(nodes); | |
| 1200 | inserted = true; | |
| 1201 | }); | |
| 1202 | if (!inserted) { | |
| 1203 | return false; | |
| 1204 | } | |
| 1205 | event.preventDefault(); | |
| 1206 | return true; | |
| 1207 | }, | |
| 1208 | COMMAND_PRIORITY_HIGH, | |
| 1209 | ); | |
| 1210 | }, [editor]); | |
| 1211 | ||
| 1212 | return null; | |
| 1213 | } | |
| 1214 | ||
| 1120 | 1215 | function ComposerSurroundSelectionPlugin(props: { |
| 1121 | 1216 | terminalContexts: ReadonlyArray<TerminalContextDraft>; |
| 1122 | 1217 | skills: ReadonlyArray<ServerProviderSkill>; |
| ... | ... | @@ -1634,6 +1729,7 @@ function ComposerPromptEditorInner({ |
| 1634 | 1729 | <ComposerInlineTokenArrowPlugin /> |
| 1635 | 1730 | <ComposerInlineTokenSelectionNormalizePlugin /> |
| 1636 | 1731 | <ComposerInlineTokenBackspacePlugin /> |
| 1732 | <ComposerInlineTokenPastePlugin /> | |
| 1637 | 1733 | <HistoryPlugin /> |
| 1638 | 1734 | </div> |
| 1639 | 1735 | </ComposerTerminalContextActionsContext> |
apps/web/src/components/chat/ChatComposer.tsx+10-4| ... | ... | @@ -390,7 +390,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( |
| 390 | 390 | export interface ChatComposerHandle { |
| 391 | 391 | focusAtEnd: () => void; |
| 392 | 392 | focusAt: (cursor: number) => void; |
| 393 | insertTextAtEnd: (text: string) => boolean; | |
| 393 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; | |
| 394 | 394 | openModelPicker: () => void; |
| 395 | 395 | toggleModelPicker: () => void; |
| 396 | 396 | isModelPickerOpen: () => boolean; |
| ... | ... | @@ -1931,7 +1931,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1931 | 1931 | focusAt: (cursor: number) => { |
| 1932 | 1932 | composerEditorRef.current?.focusAt(cursor); |
| 1933 | 1933 | }, |
| 1934 | insertTextAtEnd: (text: string) => { | |
| 1934 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => { | |
| 1935 | 1935 | if ( |
| 1936 | 1936 | text.length === 0 || |
| 1937 | 1937 | isConnecting || |
| ... | ... | @@ -1942,8 +1942,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1942 | 1942 | ) { |
| 1943 | 1943 | return false; |
| 1944 | 1944 | } |
| 1945 | const rangeEnd = promptRef.current.length; | |
| 1946 | return applyPromptReplacement(rangeEnd, rangeEnd, text); | |
| 1945 | const prompt = promptRef.current; | |
| 1946 | const needsLeadingSpace = | |
| 1947 | (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); | |
| 1948 | return applyPromptReplacement( | |
| 1949 | prompt.length, | |
| 1950 | prompt.length, | |
| 1951 | needsLeadingSpace ? ` ${text}` : text, | |
| 1952 | ); | |
| 1947 | 1953 | }, |
| 1948 | 1954 | openModelPicker: () => { |
| 1949 | 1955 | setIsComposerModelPickerOpen(true); |
apps/web/src/components/files/FileBrowserPanel.tsx+96| ... | ... | @@ -1,10 +1,19 @@ |
| 1 | import type { | |
| 2 | ContextMenuItem as TreeContextMenuItem, | |
| 3 | ContextMenuOpenContext as TreeContextMenuOpenContext, | |
| 4 | } from "@pierre/trees"; | |
| 1 | 5 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 2 | 6 | import { FileTree, useFileTree } from "@pierre/trees/react"; |
| 7 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | |
| 3 | 8 | import { RefreshCw, Search } from "lucide-react"; |
| 4 | 9 | import { useEffect, useMemo, useRef } from "react"; |
| 5 | 10 | |
| 11 | import { toastManager } from "~/components/ui/toast"; | |
| 12 | import { useComposerHandleContext } from "~/composerHandleContext"; | |
| 13 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | |
| 6 | 14 | import { useTheme } from "~/hooks/useTheme"; |
| 7 | 15 | import { cn } from "~/lib/utils"; |
| 16 | import { readLocalApi } from "~/localApi"; | |
| 8 | 17 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 9 | 18 | |
| 10 | 19 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; |
| ... | ... | @@ -39,6 +48,7 @@ export default function FileBrowserPanel({ |
| 39 | 48 | onOpenFile, |
| 40 | 49 | }: FileBrowserPanelProps) { |
| 41 | 50 | const { resolvedTheme } = useTheme(); |
| 51 | const composerRef = useComposerHandleContext(); | |
| 42 | 52 | const entriesQuery = useProjectEntriesQuery(environmentId, cwd); |
| 43 | 53 | const entries = entriesQuery.data?.entries ?? []; |
| 44 | 54 | const entryKinds = useMemo( |
| ... | ... | @@ -49,7 +59,93 @@ export default function FileBrowserPanel({ |
| 49 | 59 | const treePaths = useMemo(() => entries.map(treePath), [entries]); |
| 50 | 60 | const previousTreePathsRef = useRef<readonly string[]>([]); |
| 51 | 61 | |
| 62 | // The tree renders rows in shadow DOM and its anchor rect is unreliable, so | |
| 63 | // capture the right-click position ourselves; contextmenu is a composed | |
| 64 | // event, so a capture-phase listener sees it with viewport coordinates. | |
| 65 | const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null); | |
| 66 | useEffect(() => { | |
| 67 | const capturePointer = (event: MouseEvent) => { | |
| 68 | contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp }; | |
| 69 | }; | |
| 70 | document.addEventListener("contextmenu", capturePointer, true); | |
| 71 | return () => document.removeEventListener("contextmenu", capturePointer, true); | |
| 72 | }, []); | |
| 73 | ||
| 74 | const showEntryContextMenu = async ( | |
| 75 | item: TreeContextMenuItem, | |
| 76 | context: TreeContextMenuOpenContext, | |
| 77 | ) => { | |
| 78 | const api = readLocalApi(); | |
| 79 | if (!api) { | |
| 80 | context.close(); | |
| 81 | return; | |
| 82 | } | |
| 83 | const relativePath = item.path.replace(/\/$/, ""); | |
| 84 | const mention = serializeComposerFileLink(relativePath); | |
| 85 | const pointer = contextMenuPointerRef.current; | |
| 86 | const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000; | |
| 87 | const anchorRect = context.anchorElement.getBoundingClientRect(); | |
| 88 | const position = pointerIsFresh | |
| 89 | ? { x: pointer.x, y: pointer.y } | |
| 90 | : { x: anchorRect.left, y: anchorRect.bottom }; | |
| 91 | try { | |
| 92 | const clicked = await api.contextMenu.show( | |
| 93 | [ | |
| 94 | { id: "copy-mention", label: "Copy mention" }, | |
| 95 | { id: "add-to-chat", label: "Add to chat" }, | |
| 96 | ], | |
| 97 | position, | |
| 98 | ); | |
| 99 | if (clicked === "copy-mention") { | |
| 100 | try { | |
| 101 | await writeTextToClipboard(mention); | |
| 102 | toastManager.add({ type: "success", title: "Mention copied", description: relativePath }); | |
| 103 | } catch (error) { | |
| 104 | toastManager.add({ | |
| 105 | type: "error", | |
| 106 | title: "Failed to copy mention", | |
| 107 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 108 | }); | |
| 109 | } | |
| 110 | return; | |
| 111 | } | |
| 112 | if (clicked === "add-to-chat") { | |
| 113 | const composer = composerRef?.current; | |
| 114 | if (!composer) { | |
| 115 | toastManager.add({ | |
| 116 | type: "error", | |
| 117 | title: "Unable to add to chat", | |
| 118 | description: "Open a chat for this project and try again.", | |
| 119 | }); | |
| 120 | return; | |
| 121 | } | |
| 122 | const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true }); | |
| 123 | if (!inserted) { | |
| 124 | toastManager.add({ | |
| 125 | type: "error", | |
| 126 | title: "Unable to add to chat", | |
| 127 | description: "The chat isn't ready to accept input right now.", | |
| 128 | }); | |
| 129 | } | |
| 130 | } | |
| 131 | } finally { | |
| 132 | context.close(); | |
| 133 | } | |
| 134 | }; | |
| 135 | const showEntryContextMenuRef = useRef(showEntryContextMenu); | |
| 136 | useEffect(() => { | |
| 137 | showEntryContextMenuRef.current = showEntryContextMenu; | |
| 138 | }); | |
| 139 | ||
| 52 | 140 | const { model } = useFileTree({ |
| 141 | composition: { | |
| 142 | contextMenu: { | |
| 143 | triggerMode: "right-click", | |
| 144 | onOpen: (item, context) => { | |
| 145 | void showEntryContextMenuRef.current(item, context); | |
| 146 | }, | |
| 147 | }, | |
| 148 | }, | |
| 53 | 149 | density: "compact", |
| 54 | 150 | fileTreeSearchMode: "hide-non-matches", |
| 55 | 151 | flattenEmptyDirectories: true, |