refactor(profiles): shared project entity with per-scope switching

Projects are now global shared entities; Claude and Codex groups switch
independently via scoped current pointers and scoped payload slots.

- Remove scope column from profiles; keep current_profile_id_<scope>
- Use Option<Vec<String>> for mcp/skills to distinguish 'never captured'
  from 'captured empty', preventing cross-side accidental disable
- Update/apply operations scoped to the active group via merge_scope_from
- Tray menu nests same shared list under Claude Code/Codex groups
- Add i18n for per-scope tooltips and 'not saved for this side' hint

Refs: profile P1 shared-entity redesign
This commit is contained in:
Jason
2026-07-04 21:34:30 +08:00
parent 65a5464fcc
commit dbb5999d1e
17 changed files with 831 additions and 190 deletions
+85 -21
View File
@@ -338,34 +338,54 @@ fn sort_providers(
/// 处理项目 Profile 托盘事件,返回是否已处理
///
/// 事件 id 形如 `profile_<uuid>``profile_none` 表示"不使用项目"(只清标记,不动配置)。
/// 事件 id 形如 `profile_<scope>_<uuid>`(同一项目在各分组子菜单里各有一项,
/// 应用时只作用于该分组);`profile_none_<scope>` 表示某分组"不使用项目"
/// (只清该分组标记,不动配置)。
pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool {
let Some(suffix) = event_id.strip_prefix("profile_") else {
return false;
};
if suffix == "none" {
if let Some(scope_str) = suffix.strip_prefix("none_") {
let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else {
log::error!("未知的项目分组托盘事件: {event_id}");
return true;
};
if let Some(app_state) = app.try_state::<AppState>() {
if let Err(e) = app_state.db.set_current_profile_id(None) {
if let Err(e) = app_state.db.set_current_profile_id(scope.as_str(), None) {
log::error!("清除当前项目失败: {e}");
}
}
// 通知主窗口刷新(profileId=null 表示已清除当前项目)
if let Err(e) = app.emit("profile-applied", serde_json::json!({ "profileId": null })) {
// 通知主窗口刷新(profileId=null 表示该分组已清除当前项目)
if let Err(e) = app.emit(
"profile-applied",
serde_json::json!({ "profileId": null, "scope": scope.as_str() }),
) {
log::error!("发射 profile-applied 事件失败: {e}");
}
refresh_tray_menu(app);
return true;
}
log::info!("应用项目: {suffix}");
// scope 是固定枚举字符串(不含下划线),uuid 只含连字符,首个下划线即分界
let Some((scope_str, profile_id)) = suffix.split_once('_') else {
log::error!("无法解析项目托盘事件: {event_id}");
return true;
};
let Ok(scope) = crate::services::profile::ProfileScope::parse(scope_str) else {
log::error!("未知的项目分组托盘事件: {event_id}");
return true;
};
log::info!("应用项目: {profile_id}{scope_str} 组)");
let app_handle = app.clone();
let profile_id = suffix.to_string();
let profile_id = profile_id.to_string();
tauri::async_runtime::spawn_blocking(move || {
let Some(app_state) = app_handle.try_state::<AppState>() else {
return;
};
match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id) {
match crate::services::profile::ProfileService::apply(app_state.inner(), &profile_id, scope)
{
Ok(warnings) => {
for warning in &warnings {
log::warn!("[Profile] 应用项目 {profile_id} 警告: {warning}");
@@ -374,6 +394,7 @@ pub fn handle_profile_tray_event(app: &tauri::AppHandle, event_id: &str) -> bool
&app_handle,
app_state.inner(),
&profile_id,
scope,
);
}
Err(e) => {
@@ -664,40 +685,83 @@ pub fn create_tray_menu(
menu_builder = menu_builder.separator();
}
// 项目 Profile 子菜单(受支持应用至少一个可见且存在项目时才显示)
if crate::services::profile::PROFILE_APPS
.iter()
.any(|app_type| visible_apps.is_visible(app_type))
// 项目 Profile 子菜单:项目列表全应用共享,按分组嵌套子菜单各自勾选/应用
// (组内应用可见且存在项目时才显示该组)
{
let profiles = app_state.db.get_all_profiles()?;
if !profiles.is_empty() {
let current_profile_id = app_state.db.get_current_profile_id()?.unwrap_or_default();
let mut profiles_builder =
SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label);
use crate::services::profile::ProfileScope;
let any_scope_visible = ProfileScope::ALL.iter().any(|scope| {
scope
.apps()
.iter()
.any(|app_type| visible_apps.is_visible(app_type))
});
let profiles = if any_scope_visible {
app_state.db.get_all_profiles()?
} else {
Vec::new()
};
let mut scope_submenus = Vec::new();
for scope in ProfileScope::ALL {
if profiles.is_empty()
|| !scope
.apps()
.iter()
.any(|app_type| visible_apps.is_visible(app_type))
{
continue;
}
let current_profile_id = app_state
.db
.get_current_profile_id(scope.as_str())?
.unwrap_or_default();
// 分组标签用产品名,不进 i18n
let scope_label = match scope {
ProfileScope::Claude => "Claude Code",
ProfileScope::Codex => "Codex",
};
let mut scope_builder = SubmenuBuilder::with_id(
app,
format!("submenu_profiles_{}", scope.as_str()),
scope_label,
);
for profile in &profiles {
let item = CheckMenuItem::with_id(
app,
format!("profile_{}", profile.id),
format!("profile_{}_{}", scope.as_str(), profile.id),
&profile.name,
true,
current_profile_id == profile.id,
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建项目菜单项失败: {e}")))?;
profiles_builder = profiles_builder.item(&item);
scope_builder = scope_builder.item(&item);
}
let none_item = CheckMenuItem::with_id(
app,
"profile_none",
format!("profile_none_{}", scope.as_str()),
tray_texts.no_project_label,
true,
current_profile_id.is_empty(),
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建不使用项目菜单项失败: {e}")))?;
let profiles_submenu = profiles_builder
let scope_submenu = scope_builder
.separator()
.item(&none_item)
.build()
.map_err(|e| AppError::Message(format!("构建项目分组子菜单失败: {e}")))?;
scope_submenus.push(scope_submenu);
}
if !scope_submenus.is_empty() {
let mut profiles_builder =
SubmenuBuilder::with_id(app, "submenu_profiles", tray_texts.projects_label);
for scope_submenu in &scope_submenus {
profiles_builder = profiles_builder.item(scope_submenu);
}
let profiles_submenu = profiles_builder
.build()
.map_err(|e| AppError::Message(format!("构建项目子菜单失败: {e}")))?;
menu_builder = menu_builder.item(&profiles_submenu).separator();