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.
This commit is contained in:
xintaofei
2026-04-19 09:07:51 +08:00
parent eeeee2141c
commit 0fa2a0895f
4 changed files with 68 additions and 21 deletions

View File

@@ -6,8 +6,11 @@ import { ChevronLeft, ChevronRight } from "lucide-react"
import type { DiffOnMount } from "@monaco-editor/react" import type { DiffOnMount } from "@monaco-editor/react"
import type { editor as MonacoEditorNs } from "monaco-editor" import type { editor as MonacoEditorNs } from "monaco-editor"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
import { useZoomLevel } from "@/hooks/use-appearance"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const EDITOR_BASE_FONT_SIZE = 13
import "@/lib/monaco-local" import "@/lib/monaco-local"
const MonacoDiffEditor = dynamic( const MonacoDiffEditor = dynamic(
@@ -36,6 +39,7 @@ export function DiffViewer({
className, className,
}: DiffViewerProps) { }: DiffViewerProps) {
const editorTheme = useMonacoThemeSync() const editorTheme = useMonacoThemeSync()
const { zoomLevel } = useZoomLevel()
const diffEditorRef = useRef<MonacoEditorNs.IStandaloneDiffEditor | null>( const diffEditorRef = useRef<MonacoEditorNs.IStandaloneDiffEditor | null>(
null null
) )
@@ -187,7 +191,7 @@ export function DiffViewer({
renderSideBySide: true, renderSideBySide: true,
renderSideBySideInlineBreakpoint: 0, renderSideBySideInlineBreakpoint: 0,
automaticLayout: true, automaticLayout: true,
fontSize: 13, fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100,
minimap: { enabled: false }, minimap: { enabled: false },
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
renderOverviewRuler: false, renderOverviewRuler: false,

View File

@@ -24,7 +24,10 @@ import { Streamdown } from "streamdown"
import { readFileBase64 } from "@/lib/api" import { readFileBase64 } from "@/lib/api"
import { normalizeMathDelimiters } from "@/components/ai-elements/message" import { normalizeMathDelimiters } from "@/components/ai-elements/message"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
import { useZoomLevel } from "@/hooks/use-appearance"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
const EDITOR_BASE_FONT_SIZE = 13
import "@/lib/monaco-local" import "@/lib/monaco-local"
const math = createMathPlugin({ singleDollarTextMath: true }) const math = createMathPlugin({ singleDollarTextMath: true })
@@ -770,6 +773,7 @@ export function FileWorkspacePanel() {
const cursorListenerRef = useRef<{ dispose: () => void } | null>(null) const cursorListenerRef = useRef<{ dispose: () => void } | null>(null)
const gitChangeDecorationsRef = useRef<string[]>([]) const gitChangeDecorationsRef = useRef<string[]>([])
const editorTheme = useMonacoThemeSync() const editorTheme = useMonacoThemeSync()
const { zoomLevel } = useZoomLevel()
const [editorMountVersion, setEditorMountVersion] = useState(0) const [editorMountVersion, setEditorMountVersion] = useState(0)
const [cursorLine, setCursorLine] = useState(1) const [cursorLine, setCursorLine] = useState(1)
const [collapsedFiles, setCollapsedFiles] = useState<Record<string, boolean>>( const [collapsedFiles, setCollapsedFiles] = useState<Record<string, boolean>>(
@@ -1610,7 +1614,7 @@ export function FileWorkspacePanel() {
readOnly: !canEdit, readOnly: !canEdit,
minimap: { enabled: false }, minimap: { enabled: false },
automaticLayout: true, automaticLayout: true,
fontSize: 13, fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100,
lineNumbersMinChars, lineNumbersMinChars,
lineDecorationsWidth: 10, lineDecorationsWidth: 10,
wordWrap: "off", wordWrap: "off",

View File

@@ -7,7 +7,10 @@ import type { editor as MonacoEditorNs, IRange } from "monaco-editor"
import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react" import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
import { useZoomLevel } from "@/hooks/use-appearance"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const EDITOR_BASE_FONT_SIZE = 13
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
ResizableHandle, ResizableHandle,
@@ -55,6 +58,7 @@ export function ThreePaneMergeEditor({
}: ThreePaneMergeEditorProps) { }: ThreePaneMergeEditorProps) {
const t = useTranslations("MergePage") const t = useTranslations("MergePage")
const editorTheme = useMonacoThemeSync() const editorTheme = useMonacoThemeSync()
const { zoomLevel } = useZoomLevel()
const { registerEditor } = useSyncScroll() const { registerEditor } = useSyncScroll()
const leftEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>( const leftEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
@@ -497,23 +501,30 @@ export function ThreePaneMergeEditor({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Editor options // Editor options
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const editorOptions: MonacoEditorNs.IStandaloneEditorConstructionOptions = { const editorOptions =
fontSize: 13, useMemo<MonacoEditorNs.IStandaloneEditorConstructionOptions>(
minimap: { enabled: false }, () => ({
scrollBeyondLastLine: false, fontSize: (EDITOR_BASE_FONT_SIZE * zoomLevel) / 100,
automaticLayout: true, minimap: { enabled: false },
lineNumbers: "on", scrollBeyondLastLine: false,
glyphMargin: true, automaticLayout: true,
folding: false, lineNumbers: "on",
wordWrap: "off", glyphMargin: true,
overviewRulerLanes: 0, folding: false,
} wordWrap: "off",
overviewRulerLanes: 0,
}),
[zoomLevel]
)
const readonlyOptions = { const readonlyOptions = useMemo(
...editorOptions, () => ({
readOnly: true, ...editorOptions,
domReadOnly: true, readOnly: true,
} domReadOnly: true,
}),
[editorOptions]
)
const loadingEl = ( const loadingEl = (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground"> <div className="flex h-full items-center justify-center text-xs text-muted-foreground">

View File

@@ -8,8 +8,15 @@ import {
terminalResize, terminalResize,
terminalKill, terminalKill,
} from "@/lib/api" } from "@/lib/api"
import { useZoomLevel } from "@/hooks/use-appearance"
import type { TerminalEvent } from "@/lib/types" 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 = { const DARK_THEME: ITheme = {
background: "#1a1a1a", background: "#1a1a1a",
@@ -108,11 +115,13 @@ export function TerminalView({
}: TerminalViewProps) { }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const fitAddonRef = useRef<{ fit: () => void } | null>(null) const fitAddonRef = useRef<{ fit: () => void } | null>(null)
const termRef = useRef<{ focus: () => void } | null>(null) const termRef = useRef<XTermTerminal | null>(null)
const lastResizeRef = useRef<{ cols: number; rows: number } | null>(null) const lastResizeRef = useRef<{ cols: number; rows: number } | null>(null)
const isActiveRef = useRef(isActive) const isActiveRef = useRef(isActive)
const isVisibleRef = useRef(isVisible) const isVisibleRef = useRef(isVisible)
const onProcessExitedRef = useRef(onProcessExited) const onProcessExitedRef = useRef(onProcessExited)
const { zoomLevel } = useZoomLevel()
const zoomLevelRef = useRef(zoomLevel)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
@@ -140,7 +149,7 @@ export function TerminalView({
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 13, fontSize: computeTerminalFontSize(zoomLevelRef.current),
fontFamily: "Menlo, Monaco, 'Courier New', monospace", fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: getTerminalTheme(containerRef.current), theme: getTerminalTheme(containerRef.current),
allowProposedApi: true, allowProposedApi: true,
@@ -292,6 +301,25 @@ export function TerminalView({
} }
}, [isActive, isVisible]) }, [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 ( return (
<div <div
className="absolute inset-0 h-full w-full p-2" className="absolute inset-0 h-full w-full p-2"