本文档记录了这个项目的核心设计与实现细节,包括:
- 这个项目到底在管理什么数据;
- 代码是按什么边界组织起来的;
- 同步设计的原则和实现细节;
hyper-fm 是一个桌面端项目文件夹管理器,项目里最重要的不是路径,而是:
- 这个项目是谁;
- 这台机器上它在哪;
- 哪些信息可以跨设备共享;
- 哪些信息必须只留在本机。
因此,这个仓库真正的核心不是文件浏览,而是:
- 项目身份管理
- 本地路径 binding
.meta-data与配置文件的合并- 扫描匹配与冲突检测
- 为同步准备稳定的数据基础
{.test}/fm.shared.json # 默认共享配置:开发模式为项目根 .test/,打包后为 exe 同级 fm.shared.json
~/.fm/<configId>.local.json # 设备本地配置:扫描根、绑定、本地覆盖
~/.fm.app.json # 应用级偏好(开发为 ~/.test.fm.app.json):托盘、启动、主题、最近配置
<project-root>/.meta-data # 项目自描述(可选)
shared关注这个项目是谁,local关注这台机器上它在哪里。- 每个 shared 配置有唯一的
configId(cfg_前缀),local 文件按~/.fm/<configId>.local.json独立存放,多设备不互相覆盖。 ~/.fm.app.json(开发为~/.test.fm.app.json)通过lastSharedConfigId+knownConfigs记住最近配置,下次启动优先恢复。- 启动回退链:最近配置 → 默认共享配置(开发为
.test/fm.shared.json,打包为fm.shared.json)→ 进入欢迎页。 - 新建配置使用系统保存弹窗,后缀
.shared.json,默认名fm.shared.json。 - 同步状态为纯运行时数据,不持久化。项目目录
mtime不写入配置文件。
fm.shared.json 负责保存稳定且可共享的数据,例如:
- 项目 ID
- 项目名称 / 描述 / 标签
- 项目身份指纹
- 项目级共享动作(
shared.projects[].actions) - 共享忽略规则
- 标签注册表(删除标签时会同步清理项目与标签组中的对应引用)
- 默认会预置一个“收藏”标签组,初始包含动态标签“最近一月”;如果配置里缺少它,运行时也会虚拟补齐
- 若未来需要持久化项目元数据更新时间,也应保存在 shared,而不是复用本机目录
mtime
一个最小化的 mental model 可以理解成:
这里最重要的是:shared 里没有”这台机器上的绝对路径”。configId 是 shared 配置的唯一标识,用于派生本机 local 文件路径和多设备关联。
本机状态保存在 ~/.fm/<configId>.local.json,每个 shared 配置对应一个独立的 local 文件。主要包括:
sharedConfigId:关联的 shared 配置 IDscanRoots:从哪里开始扫bindings:projectId和本机真实路径之间的关系(不再冗余存储id,不再持久化同步状态)bindings[].actions:仅对当前设备、当前项目生效的项目级动作列表warnings:扫描时遇到的冲突与告警ignoredPaths:本机明确忽略的目录ui:当前机器的视图、主题等偏好(保留在 local 中兼容旧配置;运行时以应用级偏好为准)syncConfigs:LocalSyncConfigEntry[]联合类型——kind: 'override'仅存储 configId + 本地覆盖字段;kind: 'standalone'存储完整的本地独占同步配置devices:设备身份与已知对端actions:适用于所有项目的全局自定义动作列表(M3)
它描述的是”这个 shared 项目在当前设备上长什么样”。
{
"version": 2,
"sharedConfigId": "cfg_a1b2c3",
"scanRoots": [
{
"id": "root_a1b2c3",
"path": "D:/projects",
"label": "主代码盘",
"maxDepth": 3,
"enabled": true
}
],
"bindings": [
{
"projectId": "pj-1a2b3c",
"path": "D:/projects/fm",
"rootId": "root_a1b2c3",
"hasMetaFile": true,
"lastScannedAt": "2026-04-27T12:34:56Z",
"actions": [
{
"id": "cmd_local01",
"label": "运行 dev server",
"command": "pnpm",
"args": ["dev"],
"cwd": "project"
}
]
}
],
"actions": [
{
"id": "cmd_global01",
"label": "在 VS Code Insiders 中打开",
"command": "code-insiders",
"args": ["{{path}}"],
"cwd": "project"
}
],
"syncConfigs": [
{ "kind": "override", "configId": "sync_d4e5f6", "settings": { "folder.intervalMinutes": 15 } },
{
"kind": "standalone",
"config": {
"id": "sync_x7y8z9",
"name": "本机 ZIP 备份",
"scope": "local",
"type": "zip",
"mode": "mirror-local-to-target",
"targets": { "projectIds": [], "rootIds": [], "ignoredProjectIds": [], "ignoredRootIds": [] },
"zip": { "exportFile": "D:/backups/fm.zip" }
}
}
],
"warnings": [],
"ignoredPaths": [],
"ui": {
"theme": "system",
"view": "grid"
}
}项目根目录中的 .meta-data 是目录自描述文件,用来表达“这个项目自己怎么介绍自己”。
{
"schema": "fm.meta/v1",
"projectId": "pj-1a2b3c",
"name": "fm",
"description": "项目文件夹管理器",
"tags": ["electron", "tooling"]
}它的意义有两层:
- 让项目目录本身具备可迁移的身份描述;
- 让跨设备识别时不依赖脆弱的路径或目录名。
项目当前支持三种指纹:
type ProjectFingerprint =
| { kind: 'metadata' }
| { kind: 'folder-name'; folderName: string }
| { kind: 'file-paths'; paths: string[] };它们的用途分别是:
| 指纹 | 含义 | 适用场景 |
|---|---|---|
metadata |
使用 .meta-data.projectId |
最稳定,适合长期维护与同步 |
folder-name |
使用目录名 | 简单项目、目录名稳定时 |
file-paths |
使用一组相对文件路径 | 没有 metadata,但目录结构稳定时 |
这里有个很重要的实现倾向:metadata 是最优先、最推荐的方案。只要项目能写 .meta-data,它就是最稳定的身份锚点。
渲染层最终消费的不是 raw shared / raw local,而是主进程整理后的聚合视图。可以粗暴理解成:
shared.projects[]提供项目身份local.bindings[]补齐本机路径.meta-data提供高优先级展示字段
项目展示字段的优先级大致如下:
| 字段 | 优先级 |
|---|---|
name |
.meta-data.name → shared.name → 目录名 |
description |
.meta-data.description → shared.description |
tags |
.meta-data.tags → shared.tags |
path |
local.bindings[].path |
hasMetaFile |
local.bindings[].hasMetaFile |
actions |
local.bindings[].actions |
sharedActions |
shared.projects[].actions |
因此,UI 层不必直接处理 shared/local 的拆分细节,但主进程在写盘时必须准确地把聚合视图拆回两份配置。项目详情中的动作页编辑的是聚合后的草稿列表,保存时再根据 scope 拆回 local.bindings[].actions 与 shared.projects[].actions。
扫描期使用的是多来源忽略规则并集:
shared.ignore.globs.meta-data.ignorelocal.ignoredPaths
其中:
shared.ignore.globs适合团队或多机共享的规则;.meta-data.ignore适合某个项目自身的局部忽略;local.ignoredPaths优先级最高,表示“这台机器上我明确不要扫这个目录”。
扫描是这个项目最容易误解的一部分。核心原则只有一句话:
扫描负责识别,不负责偷偷导入。
当前规则是:
- 扫描发现候选目录,但不会自动新增 shared 项目;
- 唯一匹配成功时,更新或创建本机 binding;
- 指纹冲突时,不写 binding,只写
warnings[]; - 用户可以把问题目录加入
ignoredPaths后重扫; - 手动添加项目前,必须先经过目录检查与冲突校验。
这件事决定了很多行为都偏“克制”:宁可让用户多确认一步,也不静默造脏数据。
协作时要牢牢记住以下不变量:
- 所有内部路径统一保存为正斜杠绝对路径;
shared.configId全局唯一,用于关联 local 文件和多设备身份;shared.projects[].id在 shared 配置内唯一;tags[].name在 shared 配置内唯一;bindings[].projectId与bindings[].path在 local 配置内唯一;ProjectBinding只存projectId,不冗余存储id,不持久化同步状态;warnings[]只描述问题,不自动修复问题;- 配置写盘必须走原子替换,避免中断后损坏文件。
项目按四层组织:
| 层级 | 路径 | 主要职责 |
|---|---|---|
| 主进程 | src/main/ |
配置、文件系统、扫描、同步、IPC |
| 预加载层 | src/preload/ |
contextBridge,隔离 Node 能力 |
| 渲染层 | src/renderer/ |
React UI、状态、交互 |
| 共享层 | src/shared/ |
类型、schema、桥接契约、工具函数 |
这个边界是整个仓库最重要的结构约束。凡是跨边界模糊的修改,后面几乎都会变得难维护。
主进程是应用的“后端”,负责所有需要 Node / Electron 权限的工作。
config-store.ts:shared/local 双配置读写与原子落盘。local 路径由 configId 派生(~/.fm/<configId>.local.json),启动时自动迁移旧位置文件。app-config-store.ts:应用级持久化存储(~/.fm.app.json,开发为~/.test.fm.app.json),通过lastSharedConfigId+knownConfigs映射恢复最近打开的配置,并保存托盘开关、开机启动、主题 / 视图等 UI 偏好。login-item.ts:封装系统登录项(开机启动)设置,统一处理平台差异与开发模式跳过逻辑。tray-controller.ts:系统托盘生命周期、菜单构建,以及项目快捷动作/快速同步入口。session.ts:维护当前加载配置的会话状态,负责串行写盘,避免并发写坏配置。project-repo.ts:shared 项目、本机 binding、标签、扫描根等仓库级操作。
meta-file.ts:读写项目根.meta-data。ignore-matcher.ts:忽略规则匹配。scanner.ts:扫描根递归遍历、候选目录发现。project-matcher.ts:目录检查、指纹匹配、冲突判断;inspectDirectory()默认只预加载首层目录,文件视图/指纹选择再按需用广度优先继续展开,避免超大目录首次打开时做整树扫描;同时提供.gitignore预览所需的递归发现能力。
ipc.ts:注册app:*与fm:*通道。fm-error.ts:定义跨进程错误码与错误结构。
commands/runner.ts:全局 / 项目级自定义动作执行,以及全局动作和项目本地动作 CRUD。sync/:同步相关实现,后面单独展开。
preload 的职责非常明确:
- 渲染层不能直接用 Node API;
- 主进程能力必须显式桥接;
- 所有调用都应该有稳定的 shared 类型契约。
当前边界大致是:
window.app:基础模板能力window.fm:业务能力
命名约定:
- 基础能力:
app:* - 业务能力:
fm:*
换句话说,renderer 不应该自己去“猜”某个文件路径该怎么读,也不应该偷偷碰 fs;正确姿势是经由 preload 调主进程接口。
src/shared/ 是主进程和渲染层共同理解世界的地方,主要放:
bridge.ts:桥接接口定义types.ts/schema.ts:配置与项目模型sync-types.ts/sync-config.ts:同步相关共享结构id.ts/path-utils.ts/logger.ts/search.ts:通用工具
一个实用判断标准是:
如果某个概念既出现在主进程,又出现在渲染层,那它大概率属于
src/shared/。
渲染层主要位于 src/renderer/src/:
App.tsx:应用根组件与路由/区域编排store/:全局状态与 action 组织components/ui/:通用原子控件与基础交互控件components/basic/:可复用的小型业务组件,例如 tag、项目表单、抽屉壳、忽略规则编辑器等components/view/:大界面或大区域组件,例如项目浏览视图、项目信息面板与 panel 下的 info/sync 主视图,以及可复用的文件扩展面板components/根目录:尚未沉淀为三层结构的业务组件、对话框与设置面板browser-bridge.ts:对桥接调用做渲染层适配
从协作角度看,渲染层更像“聚合好的状态如何展示与编辑”,而不是“核心业务规则放哪”。
当前与项目浏览相关的视图组织约定如下:
- 整个右侧详情抽屉统一命名为
project-info-panel - panel 主体拆成
project-info-view、project-sync-view、project-commands-view,文件树则作为可复用的左侧扩展面板project-files-view project-sync-view负责项目级忽略规则、.gitignore预览与同步目标设置project-commands-view负责项目动作草稿编辑,支持“本地 / 共享”存储范围切换,并复用设置页的动作编辑器- 若某段 UI 会被多个 view 或对话框复用,应优先下沉到
components/basic/
- Electron 主进程启动;
- 从
~/.fm.app.json(开发为~/.test.fm.app.json)读取lastSharedConfigId+knownConfigs,解析 shared 配置路径; - 若最近配置不可用,回退到默认共享配置(开发为
.test/fm.shared.json,打包为fm.shared.json); - 仍不可用时保持未加载状态,进入欢迎页;
- 用户打开或通过保存弹窗创建
.shared.json后加载 shared/local; - 主进程整理为聚合视图(合并 shared 项目 + local binding + 同步配置覆盖);
- preload 暴露桥接接口,renderer 根据快照显示界面;
- 托盘启用时关闭窗口仅隐藏;
onConfigChanged回调在每次配置变更后刷新托盘菜单。 - 若偏好开启开机启动,主进程在启动完成后同步系统登录项(开发模式跳过)。
- renderer 触发扫描;
scanner.ts按扫描根递归遍历;ignore-matcher.ts先过滤不该看的目录;project-matcher.ts检查.meta-data、目录名、文件路径指纹;- 唯一匹配则更新 binding;冲突则写 warning;
- renderer 刷新聚合项目视图与告警列表。
- 先
inspectDirectory()获取目录信息; - 再
validateNew()检查指纹冲突; - 校验通过后
add()创建 shared 项目; - 同步创建本地 binding;
- 若指纹为
metadata,写入.meta-data.projectId。
补充约定:
inspectDirectory()分为summary / interactive / full三种模式;默认使用summary,仅检查首层目录与基础元信息。- 文件树展开与“修改文件列表”走
interactive模式,按广度优先继续扫描,并在当前层文件很多时停止向更深层预取。 - 搜索整棵文件树或确实需要完整文件列表时,才升级到
full模式执行全量递归扫描。
- 通过
pickDirectories()在系统文件窗口中一次选择多个目录; - 对每个目录执行
inspectDirectory()与validateNew(); - 批量添加默认使用
folder-name指纹,项目名也以目录名为起点; - 渲染层会额外检查本次批量里的“内部冲突”,避免两个待添加条目互相撞指纹;
- 无冲突条目可直接批量添加;有警告条目保持跳过,用户可单独重写识别方式后再添加。
这里有两个容易混淆的入口:
updateMeta():只改 shared 配置中的项目元数据;writeMetaFile():把元数据写回项目根.meta-data。
所以协作时要分清楚:当前是在改“配置里的缓存 / 共享定义”,还是在改“项目目录本身的自描述”。
如果要快速建立代码地图,建议按这个顺序读:
src/shared/types.ts、src/shared/bridge.ts、src/shared/schema.tssrc/main/config-store.ts、src/main/session.ts、src/main/project-repo.tssrc/main/meta-file.ts、src/main/scanner.ts、src/main/project-matcher.tssrc/main/ipc.tssrc/preload/index.tssrc/renderer/src/App.tsx、src/renderer/src/store/、主要组件src/main/sync/相关实现
同步时真正稳定的主键不是路径,而是 projectId。
例如同一个项目:
- 台式机:
D:/Projects/fm - 笔记本:
E:/Code/fm
路径不同,但项目身份相同。如果路径直接写在主记录里,同步模型会变得非常别扭;而 shared/local 拆分天然把这个问题拆开了:
- shared 负责项目身份;
- local 负责设备落点。
当前同步设计遵循这些原则:
- 零官方服务器:核心能力基于本地目录、zip 或设备直连。
- 手动触发:不做后台自动同步守护。
- 冲突显式展示:先 diff,再由用户决定推送或拉取。
- 路径本地化:远端永远不替本机决定真实目录。
- 不静默覆盖:扫描和同步都优先暴露歧义。
会参与同步的内容:
- shared 项目元数据
- 项目目录内容
- 可选
.meta-data - manifest、hash、快照等传输期数据
不会跨设备覆盖的内容:
scanRoots- 本机
bindings[].path warningsignoredPathsui- 本机设备设置与监听端口等偏好
src/main/sync/ 目前可以按职责这样理解:
| 模块 | 作用 |
|---|---|
snapshot.ts |
生成项目快照与摘要 |
diff.ts |
对比本地与目标端差异 |
file-sync.ts |
执行文件级同步 |
dir-bundle.ts |
目录形式的同步 bundle |
zip-bundle.ts |
zip 导入导出 |
tcp-transport.ts |
TCP 传输 |
preview-session.ts |
同步预览会话组织 |
preview-session-codec.ts |
预览数据编解码 |
preview-session-worker.ts |
预览 worker |
device.ts |
设备身份与设备侧信息 |
manager.ts |
同步管理协调 |
auto-sync.ts |
自动同步相关逻辑 |
如果把同步看成一条链,它大致是:
- 找到项目
- 生成快照
- 计算 diff
- 预览结果
- 选择 bundle / zip / TCP 方式传输
- 应用文件变更并更新本机状态
同步层围绕 manifest 思维组织,而 manifest 的主键是 projectId。它需要回答的核心问题是:
- 这是哪个项目?
- 这个项目现在包含哪些文件与摘要?
- 与另一端相比差异在哪里?
因此,传输层的核心从来不是“把某个目录原样搬过去”,而是“按 projectId 对某个项目的内容做比对与落地”。
- 用户选择要同步的项目;
- 系统按
projectId找到本机 binding; - 读取真实目录并生成快照;
- 与目标端 manifest 或 bundle 计算 diff;
- 展示差异后执行推送。
- 读取远端 manifest;
- 用户决定每个项目拉到本机哪个路径;
- 先解包到临时目录;
- 校验 hash;
- 成功后建立或更新本地 binding。
bundle适合目录式共享或中转目录;zip适合导入导出与手工携带;- 两者都不应该携带“远端真实路径”这种本机无意义的信息。
同步和扫描共享同一条价值观:
- 发现歧义时不静默继续;
- 先暴露问题,再让用户确认;
- 先写临时文件,再做替换;
- 在建立 binding 之前就尽量完成校验。
所以,拉取和导入时通常会先落到临时目录,再做 hash 校验,再执行替换或绑定更新。这是为了把“半成功状态”压到最低。
- 项目的核心不是 UI,而是数据模型与边界。
shared/local拆分是第一原则。.meta-data是高优先级、自描述、可迁移的项目身份入口。- 扫描是匹配流程,不是自动导入流程。
- 渲染层不能绕过 preload 直接访问 Node API。
- 涉及扫描、同步、配置写盘、IPC 的改动,都应该先理解主进程与 shared 模型再下手。
{ "version": 2, "configId": "cfg_a1b2c3", "name": "fm", "ignore": { "respectGitignore": true, "globs": ["node_modules", ".git", "dist"] }, "tags": [ { "name": "electron", "color": "#60a5fa" } ], "syncConfigs": [ { "id": "sync_d4e5f6", "name": "文件夹同步", "scope": "shared", "type": "folder", "mode": "two-way", "targets": { "projectIds": [], "rootIds": [], "ignoredProjectIds": [], "ignoredRootIds": [] }, "folder": { "compareBeforeSync": true, "autoSync": false } } ], "projects": [ { "id": "pj-1a2b3c", "name": "fm", "description": "项目文件夹管理器", "tags": ["electron"], "actions": [ { "id": "cmd_shared01", "label": "运行 build", "command": "pnpm build", "cwd": "project" } ], "fingerprint": { "kind": "metadata" } } ] }