细节调整及优化(26.8.3)

This commit is contained in:
zhongluofeng
2026-08-21 17:25:47 +08:00
parent 0a19b4b38a
commit 4dd60f42a1
72 changed files with 4779 additions and 1395 deletions
+84 -12
View File
@@ -1,6 +1,6 @@
//! Tauri 命令:快速面板模块
use tauri::{AppHandle, Manager};
use tauri::{AppHandle, Emitter, Manager};
use super::popup::{self, QuickPanelSettings};
use super::{file_index, app_scanner, icon_extractor};
@@ -22,14 +22,17 @@ pub async fn quickpanel_get_settings(app: AppHandle) -> Result<QuickPanelSetting
Ok(popup::load_settings(&app))
}
/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口
/// 保存快速面板设置;快捷键变化时自动重新注册 + 预创建窗口
/// 索引目录变化时闲时自动重建索引。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_save_settings(
settings: QuickPanelSettings,
app: AppHandle,
) -> Result<(), String> {
let prev_shortcut = popup::load_settings(&app).shortcut;
let prev = popup::load_settings(&app);
let prev_shortcut = prev.shortcut.clone();
let dirs_changed = prev.index_dirs != settings.index_dirs;
popup::save_settings(&app, &settings)?;
// 快捷键变化时重新注册(共享工具模块,原子化 + 冲突检测)
if settings.shortcut != prev_shortcut {
@@ -41,6 +44,10 @@ pub async fn quickpanel_save_settings(
popup::ensure_window(&app);
}
}
// 索引目录变更:闲时自动重建(新增/移除路径后无需手动点"构建索引")
if dirs_changed {
schedule_auto_build(app, true);
}
Ok(())
}
@@ -125,33 +132,98 @@ pub fn quickpanel_lock_screen() -> Result<(), String> {
Ok(())
}
/// 闲时自动建立索引的启动延迟(秒):避开应用启动/模块加载的 IO 高峰
const AUTO_BUILD_START_DELAY: u64 = 6;
/// 判定"系统空闲"的阈值(毫秒):用户停止输入超过该时长才执行构建
const AUTO_BUILD_IDLE_MS: u64 = 3000;
/// 等待系统空闲的最长轮询次数(每次间隔 2s,约 60s 上限,超时后不再等待直接构建)
const AUTO_BUILD_MAX_WAIT_ITERS: u32 = 30;
/// 解析要索引的目录:设置为空时用默认(桌面/文档/下载)
fn resolve_index_dirs(app: &AppHandle) -> Vec<String> {
let settings = popup::load_settings(app);
if settings.index_dirs.is_empty() {
popup::QuickPanelSettings::default().index_dirs
} else {
settings.index_dirs
}
}
/// 闲时自动建立/重建文件索引。
/// - `force=false`:仅首次(从未构建过)自动建立
/// - `force=true`:忽略是否已构建,直接重建(索引目录变更后调用)
///
/// 流程:先延迟避开启动 IO 高峰,再轮询等待系统空闲(用户停止输入),
/// 空闲后才开始构建,避免与应用运行/用户操作抢 IO 导致卡顿。
/// 构建完成后向前端广播 `quickpanel-index-updated` 事件(负载为条目数),
/// 供设置页刷新统计、弹窗启用文件搜索。
fn schedule_auto_build(app: AppHandle, force: bool) {
tauri::async_runtime::spawn(async move {
// 1. 启动延迟:避开应用启动、模块加载等 IO 高峰
tokio::time::sleep(std::time::Duration::from_secs(AUTO_BUILD_START_DELAY)).await;
// 2. 轮询等待系统空闲(判定阈值见 AUTO_BUILD_IDLE_MS
for _ in 0..AUTO_BUILD_MAX_WAIT_ITERS {
if crate::win32_util::get_idle_time_ms() >= AUTO_BUILD_IDLE_MS {
break;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// 3. 非强制模式:已构建过则跳过(幂等,避免每次启动重建)
if !force {
file_index::ensure_initialized(&app);
let stats = file_index::stats();
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
return;
}
}
// 4. 构建(build_index 内部有并发保护,重复调度/手动构建并发时幂等跳过)
let dirs = resolve_index_dirs(&app);
match tauri::async_runtime::spawn_blocking(move || file_index::build_index(&dirs)).await {
Ok(Ok(count)) => {
crate::logger::log_info(
"quickpanel",
&format!("闲时自动索引完成,共 {}", count),
);
let _ = app.emit(crate::constants::events::QUICKPANEL_INDEX_UPDATED, count);
}
_ => {
crate::logger::log_error("quickpanel", "闲时自动索引失败");
}
}
});
}
/// 初始化文件索引数据库(应用启动时调用)。
/// 若存在上次构建的索引(last_built_dirs 非空),自动恢复 notify 增量监听,
/// 无需重建即可继续自动同步文件变更。
/// 若从未构建过(首次运行),闲时自动建立索引,无需用户手动点"构建索引"。
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_init_file_index(app: AppHandle) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
file_index::init(&app);
let app_for_build = app.clone();
let need_auto_build = tauri::async_runtime::spawn_blocking(move || {
file_index::init(&app_for_build);
let stats = file_index::stats();
if stats.last_built_at > 0 && !stats.last_built_dirs.is_empty() {
file_index::start_watcher(&stats.last_built_dirs);
}
// 从未构建过索引 → 需要闲时自动建立
stats.last_built_at == 0 || stats.last_built_dirs.is_empty()
})
.await
.map_err(|e| format!("索引初始化任务失败: {}", e))
.map_err(|e| format!("索引初始化任务失败: {}", e))?;
if need_auto_build {
schedule_auto_build(app, false);
}
Ok(())
}
/// 构建文件索引(全量重建,阻塞操作建议在 spawn_blocking 调用)
#[tauri::command]
#[specta::specta]
pub async fn quickpanel_build_file_index(app: AppHandle) -> Result<i64, String> {
let settings = popup::load_settings(&app);
let dirs = if settings.index_dirs.is_empty() {
popup::QuickPanelSettings::default().index_dirs
} else {
settings.index_dirs
};
let dirs = resolve_index_dirs(&app);
// 懒加载:首次构建时自动初始化 DB 连接
file_index::ensure_initialized(&app);
// 阻塞操作放到 spawn_blocking
+28
View File
@@ -6,6 +6,7 @@
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::UNIX_EPOCH;
use rusqlite::{params, Connection};
@@ -118,10 +119,37 @@ where
None
}
/// 构建锁:防止手动构建与闲时自动构建并发执行(全量重建含 DELETE+INSERT
/// 两个构建交错会互相清空对方刚写入的数据,导致索引残缺)。
static BUILDING: AtomicBool = AtomicBool::new(false);
/// 尝试开始构建;已在构建中则返回 false。
pub fn try_begin_build() -> bool {
BUILDING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
}
/// 构建结束(成功/失败)后调用,释放构建锁。
pub fn end_build() {
BUILDING.store(false, Ordering::SeqCst);
}
/// 遍历指定目录列表建立索引(全量重建)。
/// 返回索引条目数。在 spawn_blocking 中调用。
/// 重建完成后自动启动 notify 监听器做增量更新。
/// 若已有构建正在进行(手动/自动并发),直接返回 Ok(0),由进行中的构建负责更新索引。
pub fn build_index(dirs: &[String]) -> Result<i64, String> {
if !try_begin_build() {
crate::logger::log_info("quickpanel", "索引构建已在进行中,跳过本次请求");
return Ok(0);
}
let result = build_index_inner(dirs);
end_build();
result
}
fn build_index_inner(dirs: &[String]) -> Result<i64, String> {
// 清空旧数据
let cleared = with_conn(|conn| {
conn.execute("DELETE FROM files", []).ok()
+64 -6
View File
@@ -13,6 +13,7 @@
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
@@ -57,6 +58,48 @@ static POPUP_PENDING_SHOW: AtomicBool = AtomicBool::new(false);
/// 避免窗口重建后仍停留在屏幕外 (-10000, -10000)。
static PENDING_POS: Mutex<Option<(f64, f64)>> = Mutex::new(None);
/// 失焦隐藏的宽限期:show 后窗口激活期间焦点可能短暂弹跳(透明 + focus:false 的
/// WebView2 窗口在透明激活时尤其容易出现),导致 Focused(false) 紧跟在 show 之后
/// 触发并把刚显示的窗口立即隐藏。距上次 show 不足该时长的失焦事件直接忽略。
const SHOW_GRACE: Duration = Duration::from_millis(500);
/// 最近一次 show 的时间,用于失焦宽限期判断。
static LAST_SHOWN: Mutex<Option<Instant>> = Mutex::new(None);
/// 标记"已发起显示",并记录时间供失焦宽限期使用。
fn mark_shown() {
if let Ok(mut t) = LAST_SHOWN.lock() {
*t = Some(Instant::now());
}
}
/// 判断距上次 show 是否仍在宽限期内(是则忽略失焦自动隐藏)。
fn within_show_grace() -> bool {
LAST_SHOWN
.lock()
.ok()
.and_then(|t| *t)
.map(|t| t.elapsed() < SHOW_GRACE)
.unwrap_or(false)
}
/// 显示窗口并确保其到达前台。
/// set_focus 受 Windows 前台锁定限制,可能静默失败;此时用 win32_util::force_foreground
/// (模拟 Alt 释放重置前台锁定 + SetForegroundWindow + BringWindowToTop)兜底。
fn show_and_focus(win: &tauri::WebviewWindow) {
if let Err(e) = win.show() {
crate::logger::log_error("quickpanel", &format!("show popup failed: {}", e));
}
mark_shown();
let focused = win.set_focus();
if focused.is_err() {
// set_focus 被前台锁定拒绝时,退回到强制置前(与主窗口焦点命令同法)
if let Ok(hwnd) = win.hwnd() {
crate::win32_util::force_foreground(hwnd.0 as isize);
}
}
}
/// 自定义命令
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -184,10 +227,14 @@ fn create_popup_window(app: &AppHandle) {
};
// 监听窗口失焦:自动隐藏
// 距上次 show 不足宽限期(激活中焦点弹跳)的失焦事件忽略,避免弹窗刚显示就被隐藏
let app_handle = app.clone();
let win_handle = win.clone();
win.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(false) = event {
if within_show_grace() {
return;
}
let _ = win_handle.hide();
let _ = app_handle.emit(crate::constants::events::QUICKPANEL_HIDE, ());
}
@@ -209,6 +256,7 @@ pub fn ensure_window(app: &AppHandle) {
/// popup_position = "cursor" 时在鼠标位置附近显示,否则在鼠标所在显示器中央显示。
/// 窗口不存在则创建(隐藏状态,等前端挂载后调用 show_window 显示)。
pub fn show_popup(app: &AppHandle) {
crate::logger::log_info("quickpanel", "show_popup triggered");
let settings = load_settings(app);
let cursor_mode = settings.popup_position == "cursor";
@@ -253,8 +301,7 @@ pub fn show_popup(app: &AppHandle) {
x: x as i32,
y: y as i32,
}));
let _ = win.show();
let _ = win.set_focus();
show_and_focus(&win);
return;
}
@@ -267,12 +314,24 @@ pub fn show_popup(app: &AppHandle) {
}
/// 显示已创建的弹窗窗口(由前端 onMounted 后调用)。
/// 预创建路径下前端 onMounted 也会调用此函数,但 POPUP_PENDING_SHOW 为 false 时直接跳过,
/// 避免应用启动时弹窗自动弹出。仅 show_popup 兜底创建路径才真正显示。
///
/// 两个路径:
/// 1. 预创建路径(POPUP_PENDING_SHOW = false):show_popup 已调用 show_and_focus 显示窗口,
/// 但若 Vue 尚未挂载,quickpanel-show 事件可能丢失。检查窗口是否可见,若可见则重新发送事件。
/// 2. 兜底创建路径(POPUP_PENDING_SHOW = true):窗口尚未显示,位置为 PENDING_POS
/// 先定位再发事件最后显示+聚焦。
pub fn show_window(app: &AppHandle) {
if !POPUP_PENDING_SHOW.swap(false, Ordering::SeqCst) {
// 预创建路径:窗口已由 show_popup 显示,但事件可能因前端未挂载而丢失。
// 窗口可见时重新发送事件,让刚注册的监听器处理(清空输入、聚焦、刷新等)。
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
if win.is_visible().unwrap_or(false) {
emit_show(app);
}
}
return;
}
// 兜底创建路径:show_popup 兜底重建,窗口尚未显示
if let Some(win) = app.get_webview_window(POPUP_LABEL) {
// 应用 show_popup 计算的兜底位置(物理坐标),避免停留在屏幕外
let pos = PENDING_POS.lock().ok().and_then(|p| *p);
@@ -284,8 +343,7 @@ pub fn show_window(app: &AppHandle) {
}
// 同样先检测 Explorer 目录再显示,避免面板抢焦点导致检测失败。
emit_show(app);
let _ = win.show();
let _ = win.set_focus();
show_and_focus(&win);
}
}