diff --git a/TeXmacs/progs/doc/docgrep.scm b/TeXmacs/progs/doc/docgrep.scm
index 6c568b96ce..076e3186b8 100644
--- a/TeXmacs/progs/doc/docgrep.scm
+++ b/TeXmacs/progs/doc/docgrep.scm
@@ -247,3 +247,15 @@
(load-document (string-append "tmfs://grep/" query))
) ;with
) ;tm-define
+
+(tm-define (recent-documents-for-qml) (map url->system (recent-file-list 25)))
+
+(tm-define (open-search-recent-documents)
+ (:interactive #t)
+ (with path
+ (cpp-search-recent-documents-dialog)
+ (when (!= path "")
+ (load-document (system->url path))
+ ) ;when
+ ) ;with
+) ;tm-define
diff --git a/TeXmacs/progs/texmacs/menus/edit-menu.scm b/TeXmacs/progs/texmacs/menus/edit-menu.scm
index dd7314b5da..1490ba42fe 100644
--- a/TeXmacs/progs/texmacs/menus/edit-menu.scm
+++ b/TeXmacs/progs/texmacs/menus/edit-menu.scm
@@ -99,7 +99,7 @@
) ;->
) ;if
---
- ("Search recent documents" (interactive docgrep-in-recent))
+ ("Search recent documents" (open-search-recent-documents))
---
(if (use-menus?) (-> "Preferences" (link preferences-menu)))
(if (use-popups?) ("Preferences" (open-preferences)))
diff --git a/ai-docs/qml/qml-dialog.html b/ai-docs/qml/qml-dialog.html
index 23e93674d5..d62c619022 100644
--- a/ai-docs/qml/qml-dialog.html
+++ b/ai-docs/qml/qml-dialog.html
@@ -299,6 +299,76 @@
box-shadow: var(--shadow);
}
+ .recent-search-shell {
+ width: min(620px, 100%);
+ background: #ffffff;
+ border: 1px solid #d6dfdf;
+ border-radius: var(--radius);
+ padding: 22px;
+ box-shadow: var(--shadow);
+ }
+
+ .recent-search-shell h3 {
+ text-align: center;
+ font-size: 18px;
+ margin-bottom: 18px;
+ }
+
+ .recent-search-input {
+ width: 100%;
+ height: 38px;
+ border: 1px solid #9fcac7;
+ border-radius: 10px;
+ padding: 0 12px;
+ background: #f9fcfc;
+ color: #25353a;
+ font: inherit;
+ }
+
+ .recent-results {
+ list-style: none;
+ margin-top: 14px;
+ border: 1px solid #cad7d7;
+ border-radius: 10px;
+ overflow: hidden;
+ }
+
+ .recent-result {
+ padding: 10px 12px;
+ border-bottom: 1px solid #e4eeee;
+ cursor: pointer;
+ }
+
+ .recent-result:last-child {
+ border-bottom: 0;
+ }
+
+ .recent-result.active {
+ background: #dff3f1;
+ color: #194f53;
+ }
+
+ .recent-result strong,
+ .recent-result span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .recent-result span {
+ margin-top: 3px;
+ color: #60787e;
+ font-size: 12px;
+ }
+
+ .recent-empty {
+ display: none;
+ padding: 18px;
+ color: var(--muted);
+ text-align: center;
+ }
+
.field {
display: grid;
grid-template-columns: 120px 1fr;
@@ -994,6 +1064,7 @@
QML 组件模拟器
+
@@ -1596,6 +1667,36 @@ 切换界面语言
+
@@ -1611,7 +1712,8 @@ 切换界面语言
statistics: document.getElementById('panel-statistics'),
version: document.getElementById('panel-version'),
preferences: document.getElementById('panel-preferences'),
- confirmRestart: document.getElementById('panel-confirm-restart')
+ confirmRestart: document.getElementById('panel-confirm-restart'),
+ recentSearch: document.getElementById('panel-recent-search')
};
const titleMap = {
@@ -1622,7 +1724,8 @@ 切换界面语言
paragraph: 'ParagraphFormat.qml 模拟',
statistics: 'Statistics.qml 模拟',
preferences: 'Preferences.qml 模拟',
- confirmRestart: 'ConfirmRestart.qml 模拟'
+ confirmRestart: 'ConfirmRestart.qml 模拟',
+ recentSearch: 'SearchRecentDocuments.qml 模拟'
};
const codeMap = {
@@ -1633,7 +1736,8 @@ 切换界面语言
paragraph: 'EnumCombo x N + tabs(基础/高级) + make-multi-line-with 写回',
statistics: 'statsModel: [{label, value}, ...] + closeBridge.choose(0) 关闭',
preferences: 'prefTabs(5) + subTabs(6) + EnumCombo + Toggle + 纯 OK 提交',
- confirmRestart: 'message + buttonLabels + closeBridge.choose(index)'
+ confirmRestart: 'message + buttonLabels + closeBridge.choose(index)',
+ recentSearch: 'recentDocuments + fuzzyScore() + recentSearchBridge.open(path)'
};
const viewTitle = document.getElementById('view-title');
@@ -1668,6 +1772,46 @@ 切换界面语言
});
});
+ const recentSearchInput = document.getElementById('recent-search-input');
+ const recentResults = document.querySelectorAll('.recent-result');
+ const recentEmpty = document.getElementById('recent-empty');
+ let recentSelected = null;
+
+ function fuzzyMatch(text, query) {
+ let cursor = 0;
+ const haystack = text.toLowerCase();
+ for (const character of query.toLowerCase()) {
+ cursor = haystack.indexOf(character, cursor);
+ if (cursor < 0) return false;
+ cursor += 1;
+ }
+ return true;
+ }
+
+ recentSearchInput.addEventListener('input', () => {
+ const query = recentSearchInput.value.trim();
+ let visibleCount = 0;
+ recentResults.forEach((row) => {
+ const matched = !query || fuzzyMatch(row.dataset.name, query) || fuzzyMatch(row.dataset.path, query);
+ row.style.display = matched ? '' : 'none';
+ if (matched) visibleCount += 1;
+ });
+ recentEmpty.style.display = visibleCount ? 'none' : 'block';
+ recentSelected = null;
+ recentResults.forEach((row) => row.classList.remove('active'));
+ });
+
+ recentResults.forEach((row) => {
+ row.addEventListener('click', () => {
+ recentSelected = row;
+ recentResults.forEach((item) => item.classList.toggle('active', item === row));
+ });
+ });
+
+ document.getElementById('recent-open').addEventListener('click', () => {
+ if (recentSelected) log('SearchRecentDocuments: open("' + recentSelected.dataset.path + '")');
+ });
+
document.getElementById('form-submit').addEventListener('click', () => {
const values = {
language: document.getElementById('field-language').value,
diff --git a/devel/2085.md b/devel/2085.md
new file mode 100644
index 0000000000..60a9465607
--- /dev/null
+++ b/devel/2085.md
@@ -0,0 +1,79 @@
+# [2085] 编辑菜单中的最近文档搜索
+
+## 相关文档
+- [Issue #4143](https://github.com/MoganLab/mogan/issues/4143) - 采用 QML 重构并修复最近文档搜索。
+
+## 2026/08/04 对齐 QML bridge 约定
+
+### What
+- 最近文档 bridge 负责读取 Scheme 数据、Cork 到 UTF-8 转码、规范化去重和已翻译文案。
+- QML 仅维护模糊筛选与显式选择状态,支持上下键选择和 Enter 打开。
+- 按钮文案改由通用 `dialogButtons` context property 注入。
+
+### Why
+此前 bridge 只承载已在对话框入口解析的数据,导致编码与去重规则分散;设计稿仍默认选中首项,也与实际交互不一致。
+
+### How
+- Windows 下对规范化路径做大小写折叠后去重,保留最近记录中的首次出现。
+- 筛选后始终清空选择,只有点击或键盘移动后才可打开候选项。
+- 设计稿、QML 加载测试和开发文档同步验证 bridge/context 契约。
+
+## 任务相关的代码文件
+- `TeXmacs/progs/doc/docgrep.scm`
+- `TeXmacs/progs/texmacs/menus/edit-menu.scm`
+- `src/Plugins/Qt/QTMQmlDialog.cpp`
+- `src/Plugins/Qt/RecentDocumentsSearchBridge.hpp`
+- `src/Plugins/Qt/RecentDocumentsSearchBridge.cpp`
+- `src/Plugins/Qt/qml/SearchRecentDocuments.qml`
+- `tests/Plugins/Qt/qml_dialog_test.cpp`
+- `tests/Plugins/Qt/qml_load_test.cpp`
+- `ai-docs/qml/qml-dialog.html`
+
+## 如何测试
+```powershell
+xmake build stem
+xmake install stem
+& '.\build\packages\stem\data\bin\MoganSTEM.exe'
+```
+
+在“编辑 -> 搜索最近打开的文档”中输入文件名的一部分,确认结果按模糊匹配显示。选中条目并点击“打开”,确认文件被加载。
+
+## 2026/08/04 Bridge 收敛与路径编码
+
+### What
+- 删除冗余的 `RecentDocumentsSearchDialog.cpp`,将对话框 glue 入口收回
+ `QTMQmlDialog.cpp`。
+- 将最近文档 bridge 收敛为独立 `QObject`:它提供只读模型并完成确认回流;通用
+ `QmlDialogBridge` 只处理取消、Esc 与窗口拖动。
+- 最近文档读取和返回均使用 UTF-8 系统路径,保留中文文件名。
+
+### Why
+专属业务动作不应继承通用 form bridge;同时 `system->url` 只接受 UTF-8 系统路径,
+将路径转换为 Cork 会导致中文路径在回传后无法打开。
+
+### How
+- `url->system` 的 Scheme 返回值经 `QString::fromUtf8` 进入 QML;QML 确认后通过
+ `from_qstring_utf8` 回传给 `system->url`。
+- QML 继续使用 `DialogShell`、`Theme` 和独立的 `recentSearchBridge`,不在筛选中
+ 修改最近记录。
+
+## 2026/08/03 采用 QML 重构最近文档搜索
+
+### What
+- 用 QML 对话框替换旧的交互式全文 grep 输入。
+- 显示最近文档的文件名和路径,并支持实时模糊筛选与显式选择。
+- 对最近文件路径去重;初始和筛选后均不默认选中,避免重复项同时高亮或误打开。
+- 补充 QML 加载与桥接测试。
+- 在 `ai-docs/qml/qml-dialog.html` 中补充该对话框的交互设计稿。
+
+### Why
+旧交互式输入在中文输入法确认候选字时会提前执行,且其全文搜索语义不符合按最近文件选择文档的预期。
+
+### How
+- Scheme 侧提供最近文件列表,并在确认选择后加载对应路径。
+- C++ 将最近文件模型注入 QML,保持 UTF-8 路径编码。
+- C++ 以规范化路径去重,保留最近记录中的第一次出现。
+- QML 按文件名优先、完整路径兜底执行子序列模糊匹配;只有显式选中并点击“打开”才加载选定文件。
+- 最近文档搜索的路径和模型逻辑独立于 QML 通用对话框底座,复用其内部模态引擎和 context 注入接口。
+- 专属 bridge 向 QML 暴露候选模型和已翻译文案,并在确认时回传所选路径;通用
+ `QmlDialogBridge` 仅负责取消、Esc 和窗口关闭。
diff --git a/src/Plugins/Qt/QTMQmlDialog.cpp b/src/Plugins/Qt/QTMQmlDialog.cpp
index fb9a195666..fcd777b583 100644
--- a/src/Plugins/Qt/QTMQmlDialog.cpp
+++ b/src/Plugins/Qt/QTMQmlDialog.cpp
@@ -13,6 +13,7 @@
#include "PreferencesBridge.hpp"
#include "QTMQmlDialogBridge.hpp"
#include "QTMQmlDialogInternal.hpp"
+#include "RecentDocumentsSearchBridge.hpp"
#include "VersionDialogBridge.hpp"
#include "analyze.hpp" // occurs
@@ -156,7 +157,7 @@ lock_fixed_size (QQuickWidget* qw, QVBoxLayout* vl, QDialog& d, int logic_w,
* QML 加载失败返回 -1。调用方据此映射结果(确认型 → 按钮下标;form 型
* → Accepted/Rejected,表单值另行从 bridge->results() 取)。
*/
-static int
+int
run_qml_dialog (const string& qml_url, const char* debug_tag,
std::function inject_context,
int logic_w, int logic_h) {
@@ -580,8 +581,36 @@ cpp_version_dialog (string title, string message) {
return choice == 1;
}
+string
+cpp_search_recent_documents_dialog () {
+ string preset= get_env ("MOGAN_TEST_SEARCH_RECENT_DOCUMENTS");
+ if (preset != "") return preset == "cancel" ? string ("") : preset;
+
+ array buttons= {string ("Open"), string ("Cancel")};
+ QmlDialogBridge* closeBridge = nullptr;
+ RecentDocumentsSearchBridge* searchBridge= nullptr;
+ run_qml_dialog (
+ "qrc:/qml/SearchRecentDocuments.qml", "SearchRecentDocuments.qml",
+ [&] (QQuickWidget* qw, QDialog& host) {
+ closeBridge = inject_common_context (qw, host);
+ searchBridge= new RecentDocumentsSearchBridge (&host);
+ qw->rootContext ()->setContextProperty ("recentSearchBridge",
+ searchBridge);
+ qw->rootContext ()->setContextProperty ("dialogButtons",
+ translate_buttons (buttons));
+ },
+ 520, 450);
+
+ QString path;
+ if (searchBridge) path= searchBridge->selectedPath ();
+ delete closeBridge;
+ delete searchBridge;
+ return from_qstring_utf8 (path);
+}
+
/**
- * @brief 首选项 QML 对话框的 glue 入口(声明/语义见 QTMQmlDialog.hpp)。
+ * @brief 首选项 QML 对话框的 glue 入口(声明/语义见
+ * QTMQmlDialog.hpp)。
*
* @details 走 run_qml_dialog(exec 阻塞模态,同
* FormDialog——首选项是一次性提交, 无需 live
diff --git a/src/Plugins/Qt/QTMQmlDialog.hpp b/src/Plugins/Qt/QTMQmlDialog.hpp
index 24bf9273ab..f0d4ced372 100644
--- a/src/Plugins/Qt/QTMQmlDialog.hpp
+++ b/src/Plugins/Qt/QTMQmlDialog.hpp
@@ -214,4 +214,9 @@ bool cpp_version_dialog (string title, string message);
*/
tree cpp_preferences_dialog ();
+/**
+ * @brief 打开最近文档搜索 QML 对话框。
+ */
+string cpp_search_recent_documents_dialog ();
+
#endif // defined QTM_QML_DIALOG_H
diff --git a/src/Plugins/Qt/QTMQmlDialogInternal.hpp b/src/Plugins/Qt/QTMQmlDialogInternal.hpp
index 4c4fb5b16d..5cfaec75e9 100644
--- a/src/Plugins/Qt/QTMQmlDialogInternal.hpp
+++ b/src/Plugins/Qt/QTMQmlDialogInternal.hpp
@@ -10,10 +10,17 @@
#ifndef QTM_QML_DIALOG_INTERNAL_H
#define QTM_QML_DIALOG_INTERNAL_H
+#include "array.hpp"
+#include "string.hpp"
#include "tree.hpp"
#include
#include
+#include
+
+class QDialog;
+class QQuickWidget;
+class QmlDialogBridge;
// 字段节点下标协议(见 QTMQmlDialog.hpp @par 数据协议):
// (