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
Цей коміт міститься в:
ppxb authored and GitHub committed 2026-11-05 20:25:32 +08:00
1 джерело 4a217a4473
коміт 47e5b0f59c
7 files changed
+219 -2

No files matched your search

Створено
+1
Переглянути файл
@@ -6379,6 +6379,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows",
"windows-sys 0.61.2",
]
+10
Переглянути файл
@@ -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",
+4 -1
Переглянути файл
@@ -46,7 +46,9 @@ pub fn recent_projects(state: State<'_, AppState>) -> Vec<RecentProject> {
#[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))
}
+170
Переглянути файл
@@ -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<RecentProject>) {
#[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<RecentProject>) {
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");
}
}
+4
Переглянути файл
@@ -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::<AppState>().recent.list());
Ok(())
})
.on_window_event(|window, event| match event {
+1
Переглянути файл
@@ -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)
}
+29 -1
Переглянути файл
@@ -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<PathBuf>) {
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::<Vec<_>>().join("\n");
app.state::<AppState>().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<String>, 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::<WindowsState>(), &args, &path);