码驰代码编辑器是一款面向鸿蒙 PC 平台的轻量级代码编辑工具,提供文件树浏览、多标签编辑、自研语法高亮、查找替换、命令面板与行操作等完整编辑器能力。语法高亮引擎完全使用 JavaScript 独立实现,支持 JavaScript、TypeScript、Python、C、C++、JSON、CSS、HTML、Markdown、纯文本共 10 种语言的词法分析,采用手写字符扫描器分词方案——不依赖 CodeMirror、Monaco 或任何第三方高亮库。编辑器 UI 使用 CSS Grid 叠加方案(textarea 透明输入层 + div 高亮层同格叠放),实现输入与着色视觉同步。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备多标签管理、10 语言语法高亮、括号匹配、缩进参考线、查找替换(实时出现次数统计)、命令面板(18 条命令)、行移动/删除/注释切换、文件树递归扫描与 CRUD 操作的完整代码编辑器的全过程——涵盖自研分词引擎、Grid 叠加编辑器、文件树安全扫描、以及鸿蒙适配等核心实现细节与踩坑经验。

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_Zed

一、技术架构分析

1.1 整体架构

本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:

  • Electron Web 层:运行于 ArkWeb 引擎,提供完整的代码编辑能力——文件树浏览、多标签管理、自研语法高亮(10 种语言)、查找替换、命令面板、行操作、文件 CRUD 等
  • 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问(真实 fs/path)、原生对话框等系统能力

这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。与采用 contextBridge 安全模型的 Electron 应用不同,本工程采用 nodeIntegration:true + contextIsolation:false 的直通模式——渲染进程直接访问 Node.js 的 fs、path、electron 的 ipcRenderer 等 API,主进程负责 dialog 对话框与文件读写 IPC,语法高亮与编辑器逻辑全部在渲染进程内完成。

1.2 技术方案

对比项传统代码编辑器(VS Code / Sublime / Notepad++)鸿蒙适配版(码驰代码编辑器)
语法高亮Tree-sitter / TextMate 语法 / Monaco 引擎纯 JavaScript 手写字符扫描器分词(10 语言)
编辑器渲染Canvas / iframe 沙箱 / Shadow DOMCSS Grid 叠加(textarea 透明输入 + div 高亮)
文件访问Node.js fs / VS Code FileSystemProvider8 个 IPC 通道(dialog/fs:tree/read/write/…)
插件体系扩展市场 / Lua 脚本 / Python API命令面板(18 条内置命令,无第三方扩展)
部署方式安装包 / 便携版HAP 包(鸿蒙应用)
运行环境Windows / Linux / macOS鸿蒙 PC(ArkWeb 引擎)

1.3 核心功能清单

功能模块具体能力
语法高亮引擎10 语言定义表(JS/TS/Python/C/C++/JSON/CSS/HTML/MD/TXT)+ 手写字符扫描器分词
分词器核心块注释跨行状态 + 三引号字符串 + 预处理指令 + 行注释 + 字符串(单/双/反引号)+ 数字(含 0x 十六进制)+ 标识符/关键字/常量/函数名 + 运算符
HTML 分词标签匹配 + HTML 注释(跨行状态机)
Markdown 分词标题 / 代码块 / 引用 / 列表 行级识别
CSS 特殊处理cssIdent 模式允许连字符(background-color 等属性名)
Grid 叠加编辑器textarea 透明输入层 + div 高亮层 CSS Grid 同格叠加 + 滚动同步
括号匹配双向查找(正向/反向)+ 深度计数 + 视觉高亮
缩进参考线每 tabSize 个空格插入 │ 字符的 span 覆盖层
行注释切换检测全部已注释 → 移除前缀 / 否则 → 添加前缀(支持块注释回退)
文件树扫描递归扫描(MAX_DEPTH=4, MAX_ENTRIES=2000)+ IGNORE_DIRS 过滤
多标签管理打开/切换/关闭 + dirty 标记(●)+ 内容缓存
查找替换大小写切换 + 实时出现次数统计 + 逐个替换 + 全部替换
命令面板Ctrl+Shift+P 唤起 + 模糊过滤 + 上下箭头选择 + Enter 执行
行操作上移/下移/删除当前行 + Ctrl+Up/Down/Ctrl+Shift+K
文件 CRUD新建文件/文件夹 + 重命名(三重校验)+ 删除(二次确认 + 5 秒超时)
文件 IPC8 个通道(dialog:openFolder / fs:tree / fs:read / fs:write / fs:createFile / fs:createDir / fs:rename / fs:delete)
鸿蒙安全菜单双保险移除 + 禁用硬件加速 + 2 MB 文件大小上限
深色主题Catppuccin Mocha 配色 + CSS 变量体系(17 色)

二、环境准备

2.1 开发环境要求

项目版本/信息
操作系统Windows 10/11
核心框架Electron (Node.js + Chromium)
技术栈HTML5/Vanilla JavaScript
高亮引擎纯 JavaScript 自研(503 行)
编辑器renderer.js(1056 行)
主进程main.js(206 行)
样式zed.css(672 行)
目标设备鸿蒙 PC
目标架构arm64-v8a
开发工具DevEco Studio(鸿蒙官方 IDE)

2.2 项目结构

ohos_hap/
├── electron/                          # Electron 壳
├── web_engine/                        # 鸿蒙壳工程
│   └── src/main/resources/resfile/resources/app/  # 部署目录(业务代码平铺)
│       ├── main.js                    # 主进程(窗口管理 + 8 个文件 IPC 通道)
│       ├── renderer.js                # 渲染进程(文件树 + 多标签 + 编辑器 + 查找 + 命令面板)
│       ├── editor-engine.js           # 自研高亮引擎(语言定义 + 分词器 + 括号匹配 + 注释切换)
│       ├── index.html                 # 页面布局(工具栏 + 侧栏 + 编辑区 + 状态栏 + 关于对话框)
│       ├── package.json               # 应用元数据(name: machi, license: Apache-2.0)
│       └── styles/
│           └── zed.css                # Catppuccin Mocha 主题 + Grid 叠加编辑器样式
├── AppScope/
│   └── app.json5                      # bundleName: com.wei.machi, vendor: wei
└── build-profile.json5

2.3 部署流程

开发时在 electron/src/ 目录下编写代码,调试完成后同步到鸿蒙壳工程的部署目录:

Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron/src/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force

然后在 DevEco Studio 中构建 HAP 包,安装到鸿蒙 PC 设备即可运行。

三、核心适配流程

3.1 主进程入口与窗口创建

main.js 负责窗口创建和 IPC 通道注册。应用启动时先禁用硬件加速、移除默认菜单,再创建主窗口:

// main.js —— 应用启动与窗口创建
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var Menu = electron.Menu;

app.disableHardwareAcceleration();
Menu.setApplicationMenu(null);  // 应用级菜单移除

function createWindow() {
  var display = screen.getPrimaryDisplay();
  mainWindow = new BrowserWindow({
    width: Math.floor(display.workAreaSize.width * 0.95),
    height: Math.floor(display.workAreaSize.height * 0.90),
    webPreferences: { nodeIntegration: true, contextIsolation: false }
  });
  mainWindow.loadFile('index.html');
  mainWindow.setMenu(null);  // 窗口级菜单移除(双保险)
}

窗口尺寸设置为工作区的 95%×90%,留出适当的边距。nodeIntegration:true + contextIsolation:false 直通模式让渲染进程可以直接 require(‘electron’).ipcRenderer 和 require(‘fs’),无需 preload 脚本桥接。

菜单移除采用双保险策略:Menu.setApplicationMenu(null) 移除应用级默认菜单栏(File/Edit/View/Window/Help),mainWindow.setMenu(null) 移除窗口级菜单。两者缺一不可——在鸿蒙 ArkWeb 环境下,仅移除其中一级可能导致另一级的默认菜单仍然显示。

3.2 文件树递归扫描

scanTree 函数递归扫描目录结构,内置三重保护机制防止扫描失控:

// main.js —— 文件树递归扫描
var IGNORE_DIRS = [
  'node_modules', '.git', 'oh_modules', '.hvigor', '.idea',
  '.ohpm', 'build', '.gradle', 'dist', 'out', '__pycache__'
];
var MAX_DEPTH = 4;
var MAX_ENTRIES = 2000;

function scanTree(dirPath, depth) {
  var result = { name: path.basename(dirPath), path: dirPath, isDir: true, children: [] };
  if (depth > MAX_DEPTH) return result;
  var entries;
  try {
    entries = fs.readdirSync(dirPath, { withFileTypes: true });
  } catch (e) {
    result.readError = e.message;
    return result;
  }
  // 排序:文件夹优先 + 字母序
  entries.sort(function(a, b) {
    if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
    return a.name.localeCompare(b.name);
  });
  for (var i = 0; i < entries.length; i++) {
    if (scanTree.count >= MAX_ENTRIES) break;  // 总量限制
    var ent = entries[i];
    if (ent.isDirectory()) {
      if (IGNORE_DIRS.indexOf(ent.name) !== -1) continue;  // 忽略构建/依赖目录
      scanTree.count++;
      result.children.push(scanTree(path.join(dirPath, ent.name), depth + 1));
    } else if (ent.isFile()) {
      scanTree.count++;
      result.children.push({
        name: ent.name, path: path.join(dirPath, ent.name),
        isDir: false, children: []
      });
    }
  }
  return result;
}

三重保护机制:

  • MAX_DEPTH=4:最大递归深度 4 层,避免深层嵌套目录导致栈溢出
  • MAX_ENTRIES=2000:全局计数器限制扫描总量,防止超大项目卡死主进程
  • IGNORE_DIRS:过滤 node_modules、.git、oh_modules、.hvigor、build 等构建产物和依赖目录,减少无效扫描

目录排序策略:文件夹始终排在文件前面(a.isDirectory() ? -1 : 1),同级条目按 localeCompare 字母序排列,保证文件树展示稳定有序。

3.3 八个 IPC 通道

主进程注册 8 个 IPC 通道,覆盖文件管理的全部操作:

通道名功能关键逻辑
dialog:openFolder选择文件夹dialog.showOpenDialog 原生对话框
fs:tree扫描文件树scanTree 递归 + 三重保护
fs:read读取文件utf-8 编码 + 2 MB 大小上限
fs:write写入文件writeFileSync + 返回 size/mtime
fs:createFile新建文件文件名合法性校验 + 存在性检查
fs:createDir新建文件夹recursive: true + 存在性检查
fs:rename重命名三重校验(源存在 + 目标目录存在 + 目标不占用)
fs:delete删除rmRecursiveSync 兼容旧版 Node

文件读取设置了 2 MB 的大小上限,超过限制直接返回错误,避免大文件加载导致渲染进程内存溢出:

// main.js —— 文件读取(2 MB 上限)
ipcMain.handle('fs:read', function(event, filePath) {
  try {
    var stat = fs.statSync(filePath);
    if (stat.size > 2 * 1024 * 1024) {
      return { success: false, error: '文件超过 2 MB,编辑器不支持' };
    }
    var content = fs.readFileSync(filePath, 'utf-8');
    return { success: true, content: content, size: stat.size };
  } catch (e) {
    return { success: false, error: e.message };
  }
});

重命名操作的三重校验确保不会误覆盖已有文件:源文件必须存在、目标目录必须存在、目标路径不能已被占用。任何一项不满足都会返回明确的错误信息。

删除操作使用自定义的 rmRecursiveSync 函数而非 fs.rmSync 的 recursive 选项,原因是鸿蒙 ArkWeb 内置的 Node.js 版本可能较旧,不支持 fs.rmSync 的 recursive 参数:

// main.js —— 兼容旧版 Node 的递归删除
function rmRecursiveSync(targetPath) {
  if (fs.existsSync(targetPath)) {
    var stat = fs.statSync(targetPath);
    if (stat.isDirectory()) {
      var entries = fs.readdirSync(targetPath);
      for (var i = 0; i < entries.length; i++) {
        rmRecursiveSync(path.join(targetPath, entries[i]));
      }
      fs.rmdirSync(targetPath);
    } else {
      fs.unlinkSync(targetPath);
    }
  }
}

递归删除子条目,最后 rmdirSync 删除空目录——这是 Node.js 早期版本删除非空目录的标准做法。

四、自研语法高亮引擎

4.1 语言定义表

editor-engine.js 的核心是一张 LANGUAGES 语言定义表,为 10 种语言分别定义了扩展名、关键字列表、常量列表、行注释前缀、块注释对、以及特殊模式标志:

// editor-engine.js —— 语言定义表(10 种语言)
var LANGUAGES = {
  javascript: {
    extensions: ['.js', '.mjs', '.cjs', '.jsx'],
    keywords: ['var', 'let', 'const', 'function', 'return', 'if', 'else', ...],
    constants: ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity'],
    lineComment: '//', blockComment: ['/*', '*/'], templateString: true
  },
  typescript: { extensions: ['.ts', '.tsx'], ... },
  python: { extensions: ['.py', '.pyw'], tripleStrings: ['"""', "'''"], ... },
  c: { extensions: ['.c', '.h'], preprocessPrefix: '#', ... },
  cpp: { extensions: ['.cpp', '.cc', '.cxx', '.hpp', '.hh'], ... },
  json: { extensions: ['.json', '.json5', '.jsonc'], ... },
  css: { extensions: ['.css'], cssIdent: true, ... },
  html: { extensions: ['.html', '.htm', '.xml', '.svg'], htmlMode: true },
  markdown: { extensions: ['.md', '.markdown'], markdownMode: true },
  plain: { extensions: ['.txt', '.log', '.ini', ...], ... }
};

detectLanguage 函数根据文件扩展名匹配语言 ID,未命中时返回 plain。CSS 语言有一个特殊的 cssIdent:true 标志,允许标识符中包含连字符(如 background-color、font-size),这在其他语言中会被误判为减法运算符。

Python 语言定义了 tripleStrings 字段支持三引号多行字符串,C/C++ 定义了 preprocessPrefix:‘#’ 识别预处理指令,JavaScript/TypeScript 定义了 templateString:true 支持反引号模板字符串。

4.2 通用行扫描器

tokenizeGenericLine 是 JS/TS/Python/C/C++/JSON/CSS 共用的分词核心。它逐字符扫描一行源码,维护跨行状态(inBlockComment / inTripleString),按优先级匹配不同的词法单元:

// editor-engine.js —— 通用行扫描器(简化)
function tokenizeGenericLine(line, lang, st) {
  var tokens = [];
  var i = 0;
  while (i < line.length) {
    // 1. 跨行块注释:查找 */ 结束标记
    if (st.inBlockComment) { ... continue; }
    // 2. 跨行三引号字符串(Python)
    if (st.inTripleString) { ... continue; }
    // 3. 预处理指令(C/C++ 行首 #)
    if (lang.preprocessPrefix && ...) { ... continue; }
    // 4. 行注释(// 或 #)
    if (lang.lineComment && line.startsWith(lang.lineComment, i)) { ... continue; }
    // 5. 块注释开始(/* ... */)
    if (lang.blockComment && line.startsWith(lang.blockComment[0], i)) { ... continue; }
    // 6. 三引号字符串开始(""" 或 ''')
    if (lang.tripleStrings) { ... continue; }
    // 7. 普通字符串(单引号/双引号/反引号)
    if (ch === '"' || ch === "'" || ...) { ... continue; }
    // 8. 数字(含 0x 十六进制)
    if (/[0-9]/.test(ch) || ...) { ... continue; }
    // 9. 标识符 → 关键字 / 常量 / 函数名 / 普通
    if (/[A-Za-z_$]/.test(ch)) { ... continue; }
    // 10. 运算符/标点(连续合并)
    if (/[+\-*/%=<>!&|^~?:;,(){}\[\]]/.test(ch)) { ... continue; }
  }
  return tokens;
}

扫描器的状态机设计要点:

  • 跨行状态通过 st 对象在行与行之间传递:inBlockComment 标记是否处于块注释内,inTripleString 记录当前未闭合的三引号类型
  • 字符串扫描处理转义字符:遇到反斜杠时跳过下一个字符(cj === ‘\’ → j += 2),避免被转义的引号提前截断
  • 数字扫描支持十六进制前缀 0x 和后缀(L/U/F),scanNumber 函数独立处理
  • 标识符分类:先查 keywords 表匹配关键字,再查 constants 表匹配常量,然后检查下一个字符是否为 ( 判断函数调用,其余归为 plain
  • 运算符连续合并:多个运算符字符连续出现时合并为一个 operator token,例如 >= 不会被拆成 > 和 =

4.3 HTML 与 Markdown 专用扫描器

HTML 和 Markdown 的词法规则与通用语言差异较大,因此分别实现了专用扫描器。

tokenizeHtmlLine 处理 HTML 标签和注释:

// editor-engine.js —— HTML 行扫描器
function tokenizeHtmlLine(line, st) {
  var tokens = [];
  var i = 0;
  while (i < line.length) {
    // 跨行 HTML 注释
    if (st.inHtmlComment) { ... }
    // 开始注释 <!--
    if (line.startsWith('<!--', i)) { ... }
    // HTML 标签 <...>
    if (line.charAt(i) === '<') { ... }
    // 普通文本
    var nxt = line.indexOf('<', i);
    ...
  }
  return tokens;
}

HTML 扫描器维护 inHtmlComment 跨行状态,识别 注释对。标签匹配采用简单策略:从 < 开始到最近的 > 结束,整段作为一个 tag token。这足以实现基本的标签着色,不需要完整的 HTML 解析器。

tokenizeMarkdownLine 按行首特征分类:

// editor-engine.js —— Markdown 行扫描器
function tokenizeMarkdownLine(line) {
  var trimmed = line.trimStart();
  if (trimmed.startsWith('#')) return [{ type: 'keyword', text: line }];     // 标题
  if (trimmed.startsWith('```')) return [{ type: 'comment', text: line }];   // 代码块
  if (trimmed.startsWith('>')) return [{ type: 'string', text: line }];      // 引用
  if (trimmed.startsWith('- ') || ...) return [{ type: 'function', text: line }]; // 列表
  return [{ type: 'plain', text: line }];
}

Markdown 扫描器不做行内级解析(如加粗、链接),仅按行首标记区分段落类型,复用已有的 CSS 类名着色——标题用 keyword 色(mauve)、代码块用 comment 色(overlay0)、引用用 string 色(green)、列表用 function 色(blue)。

4.4 括号匹配与行注释切换

findMatchingBracket 实现双向括号匹配,支持圆括号、方括号、花括号三组:

// editor-engine.js —— 双向括号匹配
var BRACKET_PAIRS = { '(': ')', '[': ']', '{': '}', ')': '(', ']': '[', '}': '{' };
var BRACKET_OPEN = { '(': 1, '[': 1, '{': 1 };

function findMatchingBracket(code, pos) {
  var ch = code.charAt(pos);
  var isForward = !!BRACKET_OPEN[ch];
  var target = BRACKET_PAIRS[ch];
  var depth = 1;
  if (isForward) {
    for (var i = pos + 1; i < code.length; i++) {
      var c = code.charAt(i);
      if (c === ch) depth++;
      else if (c === target) { depth--; if (depth === 0) return i; }
    }
  } else {
    for (var j = pos - 1; j >= 0; j--) {
      var c2 = code.charAt(j);
      if (c2 === ch) depth++;
      else if (c2 === target) { depth--; if (depth === 0) return j; }
    }
  }
  return -1;
}

光标位于括号上时,渲染进程检测光标处和光标前一个字符是否为括号,如果是则调用 findMatchingBracket 查找配对位置,返回的偏移量传给 renderHighlight 进行高亮着色。

toggleLineComment 实现行注释切换,逻辑为:先检测目标行是否全部已注释,如果是则移除注释前缀,否则添加前缀:

// editor-engine.js —— 行注释切换
function toggleLineComment(text, startLine, endLine, langId) {
  var lang = LANGUAGES[langId] || LANGUAGES.plain;
  var prefix = lang.lineComment;
  if (!prefix && lang.blockComment) prefix = lang.blockComment[0] + ' ';
  if (!prefix) return text;  // JSON 等无注释语法的语言直接返回
  var lines = text.split('\n');
  // 检测是否全部已注释
  var allCommented = true;
  for (var i = startLine; i <= endLine; i++) {
    var trimmed = lines[i].trimStart();
    if (trimmed.length > 0 && !trimmed.startsWith(prefix)) { allCommented = false; break; }
  }
  // 添加或移除
  for (var k = startLine; k <= endLine; k++) {
    if (lines[k].trim() === '') continue;
    if (allCommented) {
      var idx = lines[k].indexOf(prefix);
      if (idx !== -1) lines[k] = lines[k].substring(0, idx) + lines[k].substring(idx + prefix.length);
    } else {
      var indent = lines[k].match(/^(\s*)/)[1];
      lines[k] = indent + prefix + lines[k].substring(indent.length);
    }
  }
  return lines.join('\n');
}

当语言没有行注释(如 CSS、JSON)但有块注释时,回退使用块注释起始符加空格作为前缀(例如 CSS 使用 /* )。

4.5 缩进参考线与出现次数统计

renderIndentGuides 在行首缩进区域每隔 tabSize 个空格插入一个 │ 字符(Unicode U+2502),用 span.indent-guide 包裹后覆盖在原始 HTML 上:

// editor-engine.js —— 缩进参考线
function renderIndentGuides(htmlLine, rawLine, tabSize) {
  var match = rawLine.match(/^( +)/);
  if (!match) return htmlLine;
  var indentLen = match[1].length;
  var guided = '';
  for (var i = 0; i < indentLen && i < htmlLine.length; i++) {
    if (i > 0 && i % tabSize === 0) {
      guided += '<span class="indent-guide">\u2502</span>';
    } else {
      guided += htmlLine.charAt(i);
    }
  }
  return guided + htmlLine.substring(indentLen);
}

参考线使用 pointer-events:none 确保不干扰鼠标事件,颜色为 surface1(深灰色),视觉上仅起引导作用。

countOccurrences 使用 indexOf 循环统计子串在全文中的真实出现次数,支持大小写敏感/不敏感两种模式。这个函数被查找替换功能调用,实时显示匹配数量:

// editor-engine.js —— 出现次数统计
function countOccurrences(text, query, caseSensitive) {
  if (!query) return 0;
  var hay = caseSensitive ? String(text) : String(text).toLowerCase();
  var needle = caseSensitive ? query : query.toLowerCase();
  var count = 0;
  var pos = 0;
  while ((pos = hay.indexOf(needle, pos)) !== -1) {
    count++;
    pos += needle.length;
  }
  return count;
}

五、Grid 叠加编辑器

5.1 CSS Grid 同格叠加方案

码驰编辑器的核心 UI 创新是使用 CSS Grid 将 textarea(输入层)和 div(高亮层)放置在同一个网格单元中,实现精确的视觉叠加:

/* zed.css —— Grid 叠加布局 */
.editor-stack {
  flex: 1;
  display: grid;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
}

.highlight-layer,
.input-layer {
  grid-area: 1 / 1;  /* 同一网格单元 */
  font-family: var(--font-mono);
  font-size: var(--editor-font-size);
  line-height: var(--editor-line-height);
  padding: 12px 16px;
  tab-size: 2;
  white-space: pre;
  border: none;
}

.highlight-layer {
  margin: 0;
  overflow: hidden;
  color: var(--text);
  background: var(--base);
  pointer-events: none;  /* 不拦截鼠标事件 */
}

.input-layer {
  overflow: auto;
  background: transparent;
  color: transparent;      /* 文字透明 */
  caret-color: var(--text); /* 光标可见 */
  outline: none;
  resize: none;
}

两层共享完全相同的 font-family、font-size、line-height、padding、tab-size、white-space 属性,确保高亮层的着色文字与输入层的不可见文字精确对齐。输入层的 color:transparent 让原生文字不可见,但 caret-color 保持光标可见;pointer-events:none 让高亮层不拦截鼠标事件,所有交互由输入层处理。

5.2 滚动同步与防抖渲染

syncScroll 函数在输入层滚动时同步高亮层和行号 gutter 的滚动位置:

// renderer.js —— 滚动同步
function syncScroll() {
  el.highlightLayer.scrollTop = el.inputLayer.scrollTop;
  el.highlightLayer.scrollLeft = el.inputLayer.scrollLeft;
  el.gutter.scrollTop = el.inputLayer.scrollTop;
}

三个元素共享同一个滚动源(inputLayer),保证高亮层、行号列与输入区域始终对齐。

输入事件的防抖使用 requestAnimationFrame:

// renderer.js —— 防抖高亮
var highlightPending = false;

function onInput() {
  var tab = findTab(state.activePath);
  if (tab) tab.dirty = true;
  renderTabs();
  scheduleHighlight();
  updateCursor();
  if (state.findVisible) recalcFind();
}

function scheduleHighlight() {
  if (highlightPending) return;
  highlightPending = true;
  requestAnimationFrame(function() {
    highlightPending = false;
    renderHighlight();
  });
}

每次 onInput 只调度一次 requestAnimationFrame,同一帧内的多次输入合并为一次高亮渲染,避免频繁 DOM 操作导致卡顿。

5.3 大文件优化

renderHighlight 对超过 500 KB 的文件跳过语法高亮,直接输出纯文本 HTML:

// renderer.js —— 大文件优化
function renderHighlight() {
  var code = el.inputLayer.value;
  var rawLines = code.split('\n');
  var hlHtml;
  if (code.length > 500000) {
    // 超过 500 KB:跳过语法高亮,仅做 HTML 转义
    hlHtml = rawLines.map(function(l) { return escapeText(l); });
  } else {
    var tokenLines = tokenize(code, langId);
    hlHtml = tokenLines.map(function(toks) {
      // ... 正常高亮渲染
    });
  }
  // 缩进参考线 + 当前行 + 括号匹配 + 查找匹配 + 行号 gutter
  // ...
}

500 KB 阈值是一个经验值:在此之下,分词 + DOM 更新的总耗时通常在 16 ms 以内(一帧),不会引起可感知的卡顿;超过此阈值则跳过词法分析,仅做 HTML 转义后直接渲染,保证编辑流畅性。配合主进程 2 MB 的文件读取上限,编辑器可安全处理 0~2 MB 范围内的任意文件。

5.4 当前行与括号匹配高亮

renderHighlight 在生成每行 HTML 时,检查行号是否等于光标所在行,如果是则添加 current 类名实现当前行高亮:

// renderer.js —— 当前行高亮
var cls = (i + 1 === curLine) ? 'eline current' : 'eline';
out.push('<div class="' + cls + '">' + (lineHtml || ' ') + '</div>');
/* zed.css —— 当前行样式 */
.eline.current {
  background: var(--surface0);
}

括号匹配高亮在 renderHighlight 中通过 insertHighlightSpan 函数实现:根据 state.bracketMatch 中保存的 open/close 偏移量,定位到对应行和字符位置,插入 span.bracket-match 包裹匹配括号。CSS 使用 peach 半透明背景加 outline 边框,视觉上清晰但不刺眼。

查找匹配高亮同理:state.findMatchStart/End 记录当前匹配的字符范围,renderHighlight 逐行检查是否跨越该范围,如果是则插入 span.find-match(yellow 半透明背景)。

六、文件树与多标签管理

6.1 文件树渲染

renderTree 将主进程返回的文件树数据结构递归渲染为 DOM:

// renderer.js —— 文件树递归渲染
function renderTreeNode(node, depth, container) {
  var row = document.createElement('div');
  row.className = 'tree-row';
  row.style.paddingLeft = (8 + depth * 14) + 'px';  // 深度控制缩进

  if (node.isDir) {
    var isOpen = !!state.expanded[node.path];
    row.innerHTML = '<span class="tree-icon">' + (isOpen ? '📂' : '📁') + '</span>' +
      '<span class="tree-name">' + escapeText(node.name) + '</span>';
    row.addEventListener('click', function() {
      state.expanded[node.path] = !state.expanded[node.path];
      renderTree();
    });
    container.appendChild(row);
    if (isOpen && node.children) {
      for (var i = 0; i < node.children.length; i++) {
        renderTreeNode(node.children[i], depth + 1, container);
      }
    }
  } else {
    var isActive = node.path === state.activePath;
    if (isActive) row.classList.add('active');
    row.innerHTML = '<span class="tree-icon">📄</span>' +
      '<span class="tree-name">' + escapeText(node.name) + '</span>';
    row.addEventListener('click', function() { openFile(node.path); });
    container.appendChild(row);
  }
}

文件树通过 state.expanded 对象记录每个目录的展开/折叠状态,点击目录行切换状态后重新渲染整棵树。目录行显示文件夹图标(展开为 📂,折叠为 📁),文件行显示 📄。当前激活的文件行添加 active 类名高亮。

paddingLeft 按深度递增(8 + depth × 14 px),实现树形缩进视觉效果。

6.2 多标签管理

标签系统使用 state.tabs 数组存储所有打开的文件标签,state.activePath 记录当前激活标签的路径:

// renderer.js —— 标签管理核心
function openFile(path) {
  var existing = findTab(path);
  if (existing) { activateTab(path); return; }  // 已打开则直接切换
  ipcRenderer.invoke('fs:read', path).then(function(r) {
    if (!r.success) { setStatus(r.error); return; }
    var tab = {
      path: path,
      name: path.split(/[/\\]/).pop(),
      lang: detectLanguage(path),  // 按扩展名检测语言
      content: r.content,
      dirty: false,
      size: r.size
    };
    state.tabs.push(tab);
    updateRecent(path);  // 更新最近打开列表
    activateTab(path);
  });
}

function activateTab(path) {
  // 保存当前标签内容到 state
  if (state.activePath) {
    var cur = findTab(state.activePath);
    if (cur) cur.content = el.inputLayer.value;
  }
  state.activePath = path;
  var tab = findTab(path);
  el.inputLayer.value = tab.content;  // 恢复标签内容
  showEditor(true);
  renderTabs();
  renderHighlight();
  updateCursor();
  renderBreadcrumb();
  el.inputLayer.focus();
}

切换标签时,先将当前输入框的内容保存回 state.tabs 中对应标签的 content 字段,再从目标标签的 content 恢复到输入框。这样每个标签的内容始终保存在内存中,不依赖文件系统。

renderTabs 生成标签栏 DOM,未保存的标签在名称后显示 ● 标记(dirty 状态):

// renderer.js —— 标签栏渲染
function renderTabs() {
  el.tabbar.innerHTML = '';
  if (state.tabs.length === 0) {
    el.tabbar.appendChild(empty);  // "无打开的文件"
    return;
  }
  state.tabs.forEach(function(tab) {
    var t = document.createElement('div');
    t.className = 'tab' + (tab.path === state.activePath ? ' active' : '');
    var title = tab.name + (tab.dirty ? ' ●' : '');
    t.innerHTML = '<span class="tab-label">' + escapeText(title) + '</span>' +
      '<span class="tab-close" title="关闭 (Ctrl+W)">×</span>';
    // ... 点击事件绑定
    el.tabbar.appendChild(t);
  });
}

关闭标签时,如果该标签有未保存的修改(dirty:true),会在状态栏提示"已关闭未保存文件"。关闭当前激活的标签后,自动切换到相邻标签(优先左侧);如果所有标签都被关闭,则显示欢迎页。

6.3 面包屑导航

renderBreadcrumb 将当前文件路径相对于根目录的部分拆分为段,用 › 分隔符连接:

// renderer.js —— 面包屑导航
function renderBreadcrumb() {
  if (!state.activePath || !state.rootPath) {
    el.breadcrumb.classList.add('hidden');
    return;
  }
  el.breadcrumb.classList.remove('hidden');
  var rel = state.activePath;
  if (rel.startsWith(state.rootPath)) {
    rel = rel.substring(state.rootPath.length);
    if (rel.startsWith('\\') || rel.startsWith('/')) rel = rel.substring(1);
  }
  var parts = rel.split('\\');
  var html = '';
  for (var i = 0; i < parts.length; i++) {
    if (i > 0) html += '<span class="breadcrumb-sep">›</span>';
    html += '<span class="breadcrumb-seg">' + escapeText(parts[i]) + '</span>';
  }
  el.breadcrumb.innerHTML = html;
}

面包屑位于标签栏下方、编辑区上方,帮助用户快速了解当前文件在项目目录中的位置。

6.4 最近打开与欢迎页

updateRecent 维护一个 LRU(最近最少使用)列表,最多保存 10 条记录:

// renderer.js —— 最近打开(LRU 10 条)
function updateRecent(path) {
  var idx = state.recentFiles.indexOf(path);
  if (idx !== -1) state.recentFiles.splice(idx, 1);  // 移除旧记录
  state.recentFiles.unshift(path);  // 插入队首
  if (state.recentFiles.length > 10) state.recentFiles.pop();  // 超出上限
  renderRecent();
}

当没有打开任何文件时,编辑器显示欢迎页,包含品牌标识、操作提示和最近打开列表。打开文件后欢迎页隐藏,显示编辑区。

七、查找替换与命令面板

7.1 查找栏与大小写切换

showFindBar 打开查找栏,可选显示替换输入框:

// renderer.js —— 查找栏
function showFindBar(withReplace) {
  hideAllBars();
  state.findVisible = true;
  el.findBar.classList.remove('hidden');
  el.replaceInput.classList.toggle('hidden', !withReplace);
  document.getElementById('btnReplaceOne').classList.toggle('hidden', !withReplace);
  document.getElementById('btnReplaceAll').classList.toggle('hidden', !withReplace);
  el.findInput.focus();
  el.findInput.select();
  recalcFind();
}

toggleCase 切换大小写敏感模式,按钮添加 active 类名视觉反馈:

// renderer.js —— 大小写切换
function toggleCase() {
  state.caseSensitive = !state.caseSensitive;
  el.btnCaseToggle.classList.toggle('active', state.caseSensitive);
  recalcFind();
}

recalcFind 在每次输入变化时调用 countOccurrences 统计匹配数量,实时显示"N 个结果"或"无匹配"。

7.2 查找导航与替换

findNext/findPrev 使用 indexOf/lastIndexOf 实现向前/向后查找,到达文件末尾/开头时自动回绕:

// renderer.js —— 查找下一个(支持回绕)
function findNext() {
  var q = el.findInput.value;
  if (!q || state.activePath === null) return;
  var text = el.inputLayer.value;
  var hay = state.caseSensitive ? text : text.toLowerCase();
  var needle = state.caseSensitive ? q : q.toLowerCase();
  var from = state.findMatchEnd > 0 ? state.findMatchEnd : el.inputLayer.selectionStart;
  var idx = hay.indexOf(needle, from);
  if (idx === -1) idx = hay.indexOf(needle, 0);  // 回绕到开头
  if (idx === -1) { el.findCount.textContent = '无匹配'; return; }
  locateMatch(idx, q);
}

locateMatch 将光标定位到匹配位置,更新 state.findMatchStart/End 供 renderHighlight 绘制半透明黄色背景高亮。注意:只定位光标(setSelectionRange(idx, idx)),不选中文字——因为选中会触发浏览器默认的灰色选中背景,覆盖源码的语法高亮颜色。

replaceAll 支持大小写敏感和不敏感两种模式。不敏感模式手动循环 indexOf 逐段拼接替换结果,避免正则表达式的特殊字符转义问题。

7.3 命令面板

命令面板通过 Ctrl+Shift+P 唤起,提供 18 条内置命令的快速入口:

// renderer.js —— 命令定义(18 条)
var COMMAND_DEFS = [
  { id: 'openFolder', label: '打开文件夹', shortcut: 'Ctrl+O', action: openFolder },
  { id: 'save', label: '保存文件', shortcut: 'Ctrl+S', action: saveActive },
  { id: 'newFile', label: '新建文件', action: function() { showCreateInput('file'); } },
  { id: 'newFolder', label: '新建文件夹', action: function() { showCreateInput('folder'); } },
  { id: 'rename', label: '重命名文件', shortcut: 'F2', action: startRename },
  { id: 'delete', label: '删除文件', action: confirmDelete },
  { id: 'toggleSidebar', label: '切换侧边栏', action: toggleSidebar },
  { id: 'toggleWrap', label: '切换自动换行', action: toggleWordWrap },
  { id: 'fontUp', label: '增大字号', shortcut: 'Ctrl+=', action: ... },
  { id: 'fontDown', label: '减小字号', shortcut: 'Ctrl+-', action: ... },
  { id: 'goToLine', label: '跳转到行', shortcut: 'Ctrl+G', action: showGoToLine },
  { id: 'find', label: '查找', shortcut: 'Ctrl+F', action: ... },
  { id: 'replace', label: '替换', shortcut: 'Ctrl+H', action: ... },
  { id: 'closeTab', label: '关闭标签', shortcut: 'Ctrl+W', action: ... },
  { id: 'comment', label: '切换行注释', shortcut: 'Ctrl+/', action: toggleComment },
  { id: 'moveLineUp', label: '上移当前行', shortcut: 'Ctrl+Up', action: moveLineUp },
  { id: 'moveLineDown', label: '下移当前行', shortcut: 'Ctrl+Down', action: moveLineDown },
  { id: 'deleteLine', label: '删除当前行', shortcut: 'Ctrl+Shift+K', action: deleteCurrentLine }
];

命令面板的交互逻辑:

  • 输入框实时过滤:filterCommands 将输入文字转小写后,对每条命令的 label 做 indexOf 模糊匹配
  • 上下箭头选择:onCommandKey 监听 ArrowUp/ArrowDown 键,移动 state.commandIdx 索引并刷新选中样式
  • Enter 执行:选中当前高亮的命令后调用其 action 函数
  • Esc 关闭:hideCommandPalette 恢复编辑器焦点

命令面板使用 hidden 类控制显隐,打开时先调用 hideAllBars() 关闭其他浮层(查找栏/跳转栏/创建栏),保证同一时刻只有一个浮层可见。

7.4 浮层统一管理

hideAllBars 是全局浮层关闭函数,统一关闭查找栏、跳转栏、创建栏,并重置查找匹配状态:

// renderer.js —— 统一关闭所有浮层
function hideAllBars() {
  el.findBar.classList.add('hidden');
  el.goToLineBar.classList.add('hidden');
  el.createBar.classList.add('hidden');
  state.findVisible = false;
  state.findMatchStart = -1;
  state.findMatchEnd = -1;
  el.inputLayer.focus();
  scheduleHighlight();
}

关闭浮层后自动将焦点返回编辑器输入层,并触发一次高亮刷新(清除查找匹配着色)。

八、行操作与快捷键体系

8.1 行移动与删除

moveLineUp/moveLineDown 通过交换当前行与相邻行的数组元素实现行移动:

// renderer.js —— 上移当前行
function moveLineUp() {
  if (!state.activePath) return;
  var info = getLineInfo();
  if (info.lineIdx === 0) return;  // 已是第一行
  var tmp = info.lines[info.lineIdx - 1];
  info.lines[info.lineIdx - 1] = info.lines[info.lineIdx];
  info.lines[info.lineIdx] = tmp;
  setEditorContent(info.lines, info.lineIdx - 1, 0);
}

function deleteCurrentLine() {
  if (!state.activePath) return;
  var info = getLineInfo();
  info.lines.splice(info.lineIdx, 1);
  if (info.lines.length === 0) info.lines.push('');  // 至少保留一行
  var newIdx = Math.min(info.lineIdx, info.lines.length - 1);
  setEditorContent(info.lines, newIdx, 0);
}

getLineInfo 是一个通用工具函数,将编辑器内容按 \n 拆分为数组,根据光标位置计算当前行索引。setEditorContent 将修改后的行数组重新拼接为字符串,恢复编辑器内容并设置光标位置。

8.2 Tab 与 Enter 智能处理

onEditorKeyDown 拦截 Tab 和 Enter 键,实现缩进和自动缩进:

// renderer.js —— Tab 缩进/反缩进
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
  e.preventDefault();
  var ta = el.inputLayer;
  var start = ta.selectionStart;
  if (e.shiftKey) {
    // 反缩进:移除行首最多 TAB_SIZE 个空格
    var lineStart = val.lastIndexOf('\n', start - 1) + 1;
    var lineText = val.substring(lineStart, start);
    var spaces = 0;
    while (spaces < TAB_SIZE && lineText.charAt(spaces) === ' ') spaces++;
    if (spaces > 0) {
      ta.value = val.substring(0, lineStart) + val.substring(lineStart + spaces);
      ta.selectionStart = ta.selectionEnd = start - spaces;
    }
  } else {
    // 缩进:插入 TAB_SIZE 个空格
    var indent = '';
    for (var s = 0; s < TAB_SIZE; s++) indent += ' ';
    ta.value = val.substring(0, start) + indent + val.substring(end);
    ta.selectionStart = ta.selectionEnd = start + TABSize;
  }
}

Enter 键的自动缩进逻辑:继承当前行的前导空白,如果行末字符是 {、( 或 : 则额外增加一级缩进:

// renderer.js —— Enter 自动缩进
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey) {
  e.preventDefault();
  var pos = ta2.selectionStart;
  var lineBegin = v.lastIndexOf('\n', pos - 1) + 1;
  var currentLine = v.substring(lineBegin, pos);
  var indent = currentLine.match(/^(\s*)/)[1];  // 继承前导空白
  var lastChar = currentLine.trimEnd().slice(-1);
  if (lastChar === '{' || lastChar === '(' || lastChar === ':') indent += '  ';  // 额外缩进
  ta2.value = v.substring(0, pos) + '\n' + indent + v.substring(ta2.selectionEnd);
}

8.3 全局快捷键体系

onGlobalKey 统一管理全局快捷键,优先级从高到低:

  1. Esc:关闭命令面板 → 关闭查找/跳转/创建栏
  2. 命令面板内部快捷键(ArrowUp/Down/Enter/Esc)
  3. Ctrl+Shift+P:命令面板
  4. Ctrl+/:切换行注释
  5. Ctrl+G:跳转行
  6. Ctrl+Up/Down:移动行
  7. Ctrl+Shift+K:删除行
  8. Ctrl+=/-:字号缩放
  9. F2:重命名
  10. Ctrl+S/O/F/H/W:保存/打开/查找/替换/关闭标签
// renderer.js —— 全局快捷键(部分)
function onGlobalKey(e) {
  var mod = e.ctrlKey || e.metaKey;
  // Esc 关闭所有浮层
  if (e.key === 'Escape') {
    if (state.commandVisible) { hideCommandPalette(); return; }
    if (state.findVisible || ...) { hideAllBars(); return; }
  }
  // 命令面板打开时拦截所有其他快捷键
  if (state.commandVisible) return;
  // Ctrl+Shift+P 命令面板
  if (mod && e.shiftKey && e.key === 'P') { e.preventDefault(); showCommandPalette(); return; }
  // Ctrl+/ 注释
  if (mod && e.key === '/') { e.preventDefault(); toggleComment(); return; }
  // ...
}

命令面板打开时,所有全局快捷键被拦截(if (state.commandVisible) return),确保上下箭头和 Enter 键用于命令选择而非触发其他功能。

8.4 字号缩放与自动换行

changeFontSize 通过修改 CSS 变量 --editor-font-size 实现全局字号调整,范围限制在 10~24 px:

// renderer.js —— 字号缩放
function changeFontSize(delta) {
  state.fontSize = Math.max(10, Math.min(24, state.fontSize + delta));
  document.documentElement.style.setProperty('--editor-font-size', state.fontSize + 'px');
  setStatus('字号: ' + state.fontSize + 'px');
}

由于高亮层和输入层都使用 var(–editor-font-size) 引用字号,修改 CSS 变量后两层自动同步更新,无需手动遍历 DOM。

toggleWordWrap 切换自动换行模式,通过添加/移除 editorStack 上的 wrap-mode 类名实现:

/* zed.css —— 自动换行 */
.wrap-mode .highlight-layer,
.wrap-mode .input-layer {
  white-space: pre-wrap;
  word-wrap: break-word;
}

8.5 关于对话框

关于对话框使用 hidden 类控制显隐(而非 window.open 弹窗),通过 fixed 定位覆盖整个视口:

/* zed.css —— 关于对话框 */
#aboutDialogLayer {
  position: fixed;
  top: 0; left: 0; right: 0; bottom: 0;
  background: rgba(17, 17, 27, 0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

对话框内容分三段:开源声明(致谢 Zed 等开源编辑器,明确说明不包含其任何源代码)、免责声明(不包含任何网络传输功能,编辑内容仅保存在本地设备)、许可证信息(Apache-2.0)。这是鸿蒙应用上架审核的标准格式。

CSS 文件中特别注释了关于对话框使用 fixed 覆盖的原因:“常规区域不使用 fixed/叠放/形变/动画;关于对话框在 Electron 壳内使用 fixed 覆盖(Chromium 支持)”——在鸿蒙 ArkWeb 环境下,只有对话框这类临时覆盖层才使用 fixed 定位,常规布局区域严格遵循 Flexbox 流式布局。

九、常见问题与解决方案

Q1:语法高亮在输入时卡顿,尤其是大文件

问题现象: 用户在编辑器中输入文字时,每次按键都会触发语法高亮重绘,文件超过数百行时明显卡顿。

根因分析: 早期实现中 onInput 直接调用 renderHighlight,每次输入都同步执行分词 + DOM 更新。当文件较大时,tokenize 遍历所有行的开销超过 16 ms,导致掉帧。

解决: 使用 requestAnimationFrame 防抖,同一帧内的多次输入合并为一次高亮渲染:

// renderer.js —— 防抖高亮
var highlightPending = false;

function scheduleHighlight() {
  if (highlightPending) return;
  highlightPending = true;
  requestAnimationFrame(function() {
    highlightPending = false;
    renderHighlight();
  });
}

对于超过 500 KB 的文件,直接跳过语法高亮,仅做 HTML 转义后渲染纯文本,保证编辑流畅性。

Q2:CSS 属性名被错误拆分为关键字和运算符

问题现象: 编辑 CSS 文件时,background-color 被高亮为 background(关键字)+ -(运算符)+ color(关键字),而不是整体着色。

根因分析: 通用标识符扫描器的正则 /[A-Za-z0-9_$]/ 不包含连字符 -,遇到 background-color 时在 g 和 - 之间断开,将 - 识别为减法运算符。

解决: 为 CSS 语言定义增加 cssIdent:true 标志,标识符扫描时切换到 /[A-Za-z0-9_$-]/ 正则,允许连字符作为标识符的一部分:

// editor-engine.js —— CSS 标识符特殊处理
if (/[A-Za-z_$]/.test(ch)) {
  var w = i;
  var identRe = lang.cssIdent ? /[A-Za-z0-9_$-]/ : /[A-Za-z0-9_$]/;
  while (w < n && identRe.test(line.charAt(w))) w++;
  var word = line.substring(i, w);
  // ... 关键字/常量/函数名判断
}

其他语言不使用 cssIdent 模式,避免 count-1 这样的表达式被误识别为一个标识符。

Q3:查找替换在大小写不敏感模式下结果不正确

问题现象: 关闭大小写敏感后,查找 “Hello” 无法匹配文档中的 “hello”。

根因分析: 早期实现直接使用 String.indexOf 查找,没有对搜索文本和文档内容同时做 toLowerCase 转换。

解决: 查找时将文档和查询都转为小写后匹配,但替换时使用原始文档的文本片段,保持原有大小写:

// renderer.js —— 大小写不敏感查找
function findNext() {
  var q = el.findInput.value;
  var text = el.inputLayer.value;
  var hay = state.caseSensitive ? text : text.toLowerCase();
  var needle = state.caseSensitive ? q : q.toLowerCase();
  var from = state.findMatchEnd > 0 ? state.findMatchEnd : el.inputLayer.selectionStart;
  var idx = hay.indexOf(needle, from);
  if (idx === -1) idx = hay.indexOf(needle, 0);  // 回绕
  if (idx === -1) { el.findCount.textContent = '无匹配'; return; }
  locateMatch(idx, q);  // 用原始 q 的长度定位匹配区域
}

replaceAll 同样区分大小写/不敏感两种路径,不敏感模式手动循环 indexOf 逐段拼接,避免正则特殊字符转义问题。

Q4:文件树扫描大项目时主进程卡死

问题现象: 打开包含 node_modules 的大型前端项目时,应用无响应数秒。

根因分析: 早期实现没有 IGNORE_DIRS 过滤和 MAX_ENTRIES 限制,scanTree 递归扫描 node_modules 下数万个文件,主进程被 fs.readdirSync 阻塞。

解决: 三重保护机制:

// main.js —— 文件树扫描保护
var IGNORE_DIRS = ['node_modules', '.git', 'oh_modules', '.hvigor', '.idea',
  '.ohpm', 'build', '.gradle', 'dist', 'out', '__pycache__'];
var MAX_DEPTH = 4;
var MAX_ENTRIES = 2000;

function scanTree(dirPath, depth) {
  if (depth > MAX_DEPTH) return result;  // 深度限制
  // ...
  for (var i = 0; i < entries.length; i++) {
    if (scanTree.count >= MAX_ENTRIES) break;  // 总量限制
    if (ent.isDirectory() && IGNORE_DIRS.indexOf(ent.name) !== -1) continue;  // 目录过滤
    // ...
  }
}

IGNORE_DIRS 过滤掉常见的构建产物和依赖目录,MAX_DEPTH 限制递归深度,MAX_ENTRIES 限制全局扫描总量。三者共同确保扫描在合理时间内完成。

Q5:删除文件时误操作导致数据丢失

问题现象: 用户点击删除按钮后文件立即被删除,无法恢复。

根因分析: 早期实现单击删除按钮直接调用 fs.unlinkSync,没有二次确认机制。

解决: 实现二次确认 + 5 秒超时取消机制:

// renderer.js —— 删除二次确认
function confirmDelete() {
  if (!state.activePath) { setStatus('请先打开文件'); return; }
  var tab = findTab(state.activePath);
  if (!tab) return;
  // 第二次点击同一文件:执行删除
  if (state.deleteConfirmPath === state.activePath) {
    doDelete();
    return;
  }
  // 第一次点击:提示确认
  state.deleteConfirmPath = state.activePath;
  setStatus('确认删除 ' + tab.name + '?再次点击删除按钮');
  // 5 秒超时自动取消
  if (state.deleteConfirmTimer) clearTimeout(state.deleteConfirmTimer);
  state.deleteConfirmTimer = setTimeout(function() {
    state.deleteConfirmPath = null;
    setStatus('删除已取消');
  }, 5000);
}

第一次点击删除按钮时,状态栏显示确认提示并记录目标路径;5 秒内再次点击同一文件的删除按钮才真正执行删除。超过 5 秒未确认则自动取消,防止误操作。

Q6:修改了代码,但 HAP 安装后没有生效

问题现象: 修改了 editor-engine.js、renderer.js 等文件,重新构建 HAP 安装后,应用行为没有任何变化。

根因分析: 鸿蒙壳工程的实际运行文件从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载,而非开发目录。修改后必须手动同步。

解决: 每次修改后执行同步命令:

Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron/src/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force

验证方法: 同步后用文件哈希对比确认一致性。

Q7:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限

问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了受限 ACL 权限,但应用隐私政策中未声明对应用途。

根因分析: Electron 壳工程模板默认声明了大量系统权限,码驰代码编辑器作为纯本地编辑工具不需要位置、相机、麦克风、剪贴板等权限。

解决: 精简权限声明,只保留应用实际使用的系统权限,移除 FILE_ACCESS_PERSIST、READ_WRITE_DESKTOP_DIRECTORY、READ_PASTEBOARD、CAMERA、MICROPHONE 等不需要的权限。码驰代码编辑器无剪贴板功能,READ_PASTEBOARD 旁路不适用。

十、总结

本文完整记录了在鸿蒙 PC 平台上适配码驰代码编辑器的全过程。通过 Electron 壳方案,我们实现了一个纯 JavaScript 的轻量级代码编辑器,核心特性包括:

特性实现方案
语法高亮引擎10 语言定义表 + 手写字符扫描器分词(无第三方库依赖)
分词器核心块注释跨行状态 + 三引号字符串 + 预处理指令 + 字符串/数字/标识符
HTML/MD 分词专用扫描器(标签+注释 / 标题+代码块+引用+列表)
CSS 特殊处理cssIdent 模式允许连字符(background-color 等属性名)
Grid 叠加编辑器textarea 透明输入层 + div 高亮层 CSS Grid 同格叠加
括号匹配双向查找 + 深度计数 + peach 半透明背景高亮
缩进参考线每 tabSize 个空格插入 │ 字符 span 覆盖层
行注释切换检测全部已注释 → 移除 / 否则 → 添加(支持块注释回退)
文件树扫描递归扫描 + MAX_DEPTH=4 + MAX_ENTRIES=2000 + IGNORE_DIRS
多标签管理打开/切换/关闭 + dirty 标记(●)+ 内容缓存
查找替换大小写切换 + 实时出现次数统计 + 回绕查找 + 逐个/全部替换
命令面板18 条命令 + 模糊过滤 + 键盘导航(上下箭头 + Enter + Esc)
行操作上移/下移/删除当前行 + Tab/Shift+Tab 缩进 + Enter 自动缩进
文件 CRUD新建/重命名(三重校验)/删除(二次确认 + 5 秒超时)
文件 IPC8 个通道(dialog/tree/read/write/createFile/createDir/rename/delete)
大文件优化>500 KB 跳过语法高亮 + 2 MB 文件读取上限
鸿蒙安全菜单双保险移除 + 禁用硬件加速 + fixed 覆盖仅限对话框
深色主题Catppuccin Mocha 配色 + CSS 变量体系(17 色)

适配过程中最关键的三个点:

  1. 自研分词器与状态机设计——语法高亮引擎的核心是一个手写字符扫描器,逐字符扫描源码行并根据当前状态(是否在块注释内、是否在三引号字符串内)选择匹配规则。跨行状态通过 st 对象在行与行之间传递:inBlockComment 标记块注释、inTripleString 记录三引号类型。每种语言的关键字、常量、注释符等差异通过 LANGUAGES 定义表配置,扫描器本身是通用的。CSS 语言的 cssIdent 标志让标识符扫描器允许连字符,解决了 background-color 等属性名被误拆的问题
  2. CSS Grid 叠加编辑器方案——编辑器的核心创新是将 textarea(输入层)和 div(高亮层)放在同一个 CSS Grid 单元中(grid-area: 1/1),两层共享完全相同的字体、行高、内边距、tab-size、white-space 属性,确保高亮层的着色文字与输入层的不可见文字精确对齐。输入层设置 color:transparent 隐藏原生文字但保留 caret-color 光标可见,pointer-events:none 让高亮层不拦截鼠标事件。滚动同步通过 syncScroll 函数将 inputLayer 的 scrollTop/scrollLeft 实时复制到 highlightLayer 和 gutter
  3. 文件树安全扫描与防抖渲染——文件树扫描通过三重保护(MAX_DEPTH=4 限制递归深度、MAX_ENTRIES=2000 限制全局总量、IGNORE_DIRS 过滤构建/依赖目录)避免大项目卡死主进程。编辑器输入采用 requestAnimationFrame 防抖,同一帧内的多次按键合并为一次高亮渲染;超过 500 KB 的文件直接跳过语法高亮仅做 HTML 转义。删除操作实现二次确认 + 5 秒超时机制,防止误操作导致数据丢失

这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 代码编辑器。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐