дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
7 files changed
+219
-2
No files matched your search
Створено
@@ -6379,6 +6379,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"windows",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача