Files
openidea/src-tauri/src/commands/window.rs
T
b0f65769c0 feat(remote): open WSL projects through a WSL transport (#989)
* perf(vfs): list directories off the main thread and reuse the repository lookup

Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow
because every listing ran on the main thread and walked up to the repository
root and ran a repository discovery again.

- fs_read_dir runs on the blocking pool.
- GitignoreStack::cached keeps the repository root and the repo-level rules
  for a few seconds. An edit of .git/info/exclude is still seen at once (its
  mtime and size are checked) and every .gitignore / .ignore is read fresh.

Refs #916

* feat(remote): open WSL projects through a WSL transport

A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens
as a remote project whose openidea-server runs inside the distro, so the
terminal, SDK discovery, run, language servers and git are native Linux.

- RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote
  launch, connection, session and recents; old recents are read as SSH and a
  WSL target never inherits the legacy trust migration.
- remote_wsl: probe the distro, stream the server into ~/.openidea/server and
  start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are
  validated and every command goes through --exec with positional arguments.
- wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route
  WSL folders to the distro and focus the window that already has them.
- Remote Development dialog: SSH/WSL choice with the detected distros; the
  status bar shows `WSL: <distro>` / `SSH: <host>`.
- ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of
  wsl.exe, which made the first message of the server disappear.

Not run on Windows yet: only a stand-in for wsl.exe is tested.

Refs #925, #327, #721

* fix(remote): satisfy clippy in WSL output decoding

* style(run): format run types

* fix: regenerate bindings and drop untranslated WSL catalog entry

* test(i18n): list WSL as an untranslated product name

* fix(run): pick build wrappers and shell quoting by the execution host

A remote window runs commands on its Linux or macOS server, so a Windows
desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it.

* fix(welcome): open legacy WSL UNC recents and sessions through the distro

Recents and the restored session called the local openProject for a
\\wsl.localhost folder saved before WSL projects, which fails with OS
error 123. They now resolve it like the other entry points.

* fix(windows): keep WSL files out of remote project launches

path_launch tested the WSL share before is_dir, so a first-instance CLI
launch with a WSL file opened a remote project instead of LightEdit.

* fix(remote): run-command OS comes from the remote host, not an assumption

The server's OS was dropped after connect, so a restored session had none and
isWindows() assumed every remote host is Unix. Keep it on the connection and
the session, and resolve it through hostOs().

---------

Co-authored-by: NguyenBang <nguyenbang@NTBANG.local>
Co-authored-by: NguyenBang <nguyenbang@Mac.lan>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-11-05 15:37:19 +08:00

533 lines
18 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.
//! Desktop shell: project windows, native menu, quit flow, logs, updates.
use crate::menu::{self, MenuConfig, MenuState};
use crate::state::AppState;
use crate::windows::{self, Launch, OpenOutcome, WindowsState};
use ide_core::{IdeError, IdeResult, RequestId};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::ipc::Channel;
use tauri::{AppHandle, State, WebviewWindow};
/// Opens `path` in a new window (`new_window`) or tells the caller to open it
/// itself (`current`). A project already open in some window is focused instead.
/// `files` then open in that project's editor.
#[tauri::command]
#[specta::specta]
pub async fn window_open_project(
app: AppHandle,
path: PathBuf,
new_window: bool,
files: Vec<PathBuf>,
) -> IdeResult<OpenOutcome> {
windows::open_project_window(&app, &path, new_window, files)
}
/// Loose `files` opened from a window without a local project: a file inside a local project
/// open in some window goes to that window, the others to the LightEdit window (this one,
/// another, or a new one from a remote project window). Returns the files this window opens
/// itself, in LightEdit.
#[tauri::command]
#[specta::specta]
pub async fn window_open_files(
app: AppHandle,
window: WebviewWindow,
files: Vec<PathBuf>,
) -> IdeResult<Vec<ide_core::IpcPath>> {
Ok(windows::open_files_window(&app, window.label(), files))
}
/// Detaches an editor tab: opens `file` in a forced new window (`project`, None
/// for LightEdit, decided by the caller), bypassing the focus-an-existing-window
/// routing of [`window_open_project`] / [`window_open_files`].
#[tauri::command]
#[specta::specta]
pub async fn window_open_file_in_new_window(
app: AppHandle,
project: Option<PathBuf>,
file: PathBuf,
) -> IdeResult<OpenOutcome> {
windows::open_file_in_new_window(&app, project, file)
}
/// [`window_open_project`] for folder `path` of `target` (an SSH host or a WSL distribution;
/// Remote Development); a new window connects itself.
#[tauri::command]
#[specta::specta]
pub async fn window_open_remote(
app: AppHandle,
target: crate::remote_dev::RemoteTarget,
path: String,
new_window: bool,
) -> IdeResult<OpenOutcome> {
windows::open_remote_window(&app, &target, &path, new_window)
}
/// Opens an empty (welcome) window; returns its label.
#[tauri::command]
#[specta::specta]
pub async fn window_new(app: AppHandle) -> IdeResult<String> {
windows::create_window(&app, None).map(|w| w.label().to_owned())
}
/// Opens this window's Settings window on `page` (or focuses it and navigates);
/// `new_projects` edits the global layer only ("Settings for New Projects").
/// `background` (`#rrggbb`) paints a new window until its page has loaded.
#[tauri::command]
#[specta::specta]
pub async fn window_open_settings(
app: AppHandle,
window: WebviewWindow,
page: Option<String>,
new_projects: bool,
background: Option<String>,
) -> IdeResult<()> {
windows::open_settings_window(&app, &window, page, new_projects, background)
}
/// Opens a window listing the files that differ between two revisions of this window's project.
/// `background` (`#rrggbb`) paints it until its page has loaded.
#[tauri::command]
#[specta::specta]
pub async fn window_open_comparison(
app: AppHandle,
window: WebviewWindow,
comparison: windows::ComparisonLaunch,
background: Option<String>,
) -> IdeResult<()> {
windows::open_comparison_window(&app, &window, comparison, background)
}
/// Opens this window's Sync with Deployed window comparing `paths` with `server` (or hands them
/// to the open one). `background` (`#rrggbb`) paints a new window until its page has loaded.
#[tauri::command]
#[specta::specta]
pub async fn window_open_deployment(
app: AppHandle,
window: WebviewWindow,
server: String,
paths: Vec<PathBuf>,
background: Option<String>,
) -> IdeResult<()> {
windows::open_deployment_window(&app, &window, server, paths, background)
}
/// Focuses the next / previous window (⌘` / ⇧⌘`).
#[tauri::command]
#[specta::specta]
pub fn window_cycle(app: AppHandle, window: WebviewWindow, forward: bool) {
windows::cycle(&app, window.label(), forward);
}
#[tauri::command]
#[specta::specta]
pub fn window_toggle_fullscreen(window: WebviewWindow) -> IdeResult<bool> {
crate::window_groups::toggle_fullscreen(&window)
}
/// Height of the web title bar in native logical pixels (CSS height × IDE zoom).
#[tauri::command]
#[specta::specta]
pub fn window_set_titlebar_height(window: WebviewWindow, height: f64) -> IdeResult<()> {
if !height.is_finite() || !(1.0..=1000.0).contains(&height) {
return Err(IdeError::invalid("invalid title bar height"));
}
#[cfg(target_os = "macos")]
crate::titlebar::set_height(&window, height).map_err(|e| IdeError::internal(e.to_string()))?;
#[cfg(not(target_os = "macos"))]
let _ = window;
Ok(())
}
/// What this window was opened for (taken once).
#[tauri::command]
#[specta::specta]
pub fn window_take_launch(window: WebviewWindow, windows: State<'_, WindowsState>) -> Option<Launch> {
windows.take_launch(window.label())
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct OpenProject {
pub name: String,
pub root: PathBuf,
/// Open in the calling window.
pub current: bool,
}
/// Projects open in project windows, in window order (title bar project widget).
#[tauri::command]
#[specta::specta]
pub fn window_open_projects(
app: AppHandle,
window: WebviewWindow,
state: State<'_, AppState>,
) -> IdeResult<Vec<OpenProject>> {
Ok(windows::project_window_labels(&app)
.into_iter()
.filter_map(|label| {
let project = state.project(&label).ok().filter(|p| !p.light)?;
Some(OpenProject {
name: project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
root: project.root.clone(),
current: label == window.label(),
})
})
.collect())
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CloseContext {
/// Quit is in progress (exit was already confirmed).
pub quitting: bool,
/// Number of open project windows (settings windows excluded), including the caller.
pub windows: u32,
}
#[tauri::command]
#[specta::specta]
pub fn window_close_context(app: AppHandle, windows: State<'_, WindowsState>) -> CloseContext {
CloseContext { quitting: windows.quitting(), windows: windows::project_window_labels(&app).len() as u32 }
}
/// Quits: closes every window, each of which runs its own unsaved-changes check.
#[tauri::command]
#[specta::specta]
pub fn app_quit(app: AppHandle, windows: State<'_, WindowsState>) {
if windows.quitting() {
return;
}
windows.set_quitting(true);
windows::continue_quit(&app, None);
}
/// A window vetoed the quit (user cancelled its unsaved-changes prompt).
#[tauri::command]
#[specta::specta]
pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) {
windows.set_quitting(false);
tauri::async_runtime::spawn(async move {
let _ = crate::window_groups::persist(&app).await;
});
}
/// Sets the menu's labels / shortcut text (on locale or keymap change) and rebuilds it.
#[tauri::command]
#[specta::specta]
pub fn menu_configure(
app: AppHandle,
menu_state: State<'_, MenuState>,
state: State<'_, AppState>,
config: MenuConfig,
) {
*menu_state.config.lock() = config;
menu::install(&app, &menu_state, &state.recent.list());
}
/// Rebuilds the menu (e.g. after the recent-projects list changed).
#[tauri::command]
#[specta::specta]
pub fn menu_refresh(app: AppHandle, menu_state: State<'_, MenuState>, state: State<'_, AppState>) {
menu::install(&app, &menu_state, &state.recent.list());
}
/// The menu bar for a title bar drawn by the frontend (Windows).
#[tauri::command]
#[specta::specta]
pub fn menu_model(app: AppHandle, state: State<'_, AppState>, menu_state: State<'_, MenuState>) -> Vec<menu::MenuNode> {
let mut spec = menu::spec(menu::Os::current());
if !updates_configured(&app) {
spec = menu::without_action(spec, "app.checkForUpdates");
}
let home = dirs::home_dir();
let config = menu_state.config.lock().clone();
menu::model(&spec, &menu::recent_items(&state.recent.list(), home.as_deref()), &config)
}
/// Action ids the focused window cannot run now: their native menu items are
/// disabled in place (no rebuild).
#[tauri::command]
#[specta::specta]
pub fn menu_set_disabled(app: AppHandle, menu_state: State<'_, MenuState>, ids: Vec<String>) {
let disabled: std::collections::HashSet<String> = ids.into_iter().collect();
if *menu_state.disabled.lock() == disabled {
return;
}
*menu_state.disabled.lock() = disabled.clone();
if menu::native_menu_bar() {
menu::apply_disabled(&app, &disabled);
}
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LogLocation {
pub dir: ide_core::IpcPath,
/// Current log file, if one was written yet.
pub file: Option<ide_core::IpcPath>,
}
#[tauri::command]
#[specta::specta]
pub fn app_log_location() -> IdeResult<LogLocation> {
let dir = crate::logging::log_dir().ok_or_else(|| IdeError::unavailable("file logging is not active"))?;
Ok(LogLocation { dir: dir.into(), file: crate::logging::latest_log(dir).map(Into::into) })
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfo {
pub version: String,
/// Build id from CI (`OPENIDEA_BUILD` at compile time), else `dev`.
pub build: String,
pub profile: String,
pub os: String,
pub arch: String,
pub tauri: String,
/// An update endpoint is configured ("Check for Updates…" is offered).
pub updates: bool,
}
#[tauri::command]
#[specta::specta]
pub fn app_build_info(app: AppHandle) -> BuildInfo {
BuildInfo {
version: env!("CARGO_PKG_VERSION").into(),
build: option_env!("OPENIDEA_BUILD").unwrap_or("dev").into(),
profile: if cfg!(debug_assertions) { "debug" } else { "release" }.into(),
os: std::env::consts::OS.into(),
arch: std::env::consts::ARCH.into(),
tauri: tauri::VERSION.into(),
updates: updates_configured(&app),
}
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateInfo {
pub version: String,
pub current_version: String,
pub notes: Option<String>,
pub date: Option<String>,
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
/// False when no update endpoint is configured (no request was made).
pub enabled: bool,
pub update: Option<UpdateInfo>,
}
fn update_endpoint_env() -> Option<String> {
std::env::var("OPENIDEA_UPDATE_ENDPOINT").ok().filter(|s| !s.trim().is_empty())
}
/// Whether an update endpoint is configured: `plugins.updater.endpoints` in
/// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by
/// default, and "Check for Updates…" is hidden then.
pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
let in_config = app
.config()
.plugins
.0
.get("updater")
.and_then(|u| u.get("endpoints"))
.and_then(|e| e.as_array())
.is_some_and(|e| !e.is_empty());
in_config || update_endpoint_env().is_some()
}
fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
use tauri_plugin_updater::UpdaterExt;
if !updates_configured(app) {
return Ok(None);
}
let env = update_endpoint_env();
let proxy = ide_core::proxy_settings::proxy();
let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy));
if let Some(url) = env {
let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?;
builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?;
}
builder.build().map(Some).map_err(|e| IdeError::unavailable(e.to_string()))
}
#[tauri::command]
#[specta::specta]
pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
let Some(updater) = updater(&app)? else {
return Ok(UpdateCheck { enabled: false, update: None });
};
let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?;
Ok(UpdateCheck {
enabled: true,
update: update.map(|u| UpdateInfo {
version: u.version.clone(),
current_version: u.current_version.clone(),
notes: u.body.clone(),
date: u.date.map(|d| d.to_string()),
}),
})
}
/// The background check made once per process when the first window starts;
/// later calls (other windows) and debug builds get `None`. Failures are logged,
/// not reported: the user didn't ask for this check.
#[tauri::command]
#[specta::specta]
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
static DONE: AtomicBool = AtomicBool::new(false);
if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) {
return None;
}
match app_check_update(app).await {
Ok(check) => check.update,
Err(e) => {
tracing::warn!("startup update check failed: {e}");
None
}
}
}
/// Progress of [`app_install_update`], streamed to the UI's background task.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum UpdateProgress {
/// Bytes received so far; `total` when the server sends a length.
Downloading { downloaded: f64, total: Option<f64> },
/// Download done; the new version is being written over this one.
Installing,
}
/// Limits download events to one per whole percent (or per 256 KiB without a length).
#[derive(Default)]
struct ProgressThrottle {
downloaded: u64,
last_step: Option<u64>,
}
impl ProgressThrottle {
const UNKNOWN_STEP: u64 = 256 * 1024;
fn chunk(&mut self, len: usize, total: Option<u64>) -> Option<UpdateProgress> {
self.downloaded += len as u64;
let step = match total {
Some(t) if t > 0 => self.downloaded.min(t) * 100 / t,
_ => self.downloaded / Self::UNKNOWN_STEP,
};
if self.last_step == Some(step) {
return None;
}
self.last_step = Some(step);
Some(UpdateProgress::Downloading { downloaded: self.downloaded as f64, total: total.map(|t| t as f64) })
}
}
/// Set while an update is being downloaded or installed (by any window).
static INSTALLING: AtomicBool = AtomicBool::new(false);
struct InstallingGuard;
impl InstallingGuard {
fn acquire() -> Option<Self> {
(!INSTALLING.swap(true, Ordering::SeqCst)).then_some(Self)
}
}
impl Drop for InstallingGuard {
fn drop(&mut self) {
INSTALLING.store(false, Ordering::SeqCst);
}
}
/// Downloads and installs the available update, streaming progress to `on_progress`.
/// Cancellable through `request` until the download completes; the UI then restarts
/// with `app_restart`. One install runs at a time.
#[tauri::command]
#[specta::specta]
pub async fn app_install_update(
app: AppHandle,
state: State<'_, AppState>,
request: RequestId,
on_progress: Channel<UpdateProgress>,
) -> IdeResult<()> {
let _guard =
InstallingGuard::acquire().ok_or_else(|| IdeError::unavailable("an update is already being installed"))?;
let token = state.host.begin_request(request);
let result = async {
let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
let update = tokio::select! {
r = updater.check() => r.map_err(|e| IdeError::unavailable(e.to_string()))?,
_ = token.cancelled() => return Err(IdeError::cancelled()),
}
.ok_or_else(|| IdeError::not_found("no update available"))?;
let mut throttle = ProgressThrottle::default();
let progress = on_progress.clone();
let download = update.download(
move |len, total| {
if let Some(p) = throttle.chunk(len, total) {
let _ = progress.send(p);
}
},
|| {},
);
let bytes = tokio::select! {
r = download => r.map_err(|e| IdeError::unavailable(e.to_string()))?,
_ = token.cancelled() => return Err(IdeError::cancelled()),
};
let _ = on_progress.send(UpdateProgress::Installing);
tauri::async_runtime::spawn_blocking(move || update.install(bytes))
.await
.map_err(|e| IdeError::internal(e.to_string()))?
.map_err(|e| IdeError::internal(e.to_string()))
}
.await;
state.host.end_request(request);
result
}
#[cfg(test)]
mod tests {
use super::*;
fn downloading(p: Option<UpdateProgress>) -> Option<(f64, Option<f64>)> {
match p? {
UpdateProgress::Downloading { downloaded, total } => Some((downloaded, total)),
UpdateProgress::Installing => None,
}
}
#[test]
fn throttle_reports_each_whole_percent_once() {
let mut t = ProgressThrottle::default();
assert_eq!(downloading(t.chunk(1, Some(1000))), Some((1.0, Some(1000.0))));
assert_eq!(downloading(t.chunk(5, Some(1000))), None);
assert_eq!(downloading(t.chunk(4, Some(1000))), Some((10.0, Some(1000.0))));
let reported = (0..99).filter(|_| t.chunk(10, Some(1000)).is_some()).count();
assert_eq!(reported, 99);
assert_eq!(t.downloaded, 1000);
}
#[test]
fn throttle_without_length_reports_per_step() {
let mut t = ProgressThrottle::default();
assert!(t.chunk(10, None).is_some());
assert!(t.chunk(1024, None).is_none());
assert_eq!(downloading(t.chunk(256 * 1024, None)), Some(((256 * 1024 + 1034) as f64, None)));
// A zero length is treated as unknown, not divided by.
assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some());
}
#[test]
fn only_one_install_at_a_time() {
let first = InstallingGuard::acquire().expect("first install starts");
assert!(InstallingGuard::acquire().is_none());
drop(first);
assert!(InstallingGuard::acquire().is_some());
}
}