| author | |
| committer | |
| log | 9051ed9c3923b7b5fb88d99fe9267380d69baa51 |
| tree | a94039eea42f79170dc8e022462910f2e1c3624e |
| parent | 256e630ef7b8372f70f80590e85aada62da821ab |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>6 files changed, 398 insertions(+), 2 deletions(-)
apps/web/src/components/DiffPanel.tsx+21| ... | @@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -27,6 +27,7 @@ import * as DateTime from "effect/DateTime"; |
| 27 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 27 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 28 | import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; | 28 | import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; |
| 29 | import { useOpenInPreferredEditor } from "../editorPreferences"; | 29 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 30 | import { useFileContextMenuHandler } from "../fileContextMenu"; | ||
| 30 | import { type DraftId } from "../composerDraftStore"; | 31 | import { type DraftId } from "../composerDraftStore"; |
| 31 | import { openDiffFilePrimaryAction } from "../diffFileActions"; | 32 | import { openDiffFilePrimaryAction } from "../diffFileActions"; |
| 32 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; | 33 | import { useCheckpointDiff } from "~/lib/checkpointDiffState"; |
| ... | @@ -170,6 +171,7 @@ export default function DiffPanel({ | ... | @@ -170,6 +171,7 @@ export default function DiffPanel({ |
| 170 | const serverConfig = useAtomValue( | 171 | const serverConfig = useAtomValue( |
| 171 | serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), | 172 | serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), |
| 172 | ); | 173 | ); |
| 174 | const onFileContextMenu = useFileContextMenuHandler(activeThread?.environmentId ?? null); | ||
| 173 | const openInPreferredEditor = useOpenInPreferredEditor( | 175 | const openInPreferredEditor = useOpenInPreferredEditor( |
| 174 | activeThread?.environmentId ?? null, | 176 | activeThread?.environmentId ?? null, |
| 175 | serverConfig?.availableEditors ?? [], | 177 | serverConfig?.availableEditors ?? [], |
| ... | @@ -1067,6 +1069,25 @@ export default function DiffPanel({ | ... | @@ -1067,6 +1069,25 @@ export default function DiffPanel({ |
| 1067 | ); | 1069 | ); |
| 1068 | if (file) toggleDiffFileCollapsed(file.fileKey); | 1070 | if (file) toggleDiffFileCollapsed(file.fileKey); |
| 1069 | }} | 1071 | }} |
| 1072 | onContextMenuCapture={(event) => { | ||
| 1073 | const composedPath = event.nativeEvent.composedPath?.() ?? []; | ||
| 1074 | const title = composedPath.find( | ||
| 1075 | (node): node is HTMLElement => | ||
| 1076 | node instanceof HTMLElement && node.hasAttribute("data-title"), | ||
| 1077 | ); | ||
| 1078 | const filePath = title?.textContent?.trim(); | ||
| 1079 | if (!filePath) return; | ||
| 1080 | event.preventDefault(); | ||
| 1081 | onFileContextMenu( | ||
| 1082 | { | ||
| 1083 | environmentId: activeThread?.environmentId ?? null, | ||
| 1084 | filePath, | ||
| 1085 | workspaceRoot: activeCwd, | ||
| 1086 | repositoryRoot: activeRepositoryRoot, | ||
| 1087 | }, | ||
| 1088 | event, | ||
| 1089 | ); | ||
| 1090 | }} | ||
| 1070 | > | 1091 | > |
| 1071 | <AnnotatableCodeView | 1092 | <AnnotatableCodeView |
| 1072 | key={collapseScopeKey ?? reviewSectionId} | 1093 | key={collapseScopeKey ?? reviewSectionId} |
apps/web/src/components/chat/ChangedFilesTree.tsx+24-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { type TurnId } from "@t3tools/contracts"; | 1 | import { type TurnId } from "@t3tools/contracts"; |
| 2 | import { memo, useCallback, useMemo, useState } from "react"; | 2 | import { type MouseEvent, memo, useCallback, useMemo, useState } from "react"; |
| 3 | import { type TurnDiffFileChange } from "../../types"; | 3 | import { type TurnDiffFileChange } from "../../types"; |
| 4 | import { | 4 | import { |
| 5 | buildTurnDiffTree, | 5 | buildTurnDiffTree, |
| ... | @@ -22,6 +22,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ... | @@ -22,6 +22,9 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 22 | 22 | ||
| 23 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | 23 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 24 | 24 | ||
| 25 | /** Opens the OS-level context menu for a changed file (reveal in file manager, open in editor). */ | ||
| 26 | export type ChangedFileContextMenuHandler = (filePath: string, event: MouseEvent) => void; | ||
| 27 | |||
| 25 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | 28 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 26 | turnId: TurnId; | 29 | turnId: TurnId; |
| 27 | files: ReadonlyArray<TurnDiffFileChange>; | 30 | files: ReadonlyArray<TurnDiffFileChange>; |
| ... | @@ -29,6 +32,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -29,6 +32,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 29 | resolvedTheme: "light" | "dark"; | 32 | resolvedTheme: "light" | "dark"; |
| 30 | onToggleAllDirectories: () => void; | 33 | onToggleAllDirectories: () => void; |
| 31 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 34 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 35 | onFileContextMenu?: ChangedFileContextMenuHandler | undefined; | ||
| 32 | }) { | 36 | }) { |
| 33 | const { | 37 | const { |
| 34 | turnId, | 38 | turnId, |
| ... | @@ -37,6 +41,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -37,6 +41,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 37 | resolvedTheme, | 41 | resolvedTheme, |
| 38 | onToggleAllDirectories, | 42 | onToggleAllDirectories, |
| 39 | onOpenTurnDiff, | 43 | onOpenTurnDiff, |
| 44 | onFileContextMenu, | ||
| 40 | } = props; | 45 | } = props; |
| 41 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); | 46 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); |
| 42 | const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); | 47 | const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); |
| ... | @@ -117,6 +122,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -117,6 +122,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 117 | allDirectoriesExpanded={allDirectoriesExpanded} | 122 | allDirectoriesExpanded={allDirectoriesExpanded} |
| 118 | resolvedTheme={resolvedTheme} | 123 | resolvedTheme={resolvedTheme} |
| 119 | onOpenTurnDiff={onOpenTurnDiff} | 124 | onOpenTurnDiff={onOpenTurnDiff} |
| 125 | onFileContextMenu={onFileContextMenu} | ||
| 120 | /> | 126 | /> |
| 121 | </div> | 127 | </div> |
| 122 | ); | 128 | ); |
| ... | @@ -128,8 +134,16 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -128,8 +134,16 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 128 | allDirectoriesExpanded: boolean; | 134 | allDirectoriesExpanded: boolean; |
| 129 | resolvedTheme: "light" | "dark"; | 135 | resolvedTheme: "light" | "dark"; |
| 130 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 136 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 137 | onFileContextMenu?: ChangedFileContextMenuHandler | undefined; | ||
| 131 | }) { | 138 | }) { |
| 132 | const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; | 139 | const { |
| 140 | files, | ||
| 141 | allDirectoriesExpanded, | ||
| 142 | onOpenTurnDiff, | ||
| 143 | resolvedTheme, | ||
| 144 | turnId, | ||
| 145 | onFileContextMenu, | ||
| 146 | } = props; | ||
| 133 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); | 147 | const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); |
| 134 | const directoryPathsKey = useMemo( | 148 | const directoryPathsKey = useMemo( |
| 135 | () => collectDirectoryPaths(treeNodes).join("\u0000"), | 149 | () => collectDirectoryPaths(treeNodes).join("\u0000"), |
| ... | @@ -214,6 +228,14 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -214,6 +228,14 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 214 | className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" | 228 | className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" |
| 215 | style={{ paddingLeft: `${leftPadding}px` }} | 229 | style={{ paddingLeft: `${leftPadding}px` }} |
| 216 | onClick={() => onOpenTurnDiff(turnId, node.path)} | 230 | onClick={() => onOpenTurnDiff(turnId, node.path)} |
| 231 | onContextMenu={ | ||
| 232 | onFileContextMenu | ||
| 233 | ? (event) => { | ||
| 234 | event.preventDefault(); | ||
| 235 | onFileContextMenu(node.path, event); | ||
| 236 | } | ||
| 237 | : undefined | ||
| 238 | } | ||
| 217 | > | 239 | > |
| 218 | {hasDirectoryNodes || depth > 0 ? ( | 240 | {hasDirectoryNodes || depth > 0 ? ( |
| 219 | <span aria-hidden="true" className="size-3.5 shrink-0" /> | 241 | <span aria-hidden="true" className="size-3.5 shrink-0" /> |
apps/web/src/components/chat/MessagesTimeline.tsx+30| ... | @@ -151,6 +151,10 @@ import { | ... | @@ -151,6 +151,10 @@ import { |
| 151 | } from "./SnapShotAttachmentDetails"; | 151 | } from "./SnapShotAttachmentDetails"; |
| 152 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 152 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 153 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 153 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 154 | import { useAtomValue } from "@effect/atom-react"; | ||
| 155 | import { useFileContextMenuHandler } from "../../fileContextMenu"; | ||
| 156 | import { useProject, useThread } from "../../state/entities"; | ||
| 157 | import { serverEnvironment } from "../../state/server"; | ||
| 154 | import { | 158 | import { |
| 155 | CHAT_TIMELINE_ANCHOR_OFFSET, | 159 | CHAT_TIMELINE_ANCHOR_OFFSET, |
| 156 | readTimelinePosition, | 160 | readTimelinePosition, |
| ... | @@ -3355,12 +3359,24 @@ function AssistantChangedFilesSectionInner({ | ... | @@ -3355,12 +3359,24 @@ function AssistantChangedFilesSectionInner({ |
| 3355 | resolvedTheme: "light" | "dark"; | 3359 | resolvedTheme: "light" | "dark"; |
| 3356 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 3360 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 3357 | }) { | 3361 | }) { |
| 3362 | const ctx = use(TimelineRowCtx); | ||
| 3358 | const persistedExpanded = useUiStateStore( | 3363 | const persistedExpanded = useUiStateStore( |
| 3359 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], | 3364 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], |
| 3360 | ); | 3365 | ); |
| 3361 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); | 3366 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); |
| 3362 | const allDirectoriesExpanded = persistedExpanded ?? false; | 3367 | const allDirectoriesExpanded = persistedExpanded ?? false; |
| 3363 | 3368 | ||
| 3369 | const thread = useThread(ctx.threadRef); | ||
| 3370 | const activeProject = useProject( | ||
| 3371 | thread && thread.projectId | ||
| 3372 | ? { environmentId: thread.environmentId, projectId: thread.projectId } | ||
| 3373 | : null, | ||
| 3374 | ); | ||
| 3375 | const serverConfig = useAtomValue( | ||
| 3376 | serverEnvironment.configValueAtom(ctx.activeThreadEnvironmentId), | ||
| 3377 | ); | ||
| 3378 | const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId); | ||
| 3379 | |||
| 3364 | return ( | 3380 | return ( |
| 3365 | <ChangedFilesCard | 3381 | <ChangedFilesCard |
| 3366 | turnId={turnSummary.turnId} | 3382 | turnId={turnSummary.turnId} |
| ... | @@ -3371,6 +3387,20 @@ function AssistantChangedFilesSectionInner({ | ... | @@ -3371,6 +3387,20 @@ function AssistantChangedFilesSectionInner({ |
| 3371 | setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) | 3387 | setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) |
| 3372 | } | 3388 | } |
| 3373 | onOpenTurnDiff={onOpenTurnDiff} | 3389 | onOpenTurnDiff={onOpenTurnDiff} |
| 3390 | onFileContextMenu={(filePath, event) => | ||
| 3391 | onFileContextMenu( | ||
| 3392 | { | ||
| 3393 | environmentId: ctx.activeThreadEnvironmentId, | ||
| 3394 | filePath, | ||
| 3395 | workspaceRoot: ctx.workspaceRoot, | ||
| 3396 | repositoryRoot: | ||
| 3397 | thread?.worktreePath == null | ||
| 3398 | ? activeProject?.repositoryIdentity?.rootPath | ||
| 3399 | : undefined, | ||
| 3400 | }, | ||
| 3401 | event, | ||
| 3402 | ) | ||
| 3403 | } | ||
| 3374 | /> | 3404 | /> |
| 3375 | ); | 3405 | ); |
| 3376 | } | 3406 | } |
apps/web/src/components/files/FileBrowserPanel.tsx+15| ... | @@ -17,6 +17,7 @@ import { useComposerHandleContext } from "~/composerHandleContext"; | ... | @@ -17,6 +17,7 @@ import { useComposerHandleContext } from "~/composerHandleContext"; |
| 17 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 17 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 18 | import { useTheme } from "~/hooks/useTheme"; | 18 | import { useTheme } from "~/hooks/useTheme"; |
| 19 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | 19 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 20 | import { useFileContextMenu, type FileContextMenuAction } from "~/fileContextMenu"; | ||
| 20 | import { readLocalApi } from "~/localApi"; | 21 | import { readLocalApi } from "~/localApi"; |
| 21 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; | 22 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 22 | import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; | 23 | import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; |
| ... | @@ -105,6 +106,7 @@ export default function FileBrowserPanel({ | ... | @@ -105,6 +106,7 @@ export default function FileBrowserPanel({ |
| 105 | }: FileBrowserPanelProps) { | 106 | }: FileBrowserPanelProps) { |
| 106 | const { resolvedTheme } = useTheme(); | 107 | const { resolvedTheme } = useTheme(); |
| 107 | const composerRef = useComposerHandleContext(); | 108 | const composerRef = useComposerHandleContext(); |
| 109 | const fileContextMenu = useFileContextMenu(environmentId); | ||
| 108 | const { | 110 | const { |
| 109 | entries: directoryEntries, | 111 | entries: directoryEntries, |
| 110 | load, | 112 | load, |
| ... | @@ -157,6 +159,7 @@ export default function FileBrowserPanel({ | ... | @@ -157,6 +159,7 @@ export default function FileBrowserPanel({ |
| 157 | return () => document.removeEventListener("contextmenu", capturePointer, true); | 159 | return () => document.removeEventListener("contextmenu", capturePointer, true); |
| 158 | }, []); | 160 | }, []); |
| 159 | 161 | ||
| 162 | /** Combines the file actions (open/reveal/open with) with the panel's own mention actions. */ | ||
| 160 | const showEntryContextMenu = async ( | 163 | const showEntryContextMenu = async ( |
| 161 | item: TreeContextMenuItem, | 164 | item: TreeContextMenuItem, |
| 162 | context: TreeContextMenuOpenContext, | 165 | context: TreeContextMenuOpenContext, |
| ... | @@ -174,14 +177,26 @@ export default function FileBrowserPanel({ | ... | @@ -174,14 +177,26 @@ export default function FileBrowserPanel({ |
| 174 | const position = pointerIsFresh | 177 | const position = pointerIsFresh |
| 175 | ? { x: pointer.x, y: pointer.y } | 178 | ? { x: pointer.x, y: pointer.y } |
| 176 | : { x: anchorRect.left, y: anchorRect.bottom }; | 179 | : { x: anchorRect.left, y: anchorRect.bottom }; |
| 180 | const fileTarget = { environmentId, filePath: relativePath, workspaceRoot: cwd }; | ||
| 181 | const fileMenuItems = fileContextMenu.buildItems(fileTarget); | ||
| 177 | try { | 182 | try { |
| 178 | const clicked = await api.contextMenu.show( | 183 | const clicked = await api.contextMenu.show( |
| 179 | [ | 184 | [ |
| 185 | ...fileMenuItems, | ||
| 180 | { id: "copy-mention", label: "Copy mention" }, | 186 | { id: "copy-mention", label: "Copy mention" }, |
| 181 | { id: "add-to-chat", label: "Add to chat" }, | 187 | { id: "add-to-chat", label: "Add to chat" }, |
| 182 | ], | 188 | ], |
| 183 | position, | 189 | position, |
| 184 | ); | 190 | ); |
| 191 | if (clicked === null) return; | ||
| 192 | // "Open with" submenu selections report the child id ("editor:<id>"), | ||
| 193 | // which is not present in the top-level item list. | ||
| 194 | const isFileMenuAction = | ||
| 195 | fileMenuItems.some((entry) => entry.id === clicked) || clicked.startsWith("editor:"); | ||
| 196 | if (isFileMenuAction) { | ||
| 197 | await fileContextMenu.activate(clicked as FileContextMenuAction, fileTarget); | ||
| 198 | return; | ||
| 199 | } | ||
| 185 | if (clicked === "copy-mention") { | 200 | if (clicked === "copy-mention") { |
| 186 | try { | 201 | try { |
| 187 | await writeTextToClipboard(mention); | 202 | await writeTextToClipboard(mention); |
apps/web/src/fileContextMenu.test.ts created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import * as NodeAssert from "node:assert/strict"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { buildFileContextMenuItems, resolveFileContextMenuAbsolutePath } from "./fileContextMenu"; | ||
| 6 | |||
| 7 | const BASE_TARGET = { | ||
| 8 | environmentId: EnvironmentId.make("environment-local"), | ||
| 9 | filePath: "src/index.ts", | ||
| 10 | workspaceRoot: "/workspace/project", | ||
| 11 | }; | ||
| 12 | |||
| 13 | const EMPTY_CAPABILITIES = { | ||
| 14 | revealLabel: undefined, | ||
| 15 | canOpenDefault: false, | ||
| 16 | editorIds: [], | ||
| 17 | }; | ||
| 18 | |||
| 19 | describe("resolveFileContextMenuAbsolutePath", () => { | ||
| 20 | it("joins workspace-relative diff paths onto the workspace root", () => { | ||
| 21 | expect(resolveFileContextMenuAbsolutePath(BASE_TARGET)).toBe("/workspace/project/src/index.ts"); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("strips the repository prefix when the repo root is nested in the workspace", () => { | ||
| 25 | expect( | ||
| 26 | resolveFileContextMenuAbsolutePath({ | ||
| 27 | ...BASE_TARGET, | ||
| 28 | workspaceRoot: "/workspace/project/packages/app", | ||
| 29 | repositoryRoot: "/workspace/project", | ||
| 30 | filePath: "packages/app/src/index.ts", | ||
| 31 | }), | ||
| 32 | ).toBe("/workspace/project/packages/app/src/index.ts"); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("returns null for paths outside the workspace when a repository root is set", () => { | ||
| 36 | expect( | ||
| 37 | resolveFileContextMenuAbsolutePath({ | ||
| 38 | ...BASE_TARGET, | ||
| 39 | workspaceRoot: "/workspace/project/packages/app", | ||
| 40 | repositoryRoot: "/workspace/project", | ||
| 41 | filePath: "other/src/index.ts", | ||
| 42 | }), | ||
| 43 | ).toBeNull(); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("rejects absolute paths without a workspace root, matching diff path resolution", () => { | ||
| 47 | expect( | ||
| 48 | resolveFileContextMenuAbsolutePath({ | ||
| 49 | ...BASE_TARGET, | ||
| 50 | workspaceRoot: undefined, | ||
| 51 | filePath: "/absolute/src/index.ts", | ||
| 52 | }), | ||
| 53 | ).toBeNull(); | ||
| 54 | }); | ||
| 55 | }); | ||
| 56 | |||
| 57 | describe("buildFileContextMenuItems", () => { | ||
| 58 | it("offers open, reveal, and an open-with submenu when all are available", () => { | ||
| 59 | const items = buildFileContextMenuItems({ | ||
| 60 | hasAbsolutePath: true, | ||
| 61 | capabilities: { | ||
| 62 | revealLabel: "Reveal in Finder", | ||
| 63 | canOpenDefault: true, | ||
| 64 | editorIds: ["vscode", "cursor", "file-manager"], | ||
| 65 | }, | ||
| 66 | }); | ||
| 67 | |||
| 68 | expect(items.map((item) => item.id)).toEqual(["open", "reveal-in-folder", "open-with"]); | ||
| 69 | expect(items[0]).toMatchObject({ label: "Open" }); | ||
| 70 | expect(items[1]).toMatchObject({ label: "Reveal in Finder" }); | ||
| 71 | const openWith = items[2]; | ||
| 72 | NodeAssert.ok(openWith); | ||
| 73 | expect(openWith.children?.map((child) => child.id)).toEqual(["editor:vscode", "editor:cursor"]); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("offers only the reveal item when just reveal is enabled", () => { | ||
| 77 | const items = buildFileContextMenuItems({ | ||
| 78 | hasAbsolutePath: true, | ||
| 79 | capabilities: { | ||
| 80 | revealLabel: "Reveal in File Explorer", | ||
| 81 | canOpenDefault: false, | ||
| 82 | editorIds: [], | ||
| 83 | }, | ||
| 84 | }); | ||
| 85 | |||
| 86 | expect(items.map((item) => item.id)).toEqual(["reveal-in-folder"]); | ||
| 87 | expect(items[0]).toMatchObject({ label: "Reveal in File Explorer" }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("offers nothing when the path cannot be resolved", () => { | ||
| 91 | expect( | ||
| 92 | buildFileContextMenuItems({ | ||
| 93 | hasAbsolutePath: false, | ||
| 94 | capabilities: { | ||
| 95 | revealLabel: "Reveal in Finder", | ||
| 96 | canOpenDefault: true, | ||
| 97 | editorIds: ["vscode"], | ||
| 98 | }, | ||
| 99 | }), | ||
| 100 | ).toEqual([]); | ||
| 101 | }); | ||
| 102 | }); | ||
apps/web/src/fileContextMenu.ts created+206| ... | @@ -0,0 +1,206 @@ | ||
| 1 | /** | ||
| 2 | * Right-click actions for a workspace file: reveal it in the environment's | ||
| 3 | * file manager and open it in an editor. Reuse the chat file-chip menu's | ||
| 4 | * machinery: reveal rides `shell.openInEditor` with `reveal: true`, which the | ||
| 5 | * server only honors when its `shellRevealInFileManager` config flag is set, | ||
| 6 | * so both actions work for every client and connection mode. | ||
| 7 | */ | ||
| 8 | import { | ||
| 9 | EDITORS, | ||
| 10 | type ContextMenuItem, | ||
| 11 | type EditorId, | ||
| 12 | type EnvironmentId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { useCallback, useMemo } from "react"; | ||
| 15 | |||
| 16 | import { resolveDiffPathForWorkspace } from "./diffFileActions"; | ||
| 17 | import { | ||
| 18 | revealInFileExplorerLabelForKind, | ||
| 19 | revealInFileExplorerLabelForOs, | ||
| 20 | } from "~/components/preview/fileExplorerLabel"; | ||
| 21 | import { readLocalApi } from "./localApi"; | ||
| 22 | import { serverEnvironment } from "./state/server"; | ||
| 23 | import { shellEnvironment } from "./state/shell"; | ||
| 24 | import { useAtomCommand } from "./state/use-atom-command"; | ||
| 25 | import { resolvePathLinkTarget } from "./terminal-links"; | ||
| 26 | import { toastManager } from "./components/ui/toast"; | ||
| 27 | import { useAtomValue } from "@effect/atom-react"; | ||
| 28 | |||
| 29 | export type FileContextMenuAction = | ||
| 30 | | "reveal-in-folder" | ||
| 31 | | "open" | ||
| 32 | /** Submenu parent; never the activated id. */ | ||
| 33 | | "open-with" | ||
| 34 | | `editor:${EditorId}`; | ||
| 35 | |||
| 36 | export interface FileContextMenuTarget { | ||
| 37 | readonly environmentId: EnvironmentId | null; | ||
| 38 | /** Repo- or workspace-relative file path, as shown in diffs. */ | ||
| 39 | readonly filePath: string; | ||
| 40 | readonly workspaceRoot: string | undefined; | ||
| 41 | readonly repositoryRoot?: string | undefined; | ||
| 42 | } | ||
| 43 | |||
| 44 | /** Absolute path on the environment host, or null when it cannot be resolved. */ | ||
| 45 | /** | ||
| 46 | * Absolute path on the environment host for a diff-style target, resolving | ||
| 47 | * repo-relative paths through the workspace root like every other diff | ||
| 48 | * surface. Returns null when the path cannot be resolved, which callers must | ||
| 49 | * treat as "no file actions available". | ||
| 50 | */ | ||
| 51 | export function resolveFileContextMenuAbsolutePath(target: FileContextMenuTarget): string | null { | ||
| 52 | const workspaceFilePath = resolveDiffPathForWorkspace({ | ||
| 53 | filePath: target.filePath, | ||
| 54 | workspaceRoot: target.workspaceRoot, | ||
| 55 | repositoryRoot: target.repositoryRoot, | ||
| 56 | }); | ||
| 57 | if (workspaceFilePath === null) return null; | ||
| 58 | if (target.workspaceRoot === undefined) { | ||
| 59 | return workspaceFilePath.startsWith("/") || /^[a-zA-Z]:/.test(workspaceFilePath) | ||
| 60 | ? workspaceFilePath | ||
| 61 | : null; | ||
| 62 | } | ||
| 63 | return resolvePathLinkTarget(workspaceFilePath, target.workspaceRoot); | ||
| 64 | } | ||
| 65 | |||
| 66 | const EDITOR_LABEL_BY_ID = new Map(EDITORS.map((editor) => [editor.id, editor.label])); | ||
| 67 | |||
| 68 | export interface FileContextMenuCapabilities { | ||
| 69 | readonly revealLabel: string | undefined; | ||
| 70 | readonly canOpenDefault: boolean; | ||
| 71 | readonly editorIds: ReadonlyArray<EditorId>; | ||
| 72 | } | ||
| 73 | |||
| 74 | /** | ||
| 75 | * Menu items for a resolved file, offering only what the environment's config | ||
| 76 | * advertises: default-app open, reveal (with server-provided wording), and an | ||
| 77 | * "Open with" submenu of detected editors. Empty when nothing can act. | ||
| 78 | */ | ||
| 79 | export function buildFileContextMenuItems(input: { | ||
| 80 | readonly hasAbsolutePath: boolean; | ||
| 81 | readonly capabilities: FileContextMenuCapabilities; | ||
| 82 | }): readonly ContextMenuItem<FileContextMenuAction>[] { | ||
| 83 | // Without a resolvable absolute path nothing here can act on the file. | ||
| 84 | if (!input.hasAbsolutePath) return []; | ||
| 85 | const items: ContextMenuItem<FileContextMenuAction>[] = []; | ||
| 86 | if (input.capabilities.canOpenDefault) { | ||
| 87 | items.push({ id: "open", label: "Open", icon: "pencil" }); | ||
| 88 | } | ||
| 89 | if (input.capabilities.revealLabel !== undefined) { | ||
| 90 | items.push({ | ||
| 91 | id: "reveal-in-folder", | ||
| 92 | label: input.capabilities.revealLabel, | ||
| 93 | icon: "folder-tree", | ||
| 94 | }); | ||
| 95 | } | ||
| 96 | const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); | ||
| 97 | if (editorIds.length > 0) { | ||
| 98 | items.push({ | ||
| 99 | id: "open-with", | ||
| 100 | label: "Open with", | ||
| 101 | children: editorIds.map((editorId) => ({ | ||
| 102 | id: `editor:${editorId}` as FileContextMenuAction, | ||
| 103 | label: EDITOR_LABEL_BY_ID.get(editorId) ?? editorId, | ||
| 104 | })), | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | return items; | ||
| 108 | } | ||
| 109 | |||
| 110 | /** | ||
| 111 | * Context-menu actions for files. The environment id is fixed per component | ||
| 112 | * (a thread's environment, a file browser's environment), so capabilities | ||
| 113 | * resolve once per hook call. | ||
| 114 | */ | ||
| 115 | /** Builds and dispatches the file context menu for one environment's files. */ | ||
| 116 | export function useFileContextMenu(environmentId: EnvironmentId | null) { | ||
| 117 | const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false }); | ||
| 118 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | ||
| 119 | |||
| 120 | return useMemo(() => { | ||
| 121 | const availableEditors = serverConfig?.availableEditors ?? []; | ||
| 122 | const capabilities: FileContextMenuCapabilities = { | ||
| 123 | // The reveal wording comes from the server because on WSL the reveal can | ||
| 124 | // run through Windows File Explorer even though the host reports Linux. | ||
| 125 | revealLabel: | ||
| 126 | environmentId !== null && | ||
| 127 | serverConfig?.shellRevealInFileManager === true && | ||
| 128 | serverConfig.availableEditors.includes("file-manager") | ||
| 129 | ? serverConfig.shellRevealInFileManagerKind === undefined | ||
| 130 | ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) | ||
| 131 | : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) | ||
| 132 | : undefined, | ||
| 133 | canOpenDefault: availableEditors.includes("file-manager"), | ||
| 134 | editorIds: availableEditors, | ||
| 135 | }; | ||
| 136 | |||
| 137 | const activate = async ( | ||
| 138 | action: FileContextMenuAction, | ||
| 139 | target: FileContextMenuTarget, | ||
| 140 | ): Promise<void> => { | ||
| 141 | const absolutePath = resolveFileContextMenuAbsolutePath(target); | ||
| 142 | if (absolutePath === null || environmentId === null) return; | ||
| 143 | |||
| 144 | const reveal = action === "reveal-in-folder"; | ||
| 145 | const editor = | ||
| 146 | action === "open" || reveal | ||
| 147 | ? ("file-manager" as const) | ||
| 148 | : (action.slice("editor:".length) as EditorId); | ||
| 149 | if (action !== "open" && !reveal && !capabilities.editorIds.includes(editor)) return; | ||
| 150 | |||
| 151 | const result = await openInEditor({ | ||
| 152 | environmentId, | ||
| 153 | input: { cwd: absolutePath, editor, ...(reveal ? { reveal: true } : {}) }, | ||
| 154 | }); | ||
| 155 | if (result._tag !== "Failure") return; | ||
| 156 | toastManager.add({ | ||
| 157 | type: "error", | ||
| 158 | title: | ||
| 159 | action === "open" | ||
| 160 | ? "Could not open file" | ||
| 161 | : reveal | ||
| 162 | ? "Unable to reveal file" | ||
| 163 | : `Could not open in ${EDITOR_LABEL_BY_ID.get(editor) ?? editor}`, | ||
| 164 | description: absolutePath, | ||
| 165 | }); | ||
| 166 | }; | ||
| 167 | |||
| 168 | const show = async ( | ||
| 169 | target: FileContextMenuTarget, | ||
| 170 | position?: { x: number; y: number }, | ||
| 171 | ): Promise<void> => { | ||
| 172 | const api = readLocalApi(); | ||
| 173 | const items = buildFileContextMenuItems({ | ||
| 174 | hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null, | ||
| 175 | capabilities, | ||
| 176 | }); | ||
| 177 | if (items.length === 0 || api === undefined) return; | ||
| 178 | const clicked = await api.contextMenu.show(items, position); | ||
| 179 | if (clicked === null) return; | ||
| 180 | await activate(clicked as FileContextMenuAction, target); | ||
| 181 | }; | ||
| 182 | |||
| 183 | return { | ||
| 184 | buildItems: (target: FileContextMenuTarget) => | ||
| 185 | buildFileContextMenuItems({ | ||
| 186 | hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null, | ||
| 187 | capabilities, | ||
| 188 | }), | ||
| 189 | capabilities, | ||
| 190 | activate, | ||
| 191 | show, | ||
| 192 | }; | ||
| 193 | }, [environmentId, openInEditor, serverConfig]); | ||
| 194 | } | ||
| 195 | |||
| 196 | /** Convenience callback for onContextMenu handlers. */ | ||
| 197 | /** Returns an onContextMenu callback that shows the menu at the pointer. */ | ||
| 198 | export function useFileContextMenuHandler(environmentId: EnvironmentId | null) { | ||
| 199 | const contextMenu = useFileContextMenu(environmentId); | ||
| 200 | return useCallback( | ||
| 201 | (target: FileContextMenuTarget, event?: { clientX: number; clientY: number }) => { | ||
| 202 | void contextMenu.show(target, event ? { x: event.clientX, y: event.clientY } : undefined); | ||
| 203 | }, | ||
| 204 | [contextMenu], | ||
| 205 | ); | ||
| 206 | } | ||