From 47e5b0f59cefb1b81978132b1e1b59da50d98040 Mon Sep 17 00:00:00 2001 From: ppxb <317842449@qq.com> Date: Thu, 5 Nov 2026 20:25:32 +0800 Subject: [PATCH] feat(windows): open recent projects from the Windows taskbar jump list (#1499) * feat(windows): open recent projects from the Windows taskbar jump list * fix(windows): gate Jump List helpers for non-Windows builds --- Cargo.lock | 1 + src-tauri/Cargo.toml | 10 ++ src-tauri/src/commands/app.rs | 5 +- src-tauri/src/jump_list.rs | 170 ++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 4 + src-tauri/src/state.rs | 1 + src-tauri/src/windows.rs | 30 +++++- 7 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/jump_list.rs diff --git a/Cargo.lock b/Cargo.lock index a0dc26553..5a3e48cb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6379,6 +6379,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "windows", "windows-sys 0.61.2", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 418eef4b4..0e78c99f0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -93,6 +93,16 @@ objc2-web-kit = { version = "0.3", default-features = false, features = ["std", gtk = "0.18" [target.'cfg(windows)'.dependencies] +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Storage_EnhancedStorage", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Variant", + "Win32_UI_Shell", + "Win32_UI_Shell_Common", + "Win32_UI_Shell_PropertiesSystem", +] } windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Graphics_Gdi", diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 64b253496..afed18d58 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -46,7 +46,9 @@ pub fn recent_projects(state: State<'_, AppState>) -> Vec { #[tauri::command] #[specta::specta] pub fn remove_recent_project(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { - state.recent.remove(&path) + state.recent.remove(&path)?; + crate::jump_list::refresh(&state.app, state.recent.list()); + Ok(()) } /// The welcome screen's project groups. @@ -125,6 +127,7 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) }; project.settings.set(SettingsScope::Project, "general.projectName", value)?; state.recent.rename(&project.root, (!name.is_empty()).then_some(name))?; + crate::jump_list::refresh(&state.app, state.recent.list()); Ok(project_info(&project)) } diff --git a/src-tauri/src/jump_list.rs b/src-tauri/src/jump_list.rs new file mode 100644 index 000000000..446ba3287 --- /dev/null +++ b/src-tauri/src/jump_list.rs @@ -0,0 +1,170 @@ +//! Windows taskbar Jump List: the recent projects of the running window. +//! +//! The list is built while OpenIDEA runs, so its taskbar button offers the same +//! recent projects as Welcome / File › Open Recent. `set_app_id` must run before +//! the first window is created so the taskbar groups every launch under one +//! AppUserModelID. + +use ide_workspace::RecentProject; + +/// Windows shows only a handful of custom destinations; keep the taskbar menu short. +#[cfg(any(windows, test))] +const MAX_ITEMS: usize = 10; +#[cfg(windows)] +const CATEGORY: &str = "Recent Projects"; +/// Marks a second-instance launch from a Jump List item, so it opens in the existing window. +pub const OPEN_RECENT_ARG: &str = "--open-recent"; + +pub fn set_app_id(app_id: &str) { + #[cfg(windows)] + imp::set_app_id(app_id); + #[cfg(not(windows))] + let _ = app_id; +} + +/// Rebuilds the taskbar Jump List in the background; `items` is newest first. +pub fn refresh(app: &tauri::AppHandle, items: Vec) { + #[cfg(windows)] + imp::refresh(app, items); + #[cfg(not(windows))] + let _ = (app, items); +} + +/// The entries a Jump List shows: newest first, capped at [`MAX_ITEMS`]. +#[cfg(any(windows, test))] +pub(crate) fn visible_items(items: &[RecentProject]) -> Vec<(&str, &std::path::Path)> { + items.iter().take(MAX_ITEMS).map(|item| (item.name.as_str(), item.path.0.as_path())).collect() +} + +#[cfg(windows)] +mod imp { + use super::*; + use std::sync::Mutex; + use windows::Win32::Storage::EnhancedStorage::{PKEY_AppUserModel_ID, PKEY_Title}; + use windows::Win32::System::Com::StructuredStorage::{PROPVARIANT, PropVariantClear}; + use windows::Win32::System::Com::{ + CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, + }; + use windows::Win32::UI::Shell::Common::{IObjectArray, IObjectCollection}; + use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore; + use windows::Win32::UI::Shell::{ + DestinationList, EnumerableObjectCollection, ICustomDestinationList, IShellLinkW, + SetCurrentProcessExplicitAppUserModelID, ShellLink, + }; + use windows::core::{HSTRING, Interface}; + + /// Shell COM is not thread-safe here; serialize updates from simultaneous project opens. + static UPDATE_LOCK: Mutex<()> = Mutex::new(()); + + pub fn set_app_id(app_id: &str) { + if let Err(error) = unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(app_id)) } { + tracing::warn!(%error, "cannot set the taskbar AppUserModelID"); + } + } + + pub fn refresh(app: &tauri::AppHandle, items: Vec) { + let app_id = app.config().identifier.clone(); + let worker = std::thread::Builder::new().name("openidea-jump-list".into()).spawn(move || { + let _guard = UPDATE_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let com = ComGuard::new(); + if let Err(error) = update(&app_id, &items) { + tracing::warn!(%error, "cannot update the taskbar Jump List"); + } + drop(com); + }); + if let Err(error) = worker { + tracing::warn!(%error, "cannot start the taskbar Jump List worker"); + } + } + + struct ComGuard { + uninitialize: bool, + } + + impl ComGuard { + fn new() -> Self { + let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + let uninitialize = result.0 >= 0; + if !uninitialize { + tracing::debug!(%result, "COM already initialized for the taskbar Jump List"); + } + Self { uninitialize } + } + } + + impl Drop for ComGuard { + fn drop(&mut self) { + if self.uninitialize { + unsafe { CoUninitialize() }; + } + } + } + + fn update(app_id: &str, items: &[RecentProject]) -> windows::core::Result<()> { + let app_id_hstring = HSTRING::from(app_id); + let visible = visible_items(items); + unsafe { + let list: ICustomDestinationList = CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)?; + list.SetAppID(&app_id_hstring)?; + if visible.is_empty() { + let _ = list.DeleteList(&app_id_hstring); + return Ok(()); + } + let mut min_slots = 0u32; + let _removed: IObjectArray = list.BeginList(&mut min_slots)?; + let collection: IObjectCollection = + CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?; + let exe = HSTRING::from(std::env::current_exe()?.to_string_lossy().as_ref()); + for (name, path) in visible { + let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + link.SetPath(&exe)?; + let mut arguments = String::with_capacity(path.as_os_str().len() + OPEN_RECENT_ARG.len() + 3); + arguments.push_str(OPEN_RECENT_ARG); + arguments.push(' '); + arguments.push('"'); + arguments.push_str(&path.to_string_lossy()); + arguments.push('"'); + link.SetArguments(&HSTRING::from(arguments.as_str()))?; + link.SetDescription(&HSTRING::from(path.to_string_lossy().as_ref()))?; + if let Some(parent) = path.parent() { + link.SetWorkingDirectory(&HSTRING::from(parent.to_string_lossy().as_ref()))?; + } + link.SetIconLocation(&exe, 0)?; + let properties: IPropertyStore = link.cast()?; + let mut title = PROPVARIANT::from(name); + let mut app_id_value = PROPVARIANT::from(app_id); + let title_result = properties.SetValue(&PKEY_Title, &title); + let app_id_result = properties.SetValue(&PKEY_AppUserModel_ID, &app_id_value); + let _ = PropVariantClear(&mut title); + let _ = PropVariantClear(&mut app_id_value); + title_result?; + app_id_result?; + properties.Commit()?; + collection.AddObject(&link)?; + } + let destinations: IObjectArray = collection.cast()?; + list.AppendCategory(&HSTRING::from(CATEGORY), &destinations)?; + list.CommitList()?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::IpcPath; + + fn item(name: &str, path: &str) -> RecentProject { + RecentProject { path: IpcPath::from(path), name: name.into(), opened_at_ms: 0.0, branch: None } + } + + #[test] + fn shows_the_newest_items_capped() { + let items: Vec<_> = (0..15).map(|i| item(&format!("p{i}"), &format!("/p/{i}"))).collect(); + let visible = visible_items(&items); + assert_eq!(visible.len(), 10); + assert_eq!(visible[0].0, "p0"); + assert_eq!(visible[9].0, "p9"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 74b4f3ff8..5f7188edc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod commands; #[cfg(debug_assertions)] mod dev; mod events; +mod jump_list; #[cfg(any(target_os = "linux", test))] mod linux_window_state; mod logging; @@ -64,6 +65,8 @@ pub fn run() { let context = tauri::generate_context!(); #[cfg(debug_assertions)] let context = dev::configure(context); + // Before the first window: one taskbar identity for every launch path. + jump_list::set_app_id(&context.config().identifier); logging::init(&context.config().identifier); let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); @@ -174,6 +177,7 @@ pub fn run() { } app.on_menu_event(on_menu_event); windows::create_main_window(app.handle())?; + jump_list::refresh(app.handle(), app.state::().recent.list()); Ok(()) }) .on_window_event(|window, event| match event { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f651c71f9..f0882dbcb 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -145,6 +145,7 @@ impl AppState { if !display_name.trim().is_empty() { self.recent.rename(&project.root, Some(display_name.trim()))?; } + crate::jump_list::refresh(&self.app, self.recent.list()); Ok(project) } diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 12e9e8d7f..2bcaa63bd 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -923,8 +923,17 @@ pub fn open_path(app: &AppHandle, path: &Path) { /// Opens paths from outside the app (a second instance, Finder's Open With, the Dock): /// see [`plan_open`]; the focused window is the preferred free one. pub fn open_paths(app: &AppHandle, paths: Vec) { + let (existing, missing): (Vec<_>, Vec<_>) = paths.into_iter().partition(|path| path.exists()); + if !missing.is_empty() { + let paths = missing.iter().map(|path| path.display().to_string()).collect::>().join("\n"); + app.state::().sink.emit(ide_core::IdeEvent::Notify { + level: ide_core::event::NotifyLevel::Warning, + title: "Cannot open path".into(), + body: Some(format!("The path does not exist:\n{paths}")), + }); + } let preferred = target_window(app).map(|w| w.label().to_owned()); - run_open(app, plan_open(resolve(paths), &window_holds(app, preferred.as_deref()))); + run_open(app, plan_open(resolve(existing), &window_holds(app, preferred.as_deref()))); } /// What each window holds, `preferred` first. @@ -1005,6 +1014,25 @@ fn launch_for(path: PathBuf, is_dir: bool) -> Launch { /// Second instance launched: focus this one and open its path argument. pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { + if args.iter().any(|arg| arg == crate::jump_list::OPEN_RECENT_ARG) + && let Some(path) = cli_path(&args, Path::new(&cwd)).filter(|path| path.exists()) + { + let target = target_window(app) + .filter(|window| auxiliary_parent(window.label()).is_none()) + .or_else(|| project_window_labels(app).into_iter().find_map(|label| app.get_webview_window(&label))); + if let Some(window) = target { + let _ = app.emit_to( + window.label(), + crate::menu::MENU_EVENT, + crate::menu::MenuRoute { + action: crate::menu::OPEN_RECENT_ACTION.into(), + arg: Some(ide_core::ipc_path::to_ipc(&path)), + }, + ); + focus(&window); + return; + } + } match cli_path(&args, Path::new(&cwd)) { Some(path) => { remember_cli_position(&app.state::(), &args, &path);