diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index a95f8ea8e997..bd9105e8b849 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,3 +1,5 @@ +import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem"; import { environmentSession } from "../../state/session"; import { NativeStackScreenOptions } from "../../native/StackHeader"; @@ -783,7 +785,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { title: "Copy path", icon: "doc.on.doc", inline: false, - onPress: () => copyTextWithHaptic(relativePath), + onPress: () => + copyTextWithHaptic( + formatFilePathForClipboard( + relativePath, + resolvePathLinkTarget(relativePath, cwd ?? ""), + ), + ), } as const, ]), fileData?.contents != null diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 46d9fccac17f..2b45fc547afc 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -230,7 +230,7 @@ import { } from "../files/filePath"; import { waitForThreadShellReady } from "./threadForkNavigation"; import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; -import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; +import { fileChipClipboardPath, fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; import { useFileChipShare } from "./useFileChipShare"; import { MarkdownImageAvailableWidthContext, @@ -1869,13 +1869,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onFileContextMenuAction: (href, actionId) => { const target = resolveFileChipTarget(href, props.workspaceRoot); if (!target) return; - switch (actionId as FileChipAction) { + switch (actionId) { case "copy-full-path": - if (target.fullPath) copyTextWithHaptic(target.fullPath); - return; - case "copy-relative-path": - if (target.relativePath) copyTextWithHaptic(target.relativePath); + case "copy-relative-path": { + const clipboardPath = fileChipClipboardPath(target, actionId); + if (clipboardPath !== undefined) copyTextWithHaptic(clipboardPath); return; + } case "open-file": onMarkdownLinkPress(href); return; diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index 1627a72da5de..999c403964b3 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -1,7 +1,23 @@ import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { fileChipMenu, fileChipShareSource, resolveFileChipTarget } from "./fileChipMenu"; +import { + fileChipClipboardPath, + fileChipMenu, + fileChipShareSource, + resolveFileChipTarget, +} from "./fileChipMenu"; + +it("copies native paths from a relative file chip in a Windows workspace", () => { + const target = resolveFileChipTarget("reports/Device Users.csv", "C:/repo"); + if (!target) throw new Error("File-chip target unavailable"); + expect(fileChipClipboardPath(target, "copy-full-path")).toBe( + String.raw`C:\repo\reports\Device Users.csv`, + ); + expect(fileChipClipboardPath(target, "copy-relative-path")).toBe( + String.raw`reports\Device Users.csv`, + ); +}); describe("resolveFileChipTarget", () => { it("resolves a workspace-relative link to both paths", () => { diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index d55d3e4b5964..d020e5f542a3 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,3 +1,4 @@ +import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { fileBasename } from "@t3tools/shared/path"; import type { ThreadId } from "@t3tools/contracts"; import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks"; @@ -10,13 +11,19 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save"; - export interface FileChipTarget { readonly fullPath?: string; readonly relativePath?: string; } +export function fileChipClipboardPath( + target: FileChipTarget, + action: "copy-full-path" | "copy-relative-path", +): string | undefined { + const path = action === "copy-full-path" ? target.fullPath : target.relativePath; + return path === undefined ? undefined : formatFilePathForClipboard(path, target.fullPath); +} + export function resolveFileChipTarget( href: string, workspaceRoot: string | null | undefined, diff --git a/apps/mobile/src/lib/mediaActions.test.ts b/apps/mobile/src/lib/mediaActions.test.ts index e270f723d8d6..6670587b1ca2 100644 --- a/apps/mobile/src/lib/mediaActions.test.ts +++ b/apps/mobile/src/lib/mediaActions.test.ts @@ -5,6 +5,7 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { beforeEach, expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ @@ -96,6 +97,56 @@ beforeEach(() => { state.copy.mockReset(); }); +it.each([ + [ + "C:/repo/assets/shot.png", + "C:/repo", + String.raw`C:\repo\assets\shot.png`, + String.raw`assets\shot.png`, + ], + [ + String.raw`\\server\share\repo/assets/clip.mp4`, + String.raw`\\server\share\repo`, + String.raw`\\server\share\repo\assets\clip.mp4`, + String.raw`assets\clip.mp4`, + ], +] as const)("copies full and relative media paths for %s", (path, root, full, relative) => { + const media = useMediaActions({ + environmentId, + threadId, + resource: { _tag: "media-file", threadId, path }, + reference: mediaFileReference(path, root), + name: path.split(/[\\/]/).at(-1)!, + mimeType: path.endsWith(".mp4") ? "video/mp4" : "image/png", + }); + for (const [actionId, expected] of [ + ["copy-full-path", full], + ["copy-relative-path", relative], + ] as const) { + const action = media.actions.find(({ id }) => id === actionId); + if (!action) throw new Error(`Missing media action: ${actionId}`); + action.run(); + expect(state.copy).toHaveBeenLastCalledWith(expected); + } + expect(state.refresh).not.toHaveBeenCalled(); + expect(state.download).not.toHaveBeenCalled(); +}); + +it("preserves media URLs when copying", () => { + const url = "https://cdn.test/assets/media?path=C:/repo/media"; + const media = useMediaActions({ + uri: url, + name: "media", + mimeType: "video/mp4", + reference: { kind: "url", url }, + }); + const action = media.actions.find(({ id }) => id === "copy-url"); + if (!action) throw new Error("Copy URL action missing"); + action.run(); + expect(state.copy).toHaveBeenCalledWith(url); + expect(state.refresh).not.toHaveBeenCalled(); +}); + it.each(["workspace-file", "media-file"] as const)( "waits for the %s grant before enabling host menu actions", (_tag) => { diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts index a0a9a678205f..dde7e2fe6b5b 100644 --- a/apps/mobile/src/state/mediaActions.ts +++ b/apps/mobile/src/state/mediaActions.ts @@ -2,7 +2,10 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime import * as Dialog from "../components/ui/Dialog"; import type { MediaActionsSource } from "../lib/mediaActionsSource"; import { useNavigation } from "@react-navigation/native"; -import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; +import { + formatFilePathForClipboard, + type MediaActionId, +} from "@t3tools/client-runtime/media-actions"; import { AuthFilesystemReadScope, type EnvironmentId, @@ -108,16 +111,17 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi { id: "copy-full-path" as const, title: "Copy full path", - run: () => copyTextWithHaptic(reference.path), + run: () => copyTextWithHaptic(formatFilePathForClipboard(reference.path)), }, ] : []), - ...(relativePath + ...(relativePath && reference?.kind === "file" ? [ { id: "copy-relative-path" as const, title: "Copy relative path", - run: () => copyTextWithHaptic(relativePath), + run: () => + copyTextWithHaptic(formatFilePathForClipboard(relativePath, reference.path)), }, ] : []), diff --git a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx index 0c0a20593305..51ce200a53e6 100644 --- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx +++ b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx @@ -132,6 +132,7 @@ it.each([ ["local", null, "/tmp/project"], ["worktree", null, null], ["worktree", "/tmp/worktree", "/tmp/worktree"], + ["worktree", "C:/repo/worktree", String.raw`C:\repo\worktree`], ] as const)( "copies only an existing workspace path with mode %s and worktree %s", async (envMode, worktreePath, copiedPath) => { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 4d6fbef50a6e..d27dc428ac3e 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,4 +1,5 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { readLocalApi } from "../localApi"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { ComposerContextLabel } from "./ComposerContextLabel"; @@ -118,13 +119,14 @@ function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) }) .then((action) => { if (action !== "copy-path") return; - void writeTextToClipboard(workspacePath, "workspace path").then( + const clipboardPath = formatFilePathForClipboard(workspacePath); + void writeTextToClipboard(clipboardPath, "workspace path").then( (didCopy) => { if (didCopy) { toastManager.add({ type: "success", title: "Path copied", - description: workspacePath, + description: clipboardPath, }); } }, diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 361b114250c4..05a900387724 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -11,6 +11,8 @@ import "../index.css"; import { useTheme } from "../hooks/useTheme"; import { applyThemeColorPreview, getStandardThemeColors } from "../themePalette"; +import { readLocalApi } from "../localApi"; +import { toastManager } from "./ui/toast"; import { filesystemEnvironment } from "../state/filesystem"; import { useRightPanelStore } from "../rightPanelStore"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; @@ -1262,6 +1264,60 @@ describe("ChatMarkdown thread find", () => { ); }); +it.each([ + { + name: "Windows drive paths on a Mac client", + platform: "MacIntel", + cwd: "C:/repo", + href: "C:/repo/reports/Device Users.csv:12:3", + full: String.raw`C:\repo\reports\Device Users.csv:12:3`, + relative: String.raw`reports\Device Users.csv:12:3`, + }, + { + name: "UNC paths on a Mac client", + platform: "MacIntel", + cwd: String.raw`\\server\share\repo`, + href: "file://server/share/repo/reports/report.csv", + full: String.raw`\\server\share\repo\reports\report.csv`, + relative: String.raw`reports\report.csv`, + }, + { + name: "POSIX paths on a Windows client", + platform: "Win32", + cwd: "/home/repo", + href: "/home/repo/reports/report.csv", + full: "/home/repo/reports/report.csv", + relative: "reports/report.csv", + }, +])("copies $name and shows the copied text", async ({ platform, cwd, href, full, relative }) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + vi.stubGlobal("ClipboardItem", undefined); + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + const toast = vi.spyOn(toastManager, "add"); + const api = readLocalApi(); + if (!api) throw new Error("Local API unavailable"); + const choose = vi.spyOn(api.contextMenu, "show"); + const container = await renderText(`[File](<${href}>)`, { + cwd, + environmentId: EnvironmentId.make("env-file-copy"), + }); + const link = container.querySelector(".chat-markdown-file-link"); + if (!link) throw new Error("File link unavailable"); + for (const [action, expected] of [ + ["copy-full", full], + ["copy-relative", relative], + ] as const) { + choose.mockResolvedValue(action); + await act(async () => { + link.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })); + }); + expect(writeText).toHaveBeenLastCalledWith(expected); + expect(toast).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "success", description: expected }), + ); + } +}); + describe("ChatMarkdown math", () => { const renderMath = async ( text: string, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 000a3617953e..baf23d2b4cec 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { MarkdownFindContext, findRevealRef } from "./chat/markdownFindContext"; import { buildFileLinkParentSuffixByPath, @@ -2119,7 +2120,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } - const value = resolveValue(); + const value = resolveValue().then((text) => formatFilePathForClipboard(text, targetPath)); // Start the write before workspace lookup finishes so Safari retains the user gesture. const write = typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined" diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 43fd030f8e18..49a84f9900d8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,5 @@ +import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; @@ -3941,12 +3943,16 @@ export default function ChatView(props: ChatViewProps) { return; } - void navigator.clipboard.writeText(relativePath).then( + const clipboardPath = formatFilePathForClipboard( + relativePath, + resolvePathLinkTarget(relativePath, gitCwd ?? activeProjectCwd ?? ""), + ); + void navigator.clipboard.writeText(clipboardPath).then( () => { toastManager.add({ type: "success", title: "Path copied", - description: relativePath, + description: clipboardPath, }); }, (error) => { diff --git a/apps/web/src/components/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx index bcf2d64db418..1b12a92e74c9 100644 --- a/apps/web/src/components/media/MediaActions.test.tsx +++ b/apps/web/src/components/media/MediaActions.test.tsx @@ -5,6 +5,7 @@ import { type AuthSessionState, } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { createElement, isValidElement, type ReactNode } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -24,6 +25,7 @@ const state = vi.hoisted(() => ({ showMenu: vi.fn(), openFile: vi.fn(), clipboard: vi.fn(), + copyText: vi.fn(), menuFinished: null as (() => void) | null, })); @@ -33,7 +35,7 @@ vi.mock("react", async (importOriginal) => ({ useRef: (current: A) => ({ current }), useState: (initial: A) => [initial, () => {}], })); -vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() })); +vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: state.copyText })); vi.mock("../../localApi", () => ({ readLocalApi: () => ({ contextMenu: { show: state.showMenu } }), })); @@ -128,6 +130,7 @@ beforeEach(() => { state.png.mockReset().mockResolvedValue(new Blob(["png"], { type: "image/png" })); state.showMenu.mockReset().mockResolvedValue(null); state.openFile.mockReset(); + state.copyText.mockReset().mockResolvedValue(true); state.menuFinished = null; class TestClipboardItem { constructor(readonly items: Record>) {} @@ -141,6 +144,56 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); +it.each([ + [ + "image", + "C:/repo/assets/shot.png", + "C:/repo", + String.raw`C:\repo\assets\shot.png`, + String.raw`assets\shot.png`, + ], + [ + "video", + String.raw`\\server\share\repo/assets/clip.mp4`, + String.raw`\\server\share\repo`, + String.raw`\\server\share\repo\assets\clip.mp4`, + String.raw`assets\clip.mp4`, + ], +] as const)( + "copies full and relative paths for %s %s", + async (kind, path, root, full, relative) => { + const source: MediaActionSource = { + ...hostSource(), + kind, + reference: mediaFileReference(path, root), + }; + for (const [action, expected] of [ + ["copy-full-path", full], + ["copy-relative-path", relative], + ] as const) { + const completed = deferred(); + state.menuFinished = () => completed.resolve(); + state.showMenu.mockResolvedValue(action); + openMenu(source); + await completed.promise; + expect(state.copyText).toHaveBeenLastCalledWith(expected, "file path"); + } + expect(state.mint).not.toHaveBeenCalled(); + expect(state.download).not.toHaveBeenCalled(); + }, +); + +it("preserves media URLs when copying", async () => { + const url = "https://cdn.test/assets/clip.mp4?path=C:/repo/media"; + const completed = deferred(); + state.menuFinished = () => completed.resolve(); + state.showMenu.mockResolvedValue("copy-url"); + openMenu({ ...hostSource(), kind: "video", src: url, reference: { kind: "url", url } }); + await completed.promise; + expect(state.copyText).toHaveBeenCalledWith(url, "URL"); + expect(state.mint).not.toHaveBeenCalled(); +}); + it.each(["workspace-file", "media-file"] as const)( "waits for the %s grant before enabling host menu actions", (_tag) => { diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index 45b273e21305..314f7a6435d2 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -1,4 +1,7 @@ -import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; +import { + formatFilePathForClipboard, + type MediaActionId, +} from "@t3tools/client-runtime/media-actions"; import { mediaReferenceFileName, type MediaReference, @@ -166,7 +169,9 @@ export function MediaActions({ ? reference.url : undefined; if (text !== undefined) { - await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL"); + const clipboardText = + reference?.kind === "file" ? formatFilePathForClipboard(text, reference.path) : text; + await writeTextToClipboard(clipboardText, reference?.kind === "file" ? "file path" : "URL"); toastManager.add({ type: "success", title: action === "copy-url" ? "URL copied" : "Path copied", diff --git a/packages/client-runtime/src/mediaActions.ts b/packages/client-runtime/src/mediaActions.ts index fda507b17bb1..85bdcf67812b 100644 --- a/packages/client-runtime/src/mediaActions.ts +++ b/packages/client-runtime/src/mediaActions.ts @@ -1,3 +1,10 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +// The file host can use a different path style from the client. +export function formatFilePathForClipboard(path: string, hostPath = path): string { + return isWindowsAbsolutePath(hostPath) ? path.replaceAll("/", "\\") : path; +} + export type MediaActionId = | "copy-full-path" | "copy-relative-path"