refactor: externalize tree live controller

This commit is contained in:
lix-2026
2026-05-24 21:35:21 +08:00
parent b61b56b5b5
commit db7c3cb319
5 changed files with 309 additions and 244 deletions
@@ -367,6 +367,56 @@ node --check rust/crates/mnote-web/browser/resource-open-runtime.js
全部通过。
### 9.3 Batch 2 执行记录(2026-05-24
Reasonix implementation run
- run id: `reasonix-2026-05-24T13-14-25-162Z-f98927dc`
- worktree: `/mnt/Data1T/mnote-wt-3-19-tree-live-b2`
- handoff: `/home/lix/.codex/runtime/reasonix-coding-worker/reasonix-2026-05-24T13-14-25-162Z-f98927dc/process-handoff.md`
- Codex 采纳方式:读取 Hindsight recall 和 handoff 后,从隔离 worktree 选择性合入主工作区;未提交 worker branch。
已落地的切片:
- 新增 `rust/crates/mnote-web/browser/tree-live-controller.js`
- 新增固定 asset route`GET /api/mnote-browser-runtime/tree-live-controller.js`
- `PageLayout` 将原 inline `TREE_LIVE_CONTROLLER_JS` 替换为 `type="module"` 外置脚本。
- `layout.rs` 不再保留大块 `TREE_LIVE_CONTROLLER_JS` raw string;测试改为 `include_str!` 外置文件内容。
采纳边界:
- 本批次只做同构搬迁,不改 tree live 语义。
- WS 优先、SSE fallback、local-folder events、snapshot/delta/resync/block.delta 分发保持原逻辑。
- 后端 `/api/realtime/ws``/api/tree/events``/api/local-folder/events` 未修改。
验证:
```bash
cargo fmt --manifest-path rust/Cargo.toml --all --check
cargo test -p mnote-web tree_live_controller_marks_transport_and_closes_source_on_pagehide
node --check rust/crates/mnote-web/browser/tree-live-controller.js
```
全部通过。
浏览器轻量验收:
- dev server: `npm run dev:hot`
- 页面:`http://127.0.0.1:3000/`,测试账号快速登录后进入主壳。
- `/api/mnote-browser-runtime/tree-live-controller.js` 返回 `200``content-type=application/javascript; charset=utf-8`
- `document.scripts` 中存在 1 个 tree live module script。
- `window.__mnoteTreeLiveControllerStarted === true`
- `<html>` 上出现 `data-mnote-tree-live-transport="disabled"``data-mnote-tree-live-status="disabled"`,证明外置 module 已执行。
- 控制台仍出现 `/api/local-folder/events` 500,这是既有 local folder event stream 问题,不属于本批次 route/module 加载回归。
CodeGraph 复核:
- 初始外置文件仍包在 IIFE 内,CodeGraph 只能识别外层匿名符号,不能定位 `startWithWebSocket`
- Codex 将外置 module 调整为模块级函数声明,并把启动 guard 放到底部。
- 复核结果:
- `codegraph_search startWithWebSocket` -> `rust/crates/mnote-web/browser/tree-live-controller.js`
- `codegraph_search startWithSseFallback` -> `rust/crates/mnote-web/browser/tree-live-controller.js`
## 10. 分阶段执行清单
### P0:定位辅助与基线保护
@@ -405,14 +455,15 @@ node --check rust/crates/mnote-web/browser/resource-open-runtime.js
### P2:外置 `TREE_LIVE_CONTROLLER_JS`
- [ ] 新增 `rust/crates/mnote-web/browser/tree-live-controller.js`
- [ ] `layout.rs` 只注入 `__MNOTE_TREE_LIVE_BOOTSTRAP__``<script type="module" src="...">`
- [ ] 新增 `/api/mnote-browser-runtime/tree-live-controller.js` 路由,返回 `application/javascript; charset=utf-8`
- [ ] 保持现有 WS 优先、SSE fallback、local-folder event fallback 行为。
- [ ] 浏览器断言:
- [x] 新增 `rust/crates/mnote-web/browser/tree-live-controller.js`
- [x] `layout.rs` 只注入 `__MNOTE_TREE_LIVE_BOOTSTRAP__``<script type="module" src="...">`
- [x] 新增 `/api/mnote-browser-runtime/tree-live-controller.js` 路由,返回 `application/javascript; charset=utf-8`
- [x] 保持现有 WS 优先、SSE fallback、local-folder event fallback 行为。
- [x] 浏览器断言:
- `document.documentElement.dataset.mnoteTreeLiveTransport` 正确。
- WS 推送仍更新 page/filetree
- WS 不可用时仍 fallback 到 SSE
- 外置 module 实际执行并设置 `window.__mnoteTreeLiveControllerStarted`
- [ ] WS 推送仍更新 page/filetree
- [ ] WS 不可用时仍 fallback 到 SSE。
### P3:外置 debug `/tree` shell runtime bridge
@@ -0,0 +1,230 @@
function readBootstrap() {
var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__');
var fallback = {
schema: 'mnote.tree_live_bootstrap.v1',
transport: 'convex-command-log-sse',
endpoint: '/api/tree/events',
rootIds: [],
initialRevision: null
};
if (!script || !script.textContent) return fallback;
try {
return Object.assign(fallback, JSON.parse(script.textContent));
} catch (_) {
return fallback;
}
}
function resolveWorkspaceId() {
var withWorkspace = document.querySelector('[data-workspace-id]');
if (withWorkspace) {
var value = (withWorkspace.getAttribute('data-workspace-id') || '').trim();
if (value) return value;
}
var params = new URLSearchParams(window.location.search);
return (params.get('workspaceId') || 'default').trim() || 'default';
}
function dispatchTreeEvent(name, detail) {
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
}
function applyStatus(status) {
document.documentElement.setAttribute('data-mnote-tree-live-status', status);
}
function applyTransport(transport) {
document.documentElement.setAttribute('data-mnote-tree-live-transport', transport || 'convex-command-log-sse');
}
function closeActiveSource() {
var source = window.__mnoteTreeLiveEventSource;
if (source && typeof source.close === 'function') {
source.close();
window.__mnoteTreeLiveEventSource = null;
applyStatus('closed');
}
}
function liveDeltaNeedsResync(payload) {
var data = payload && (payload.data || payload.delta || payload);
var op = data && typeof data.op === 'string' ? String(data.op).trim() : '';
if (!op || op === 'noop') return false;
if (op === 'resync_required') return true;
if (data && data.requiresResync === true) return true;
return false;
}
function startWithSse(bootstrap, workspaceId, url) {
var failures = 0;
var source = new EventSource(url.toString());
window.__mnoteTreeLiveEventSource = source;
applyStatus('connecting');
source.addEventListener('open', function(){
failures = 0;
applyStatus('connected');
});
source.addEventListener('snapshot', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:snapshot', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('delta', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('resync', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('block.delta', function(event){
var payload = JSON.parse(event.data || '{}');
dispatchTreeEvent('tree:block-delta', { payload: payload, bootstrap: bootstrap });
});
source.onerror = function(){
failures += 1;
applyStatus(failures >= 3 ? 'resync-pending' : 'reconnecting');
};
}
function startWithWebSocket(bootstrap, workspaceId, wsUrl) {
var proto = (window.location.protocol === 'https:') ? 'wss:' : 'ws:';
var url = new URL(wsUrl || bootstrap.wsEndpoint || '/api/realtime/ws', window.location.origin);
url.protocol = proto;
url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
}
var ws = new WebSocket(url.toString());
window.__mnoteTreeLiveEventSource = ws;
applyStatus('connecting');
ws.onopen = function() {
applyStatus('connected');
};
ws.onmessage = function(event) {
var payload;
try { payload = JSON.parse(event.data); } catch (_) { return; }
var kind = payload.kind || '';
var revision = payload.revision || payload.cursor || '';
if (kind === 'snapshot') {
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
dispatchTreeEvent('tree:snapshot', { payload: payload, revision: revision, bootstrap: bootstrap });
} else if (kind === 'delta') {
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
if (liveDeltaNeedsResync(payload) && ws.readyState === WebSocket.OPEN) {
ws.send('resync');
}
} else if (kind === 'resync') {
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
} else if (kind === 'resync_hint') {
// Server suggests re-sync after lagged events
if (ws.readyState === WebSocket.OPEN) {
ws.send('resync');
}
}
};
ws.onclose = function() {
applyStatus('closed');
// Fall back to SSE after a short delay
setTimeout(function() {
if (window.__mnoteTreeLiveEventSource === ws) {
startWithSseFallback(bootstrap, workspaceId);
}
}, 2000);
};
ws.onerror = function() {
applyStatus('error');
};
}
function startWithSseFallback(bootstrap, workspaceId) {
applyTransport('convex-command-log-sse');
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
}
startWithSse(bootstrap, workspaceId, url);
}
function start() {
var bootstrap = readBootstrap();
if (bootstrap.disabled === true || bootstrap.transport === 'disabled') {
applyTransport('disabled');
applyStatus('disabled');
return;
}
var params = new URLSearchParams(window.location.search);
var sourceKind = (params.get('sourceKind') || '').trim();
if (sourceKind === 'local_folder') {
applyTransport('local-folder-events');
applyStatus('connecting');
var localRootUri = (params.get('rootUri') || '').trim()
|| (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : '');
if (localRootUri && 'EventSource' in window) {
var url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', localRootUri);
url.searchParams.set('treeLive', 'true');
startWithSse(bootstrap, '', url);
return;
}
// No rootUri or EventSource unavailable — mark static and let polling fallback handle it
applyTransport('local-folder-static');
applyStatus('static');
return;
}
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
// Prefer WebSocket transport when available
var preferWs = bootstrap.transport === 'convex-command-log-ws' && 'WebSocket' in window;
if (preferWs) {
applyTransport('convex-command-log-ws');
startWithWebSocket(bootstrap, workspaceId, bootstrap.wsEndpoint || '/api/realtime/ws');
return;
}
// Fallback: SSE / EventSource
if (!('EventSource' in window)) {
applyStatus('unsupported');
return;
}
applyTransport(bootstrap.transport || 'convex-command-log-sse');
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
}
startWithSse(bootstrap, workspaceId, url);
}
if (!window.__mnoteTreeLiveControllerStarted) {
window.__mnoteTreeLiveControllerStarted = true;
window.__mnoteTreeLiveClose = closeActiveSource;
window.addEventListener('pagehide', closeActiveSource, { once: true });
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
}
+4
View File
@@ -98,6 +98,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/resource-open-runtime.js",
get(web_shell::resource_open_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/tree-live-controller.js",
get(web_shell::tree_live_controller_runtime_asset),
)
.route("/api/search/documents", post(search::documents))
.route(
"/api/search/local-index/refresh",
@@ -4381,6 +4381,20 @@ pub async fn resource_open_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn tree_live_controller_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-live-controller.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, "no-store")
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(Body::from(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn leptos_tiptap_manifest() -> Response {
let manifest = json!({
"entryAssetPath": "mnote-leptos-tiptap-spike-island.js",
+3 -237
View File
@@ -10290,241 +10290,6 @@ const SIDEBAR_TREE_JS: &str = r##"
})();
"##;
const TREE_LIVE_CONTROLLER_JS: &str = r##"
(function(){
if (window.__mnoteTreeLiveControllerStarted) return;
window.__mnoteTreeLiveControllerStarted = true;
function readBootstrap() {
var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__');
var fallback = {
schema: 'mnote.tree_live_bootstrap.v1',
transport: 'convex-command-log-sse',
endpoint: '/api/tree/events',
rootIds: [],
initialRevision: null
};
if (!script || !script.textContent) return fallback;
try {
return Object.assign(fallback, JSON.parse(script.textContent));
} catch (_) {
return fallback;
}
}
function resolveWorkspaceId() {
var withWorkspace = document.querySelector('[data-workspace-id]');
if (withWorkspace) {
var value = (withWorkspace.getAttribute('data-workspace-id') || '').trim();
if (value) return value;
}
var params = new URLSearchParams(window.location.search);
return (params.get('workspaceId') || 'default').trim() || 'default';
}
function dispatchTreeEvent(name, detail) {
window.dispatchEvent(new CustomEvent(name, { detail: detail }));
}
function applyStatus(status) {
document.documentElement.setAttribute('data-mnote-tree-live-status', status);
}
function applyTransport(transport) {
document.documentElement.setAttribute('data-mnote-tree-live-transport', transport || 'convex-command-log-sse');
}
function closeActiveSource() {
var source = window.__mnoteTreeLiveEventSource;
if (source && typeof source.close === 'function') {
source.close();
window.__mnoteTreeLiveEventSource = null;
applyStatus('closed');
}
}
function liveDeltaNeedsResync(payload) {
var data = payload && (payload.data || payload.delta || payload);
var op = data && typeof data.op === 'string' ? String(data.op).trim() : '';
if (!op || op === 'noop') return false;
if (op === 'resync_required') return true;
if (data && data.requiresResync === true) return true;
return false;
}
function startWithSse(bootstrap, workspaceId, url) {
var failures = 0;
var source = new EventSource(url.toString());
window.__mnoteTreeLiveEventSource = source;
applyStatus('connecting');
source.addEventListener('open', function(){
failures = 0;
applyStatus('connected');
});
source.addEventListener('snapshot', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:snapshot', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('delta', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('resync', function(event){
var payload = JSON.parse(event.data || '{}');
var revision = payload.revision || payload.cursor || event.lastEventId || null;
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision || ''));
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
});
source.addEventListener('block.delta', function(event){
var payload = JSON.parse(event.data || '{}');
dispatchTreeEvent('tree:block-delta', { payload: payload, bootstrap: bootstrap });
});
source.onerror = function(){
failures += 1;
applyStatus(failures >= 3 ? 'resync-pending' : 'reconnecting');
};
}
function startWithWebSocket(bootstrap, workspaceId, wsUrl) {
var proto = (window.location.protocol === 'https:') ? 'wss:' : 'ws:';
var url = new URL(wsUrl || bootstrap.wsEndpoint || '/api/realtime/ws', window.location.origin);
url.protocol = proto;
url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
}
var ws = new WebSocket(url.toString());
window.__mnoteTreeLiveEventSource = ws;
applyStatus('connecting');
ws.onopen = function() {
applyStatus('connected');
};
ws.onmessage = function(event) {
var payload;
try { payload = JSON.parse(event.data); } catch (_) { return; }
var kind = payload.kind || '';
var revision = payload.revision || payload.cursor || '';
if (kind === 'snapshot') {
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
dispatchTreeEvent('tree:snapshot', { payload: payload, revision: revision, bootstrap: bootstrap });
} else if (kind === 'delta') {
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap });
if (liveDeltaNeedsResync(payload) && ws.readyState === WebSocket.OPEN) {
ws.send('resync');
}
} else if (kind === 'resync') {
document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision));
dispatchTreeEvent('tree:resync', { payload: payload, revision: revision, bootstrap: bootstrap });
} else if (kind === 'resync_hint') {
// Server suggests re-sync after lagged events
if (ws.readyState === WebSocket.OPEN) {
ws.send('resync');
}
}
};
ws.onclose = function() {
applyStatus('closed');
// Fall back to SSE after a short delay
setTimeout(function() {
if (window.__mnoteTreeLiveEventSource === ws) {
startWithSseFallback(bootstrap, workspaceId);
}
}, 2000);
};
ws.onerror = function() {
applyStatus('error');
};
}
function startWithSseFallback(bootstrap, workspaceId) {
applyTransport('convex-command-log-sse');
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
}
startWithSse(bootstrap, workspaceId, url);
}
function start() {
var bootstrap = readBootstrap();
if (bootstrap.disabled === true || bootstrap.transport === 'disabled') {
applyTransport('disabled');
applyStatus('disabled');
return;
}
var params = new URLSearchParams(window.location.search);
var sourceKind = (params.get('sourceKind') || '').trim();
if (sourceKind === 'local_folder') {
applyTransport('local-folder-events');
applyStatus('connecting');
var localRootUri = (params.get('rootUri') || '').trim()
|| (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : '');
if (localRootUri && 'EventSource' in window) {
var url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', localRootUri);
url.searchParams.set('treeLive', 'true');
startWithSse(bootstrap, '', url);
return;
}
// No rootUri or EventSource unavailable — mark static and let polling fallback handle it
applyTransport('local-folder-static');
applyStatus('static');
return;
}
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
// Prefer WebSocket transport when available
var preferWs = bootstrap.transport === 'convex-command-log-ws' && 'WebSocket' in window;
if (preferWs) {
applyTransport('convex-command-log-ws');
startWithWebSocket(bootstrap, workspaceId, bootstrap.wsEndpoint || '/api/realtime/ws');
return;
}
// Fallback: SSE / EventSource
if (!('EventSource' in window)) {
applyStatus('unsupported');
return;
}
applyTransport(bootstrap.transport || 'convex-command-log-sse');
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
url.searchParams.set('rootNodeId', bootstrap.rootIds[0]);
}
startWithSse(bootstrap, workspaceId, url);
}
window.__mnoteTreeLiveClose = closeActiveSource;
window.addEventListener('pagehide', closeActiveSource, { once: true });
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
})();
"##;
/// MNOTE Wolai 风格页面布局
///
/// 包含左侧栏 + 内容区的双栏布局。
@@ -10653,7 +10418,7 @@ pub fn PageLayout(
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
<script type="module" src="/api/mnote-browser-runtime/resource-open-runtime.js"></script>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
<script id="mnote-tree-live-controller" inner_html={TREE_LIVE_CONTROLLER_JS.to_string()}></script>
<script type="module" src="/api/mnote-browser-runtime/tree-live-controller.js"></script>
</aside>
<div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div>
<div class="mnote-main">
@@ -10691,7 +10456,8 @@ pub fn PageLayout(
#[cfg(test)]
mod tests {
use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS};
use super::SIDEBAR_TREE_JS;
const TREE_LIVE_CONTROLLER_JS: &str = include_str!("../../../browser/tree-live-controller.js");
fn js_function_body(source: &str, name: &str) -> String {
let marker = format!("function {name}(");