diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 3e1222e0ebb9..5195d78c946d 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -5,6 +5,7 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { useRemoteOpenResolution } from "../remoteOpen"; import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; @@ -45,8 +46,13 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map(), })); -vi.mock("../remoteOpen", () => ({ - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +vi.mock("../remoteOpen", async (importOriginal) => ({ + ...(await importOriginal()), + useRemoteOpenResolution: vi.fn(() => ({ + state: { mode: "local-exec" }, + isResolved: true, + environmentLabel: "sol", + })), })); vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn(), @@ -598,6 +604,26 @@ describe("ChatMarkdown file option chips", () => { expect(html).toContain("index.ts · project/test"); }); + it("marks chip paths with the hosting environment when viewing remotely", () => { + vi.mocked(useRemoteOpenResolution).mockReturnValue({ + state: { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }, + isResolved: true, + environmentLabel: "sol", + }); + try { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('data-host-environment="sol"'); + } finally { + vi.mocked(useRemoteOpenResolution).mockReset(); + } + }); + it("preserves rejected citations created by over-indented list recovery", () => { const malformedHtml = renderToStaticMarkup( + hostEnvironment ? `${label} on ${hostEnvironment}` : label; try { const clicked = await api.contextMenu.show( [ @@ -2070,8 +2087,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), - { id: "copy-relative", label: "Copy relative path" }, - { id: "copy-full", label: "Copy full path" }, + { id: "copy-relative", label: copyTitle("Copy relative path") }, + { id: "copy-full", label: copyTitle("Copy full path") }, ] as const, position, ); @@ -2093,11 +2110,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); + handleCopy(displayPath, copyTitle("Relative path")); return; } if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); + // `iconPath` is the position-free path, so the scp form works as an + // scp/rsync source even when the link targets `file.ts:12:3`. + handleCopy(scpHost ? `${scpHost}:${iconPath}` : targetPath, copyTitle("Full path")); } } catch (cause) { reportMarkdownActionFailure( @@ -2112,12 +2131,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser, handleOpenInEditor, handleRevealInFileManager, + hostEnvironment, + iconPath, onOpenInBrowser, onOpenMedia, onOpen, onReveal, openInEditorMenuLabel, revealLabel, + scpHost, targetPath, ], ); @@ -2164,6 +2186,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ render={} className={MARKDOWN_FILE_LINK_CLASS_NAME} data-markdown-copy={copyMarkdown} + {...(hostEnvironment !== null ? { "data-host-environment": hostEnvironment } : {})} onClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -2189,6 +2212,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ aria-haspopup="menu" className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} data-markdown-copy={copyMarkdown} + {...(hostEnvironment !== null ? { "data-host-environment": hostEnvironment } : {})} onClick={handleContextMenu} onContextMenu={handleContextMenu} > @@ -2203,6 +2227,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
{targetPath}
+ {hostEnvironment !== null && ( +
Path on {hostEnvironment}
+ )} ); @@ -2223,6 +2250,8 @@ function areMarkdownFileLinkPropsEqual( previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && previous.threadRef === next.threadRef && + previous.hostEnvironment === next.hostEnvironment && + previous.scpHost === next.scpHost && previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && @@ -2279,6 +2308,10 @@ function useChatMarkdownState({ remoteOpen.isResolved, ); const preparedConnection = usePreparedConnection(environmentId); + // File chips copy host paths; when this client is not on the environment + // machine, the copy needs to say which machine it names. + const remoteHostEnvironment = remotePathCopyQualifier(remoteOpen); + const remoteScpHost = remotePathScpHost(remoteOpen.state); const openMarkdownMedia = useCallback( (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => { const requestId = ++mediaRequestId.current; @@ -2588,6 +2621,8 @@ function useChatMarkdownState({ iconPath={fileLinkMeta.filePath} displayPath={fileLinkMeta.displayPath} panelPath={panelPath} + hostEnvironment={remoteHostEnvironment} + scpHost={remoteScpHost} line={fileLinkMeta.line} label={labelParts.join(" · ")} copyMarkdown={copyMarkdown} @@ -2628,6 +2663,8 @@ function useChatMarkdownState({ resolvedTheme, revealInFileManagerLabel, revealMarkdownFileInFileManager, + remoteHostEnvironment, + remoteScpHost, threadRef, ], ); diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index bab751c74816..402e5537f97a 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -34,8 +34,13 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useServerConfigs: () => new Map(), })); -vi.mock("../remoteOpen", () => ({ - useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), +vi.mock("../remoteOpen", async (importOriginal) => ({ + ...(await importOriginal()), + useRemoteOpenResolution: () => ({ + state: { mode: "local-exec" }, + isResolved: true, + environmentLabel: null, + }), })); vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn(), diff --git a/apps/web/src/components/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx new file mode 100644 index 000000000000..4261e190da34 --- /dev/null +++ b/apps/web/src/components/media/MediaActions.test.tsx @@ -0,0 +1,127 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act, type ComponentProps } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const contextMenuState = vi.hoisted(() => ({ show: vi.fn() })); + +vi.mock("../../localApi", () => ({ + readLocalApi: () => ({ + contextMenu: { show: contextMenuState.show }, + }), +})); +vi.mock("../../remoteOpen", () => ({ + remotePathCopyQualifier: () => "sol", + remotePathScpHost: (state: { mode: string }) => (state.mode === "remote-links" ? "sol" : null), + useRemoteOpenResolution: (environmentId: string | null) => ({ + state: environmentId + ? { mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } } + : { mode: "local-exec" }, + isResolved: true, + environmentLabel: "sol", + }), +})); +vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../ui/toast", () => ({ + toastManager: { add: vi.fn(), update: vi.fn() }, + stackedThreadToast: (toast: unknown) => toast, +})); +vi.mock("../ui/tooltip", async () => { + const { cloneElement, isValidElement } = await import("react"); + return { + Tooltip: ({ children }: { children: React.ReactNode }) => children, + TooltipTrigger: ({ + render, + ...rest + }: { render: React.ReactElement } & Record) => { + if (!isValidElement(render)) return render; + return cloneElement(render, rest); + }, + TooltipPopup: () => null, + }; +}); +vi.mock("../../hooks/useCopyToClipboard", () => ({ + writeTextToClipboard: vi.fn().mockResolvedValue(undefined), +})); + +import { toastManager } from "../ui/toast"; +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { MediaActions } from "./MediaActions"; + +describe("MediaActions", () => { + it("keeps the remote host when a signed asset falls back to a direct URL", async () => { + contextMenuState.show.mockResolvedValue("copy-full-path"); + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + + shot + , + ); + }); + const { onContextMenu } = renderer!.root.findByType("img").props as ComponentProps<"img">; + await act(async () => { + await onContextMenu!({ + defaultPrevented: false, + preventDefault() {}, + stopPropagation() {}, + clientX: 10, + clientY: 10, + currentTarget: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) }, + } as Parameters>[0]); + }); + expect(writeTextToClipboard).toHaveBeenCalledWith("sol:/work/shot.png", "file path"); + } finally { + await act(async () => renderer?.unmount()); + vi.clearAllMocks(); + } + }); + + it("does not describe a copied URL as a host path", async () => { + contextMenuState.show.mockResolvedValue("copy-url"); + let renderer: ReactTestRenderer | undefined; + await act(async () => { + renderer = create( + + diagram + , + ); + }); + + const img = renderer!.root.findByType("img"); + const { onContextMenu } = img.props as ComponentProps<"img">; + if (!onContextMenu) throw new Error("Media element has no context menu handler"); + await act(async () => { + await onContextMenu({ + defaultPrevented: false, + preventDefault: () => {}, + stopPropagation: () => {}, + clientX: 10, + clientY: 10, + currentTarget: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) }, + } as unknown as Parameters>[0]); + }); + + expect(vi.mocked(toastManager.add)).toHaveBeenCalledWith( + expect.objectContaining({ title: "URL copied" }), + ); + const [toast] = vi.mocked(toastManager.add).mock.calls[0]!; + expect(toast).not.toHaveProperty("description"); + }); +}); diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index d4e96c5445b4..086492127abf 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -10,6 +10,11 @@ import { useCallback, useRef, useState, type ReactElement } from "react"; import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; import { readLocalApi } from "../../localApi"; +import { + remotePathCopyQualifier, + remotePathScpHost, + useRemoteOpenResolution, +} from "../../remoteOpen"; import { assetEnvironment } from "../../state/assets"; import { readPreparedConnection } from "../../state/session"; import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; @@ -22,6 +27,7 @@ export interface MediaActionSource { readonly name: string; readonly src: string | null; readonly reference?: MediaReference; + readonly environmentId?: EnvironmentId; readonly asset?: { readonly environmentId: EnvironmentId; readonly resource: AssetResource }; readonly onOpenFile?: () => void; } @@ -78,6 +84,9 @@ export function MediaActions({ children: ReactElement; }) { const { save, copyImage } = useMediaActions(source); + const remoteOpen = useRemoteOpenResolution( + source.asset?.environmentId ?? source.environmentId ?? null, + ); const [tooltipOpen, setTooltipOpen] = useState(false); const menuOpen = useRef(false); const reference = source.reference; @@ -97,11 +106,16 @@ export function MediaActions({ typeof navigator !== "undefined" && Boolean(navigator.clipboard?.write) && typeof ClipboardItem !== "undefined"; + // An off-machine file path is ambiguous to paste, so the menu names the + // environment that holds the file. + const hostEnvironment = remotePathCopyQualifier(remoteOpen); + const copyTitle = (label: string) => + hostEnvironment ? `${label} on ${hostEnvironment}` : label; const items: ContextMenuItem[] = []; if (reference?.kind === "file") { - items.push({ id: "copy-full-path", label: "Copy full path" }); + items.push({ id: "copy-full-path", label: copyTitle("Copy full path") }); if (reference.relativePath) - items.push({ id: "copy-relative-path", label: "Copy relative path" }); + items.push({ id: "copy-relative-path", label: copyTitle("Copy relative path") }); } else if (reference?.kind === "url") { items.push({ id: "copy-url", label: "Copy URL" }); } @@ -118,9 +132,12 @@ export function MediaActions({ const action = await api.contextMenu.show(items, position); if (!action) return; failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`; + const scpHost = remotePathScpHost(remoteOpen.state); const text = action === "copy-full-path" && reference?.kind === "file" - ? reference.path + ? scpHost + ? `${scpHost}:${reference.path}` + : reference.path : action === "copy-relative-path" && reference?.kind === "file" ? reference.relativePath : action === "copy-url" && reference?.kind === "url" @@ -131,6 +148,9 @@ export function MediaActions({ toastManager.add({ type: "success", title: action === "copy-url" ? "URL copied" : "Path copied", + ...(hostEnvironment && reference?.kind === "file" + ? { description: `Path on ${hostEnvironment}` } + : {}), }); } else if (action === "open-file") { source.onOpenFile?.(); diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 0f636a526cef..fef94f893c63 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -7,7 +7,7 @@ import { import { buildRemoteOpenUrl, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveRemoteOpenState } from "./remoteOpen"; +import { remotePathCopyQualifier, remotePathScpHost, resolveRemoteOpenState } from "./remoteOpen"; const environmentId = EnvironmentId.make("environment-1"); @@ -118,6 +118,50 @@ describe("resolveRemoteOpenState", () => { }); }); +describe("remotePathCopyQualifier", () => { + const resolution = ( + mode: "local-exec" | "remote-links" | "remote-unavailable", + isResolved = true, + environmentLabel: string | null = "sol", + ) => ({ + state: (mode === "remote-links" + ? { mode, host: { kind: "ssh-alias" as const, host: "sol" } } + : { mode }) as ReturnType, + isResolved, + environmentLabel, + }); + + it("names the environment when the client is off its machine", () => { + expect(remotePathCopyQualifier(resolution("remote-links"))).toBe("sol"); + expect(remotePathCopyQualifier(resolution("remote-unavailable"))).toBe("sol"); + }); + + it("leaves local copies unqualified", () => { + expect(remotePathCopyQualifier(resolution("local-exec"))).toBeNull(); + }); + + it("stays quiet before the environment resolves", () => { + expect(remotePathCopyQualifier(resolution("remote-links", false))).toBeNull(); + }); + + it("stays quiet when the environment has no name", () => { + expect(remotePathCopyQualifier(resolution("remote-links", true, null))).toBeNull(); + }); +}); + +describe("remotePathScpHost", () => { + it("returns the connectable host for remote links", () => { + expect( + remotePathScpHost({ mode: "remote-links", host: { kind: "mdns", host: "sol.local" } }), + ).toBe("sol.local"); + }); + + it("returns null when no SSH route exists", () => { + expect(remotePathScpHost({ mode: "local-exec" })).toBeNull(); + expect(remotePathScpHost({ mode: "remote-unavailable" })).toBeNull(); + }); +}); + describe("buildRemoteOpenUrl", () => { it("builds a vscode-remote deep link", () => { expect( diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 7f23d408844e..1842914ecf5e 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -39,6 +39,8 @@ export type RemoteOpenMode = RemoteOpenState["mode"]; export interface RemoteOpenResolution { readonly state: RemoteOpenState; readonly isResolved: boolean; + /** Sidebar name of the environment, for prose that outlives the host string. */ + readonly environmentLabel: string | null; } const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; @@ -46,6 +48,7 @@ const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; const UNRESOLVED_REMOTE_OPEN: RemoteOpenResolution = { state: LOCAL_EXEC, isResolved: false, + environmentLabel: null, }; function parseHostname(url: string): string | null { @@ -114,6 +117,7 @@ export function useRemoteOpenResolution(environmentId: EnvironmentId | null): Re isDesktopRenderer: window.desktopBridge !== undefined, }), isResolved: true, + environmentLabel: presentation.entry.target.label, }; }, [presentation]); } @@ -122,6 +126,35 @@ export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteO return useRemoteOpenResolution(environmentId).state; } +/** + * One sentence for path-copy UIs: the copied path is only unambiguous on the + * machine it names. Local-exec means this machine and the caller needs no + * qualifier; anything else means the file lives on the environment host, so + * the label and clipboard value should say which one. + */ +export function remotePathCopyQualifier(resolution: { + readonly state: RemoteOpenState; + readonly isResolved: boolean; + readonly environmentLabel: string | null; +}): string | null { + if ( + !resolution.isResolved || + resolution.state.mode === "local-exec" || + resolution.environmentLabel === null + ) { + return null; + } + return resolution.environmentLabel; +} + +/** + * Connectable SSH host for scp/rsync from this machine, or null when no SSH + * route is known (local, or tunnel/relay environments without advertised hosts). + */ +export function remotePathScpHost(state: RemoteOpenState): string | null { + return state.mode === "remote-links" ? state.host.host : null; +} + /** * Editors offered in remote-link mode. The desktop app probes the machine the * renderer runs on; a browser cannot, so it offers VS Code only.