Files
openidea/src-tauri/src/commands/app.rs
T
l0ng-ai de09ee0678 fix(windows): reopen every project window on startup (#1574)
"Reopen projects on startup" only saved window groups, so windows opened with New Window were lost. The saved session now lists every open project window (groups with their tabs and selection, single windows on their own), and the previous run's session is read before this run first overwrites it, so the startup project can't replace it before the other windows are restored.
2026-11-05 22:29:41 +08:00

326 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::state::AppState;
use ide_core::settings::Settings;
use ide_core::{EventSink, IdeError, IdeResult, RequestId, TaskId};
use ide_language::LanguageInfo;
use ide_workspace::settings::SettingsScope;
use ide_workspace::{RecentProject, RecentProjectGroup};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
use tauri::{Manager, State, Window};
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AppInfo {
pub version: String,
pub platform: String,
pub config_dir: ide_core::IpcPath,
/// Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges.
pub wayland: bool,
}
pub use ide_host::ops::project::ProjectInfo;
#[tauri::command]
#[specta::specta]
pub fn app_info(state: State<'_, AppState>) -> AppInfo {
state.note_ipc();
AppInfo {
version: env!("CARGO_PKG_VERSION").into(),
platform: std::env::consts::OS.into(),
config_dir: state.config_dir.clone().into(),
#[cfg(target_os = "linux")]
wayland: crate::linux_window_state::is_wayland(),
#[cfg(not(target_os = "linux"))]
wayland: false,
}
}
#[tauri::command]
#[specta::specta]
pub fn recent_projects(state: State<'_, AppState>) -> Vec<RecentProject> {
state.note_ipc();
state.recent.list()
}
#[tauri::command]
#[specta::specta]
pub fn remove_recent_project(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
state.recent.remove(&path)?;
crate::jump_list::refresh(&state.app, state.recent.list());
Ok(())
}
/// The welcome screen's project groups.
#[tauri::command]
#[specta::specta]
pub fn recent_project_groups(state: State<'_, AppState>) -> Vec<RecentProjectGroup> {
state.recent.groups()
}
/// Replaces the project groups; returns them as saved (names trimmed and unique, each
/// project in one group, only recent projects).
#[tauri::command]
#[specta::specta]
pub fn set_recent_project_groups(
state: State<'_, AppState>,
groups: Vec<RecentProjectGroup>,
) -> IdeResult<Vec<RecentProjectGroup>> {
state.recent.set_groups(groups)
}
#[tauri::command]
#[specta::specta]
pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInfo> {
// Opening touches the disk (index cache, watcher, git): keep it off the async runtime threads.
let app = window.app_handle().clone();
let label = window.label().to_owned();
// Replacing this window's project (the UI skips reopening the same one).
if app.state::<AppState>().project(&label).is_ok() {
crate::windows::close_auxiliary_windows(&app, &label);
}
let project = tauri::async_runtime::spawn_blocking(move || app.state::<AppState>().open_project(&label, &path))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
let info = project_info(&project);
let _ = window.set_title(&info.name);
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
// Every project window is part of the session reopened at the next start.
crate::window_groups::persist(window.app_handle()).await?;
Ok(info)
}
/// Edits the loose file `path` in this window without a project (LightEdit);
/// a window already in LightEdit keeps its files. Returns the effective (global) settings.
#[tauri::command]
#[specta::specta]
pub async fn light_edit_open(window: Window, path: PathBuf) -> IdeResult<Settings> {
let app = window.app_handle().clone();
let label = window.label().to_owned();
if app.state::<AppState>().project(&label).is_ok_and(|p| !p.light) {
crate::windows::close_auxiliary_windows(&app, &label);
crate::window_groups::detach(&app, &label)?;
crate::window_groups::persist(&app).await?;
}
let project = tauri::async_runtime::spawn_blocking(move || app.state::<AppState>().open_light(&label, &path))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
Ok(project.settings.effective())
}
fn project_info(project: &crate::state::Project) -> ProjectInfo {
ide_host::ops::project::info(project)
}
/// File › Rename Project…: the project's display name (title bar, recent
/// projects); an empty `name` restores the folder name. Stored in the project's settings.
#[tauri::command]
#[specta::specta]
pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) -> IdeResult<ProjectInfo> {
let project = state.project(window.label())?;
let name = name.trim();
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
return ide_host::ops::project::save_workspace(&state.host, window.label(), name, workspace.projects);
}
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))
}
#[tauri::command]
#[specta::specta]
pub fn close_project(window: Window, state: State<'_, AppState>) {
crate::windows::close_auxiliary_windows(window.app_handle(), window.label());
let _ = crate::window_groups::detach(window.app_handle(), window.label());
state.close_project(window.label());
let app = window.app_handle().clone();
tauri::async_runtime::spawn(async move {
let _ = crate::window_groups::persist(&app).await;
});
}
#[tauri::command]
#[specta::specta]
pub fn languages(state: State<'_, AppState>) -> Vec<LanguageInfo> {
state.note_ipc();
// Includes plugin languages (built-in definitions win on id clashes).
state.languages.all().iter().map(LanguageInfo::from).collect()
}
/// Effective settings for the window's project, or global settings without one.
#[tauri::command]
#[specta::specta]
pub fn settings_get(window: Window, state: State<'_, AppState>) -> Settings {
state.note_ipc();
ide_host::ops::project::settings(&state.host, window.label())
}
/// Defaults plus the global layer, without any project's values: what new
/// projects (and projects without their own value) get.
#[tauri::command]
#[specta::specta]
pub fn settings_get_global(state: State<'_, AppState>) -> Settings {
state.host.with_proxy_password(state.global_settings.effective())
}
/// `value` is JSON text, e.g. `14` or `"dark"`. Every window (for a project
/// value: that project's windows) is told (`IdeEvent::SettingsChanged`) so it
/// re-reads its effective settings.
#[tauri::command]
#[specta::specta]
pub fn settings_set(
window: Window,
state: State<'_, AppState>,
scope: SettingsScope,
key: String,
value: String,
) -> IdeResult<Settings> {
let applied = ide_host::ops::project::set_settings(&state.host, window.label(), scope, &key, &value)?;
// A project value concerns only that project's window (and its settings window).
let event = ide_core::IdeEvent::SettingsChanged { key };
if applied.project_scope {
let owner = AppState::owner_window(window.label());
for label in [owner.to_owned(), crate::windows::settings_label(owner)] {
let sink = crate::events::TauriSink { app: state.app.clone(), window: Some(label) };
sink.emit(event.clone());
if let Some(root) = &applied.roots_changed {
sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: root.clone() });
}
}
} else {
state.sink.emit(event);
}
Ok(applied.settings)
}
/// File › Manage IDE Settings › Export Settings…: the global settings plus the
/// frontend's IDE-level `storage` (live / file templates, macros) to `path`.
#[tauri::command]
#[specta::specta]
pub async fn settings_export(
state: State<'_, AppState>,
path: PathBuf,
storage: std::collections::BTreeMap<String, String>,
) -> IdeResult<()> {
state.global_settings.export_bundle(&path, storage)
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SettingsImport {
/// Effective settings of the calling window after the import.
pub settings: Settings,
/// IDE state the frontend keeps (written back to its storage).
pub storage: std::collections::BTreeMap<String, String>,
}
/// Import Settings…: replaces the global settings with the file's; every window re-reads them.
#[tauri::command]
#[specta::specta]
pub async fn settings_import(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<SettingsImport> {
let (_, storage) = state.global_settings.import_bundle(&path)?;
for project in state.projects() {
let effective = project.settings.reload();
project
.documents
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
let lsp = project.lsp.clone();
tauri::async_runtime::spawn(async move { lsp.reconfigure().await });
}
crate::commands::ssh::apply_ssh_settings(&state);
state.host.apply_proxy();
state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() });
let settings = ide_host::ops::project::settings(&state.host, window.label());
Ok(SettingsImport { settings, storage })
}
/// Effective source / test / resource / excluded roots of the window's project:
/// auto-detected from build files, overridden by Settings › Project Structure.
#[tauri::command]
#[specta::specta]
pub fn project_roots(window: Window, state: State<'_, AppState>) -> IdeResult<ide_workspace::ProjectRoots> {
ide_host::ops::project::roots(&state.host, window.label())
}
/// The project open in this window (a settings window: its parent's), if any.
#[tauri::command]
#[specta::specta]
pub fn project_current(window: Window, state: State<'_, AppState>) -> Option<ProjectInfo> {
state.project(window.label()).ok().filter(|p| !p.light).map(|p| project_info(&p))
}
#[tauri::command]
#[specta::specta]
pub fn cancel_request(state: State<'_, AppState>, id: RequestId) {
state.cancel_request(id);
}
/// Stops a background task the backend reported with `cancellable` set; false when it
/// has ended or can't be cancelled.
#[tauri::command]
#[specta::specta]
pub fn cancel_task(id: TaskId) -> bool {
ide_core::task::cancel(id)
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
/// Routes frontend logs into the backend `tracing` pipeline.
#[tauri::command]
#[specta::specta]
pub fn log_frontend(level: LogLevel, message: String) {
match level {
LogLevel::Debug => tracing::debug!(target: "frontend", "{message}"),
LogLevel::Info => tracing::info!(target: "frontend", "{message}"),
LogLevel::Warn => tracing::warn!(target: "frontend", "{message}"),
LogLevel::Error => tracing::error!(target: "frontend", "{message}"),
}
}
#[tauri::command]
#[specta::specta]
pub fn project_trust_status(state: State<'_, AppState>, path: PathBuf) -> IdeResult<ide_host::trust::TrustStatus> {
state.trust.status(&path)
}
#[tauri::command]
#[specta::specta]
pub fn project_trust_list(state: State<'_, AppState>) -> IdeResult<ide_host::trust::TrustDecisions> {
state.trust.decisions()
}
#[tauri::command]
#[specta::specta]
pub async fn project_trust_set(
state: State<'_, AppState>,
path: PathBuf,
trusted: bool,
folder: bool,
) -> IdeResult<()> {
state.host.set_project_trust(&path, trusted, folder).await?;
if !trusted {
state.plugins.restrict_project(&dunce::canonicalize(&path).unwrap_or(path));
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn project_trust_remove_location(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
state.trust.remove_location(&path)?;
state.host.refresh_project_trust().await?;
for project in state.host.projects().into_iter().filter(|p| !p.is_trusted()) {
state.plugins.restrict_project(&project.root);
}
Ok(())
}