Skip to content
Closed
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
30 changes: 28 additions & 2 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof import("../remoteOpen")>()),
useRemoteOpenResolution: vi.fn(() => ({
state: { mode: "local-exec" },
isResolved: true,
environmentLabel: "sol",
})),
}));
vi.mock("../editorPreferences", () => ({
useOpenInPreferredEditor: () => vi.fn(),
Expand Down Expand Up @@ -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(
<ChatMarkdown
cwd="/home/saphid/project"
text={"[Document](/home/saphid/theos-rules-receipts.md)"}
/>,
);

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(
<ChatMarkdown
Expand Down
47 changes: 42 additions & 5 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,12 @@ import {
import { readLocalApi } from "../localApi";
import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
import { cn } from "../lib/utils";
import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen";
import {
remotePathCopyQualifier,
remotePathScpHost,
useRemoteOpenResolution,
type RemoteOpenMode,
} from "../remoteOpen";
import { useRightPanelStore } from "../rightPanelStore";
import { readThreadShell, useProjects } from "../state/entities";
import { serverEnvironment } from "../state/server";
Expand Down Expand Up @@ -1139,6 +1144,11 @@ interface MarkdownFileLinkProps {
/** What the files panel opens: workspace-relative inside the workspace, the
absolute host path outside it, null when the panel cannot show the file. */
panelPath: string | null;
/** Sidebar name of the environment hosting the file when the viewing client
is not on that machine; null on local environments. */
hostEnvironment: string | null;
/** Connectable SSH host for scp/rsync from this machine; null when unknown. */
scpHost: string | null;
line?: number | undefined;
label: string;
copyMarkdown: string;
Expand Down Expand Up @@ -1638,6 +1648,7 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
: undefined);
const actionsSource: MediaActionSource = {
kind: props.kind ?? "image",
environmentId: props.environmentId,
name: props.alt || (props.kind ?? "image"),
src,
...(fallbackSrc === undefined
Expand Down Expand Up @@ -1878,6 +1889,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
iconPath,
displayPath,
panelPath,
hostEnvironment,
scpHost,
line,
label,
copyMarkdown,
Expand Down Expand Up @@ -2061,6 +2074,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
const api = readLocalApi();
if (!api) return;

// Off-machine paths are ambiguous to paste, so the menu, the toast, and
// the scp form all name the host that holds the file.
const copyTitle = (label: string) =>
hostEnvironment ? `${label} on ${hostEnvironment}` : label;
try {
const clicked = await api.contextMenu.show(
[
Expand All @@ -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,
);
Expand All @@ -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(
Expand All @@ -2112,12 +2131,15 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
handleOpenInBrowser,
handleOpenInEditor,
handleRevealInFileManager,
hostEnvironment,
iconPath,
onOpenInBrowser,
onOpenMedia,
onOpen,
onReveal,
openInEditorMenuLabel,
revealLabel,
scpHost,
targetPath,
],
);
Expand Down Expand Up @@ -2164,6 +2186,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
render={<a href={href} />}
className={MARKDOWN_FILE_LINK_CLASS_NAME}
data-markdown-copy={copyMarkdown}
{...(hostEnvironment !== null ? { "data-host-environment": hostEnvironment } : {})}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
Expand All @@ -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}
>
Expand All @@ -2203,6 +2227,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
<div className="overflow-x-auto whitespace-nowrap [scrollbar-color:color-mix(in_srgb,var(--contrast-border)_78%,transparent)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--contrast-border)_78%,transparent)] [&::-webkit-scrollbar-track]:bg-transparent">
{targetPath}
</div>
{hostEnvironment !== null && (
<div className="text-muted-foreground">Path on {hostEnvironment}</div>
)}
</TooltipPopup>
</Tooltip>
);
Expand All @@ -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 &&
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -2628,6 +2663,8 @@ function useChatMarkdownState({
resolvedTheme,
revealInFileManagerLabel,
revealMarkdownFileInFileManager,
remoteHostEnvironment,
remoteScpHost,
threadRef,
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("../remoteOpen")>()),
useRemoteOpenResolution: () => ({
state: { mode: "local-exec" },
isResolved: true,
environmentLabel: null,
}),
}));
vi.mock("../editorPreferences", () => ({
useOpenInPreferredEditor: () => vi.fn(),
Expand Down
127 changes: 127 additions & 0 deletions apps/web/src/components/media/MediaActions.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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(
<MediaActions
source={{
kind: "image",
name: "shot.png",
src: "https://fallback.test/shot.png",
environmentId: EnvironmentId.make("remote"),
reference: { kind: "file", path: "/work/shot.png" },
}}
>
<img alt="shot" />
</MediaActions>,
);
});
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<NonNullable<typeof onContextMenu>>[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(
<MediaActions
source={{
kind: "image",
name: "diagram.png",
src: "https://example.com/diagram.png",
reference: { kind: "url", url: "https://example.com/diagram.png" },
}}
>
<img alt="diagram" />
</MediaActions>,
);
});

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<NonNullable<typeof onContextMenu>>[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");
});
});
Loading
Loading