Skip to content
Merged
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
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
77 changes: 77 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,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<TerminalContextDraft>;
skills: ReadonlyArray<ServerProviderSkill>;
Expand Down Expand Up @@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({
<ComposerInlineTokenArrowPlugin />
<ComposerInlineTokenSelectionNormalizePlugin />
<ComposerInlineTokenBackspacePlugin />
<ComposerInlineTokenPastePlugin />
<HistoryPlugin />
</div>
</ComposerTerminalContextActionsContext>
Expand Down
87 changes: 87 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,84 @@ 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 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,
Expand Down
29 changes: 29 additions & 0 deletions docs/fork/0001-file-explorer-mention-actions.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions docs/fork/README.md
Original file line number Diff line number Diff line change
@@ -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 |