diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 09fb5d1807d..4d3e5a1c241 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } +// Renderer positions arrive in CSS pixels; popup() expects window points, so +// page zoom must be factored in or menus drift proportionally to their +// distance from the window origin. const normalizePosition = ( position: Option.Option, + zoomFactor: number, ): Option.Option => Option.filter( position, - ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, - ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + ({ x, y }) => + Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor), + ).pipe( + Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), + ); export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; @@ -214,7 +221,10 @@ export const make = Effect.gen(function* () { try { const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); + const popupPosition = normalizePosition( + input.position, + input.window.webContents.getZoomFactor(), + ); const popupOptions = Option.match(popupPosition, { onNone: (): Electron.PopupOptions => ({ window: input.window, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 18579cda6d3..cd5cb409e95 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -27,6 +27,7 @@ import { KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, + PASTE_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -55,6 +56,7 @@ import { expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { selectionTouchesMentionBoundary, splitPromptIntoComposerSegments, @@ -1117,6 +1119,80 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +function ComposerInlineTokenPastePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { + return false; + } + if (event.clipboardData.files.length > 0) { + return false; + } + const text = event.clipboardData.getData("text/plain"); + if (text.length === 0) { + return false; + } + // Token grammar requires trailing whitespace; a virtual newline lets a + // mention at the very end of the pasted text still parse. + const mentions = collectComposerInlineTokens(`${text}\n`).filter( + (token) => token.type === "mention" && token.end <= text.length, + ); + if (mentions.length === 0) { + return false; + } + event.preventDefault(); + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + const nodes: LexicalNode[] = []; + const appendText = (value: string) => { + const lines = value.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (line.length > 0) { + nodes.push($createTextNode(line)); + } + if (index < lines.length - 1) { + nodes.push($createLineBreakNode()); + } + } + }; + let cursor = 0; + for (const mention of mentions) { + if (mention.start < cursor) { + continue; + } + if (mention.start > cursor) { + appendText(text.slice(cursor, mention.start)); + } + nodes.push($createComposerMentionNode(mention.value)); + cursor = mention.end; + } + if (cursor < text.length) { + appendText(text.slice(cursor)); + } else { + // Keep the serialized prompt valid: mention tokens need trailing + // whitespace, so a paste ending in a mention gets the same + // trailing space the autocomplete inserts. + nodes.push($createTextNode(" ")); + } + selection.insertNodes(nodes); + }); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor]); + + return null; +} + function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; skills: ReadonlyArray; @@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 694341ee19a..7d48f0a28d6 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -1,10 +1,19 @@ +import type { + ContextMenuItem as TreeContextMenuItem, + ContextMenuOpenContext as TreeContextMenuOpenContext, +} from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree } from "@pierre/trees/react"; +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { RefreshCw, Search } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; +import { toastManager } from "~/components/ui/toast"; +import { useComposerHandleContext } from "~/composerHandleContext"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; @@ -39,6 +48,7 @@ export default function FileBrowserPanel({ onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); + const composerRef = useComposerHandleContext(); const entriesQuery = useProjectEntriesQuery(environmentId, cwd); const entries = entriesQuery.data?.entries ?? []; const entryKinds = useMemo( @@ -49,7 +59,84 @@ export default function FileBrowserPanel({ const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); + // The tree renders rows in shadow DOM and its anchor rect is unreliable, so + // capture the right-click position ourselves; contextmenu is a composed + // event, so a capture-phase listener sees it with viewport coordinates. + const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null); + useEffect(() => { + const capturePointer = (event: MouseEvent) => { + contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp }; + }; + document.addEventListener("contextmenu", capturePointer, true); + return () => document.removeEventListener("contextmenu", capturePointer, true); + }, []); + + const showEntryContextMenu = async ( + item: TreeContextMenuItem, + context: TreeContextMenuOpenContext, + ) => { + const api = readLocalApi(); + if (!api) { + context.close(); + return; + } + const relativePath = item.path.replace(/\/$/, ""); + const mention = serializeComposerFileLink(relativePath); + const pointer = contextMenuPointerRef.current; + const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000; + const anchorRect = context.anchorElement.getBoundingClientRect(); + const position = pointerIsFresh + ? { x: pointer.x, y: pointer.y } + : { x: anchorRect.left, y: anchorRect.bottom }; + try { + const clicked = await api.contextMenu.show( + [ + { id: "copy-mention", label: "Copy mention" }, + { id: "add-to-chat", label: "Add to chat" }, + ], + position, + ); + if (clicked === "copy-mention") { + try { + await writeTextToClipboard(mention); + toastManager.add({ type: "success", title: "Mention copied", description: relativePath }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy mention", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } + if (clicked === "add-to-chat") { + const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false; + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "Open a chat for this project and try again.", + }); + } + } + } finally { + context.close(); + } + }; + const showEntryContextMenuRef = useRef(showEntryContextMenu); + useEffect(() => { + showEntryContextMenuRef.current = showEntryContextMenu; + }); + const { model } = useFileTree({ + composition: { + contextMenu: { + triggerMode: "right-click", + onOpen: (item, context) => { + void showEntryContextMenuRef.current(item, context); + }, + }, + }, density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true, diff --git a/docs/fork/0001-file-explorer-mention-actions.md b/docs/fork/0001-file-explorer-mention-actions.md new file mode 100644 index 00000000000..7111d5efe6c --- /dev/null +++ b/docs/fork/0001-file-explorer-mention-actions.md @@ -0,0 +1,29 @@ +# 0001: File explorer mention actions and zoom-aware context menus + +- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1) +- Status: active + +## What you can do now + +- Right-click a file or directory in the Files panel and pick **Add to chat** + to drop it into the active composer as a file pill, or **Copy mention** to + put it on the clipboard and paste it wherever it belongs in a prompt. +- Pasting mention text into the composer renders the file pill. This works + for any pasted mention, including ones copied out of an earlier message, + not just ones coming from the file explorer. +- Context menus across the desktop app open at the cursor even when the + window is zoomed. Before this, any zoom level made menus drift away from + the click, worst near the right edge of the window. + +## Why + +Attaching files to a prompt required typing @-mentions by hand even though +the file explorer already has the file in front of you. Browsing and +prompting should be one flow. + +## Upstream considerations + +The zoomed context menu drift is a genuine upstream bug and that part is the +strongest candidate to submit once upstream opens contributions. The mention +actions are a feature upstream may or may not want; they depend only on +public composer and tree APIs, so the rebase burden is low. diff --git a/docs/fork/README.md b/docs/fork/README.md new file mode 100644 index 00000000000..d73a005d602 --- /dev/null +++ b/docs/fork/README.md @@ -0,0 +1,34 @@ +# Fork divergence ledger + +This directory tracks everything this fork carries that is not in upstream +[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per +divergence, newest last. When upstream ships an equivalent, update the status +here and note what replaced our version instead of deleting the entry, so the +history of what we carried and why stays reconstructable. + +## Writing an entry + +Entries are product focused. Describe what someone can do now and why we +wanted it, not how it was built. No touched-file lists, component names, or +implementation details; the linked PR already carries all of that, and code +detail in the ledger goes stale the moment the code moves. + +Each entry uses these sections: + +- **What you can do now**: the user-visible capabilities, as bullets. +- **Why**: the product rationale for carrying the divergence. +- **Upstream considerations**: whether and how we would submit it upstream, + and anything that affects the rebase burden. + +## Statuses + +- `active`: carried by this fork, not in upstream. +- `submitted`: proposed to upstream, waiting on the outcome. +- `upstreamed`: merged into upstream; our patch was dropped or reconciled. +- `superseded`: upstream shipped a different solution; our patch was dropped. + +## Ledger + +| # | Divergence | PR | Status | +| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ | +| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active |