Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,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 @@ -552,6 +553,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 remoteOpen = useRemoteOpenResolution(environmentId);
const canUseShellActions =
canOperateHost &&
Expand Down Expand Up @@ -875,6 +878,16 @@ function useChatMarkdownState({
: undefined
}
revealLabel={revealInFileManagerLabel}
onSave={
threadRef && canReadFiles
? () =>
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 @@ -44,6 +44,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 @@ -68,6 +69,7 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({
onReveal,
revealLabel,
resolveCopyPaths,
onSave,
}: MarkdownFileLinkProps) {
const handleOpenInEditor = () => {
if (!onOpen) {
Expand Down Expand Up @@ -255,6 +257,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 @@ -276,6 +279,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 @@ -408,6 +415,7 @@ 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
);
}
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
59 changes: 59 additions & 0 deletions apps/web/src/components/files/useSaveFileAs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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";

/**
* 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 = useAtomQueryRunner(assetEnvironment.createUrl, {
reportFailure: false,
refresh: true,
});
return async (input: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
/** Absolute host path, or a lookup that finds it. */
readonly path: string | (() => Promise<string>);
}) => {
const toast = toastManager.add({ type: "loading", title: "Preparing file…" });
try {
const path = typeof input.path === "string" ? input.path : await input.path();
const fileName = path.split(/[\\/]/).at(-1) || 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, fileName);
toastManager.update(toast, { type: "success", title: "Download started" });
} catch (error) {
toastManager.update(
toast,
stackedThreadToast({
type: "error",
title: "Could not save file",
description: error instanceof Error ? error.message : "An error occurred.",
}),
);
}
Comment thread
0xtlt marked this conversation as resolved.
Resolved
};
}
Loading