From b9b6dbf2004e29b9c56044ad591340c8dd603c65 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 04:16:12 -0700 Subject: [PATCH] fix(web): file preview wrap buttons no longer turn off word wrap everywhere The wrap button in the file and attachment previews wrote the global Word wrap setting, so one click there turned wrapping off for chat code blocks, tables, diffs, and every file preview. Those buttons now keep a per-view override, like the diff panel and PR code tab already do. Settings > Appearance > Word wrap is the only place that changes the default. Co-Authored-By: Claude Opus 5.5 --- .../AttachmentFilePreview.browser.test.tsx | 22 ++++++++++++++++++- .../files/AttachmentFilePreview.tsx | 13 ++++++----- .../src/components/files/FilePreviewPanel.tsx | 12 +++++++--- .../src/components/files/LargeTextViewer.tsx | 5 ++++- .../files/ReadOnlySourcePreview.tsx | 5 ++++- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 9879edfeedbd8..7492313f3c7e8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -18,7 +18,9 @@ vi.mock(import("~/assets/assetUrls"), async (original) => ({ // The real source view highlights through a worker pool; the text it receives is what matters here. // Never load the original: its highlighter graph can outlast waitFor on a cold CI browser. vi.mock(import("./ReadOnlySourcePreview"), () => ({ - default: ({ text }: { text: string }) =>
{text}
, + default: ({ text, wordWrap }: { text: string; wordWrap?: boolean }) => ( +
{text}
+ ), })); // The Word engine compiles about 30 MB of WebAssembly in a fresh worker per document, which took @@ -41,6 +43,7 @@ vi.mock(import("./office/WordDocument"), async () => { }; }); +import { getClientSettings } from "~/hooks/useSettings"; import { AttachmentFilePreview } from "./AttachmentFilePreview"; import LargeTextViewer from "./LargeTextViewer"; import VirtualTablePreview from "./VirtualTablePreview"; @@ -492,6 +495,7 @@ describe("large text preview recovery", () => { workspaceMutationId={mutation} revealLine={null} revealRequestId={0} + wordWrap /> ); @@ -2341,6 +2345,22 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); +it("unwraps one text attachment without turning off the word wrap setting", async () => { + const file = new Blob(["a long line"], { type: "text/plain" }); + await using screen = await render( + , + ); + await screen.getByRole("button", { name: "Disable word wrap" }).click(); + await expect.element(screen.getByRole("button", { name: "Enable word wrap" })).toBeVisible(); + expect(getClientSettings().wordWrap).toBe(true); + expect(screen.container.querySelector("pre")?.dataset.wordWrap).toBe("false"); +}); + it("mounts a bounded set of saved notebook outputs as the reader scrolls", async () => { const notebook = { nbformat: 4, diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index e57289bd60144..1d8bc609ff56a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -21,7 +21,7 @@ import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { BinaryFilePreview } from "./BinaryFilePreview"; @@ -55,7 +55,7 @@ const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); const STALE_URL_MS = 5 * 60_000; -export function ReadOnlySourcePreview(props: { name: string; text: string }) { +export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) { return ( }> @@ -214,7 +214,9 @@ export function AttachmentFilePreview(props: { return () => controller.abort(); }, [url, needsText, streamsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const showsRawText = failure === null && needsText && @@ -272,6 +274,7 @@ export function AttachmentFilePreview(props: { onRetry={() => setRevision((value) => value + 1)} delimiter={rendered ? delimiter : null} copyContents={copyContents} + wordWrap={wordWrap} /> ) : !url || (needsText && !content) ? ( @@ -292,7 +295,7 @@ export function AttachmentFilePreview(props: { /> ) : ( - + ) ) : kind === "archive" || kind === "sqlite" ? ( updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 56854b3ec6f72..03e682758e321 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -56,7 +56,7 @@ import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { MorphIcon } from "~/components/MorphIcon"; import { useRemoteOpenState } from "~/remoteOpen"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; @@ -996,7 +996,9 @@ export default function FilePreviewPanel({ attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const draft = typeof composerDraftTarget === "string"; const { resolvedTheme } = useTheme(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); @@ -1291,7 +1293,7 @@ export default function FilePreviewPanel({ updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > @@ -1403,6 +1405,7 @@ export default function FilePreviewPanel({ workspaceMutationId={workspaceMutationId} revealLine={revealLine} revealRequestId={revealRequestId} + wordWrap={wordWrap} /> ) : relativePath && absolutePath && @@ -1488,6 +1491,7 @@ export default function FilePreviewPanel({ text={file.data.contents} cacheKey={projectFileCacheKey(cwd, relativePath, file.data.contents)} onPostRender={onFilePostRender} + wordWrap={wordWrap} /> ) : ( @@ -1557,6 +1561,7 @@ export function WorkspaceLargeTextPreview(props: { workspaceMutationId: string | null; revealLine: number | null; revealRequestId: number; + wordWrap: boolean; }) { const resource = { _tag: "media-file", @@ -1659,6 +1664,7 @@ export function WorkspaceLargeTextPreview(props: { delimiter={props.delimiter} revealLine={props.revealLine} revealRequestId={props.revealRequestId} + wordWrap={props.wordWrap} /> diff --git a/apps/web/src/components/files/LargeTextViewer.tsx b/apps/web/src/components/files/LargeTextViewer.tsx index a12842f7047c9..097f0dc717f70 100644 --- a/apps/web/src/components/files/LargeTextViewer.tsx +++ b/apps/web/src/components/files/LargeTextViewer.tsx @@ -36,6 +36,8 @@ export default function LargeTextViewer(props: { readonly delimiter: "," | "\t" | null; readonly revealLine?: number | null; readonly revealRequestId?: number; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const [meta, setMeta] = useState | null>(null); const [pages, setPages] = useState(new Map()); @@ -46,7 +48,8 @@ export default function LargeTextViewer(props: { reject: (cause: Error) => void; } | null>(null); const pending = useRef(new Set()); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the prefix fetch and indexing worker, cancelling requests and pending copies when the source changes or unmounts. useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- A replacement source must discard the previous worker's error and pages. diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index b35143fd36575..2fdfe04a238d6 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -13,9 +13,12 @@ export default function ReadOnlySourcePreview(props: { readonly text: string; readonly cacheKey?: string; readonly onPostRender?: FileOptions["onPostRender"]; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const resolvedTheme = useRenderedAppearance(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; return (