Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@

## [Unreleased]

### Added

- **主题与外观**
- 深色 / 浅色主题切换:可选浅色、深色或跟随系统,界面即时切换、重启保留。
- 编辑器配色主题:内置多款可选(VS Code 2026 / Modern、高对比,以及 GitHub、Monokai、Dracula、Nord、Solarized 等),可跟随应用主题或单独指定。
- 编辑器字体与字号:自定义等宽字体(可多个候选)与字号,编辑器及全应用等宽文本(diff / 评论 / 代码块)一并生效。

### Changed

- 配置面板改为左右分区布局:左侧分区导航(常规 / 连接 / AI / 关于)、右侧按分区归类展示配置项,替代此前单列平铺;分区结构为后续扩展(主题、编辑器风格、上下文窗口等)预留。
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/main/controllers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,43 @@ export const setLanguage: IpcController<'config:setLanguage'> = async (_event, r
logger.info({ language: req.language }, 'language config updated');
};

/**
* 写 GUI 主题偏好;内存同步。纯前端展示项,主进程无副作用(不切 i18n、不重建 adapter)。
*/
export const setTheme: IpcController<'config:setTheme'> = async (_event, req) => {
const { bootstrap, logger } = getContext();
const appearance = { ...bootstrap.config.appearance, theme: req.theme };
await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, appearance });
bootstrap.config.appearance = appearance;
logger.info({ theme: req.theme }, 'theme preference updated');
};

/**
* 写编辑器外观(Monaco 主题 + 等宽字体);内存同步。纯前端展示项,主进程无副作用。
*/
export const setEditorAppearance: IpcController<'config:setEditorAppearance'> = async (
_event,
req,
) => {
const { bootstrap, logger } = getContext();
const appearance = {
...bootstrap.config.appearance,
editor_theme: req.editor_theme,
editor_font_family: req.editor_font_family,
editor_font_size: req.editor_font_size,
};
await writeConfig(bootstrap.paths.configFile, { ...bootstrap.config, appearance });
bootstrap.config.appearance = appearance;
logger.info(
{
editorTheme: req.editor_theme,
editorFontFamily: req.editor_font_family,
editorFontSize: req.editor_font_size,
},
'editor appearance updated',
);
};

/**
* 写 LLM Provider 配置;内存同步,下次 pragent:run 用新值。
*/
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export function registerIpcHandlers(deps: RegisterDeps): {
ipcMain.handle('config:read', config.readConfig); // 读当前内存配置
ipcMain.handle('config:setReposDir', config.setReposDir); // 设仓库目录(重启生效)
ipcMain.handle('config:setLanguage', config.setLanguage); // 设 UI 语言(热生效)
ipcMain.handle('config:setTheme', config.setTheme); // 设 GUI 主题偏好(前端即时生效)
ipcMain.handle('config:setEditorAppearance', config.setEditorAppearance); // 设编辑器主题 + 字体(前端即时生效)
ipcMain.handle('config:setLlm', config.setLlm); // 设 LLM Provider 配置
ipcMain.handle('config:setAgent', config.setAgent); // 设 Agent 配置(含 agent.dir)
ipcMain.handle('agent:setAutopilotEnabled', config.setAutopilotEnabled); // AutoPilot 开关
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { usePanelLayout } from './hooks/usePanelLayout';
import { useUpdateNotice } from './hooks/useUpdateNotice';
import { useAppStores } from './hooks/useAppStores';
import { useExternalLinkGuard } from './hooks/useExternalLinkGuard';
import { useTheme, useEditorAppearanceSync } from './hooks/useTheme';

export default function App() {
const { t } = useTranslation();
Expand Down Expand Up @@ -51,6 +52,18 @@ export default function App() {
const updateInfo = useUpdateNotice();
useAppStores();
useExternalLinkGuard();
// GUI 主题:跟随 config 偏好生效('system' 下还跟随 OS 切换)。boot 前用默认深色,模块导入时已按
// localStorage 缓存定下首帧主题,boot 到达后切到 config 偏好。
useTheme(boot?.config.appearance.theme ?? 'dark');
// 编辑器外观(Monaco 主题 + 等宽字体):跟随 config 同步到运行时 store + 字体 CSS 变量。
useEditorAppearanceSync(
boot?.config.appearance ?? {
theme: 'dark',
editor_theme: 'auto',
editor_font_family: '',
editor_font_size: 14,
},
);

const [showSettings, setShowSettings] = useState(false);
/**
Expand Down Expand Up @@ -195,6 +208,12 @@ export default function App() {
onLlmChange={(llm) => patchConfig((c) => ({ ...c, llm }))}
onProxyChange={(proxy) => patchConfig((c) => ({ ...c, proxy }))}
onLanguageChange={(language) => patchConfig((c) => ({ ...c, language }))}
onThemeChange={(theme) =>
patchConfig((c) => ({ ...c, appearance: { ...c.appearance, theme } }))
}
onEditorAppearanceChange={(appearance) =>
patchConfig((c) => ({ ...c, appearance: { ...c.appearance, ...appearance } }))
}
onConnectionsChange={refreshBootAndPrs}
onClose={() => setShowSettings(false)}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useResolvedTheme } from '../../hooks/useTheme';

/**
* Mermaid 渲染:把 ```mermaid 代码块渲染成 SVG 图(Qodo `/describe` 常生成架构图)。
Expand All @@ -10,7 +11,8 @@ import { useTranslation } from 'react-i18next';
* - **securityLevel: 'strict'**:内容来自 AI / 远端 PR 描述,strict 下 mermaid 转义
* 标签文本、禁用点击脚本,产出的 SVG 可安全注入。
* - **失败回退**:语法错 / 渲染异常时回退展示原始代码块,图画错也能看源码。
* - 主题 `dark`,与应用深色界面一致。
* - 主题随应用深 / 浅色切换(`dark` / `default`):mermaid 主题为全局态、不走 CSS 自定义属性,
* 故每次渲染前按当前解析主题 re-initialize,并把主题纳入渲染 effect 依赖、切换时重绘。
*/

// 仅声明本组件用到的最小接口,避免 import() 类型注解(且与 mermaid 内部类型解耦)。
Expand All @@ -26,7 +28,8 @@ function loadMermaid(): Promise<MermaidApi> {
mermaidLoader ??= import('mermaid')
.then((m) => {
const mermaid = m.default as unknown as MermaidApi;
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'strict' });
// 主题不在此固定:每次渲染前按当前应用主题 re-initialize(见组件渲染 effect)。
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
return mermaid;
})
.catch((e: unknown) => {
Expand All @@ -38,6 +41,8 @@ function loadMermaid(): Promise<MermaidApi> {

export function MermaidDiagram({ source }: { source: string }) {
const { t } = useTranslation();
// mermaid 主题为全局态、不走 CSS 自定义属性:随应用解析主题切换(深 'dark' / 浅 'default')。
const mermaidTheme = useResolvedTheme() === 'light' ? 'default' : 'dark';
// mermaid.render 需要唯一 id(内部建临时 DOM 节点);useId 保证每个实例稳定唯一,
// 去掉 `:`(mermaid 用作 DOM id / CSS 选择器,冒号非法)
const renderId = `mmd-${useId().replace(/:/g, '')}`;
Expand All @@ -52,6 +57,8 @@ export function MermaidDiagram({ source }: { source: string }) {
void (async () => {
try {
const mermaid = await loadMermaid();
// 渲染前按当前主题 re-initialize(全局态,幂等):主题切换后重渲即换配色。
mermaid.initialize({ startOnLoad: false, theme: mermaidTheme, securityLevel: 'strict' });
const out = await mermaid.render(renderId, source);
if (!cancelled) setSvg(out.svg);
} catch (e) {
Expand All @@ -63,7 +70,7 @@ export function MermaidDiagram({ source }: { source: string }) {
return () => {
cancelled = true;
};
}, [source, renderId]);
}, [source, renderId, mermaidTheme]);

if (failed) {
// 回退:保留原始 mermaid 源码,至少可读
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
import '../../../../../lib/monaco-setup';
import { Editor, type Monaco } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PrCommentAnchor, StoredPullRequest } from '@meebox/shared';
import { EDITOR_FONT_SIZE_MIN } from '@meebox/shared';
import { invoke } from '../../../../../api';
import { editorFontSize } from '../../../../../lib/editor-font';
import { useMonacoEditorTheme } from '../../../../../hooks/useTheme';
import { useEditorAppearance } from '../../../../../stores/editor-appearance-store';
import { resolveEditorFontFamily } from '../../../../../theme';
import { languageFor } from '../../../../../utils/language';

interface InlineCodeContextProps {
Expand Down Expand Up @@ -111,8 +115,8 @@ function InlineCodeContextImpl({
return <CodeSnippet snippet={snippet} language={languageFor(anchor.path)} />;
}

/** Monaco fs=12 时近似行高;上下各 6px padding */
const SNIPPET_LINE_HEIGHT = 19;
/** 行内片段行高 / 字号比(源自 fs=12 时行高 19);按配置字号等比缩放行高。 */
const SNIPPET_LINE_HEIGHT_RATIO = 19 / 12;

const READONLY_OPTIONS: editor.IStandaloneEditorConstructionOptions = {
readOnly: true,
Expand Down Expand Up @@ -141,8 +145,25 @@ const CodeSnippet = memo(function CodeSnippet({
snippet: Snippet;
language: string;
}) {
// Monaco 内置主题不走 CSS 自定义属性,须显式切换:按编辑器主题偏好('auto' 跟随 GUI 深浅)解析。
const monacoTheme = useMonacoEditorTheme();
// 等宽字体 + 字号随配置切换。行内片段比主编辑器小 2px(保留历史观感)、随配置字号联动,下限受 MIN 约束;
// 行高按字号等比缩放。字号 / 行高 / 字体一并进 options(@monaco-editor/react 按引用比对,useMemo 稳定)。
const appearance = useEditorAppearance();
const fontFamily = resolveEditorFontFamily(appearance.fontFamily);
const snippetFontSize = Math.max(EDITOR_FONT_SIZE_MIN, appearance.fontSize - 2);
const lineHeight = Math.round(snippetFontSize * SNIPPET_LINE_HEIGHT_RATIO);
const options = useMemo<editor.IStandaloneEditorConstructionOptions>(
() => ({
...READONLY_OPTIONS,
fontFamily,
fontSize: editorFontSize(snippetFontSize),
lineHeight,
}),
[fontFamily, snippetFontSize, lineHeight],
);
const lineCount = snippet.text.split('\n').length;
const height = lineCount * SNIPPET_LINE_HEIGHT + 12;
const height = lineCount * lineHeight + 12;

const handleMount = useCallback(
(ed: editor.IStandaloneCodeEditor, monaco: Monaco): void => {
Expand All @@ -161,8 +182,7 @@ const CodeSnippet = memo(function CodeSnippet({
contextmenu: false,
folding: false,
glyphMargin: false,
fontSize: editorFontSize(12),
lineHeight: SNIPPET_LINE_HEIGHT,
// 字号 / 行高 / 字体由 options 统一驱动(随配置实时更新),不在此固定。
padding: { top: 6, bottom: 6 },
// 行宽自适应,长行用 word wrap 而不是横向滚动条 (滚动条已禁)
wordWrap: 'on',
Expand All @@ -188,9 +208,9 @@ const CodeSnippet = memo(function CodeSnippet({
height={`${String(height)}px`}
language={language}
value={snippet.text}
theme="vs-dark"
theme={monacoTheme}
onMount={handleMount}
options={READONLY_OPTIONS}
options={options}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { DiffEditor } from '@monaco-editor/react';
import { type editor as MonacoEditor } from 'monaco-editor';
import type { DiffChangedFile } from '@meebox/ipc';
import { editorFontSize } from '../../../../../lib/editor-font';
import { useMonacoEditorTheme } from '../../../../../hooks/useTheme';
import { useEditorAppearance } from '../../../../../stores/editor-appearance-store';
import { resolveEditorFontFamily } from '../../../../../theme';
import { languageFor } from '../../../../../utils/language';
import { PaneLoading } from '../../../../common';
import { Spinner } from './DiffStatus';
Expand All @@ -27,6 +30,11 @@ export function DiffPane({
onMount: (editor: MonacoEditor.IStandaloneDiffEditor) => void;
}) {
const { t } = useTranslation();
// Monaco 内置主题不走 CSS 自定义属性,须显式切换:按编辑器主题偏好('auto' 跟随 GUI 深浅)解析。
const monacoTheme = useMonacoEditorTheme();
// 编辑器等宽字体 + 字号:随配置切换(字体空 = Monaco 默认;字号按平台再做微调)。
const editorAppearance = useEditorAppearance();
const fontFamily = resolveEditorFontFamily(editorAppearance.fontFamily);
// Monaco 挂载后 diff 还要异步计算 + hideUnchangedRegions 折叠才稳定(见上文 reveal 逻辑),
// 期间编辑器是「空 → 跳一下」的重排。在它之上盖一层 overlay loading,首次 onDidUpdateDiff
// (或挂载即已算完)后卸载,遮住这段抖动一次性 reveal。DiffPane 按 file path keyed →
Expand All @@ -36,7 +44,7 @@ export function DiffPane({
// editor.updateOptions()。父级 DiffView 随 poll(pr 换新对象引用)重渲染 → DiffPane 重渲染,
// 若每次新建 options 字面量,每次 poll 都触发 updateOptions → hideUnchangedRegions 折叠布局重算 →
// 编辑器渲染抖动。只在真正影响项(并排/空白/字号)变化时重建。
const fontSize = editorFontSize(14);
const fontSize = editorFontSize(editorAppearance.fontSize);
const editorOptions = useMemo<MonacoEditor.IDiffEditorConstructionOptions>(
() => ({
readOnly: true,
Expand All @@ -46,6 +54,7 @@ export function DiffPane({
automaticLayout: true,
minimap: { enabled: false },
fontSize,
fontFamily,
scrollBeyondLastLine: false,
// 关掉 diff 专属的合并总览列(renderOverviewRuler=true 会在两侧滚动条之外再加一条宽列,
// 跟 VS Code 编辑模式「滚动条内打标」不一致)。改走编辑模式效果:内层 modified 编辑器自带的
Expand Down Expand Up @@ -75,7 +84,7 @@ export function DiffPane({
stickyScroll: { enabled: false },
occurrencesHighlight: 'off',
}),
[renderSideBySide, showWhitespace, fontSize],
[renderSideBySide, showWhitespace, fontSize, fontFamily],
);
const handleMount = useCallback(
(editor: MonacoEditor.IStandaloneDiffEditor) => {
Expand Down Expand Up @@ -127,7 +136,7 @@ export function DiffPane({
.join(' ') || undefined
}
options={editorOptions}
theme="vs-dark"
theme={monacoTheme}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { AppInfo, AppPaths, Config, SupportedLanguage } from '@meebox/shared';
import type {
AppInfo,
AppPaths,
Config,
EditorTheme,
SupportedLanguage,
ThemePreference,
} from '@meebox/shared';
import {
ConfirmModal,
GlobeIcon,
Expand All @@ -14,6 +21,8 @@ import { ConnectionEditorModal } from './editors/ConnectionEditorModal';
import { LlmEditorModal } from './editors/LlmEditorModal';
import { ProxyEditorModal } from './editors/ProxyEditorModal';
import { LanguageSection } from './sections/LanguageSection';
import { ThemeSection } from './sections/ThemeSection';
import { EditorSection } from './sections/EditorSection';
import { ConnectionsSection } from './sections/ConnectionsSection';
import { PollerSection } from './sections/PollerSection';
import { LlmSection } from './sections/LlmSection';
Expand Down Expand Up @@ -49,6 +58,14 @@ interface SettingsModalProps {
onProxyChange?: (proxy: Config['proxy']) => void;
/** UI 语言即时切换后通知父级同步 boot.config.language(与写盘/实时切换解耦的状态同步) */
onLanguageChange?: (language: SupportedLanguage) => void;
/** GUI 主题即时切换后通知父级同步 boot.config.appearance.theme(同语言,解耦状态同步) */
onThemeChange?: (theme: ThemePreference) => void;
/** 编辑器外观(Monaco 主题 + 等宽字体 + 字号)即时改动后通知父级同步 boot.config.appearance */
onEditorAppearanceChange?: (appearance: {
editor_theme: EditorTheme;
editor_font_family: string;
editor_font_size: number;
}) => void;
/**
* 连接改动(含切换活动连接)保存成功后通知父级。父级需重拉 config + 连接摘要 + PR 列表:
* 活动连接变化后,main 端 app:connections 只返回新活动连接的摘要、prs:list 只返回其 PR,
Expand All @@ -69,6 +86,8 @@ export function SettingsModal({
onLlmChange,
onProxyChange,
onLanguageChange,
onThemeChange,
onEditorAppearanceChange,
onConnectionsChange,
onClose,
}: SettingsModalProps) {
Expand All @@ -80,6 +99,8 @@ export function SettingsModal({
onLlmChange,
onProxyChange,
onLanguageChange,
onThemeChange,
onEditorAppearanceChange,
onConnectionsChange,
onClose,
});
Expand Down Expand Up @@ -131,14 +152,26 @@ export function SettingsModal({
aria-current={category === id ? 'page' : undefined}
onClick={() => setCategory(id)}
>
<Icon size={16} />
<Icon size={18} />
<span>{t(labelKey)}</span>
</button>
))}
</nav>
<div className="settings-panel">
{category === 'general' && (
<LanguageSection language={s.language} onChange={s.handleLanguageChange} />
<>
<LanguageSection language={s.language} onChange={s.handleLanguageChange} />
<ThemeSection theme={s.themePreference} onChange={s.handleThemeChange} />
<EditorSection
theme={s.editorTheme}
fontFamily={s.editorFontFamily}
fontSize={s.editorFontSize}
onThemeChange={s.handleEditorThemeChange}
onFontChange={s.handleEditorFontChange}
onFontCommit={s.commitEditorFont}
onFontSizeChange={s.handleEditorFontSizeChange}
/>
</>
)}
{category === 'connection' && (
<>
Expand Down
Loading
Loading