Skip to content
Open
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
14 changes: 12 additions & 2 deletions apps/desktop/src/electron/ElectronMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe(
Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
);

const makeWindow = (zoomFactor = 1): Electron.BrowserWindow =>
({
id: 7,
webContents: { getZoomFactor: () => zoomFactor },
}) as unknown as Electron.BrowserWindow;

describe("ElectronMenu", () => {
beforeEach(() => {
buildFromTemplateMock.mockReset();
Expand Down Expand Up @@ -70,7 +76,7 @@ describe("ElectronMenu", () => {

const electronMenu = yield* ElectronMenu.ElectronMenu;
const selectedItemId = yield* electronMenu.showContextMenu({
window: {} as Electron.BrowserWindow,
window: makeWindow(),
items: [{ id: "copy", label: "Copy" }],
position: Option.none(),
});
Expand All @@ -81,20 +87,24 @@ describe("ElectronMenu", () => {

it.effect("resolves with none when the menu closes without a click", () =>
Effect.gen(function* () {
let popupOptions: Electron.PopupOptions | undefined;
buildFromTemplateMock.mockImplementation(() => ({
popup: (options: Electron.PopupOptions) => {
popupOptions = options;
options.callback?.();
},
}));

const electronMenu = yield* ElectronMenu.ElectronMenu;
const selectedItemId = yield* electronMenu.showContextMenu({
window: {} as Electron.BrowserWindow,
window: makeWindow(2),
items: [{ id: "copy", label: "Copy" }],
position: Option.some({ x: 10.8, y: 20.2 }),
});

assert.isTrue(Option.isNone(selectedItemId));
assert.equal(popupOptions?.x, 21);
assert.equal(popupOptions?.y, 40);
assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
label: "Copy",
enabled: true,
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ElectronMenuPosition>,
zoomFactor: number,
): Option.Option<ElectronMenuPosition> =>
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;
Expand Down Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
KEY_TAB_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_BACKSPACE_COMMAND,
PASTE_COMMAND,
$getRoot,
HISTORY_MERGE_TAG,
DecoratorNode,
Expand Down Expand Up @@ -55,6 +56,7 @@ import {
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
} from "~/composer-logic";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import {
selectionTouchesMentionBoundary,
splitPromptIntoComposerSegments,
Expand Down Expand Up @@ -1117,6 +1119,99 @@ 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;
}
let inserted = false;
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const nodes: LexicalNode[] = [];
const appendText = (value: string) => {
Comment thread
yordis marked this conversation as resolved.
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());
}
}
};
const firstMention = mentions[0];
if (firstMention && firstMention.start === 0) {
const startPoint = selection.isBackward() ? selection.focus : selection.anchor;
const insertionOffset = getExpandedAbsoluteOffsetForPoint(
startPoint.getNode(),
startPoint.offset,
);
const precedingChar = $getRoot()
.getTextContent()
.slice(insertionOffset - 1, insertionOffset);
if (precedingChar.length > 0 && !/\s/.test(precedingChar)) {
nodes.push($createTextNode(" "));
}
Comment thread
yordis marked this conversation as resolved.
}
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);
inserted = true;
});
if (!inserted) {
return false;
}
event.preventDefault();
return true;
Comment thread
cursor[bot] marked this conversation as resolved.
},
COMMAND_PRIORITY_HIGH,
);
}, [editor]);

return null;
}

function ComposerSurroundSelectionPlugin(props: {
terminalContexts: ReadonlyArray<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand Down Expand Up @@ -1634,6 +1729,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
export interface ChatComposerHandle {
focusAtEnd: () => void;
focusAt: (cursor: number) => void;
insertTextAtEnd: (text: string) => boolean;
insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
openModelPicker: () => void;
toggleModelPicker: () => void;
isModelPickerOpen: () => boolean;
Expand Down Expand Up @@ -1918,7 +1918,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
focusAt: (cursor: number) => {
composerEditorRef.current?.focusAt(cursor);
},
insertTextAtEnd: (text: string) => {
insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => {
if (
text.length === 0 ||
isConnecting ||
Expand All @@ -1928,8 +1928,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) {
return false;
}
const rangeEnd = promptRef.current.length;
return applyPromptReplacement(rangeEnd, rangeEnd, text);
const prompt = promptRef.current;
const needsLeadingSpace =
(options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt);
return applyPromptReplacement(
prompt.length,
prompt.length,
needsLeadingSpace ? ` ${text}` : text,
);
},
openModelPicker: () => {
setIsComposerModelPickerOpen(true);
Expand Down
96 changes: 96 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand All @@ -49,7 +59,93 @@ export default function FileBrowserPanel({
const treePaths = useMemo(() => entries.map(treePath), [entries]);
const previousTreePathsRef = useRef<readonly string[]>([]);

// 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 composer = composerRef?.current;
if (!composer) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "Open a chat for this project and try again.",
});
return;
}
const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true });
if (!inserted) {
toastManager.add({
type: "error",
title: "Unable to add to chat",
description: "The chat isn't ready to accept input right now.",
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
} 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,
Expand Down
Loading