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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Agent 笔记:macOS 全局源码编辑器样式

状态:已实现

## 先说结论

所有项目的源码编辑器现在共享一组可持久化的样式偏好,包括已有字体、字号,以及新增的
行间距、配色、行号、空白符、光标和常用显示开关。修改时更新现有编辑器的选项,不重建
文档,因此光标、内容和撤销历史能够保留。设置不涉及 VS Code 或 IDEA 的检测与导入。

## 问题

此前 macOS 只暴露部分编辑器选项,高亮使用固定色表。将来接收其他 IDE 的配置前,产品
需要先能保存和应用相应偏好;仅在设置页增加控件会遗漏分栏和完整源码预览。

## 决策

- 复用 `AppSettings` 和既有 `KeyValueStore`。字体、字号、软换行、缩略图继续使用原键,
新选项由 `EditorAppearanceSettings` 保存为一份属性字典。每个字段独立校验和回退,
一个未知枚举值不会清空其他有效设置。字号限制为 6–72,行间距用字号倍数 1–3;
0 表示沿用 Monaco 的自动行高,不用新的默认值改变老用户排版。
- 设置使用现有原生控件与共享 i18n 词条,说明进入信息提示;这些是 Lithe 自有的全局偏好,
所有值即时保存,不引入第二个“应用”阶段。保留新版的编辑器软换行菜单与按平台确定的行号栏宽度。
- `MonacoWorkbenchDisplayConfiguration` 的值比较包含全部新选项;将配置传入当前及
后续创建的编辑器。正确做法是调用 `updateOptions`;不要为了换主题重新创建模型,
否则会丢失撤销栈,也可能破坏多个视图共享同一文档的关系。Git 作者栏使用自定义行号
渲染器;配置更新后要重新安装作者栏,关闭作者栏或切换标签时恢复用户选择的行号模式,
不能硬编码回绝对行号。相对行号继续由 Monaco 在光标换行时重新绘制,不增加轮询。
- macOS 隐藏行号时只隐藏数字,继续保留现有断点栏与鼠标操作区域。Monaco 的 `off`
会连同行号层的断点装饰一起关闭,因此原生行号优先布局改用返回零宽字符的行号渲染器,
让没有断点的行也保留可点击元素;不增加第二个断点栏。这个渲染器与 Git 作者栏的渲染器
分开识别,不能再把所有自定义行号函数都当成作者栏而禁用断点操作。Windows 继续使用
原有独立图标栏;持久化设置仍为 `off`,内部转换仅发生在编辑器适配层。
- 高亮方案与工作台主题独立,提供 Lithe、GitHub、One、VS Code、IntelliJ IDEA,按当前深浅外观选对应色表。
GitHub 与 One 直接复用原 Windows JSON,移到 `frontend/editor/themes/`,Windows
只改变导入路径。共享主题生成器同时处理词法和语义角色别名,避免 Java 字段、方法等
仍沿用旧颜色。注释斜体可单独设置,取消时显式清除样式。
- VS Code 使用官方 `b6bfa95ebee8b183d65dee4997c0527eadfe5384` 的 Dark/Light 2026
及其 Modern/Plus/VS 继承链;相同作用域取后定义,具体作用域优先。IDEA 使用 Community
`74dbd42b7a8787b0707437cb857c68e78199f611` 的 Islands Dark 与 Light 编辑器色表。
`themes/vscode.json` 与 `themes/idea.json` 保存固定来源链接;仅映射现有词法/语义角色,
不承诺完整复现两种 IDE 的语言分析或所有 TextMate scope。注释斜体仍由用户设置决定。
MIT/Apache 许可证及 JetBrains NOTICE 随编辑器资源打包,运行时不新增下载或可写目录。
- 彩色括号由配色方案决定:IDEA 关闭,其余当前内置方案启用;语法高亮分组显示只读状态,
不提供单独开关。历史 `bracketPairColorization` 值不再读取或写出,避免旧偏好覆盖方案。
- Java 方法颜色不能只看通用 function:IDEA 的方法调用继承正文色,普通声明使用声明色,
Darcula 父方案的 `STATIC_METHOD_ATTRIBUTES` 没有前景色,因此静态方法使用正文色。
依据为同一上游提交的 `DefaultColorSchemesManager.xml` 与 `JavaHighlightingColors.java`,
并核对了本机 Islands Dark 配置。共享语义编码保留 declaration/static(在既有修饰符末尾
追加,保留原 bit 位置);词法 `function.java` 在 IDEA 中先用正文色,由语义信息细分。
不能把所有方法都改成蓝色,也不能为了截图把其他方案的 function 颜色一并改掉。
- 字体枚举和字体缺失回退继续遵循现有
[编程字体决策](2026-10-07-macos-editor-programming-font-family.md)。
- 作用范围是主源码编辑器、分栏、独立文件窗口与完整源码预览。原生 Git Diff、SQL
控制台、终端、输出窗口和结果行片段保留各自的排版规则;不把所有等宽文字绑定到同一
组编辑器选项。界面提示明确说明应用于所有项目的源码编辑器。

## 考虑过的备选方案

- 复制 Windows 色表到 macOS:会产生两个需要同步的版本,改为移动原文件并共享读取。
- 将整个工作台主题与源码配色绑定:不能表达用户保留界面主题、只更换代码颜色的需求。
- 导入外部配置后再补设置:导入结果缺少可见且可修改的落点。本次先完成设置本身,
不提前引入外部格式解析或插件兼容层。

## 后果

新增偏好在原有应用设置存储中保存,生命周期和所有权均不变。色表属于构建输入,随
编辑器资源打包;运行时只读,不下载字体或主题,不产生新的缓存目录,不改变签名包
字节,也不影响 Sparkle 增量更新基线。

方案只覆盖当前列出的预设与选项,尚不等价于完整的 VS Code/IDEA 主题和配置格式。
将来增加导入时必须转换单位、颜色角色与配置作用域,不能直接透传外部编辑器选项对象。

## 验证

- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AppSettingsTests|MonacoWorkbenchThemeTests|SettingsSearchVocabularyTests|ContextMenuCoverageTests|EditorFontFamilyTests'`
- `./scripts/macos/probe/probe-macos-monaco.sh --workbench-tests`:真实 WebKit 验证新旧分栏、字号和行高、
显示选项、深浅配色、斜体撤销、内容和撤销栈保留;隐藏行号后的断点图标、已有断点与空白行的鼠标点击、恢复相对行号。
- `node scripts/i18n/generate-i18n.mjs --check`
- `node scripts/i18n/verify-app-language-localization.mjs`
- `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`
- `node scripts/architecture/verify-editor-boundaries.mjs`
- `./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`

自动化不替代完整产品的设置布局、键盘操作、多项目与重启验收;矩阵单独记录待验证状态。

## 适用范围

- `macos/Sources/Lithe/Models/Settings/EditorAppearanceSettings.swift`
- `macos/Sources/Lithe/Models/Settings/AppSettings.swift`
- `macos/Sources/Lithe/Views/App/SettingsView.swift`
- `macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift`
- `frontend/editor/src/theme.ts`
- `frontend/editor/src/workbench.ts`
- `frontend/editor/themes/`
- `windows/tauri/src/extensions/themes/theme-loader.ts`
6 changes: 5 additions & 1 deletion docs/ci-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,11 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono

- `.artifacts/bun-tmp/`、下载或解压过程中的临时目录;
- `.artifacts/jdtls/`、`.artifacts/jdk-*` 等可以由已验证下载重新生成的解压输出;
- `.artifacts/editor/macos/` 和官方插件等尚未写入构建身份 stamp 的生成资源;
- `.artifacts/editor/macos/`、Monaco probe/workbench 资源与其中的主题许可证、NOTICE,
由 `editor-theme-assets` 排除路由拒绝复用。它们与当前源码、上游色表固定提交、Monaco/Bun
版本绑定,没有可靠构建身份 stamp;各工作树构建时一起生成 JS 并复制许可证,不在任何阶段
跨树复制。运行时只读,不改变签名和 Sparkle delta。官方插件等尚未写入构建身份 stamp 的
生成资源同样不得复用;
- `.build` 中的 SwiftPM 构建状态;
- `rust/target/` 中与当前源码、编译器或构建参数绑定的构建输出;
- LSP workspace `-data`、运行时数据库、测试报告和其他会被进程修改的状态。
Expand Down
2 changes: 2 additions & 0 deletions frontend/editor/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export async function buildEditorAssets(options: EditorAssetBuild): Promise<void
}
await copyFile(options.html, join(output, "index.html"));
await copyFile(join(monaco, "LICENSE"), join(output, "MONACO-LICENSE.txt"));
for (const license of ["vscode-LICENSE.txt", "idea-LICENSE.txt", "idea-NOTICE.txt"])
await copyFile(join(editorRoot, "themes", license), join(output, license));
await writeFile(join(output, "version.json"), JSON.stringify({ monaco: version, bun: Bun.version }));
console.log(`Monaco ${version}: ${output}`);

Expand Down
2 changes: 1 addition & 1 deletion frontend/editor/src/semantic-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const MONACO_SEMANTIC_TOKEN_TYPES = [
"attribute",
] as const;

export const MONACO_SEMANTIC_TOKEN_MODIFIERS = ["readonly", "deprecated"] as const;
export const MONACO_SEMANTIC_TOKEN_MODIFIERS = ["readonly", "deprecated", "declaration", "static"] as const;

export const MONACO_SEMANTIC_TOKEN_LEGEND: Monaco.languages.SemanticTokensLegend = {
tokenTypes: [...MONACO_SEMANTIC_TOKEN_TYPES],
Expand Down
2 changes: 1 addition & 1 deletion frontend/editor/src/textmate-grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function tokenRole(scopes: string[]): string {
if (/^constant.language/.test(scope)) return "keyword";
if (/^(keyword|storage)/.test(scope)) return "keyword";
if (/^entity.name.(type|class|namespace)|^support.(class|type)/.test(scope)) return "type.identifier";
if (/^entity.name.function/.test(scope)) return "function";
if (/^entity.name.function/.test(scope)) return "function.java";
if (/^variable/.test(scope)) return "variable";
if (/^punctuation/.test(scope)) return "delimiter";
}
Expand Down
41 changes: 40 additions & 1 deletion frontend/editor/src/theme.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import github from "../themes/github.json";
import one from "../themes/one.json";
import vscode from "../themes/vscode.json";
import idea from "../themes/idea.json";

import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js";

import { MONACO_TOKEN_SYNTAX_ROLES, createMonacoTokenStyleRules } from "./token-theme-roles";

export interface WorkbenchThemeInput {
id: string;
dark: boolean;
syntaxScheme?: "lithe" | "github" | "one" | "vscode" | "idea";
italicComments?: boolean;
colors?: {
background?: string;
foreground?: string;
Expand Down Expand Up @@ -35,11 +42,41 @@ export function defineWorkbenchTheme(input: WorkbenchThemeInput, palette: Syntax
const appearance = input.dark ? "dark" : "light";
// Token names such as "constructor" must never resolve through Object.prototype.
const syntaxColors = new Map(Object.entries(palette.defaults).map(([role, value]) => [role, value[appearance]]));
// Reuse the same bundled palettes as Windows. These only replace syntax
// colors; workbench surfaces continue to follow the native interface theme.
const scheme = input.syntaxScheme === "github" ? github : input.syntaxScheme === "one" ? one
: input.syntaxScheme === "vscode" ? vscode : input.syntaxScheme === "idea" ? idea : undefined;
const variant = scheme?.themes.find(value => value.id === `${input.syntaxScheme}-${appearance}`);
if (variant) {
const syntax: Record<string, string> = variant.syntax;
const aliases: Record<string, string> = {
annotation: "attribute", documentationComment: "comment", field: "property",
functionCall: "function", functionDeclaration: "function", parameter: "variable", typeParameter: "type",
};
for (const role of syntaxColors.keys()) {
const color = syntax[aliases[role] ?? role];
if (color) syntaxColors.set(role, color);
}
for (const [role, color] of Object.entries(syntax)) syntaxColors.set(role, color);
}
const tokens = new Map(syntaxColors);
for (const [token, role] of MONACO_TOKEN_SYNTAX_ROLES) {
const color = syntaxColors.get(token) ?? syntaxColors.get(nativeRoles[role] ?? role);
if (color) tokens.set(token, color);
}
if (input.syntaxScheme === "idea") {
// Java METHOD_CALL inherits the identifier color. Darcula's STATIC_METHOD
// has font attributes but no foreground; declarations use FUNCTION_DECLARATION.
// Keep lexical Java names neutral until the language server classifies them.
const text = syntaxColors.get("text")!;
tokens.set("function.java", text);
for (const prefix of ["method", "method.readonly", "method.deprecated", "method.readonly.deprecated"]) {
tokens.set(prefix, text);
tokens.set(`${prefix}.declaration`, syntaxColors.get("functionDeclaration")!);
tokens.set(`${prefix}.static`, text);
tokens.set(`${prefix}.declaration.static`, text);
}
}
const colors: Record<string, string> = {
"editor.foreground": input.colors?.foreground ?? syntaxColors.get("text")!,
// Keep the minimap shadow and overview ruler border from forming vertical separators.
Expand Down Expand Up @@ -70,7 +107,9 @@ export function defineWorkbenchTheme(input: WorkbenchThemeInput, palette: Syntax
monaco.editor.defineTheme(name, {
base: input.dark ? "vs-dark" : "vs", inherit: true, colors,
rules: [...[...tokens].map(([token, foreground]) => ({ token, foreground: foreground.slice(1) })),
...createMonacoTokenStyleRules(false)],
{ token: "comment", fontStyle: input.italicComments ? "italic" : "" },
{ token: "documentationComment", fontStyle: input.italicComments ? "italic" : "" },
...createMonacoTokenStyleRules(input.italicComments === true)],
});
return name;
}
Expand Down
26 changes: 21 additions & 5 deletions frontend/editor/src/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export function mountWorkbench(host: WorkbenchHost) {
let suspendedViews: { role: string; id: string | undefined; state: monaco.editor.ICodeEditorViewState | null;
find: FindSnapshot | undefined; focused: boolean }[] | undefined;
let displayOptions: monaco.editor.IStandaloneEditorConstructionOptions = {};
// Keep a rendered, hit-testable gutter on lines without breakpoints too.
// Monaco Off suppresses lineNumberClassName decorations along with the digits.
const hiddenLineNumbers = () => "\u200b";
const allEditors = () => [editor, ...[...surfaces.values()].map(surface => surface.editor)].filter(Boolean);
function applyReadOnly(entry: Entry) {
for (const view of allEditors()) {
Expand Down Expand Up @@ -458,14 +461,22 @@ export function mountWorkbench(host: WorkbenchHost) {
const show = value && (line === firstVisibleLine || byLine.get(line - 1)?.commit !== value.commit);
const label = value ? `${value.author} · ${value.date}` : "";
const metadata = value ? `<span class="lithe-blame" title="${escapeHTML(`${label} · ${value.commit.slice(0, 8)}`)}">${show ? escapeHTML(label) : "&#8203;"}</span>` : "";
return `${metadata}<span class="lithe-blame-line">${line}</span>`;
} } : { lineNumbers: "on", lineNumbersMinChars: host.lineNumbersFirst ? 1 : 5 });
const mode = displayOptions.lineNumbers === hiddenLineNumbers ? "off" : displayOptions.lineNumbers ?? "on";
const activeLine = view.getPosition()?.lineNumber ?? line;
const number = mode === "off" ? "" : mode === "relative" && line !== activeLine ? Math.abs(line - activeLine) : line;
return `${metadata}<span class="lithe-blame-line">${number}</span>`;
} } : { lineNumbers: displayOptions.lineNumbers ?? "on", lineNumbersMinChars: host.lineNumbersFirst ? 1 : 5 });
}
const value = current();
for (const action of ["show", "stage", "unstage", "discard"] as const)
keys[action].set(!!value && (action === "show" || (!value.entry.readonly && value.marker[action])));
};
gitContextUpdates.set(view, refresh);
gitContextUpdates.set(view, () => {
// Display updates replace Monaco's line-number option. Reinstall active
// blame without losing the user's relative/hidden line-number preference.
renderedBlame = {};
refresh();
});
const run = async (action: string, line?: number) => {
const value = current(line);
if (!value) return;
Expand Down Expand Up @@ -713,7 +724,8 @@ export function mountWorkbench(host: WorkbenchHost) {
const entry = [...entries.values()].find(entry => entry.model === view.getModel());
view.getDomNode()?.toggleAttribute("data-lithe-breakpoints-editable", !!entry &&
entry.canToggleBreakpoints === true && !entry.readonly && !entry.frozen && !entry.closingHolds && !failed &&
!view.getOption(monaco.editor.EditorOption.readOnly) && typeof view.getRawOptions().lineNumbers !== "function");
!view.getOption(monaco.editor.EditorOption.readOnly) &&
(typeof view.getRawOptions().lineNumbers !== "function" || view.getRawOptions().lineNumbers === hiddenLineNumbers));
}
const value = navigation();
for (const direction of ["up", "down"] as const) navigationKeys[direction].set(!!value?.markers.some(marker => marker.direction === direction));
Expand Down Expand Up @@ -793,7 +805,8 @@ export function mountWorkbench(host: WorkbenchHost) {
if (host.gitBlameMenu && host.lineNumbersFirst && (event.event.rightButton || isMacintosh && event.event.ctrlKey) &&
event.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS) return;
if (event.target.element?.closest(".lithe-java-navigation, .lithe-java-run, .lithe-blame") ||
host.lineNumbersFirst && typeof view.getRawOptions().lineNumbers === "function") return;
host.lineNumbersFirst && typeof view.getRawOptions().lineNumbers === "function" &&
view.getRawOptions().lineNumbers !== hiddenLineNumbers) return;
if ((event.target.type !== monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN &&
!(host.lineNumbersFirst && event.target.type === monaco.editor.MouseTargetType.GUTTER_LINE_NUMBERS)) || !event.target.position ||
(!event.event.leftButton && !event.event.rightButton)) return;
Expand Down Expand Up @@ -1068,6 +1081,7 @@ export function mountWorkbench(host: WorkbenchHost) {
const name = theme ? defineWorkbenchTheme(theme, host.palette)
: payload.dark ? "lithe-dark" : "lithe-light";
Object.assign(displayOptions, {
...payload.appearance,
// Standalone updateOptions and newly created split editors both apply
// this construction theme globally, so it must track the host theme.
theme: name,
Expand All @@ -1076,9 +1090,11 @@ export function mountWorkbench(host: WorkbenchHost) {
wordWrap: payload.wrap ? "on" : "off",
minimap: { enabled: payload.minimap !== false },
});
if (host.lineNumbersFirst && displayOptions.lineNumbers === "off") displayOptions.lineNumbers = hiddenLineNumbers;
for (const view of allEditors()) {
view.updateOptions(displayOptions);
navigationContexts.get(view)?.();
gitContextUpdates.get(view)?.();
}
monaco.editor.setTheme(name);
},
Expand Down
Loading
Loading