feat: advance local-first conflict and search indexing
- 补齐本地 markdown 冲突处理与合并写回路径\n- 增加本地搜索索引路由、刷新与 browser smoke\n- 同步更新 current-priority checklist 的阶段进度
This commit is contained in:
@@ -4,6 +4,7 @@ use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -13,6 +14,8 @@ pub struct ErrorBody {
|
||||
pub message: String,
|
||||
pub request_id: Option<String>,
|
||||
pub trace_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -22,6 +25,7 @@ pub struct WebError {
|
||||
message: String,
|
||||
request_context: Option<RequestContext>,
|
||||
headers: Vec<(&'static str, String)>,
|
||||
details: Option<Value>,
|
||||
}
|
||||
|
||||
impl WebError {
|
||||
@@ -32,6 +36,7 @@ impl WebError {
|
||||
message: message.into(),
|
||||
request_context: None,
|
||||
headers: Vec::new(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +74,11 @@ impl WebError {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: Value) -> Self {
|
||||
self.details = Some(details);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
@@ -96,6 +106,7 @@ impl IntoResponse for WebError {
|
||||
.request_context
|
||||
.as_ref()
|
||||
.map(|context| context.trace.trace_id.clone()),
|
||||
details: self.details,
|
||||
};
|
||||
let mut response = (self.status, Json(body)).into_response();
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-error-code") {
|
||||
|
||||
@@ -1558,6 +1558,32 @@ mod tests {
|
||||
.expect("save response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "local_markdown_external_change");
|
||||
assert_eq!(
|
||||
payload["details"]["conflict"]["documentId"].as_str(),
|
||||
Some(document_id)
|
||||
);
|
||||
assert_eq!(
|
||||
payload["details"]["conflict"]["rootUri"].as_str(),
|
||||
Some(root_uri.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
|
||||
Some(stale_file_version.as_str())
|
||||
);
|
||||
assert!(payload["details"]["conflict"]["currentDiskVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:local-mdid:expected-file-version:"));
|
||||
assert!(payload["details"]["conflict"]["suggestedActions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.as_str() == Some("merge")));
|
||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(markdown.contains("# External"));
|
||||
assert!(!markdown.contains("# Editor"));
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::page_aggregate::{
|
||||
use crate::routes::local_markdown_parser::{
|
||||
file_stem_title, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use crate::routes::local_search_index;
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
@@ -1380,10 +1381,11 @@ pub fn save_local_markdown_page(
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if expected_key != current_conflict_key {
|
||||
return Err(WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_markdown_external_change",
|
||||
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
||||
return Err(local_markdown_conflict_error(
|
||||
root_uri,
|
||||
document_id,
|
||||
expected_key,
|
||||
¤t_conflict_key,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1403,6 +1405,8 @@ pub fn save_local_markdown_page(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
||||
let next_conflict_key =
|
||||
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
|
||||
Ok(json!({
|
||||
@@ -1417,6 +1421,38 @@ pub fn save_local_markdown_page(
|
||||
}))
|
||||
}
|
||||
|
||||
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
|
||||
let _ = local_search_index::refresh_local_search_index(root, root_uri, workspace_id);
|
||||
}
|
||||
|
||||
fn local_markdown_conflict_error(
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
editor_base_version: &str,
|
||||
current_disk_version: &str,
|
||||
) -> WebError {
|
||||
WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_markdown_external_change",
|
||||
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
||||
)
|
||||
.with_details(json!({
|
||||
"conflict": {
|
||||
"code": "local_markdown_external_change",
|
||||
"documentId": document_id,
|
||||
"rootUri": root_uri,
|
||||
"currentDiskVersion": current_disk_version,
|
||||
"editorBaseVersion": editor_base_version,
|
||||
"suggestedActions": [
|
||||
"accept_disk",
|
||||
"keep_editor",
|
||||
"open_diff",
|
||||
"merge"
|
||||
]
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn write_local_markdown_page_body(
|
||||
request: &core_protocol::PageBodyWriteRequest,
|
||||
) -> Result<Value, WebError> {
|
||||
@@ -1765,6 +1801,8 @@ pub fn update_local_markdown_title(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"documentId": document_id,
|
||||
@@ -1814,7 +1852,7 @@ pub fn execute_local_tree_command(
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
})?;
|
||||
match action {
|
||||
let result = match action {
|
||||
"create" => {
|
||||
create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面"))
|
||||
}
|
||||
@@ -1838,7 +1876,10 @@ pub fn execute_local_tree_command(
|
||||
"local_tree_command_unsupported",
|
||||
format!("local_folder 暂不支持 tree action: {other}"),
|
||||
)),
|
||||
}
|
||||
}?;
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn create_local_markdown_page(
|
||||
@@ -4412,12 +4453,12 @@ mod tests {
|
||||
add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base,
|
||||
create_local_access_grant, ensure_local_path_read_access,
|
||||
ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor,
|
||||
get_local_access_policy, initialize_local_page_id, initialize_local_workspace_for_actor,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision, open_local_file,
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body,
|
||||
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
||||
LocalUploadFile,
|
||||
execute_local_tree_command, get_local_access_policy, initialize_local_page_id,
|
||||
initialize_local_workspace_for_actor, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, open_local_file, resolve_local_markdown_page_aggregate,
|
||||
save_local_markdown_page, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||
LocalFileOpenQuery, LocalUploadFile,
|
||||
};
|
||||
use crate::context::RequestContext;
|
||||
use axum::extract::{Extension, Path as AxumPath, Query};
|
||||
@@ -5353,6 +5394,88 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tree_command_refreshes_search_index_after_rename() {
|
||||
let root = temp_root("mnote-local-tree-refresh-search-index");
|
||||
init_workspace(&root);
|
||||
std::fs::write(
|
||||
root.join("search-target.md"),
|
||||
"# Search Target\nrename-token\n",
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
crate::routes::local_search_index::refresh_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
"local-ws-search-index-test",
|
||||
)
|
||||
.expect("initial index");
|
||||
let before =
|
||||
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
|
||||
.expect("before index");
|
||||
assert!(before.contains("search-target.md"));
|
||||
|
||||
execute_local_tree_command(
|
||||
&root_uri,
|
||||
"rename",
|
||||
"local-md:search-target.md",
|
||||
None,
|
||||
Some("Renamed Target"),
|
||||
)
|
||||
.expect("rename");
|
||||
|
||||
let after =
|
||||
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
|
||||
.expect("after index");
|
||||
assert!(after.contains("Renamed Target.md"), "{after}");
|
||||
assert!(!after.contains("search-target.md"), "{after}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_refreshes_search_index_after_write() {
|
||||
let root = temp_root("mnote-local-save-refresh-search-index");
|
||||
init_workspace(&root);
|
||||
std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
crate::routes::local_search_index::refresh_local_search_index(
|
||||
&root,
|
||||
&root_uri,
|
||||
"local-ws-search-index-test",
|
||||
)
|
||||
.expect("initial index");
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
"local-md:README.md",
|
||||
None,
|
||||
&serde_json::json!([
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "After" }]
|
||||
},
|
||||
{
|
||||
"id": "p_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "new-token" }]
|
||||
}
|
||||
]),
|
||||
)
|
||||
.expect("save markdown");
|
||||
|
||||
let index =
|
||||
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
|
||||
.expect("index");
|
||||
assert!(index.contains("new-token"), "{index}");
|
||||
assert!(!index.contains("old-token"), "{index}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() {
|
||||
let root = temp_root("mnote-local-markdown-asset-upload");
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_markdown_parser::{
|
||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalSearchIndex {
|
||||
version: u32,
|
||||
built_at: u128,
|
||||
root_uri: String,
|
||||
workspace_id: String,
|
||||
documents: Vec<LocalSearchDocument>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalSearchDocument {
|
||||
document_id: String,
|
||||
title: String,
|
||||
path: String,
|
||||
raw_text: String,
|
||||
tags: Vec<String>,
|
||||
backlinks: Vec<String>,
|
||||
resource_refs: Vec<String>,
|
||||
updated_at: u128,
|
||||
}
|
||||
|
||||
pub(crate) fn query_local_search_index(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
query: &str,
|
||||
page_id: Option<&str>,
|
||||
limit: u32,
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let normalized_query = normalize_search_text(query);
|
||||
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
|
||||
let mut results = Vec::new();
|
||||
for document in index.documents.iter() {
|
||||
if let Some(page_id) = page_id {
|
||||
if document.document_id != page_id {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !local_search_document_matches(document, &normalized_query, title_only, exact) {
|
||||
continue;
|
||||
}
|
||||
results.push(local_search_document_projection(
|
||||
document,
|
||||
root_uri,
|
||||
&normalized_query,
|
||||
));
|
||||
if results.len() >= limit.max(1) as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
"sourceKind": "local_folder",
|
||||
"index": {
|
||||
"version": index.version,
|
||||
"rootUri": index.root_uri,
|
||||
"workspaceId": index.workspace_id,
|
||||
"builtAt": index.built_at,
|
||||
"documentCount": index.documents.len()
|
||||
},
|
||||
"recentChanges": recent_changes,
|
||||
"results": results
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_local_search_index(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
Ok(json!({
|
||||
"version": index.version,
|
||||
"rootUri": index.root_uri,
|
||||
"workspaceId": index.workspace_id,
|
||||
"builtAt": index.built_at,
|
||||
"documentCount": index.documents.len()
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn query_local_backlinks(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let Some(target) = index
|
||||
.documents
|
||||
.iter()
|
||||
.find(|document| document.document_id == document_id)
|
||||
else {
|
||||
return Ok(json!({
|
||||
"documentId": document_id,
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"backlinks": []
|
||||
}));
|
||||
};
|
||||
let target_keys = normalized_document_targets(target);
|
||||
let backlinks = index
|
||||
.documents
|
||||
.iter()
|
||||
.filter(|document| document.document_id != target.document_id)
|
||||
.filter_map(|document| {
|
||||
let matched_targets = document
|
||||
.backlinks
|
||||
.iter()
|
||||
.filter(|link| target_keys.contains(&normalize_search_text(link)))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if matched_targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"documentId": document.document_id,
|
||||
"title": document.title,
|
||||
"path": document.path,
|
||||
"resourceType": "markdown",
|
||||
"matchedTargets": matched_targets,
|
||||
"snippet": search_snippet(document, &normalize_search_text(&target.title))
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"documentId": target.document_id,
|
||||
"title": target.title,
|
||||
"path": target.path,
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"backlinks": backlinks
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn query_local_tags(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
|
||||
let mut tags = std::collections::BTreeMap::<String, Vec<&LocalSearchDocument>>::new();
|
||||
for document in &index.documents {
|
||||
for tag in &document.tags {
|
||||
tags.entry(tag.clone()).or_default().push(document);
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"rootUri": root_uri,
|
||||
"workspaceId": workspace_id,
|
||||
"tags": tags
|
||||
.into_iter()
|
||||
.map(|(tag, documents)| {
|
||||
json!({
|
||||
"tag": tag,
|
||||
"count": documents.len(),
|
||||
"documents": documents
|
||||
.into_iter()
|
||||
.map(|document| {
|
||||
json!({
|
||||
"documentId": document.document_id,
|
||||
"title": document.title,
|
||||
"path": document.path,
|
||||
"resourceType": "markdown"
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}))
|
||||
}
|
||||
|
||||
fn rebuild_local_search_index(
|
||||
root_path: &Path,
|
||||
root_uri: &str,
|
||||
workspace_id: &str,
|
||||
) -> Result<LocalSearchIndex, WebError> {
|
||||
let mut documents = Vec::new();
|
||||
collect_markdown_documents(root_path, root_path, &mut documents)?;
|
||||
documents.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
let index = LocalSearchIndex {
|
||||
version: LOCAL_SEARCH_INDEX_VERSION,
|
||||
built_at: now_ms(),
|
||||
root_uri: root_uri.to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
documents,
|
||||
};
|
||||
write_local_search_index(root_path, &index)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn collect_markdown_documents(
|
||||
root_path: &Path,
|
||||
current: &Path,
|
||||
documents: &mut Vec<LocalSearchDocument>,
|
||||
) -> Result<(), WebError> {
|
||||
let entries = match fs::read_dir(current) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_search_index_read_dir_failed",
|
||||
format!("无法读取本地搜索索引目录 {}: {error}", current.display()),
|
||||
));
|
||||
}
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_index_read_entry_failed",
|
||||
format!("无法读取本地搜索索引条目: {error}"),
|
||||
)
|
||||
})?;
|
||||
let path = entry.path();
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default();
|
||||
if file_name == ".mnote" {
|
||||
continue;
|
||||
}
|
||||
let file_type = entry.file_type().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_index_stat_failed",
|
||||
format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()),
|
||||
)
|
||||
})?;
|
||||
if file_type.is_dir() {
|
||||
collect_markdown_documents(root_path, &path, documents)?;
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() || !is_markdown_path(&path) {
|
||||
continue;
|
||||
}
|
||||
documents.push(index_markdown_file(root_path, &path)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocument, WebError> {
|
||||
let markdown = fs::read_to_string(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_index_read_markdown_failed",
|
||||
format!("无法读取本地 Markdown 索引文件 {}: {error}", path.display()),
|
||||
)
|
||||
})?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("");
|
||||
let parsed = parse_markdown_page(&markdown, file_name);
|
||||
let relative_path = path
|
||||
.strip_prefix(root_path)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let document_id = parsed
|
||||
.mnote_id
|
||||
.as_deref()
|
||||
.map(|id| format!("local-mdid:{id}"))
|
||||
.unwrap_or_else(|| format!("local-md:{}", relative_path.replace('/', "~2F")));
|
||||
let metadata = fs::metadata(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_index_stat_failed",
|
||||
format!(
|
||||
"无法读取本地 Markdown 索引文件状态 {}: {error}",
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(LocalSearchDocument {
|
||||
document_id,
|
||||
title: parsed.title,
|
||||
path: relative_path,
|
||||
raw_text: parsed.body.clone(),
|
||||
tags: extract_tags(&markdown),
|
||||
backlinks: extract_backlinks(&parsed.body),
|
||||
resource_refs: extract_resource_refs(&parsed.body),
|
||||
updated_at: metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|value| value.as_millis())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
|
||||
let index_dir = root_path.join(".mnote").join("index");
|
||||
fs::create_dir_all(&index_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_index_create_failed",
|
||||
format!("无法创建本地搜索索引目录 {}: {error}", index_dir.display()),
|
||||
)
|
||||
})?;
|
||||
let index_path = index_dir.join("search-index.json");
|
||||
let payload = serde_json::to_string_pretty(index)
|
||||
.map_err(|error| WebError::internal(format!("本地搜索索引序列化失败: {error}")))?;
|
||||
fs::write(&index_path, format!("{payload}\n")).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_index_write_failed",
|
||||
format!("无法写入本地搜索索引 {}: {error}", index_path.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn local_search_document_matches(
|
||||
document: &LocalSearchDocument,
|
||||
query: &str,
|
||||
title_only: bool,
|
||||
exact: bool,
|
||||
) -> bool {
|
||||
if query.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let haystack = if title_only {
|
||||
normalize_search_text(&document.title)
|
||||
} else {
|
||||
normalize_search_text(&format!(
|
||||
"{}\n{}\n{}\n{}",
|
||||
document.title,
|
||||
document.raw_text,
|
||||
document.tags.join(" "),
|
||||
document.resource_refs.join(" ")
|
||||
))
|
||||
};
|
||||
if exact {
|
||||
haystack == query
|
||||
} else {
|
||||
haystack.contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
fn local_search_document_projection(
|
||||
document: &LocalSearchDocument,
|
||||
root_uri: &str,
|
||||
query: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"id": document.document_id,
|
||||
"documentId": document.document_id,
|
||||
"title": document.title,
|
||||
"path": document.path,
|
||||
"resourceType": "markdown",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"snippet": search_snippet(document, query),
|
||||
"tags": document.tags,
|
||||
"backlinks": document.backlinks,
|
||||
"resourceRefs": document.resource_refs,
|
||||
"updatedAt": document.updated_at,
|
||||
"publicPath": format!("/documents/{}?sourceKind=local_folder&rootUri={}", document.document_id, root_uri)
|
||||
})
|
||||
}
|
||||
|
||||
fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value {
|
||||
let mut sorted = documents.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
|
||||
Value::Array(
|
||||
sorted
|
||||
.into_iter()
|
||||
.take(20)
|
||||
.map(|document| {
|
||||
json!({
|
||||
"id": document.document_id,
|
||||
"documentId": document.document_id,
|
||||
"title": document.title,
|
||||
"path": document.path,
|
||||
"resourceType": "markdown",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"updatedAt": document.updated_at
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalized_document_targets(
|
||||
document: &LocalSearchDocument,
|
||||
) -> std::collections::BTreeSet<String> {
|
||||
let mut values = std::collections::BTreeSet::new();
|
||||
values.insert(normalize_search_text(&document.document_id));
|
||||
values.insert(normalize_search_text(&document.path));
|
||||
values.insert(normalize_search_text(&document.title));
|
||||
if let Some(encoded) = document.document_id.strip_prefix("local-md:") {
|
||||
values.insert(normalize_search_text(&encoded.replace("~2F", "/")));
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn extract_tags(markdown: &str) -> Vec<String> {
|
||||
let Some(frontmatter) = split_frontmatter(markdown).0 else {
|
||||
return Vec::new();
|
||||
};
|
||||
for line in frontmatter.lines() {
|
||||
let trimmed = line.trim();
|
||||
let Some(value) = trimmed
|
||||
.strip_prefix("tags:")
|
||||
.or_else(|| trimmed.strip_prefix("tag:"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
return value
|
||||
.trim()
|
||||
.trim_matches(['[', ']'])
|
||||
.split([',', ' '])
|
||||
.map(|item| item.trim().trim_matches(['"', '\'']))
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extract_backlinks(markdown: &str) -> Vec<String> {
|
||||
let mut values = extract_markdown_links(markdown)
|
||||
.into_iter()
|
||||
.filter(|target| is_markdown_reference(target))
|
||||
.collect::<Vec<_>>();
|
||||
let mut rest = markdown;
|
||||
while let Some(start) = rest.find("[[") {
|
||||
let after_start = &rest[start + 2..];
|
||||
let Some(end) = after_start.find("]]") else {
|
||||
break;
|
||||
};
|
||||
let link = after_start[..end].trim();
|
||||
if !link.is_empty() {
|
||||
values.push(link.to_string());
|
||||
}
|
||||
rest = &after_start[end + 2..];
|
||||
}
|
||||
values.sort();
|
||||
values.dedup();
|
||||
values
|
||||
}
|
||||
|
||||
fn extract_resource_refs(markdown: &str) -> Vec<String> {
|
||||
let mut values = markdown
|
||||
.lines()
|
||||
.filter_map(|line| parse_markdown_attachment_link(line.trim()))
|
||||
.map(|(_, target)| target)
|
||||
.chain(
|
||||
extract_markdown_links(markdown)
|
||||
.into_iter()
|
||||
.filter(|target| {
|
||||
!is_markdown_reference(target) && is_local_resource_reference(target)
|
||||
}),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
values
|
||||
}
|
||||
|
||||
fn extract_markdown_links(markdown: &str) -> Vec<String> {
|
||||
let mut links = Vec::new();
|
||||
let mut rest = markdown;
|
||||
while let Some(label_end) = rest.find("](") {
|
||||
let after = &rest[label_end + 2..];
|
||||
let Some(target_end) = after.find(')') else {
|
||||
break;
|
||||
};
|
||||
let target = after[..target_end].trim();
|
||||
if !target.is_empty() {
|
||||
links.push(target.to_string());
|
||||
}
|
||||
rest = &after[target_end + 1..];
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
fn is_markdown_reference(target: &str) -> bool {
|
||||
let lower = target.to_lowercase();
|
||||
lower.ends_with(".md") || lower.ends_with(".markdown") || lower.starts_with("local-md:")
|
||||
}
|
||||
|
||||
fn is_local_resource_reference(target: &str) -> bool {
|
||||
let lower = target.to_lowercase();
|
||||
!lower.starts_with("http://")
|
||||
&& !lower.starts_with("https://")
|
||||
&& !lower.starts_with("mailto:")
|
||||
&& !lower.starts_with('#')
|
||||
&& Path::new(target).extension().is_some()
|
||||
}
|
||||
|
||||
fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
|
||||
for line in document.raw_text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if normalize_search_text(trimmed).contains(query) {
|
||||
return trimmed.chars().take(180).collect();
|
||||
}
|
||||
}
|
||||
document.raw_text.chars().take(180).collect()
|
||||
}
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.eq_ignore_ascii_case("md") || value.eq_ignore_ascii_case("markdown"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn normalize_search_text(value: &str) -> String {
|
||||
value.trim().to_lowercase()
|
||||
}
|
||||
|
||||
fn now_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|value| value.as_millis())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_root(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("docs")).expect("create root");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_search_index_extracts_backlinks_resource_refs_and_tags() {
|
||||
let root = temp_root("mnote-local-search-index");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Home\ntags: [alpha, beta]\n---\n# Home\nSee [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n[Map](maps/idea.mindmap.json)\n[Sheet](office/report.xlsx)\n\n",
|
||||
)
|
||||
.expect("write readme");
|
||||
fs::write(
|
||||
root.join("docs").join("child.md"),
|
||||
"---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n",
|
||||
)
|
||||
.expect("write child");
|
||||
|
||||
let projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
"local-ws-test",
|
||||
"alpha",
|
||||
None,
|
||||
10,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.expect("projection");
|
||||
let results = projection["results"].as_array().expect("results");
|
||||
let home = results
|
||||
.iter()
|
||||
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
.expect("home result");
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("docs/child.md")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod kernel;
|
||||
mod local_folder_events;
|
||||
mod local_folder_source;
|
||||
mod local_markdown_parser;
|
||||
mod local_search_index;
|
||||
mod media;
|
||||
mod mindmap_api;
|
||||
mod mindmap_shell;
|
||||
@@ -89,6 +90,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
get(web_shell::editor_image_placeholder_asset),
|
||||
)
|
||||
.route("/api/search/documents", post(search::documents))
|
||||
.route(
|
||||
"/api/search/local-index/refresh",
|
||||
post(search::refresh_local_index),
|
||||
)
|
||||
.route(
|
||||
"/api/search/local-index/backlinks",
|
||||
get(search::local_index_backlinks),
|
||||
)
|
||||
.route(
|
||||
"/api/search/local-index/tags",
|
||||
get(search::local_index_tags),
|
||||
)
|
||||
.route("/api/gateway/health", get(gateway::gateway_health))
|
||||
.route("/api/dev/hot-reload", get(dev_hot::hot_reload))
|
||||
.route("/api/runtime/config", get(session::runtime_config))
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::routes::query_support::{
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||
use crate::routes::{local_folder_source, local_search_index};
|
||||
use crate::ssr::pages::search::SearchPage;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
@@ -22,6 +23,8 @@ const HEADER_QUERY_NAME: &str = "x-query-name";
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchDocumentsRequest {
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub query: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
@@ -54,6 +57,21 @@ pub struct SearchShellQuery {
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSearchIndexRefreshRequest {
|
||||
pub workspace_id: Option<String>,
|
||||
pub root_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalSearchIndexQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
pub root_uri: String,
|
||||
pub document_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn shell(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -151,16 +169,40 @@ pub async fn documents(
|
||||
None
|
||||
};
|
||||
|
||||
let result = load_search_results_with_filters(
|
||||
state.config(),
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
&normalized_query,
|
||||
page_id,
|
||||
body.limit.unwrap_or(30),
|
||||
filters,
|
||||
)
|
||||
.await?;
|
||||
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
|
||||
let root_uri = body
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
local_search_index::query_local_search_index(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
&normalized_query,
|
||||
page_id.as_deref(),
|
||||
body.limit.unwrap_or(30),
|
||||
filters.title_only.unwrap_or(false),
|
||||
filters.exact.unwrap_or(false),
|
||||
)?
|
||||
} else {
|
||||
load_search_results_with_filters(
|
||||
state.config(),
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
&normalized_query,
|
||||
page_id,
|
||||
body.limit.unwrap_or(30),
|
||||
filters,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
@@ -170,7 +212,7 @@ pub async fn documents(
|
||||
Json(json!({
|
||||
"results": result.get("results").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
"recent": [],
|
||||
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
|
||||
@@ -184,6 +226,138 @@ pub async fn documents(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn refresh_local_index(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<LocalSearchIndexRefreshRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let root_uri = body.root_uri.trim();
|
||||
if root_uri.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_search_root_required",
|
||||
"本地索引刷新缺少 rootUri",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let refreshed = local_search_index::refresh_local_search_index(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"index": refreshed,
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
"projectionOwner": "rust-kernel",
|
||||
"queryName": "search.local_index.refresh",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
}
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn local_index_backlinks(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalSearchIndexQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let root_uri = query.root_uri.trim();
|
||||
if root_uri.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_search_root_required",
|
||||
"本地反链查询缺少 rootUri",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let document_id = query
|
||||
.document_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_search_document_required",
|
||||
"本地反链查询缺少 documentId",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let backlinks = local_search_index::query_local_backlinks(
|
||||
&root_path,
|
||||
root_uri,
|
||||
&effective_workspace_id,
|
||||
document_id,
|
||||
)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"result": backlinks,
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
"projectionOwner": "rust-kernel",
|
||||
"queryName": "search.local_index.backlinks",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
}
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn local_index_tags(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalSearchIndexQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let root_uri = query.root_uri.trim();
|
||||
if root_uri.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_search_root_required",
|
||||
"本地标签查询缺少 rootUri",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let root_path = local_folder_source::ensure_local_workspace_read_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let tags = local_search_index::query_local_tags(&root_path, root_uri, &effective_workspace_id)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
stamp_search_headers(&mut headers);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"result": tags,
|
||||
"meta": {
|
||||
"owner": "mnote-web",
|
||||
"projectionOwner": "rust-kernel",
|
||||
"queryName": "search.local_index.tags",
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
}
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
async fn load_search_results(
|
||||
config: &crate::app::AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -365,6 +539,7 @@ mod tests {
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -390,6 +565,18 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
fn query_escape(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(byte as char).to_string()
|
||||
}
|
||||
_ => format!("%{byte:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_shell_returns_server_first_island_contract() {
|
||||
let response = app()
|
||||
@@ -533,4 +720,227 @@ mod tests {
|
||||
assert_eq!(payload["meta"]["degraded"], true);
|
||||
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_documents_local_folder_uses_authorized_root_index() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-search-route-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::create_dir_all(root.join("docs")).expect("docs");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-search","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Search Home\ntags: [alpha]\n---\n# Search Home\nAlpha body links [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n",
|
||||
)
|
||||
.expect("readme");
|
||||
fs::write(root.join("docs").join("child.md"), "# Child\nalpha child\n").expect("child");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/search/documents")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "local-ws-search",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"query": "alpha",
|
||||
"limit": 10
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["meta"]["degraded"], false);
|
||||
let results = payload["results"].as_array().expect("results");
|
||||
let home = results
|
||||
.iter()
|
||||
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
.expect("home result");
|
||||
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
|
||||
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists());
|
||||
assert!(payload["recent"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_local_index_refresh_rebuilds_authorized_root() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-search-refresh-route-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-refresh","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/search/local-index/refresh")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "local-ws-refresh",
|
||||
"rootUri": root_uri
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["index"]["documentCount"], 1);
|
||||
assert_eq!(
|
||||
payload["meta"]["queryName"].as_str(),
|
||||
Some("search.local_index.refresh")
|
||||
);
|
||||
let index = fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
|
||||
.expect("index");
|
||||
assert!(index.contains("refresh-token"));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_local_index_backlinks_and_tags_read_authorized_root() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-search-backlinks-route-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::create_dir_all(root.join("docs")).expect("docs");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-backlinks","ownerId":"user_test","createdAt":"2026-05-19T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Home\ntags: [alpha]\n---\n# Home\nSee [Child](docs/child.md).\n",
|
||||
)
|
||||
.expect("home");
|
||||
fs::write(
|
||||
root.join("docs").join("child.md"),
|
||||
"---\ntitle: Child\ntags: [alpha, beta]\n---\n# Child\n",
|
||||
)
|
||||
.expect("child");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let encoded_root = query_escape(&root_uri);
|
||||
|
||||
let backlinks_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"/api/search/local-index/backlinks?workspaceId=local-ws-backlinks&rootUri={encoded_root}&documentId=local-md:docs~2Fchild.md"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("backlinks response");
|
||||
assert_eq!(backlinks_response.status(), StatusCode::OK);
|
||||
let backlinks_body = to_bytes(backlinks_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("backlinks body");
|
||||
let backlinks_payload: Value = serde_json::from_slice(&backlinks_body).expect("json");
|
||||
assert_eq!(
|
||||
backlinks_payload["meta"]["queryName"].as_str(),
|
||||
Some("search.local_index.backlinks")
|
||||
);
|
||||
assert!(backlinks_payload["result"]["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||
|
||||
let tags_response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"/api/search/local-index/tags?workspaceId=local-ws-backlinks&rootUri={encoded_root}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("tags response");
|
||||
assert_eq!(tags_response.status(), StatusCode::OK);
|
||||
let tags_body = to_bytes(tags_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("tags body");
|
||||
let tags_payload: Value = serde_json::from_slice(&tags_body).expect("json");
|
||||
assert!(tags_payload["result"]["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag["tag"].as_str() == Some("alpha") && tag["count"].as_u64() == Some(2)));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1571,6 +1571,27 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const conflictEnvelopeFromResponse = (payload) => (
|
||||
payload?.error?.details?.conflict
|
||||
|| payload?.details?.conflict
|
||||
|| null
|
||||
);
|
||||
|
||||
const conflictSourceLabel = (session) => {
|
||||
if (!session) return '';
|
||||
if (session.lastExternalWriteSource === 'mnote-hermes-tool') {
|
||||
const runId = String(session.lastExternalWriteRunId || '').trim();
|
||||
return runId ? `agent run ${runId}` : 'agent run';
|
||||
}
|
||||
return '本地文件变更';
|
||||
};
|
||||
|
||||
const conflictMessageFromEnvelope = (session, envelope) => {
|
||||
const baseMessage = envelope?.message || externalConflictMessage;
|
||||
const sourceLabel = conflictSourceLabel(session);
|
||||
return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage;
|
||||
};
|
||||
|
||||
const clearSessionConflictSurface = (session) => {
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
@@ -1633,7 +1654,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
currentBox.append(currentTitle, current);
|
||||
const diskBox = document.createElement('section');
|
||||
diskBox.append(diskTitle, disk);
|
||||
diffPanel.append(currentBox, diskBox);
|
||||
const mergeTitle = document.createElement('h3');
|
||||
mergeTitle.textContent = '合并结果';
|
||||
const mergeText = document.createElement('textarea');
|
||||
mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text');
|
||||
mergeText.value = sessionPlainText(session) || aggregatePlainText(latest) || '';
|
||||
const mergeActions = document.createElement('div');
|
||||
mergeActions.className = 'mnote-conflict-actions';
|
||||
const useCurrent = document.createElement('button');
|
||||
useCurrent.type = 'button';
|
||||
useCurrent.textContent = '使用当前版本';
|
||||
useCurrent.setAttribute('data-testid', 'mnote-conflict-merge-use-current');
|
||||
const useDisk = document.createElement('button');
|
||||
useDisk.type = 'button';
|
||||
useDisk.textContent = '使用磁盘版本';
|
||||
useDisk.setAttribute('data-testid', 'mnote-conflict-merge-use-disk');
|
||||
const saveMerge = document.createElement('button');
|
||||
saveMerge.type = 'button';
|
||||
saveMerge.textContent = '写回合并结果';
|
||||
saveMerge.setAttribute('data-testid', 'mnote-conflict-merge-save');
|
||||
mergeActions.append(useCurrent, useDisk, saveMerge);
|
||||
const mergeBox = document.createElement('section');
|
||||
mergeBox.className = 'mnote-conflict-merge-box';
|
||||
mergeBox.append(mergeTitle, mergeText, mergeActions);
|
||||
diffPanel.append(currentBox, diskBox, mergeBox);
|
||||
useCurrent.addEventListener('click', () => {
|
||||
mergeText.value = sessionPlainText(session) || '';
|
||||
});
|
||||
useDisk.addEventListener('click', () => {
|
||||
mergeText.value = aggregatePlainText(latest) || '';
|
||||
});
|
||||
saveMerge.addEventListener('click', () => {
|
||||
writeMergedConflictResult(session, panel, mergeText.value).catch((error) => {
|
||||
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
|
||||
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
loading.textContent = error instanceof Error ? error.message : String(error);
|
||||
diffPanel.replaceChildren(loading);
|
||||
@@ -1673,6 +1729,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
await persistSession(session);
|
||||
};
|
||||
|
||||
const writeMergedConflictResult = async (session, panel, mergedText) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
|
||||
if (nextKey) {
|
||||
session.conflictDetectionKey = nextKey;
|
||||
session.lastExternalConflictDetectionKey = nextKey;
|
||||
}
|
||||
session.currentTiptapDocument = textToTiptapDocument(String(mergedText || ''));
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.saving = false;
|
||||
session.dirty = true;
|
||||
if (panel && typeof panel.remove === 'function') panel.remove();
|
||||
await persistSession(session);
|
||||
};
|
||||
|
||||
const renderSessionConflictSurface = (session, message) => {
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
@@ -1690,7 +1764,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
text.textContent = message || externalConflictMessage;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'mnote-conflict-meta';
|
||||
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`;
|
||||
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'mnote-conflict-actions';
|
||||
const acceptDisk = document.createElement('button');
|
||||
@@ -1744,8 +1818,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
window.clearTimeout(session.saveTimer);
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
|
||||
renderSessionConflictSurface(session, message || externalConflictMessage);
|
||||
const nextMessage = message || externalConflictMessage;
|
||||
setSessionStatus(session, 'external-change-conflict', nextMessage);
|
||||
renderSessionConflictSurface(session, nextMessage);
|
||||
};
|
||||
|
||||
const queueSessionSave = (session) => {
|
||||
@@ -1801,6 +1876,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result || result.ok !== true) {
|
||||
const message = result?.error?.message || result?.message || `save_failed_${response.status}`;
|
||||
const conflictEnvelope = conflictEnvelopeFromResponse(result);
|
||||
if (response.status === 409 && conflictEnvelope && session.sourceKind === 'local_folder') {
|
||||
session.saving = false;
|
||||
session.lastExternalConflictEnvelope = conflictEnvelope;
|
||||
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const saved = result.result || {};
|
||||
@@ -1879,6 +1961,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const conflictEnvelope = conflictEnvelopeFromResponse(payload);
|
||||
if (response.status === 409 && conflictEnvelope) {
|
||||
session.lastExternalConflictEnvelope = conflictEnvelope;
|
||||
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, conflictEnvelope));
|
||||
return;
|
||||
}
|
||||
const nextAggregate = payload?.result;
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
@@ -2137,8 +2225,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (!sessionMatchesDocumentWorkspace(session, documentId, workspaceId)) return;
|
||||
session.lastExternalChangeSignalAt = Date.now();
|
||||
session.externalChangePending = true;
|
||||
session.lastExternalWriteSource = source || session.lastExternalWriteSource || '';
|
||||
session.lastExternalWriteRunId = String(detail?.runId || detail?.traceId || detail?.toolCallId || '').trim();
|
||||
session.lastExternalWriteDocumentId = documentId;
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
markSessionExternalConflict(session, treeExternalConflictMessage);
|
||||
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, session.lastExternalConflictEnvelope) || treeExternalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduled += 1;
|
||||
@@ -3676,6 +3767,9 @@ mod tests {
|
||||
assert!(html.contains("mnote-conflict-accept-disk"));
|
||||
assert!(html.contains("mnote-conflict-keep-current"));
|
||||
assert!(html.contains("mnote-conflict-open-diff"));
|
||||
assert!(html.contains("mnote-conflict-merge-text"));
|
||||
assert!(html.contains("mnote-conflict-merge-save"));
|
||||
assert!(html.contains("agent run"));
|
||||
assert!(!html.contains(
|
||||
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
||||
));
|
||||
|
||||
@@ -3806,6 +3806,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
sourceKind: currentSourceKind() || null,
|
||||
rootUri: currentRootUri() || null,
|
||||
documentId: currentDocumentId() || null,
|
||||
query: query,
|
||||
limit: 30,
|
||||
@@ -3831,10 +3833,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var title = searchText(item.title || item.name || item.documentTitle || '无标题');
|
||||
var snippet = searchText(item.snippet || (Array.isArray(item.evidence) && item.evidence[0] && item.evidence[0].snippet) || '');
|
||||
var path = searchText(item.path || item.parentTitle || item.workspaceName || currentWorkspaceName());
|
||||
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '">' +
|
||||
var resourceType = searchText(item.resourceType || item.resourceKind || 'page');
|
||||
return '<button type="button" class="wolai-search-result-row" data-testid="wolai-search-result-row" data-search-result-owner="rust-kernel" data-document-id="' + escapeHtml(item.documentId || item.nodeId || item.id || '') + '" data-resource-type="' + escapeHtml(resourceType) + '">' +
|
||||
'<span class="material-symbols-outlined wolai-search-result-icon" data-icon="article" aria-hidden="true"></span>' +
|
||||
'<span class="wolai-search-result-main"><span class="wolai-search-result-title">' + highlightSearchTitle(title, query) + '</span>' +
|
||||
'<span class="wolai-search-result-snippet">' + (snippet ? highlightedHtml(snippet) : escapeHtml(path)) + '</span></span>' +
|
||||
'<span class="wolai-search-result-snippet">' + (snippet ? highlightedHtml(snippet) : escapeHtml(path)) + '</span>' +
|
||||
'<span class="wolai-search-result-path"><span>' + escapeHtml(path) + '</span><span class="wolai-search-result-type">' + escapeHtml(resourceType) + '</span></span></span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
@@ -6407,6 +6411,27 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
traceId: runTraceId,
|
||||
auditId: String(agentAudit.eventId || '')
|
||||
});
|
||||
try {
|
||||
var currentId = currentDocumentId();
|
||||
var currentPath = String(currentId || '').replace(/^local-md:/, '').replace(/~2F/g, '/');
|
||||
var touchesCurrent = changedFiles.some(function(file) {
|
||||
var path = String(file && (file.documentId || file.path || file.filePath || '') || '');
|
||||
return path === currentId || (currentPath && path.indexOf(currentPath) >= 0);
|
||||
});
|
||||
if (touchesCurrent) {
|
||||
window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', {
|
||||
detail: {
|
||||
toolName: 'agent.changed_files',
|
||||
normalizedToolName: 'agent.changed_files',
|
||||
documentId: currentId,
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
runId: runId,
|
||||
traceId: runTraceId,
|
||||
toolCallId: runId + ':agent.changed_files'
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
pageAiSetRunStatus('completed', runId);
|
||||
|
||||
@@ -1190,6 +1190,9 @@ a:hover {
|
||||
.wolai-search-result-title{color:#2F2D29;font-size:14px;line-height:1.35;word-break:break-word}
|
||||
.wolai-search-result-title mark,.wolai-search-result-snippet mark{padding:0 1px;border-radius:2px;background:#FFE9E6;color:#D83A32}
|
||||
.wolai-search-result-snippet,.wolai-search-empty{color:#8B8780;font-size:12px;line-height:1.35}
|
||||
.wolai-search-result-path{display:flex;align-items:center;gap:8px;color:#A09B93;font-size:11px;line-height:1.3;min-width:0}
|
||||
.wolai-search-result-path span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wolai-search-result-type{flex:0 0 auto;padding:1px 5px;border-radius:4px;background:rgba(55,53,47,.06);color:#6F6A63;text-transform:uppercase;font-size:10px;letter-spacing:0}
|
||||
.wolai-search-empty,.wolai-search-recent{padding:28px 12px 32px;text-align:center;color:#8B8780;font-size:12px;line-height:1.35}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -2544,6 +2547,7 @@ body {
|
||||
}
|
||||
|
||||
.mnote-conflict-diff-panel pre,
|
||||
.mnote-conflict-merge-box textarea,
|
||||
.mnote-conflict-diff-status {
|
||||
min-height: 96px;
|
||||
max-height: 260px;
|
||||
@@ -2558,6 +2562,16 @@ body {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-conflict-merge-box {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.mnote-conflict-merge-box textarea {
|
||||
width: 100%;
|
||||
min-height: 132px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mnote-conflict-diff-panel {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user