智码编程助手是一款纯 JavaScript 实现的本地集成开发环境,提供文件资源管理器、多标签代码编辑器(支持 20 余种语言语法高亮)、项目概览面板(文件统计、两级目录树、最近修改列表、文件类型分布条形图)、拖拽分隔条三栏布局、右键上下文菜单、未保存关闭确认等完整 IDE 能力。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备项目浏览、代码查看与编辑、文件重命名与删除、原生目录选择、项目概览分析的完整过程——涵盖 contextBridge 安全通信模型、单通道动作路由、Workspace 沙箱路径校验、多标签编辑器状态管理、语法高亮引擎、项目递归扫描与可视化、以及 Catppuccin 深色主题等核心功能的实现细节与踩坑经验。

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

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

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

一、技术架构分析

1.1 整体架构

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

  • Electron Web 层:运行于 ArkWeb 引擎,提供完整的文件资源管理、多标签代码编辑、语法高亮、项目概览分析、拖拽分隔条布局与文件操作能力
  • 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、原生目录选择对话框、屏幕自适应等系统能力

这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。与典型的 Electron 应用不同,本工程采用 contextBridge 安全通信模型——渲染进程完全不接触 Node.js API,所有文件操作通过预加载脚本暴露的受控桥接器发起,主进程内的 Workspace 类执行严格的路径沙箱校验后才真正读写文件系统。

1.2 技术方案

对比项传统 IDE(VS Code / WebStorm)鸿蒙适配版(智码编程助手)
语言支持数百种语言 + LSP 智能补全20 余种语言语法高亮(纯前端正则)
文件操作扩展 API + 本地文件系统Workspace 沙箱类 + IPC 动作路由
编辑器Monaco / CodeMirror 组件原生 textarea + 透明叠加高亮层
项目分析内置终端 + 调试器 + Git 集成本地递归扫描 + 文件统计 + 分布图表
安全模型扩展沙箱 + 信任工作区contextBridge + CSP + 路径沙箱三重防护
部署方式安装包HAP 包(鸿蒙应用)
运行环境Windows/macOS/Linux鸿蒙 PC(ArkWeb 引擎)

1.3 核心功能清单

功能模块具体能力
文件资源管理目录树展开/折叠、文件类型徽章(30 余种扩展名映射)、点击打开
多标签编辑器标签页增删切换、未保存标记(圆点/关闭符号动态切换)、Ctrl+S 保存
语法高亮20 余种语言正则分词(关键字/字符串/数字/注释四类 token)
代码编辑textarea 透明叠加 + 高亮层同步滚动、Tab 缩进、行号联动
文件操作重命名、删除(自研递归删除,兼容鸿蒙旧版 Node)、未保存关闭确认
项目概览文件/目录/类型统计卡片、两级目录树、最近修改列表(mtime 排序)
分布图表Top 10 扩展名条形图(16 色循环配色)
拖拽分隔条左右两条分隔条,鼠标拖拽调整面板宽度(含边界约束)
右键菜单文件/目录右键重命名与删除确认
原生目录选择dialog.showOpenDialog 选择项目目录
安全通信contextBridge 白名单动作 + CSP connect-src:none + 路径沙箱
鸿蒙稳定关于弹窗 + 三防窗口配置 + 禁用原生菜单 + Toast 反馈

二、环境准备

2.1 开发环境要求

项目版本/信息
操作系统Windows 10/11
核心框架Electron (Node.js + Chromium)
技术栈HTML5/Vanilla JavaScript
渲染引擎原生 textarea + 高亮叠加层
目标设备鸿蒙 PC
目标架构arm64-v8a
开发工具DevEco Studio(鸿蒙官方 IDE)

2.2 项目结构

ohos_hap/
├── electron/                          # Electron 壳
├── web_engine/                        # 鸿蒙壳工程
│   └── src/main/resources/resfile/resources/app/  # 部署目录(业务代码平铺)
│       ├── main.js                    # 主进程(窗口管理 + 单通道动作路由 IPC)
│       ├── preload.js                 # 预加载脚本(contextBridge 安全桥接)
│       ├── renderer.js                # 渲染进程(UI 交互 + 编辑器 + 文件树 + 概览)
│       ├── index.html                 # 页面布局(三栏 + 状态栏 + 弹层 + 对话框)
│       ├── package.json               # 项目配置
│       ├── logo.png                   # 应用图标(面板 logo + 欢迎区大图)
│       ├── lib/
│       │   ├── service.cjs            # 后端服务层(会话管理 + 设置 + 工作区调度)
│       │   └── tools.cjs              # 工具层(Workspace 沙箱类 + 文件操作 + 搜索)
│       ├── runtime/
│       │   ├── pi-sdk.cjs             # Agent SDK(esbuild 打包产物,不可修改)
│       │   ├── provenance.json        # 上游来源记录
│       │   └── THIRD_PARTY_LICENSES.txt
│       └── styles/
│           └── pi.css                 # 深色主题样式(Flex 三栏 + 弹层 + 徽章)
└── AppScope/                          # 应用配置(bundleName: com.wei.zhima)

2.3 依赖说明

依赖版本用途
esbuild0.28.2打包 Agent SDK 为单文件产物
openai7.19.0OpenAI 兼容协议客户端
partial-json0.1.7流式 JSON 增量解析
typebox1.3.27工具参数 JSON Schema 类型定义
jsdom26.1.0服务端 DOM 环境(测试用)

2.4 应用身份信息

配置项值
应用名智码编程助手
bundleNamecom.wei.zhima
vendorwei
版本名1.0.0
版本号1000000

身份五处同步:AppScope/app.json5 的 bundleName 与 vendor、AppScope string.json 的 app_name、electron 模块 base/en_US/zh_CN 三套 EntryAbility_label 均为"智码编程助手"。

2.5 string.json 格式

鸿蒙资源文件使用数组格式:

{
  "string": [
    {
      "name": "EntryAbility_label",
      "value": "智码编程助手"
    }
  ]
}

base/en_US/zh_CN 三套 string.json 的 EntryAbility_label 值均为"智码编程助手",鸿蒙系统按 locale 目录优先级覆盖——中文环境加载 zh_CN,英文环境加载 en_US,其他语言回退到 base。

2.6 构建流程

  1. 在 DevEco Studio 中导入 ohos_hap 工程
  2. 配置 Release 签名(.p12 密钥库 + .p7b 描述文件)
  3. 同步运行配置的 Bundle Name 与 app.json5 一致(com.wei.zhima)
  4. Build > Build Hap(s)/APP(s) 生成签名 HAP 包
  5. 通过 hdc install 或 AppGallery Connect 提交部署

三、核心适配流程

3.1 主进程:单通道动作路由架构

与传统 Electron 应用为每种文件操作注册独立 IPC 通道不同,本工程采用单通道动作路由模式——全部 16 种操作通过 pi:request 一个通道分发,由主进程的 switch-case 路由到对应服务方法。

// main.js —— 单通道动作路由
ipcMain.handle('pi:request', async (event, action, input) => {
  try {
    if (!mainWindow || event.sender !== mainWindow.webContents || event.sender.getURL().split('#')[0] !== indexUrl || (event.senderFrame && event.senderFrame !== event.sender.mainFrame)) throw new Error('拒绝非应用页面请求');
    if (!service) throw new Error(startupError || '后端尚未就绪');
    let data;
    switch (action) {
      case 'state': data = service.snapshot(); break;
      case 'settings': data = service.saveSettings(input); break;
      case 'new': data = service.newSession(); break;
      case 'open': data = service.openSession(input); break;
      case 'delete': data = service.deleteSession(input); break;
      case 'workspace': data = service.setWorkspace(input); break;
      case 'chooseWorkspace': {
        service.ensureIdle();
        const result = await dialog.showOpenDialog(mainWindow, { title: '选择允许智码访问的项目目录', properties: ['openDirectory'] });
        data = result.canceled || !result.filePaths.length ? null : service.setWorkspace(result.filePaths[0]);
        break;
      }
      case 'list': data = service.workspace().list(input || '.'); break;
      case 'read': data = service.workspace().read(input); break;
      case 'write': service.ensureIdle(); data = service.workspace().write(input.path, input.content); break;
      case 'rename': service.ensureIdle(); data = service.workspace().rename(input.path, input.newName); break;
      case 'remove': service.ensureIdle(); data = service.workspace().remove(input); break;
      case 'send': data = await service.send(input); break;
      case 'testConnection': data = await service.testConnection(); break;
      case 'stop': service.stop(); data = true; break;
      case 'approve': service.resolveApproval(input.id, input.allow); data = true; break;
      default: throw new Error('未知操作');
    }
    return { ok: true, data };
  } catch (error) {
    const detail = error.cause ? (error.cause.message || String(error.cause)) : '';
    const msg = detail ? error.message + ' (' + detail + ')' : error.message;
    return { ok: false, error: service ? service.redact(msg) : String(msg) };
  }
});

该设计的核心要点:

  • 来源校验:event.sender 必须等于 mainWindow.webContents,URL 的 hash 前缀必须匹配 indexUrl,senderFrame 必须是主框架——三重条件防止任何非应用页面或子框架注入请求
  • 错误脱敏:redact 方法将错误消息中可能包含的 API Key 替换为 [已隐藏密钥],Bearer Token 替换为 [已隐藏]
  • 互斥保护:写操作(write/rename/remove)调用前执行 ensureIdle(),防止后端忙碌时并发修改

3.2 预加载脚本:contextBridge 白名单桥接

preload.js 是渲染进程与主进程之间的唯一通信桥梁。它通过 contextBridge.exposeInMainWorld 将受控 API 暴露到 window.pi 对象,并在白名单中严格限定允许的动作名称。

// preload.js —— contextBridge 白名单桥接
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
const allowed = new Set(['state', 'settings', 'new', 'open', 'delete', 'workspace', 'chooseWorkspace', 'list', 'read', 'write', 'rename', 'remove', 'send', 'stop', 'approve', 'testConnection']);
contextBridge.exposeInMainWorld('pi', {
  request(action, input) {
    if (!allowed.has(action)) return Promise.resolve({ ok: false, error: '未知操作' });
    return ipcRenderer.invoke('pi:request', action, input);
  },
  subscribe(callback) {
    const listener = (_event, data) => callback(data);
    ipcRenderer.on('pi:event', listener);
    return () => ipcRenderer.removeListener('pi:event', listener);
  }
});

白名单机制的意义在于:即使页面被注入恶意脚本,也只能调用白名单内的 16 种动作,无法构造任意 IPC 消息。subscribe 方法用于接收主进程主动推送的事件(如忙碌状态变更、审批请求等),返回取消订阅函数以便清理。

3.3 渲染进程安全通信封装

renderer.js 中对 window.pi 的调用进一步封装了错误处理与异步守卫:

// renderer.js —— 安全通信封装
async function request(action, input) {
  if (!window.pi) throw new Error('安全通信桥未加载');
  const result = await window.pi.request(action, input);
  if (!result.ok) throw new Error(result.error || '操作失败');
  return result.data;
}
const guard = (fn) => async (...args) => { try { await fn(...args); } catch (error) { status(error.message); } };

guard 高阶函数将所有异步操作包裹在 try-catch 中,异常时自动将错误信息显示在状态栏,避免未捕获异常导致页面白屏。

四、Workspace 沙箱与文件操作引擎

4.1 Workspace 类:路径安全沙箱

tools.cjs 中的 Workspace 类是所有文件操作的执行主体。它在构造时记录工作区根目录的绝对路径,每次文件访问前执行多层安全校验。

// tools.cjs —— Workspace 路径解析与安全校验
resolve(relative = '.', allowMissing = false) {
  if (typeof relative !== 'string' || !relative || relative.includes('\0') || relative.includes(':') || path.isAbsolute(relative)) throw new Error('仅允许工作区内相对路径');
  const parts = relative.replace(/\\/g, '/').split('/');
  if (parts.some((part) => part === '..' || HIDDEN.has(part.toLowerCase()) || /^\.env(?:\.|$)/i.test(part) || /[. ]$/.test(part) && part !== '.')) throw new Error('路径或敏感目录禁止访问');
  const target = path.resolve(this.root, relative);
  if (!within(this.root, target) || this.protectedPaths.some((p) => within(p, target))) throw new Error('禁止访问工作区外或应用私有数据');
  let current = this.root;
  for (const part of path.relative(this.root, target).split(path.sep).filter(Boolean)) {
    current = path.join(current, part);
    let st;
    try { st = fs.lstatSync(current); } catch (e) { if (allowMissing && e.code === 'ENOENT') continue; throw e; }
    if (st.isSymbolicLink()) throw new Error('不访问符号链接或目录联接');
    if (!within(this.root, fs.realpathSync(current))) throw new Error('路径越界');
  }
  return target;
}

安全校验覆盖五个维度:

  • 空字节注入防御:检测 \0 字符防止路径截断攻击
  • 敏感目录过滤:.git、.ssh、.aws、.gnupg、node_modules、.hvigor 以及 .env 文件一律禁止访问
  • 路径穿越防御:within 函数通过 path.relative 验证目标是否仍在工作区范围内
  • 应用数据保护:protectedPaths 数组包含 userData 目录和 app 目录,防止读取会话数据或应用私有配置
  • 符号链接防御:逐级 lstatSync 检查路径链中每一跳,遇到符号链接立即拒绝

4.2 文件列举与 mtime 字段

list 方法在返回条目前通过 statSync 补充 mtime(修改时间戳),供前端"最近修改"列表排序使用:

// tools.cjs —— 文件列举(含 mtime 与排序)
list(relative = '.') {
  const dir = this.resolve(relative);
  const entries = fs.readdirSync(dir, { withFileTypes: true });
  return entries.filter((e) => !HIDDEN.has(e.name.toLowerCase()) && !/^\.env(?:\.|$)/i.test(e.name) && !e.isSymbolicLink()).slice(0, 500).map((e) => {
    const full = path.join(dir, e.name);
    let mtime = 0;
    try { mtime = fs.statSync(full).mtimeMs; } catch { /* ignore */ }
    return { name: e.name, path: path.relative(this.root, full), directory: e.isDirectory(), mtime };
  }).sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name));
}

排序规则:目录优先(directory 降序),同级内按名称字母序。条目上限 500 条,防止超大目录阻塞渲染。

4.3 自研递归删除(兼容鸿蒙旧版 Node)

鸿蒙受限运行时中旧版 Node 可能不提供 fs.rmSync 的 recursive 选项,调用即抛异常导致主进程崩溃。tools.cjs 采用自研递归删除替代:

// tools.cjs —— 自研递归删除
remove(relative) {
  const target = this.resolve(relative);
  const removeRec = (p) => {
    const st = fs.lstatSync(p);
    if (st.isDirectory()) {
      for (const entry of fs.readdirSync(p)) removeRec(path.join(p, entry));
      fs.rmdirSync(p);
    } else fs.unlinkSync(p);
  };
  removeRec(target);
  return true;
}

先递归进入子目录清空内容,再 rmdirSync 删除空目录;文件直接 unlinkSync。全部使用旧版 Node 即有的同步 API,不依赖任何新增接口。

4.4 文件写入的排他创建保护

write 方法直接写入文件,而更高级的 change 方法(供 Agent 工具链调用)则实现了完整的排他创建流程:

// tools.cjs —— 排他创建文件防止覆盖
const flags = existed ? fs.constants.O_RDWR : fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL;
const fd = fs.openSync(target, flags | (fs.constants.O_NOFOLLOW || 0), 0o600);
try {
  const st = fs.fstatSync(fd);
  if (!st.isFile() || st.nlink > 1) throw new Error('目标不是可修改的普通文件');
  if (existed && fs.readFileSync(fd, 'utf8') !== before) throw new Error('文件已变化,请重新读取');
  const bytes = Buffer.from(newText, 'utf8');
  let offset = 0;
  while (offset < bytes.length) offset += fs.writeSync(fd, bytes, offset, bytes.length - offset, offset);
  fs.ftruncateSync(fd, bytes.length);
  fs.fsyncSync(fd);
} finally { fs.closeSync(fd); }

O_EXCL 标志确保新建时若文件已存在则失败(防止竞态覆盖),O_NOFOLLOW 拒绝打开符号链接。写入后通过 fsyncSync 强制刷盘,保证数据持久化。硬链接检测(nlink > 1)防止通过硬链接绕过沙箱修改受保护文件。

五、多标签编辑器与语法高亮引擎

5.1 标签页状态管理

renderer.js 中的 state 对象维护标签页生命周期:

// renderer.js —— 标签页状态
const state = {
  directory: '.', previewPath: '', fileVersion: 0,
  openTabs: [], activeTab: null
};

每个标签页对象包含 path(文件路径)、text(当前编辑内容)、original(上次保存的内容)。text !== original 时标记为"未保存",关闭时弹出确认对话框。

5.2 未保存关闭确认流程

当用户关闭有未保存更改的标签页时,系统弹出三选一确认弹层:

// renderer.js —— 未保存关闭确认
function closeTab(path) {
  const tab = state.openTabs.find((t) => t.path === path);
  if (tab && tab.text !== tab.original) { pendingClose = path; showCloseConfirm(tab); return; }
  doCloseTab(path);
}
async function closeConfirmAction(action) {
  const path = pendingClose;
  if (!path) { hideCloseConfirm(); return; }
  if (action === 'save') {
    const tab = state.openTabs.find((t) => t.path === path);
    try {
      await request('write', { path, content: tab.text });
      tab.original = tab.text;
      status('已保存:' + shortName(path));
      doCloseTab(path);
    } catch (e) { showToast('保存失败:' + e.message, false); status('保存失败:' + e.message); hideCloseConfirm(); return; }
  } else if (action === 'discard') { doCloseTab(path); }
  hideCloseConfirm();
}

三个按钮分别对应"保存并关闭"、“不保存"和"取消”。保存失败时不关闭弹层,让用户有机会重试或取消。

5.3 标签页关闭按钮的动态标记

标签页的关闭按钮在已保存和未保存两种状态下显示不同符号,避免占位抖动:

// renderer.js —— 关闭按钮动态标记
const dirty = tab.text !== tab.original;
const close = el('span', 'editor-tab-close' + (dirty ? ' dirty' : ''));
close.appendChild(el('span', 'mark-dirty', '●'));
close.appendChild(el('span', 'mark-close', '×'));

对应 CSS 通过 .dirty 类控制显隐:已保存时只显示 ×,未保存时显示 ●(金色圆点),鼠标悬停在关闭位置时 ● 切换为 ×。固定占位宽度避免状态切换时的布局抖动。

5.4 语法高亮引擎

highlightCode 函数通过正则表达式对代码进行分词,将源码拆分为关键字、字符串、数字、注释四类 token,每个 token 包裹在带 syntax- 前缀类名的 span 中:

// renderer.js —— 语法高亮分词
function highlightCode(container, code, language) {
  const lang = String(language || '').toLowerCase();
  if (/^(html|htm|xml|svg|vue|jsx|tsx|md|markdown|php)$/.test(lang)) { if (lang === 'md' || lang === 'markdown') { container.textContent = code; return; } highlightMarkup(container, code); return; }
  const supported = /^(js|javascript|ts|typescript|json|py|python|css|java|c|cpp|csharp|go|rust|sh|bash|sql|ruby|yaml)$/.test(lang);
  if (!supported) { container.textContent = code; return; }
  const regex = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b(?:const|let|var|function|return|if|else|class|import|export|from|async|await|try|catch|throw|new|true|false|null|undefined|typeof|instanceof|def|self|for|while|in|do|switch|case|break|continue|public|private|protected|static|void|int|char|float|double|bool|struct|enum|interface|extends|implements|package|require|module|this|super|yield|delete|finally|of)\b|\b\d+(?:\.\d+)?(?:[uUlLfF]*)\b)/g;
  let last = 0;
  for (const match of code.matchAll(regex)) {
    if (match.index > last) container.appendChild(document.createTextNode(code.slice(last, match.index)));
    const token = match[0];
    const kind = token.startsWith('//') || token.startsWith('#') ? 'comment' : /^["'`]/.test(token) ? 'string' : /^\d/.test(token) ? 'number' : 'keyword';
    container.appendChild(el('span', 'syntax-' + kind, token));
    last = match.index + token.length;
  }
  if (last < code.length) container.appendChild(document.createTextNode(code.slice(last)));
}

语言检测通过 detectLang 函数根据文件扩展名映射,覆盖 js/mjs/cjs/ts/tsx/jsx/json/py/css/html/xml/java/c/cpp/h/cs/go/rs/sh/yaml/md/sql/rb/php 等 20 余种类型。HTML/XML 类使用独立的 highlightMarkup 函数处理标签和属性。

5.5 透明叠加高亮架构

编辑器的视觉效果由三层叠加实现:

  • 底层:line-numbers 显示行号(Consolas 等宽字体,与编辑区同步滚动)
  • 中层:code-highlight 绝对定位的 pre 元素,显示着色后的代码(pointer-events:none 不拦截鼠标事件)
  • 顶层:code-edit 透明 textarea,文字颜色设为 transparent,仅保留 caret-color 显示光标
/* pi.css —— 高亮层与编辑区叠加 */
.code-highlight { position: absolute; left: 0; top: 0; right: 0; bottom: 0; margin: 0; padding: 12px 16px; overflow: hidden; font: 12px/1.7 Consolas, 'SFMono-Regular', monospace; tab-size: 2; white-space: pre; color: var(--text); pointer-events: none; }
.code-edit { position: absolute; left: 0; top: 0; width: 100%; height: 100%; margin: 0; padding: 12px 16px; border: none; resize: none; font: 12px/1.7 Consolas, 'SFMono-Regular', monospace; tab-size: 2; color: transparent; caret-color: #c8d3e0; background: transparent; outline: none; white-space: pre; overflow: auto; }

textarea 的 input 事件触发 updateHighlight 重新分词,scroll 事件同步高亮层与行号的滚动位置。Tab 键拦截后插入两个空格而非切换焦点。

六、项目概览面板

6.1 递归扫描器

scanProject 函数从工作区根目录开始广度优先遍历,聚合文件计数、目录计数、按扩展名分组统计和最近修改文件列表:

// renderer.js —— 项目递归扫描
async function scanProject() {
  const stats = { files: 0, dirs: 0, byExt: {}, recent: [], totalSize: 0 };
  const queue = ['.'];
  let visited = 0;
  while (queue.length && visited < 2000) {
    const dir = queue.shift(); visited++;
    let entries;
    try { entries = await request('list', dir); } catch { continue; }
    for (const entry of entries) {
      if (entry.directory) { queue.push(entry.path); stats.dirs++; }
      else {
        stats.files++;
        const ext = entry.name.includes('.') ? entry.name.split('.').pop().toLowerCase() : '无扩展';
        stats.byExt[ext] = (stats.byExt[ext] || 0) + 1;
        if (entry.mtime) stats.recent.push({ path: entry.path, name: entry.name, mtime: entry.mtime, ext });
      }
    }
  }
  stats.recent.sort((a, b) => b.mtime - a.mtime);
  stats.recent = stats.recent.slice(0, 15);
  return stats;
}

遍历上限 2000 个目录,防止超大项目阻塞 UI。每个目录的 list 请求独立 try-catch,单个目录失败不影响整体扫描。最近修改文件按 mtime 降序排列取前 15 条。

6.2 四模块渲染

renderOverview 函数将扫描结果分发到四个子模块:

  • renderStatsGrid:2x2 网格卡片(文件数、目录数、文件类型数、最新修改时间的相对描述)
  • renderOverviewTree:两级目录树(maxDepth=2),目录显示文件夹图标,文件显示类型徽章,点击文件直接在编辑器中打开
  • renderRecentFiles:最近修改文件列表,每行显示徽章 + 文件名 + 相对时间(如"3 分钟前"“2 小时前”)
  • renderChart:Top 10 扩展名条形图,16 色循环配色,条宽度按比例计算

6.3 文件类型徽章系统

fileBadge 函数根据文件扩展名返回对应颜色和缩写的徽章元素,覆盖 30 余种常见类型:

// renderer.js —— 文件类型徽章映射
const map = {
  js: ['JS', 'b-js'], mjs: ['JS', 'b-js'], cjs: ['JS', 'b-js'], jsx: ['JS', 'b-js'],
  ts: ['TS', 'b-ts'], tsx: ['TS', 'b-ts'],
  json: ['{}', 'b-json'], json5: ['{}', 'b-json'],
  css: ['#', 'b-css'], scss: ['#', 'b-css'], less: ['#', 'b-css'],
  html: ['<>', 'b-html'], htm: ['<>', 'b-html'], vue: ['V', 'b-html'],
  md: ['M', 'b-md'], markdown: ['M', 'b-md'], txt: ['T', 'b-txt'],
  py: ['PY', 'b-py'], java: ['J', 'b-java'],
  c: ['C', 'b-c'], h: ['H', 'b-c'], cpp: ['C+', 'b-c'], hpp: ['C+', 'b-c'],
  go: ['GO', 'b-go'], rs: ['RS', 'b-rust'], rb: ['RB', 'b-py'], php: ['PHP', 'b-html'],
  sh: ['>_', 'b-sh'], bat: ['>_', 'b-sh'], ps1: ['>_', 'b-sh'],
  yml: ['Y', 'b-yaml'], yaml: ['Y', 'b-yaml'], xml: ['<>', 'b-xml'], toml: ['T', 'b-yaml'],
  sql: ['DB', 'b-json'],
  png: ['IM', 'b-img'], jpg: ['IM', 'b-img'], jpeg: ['IM', 'b-img'], gif: ['IM', 'b-img'], svg: ['SV', 'b-img'],
  ets: ['ET', 'b-ets'],
  so: ['SO', 'b-bin'], dll: ['DL', 'b-bin'], exe: ['EX', 'b-bin']
};

CSS 中每种徽章类型有独立的背景色和文字色(如 b-js 使用 One Dark 风格的黄色 #e5c07b,b-ts 使用蓝色 #61afef),均带透明度实现深色主题融合。

七、拖拽分隔条与三栏布局

7.1 Flex 三栏布局

页面主体采用 Flex 水平排列三栏,中间以分隔条间隔:

<!-- index.html —— 三栏布局骨架 -->
<div class="app">
  <aside class="panel-left">...</aside>
  <div class="splitter" id="splitLeft" aria-label="拖动调整宽度"></div>
  <section class="panel-center">...</section>
  <div class="splitter" id="splitRight" aria-label="拖动调整宽度"></div>
  <aside class="panel-right">...</aside>
</div>

中间编辑器区域 flex:1 1 0 自动填满剩余宽度,左右面板固定初始宽度(300px / 400px)并通过分隔条拖拽调整。

7.2 分隔条拖拽实现

setupSplitter 函数为每条分隔条绑定 mousedown/mousemove/mouseup 事件链:

// renderer.js —— 分隔条拖拽
function setupSplitter(splitter, panel, side, min, max) {
  if (!splitter || !panel) return;
  let dragging = false, startX = 0, startW = 0;
  splitter.addEventListener('mousedown', (e) => {
    dragging = true; startX = e.clientX;
    startW = panel.getBoundingClientRect().width;
    splitter.classList.add('active');
    document.body.style.userSelect = 'none';
    e.preventDefault();
  });
  document.addEventListener('mousemove', (e) => {
    if (!dragging) return;
    const delta = side === 'left' ? e.clientX - startX : startX - e.clientX;
    const w = Math.min(max, Math.max(min, startW + delta));
    panel.style.width = w + 'px';
    panel.style.maxWidth = w + 'px';
    panel.style.minWidth = min + 'px';
    panel.style.flexBasis = w + 'px';
  });
  document.addEventListener('mouseup', () => {
    if (!dragging) return;
    dragging = false;
    splitter.classList.remove('active');
    document.body.style.userSelect = '';
  });
}

左分隔条调整左面板宽度(200px520px),右分隔条调整右面板宽度(280px680px)。拖拽期间禁用文本选择(userSelect:none)防止误选代码。右侧面板的 delta 方向取反(startX - e.clientX),因为向左拖拽时右面板变宽。

7.3 响应式适配

CSS 通过两个断点实现窄屏适配:

/* pi.css —— 响应式断点 */
@media (max-width: 1100px) {
  .panel-left { width: 260px; max-width: 260px; flex: 0 0 260px; }
  .panel-right { width: 340px; min-width: 280px; max-width: 340px; flex: 0 0 340px; }
}
@media (max-width: 850px) {
  .panel-left { width: 220px; max-width: 220px; flex: 0 0 220px; }
  .panel-right { width: 300px; min-width: 260px; max-width: 300px; flex: 0 0 300px; }
}

八、鸿蒙稳定性适配

8.1 三防窗口配置

main.js 中采用与其他项目一致的三防配置:

// main.js —— 三防窗口配置
app.disableHardwareAcceleration();
mainWindow = new BrowserWindow({
  width: 1240, height: 840, minWidth: 820, minHeight: 600,
  backgroundColor: '#11151c', title: '智码编程助手',
  webPreferences: {
    preload: path.join(__dirname, 'preload.js'),
    nodeIntegration: false, contextIsolation: true, sandbox: false,
    webSecurity: true
  }
});

与前面几个项目不同,本工程的安全配置更为严格:nodeIntegration 为 false(不允许渲染进程直接访问 Node.js)、contextIsolation 为 true(预加载脚本与渲染进程隔离)、webSecurity 为 true(启用同源策略)。这是 contextBridge 安全模型的前提条件。

8.2 菜单双保险与导航拦截

// main.js —— 菜单双保险 + 导航拦截
Menu.setApplicationMenu(null);    // 应用级
mainWindow.setMenu(null);         // 窗口级
const contents = mainWindow.webContents;
if (contents.setWindowOpenHandler) contents.setWindowOpenHandler(() => ({ action: 'deny' }));
contents.on('will-navigate', (event) => event.preventDefault());
contents.on('will-attach-webview', (event) => event.preventDefault());

除菜单双保险外,还拦截了三种页面跳转场景:setWindowOpenHandler 阻止 window.open 创建新窗口、will-navigate 阻止页面内导航、will-attach-webview 阻止 webview 标签挂载。

8.3 CSP 内容安全策略

index.html 的 head 中配置了严格的 CSP:

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">

connect-src 设为 none 意味着页面无法发起任何网络请求(XHR/fetch/WebSocket 全部禁止),所有数据通信只能通过 contextBridge 走 IPC 通道。object-src none 禁止插件,frame-src none 禁止 iframe 嵌入。

8.4 渲染进程崩溃恢复

// main.js —— 崩溃恢复
contents.on('render-process-gone', () => { if (service) service.stop(); });
contents.on('did-start-loading', () => { if (service && service.busy) service.stop(); });
mainWindow.on('closed', () => { if (service) service.stop(); mainWindow = null; });

渲染进程崩溃时立即停止后端服务,防止忙碌状态残留。页面开始加载时如果后端仍在忙碌也执行停止,确保新页面获得干净状态。

8.5 Toast 与状态栏反馈

所有操作的成败通过状态栏和 Toast 双重反馈:

// renderer.js —— Toast 通知
let toastTimer = null;
function showToast(text, ok) {
  const t = $('toast'); if (!t) return;
  t.textContent = (ok ? '✓ ' : '✗ ') + text;
  t.classList.toggle('toast-error', !ok);
  t.style.position = 'fixed'; t.style.zIndex = '200';
  t.hidden = false;
  if (toastTimer) clearTimeout(toastTimer);
  toastTimer = setTimeout(() => { t.hidden = true; }, 3200);
}

成功操作显示绿色 ✓ 前缀,失败操作显示红色 ✗ 前缀并切换 toast-error 样式。3200 毫秒后自动隐藏。

8.6 关于弹窗(开源声明)

页面右下角状态栏的"关于"链接打开三段式弹窗:

  • 开源声明:基于 Electron(MIT 协议)构建,核心 Agent 循环参考 Pi Agent(MIT 开源)二次开发
  • 免责声明:所有文件读取、分析与修改均在本地完成,不包含任何网络传输功能
  • 许可证:Apache-2.0 完全开源

弹窗通过 hidden 属性切换显隐,pi.css 中 [hidden]{display:none!important} 确保优先级。

九、常见问题与解决方案

Q1:AGC 审核驳回——应用名称与包名不一致

问题现象: 提交 AGC 审核后收到驳回意见,指出应用显示名称与包名或内部配置不一致。

根因分析: AppScope/app.json5 中的 bundleName、app_name,以及三套 EntryAbility_label(base/zh_CN/en_US)未统一。

解决: 确保以下五处名称完全一致:

  • AppScope/app.json5 → bundleName: com.wei.zhima
  • AppScope/resources/base/element/string.json → app_name: 智码编程助手
  • electron/src/main/resources/base/element/string.json → EntryAbility_label: 智码编程助手
  • electron/src/main/resources/en_US/element/string.json → EntryAbility_label: 智码编程助手
  • electron/src/main/resources/zh_CN/element/string.json → EntryAbility_label: 智码编程助手

教训: 鸿蒙资源覆盖规则是 locale 目录 > base 目录,修改应用名时必须全局 grep 所有 element/string.json 文件,不能只改 base。

Q2:构建签名报错 ERROR: 11014001 key alias not found

问题现象: 在 DevEco Studio 中执行 Build → Build Hap(s),SignHap 阶段报错 ERROR: 11014001,提示签名密钥别名找不到。

根因分析: build-profile.json5 中 signingConfigs.default.material.keyAlias 填写的值与 .p12 签名文件内实际存储的别名不一致。

解决: 使用 DevEco 内置 JBR 的 keytool 查看 p12 内真实别名:

"%DEVECO_JBR%\jbr\bin\keytool" -list -keystore "Zhima.p12" -storepass 你的密码

查看输出中的 Alias Name,然后填入 build-profile.json5:

"signingConfigs": [{
  "name": "default",
  "type": "HarmonyOS",
  "material": {
    "storeFile": "C:/path/to/Zhima.p12",
    "keyAlias": "zhima1",  // 必须与 p12 内真实别名完全一致
    "signAlg": "SHA256withECDSA",
    "profile": "C:/path/to/ZhimaRelease.p7b",
    "certpath": "C:/path/to/Zhima.cer"
  }
}]

Q3:工作区未选择时文件树报错"请先选择项目目录"

问题现象: 应用启动后,左侧文件树区域直接显示错误信息"请先选择项目目录",而非欢迎空态页面。

根因分析: renderer.js 初始化时调用 openWorkspace(‘.’),在 setWorkspace 尚未执行前 state.directory 为 ‘.’,此时 loadFiles 和 refreshOverview 以 ‘.’ 触发 list 请求,后端 Workspace 类检测到工作区未设置直接抛出异常。

解决: 初始化改为 openWorkspace(null),并在各渲染函数中加入 !state.directory 空态守卫:

// renderer.js —— 初始化与空态守卫
guard(async () => {
  if (!window.pi) throw new Error('通信桥加载失败');
  openWorkspace(null);  // 以 null 起步,不触发 list 请求
})();

async function loadFiles() {
  const box = $('fileTree'); if (!box) return;
  if (!state.directory) { box.replaceChildren(el('p', 'muted', '选择项目后浏览文件')); return; }
  // ... 正常加载文件树
}

async function refreshOverview() {
  if (!state.directory) {
    $('overviewEmpty').hidden = false;
    $('overviewContent').hidden = true;
    return;
  }
  // ... 正常扫描项目
}

openWorkspace(null) 将 state.directory 置为 null,各渲染函数检测到 null 后显示欢迎空态,而非向后端发起无效请求。

Q4:左侧面板滚动条过小,文件显示区被压塌

问题现象: 文件数量超出左侧面板高度后,滚动条只出现在 .file-tree 子区域内,显示区被压缩到页面上部约 1/3 的高度。

根因分析: 早期方案给 .file-tree 加 overflow-y:auto + flex:1 1 auto + min-height:0,在 flex column 子项上叠加 min-height:0 虽然能出滚动条,但会让子项高度塌缩到近零,父容器的高度约束传不进来。

解决: 把滚动从子项上移到面板层,让子项恢复自然高度:

/* pi.css —— 左侧面板整体滚动 */
.panel-left {
  width: 300px; min-width: 240px; max-width: 300px;
  flex: 0 0 300px;
  display: flex; flex-direction: column;
  overflow-y: auto;  /* 整个面板统一滚动 */
}
.explorer-section:first-of-type {
  flex: 1 0 auto;    /* 填满剩余高度,不收缩 */
  min-height: 120px;
}
.file-tree {
  flex: 1 0 auto;    /* 自然高度,不单独滚动 */
  padding: 0 4px;
}

判据:用户要的是"整块面板的大滚动条",应在面板容器加 overflow-y:auto 并让子项恢复自然高度,而不是在子项上叠加 min-height:0。

Q5:文件重命名后标签页路径不同步导致保存失败

问题现象: 在文件树中右键重命名一个已打开的文件后,编辑器标签页仍持有旧路径,点击保存时后端报"文件不存在"。

根因分析: ctxRename 成功后只刷新了文件树,没有同步更新 state.openTabs 中对应标签页的 path 字段,导致后续 write 请求使用旧路径。

解决: 重命名成功后遍历所有打开的标签页,将匹配旧路径的标签页 path 更新为新路径(通过目录拼接新文件名),同步更新 activeTab:

// renderer.js —— 重命名后标签页路径同步
async function ctxRename() {
  if (!ctxTarget) return;
  const oldName = ctxTarget.name; const entryPath = ctxTarget.path;
  hideCtxMenu();
  showPromptModal('重命名', oldName, '', true, async (newName) => {
    newName = (newName || '').trim();
    if (!newName || newName === oldName) return;
    try {
      await request('rename', { path: entryPath, newName });
      status('已重命名:' + oldName + ' → ' + newName);
      guard(loadFiles)();
      // 同步已打开的标签页路径
      const tab = state.openTabs.find((t) => t.path === entryPath);
      if (tab) {
        const dir = entryPath.replace(/[\\/][^\\/]*$/, '');
        tab.path = dir ? dir + '/' + newName : newName;
        if (state.activeTab === entryPath) state.activeTab = tab.path;
        renderTabs();
      }
    } catch (e) { status('重命名失败:' + e.message); }
  });
}

dir 提取旧路径的目录部分,再拼接新文件名组成新路径。如果重命名的文件恰好是当前活动标签页,同步更新 activeTab。

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

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

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

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

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

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

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

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

根因分析: Electron 壳工程模板默认声明了大量系统权限,智码编程助手作为本地编程辅助工具不需要位置、相机、麦克风等权限。

解决: 精简权限声明,只保留应用实际使用的 14 项系统权限(INTERNET、GET_NETWORK_INFO、ACCESS_CERT_MANAGER、RUNNING_LOCK、PRINT、PREPARE_APP_TERMINATE、ACCESS_BIOMETRIC、PRIVACY_WINDOW、WINDOW_TOPMOST、GYROSCOPE、ACCELEROMETER、SET_ABILITY_INSTANCE_INFO、GET_FILE_ICON、LOCK_WINDOW_CURSOR),移除 FILE_ACCESS_PERSIST、READ_WRITE_DESKTOP_DIRECTORY、READ_PASTEBOARD、CAMERA、MICROPHONE 等不需要的权限。

十、总结

本文完整记录了在鸿蒙 PC 平台上适配智码编程助手的全过程。通过 Electron 壳方案,我们实现了一个高安全等级的纯 JavaScript 本地集成开发环境,核心特性包括:

特性实现方案
contextBridge白名单动作桥接 + CSP connect-src:none + 来源校验
单通道路由pi:request 统一分发 16 种操作 + 错误脱敏 + 互斥保护
Workspace 沙箱五维路径校验 + 敏感目录过滤 + 自研递归删除 + 排他创建
多标签编辑器textarea 透明叠加 + 高亮层同步滚动 + 未保存标记动态切换
语法高亮20 余种语言正则分词 + 关键字/字符串/数字/注释四类 token
项目概览递归扫描 + 文件统计 + 两级目录树 + 最近修改 + 类型分布条形图
拖拽三栏布局两条分隔条 + 面板宽度边界约束 + 响应式断点
鸿蒙稳定三防窗口 + 菜单双保险 + 导航拦截 + 渲染崩溃恢复 + Toast 反馈

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

  1. contextBridge 安全通信模型——渲染进程完全不接触 Node.js API,所有文件操作通过 preload.js 暴露的白名单桥接器发起,主进程 Workspace 类执行五维路径安全校验(空字节注入、敏感目录过滤、路径穿越、应用数据保护、符号链接防御),即使页面被注入恶意脚本攻击面也被压缩到最小
  2. textarea 透明叠加高亮架构——代码编辑器采用三层叠加方案(底层行号 + 中层高亮 pre + 顶层透明 textarea),textarea 文字颜色设为 transparent 仅保留 caret-color,input 事件触发正则分词重新着色,scroll 事件同步三层滚动位置,在鸿蒙 ArkWeb 引擎上兼容性良好
  3. 项目概览递归扫描防阻塞——scanProject 采用广度优先遍历,2000 目录上限自动截断,每个目录的 list 请求独立 try-catch 单目录失败不影响整体,最近修改文件按 mtime 降序取前 15 条,Top 10 扩展名条形图使用 16 色循环配色

这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版高安全等级本地集成开发环境。

Logo

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

更多推荐