尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Markdown文件协作工作区:从同步原理到自建实践

Markdown文件协作工作区:从同步原理到自建实践 Markdown 的流行不是因为语法复杂而是因为它把内容从排版软件里解放出来。一个.md文件可以用记事本打开能进 Git能渲染成网页也能被脚本批量处理。可一旦多人要一起编辑这些文件问题马上就来了有人改了本地文件不推上去有人用在线文档拷贝粘贴文件版本越堆越多最终没人知道哪份才是最新的。Marktwin 给出的思路是不要建一个封闭的数据库来装这些内容而是在你本来就拥有的 Markdown 文件之上加一层协作工作区。这篇文章会沿着这个思路展开先讲 Marktwin 背后的协作模型再给出一个最小可运行的自建原型最后讨论冲突、安全和生产化部署。如果你正在做团队知识库、内部文档、LeetCode 刷题笔记管理或者只是想让自己的 Markdown 文件夹在多人之间同步编辑那么 Marktwin 这类“以 Markdown 文件为事实源”的协作方式值得认真了解一下。它不是把内容迁移到另一个私有格式而是把“文件所有权”继续留给用户把“协作能力”做成工作区。1. Marktwin 想解决的协作问题是什么1.1 Markdown 文件适合协作但缺少协作层Markdown 作为纯文本格式天然具备几个适合协作的特性内容可读、差异可比较、历史可追踪、迁移成本低。只要文件是 UTF-8 编码的纯文本任何支持文本编辑的工具都能打开。Git 能对 Markdown 做逐行 diff脚本能批量替换标题和链接CI 能自动把 Markdown 渲染成站点。但这些能力更多属于“文件层面”而不是“协作层面”。多人同时编辑一个 Markdown 文件时真正需要的是一套机制谁在什么时间改了什么、改动要不要实时同步给对方、两个人改到同一段怎么处理、文件在磁盘上的最终版本是什么。Marktwin 把这一层机制抽出来做成一个工作区而不是要求所有人都去学 Git。这里要区分两个概念文件存储和协作编排。Markdown 文件本身只负责内容不负责权限、连接、同步和冲突处理。工作区负责把这些能力附加到文件上。你不需要把.md文件导入某个私有数据库工作区可以直接指向一个本地文件夹或远端 Git 仓库。1.2 从 Git 到在线文档现有方案各有取舍在 Marktwin 出现之前大多数团队会从三条路线里选一条Git 工作流、共享网盘、在线文档平台。三条路线都有自己的成本。方案数据所有权学习成本实时协作权限控制定制能力Git/GitHub高文件在仓库里高要理解分支和提交弱需要手动拉取推送中等仓库级权限高可配合 CI 和脚本共享网盘中文件能下载但同步规则不透明低差容易冲突弱低在线文档平台低数据留在平台内低强中低导出格式受限Marktwin 的工作区思路高文件仍归用户中编辑体验接近文档工具中到强取决于同步实现需要自己实现高可基于开源扩展表格里的取舍说明一件事没有哪个方案能同时做到“数据完全可控”和“零学习成本”。Marktwin 选择的是把文件所有权和工作区能力分开文件归你协作归工作区。这样你既能保留 Markdown 的便携性又能获得接近在线文档的编辑体验。1.3 Marktwin 的定位Ownership 与 Workspace 分离从项目标题Marktwin – collaborative workspaces on Markdown files you own可以提炼出两个核心词collaborative workspaces和files you own。files you own强调的是数据主权。用户可以用自己的方式组织文件可以在没有 Marktwin 的情况下继续使用这些文件。只要文件是标准 Markdown工具坏了也能用别的编辑器打开。collaborative workspaces强调的是协作场景。工作区可以包含多个 Markdown 文件成员进入工作区后能看到文件树打开同一个文件进行编辑看到彼此的更新。工作区本身不再是存储介质而是文件之上的协作层。这种设计的好处是迁移成本低。你不需要把已经有几百篇文档的目录导入一个新的内容管理系统只需要把目录挂载为工作区原目录不变文件原样在磁盘上。坏处也很明显协作层如果做得不好文件会频繁冲突如果实现不透明用户会怀疑“磁盘上的文件到底是不是我改的那一份”。2. 核心机制拆解文件、工作区与同步模型2.1 工作区目录就是事实源在 Marktwin 的工作区模型里一个工作区通常对应一个根目录。根目录下可以有子目录目录中的.md、.markdown文件会被识别为可协作文档。工作区目录是事实源也就是最终内容以磁盘文件为准。客户端显示的文件列表来自服务器对目录的扫描编辑保存时写入磁盘外部脚本改了文件工作区也能通过文件监听感知到变化。目录映射关系可以这样理解workspace/ docs/ handbook.md faq.md meeting-notes/ 2025-01.md工作区根路径对应workspace/文件相对路径docs/handbook.md是客户端请求和 WebSocket 消息里使用的唯一标识。不要在协议里传绝对路径否则会引入跨平台差异和安全问题。2.2 同步的最小模型版本号加整文件广播Marktwin 的同步机制没有统一标准但最小可用模型可以这样设计每个文件在服务端维护一个版本号。客户端编辑后把文件完整内容和当前版本号发给服务端。服务端检查版本号是否匹配匹配则保存并广播给其他客户端不匹配则返回冲突信息。其他客户端收到广播后更新编辑器和预览区。协议消息大致是 JSON 格式{ type: edit, file: docs/handbook.md, version: 3, content: # 新内容, author: alice }服务端成功保存后广播给当前文件的所有订阅者{ type: update, file: docs/handbook.md, version: 4, content: # 新内容, author: alice }如果服务端发现客户端传过来的 version 不是当前值就返回{ type: conflict, file: docs/handbook.md, version: 4, content: # 服务器上的最新内容 }这种模型本质上是“整文件覆盖”。优点是实现简单适合篇幅不长的 Markdown 文档缺点是并发高时冲突多。如果希望做到类似在线文档的细粒度合并需要引入 OT 或 CRDT复杂度会明显上升。2.3 冲突不可避免策略要提前定多人编辑同一个文件时冲突不是 bug而是系统的正常状态。关键是冲突发生后如何处理。策略原理优点缺点适用场景Last Write Wins后保存的人覆盖先保存的人实现简单会丢失较早编辑者的内容单人编辑、演示环境版本号冲突拒绝版本不一致时拒绝写入数据不容易丢失用户需要手动处理小团队文档编辑三方合并基于共同祖先做 diff3 合并自动合并大部分修改需要维护历史版本多人频繁编辑CRDT / OT通过操作类型同步变化体验接近在线文档实现复杂协议重要求高实时性的场景Marktwin 这类工具如果做最小版本推荐先实现“版本号冲突拒绝”再逐步升级为“三方合并”。不要一开始就上 CRDT因为 Markdown 文件的协作特点是有段落、标题和列表结构按块的合并往往比按字符的 OT 更适合文档写作。2.4 为什么文件所有权会影响同步协议如果工作区只是数据库表冲突处理可以完全在数据库事务里完成。但 Marktwin 面向的是“用户拥有的 Markdown 文件”所以协议还要考虑外部修改。外部修改指的是没有通过工作区编辑器的改动。比如有人用 Vim 改了文件比如 CI 脚本自动更新了 README比如用户通过 Git pull 拉下了新版本。工作区需要监听文件变化并把磁盘上的最新内容重新加载到状态里再广播给在线客户端。这带来一个设计约束服务端不能只依赖内存中的文件内容作为唯一状态每个文件从磁盘加载后都要在文件系统发生变化时失效或刷新。否则会出现“编辑器显示的是旧内容磁盘上已经是新内容”的情况。3. 最小可运行原型让本地 Markdown 文件夹变成协作工作区这一节会实现一个最简单的 Marktwin 原型。它不是一个完整产品但已经能体现最关键链路文件列表、读取文件、WebSocket 同步、冲突保护、磁盘持久化。3.1 环境准备和目录结构需要准备一台安装了 Node.js 的机器。下面的示例使用 Node.js 18 以上的版本因为会用到fs/promises、path.resolve和 ES Module 语法。项目要求操作系统Windows / macOS / Linux 均可Node.js建议 18 LTS 以上npm随 Node.js 安装浏览器Chrome、Edge、Firefox 等现代浏览器目录结构如下marktwin-lab/ package.json server/ index.js public/ index.html app.js workspace/ meeting-notes.md roadmap.mdworkspace/就是工作区的根目录也是用户拥有的 Markdown 文件所在位置。生产环境不要把工作区放在程序代码目录里应该使用独立挂载卷或环境变量指定路径。3.2 安装依赖并编写 package.json创建package.json{ name: marktwin-lab, version: 0.1.0, type: module, scripts: { start: node server/index.js }, dependencies: { chokidar: ^3.6.0, express: ^4.19.2, markdown-it: ^14.0.0, ws: ^8.17.0 } }这里用 Express 提供静态文件和 REST API用 ws 提供 WebSocket用 chokidar 监听文件系统变化用 markdown-it 在前端做渲染。版本号在示例中不是固定要求实际项目以安装时解析到的版本为准。安装依赖npm install3.3 后端文件读写、WebSocket 和文件监听在server/index.js中实现服务端。先实现工作区根路径解析和文件读取工具函数。import express from express; import http from http; import { WebSocketServer } from ws; import chokidar from chokidar; import path from path; import fs from fs/promises; import { fileURLToPath } from url; const __dirname path.dirname(fileURLToPath(import.meta.url)); const WORKSPACE_ROOT path.resolve(process.env.WORKSPACE_ROOT || path.join(__dirname, ../workspace)); const app express(); const server http.createServer(app); const wss new WebSocketServer({ server, path: /sync }); app.use(express.json({ limit: 5mb })); app.use(express.static(path.join(__dirname, ../public))); const fileState new Map(); function safeResolve(rel) { const target path.resolve(WORKSPACE_ROOT, rel); if (target ! WORKSPACE_ROOT !target.startsWith(WORKSPACE_ROOT path.sep)) { const err new Error(invalid path); err.status 400; throw err; } return target; } async function readFileState(rel) { if (fileState.has(rel)) return fileState.get(rel); const target safeResolve(rel); const content await fs.readFile(target, utf8); const state { version: 1, content }; fileState.set(rel, state); return state; } async function writeFileState(rel, content) { const target safeResolve(rel); const prev fileState.get(rel) || { version: 0 }; const state { version: prev.version 1, content }; await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, content, utf8); fileState.set(rel, state); return state; }safeResolve是路径安全的关键。客户端只能通过相对路径访问工作区内的文件如果传入../或绝对路径必须直接拒绝。再实现文件列表接口和文件读取接口。async function listFiles(dir WORKSPACE_ROOT, prefix ) { const entries await fs.readdir(dir, { withFileTypes: true }); const result []; for (const entry of entries) { if (entry.name.startsWith(.)) continue; const full path.join(dir, entry.name); const rel prefix ? ${prefix}/${entry.name} : entry.name; if (entry.isDirectory()) { result.push(...await listFiles(full, rel)); } else if (/\.(md|markdown)$/i.test(entry.name)) { result.push(rel); } } return result; } app.get(/api/files, async (req, res) { try { res.json({ files: await listFiles() }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get(/api/file, async (req, res) { try { const state await readFileState(req.query.path); res.json({ path: req.query.path, ...state }); } catch (err) { res.status(err.status || 500).json({ error: err.message }); } });然后实现 WebSocket 同步逻辑和文件监听。function broadcast(message) { const data JSON.stringify(message); for (const client of wss.clients) { if (client.readyState 1) client.send(data); } } wss.on(connection, (ws) { ws.on(message, async (raw) { let message; try { message JSON.parse(raw.toString()); } catch { return; } if (message.type ! edit) return; try { const state await readFileState(message.file); if (message.version ! state.version) { ws.send(JSON.stringify({ type: conflict, file: message.file, version: state.version, content: state.content })); return; } const next await writeFileState(message.file, message.content); broadcast({ type: update, file: message.file, version: next.version, content: next.content, author: message.author || anonymous }); } catch (err) { ws.send(JSON.stringify({ type: error, message: err.message })); } }); }); const watcher chokidar.watch(WORKSPACE_ROOT, { ignoreInitial: true }); watcher.on(change, async (filePath) { const rel path.relative(WORKSPACE_ROOT, filePath).split(path.sep).join(/); if (!/\.(md|markdown)$/i.test(rel)) return; try { const external await fs.readFile(filePath, utf8); const prev fileState.get(rel); if (prev prev.content external) return; const next { version: (prev?.version || 0) 1, content: external }; fileState.set(rel, next); broadcast({ type: update, file: rel, version: next.version, content: external, source: disk }); } catch (err) { console.error(watcher error, err); } }); const PORT process.env.PORT || 3000; server.listen(PORT, () { console.log(Marktwin lab running at http://localhost:${PORT}); console.log(Workspace root: ${WORKSPACE_ROOT}); });这段代码有几个关键点文件状态分版本号版本不匹配拒绝写入编辑写入磁盘后再广播文件系统外部变化通过 chokidar 感知并广播。它还没有处理用户认证、细粒度权限、操作日志和断线重连这些放在生产化章节讨论。3.4 前端CodeMirror 编辑器加 Markdown 实时预览前端使用 CodeMirror 5 作为 Markdown 编辑器使用 markdown-it 做预览。为了让示例简单index.html通过 CDN 加载依赖。!doctype html html langzh-CN head meta charsetutf-8 titleMarktwin Lab/title link relstylesheet hrefhttps://cdn.jsdelivr.net/npm/codemirror5.65.16/lib/codemirror.css script srchttps://cdn.jsdelivr.net/npm/codemirror5.65.16/lib/codemirror.min.js/script script srchttps://cdn.jsdelivr.net/npm/codemirror5.65.16/mode/markdown/markdown.min.js/script script srchttps://cdn.jsdelivr.net/npm/markdown-it14.0.0/dist/markdown-it.min.js/script /head body div idapp aside idfile-list/aside main textarea ideditor/textarea article idpreview/article /main /div script src/app.js/script /body /html在public/app.js中实现文件加载、编辑、WebSocket 同步和冲突处理。const md window.markdownit(); const editor CodeMirror.fromTextArea(document.getElementById(editor), { mode: markdown, lineWrapping: true }); let currentFile null; let currentVersion 0; let remoteUpdating false; const socket new WebSocket(${location.protocol https: ? wss : ws}://${location.host}/sync); async function loadFileList() { const res await fetch(/api/files); const { files } await res.json(); const list document.getElementById(file-list); list.innerHTML ; for (const file of files) { const item document.createElement(button); item.textContent file; item.onclick () openFile(file); list.appendChild(item); } } async function openFile(file) { const res await fetch(/api/file?path${encodeURIComponent(file)}); const data await res.json(); currentFile file; currentVersion data.version; remoteUpdating true; editor.setValue(data.content); remoteUpdating false; renderPreview(); } function renderPreview() { document.getElementById(preview).innerHTML md.render(editor.getValue()); } let saveTimer null; editor.on(change, () { if (remoteUpdating) return; renderPreview(); clearTimeout(saveTimer); saveTimer setTimeout(sendEdit, 300); }); function sendEdit() { if (!currentFile) return; socket.send(JSON.stringify({ type: edit, file: currentFile, version: currentVersion, content: editor.getValue(), author: local-user })); } socket.onmessage (event) { const message JSON.parse(event.data); if (message.file ! currentFile) return; if (message.type update) { if (message.author local-user) { currentVersion message.version; return; } remoteUpdating true; editor.setValue(message.content); remoteUpdating false; currentVersion message.version; renderPreview(); } else if (message.type conflict) { remoteUpdating true; editor.setValue(message.content); remoteUpdating false; currentVersion message.version; renderPreview(); } }; loadFileList();这段前端代码做了三件事展示文件列表、把当前文件内容放到编辑器里、通过 WebSocket 发送编辑内容并接收更新。注意remoteUpdating标志位它避免收到远端更新时再次触发 change 事件造成回环。4. 端到端验证两个浏览器一起编辑同一个文件4.1 启动服务的完整步骤在工作区目录下创建两个测试文件mkdir -p workspace/docs echo # 会议纪要 workspace/docs/meeting.md echo # 路线图 workspace/roadmap.md然后启动服务npm start启动后终端会显示服务地址和工作区根路径。打开浏览器访问http://localhost:3000应该能在左侧看到docs/meeting.md和roadmap.md两个文件。再开一个无痕窗口访问同一个地址。两个窗口打开同一个文件docs/meeting.md这样就有了两个协作客户端。4.2 验证场景一同步写入在第一个窗口编辑文件输入一行新内容。等待 300 毫秒的防抖时间后WebSocket 会发送edit消息。第二个窗口应该会自动更新编辑器内容同时预览区域同步变化。这个过程中服务端控制台会收到来自 chokidar 的事件因为writeFileState把内容写入了磁盘。广播消息已经发出第二个窗口通过update消息拿到最新内容。要观察得更仔细可以打开浏览器开发者工具中的 Network 面板找到 WebSocket 连接查看发送和接收的帧。正常情况下会依次看到- {type:edit,file:docs/meeting.md,version:1,...} - {type:update,file:docs/meeting.md,version:2,...}4.3 验证场景二冲突保护在两个窗口都打开同一个文件并且都保持版本号一致。然后在第一个窗口输入内容先让它同步成功。在第二个窗口收到更新前故意继续编辑并发送旧版本号。具体操作先让两个窗口都打开文件在两个窗口都未编辑时第一个窗口快速输入一段文本并立刻在第二个窗口输入一段文本。由于第二个窗口还没收到第一个窗口的更新它发送的 version 仍然是旧值服务端会返回conflict消息。前端收到conflict消息后会用服务端最新内容覆盖编辑器。这个动作会丢失第二个窗口未同步的修改但保证了文件不会基于过期版本继续叠加写入。对于最小原型来说这种“宁可提示冲突也不静默覆盖”的策略是安全的。4.4 验证数据所有权文件确实在磁盘上工作区里的文件是普通 Markdown。编辑完成后直接查看磁盘文件cat workspace/docs/meeting.md能看到新写入的内容且文件格式是纯文本。这意味着即使后来不再使用 Marktwin这些文档也完全可以被其他工具继续读取。数据所有权不是抽象的承诺而是落地在文件系统里。5. 常见故障与排查链路5.1 现象文件列表为空或路径不对可能原因WORKSPACE_ROOT指向了错误目录。目录下没有.md或.markdown文件。文件以.开头被listFiles忽略。启动服务时进程没有权限读取目录。检查方式echo $WORKSPACE_ROOT ls -la workspace/ curl http://localhost:3000/api/files处理建议启动时打印工作区根路径确认文件存在确认扩展名正确确认进程运行用户有读取权限。5.2 现象其他端收不到更新可能原因WebSocket 没有连接成功/sync路径被前端写错。Nginx 或反向代理没有配置 WebSocket Upgrade。当前打开的客户端文件路径和广播的message.file不一致。服务端broadcast逻辑在发送前抛出了异常。检查方式浏览器 Console 是否出现 WebSocket 连接错误。Network 面板查看 WebSocket 帧。服务端日志是否有Error输出。在两个客户端分别打印收到的message.file。处理建议先直接访问http://localhost:3000测试不经过代理确认 WebSocket URL 是/sync在服务端广播前增加一行日志确认消息确实发送。5.3 现象编辑互相覆盖可能原因服务端没有校验版本号任何写入都直接覆盖。前端发送的 version 始终是同一个值。冲突发生后前端没有更新currentVersion。多个文件操作共用了一个全局变量。检查方式查看服务端writeFileState是否读取了prev.version查看前端socket.onmessage是否更新了currentVersion。处理建议以版本号作为写入依据每次保存成功后把服务端返回的新版本号写回前端状态不要在收到 conflict 后继续盲目重发。5.4 现象中文乱码或换行异常可能原因文件本身不是 UTF-8 编码。写入时没有指定utf8。浏览器和后端之间传输的 JSON 没有正确设置字符集。Windows 下文件使用 CRLF而其他端使用 LF。检查方式file workspace/docs/meeting.md hexdump -C workspace/docs/meeting.md | head处理建议统一使用 UTF-8 编码服务端读写文件时显式传utf8在 Git 仓库中使用.gitattributes规范换行符。5.5 排查顺序与速查表遇到问题先按这个顺序排查输入是否正确、文件路径是否正确、依赖版本是否匹配、配置是否生效、权限和端口是否正常、日志有没有异常、WebSocket 有没有连接成功。问题现象常见原因检查方式处理建议文件列表为空路径错或没有 md 文件查看启动日志和ls修正WORKSPACE_ROOT其他端不刷新WebSocket 断开或文件路径不匹配查看 Network 帧和服务端日志重新连接并检查路径内容被覆盖未校验版本号检查writeFileState加入版本冲突拒绝中文乱码编码不是 UTF-8file和hexdump统一 UTF-8 写入端口被占用端口冲突lsof -i:3000设置PORT环境变量修改后无法打开文件权限不足查看错误日志调整进程权限6. 生产化部署安全、备份与最佳实践6.1 本地原型与生产环境的差异上面的原型适合学习但直接放到生产环境会出问题。生产环境至少要补上认证、代理、文件权限、备份和监控。能力本地原型生产环境用户认证无需要登录、会话或 JWT权限控制所有文件可见按工作区和目录控制传输安全HTTP/WSHTTPS/WSS反向代理无Nginx/Caddy 转发并配置 Upgrade文件备份本地文件Git 仓库加定时快照冲突策略版本号拒绝版本号加三方合并日志监控控制台打印结构化的应用日志和告警资源限制无文件大小、连接数、请求体限制6.2 安全边界路径、上传、认证和传输文件类应用的第一个安全风险是路径穿越。客户端请求/api/file?path../../etc/passwd时服务端必须用path.resolve后判断是否还在工作区根目录内。示例中的safeResolve已经做了这件事生产环境还要加一层校验拒绝空路径、绝对路径、以及包含空字符的路径。第二个风险是请求体过大。如果用户直接发送几十 MB 的 Markdown 文件服务端内存和磁盘都会被拖垮。要限制上传体积Express 的express.json({ limit: 5mb })只是第一步还要在 WebSocket 消息里限制单条消息大小超过阈值直接断开连接。第三个风险是认证缺失。生产环境至少要有登录态WebSocket 建立连接时要校验身份和权限。不要在 WebSocket 消息体里反复传用户名和密码应该建立连接时完成认证之后只传文件操作。还要保证生产环境使用 HTTPS/WSS。浏览器会阻止混合内容如果页面是 HTTPS但 WebSocket 使用ws://连接会被拦截。6.3 备份与所有权把工作区纳入版本管理Marktwin 这类工具的优点就是文件本身在磁盘上备份方式很直接把工作区变成一个 Git 仓库。cd workspace git init git add . git commit -m initial workspace可以设置定时任务每隔一段时间自动提交一次。这样即使工作区编辑器出现数据错误也能从 Git 历史恢复到任意版本。对于 Markdown 文件Git 的 diff 已经有足够的可读性很适合做文档历史。要注意的是不要把运行时状态和临时文件提交进仓库。建议在仓库里添加.gitignore忽略.DS_Store、临时编辑文件、日志文件等。生产环境还要对workspace目录做文件系统级快照防止误删或被异常进程覆盖。6.4 上线前检查清单上线一个基于 Markdown 文件的协作工作区前可以做一次清单检查。[ ] 工作区根目录是否通过环境变量指定而不是硬编码在代码里。[ ] 文件读写是否全部使用 UTF-8 编码。[ ] 是否实现了路径穿越防护。[ ] 是否限制单个文件大小和 WebSocket 消息大小。[ ] 是否配置了用户认证和工作区权限。[ ] 是否通过 HTTPS/WSS 对外提供服务。[ ] 反向代理是否正确配置了 WebSocket Upgrade。[ ] 是否验证了文件冲突时用户能看到提示而不是静默覆盖。[ ] 是否把工作区纳入 Git 备份。[ ] 是否记录了编辑日志和异常日志。[ ] 是否做了加载测试确认多个客户端同时编辑时不会出现内存暴涨。这个清单可以贴在发布手册里每次上线前逐项确认。6.5 可扩展方向把最小原型跑通后可以有节奏地加入更多能力。第一优先级是冲突处理升级。当前整文件版本号只能防止过期写入不能自动合并。引入 diff3 三方合并后两个人改不同段落时可以自动合并改同一行时才提示冲突。第二优先级是连接可靠性。WebSocket 断线后要自动重连重连后要重新拉取文件最新状态避免用旧 version 发消息。第三优先级是编辑体验。比如光标位置同步、在线成员列表、Markdown 大纲、评论和批注、文件搜索和多工作区切换。这些功能对协作效率的提升非常明显。第四优先级是 Git 集成。可以把工作区每次保存变成一次 Git commit或者在打开文件时拉取远端仓库在保存后推送。这样就把“文件归用户所有”进一步落地为“文件归 Git 仓库所有”。如果只做一个改进建议先把冲突处理从“整文件覆盖”升级为“基于 diff3 的三方合并”再配合 WebSocket 心跳与断线重连。到这一步Marktwin 类的协作工作区已经能承担小团队的日常 Markdown 协作又能保证数据始终是用户能够自由迁移的普通文件。
返回列表