From 0fa2a0895f17e63f279aab56b7886dedfd8def5d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 19 Apr 2026 09:07:51 +0800 Subject: [PATCH] feat(appearance): apply UI zoom level to terminal and Monaco editors Extend the existing appearance zoom setting so it also scales xterm.js terminals and Monaco editors (diff viewer, three-pane merge editor, file workspace editor), which previously rendered at a hard-coded 13px regardless of zoom. - Terminals read zoom at init and update term.options.fontSize live on zoom change, refitting after a double rAF so xterm's renderer has recomputed cell metrics. Font size is rounded to an integer to avoid subpixel blur in the canvas renderer. - Monaco editors derive fontSize from zoomLevel; three-pane merge editor memoizes its options object to avoid redundant updateOptions calls on unrelated re-renders. --- src/components/diff/diff-viewer.tsx | 6 ++- src/components/files/file-workspace-panel.tsx | 6 ++- .../merge/three-pane-merge-editor.tsx | 43 ++++++++++++------- src/components/terminal/terminal-view.tsx | 34 +++++++++++++-- 4 files changed, 68 insertions(+), 21 deletions(-) diff --git a/src/components/diff/diff-viewer.tsx b/src/components/diff/diff-viewer.tsx index b419d32..32040b8 100644 --- a/src/components/diff/diff-viewer.tsx +++ b/src/components/diff/diff-viewer.tsx @@ -6,8 +6,11 @@ import { ChevronLeft, ChevronRight } from "lucide-react" import type { DiffOnMount } from "@monaco-editor/react" import type { editor as MonacoEditorNs } from "monaco-editor" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" +import { useZoomLevel } from "@/hooks/use-appearance" import { cn } from "@/lib/utils" +const EDITOR_BASE_FONT_SIZE = 13 + import "@/lib/monaco-local" const MonacoDiffEditor = dynamic( @@ -36,6 +39,7 @@ export function DiffViewer({ className, }: DiffViewerProps) { const editorTheme = useMonacoThemeSync() + const { zoomLevel } = useZoomLevel() const diffEditorRef = useRef( null ) @@ -187,7 +191,7 @@ export function DiffViewer({ renderSideBySide: true, renderSideBySideInlineBreakpoint: 0, automaticLayout: true, - fontSize: 13, + fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100, minimap: { enabled: false }, scrollBeyondLastLine: false, renderOverviewRuler: false, diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index 54b4664..f5fe5eb 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -24,7 +24,10 @@ import { Streamdown } from "streamdown" import { readFileBase64 } from "@/lib/api" import { normalizeMathDelimiters } from "@/components/ai-elements/message" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" +import { useZoomLevel } from "@/hooks/use-appearance" import { ScrollArea } from "@/components/ui/scroll-area" + +const EDITOR_BASE_FONT_SIZE = 13 import "@/lib/monaco-local" const math = createMathPlugin({ singleDollarTextMath: true }) @@ -770,6 +773,7 @@ export function FileWorkspacePanel() { const cursorListenerRef = useRef<{ dispose: () => void } | null>(null) const gitChangeDecorationsRef = useRef([]) const editorTheme = useMonacoThemeSync() + const { zoomLevel } = useZoomLevel() const [editorMountVersion, setEditorMountVersion] = useState(0) const [cursorLine, setCursorLine] = useState(1) const [collapsedFiles, setCollapsedFiles] = useState>( @@ -1610,7 +1614,7 @@ export function FileWorkspacePanel() { readOnly: !canEdit, minimap: { enabled: false }, automaticLayout: true, - fontSize: 13, + fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100, lineNumbersMinChars, lineDecorationsWidth: 10, wordWrap: "off", diff --git a/src/components/merge/three-pane-merge-editor.tsx b/src/components/merge/three-pane-merge-editor.tsx index c0a6b1f..6b0cd58 100644 --- a/src/components/merge/three-pane-merge-editor.tsx +++ b/src/components/merge/three-pane-merge-editor.tsx @@ -7,7 +7,10 @@ import type { editor as MonacoEditorNs, IRange } from "monaco-editor" import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react" import { useTranslations } from "next-intl" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" +import { useZoomLevel } from "@/hooks/use-appearance" import { cn } from "@/lib/utils" + +const EDITOR_BASE_FONT_SIZE = 13 import { Button } from "@/components/ui/button" import { ResizableHandle, @@ -55,6 +58,7 @@ export function ThreePaneMergeEditor({ }: ThreePaneMergeEditorProps) { const t = useTranslations("MergePage") const editorTheme = useMonacoThemeSync() + const { zoomLevel } = useZoomLevel() const { registerEditor } = useSyncScroll() const leftEditorRef = useRef( @@ -497,23 +501,30 @@ export function ThreePaneMergeEditor({ // --------------------------------------------------------------------------- // Editor options // --------------------------------------------------------------------------- - const editorOptions: MonacoEditorNs.IStandaloneEditorConstructionOptions = { - fontSize: 13, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - automaticLayout: true, - lineNumbers: "on", - glyphMargin: true, - folding: false, - wordWrap: "off", - overviewRulerLanes: 0, - } + const editorOptions = + useMemo( + () => ({ + fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + lineNumbers: "on", + glyphMargin: true, + folding: false, + wordWrap: "off", + overviewRulerLanes: 0, + }), + [zoomLevel] + ) - const readonlyOptions = { - ...editorOptions, - readOnly: true, - domReadOnly: true, - } + const readonlyOptions = useMemo( + () => ({ + ...editorOptions, + readOnly: true, + domReadOnly: true, + }), + [editorOptions] + ) const loadingEl = (
diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index d5f9bfd..f77c261 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -8,8 +8,15 @@ import { terminalResize, terminalKill, } from "@/lib/api" +import { useZoomLevel } from "@/hooks/use-appearance" import type { TerminalEvent } from "@/lib/types" -import type { ITheme } from "@xterm/xterm" +import type { ITheme, Terminal as XTermTerminal } from "@xterm/xterm" + +const TERMINAL_BASE_FONT_SIZE = 13 + +function computeTerminalFontSize(zoomLevel: number): number { + return Math.round((TERMINAL_BASE_FONT_SIZE * zoomLevel) / 100) +} const DARK_THEME: ITheme = { background: "#1a1a1a", @@ -108,11 +115,13 @@ export function TerminalView({ }: TerminalViewProps) { const containerRef = useRef(null) const fitAddonRef = useRef<{ fit: () => void } | null>(null) - const termRef = useRef<{ focus: () => void } | null>(null) + const termRef = useRef(null) const lastResizeRef = useRef<{ cols: number; rows: number } | null>(null) const isActiveRef = useRef(isActive) const isVisibleRef = useRef(isVisible) const onProcessExitedRef = useRef(onProcessExited) + const { zoomLevel } = useZoomLevel() + const zoomLevelRef = useRef(zoomLevel) const [loading, setLoading] = useState(true) useEffect(() => { @@ -140,7 +149,7 @@ export function TerminalView({ const term = new Terminal({ cursorBlink: true, - fontSize: 13, + fontSize: computeTerminalFontSize(zoomLevelRef.current), fontFamily: "Menlo, Monaco, 'Courier New', monospace", theme: getTerminalTheme(containerRef.current), allowProposedApi: true, @@ -292,6 +301,25 @@ export function TerminalView({ } }, [isActive, isVisible]) + // React to zoom level changes. Updates the ref synchronously so async init() + // always reads the latest zoom, and pushes the new font size to already-mounted + // terminals. Double rAF ensures xterm's renderer has recomputed cell metrics + // before we refit. + useEffect(() => { + zoomLevelRef.current = zoomLevel + const term = termRef.current + if (!term) return + term.options.fontSize = computeTerminalFontSize(zoomLevel) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const el = containerRef.current + if (el && el.clientWidth > 0 && el.clientHeight > 0) { + fitAddonRef.current?.fit() + } + }) + }) + }, [zoomLevel]) + return (