Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 63 additions & 31 deletions crates/app_kit/src/import/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ pub struct ImportDialog {
detected: Option<PathBuf>,
/// 全量产品数据库路径:探测成功时预填,也可以由用户选择或手写。
db_input: Entity<InputState>,
/// 用户点过「换一个文件…」(便携版/多套数据目录)后展开手动路径输入框。
manual_db: bool,
/// 迁移包文件路径(也接受直接选 `one-hub.db`,按文件头判定)。
path_input: Entity<InputState>,
json_input: Entity<TextareaState>,
Expand Down Expand Up @@ -90,6 +92,7 @@ impl ImportDialog {
tab: ImportTab::FullProduct,
detected,
db_input,
manual_db: false,
path_input,
json_input,
status: None,
Expand Down Expand Up @@ -168,9 +171,13 @@ impl ImportDialog {
}

/// 原生文件选择器;选中后把路径回填到对应输入框并提示识别结果。
///
/// `reveal_db_input` 为真时(全量产品源库的「换一个文件…」)顺带把手动路径
/// 输入框展开,让用户看到选中的是哪个文件。
fn choose_file(
input: Entity<InputState>,
prompt: &'static str,
reveal_db_input: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
Expand Down Expand Up @@ -201,6 +208,9 @@ impl ImportDialog {
window.refresh();
});
let _ = dialog.update(cx, |this, cx| {
if reveal_db_input {
this.manual_db = true;
}
this.status = Some(SharedString::from(note));
this.status_ok = true;
cx.notify();
Expand Down Expand Up @@ -255,43 +265,64 @@ impl ImportDialog {
}

fn render_full_product(&self, cx: &mut Context<Self>) -> impl IntoElement {
let (hint, muted_hint) = match &self.detected {
Some(path) => (
// 默认和全量产品自己一样:直接用自动探测到的库,不要求用户选路径。
// 只有便携版(数据在安装目录 data/config 下)或有多套数据目录时才手选。
let detected = self.detected.clone();
let manual = self.manual_db || detected.is_none();
let (hint, secondary) = match (&detected, self.manual_db) {
(Some(path), false) => (
format!("已自动识别全量产品数据库:{}", path.display()),
"如果这不是你要迁移的库(比如换机、便携版或多套数据目录),点「选择文件…」重新指定。",
"直接点下面的「从全量产品导入」即可。便携版(数据在安装目录 data/config 下)或有多套数据目录时,再用「换一个文件…」指定。".to_string(),
),
None => (
"未自动识别到全量产品数据库。".to_string(),
"点「选择文件…」定位全量产品的 one-hub.db:macOS/Linux 通常在 ~/.config/navop/one-hub.db,Windows 在 %APPDATA%\\navop\\one-hub.db。",
(_, true) => (
"已选好要迁移的源库。".to_string(),
"便携版请选全量产品安装目录下的 data/config/one-hub.db。".to_string(),
),
(None, false) => (
"未在标准位置找到全量产品的 one-hub.db。".to_string(),
"常规安装通常在这个位置:~/.config/navop/one-hub.db(Windows 为 %APPDATA%\\navop\\one-hub.db);便携版请点「选择文件…」选中安装目录下的 data/config/one-hub.db。".to_string(),
),
};
v_flex()
.gap_3()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(hint),
)
.child(div().text_sm().child("全量产品数据库路径"))
.child(
h_flex()
.gap_2()
.items_center()
.child(div().flex_1().min_w_0().child(Input::new(&self.db_input)))
.child(div().text_sm().child(hint))
.when(!manual, |el| {
el.child(
Button::new("import-choose-db-another")
.label("换一个文件…")
.on_click(cx.listener(|this, _, window, cx| {
Self::choose_file(
this.db_input.clone(),
DB_PICKER_PROMPT,
true,
window,
cx,
);
})),
)
})
.when(manual, |el| {
el.child(div().text_sm().child("全量产品数据库路径"))
.child(
Button::new("import-choose-db-file")
.label("选择文件…")
.on_click(cx.listener(|this, _, window, cx| {
Self::choose_file(
this.db_input.clone(),
DB_PICKER_PROMPT,
window,
cx,
);
})),
),
)
h_flex()
.gap_2()
.items_center()
.child(div().flex_1().min_w_0().child(Input::new(&self.db_input)))
.child(
Button::new("import-choose-db-file")
.label("选择文件…")
.on_click(cx.listener(|this, _, window, cx| {
Self::choose_file(
this.db_input.clone(),
DB_PICKER_PROMPT,
true,
window,
cx,
);
})),
),
)
})
.child(
Button::new("import-full-product")
.primary()
Expand All @@ -304,7 +335,7 @@ impl ImportDialog {
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(muted_hint),
.child(secondary),
)
.child(
div()
Expand All @@ -330,6 +361,7 @@ impl ImportDialog {
Self::choose_file(
this.path_input.clone(),
BUNDLE_PICKER_PROMPT,
false,
window,
cx,
);
Expand Down
4 changes: 2 additions & 2 deletions crates/app_kit/src/import/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ pub use self::sqlite::{

pub(crate) use self::kinds::supported_connection_types;

/// 从全量产品的数据库文件一键迁移(自动探测源库)。
/// 从全量产品的数据库文件一键迁移(按全量产品自己的目录规则自动探测源库)。
pub fn import_from_full_product(cx: &mut App) -> Result<ImportReport> {
let path = detect_full_product().ok_or_else(|| {
anyhow!(
"未自动找到全量产品的 one-hub.db,请点「选择文件…」手动指定(常见位置:~/.config/navop/one-hub.db)"
"未自动找到全量产品的 one-hub.db,请点「选择文件…」手动指定(常规安装位置:~/.config/navop/one-hub.db;便携版在安装目录 data/config/one-hub.db)"
)
})?;
import_from_database(&path, cx)
Expand Down
111 changes: 69 additions & 42 deletions crates/app_kit/src/import/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,57 +12,36 @@ use chrono::Utc;
use rusqlite::{Connection, OpenFlags};

use super::bundle::{BUNDLE_VERSION, BundleConnection, BundleWorkspace, ConnectionBundle};
/// 三个产品共用的数据库文件名(与全量产品 `get_db_path` 同源,仅测试断言用)。
#[cfg(test)]
use one_core::app_dirs::DATABASE_FILE_NAME as DB_FILE_NAME;

/// sqlite 文件头魔数,用来区分 `one-hub.db` 与迁移包 JSON。
const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
/// 三个产品共用的数据库文件名。
const DB_FILE_NAME: &str = "one-hub.db";
/// 全量产品的 app id,写入迁移包便于追溯来源。
const SOURCE_APP_ID: &str = "navop";
/// 全量产品的数据目录候选:正式目录、开发目录与历史目录。
const FULL_PRODUCT_DIR_NAMES: [&str; 3] = ["navop", ".navop-dev", "one-hub"];

/// 全量产品可能的数据目录下的数据库文件;按优先级排序并去重。
///
/// 目录规则统一由 [`one_core::app_dirs::full_product_database_candidates`] 提供,
/// 与全量产品自己的 `config_dir` 解析保持同一套名字(`navop` / 历史 `one-hub` /
/// 开发 `~/.navop-dev`):独立产品的目录名已被换成 `.navop-data-dev` 之类,不能
/// 按当前身份解析。
pub fn full_product_candidates() -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(dir) = dirs::config_dir() {
roots.push(dir);
}
if let Some(dir) = dirs::data_dir() {
roots.push(dir);
}
if let Some(home) = dirs::home_dir() {
roots.push(home.join(".config"));
roots.push(home.join(".local").join("share"));
roots.push(home);
}
let mut candidates = Vec::new();
for root in roots {
for name in FULL_PRODUCT_DIR_NAMES {
let dir = root.join(name);
for path in [
dir.join(DB_FILE_NAME),
dir.join("config").join(DB_FILE_NAME),
] {
if !candidates.contains(&path) {
candidates.push(path);
}
}
}
}
candidates
one_core::app_dirs::full_product_database_candidates()
}

/// 探测本机可用于迁移的全量产品数据库。
///
/// 优先返回真正带连接的那个库:全量产品目录里可能残留刚被独立产品初始化出来
/// 的空库(0 条连接),若只按「能读通」判断就会导入 0 条连接。因此先找有连接
/// 的候选,都为空时才退回第一个可读库。
/// 的空库(0 条连接),若只按「能读通」判断就会导入 0 条连接。多个库都非空时
/// 取最近修改的那个(用户最后在用的数据),都为空时才退回第一个可读库。
pub fn detect_full_product() -> Option<PathBuf> {
pick_candidate(full_product_candidates())
}

fn pick_candidate(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
let mut freshest: Option<(std::time::SystemTime, PathBuf)> = None;
let mut fallback = None;
for path in candidates {
if !is_readable_db(&path) {
Expand All @@ -71,12 +50,33 @@ fn pick_candidate(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathB
let Ok(bundle) = read_bundle_from_sqlite(&path) else {
continue;
};
if !bundle.connections.is_empty() {
return Some(path);
if bundle.connections.is_empty() {
fallback.get_or_insert(path);
continue;
}
let modified = database_modified(&path);
if freshest.as_ref().is_none_or(|(best, _)| modified > *best) {
freshest = Some((modified, path));
}
}
freshest.map(|(_, path)| path).or(fallback)
}

/// 库文件(含 WAL 旁文件)的最后修改时间。
///
/// WAL 模式下新写入可能只落在 `one-hub.db-wal`,主库文件的 mtime 会偏旧。
fn database_modified(path: &Path) -> std::time::SystemTime {
let mut newest = std::time::SystemTime::UNIX_EPOCH;
let mut wal = path.as_os_str().to_os_string();
wal.push("-wal");
for candidate in [path.to_path_buf(), PathBuf::from(wal)] {
if let Ok(modified) = std::fs::metadata(&candidate).and_then(|meta| meta.modified())
&& modified > newest
{
newest = modified;
}
fallback.get_or_insert(path);
}
fallback
newest
}

fn is_readable_db(path: &Path) -> bool {
Expand Down Expand Up @@ -268,11 +268,15 @@ mod tests {
assert!(candidates.iter().all(|path| path.ends_with(DB_FILE_NAME)));
let unique: std::collections::HashSet<_> = candidates.iter().collect();
assert_eq!(unique.len(), candidates.len());
assert!(candidates.iter().any(|path| {
path.parent()
.and_then(|dir| dir.file_name())
.is_some_and(|name| name == ".navop-dev")
}));
// 与全量产品的目录规则一致:现行 `navop`、开发 `.navop-dev`、历史 `one-hub`。
for name in ["navop", ".navop-dev", "one-hub"] {
assert!(
candidates
.iter()
.any(|path| path.components().any(|part| part.as_os_str() == name)),
"候选里应当包含 {name} 目录:{candidates:?}"
);
}
}

#[test]
Expand Down Expand Up @@ -368,6 +372,29 @@ mod tests {
assert_eq!(pick_candidate(Vec::new()), None);
}

#[test]
fn pick_candidate_prefers_the_most_recently_used_database() {
let older = TempDb::create(
"CREATE TABLE connections (id INTEGER PRIMARY KEY, name TEXT, params TEXT);
INSERT INTO connections (name, params) VALUES ('旧', '{}');",
);
std::thread::sleep(std::time::Duration::from_millis(20));
let newer = TempDb::create(
"CREATE TABLE connections (id INTEGER PRIMARY KEY, name TEXT, params TEXT);
INSERT INTO connections (name, params) VALUES ('新', '{}');",
);

assert_eq!(
pick_candidate(vec![older.0.clone(), newer.0.clone()]),
Some(newer.0.clone())
);
// 候选顺序颠倒也要选「最近在用」的那个,而不是列表里的第一个。
assert_eq!(
pick_candidate(vec![newer.0.clone(), older.0.clone()]),
Some(newer.0.clone())
);
}

#[test]
fn pick_candidate_skips_unreadable_files() {
let missing = std::env::temp_dir().join("navop-import-missing-candidate.db");
Expand Down
7 changes: 4 additions & 3 deletions crates/app_kit/src/settings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ fn general_page(cx: &App) -> SettingPage {

/// 迁移:从全量产品导入连接。
///
/// 只对独立产品展示:全量产品自己就是来源,没有可导入的对象。导入走同一份
/// 迁移包规划逻辑(判重、重命名、工作区匹配),导入后通过连接事件刷新列表。
/// 只对独立产品展示:全量产品自己就是来源,没有可导入的对象。默认按全量产品自己
/// 的目录规则自动找库,便携版才需要手动选文件。导入走同一份迁移包规划逻辑
/// (判重、重命名、工作区匹配),导入后通过连接事件刷新列表。
fn migration_group() -> Option<SettingGroup> {
let identity = crate::identity()?;
if identity.app_id == domain_api::AppIdentity::FULL.app_id {
Expand All @@ -95,7 +96,7 @@ fn migration_group() -> Option<SettingGroup> {
Some(SettingGroup::new().title("迁移").items([button_item(
"import-from-full-product",
"从全量产品导入连接",
"只读打开全量产品的 one-hub.db,也可以改用迁移包文件",
"默认自动找到全量产品的 one-hub.db;便携版可手动选择,也可以改用迁移包文件",
|window, cx| {
crate::import::open_import_dialog(window, cx);
},
Expand Down
Loading
Loading