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
189 changes: 164 additions & 25 deletions crates/app_kit/src/import/dialog.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
//! 「导入连接」弹窗:从全量产品一键迁移,或导入迁移包(文件 / 粘贴 JSON)。
//! 「导入连接」弹窗:从全量产品迁移,或导入迁移包(文件 / 粘贴 JSON)。
//!
//! 自动探测只是便利:全量产品可能装在便携目录、被换过 `--data-dir`、或干脆已
//! 卸载,因此两个页签都提供「选择文件…」原生选择器,输入框也允许手写路径
//! (`~` 与 `%VAR%` 会被展开)。

use std::path::PathBuf;

use gpui::{
App, AppContext as _, Context, Entity, FocusHandle, Focusable, IntoElement, Render,
SharedString, Styled, Window, div, prelude::*, px,
App, AppContext as _, Context, Entity, FocusHandle, Focusable, IntoElement, PathPromptOptions,
Render, SharedString, Styled, Window, div, prelude::*, px,
};
use gpui_component::WindowExt as _;
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
Expand All @@ -17,19 +21,24 @@ use gpui_component::{
};
use one_core::popup_window::{PopupWindowOptions, open_reusable_popup_window};

use super::{detect_full_product, import_bundle, import_from_full_product};
use super::{
detect_full_product, expand_source_path, import_bundle, import_from_database,
import_from_full_product, import_from_source_file, is_sqlite_database,
};

const DIALOG_WIDTH: f32 = 760.0;
const DIALOG_HEIGHT: f32 = 560.0;
const DIALOG_REUSE_KEY: &str = "app-kit-import";
const DB_PICKER_PROMPT: &str = "选择全量产品的 one-hub.db";
const BUNDLE_PICKER_PROMPT: &str = "选择迁移包文件(.json 或 one-hub.db)";

#[derive(Clone, Copy, PartialEq, Eq)]
enum ImportTab {
FullProduct,
Bundle,
}

/// 打开「导入连接」弹窗;重复打开会复用同一个窗口。
/// 打开「导入连接」弹窗;重复打开会复用同一个窗口(每次都会重新探测源库)。
pub fn open_import_dialog(window: &mut Window, cx: &mut App) {
open_reusable_popup_window(
PopupWindowOptions::new("导入连接").size(DIALOG_WIDTH, DIALOG_HEIGHT),
Expand All @@ -45,6 +54,9 @@ pub struct ImportDialog {
focus_handle: FocusHandle,
tab: ImportTab,
detected: Option<PathBuf>,
/// 全量产品数据库路径:探测成功时预填,也可以由用户选择或手写。
db_input: Entity<InputState>,
/// 迁移包文件路径(也接受直接选 `one-hub.db`,按文件头判定)。
path_input: Entity<InputState>,
json_input: Entity<TextareaState>,
status: Option<SharedString>,
Expand All @@ -54,9 +66,16 @@ pub struct ImportDialog {
impl ImportDialog {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let detected = detect_full_product();
let db_input = cx.new(|cx| {
let mut state = InputState::new(window, cx)
.placeholder("全量产品的 one-hub.db 路径,例如 ~/.config/navop/one-hub.db");
if let Some(path) = detected.as_ref() {
state.set_value(path.to_string_lossy().to_string(), window, cx);
}
state
});
let path_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("迁移包 JSON 文件路径,例如 ~/Desktop/navop-connections.json")
InputState::new(window, cx).placeholder("迁移包 JSON 路径,也可以直接选 one-hub.db")
});
let json_input = cx.new(|cx| {
TextareaState::new(window, cx)
Expand All @@ -67,12 +86,10 @@ impl ImportDialog {
focus_handle.focus(window, cx);
Self {
focus_handle,
tab: if detected.is_some() {
ImportTab::FullProduct
} else {
ImportTab::Bundle
},
// 始终落在「从全量产品迁移」页:即使没探测到,这一页现在也能选文件。
tab: ImportTab::FullProduct,
detected,
db_input,
path_input,
json_input,
status: None,
Expand All @@ -88,25 +105,53 @@ impl ImportDialog {
cx.notify();
}

/// 从全量产品数据库导入:优先用输入框(探测结果已预填),清空时重新探测。
fn import_full_product(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match import_from_full_product(cx) {
let typed = self.db_input.read(cx).value().trim().to_string();
if typed.is_empty() {
// 输入框被清空 = 想重新探测一次(探测结果是打开弹窗时抓的快照)。
return match import_from_full_product(cx) {
Ok(report) => self.finish("已从全量产品导入", &report, window, cx),
Err(error) => self.fail(error.to_string(), window, cx),
};
}
let path = expand_source_path(&typed);
if !is_sqlite_database(&path) {
self.fail(
format!(
"「{}」不是 SQLite 数据库;迁移包 JSON 请到「迁移包」页导入",
path.display()
),
window,
cx,
);
return;
}
match import_from_database(&path, cx) {
Ok(report) => self.finish("已从全量产品导入", &report, window, cx),
Err(error) => self.fail(error.to_string(), window, cx),
}
}

/// 从文件导入:SQLite 库走全量产品迁移,其它按迁移包 JSON 解析。
fn import_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let path = self.path_input.read(cx).value().trim().to_string();
if path.is_empty() {
self.fail("请先填写迁移包文件路径".to_string(), window, cx);
let typed = self.path_input.read(cx).value().trim().to_string();
if typed.is_empty() {
self.fail("请先选择或填写迁移包文件路径".to_string(), window, cx);
return;
}
let result = std::fs::read_to_string(&path)
.map_err(|error| format!("读取「{path}」失败:{error}"))
.and_then(|text| import_bundle(&text, cx).map_err(|error| error.to_string()));
match result {
Ok(report) => self.finish("已从迁移包导入", &report, window, cx),
Err(message) => self.fail(message, window, cx),
let path = expand_source_path(&typed);
let sqlite_source = is_sqlite_database(&path);
match import_from_source_file(&path, cx) {
Ok(report) => {
let prefix = if sqlite_source {
"已从全量产品数据库导入"
} else {
"已从迁移包导入"
};
self.finish(prefix, &report, window, cx);
}
Err(error) => self.fail(error.to_string(), window, cx),
}
}

Expand All @@ -122,6 +167,51 @@ impl ImportDialog {
}
}

/// 原生文件选择器;选中后把路径回填到对应输入框并提示识别结果。
fn choose_file(
input: Entity<InputState>,
prompt: &'static str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let target_window = window.window_handle();
let dialog = cx.entity().downgrade();
let future = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some(prompt.into()),
});
window
.spawn(cx, async move |cx| {
if let Ok(Ok(Some(paths))) = future.await
&& let Some(path) = paths.into_iter().next()
{
let value = path.to_string_lossy().to_string();
let note = if is_sqlite_database(&path) {
format!("已选择数据库:{value}")
} else {
format!("已选择文件:{value}")
};
let _ = cx.update(|_view, cx: &mut App| {
let _ = cx.update_window(target_window, |_, window, cx| {
input.update(cx, |state, cx| {
state.set_value(value.clone(), window, cx);
});
window.refresh();
});
let _ = dialog.update(cx, |this, cx| {
this.status = Some(SharedString::from(note));
this.status_ok = true;
cx.notify();
});
});
}
Ok::<(), anyhow::Error>(())
})
.detach();
}

fn finish(
&mut self,
prefix: &str,
Expand Down Expand Up @@ -165,9 +255,15 @@ impl ImportDialog {
}

fn render_full_product(&self, cx: &mut Context<Self>) -> impl IntoElement {
let hint = match &self.detected {
Some(path) => format!("已检测到全量产品数据库:{}", path.display()),
None => "未自动检测到全量产品数据库。若已卸载全量版,可在「迁移包」页选择其 one-hub.db 路径。".to_string(),
let (hint, muted_hint) = match &self.detected {
Some(path) => (
format!("已自动识别全量产品数据库:{}", path.display()),
"如果这不是你要迁移的库(比如换机、便携版或多套数据目录),点「选择文件…」重新指定。",
),
None => (
"未自动识别到全量产品数据库。".to_string(),
"点「选择文件…」定位全量产品的 one-hub.db:macOS/Linux 通常在 ~/.config/navop/one-hub.db,Windows 在 %APPDATA%\\navop\\one-hub.db。",
),
};
v_flex()
.gap_3()
Expand All @@ -177,6 +273,25 @@ impl ImportDialog {
.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(
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,
);
})),
),
)
.child(
Button::new("import-full-product")
.primary()
Expand All @@ -185,6 +300,12 @@ impl ImportDialog {
this.import_full_product(window, cx);
})),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(muted_hint),
)
.child(
div()
.text_xs()
Expand All @@ -202,6 +323,18 @@ impl ImportDialog {
.gap_2()
.items_center()
.child(div().flex_1().min_w_0().child(Input::new(&self.path_input)))
.child(
Button::new("import-choose-bundle-file")
.label("选择文件…")
.on_click(cx.listener(|this, _, window, cx| {
Self::choose_file(
this.path_input.clone(),
BUNDLE_PICKER_PROMPT,
window,
cx,
);
})),
)
.child(
Button::new("import-from-file")
.label("从文件导入")
Expand All @@ -210,6 +343,12 @@ impl ImportDialog {
})),
),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("支持迁移包 .json,也支持直接选择全量产品的 one-hub.db(按文件头自动识别)。"),
)
.child(div().mt_2().text_sm().child("或粘贴迁移包 JSON"))
.child(Textarea::new(&self.json_input).h(px(180.0)))
.child(
Expand Down
75 changes: 71 additions & 4 deletions crates/app_kit/src/import/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod kinds;
mod sqlite;

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::{Context as _, Result, anyhow, bail};
Expand All @@ -30,15 +31,81 @@ pub use self::bundle::{
BUNDLE_VERSION, BundleConnection, BundleWorkspace, ConnectionBundle, ImportReport,
};
pub use self::dialog::open_import_dialog;
pub use self::sqlite::{detect_full_product, full_product_candidates};
pub use self::sqlite::{
detect_full_product, full_product_candidates, is_sqlite_database, read_bundle_from_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 路径"))?;
import_parsed_bundle(self::sqlite::read_bundle_from_sqlite(&path)?, cx)
let path = detect_full_product().ok_or_else(|| {
anyhow!(
"未自动找到全量产品的 one-hub.db,请点「选择文件…」手动指定(常见位置:~/.config/navop/one-hub.db)"
)
})?;
import_from_database(&path, cx)
}

/// 从用户指定的数据库文件迁移(只读打开,不改动源库)。
pub fn import_from_database(path: &Path, cx: &mut App) -> Result<ImportReport> {
import_parsed_bundle(self::sqlite::read_bundle_from_sqlite(path)?, cx)
}

/// 从用户指定的源文件迁移:SQLite 库按全量产品库读取,其余按迁移包 JSON 读取。
///
/// 独立产品没有「全量产品目录」可探测时(换机、便携版、全量版已卸载),这是唯一
/// 的迁移入口,因此不能只看扩展名:`one-hub.db` 常被重命名或没有扩展名。
pub fn import_from_source_file(path: &Path, cx: &mut App) -> Result<ImportReport> {
if is_sqlite_database(path) {
return import_from_database(path, cx);
}
let text =
std::fs::read_to_string(path).with_context(|| format!("读取「{}」失败", path.display()))?;
import_bundle(&text, cx)
}

/// 展开用户手填/粘贴的路径:去空白与引号、去 `file://` 前缀、展开 `~` 与 `%VAR%`。
///
/// 弹窗会优先让用户点「选择文件…」,但输入框仍允许手写,而手写时常见的
/// `"~/Desktop/one-hub.db"`(从 Finder 复制/拖入带引号)与 Windows 的
/// `%USERPROFILE%\...` 必须能读通。
pub fn expand_source_path(input: &str) -> PathBuf {
let trimmed = input.trim().trim_matches(|ch| ch == '"' || ch == '\'');
let without_scheme = trimmed.strip_prefix("file://").unwrap_or(trimmed);
let expanded = expand_env_vars(without_scheme);
if expanded == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(expanded));
}
if let Some(rest) = expanded
.strip_prefix("~/")
.or_else(|| expanded.strip_prefix("~\\"))
&& let Some(home) = dirs::home_dir()
{
return home.join(rest);
}
PathBuf::from(expanded)
}

/// 展开 `%VAR%`(Windows 手写路径的常见写法);不认识的变量原样保留。
fn expand_env_vars(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find('%') {
let Some(end_offset) = rest[start + 1..].find('%') else {
break;
};
let end = start + 1 + end_offset;
let name = &rest[start + 1..end];
out.push_str(&rest[..start]);
match std::env::var(name) {
Ok(value) if !name.is_empty() => out.push_str(&value),
_ => out.push_str(&rest[start..=end]),
}
rest = &rest[end + 1..];
}
out.push_str(rest);
out
}

/// 迁移包里的 JSON 字符串必须是合法 JSON,否则说明包已被破坏。
Expand Down
Loading
Loading