Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,15 @@ async function setAllowed(allowed: boolean) {
});
}

it("removes host actions after revocation while keeping file preview and copying", async () => {
it("removes host actions after revocation while keeping file preview, saving, and copying", async () => {
using _setup = setup();
await using screen = await renderMarkdown("[Readme](docs/readme.md)");
await screen.openContextMenu();
expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]);
expect(offeredActions()).toEqual(["open", "reveal", "save", "copy-relative", "copy-full"]);
await setAllowed(false);
state.choose.mockResolvedValue("copy-full");
await screen.openContextMenu();
expect(offeredActions()).toEqual(["copy-relative", "copy-full"]);
expect(offeredActions()).toEqual(["save", "copy-relative", "copy-full"]);
await vi.waitFor(() => expect(state.copy).toHaveBeenCalledWith("/work/docs/readme.md"));
await act(async () => screen.anchor().click());
expect(state.openFile).toHaveBeenCalledWith(threadRef, "docs/readme.md", undefined);
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import {
resolveMarkdownFileHostPath,
} from "../markdownFileHostPath";
import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
import { useSaveFileAs } from "./files/useSaveFileAs";
import { isElectron } from "../env";
import { readLocalApi } from "../localApi";
import { cn } from "../lib/utils";
Expand Down Expand Up @@ -554,6 +555,8 @@ function useChatMarkdownState({
const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null;
const canOperateHost = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope);
const canReadFiles = useEnvironmentScope(environmentId, AuthFilesystemReadScope);
const saveFileAs = useSaveFileAs();
const isClientHost = useAtomValue(clientHostEnvironmentAtom(environmentId));
const remoteOpen = useRemoteOpenResolution(environmentId);
const canUseShellActions =
Expand Down Expand Up @@ -887,6 +890,16 @@ function useChatMarkdownState({
: undefined
}
revealLabel={revealInFileManagerLabel}
onSave={
threadRef && canReadFiles && !isAppBundle && !/[\\/]$/.test(fileLinkMeta.filePath)
? () =>
void saveFileAs({
environmentId: threadRef.environmentId,
threadId: threadRef.threadId,
path: async () => (await resolveHostPath()).absolutePath,
})
: undefined
}
onOpenInBrowser={
threadRef &&
canOperatePreview &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/chat/MarkdownFileLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface MarkdownFileLinkProps {
onOpenMedia?: (() => void) | undefined;
onReveal?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
revealLabel?: string | undefined;
onSave?: (() => void) | undefined;
}

const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link";
Expand All @@ -71,6 +72,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({
onReveal,
revealLabel,
resolveCopyPaths,
onSave,
}: MarkdownFileLinkProps) {
const handleOpenInEditor = () => {
if (!onOpen) {
Expand Down Expand Up @@ -258,6 +260,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({
? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const)
: []),
...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []),
...(onSave ? ([{ id: "save", label: "Save file" }] as const) : []),
...copyItems.map(({ id, label }) => ({ id, label })),
] as const,
position,
Expand All @@ -279,6 +282,10 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({
handleRevealInFileManager();
return;
}
if (clicked === "save") {
onSave?.();
return;
}
if (clicked === "copy-relative" || clicked === "copy-full") {
handleCopy(
() =>
Expand Down Expand Up @@ -422,7 +429,8 @@ function areMarkdownFileLinkPropsEqual(
previous.onOpenMedia === next.onOpenMedia &&
previous.onReveal === next.onReveal &&
previous.resolveCopyPaths === next.resolveCopyPaths &&
previous.revealLabel === next.revealLabel
previous.revealLabel === next.revealLabel &&
previous.onSave === next.onSave
);
}

Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { stackedThreadToast, toastManager } from "~/components/ui/toast";
import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { buildFileReviewComment } from "~/reviewCommentContext";
import { assetEnvironment } from "~/state/assets";
import { useSaveFileAs } from "./useSaveFileAs";
import { usePreviewAvailable } from "~/browser/previewRuntime";
import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
import { previewEnvironment } from "~/state/preview";
Expand Down Expand Up @@ -1177,6 +1178,18 @@ export default function FilePreviewPanel({
: relativePath && attachment === undefined
? resolvePathLinkTarget(relativePath, cwd)
: null;
const saveFileAs = useSaveFileAs();
// Large text and binary or document files render their own Save button.
const rendersOwnSave =
!isMedia &&
!renderBrowserFile &&
(isDocument ||
file.readError?.failure === "binary_file" ||
(file.data?.truncated === true && !isMarkdown && !isMermaid && !isHtml));
const handleSave =
!draft && previewPath !== null && absolutePath !== null && !rendersOwnSave
? () => void saveFileAs({ environmentId, threadId: threadRef.threadId, path: absolutePath })
: null;
const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
// Mounting the file read under the workspace mutation id refetches it after each mutation.
useAtomMount(
Expand Down Expand Up @@ -1329,6 +1342,11 @@ export default function FilePreviewPanel({
<Globe2 className="size-3.5" />
</FileSurfaceAction>
) : null}
{handleSave ? (
<FileSurfaceAction label="Save file" onPress={handleSave}>
<DownloadIcon className="size-3.5" />
</FileSurfaceAction>
) : null}
{!isHostFile && previewPath !== null ? (
<FileSurfaceAction
label={explorerOpen ? "Hide file explorer" : "Show file explorer"}
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/components/files/useSaveFileAs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";

import { assetEnvironment } from "../../state/assets";
import { readPreparedConnection } from "../../state/session";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
import { downloadMedia } from "../media/mediaContent";
import { stackedThreadToast, toastManager } from "../ui/toast";

interface SaveFileInput {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
/** Absolute host path, or a lookup that finds it. */
readonly path: string | (() => Promise<string>);
}

async function downloadHostFile(
createAssetUrl: ReturnType<typeof useCreateAssetUrl>,
input: SaveFileInput,
) {
const path = typeof input.path === "string" ? input.path : await input.path();
const connection = readPreparedConnection(input.environmentId);
if (!connection) throw new Error("Reconnect to this environment and try again.");
const result = await createAssetUrl({
environmentId: input.environmentId,
input: {
resource: { _tag: "media-file", threadId: input.threadId, path, disposition: "attachment" },
},
});
if (result._tag === "Failure") throw squashAtomCommandFailure(result);
const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
if (!url) throw new Error("The environment returned an invalid file URL.");
await downloadMedia(url, path.split(/[\\/]/).at(-1) || path);
}

function useCreateAssetUrl() {
return useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true });
}

/**
* Copies a host file to the viewing device, wherever the environment runs.
* Desktop asks where to save it; browsers follow their download settings.
*/
export function useSaveFileAs() {
const createAssetUrl = useCreateAssetUrl();
return (input: SaveFileInput) => {
const toast = toastManager.add({ type: "loading", title: "Preparing file…" });
return downloadHostFile(createAssetUrl, input).then(
() => toastManager.update(toast, { type: "success", title: "Download started" }),
(error: unknown) =>
toastManager.update(
toast,
stackedThreadToast({
type: "error",
title: "Could not save file",
description: error instanceof Error ? error.message : "An error occurred.",
}),
),
);
};
}
Loading