Файли
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 <[email protected]>
Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-05 15:37:19 +08:00

533 рядки
18 KiB
Rust
Неформатований Постійне посилання Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! 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());
}
}