authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-07-18 07:05:26-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-18 13:05:26+02:00
log4cfec8c128084a0abde67853d10e996f5b2aac05
tree073bab4704348888185841bd3ea33aeba1455ec1
parent1bd2fa25e2214e0318a8a0d4cd4c4c513ddd800e
signature Signed by PGP key B5690EEEBB952194

feat: file explorer mention actions and zoom-aware context menus (#4054)

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(
3131 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
3232);
3333
34const makeWindow = (zoomFactor = 1): Electron.BrowserWindow =>
35 ({
36 id: 7,
37 webContents: { getZoomFactor: () => zoomFactor },
38 }) as unknown as Electron.BrowserWindow;
39
3440describe("ElectronMenu", () => {
3541 beforeEach(() => {
3642 buildFromTemplateMock.mockReset();
......@@ -70,7 +76,7 @@ describe("ElectronMenu", () => {
7076
7177 const electronMenu = yield* ElectronMenu.ElectronMenu;
7278 const selectedItemId = yield* electronMenu.showContextMenu({
73 window: {} as Electron.BrowserWindow,
79 window: makeWindow(),
7480 items: [{ id: "copy", label: "Copy" }],
7581 position: Option.none(),
7682 });
......@@ -81,20 +87,24 @@ describe("ElectronMenu", () => {
8187
8288 it.effect("resolves with none when the menu closes without a click", () =>
8389 Effect.gen(function* () {
90 let popupOptions: Electron.PopupOptions | undefined;
8491 buildFromTemplateMock.mockImplementation(() => ({
8592 popup: (options: Electron.PopupOptions) => {
93 popupOptions = options;
8694 options.callback?.();
8795 },
8896 }));
8997
9098 const electronMenu = yield* ElectronMenu.ElectronMenu;
9199 const selectedItemId = yield* electronMenu.showContextMenu({
92 window: {} as Electron.BrowserWindow,
100 window: makeWindow(2),
93101 items: [{ id: "copy", label: "Copy" }],
94102 position: Option.some({ x: 10.8, y: 20.2 }),
95103 });
96104
97105 assert.isTrue(Option.isNone(selectedItemId));
106 assert.equal(popupOptions?.x, 21);
107 assert.equal(popupOptions?.y, 40);
98108 assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
99109 label: "Copy",
100110 enabled: true,
apps/desktop/src/electron/ElectronMenu.ts+13-3
......@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
9494 return normalizedItems;
9595}
9696
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.
97100const normalizePosition = (
98101 position: Option.Option<ElectronMenuPosition>,
102 zoomFactor: number,
99103): Option.Option<ElectronMenuPosition> =>
100104 Option.filter(
101105 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 );
104111
105112export const make = Effect.gen(function* () {
106113 const platform = yield* HostProcessPlatform;
......@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {
214221
215222 try {
216223 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 );
218228 const popupOptions = Option.match(popupPosition, {
219229 onNone: (): Electron.PopupOptions => ({
220230 window: input.window,
apps/web/src/components/ComposerPromptEditor.tsx+96
......@@ -27,6 +27,7 @@ import {
2727 KEY_TAB_COMMAND,
2828 COMMAND_PRIORITY_HIGH,
2929 KEY_BACKSPACE_COMMAND,
30 PASTE_COMMAND,
3031 $getRoot,
3132 HISTORY_MERGE_TAG,
3233 DecoratorNode,
......@@ -55,6 +56,7 @@ import {
5556 expandCollapsedComposerCursor,
5657 isCollapsedCursorAdjacentToInlineToken,
5758} from "~/composer-logic";
59import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
5860import {
5961 selectionTouchesMentionBoundary,
6062 splitPromptIntoComposerSegments,
......@@ -1117,6 +1119,99 @@ function ComposerInlineTokenBackspacePlugin() {
11171119 return null;
11181120}
11191121
1122function 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
11201215function ComposerSurroundSelectionPlugin(props: {
11211216 terminalContexts: ReadonlyArray<TerminalContextDraft>;
11221217 skills: ReadonlyArray<ServerProviderSkill>;
......@@ -1634,6 +1729,7 @@ function ComposerPromptEditorInner({
16341729 <ComposerInlineTokenArrowPlugin />
16351730 <ComposerInlineTokenSelectionNormalizePlugin />
16361731 <ComposerInlineTokenBackspacePlugin />
1732 <ComposerInlineTokenPastePlugin />
16371733 <HistoryPlugin />
16381734 </div>
16391735 </ComposerTerminalContextActionsContext>
apps/web/src/components/chat/ChatComposer.tsx+10-4
......@@ -390,7 +390,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
390390export interface ChatComposerHandle {
391391 focusAtEnd: () => void;
392392 focusAt: (cursor: number) => void;
393 insertTextAtEnd: (text: string) => boolean;
393 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
394394 openModelPicker: () => void;
395395 toggleModelPicker: () => void;
396396 isModelPickerOpen: () => boolean;
......@@ -1931,7 +1931,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19311931 focusAt: (cursor: number) => {
19321932 composerEditorRef.current?.focusAt(cursor);
19331933 },
1934 insertTextAtEnd: (text: string) => {
1934 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => {
19351935 if (
19361936 text.length === 0 ||
19371937 isConnecting ||
......@@ -1942,8 +1942,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19421942 ) {
19431943 return false;
19441944 }
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 );
19471953 },
19481954 openModelPicker: () => {
19491955 setIsComposerModelPickerOpen(true);
apps/web/src/components/files/FileBrowserPanel.tsx+96
......@@ -1,10 +1,19 @@
1import type {
2 ContextMenuItem as TreeContextMenuItem,
3 ContextMenuOpenContext as TreeContextMenuOpenContext,
4} from "@pierre/trees";
15import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
26import { FileTree, useFileTree } from "@pierre/trees/react";
7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
38import { RefreshCw, Search } from "lucide-react";
49import { useEffect, useMemo, useRef } from "react";
510
11import { toastManager } from "~/components/ui/toast";
12import { useComposerHandleContext } from "~/composerHandleContext";
13import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
614import { useTheme } from "~/hooks/useTheme";
715import { cn } from "~/lib/utils";
16import { readLocalApi } from "~/localApi";
817import { T3_PIERRE_ICONS } from "~/pierre-icons";
918
1019import { useProjectEntriesQuery } from "./projectFilesQueryState";
......@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
3948 onOpenFile,
4049}: FileBrowserPanelProps) {
4150 const { resolvedTheme } = useTheme();
51 const composerRef = useComposerHandleContext();
4252 const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
4353 const entries = entriesQuery.data?.entries ?? [];
4454 const entryKinds = useMemo(
......@@ -49,7 +59,93 @@ export default function FileBrowserPanel({
4959 const treePaths = useMemo(() => entries.map(treePath), [entries]);
5060 const previousTreePathsRef = useRef<readonly string[]>([]);
5161
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
52140 const { model } = useFileTree({
141 composition: {
142 contextMenu: {
143 triggerMode: "right-click",
144 onOpen: (item, context) => {
145 void showEntryContextMenuRef.current(item, context);
146 },
147 },
148 },
53149 density: "compact",
54150 fileTreeSearchMode: "hide-non-matches",
55151 flattenEmptyDirectories: true,