diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bb12e5cc..d75c7fd7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "project-*", "settings-*"], + "windows": ["main", "project-*", "settings-*", "comparison-*"], "permissions": [ "core:default", "core:window:allow-start-dragging", @@ -17,6 +17,9 @@ "opener:allow-reveal-item-in-dir", "dialog:default", "core:window:allow-destroy", - "core:window:allow-close" + "core:window:allow-close", + "core:window:allow-set-focus", + "core:window:allow-unminimize", + "core:window:allow-show" ] } diff --git a/src-tauri/capabilities/e2e.json b/src-tauri/capabilities/e2e.json index 2e1cd42c..10e5dea3 100644 --- a/src-tauri/capabilities/e2e.json +++ b/src-tauri/capabilities/e2e.json @@ -2,6 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "e2e-bridge", "description": "Debug-only automation bridge", - "windows": ["main", "project-*", "settings-*"], + "windows": ["main", "project-*", "settings-*", "comparison-*"], "permissions": ["mcp-bridge:default"] } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index dbef3318..8c5e994c 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -57,7 +57,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult().project(&label).is_ok() { - crate::windows::close_settings_window(&app, &label); + crate::windows::close_auxiliary_windows(&app, &label); } let project = tauri::async_runtime::spawn_blocking(move || app.state::().open_project(&label, &path)) .await @@ -80,7 +80,7 @@ pub async fn light_edit_open(window: Window, path: PathBuf) -> IdeResult().project(&label).is_ok_and(|p| !p.light) { - crate::windows::close_settings_window(&app, &label); + crate::windows::close_auxiliary_windows(&app, &label); crate::window_groups::detach(&app, &label)?; crate::window_groups::persist(&app).await?; } @@ -113,7 +113,7 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) #[tauri::command] #[specta::specta] pub fn close_project(window: Window, state: State<'_, AppState>) { - crate::windows::close_settings_window(window.app_handle(), window.label()); + 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(); diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index 5d366faa..c2a3ef90 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -8,7 +8,7 @@ use ide_language::{FoldRange, SyntaxHighlights}; use ide_text::LineEnding; use ide_workspace::{DocumentInfo, HistoryEntry}; use std::path::PathBuf; -use tauri::{State, Window}; +use tauri::{Manager, State, Window}; /// Opens a document. Async: reading and decoding a large file must not block /// the main thread (synchronous commands run on it). @@ -38,8 +38,14 @@ pub async fn doc_edit( changes: ChangeSet, ) -> IdeResult { let host = state.host.clone(); - let window = window.label().to_owned(); - blocking(move || ide_host::ops::doc::edit(&host, &window, doc, base_version, changes)).await + let label = window.label().to_owned(); + let peers = crate::events::document_edit_peers(window.app_handle(), &label); + let mirrored = (!peers.is_empty()).then(|| changes.changes.clone()); + let version = blocking(move || ide_host::ops::doc::edit(&host, &label, doc, base_version, changes)).await?; + if let Some(changes) = mirrored { + crate::events::emit_document_edit(window.app_handle(), peers, doc, base_version, version, changes); + } + Ok(version) } #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2b4cee4e..d2fba7d5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -383,6 +383,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_open_remote, window::window_new, window::window_open_settings, + window::window_open_comparison, window::window_cycle, window::window_toggle_fullscreen, window::window_set_titlebar_height, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index b655bf6e..7c4abb1c 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -87,6 +87,19 @@ pub async fn window_open_settings( 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, +) -> IdeResult<()> { + windows::open_comparison_window(&app, &window, comparison, background) +} + /// Focuses the next / previous window (⌘` / ⇧⌘`). #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 4263bc10..ef2576f8 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -4,7 +4,35 @@ use tauri::{AppHandle, Emitter}; /// Name of the single backend → UI event. Payload: [`IdeEvent`]. pub const IDE_EVENT: &str = "ide://event"; -/// Forwards events to one window (`Some(label)`) or to all windows. +/// The other windows showing the documents of `source`'s project: its project window and the +/// auxiliary windows with documents. An edit made in one is not echoed by the backend, so they +/// get it as a patch ([`emit_document_edit`]). +pub fn document_edit_peers(app: &AppHandle, source: &str) -> Vec { + let owner = crate::state::AppState::owner_window(source); + let mut peers = crate::windows::document_windows(app, owner); + peers.push(owner.to_owned()); + peers.retain(|label| label != source); + peers +} + +pub fn emit_document_edit( + app: &AppHandle, + peers: Vec, + doc: ide_core::DocId, + base_version: u32, + version: u32, + changes: Vec, +) { + let event = IdeEvent::DocPatched { doc, base_version, version, changes, workspace_edit: false }; + for label in peers { + if let Err(e) = app.emit_to(label, IDE_EVENT, &event) { + tracing::warn!("failed to mirror document edit: {e}"); + } + } +} + +/// Forwards events to one window (`Some(label)`, plus its auxiliary windows that show its +/// project's documents) or to all windows. pub struct TauriSink { pub app: AppHandle, pub window: Option, @@ -13,7 +41,12 @@ pub struct TauriSink { impl EventSink for TauriSink { fn emit(&self, event: IdeEvent) { let result = match &self.window { - Some(label) => self.app.emit_to(label.as_str(), IDE_EVENT, &event), + Some(label) => { + for target in crate::windows::document_windows(&self.app, label) { + let _ = self.app.emit_to(target.as_str(), IDE_EVENT, &event); + } + self.app.emit_to(label.as_str(), IDE_EVENT, &event) + } None => self.app.emit(IDE_EVENT, &event), }; if let Err(e) = result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ba8e5f9..f7ecc3c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -85,8 +85,8 @@ pub fn run() { let app = app.plugin( tauri_plugin_window_state::Builder::default() .with_state_flags(window_state_flags()) - // One remembered geometry shared by every project's settings window. - .map_label(|l| if windows::is_settings_window(l) { "settings" } else { l }) + // One remembered geometry per kind of auxiliary window (all projects' settings windows share one). + .map_label(windows::state_key) .build(), ); let app = app @@ -182,17 +182,17 @@ pub fn run() { titlebar::forget(window); macos::forget_fullscreen(window.label()); } - window_groups::destroyed(window.app_handle(), window.label()); - if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) { - let _ = settings.destroy(); - } - if let Some(state) = window.try_state::() { - state.close_project(window.label()); + if windows::auxiliary_parent(window.label()).is_none() { + window_groups::destroyed(window.app_handle(), window.label()); + windows::close_auxiliary_windows(window.app_handle(), window.label()); + if let Some(state) = window.try_state::() { + state.close_project(window.label()); + } } if let Some(w) = window.try_state::() { w.forget(window.label()); } - if !windows::is_settings_window(window.label()) { + if windows::auxiliary_parent(window.label()).is_none() { windows::continue_quit(window.app_handle(), Some(window.label())); } } @@ -237,15 +237,20 @@ fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) { let Some(route) = menu::route(&menu::spec(menu::Os::current()), id) else { return }; match windows::target_window(app) { Some(w) => { - // Settings is modal for its project window: only app-level items get through, to the parent. - let label = match windows::settings_parent(w.label()) { - Some(parent) + // App-level items of an auxiliary window go to its project window. Settings is modal for it: + // nothing else gets through. Others handle their own items; Close Tab closes the window. + let label = match windows::auxiliary(w.label()) { + Some((_, parent)) if matches!(route.action.as_str(), "app.quit" | "file.settings" | "file.newProjectsSettings") => { parent } - Some(_) => return, - None => w.label(), + Some((kind, _)) if kind.modal() => return, + Some(_) if route.action == "editor.closeTab" => { + let _ = w.close(); + return; + } + _ => w.label(), }; if let Err(e) = app.emit_to(label, menu::MENU_EVENT, &route) { tracing::warn!("failed to deliver menu event {id}: {e}"); diff --git a/src-tauri/src/linux_window_state.rs b/src-tauri/src/linux_window_state.rs index a05a6f7e..f3923bfa 100644 --- a/src-tauri/src/linux_window_state.rs +++ b/src-tauri/src/linux_window_state.rs @@ -120,7 +120,7 @@ mod runtime { } fn key(label: &str) -> &str { - if crate::windows::is_settings_window(label) { "settings" } else { label } + crate::windows::state_key(label) } pub fn prepare(app: &AppHandle, config: &mut WindowConfig) -> bool { @@ -198,7 +198,7 @@ mod runtime { pending.phase = Phase::Mapping; pending.mode_requested = false; } - let mut fit_settings = false; + let mut place_aux = false; let mut position = None; if matches!(pending.phase, Phase::Mapping) { if pending.restore_position { @@ -286,7 +286,7 @@ mod runtime { return; } pending.phase = Phase::Tracking; - fit_settings = crate::windows::is_settings_window(window.label()); + place_aux = crate::windows::auxiliary_parent(window.label()).is_some(); } pending.history = ModeHistory(frame.mode); pending.normal = remember_size(pending.normal, frame.size, false, frame.mode); @@ -304,15 +304,15 @@ mod runtime { if let Some(position) = position { let _ = window.set_position(position); } - if fit_settings { + if place_aux { let app = window.app_handle().clone(); let label = window.label().to_owned(); let _ = window.run_on_main_thread(move || { - if let Some(parent) = crate::windows::settings_parent(&label).and_then(|p| app.get_webview_window(p)) - && let Some(settings) = app.get_webview_window(&label) - && let Err(error) = crate::windows::place_settings_window(&settings, &parent) + if let Some(parent) = crate::windows::auxiliary_parent(&label).and_then(|p| app.get_webview_window(p)) + && let Some(aux) = app.get_webview_window(&label) + && let Err(error) = crate::windows::place_over_parent(&aux, &parent) { - tracing::warn!("cannot fit restored settings window: {error}"); + tracing::warn!("cannot fit restored auxiliary window: {error}"); } }); } diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 783e0a99..a38f0be0 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -35,7 +35,7 @@ pub fn new_project_tab_target(app: &AppHandle, launch: &Launch) -> IdeResult().last_focused().and_then(|label| windows.get(&label).cloned())); let active = active.and_then(|w| { - let label = crate::windows::settings_parent(w.label()).unwrap_or(w.label()); + let label = crate::windows::auxiliary_parent(w.label()).unwrap_or(w.label()); windows.get(label).cloned() }); let active = active.filter(|w| { diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 0a6c74cc..2d210b4b 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -635,8 +635,37 @@ pub async fn remote_invoke( } } } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct DocumentEdit { + doc: ide_core::DocId, + base_version: u32, + changes: ide_core::ChangeSet, + } + let mirrored = if cmd == "doc_edit" { + let peers = crate::events::document_edit_peers(window.app_handle(), window.label()); + if peers.is_empty() { + None + } else { + serde_json::from_value::(args.clone()).ok().map(|edit| (peers, edit)) + } + } else { + None + }; let call = Invoke { window: label, cmd, args }; let result: Value = c.peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; + if let (Some((peers, edit)), Some(version)) = + (mirrored, result.as_u64().and_then(|version| u32::try_from(version).ok())) + { + crate::events::emit_document_edit( + window.app_handle(), + peers, + edit.doc, + edit.base_version, + version, + edit.changes.changes, + ); + } Ok(result.to_string()) } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b205e44c..34011eb7 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -131,7 +131,7 @@ impl AppState { /// The window that owns what `window` starts: a settings window's parent, else `window`. pub fn owner_window(window: &str) -> &str { - crate::windows::settings_parent(window).unwrap_or(window) + crate::windows::auxiliary_parent(window).unwrap_or(window) } /// Opens `root` in `window` and records it in the recent projects. diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index ecc5021e..871b81da 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -6,9 +6,9 @@ //! (`window_take_launch`), and re-taken whenever [`LAUNCH_EVENT`] fires (a //! second app instance asking an already-loaded window to open a path). //! -//! Settings open in a separate window `settings-` (one per project -//! window) that shares its parent's project (see [`AppState::project`]); its -//! [`Launch::settings_page`] tells it which page to show. +//! Auxiliary windows ([`AuxKind`]: Settings, Compare with Local) belong to a project window and share its project (see +//! [`AppState::project`]). Their labels name the kind and the parent +//! (`settings-main`, `comparison-project-2-3`); their [`Launch`] says what to show. use crate::state::AppState; use crate::theme_frame::builtin_frame; @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use tauri::utils::config::{Color, WindowConfig}; use tauri::{AppHandle, Emitter, Manager, Theme, WebviewWindow, WebviewWindowBuilder}; @@ -27,10 +27,11 @@ use tauri::{AppHandle, Emitter, Manager, Theme, WebviewWindow, WebviewWindowBuil pub const LAUNCH_EVENT: &str = "openidea://launch"; const MAIN: &str = "main"; const PREFIX: &str = "project-"; -const SETTINGS_PREFIX: &str = "settings-"; const CASCADE: f64 = 28.0; const SETTINGS_SIZE: (f64, f64) = (980.0, 720.0); const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0); +const COMPARISON_SIZE: (f64, f64) = (1100.0, 720.0); +const COMPARISON_MIN_SIZE: (f64, f64) = (640.0, 360.0); /// What a freshly opened window should do once its frontend is up. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -50,6 +51,32 @@ pub struct Launch { /// Where to place the caret in some of `files` (`openidea --line 12 --column 5 `). #[serde(default)] pub positions: Vec, + /// Comparison windows: the revisions to compare (boxed: most launches have none). + #[serde(default)] + pub comparison: Option>, +} + +/// A Git revision and how the comparison window names it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct ComparisonRevision { + pub rev: String, + pub label: String, +} + +/// Limits a comparison to some paths (Compare with Branch on a selection). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct ComparisonScope { + pub paths: Vec, + pub repositories: Option>, +} + +/// Files that differ between `from` and `to` (the working tree when `None`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct ComparisonLaunch { + pub from: ComparisonRevision, + pub to: Option, + pub scope: Option, + pub repo: Option, } /// A caret position for a file opened from the command line: 0-based line and column. @@ -85,6 +112,8 @@ pub struct WindowsState { pub tab_closes: Mutex>, /// Relaunch once the quit completes (after installing an update). restart: AtomicBool, + /// Numbers windows of kinds that can open several times per project window. + aux_serial: AtomicU64, } impl WindowsState { @@ -159,29 +188,99 @@ impl WindowsState { } } -/// The project window a settings window belongs to (`settings-main` → `main`). -pub fn settings_parent(label: &str) -> Option<&str> { - label.strip_prefix(SETTINGS_PREFIX) +/// A window that belongs to a project window (its parent) and shares its project. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuxKind { + Settings, + Comparison, } -pub fn is_settings_window(label: &str) -> bool { - settings_parent(label).is_some() -} +impl AuxKind { + const ALL: [AuxKind; 2] = [AuxKind::Settings, AuxKind::Comparison]; -pub fn settings_label(parent: &str) -> String { - format!("{SETTINGS_PREFIX}{parent}") -} + /// Label prefix, and the key of the one remembered geometry shared by every window of the kind. + pub fn name(self) -> &'static str { + match self { + AuxKind::Settings => "settings", + AuxKind::Comparison => "comparison", + } + } -/// Closes `parent`'s settings window: it shows (and would apply to) the project the parent is leaving. -pub fn close_settings_window(app: &AppHandle, parent: &str) { - if let Some(w) = app.get_webview_window(&settings_label(parent)) { - let _ = w.close(); + /// Several per project window (`--`), else one (`-`). + fn multiple(self) -> bool { + self == AuxKind::Comparison + } + + /// Shows the project's documents: gets its parent's events and edits made in sibling windows, + /// and asks about its own unsaved changes before the parent closes it. + pub fn shares_documents(self) -> bool { + self != AuxKind::Settings + } + + /// Modal for its parent: menu items other than app-level ones are dropped. + pub fn modal(self) -> bool { + self == AuxKind::Settings + } + + /// Needs its parent to have a project open. + fn needs_project(self) -> bool { + self != AuxKind::Settings } } -/// Project windows (everything but settings windows), for counting and cycling. +/// The kind and parent of an auxiliary window (`comparison-project-2-3` → `Comparison`, `project-2`). +pub fn auxiliary(label: &str) -> Option<(AuxKind, &str)> { + AuxKind::ALL.into_iter().find_map(|kind| { + let rest = label.strip_prefix(kind.name())?.strip_prefix('-')?; + let parent = if kind.multiple() { + let (parent, n) = rest.rsplit_once('-')?; + n.parse::().ok()?; + parent + } else { + rest + }; + (!parent.is_empty()).then_some((kind, parent)) + }) +} + +/// The project window an auxiliary window belongs to (`settings-main` → `main`). +pub fn auxiliary_parent(label: &str) -> Option<&str> { + auxiliary(label).map(|(_, parent)| parent) +} + +pub fn settings_label(parent: &str) -> String { + format!("{}-{parent}", AuxKind::Settings.name()) +} + +/// The window state key: one geometry per auxiliary kind, else the label itself. +pub fn state_key(label: &str) -> &str { + auxiliary(label).map_or(label, |(kind, _)| kind.name()) +} + +/// `parent`'s auxiliary windows that show its project's documents. +pub fn document_windows(app: &AppHandle, parent: &str) -> Vec { + app.webview_windows() + .into_keys() + .filter(|label| auxiliary(label).is_some_and(|(kind, p)| kind.shares_documents() && p == parent)) + .collect() +} + +/// Closes `parent`'s auxiliary windows: they show (and would apply to) the project it is leaving. +/// Windows with documents are destroyed: the parent's leave guard has already settled their +/// unsaved changes (`closeAuxiliaryWindows` in the UI). +pub fn close_auxiliary_windows(app: &AppHandle, parent: &str) { + for (label, window) in app.webview_windows() { + match auxiliary(&label) { + Some((kind, p)) if p == parent && kind.shares_documents() => drop(window.destroy()), + Some((_, p)) if p == parent => drop(window.close()), + _ => {} + } + } +} + +/// Project windows, excluding auxiliary windows, for counting and cycling. pub fn project_window_labels(app: &AppHandle) -> Vec { - sorted_labels(app.webview_windows().keys().map(String::as_str).filter(|l| !is_settings_window(l))) + sorted_labels(app.webview_windows().keys().map(String::as_str).filter(|l| auxiliary_parent(l).is_none())) } /// Sort key: `main` first, then `project-` by n, then anything else. @@ -295,7 +394,7 @@ pub fn continue_quit(app: &AppHandle, closed: Option<&str>) { /// The window whose open project is `root` (already canonical). pub fn find_project_window(app: &AppHandle, root: &Path) -> Option { let state = app.try_state::()?; - app.webview_windows().into_iter().filter(|(label, _)| !is_settings_window(label)).find_map(|(label, w)| { + app.webview_windows().into_iter().filter(|(label, _)| auxiliary_parent(label).is_none()).find_map(|(label, w)| { let project = state.project(&label).ok()?; (project.root == root && !project.light).then_some(w) }) @@ -839,6 +938,93 @@ pub fn cycle(app: &AppHandle, current: &str, forward: bool) { } } +/// An auxiliary window to open over its parent. +pub struct AuxWindow { + pub kind: AuxKind, + pub title: &'static str, + pub size: (f64, f64), + pub min_size: (f64, f64), + pub launch: Launch, + /// `#rrggbb` painted until the page has loaded (the theme's frame colour). + pub background: Option, +} + +/// Opens an auxiliary window of `caller`'s project window, centred over it. A kind with one window +/// per parent reuses it: the new launch is handed over ([`LAUNCH_EVENT`]) and the window focused. +pub fn open_auxiliary_window(app: &AppHandle, caller: &WebviewWindow, aux: AuxWindow) -> IdeResult<()> { + // Asked from an auxiliary window itself: open it for that window's parent. + let parent_label = auxiliary_parent(caller.label()).unwrap_or(caller.label()).to_owned(); + if aux.kind.needs_project() { + let state = app.state::(); + if state.project(&parent_label).is_err() && state.remote_dev.get(&parent_label).is_none() { + return Err(IdeError::not_found("no project is open")); + } + } + let windows = app.state::(); + let label = if aux.kind.multiple() { + format!("{}-{parent_label}-{}", aux.kind.name(), windows.aux_serial.fetch_add(1, Ordering::Relaxed)) + } else { + format!("{}-{parent_label}", aux.kind.name()) + }; + if let Some(existing) = app.get_webview_window(&label) { + if aux.launch != Launch::default() { + windows.set_launch(&label, aux.launch); + let _ = app.emit_to(label.as_str(), LAUNCH_EVENT, ()); + } + focus(&existing); + return Ok(()); + } + let parent = app.get_webview_window(&parent_label).ok_or_else(|| IdeError::not_found("no parent window"))?; + let mut config = window_config(app)?; + config.label = label.clone(); + config.title = aux.title.into(); + // Frameless on Windows and Linux like project windows (tauri..conf.json): the page draws + // the title bar in the app theme instead of a system caption that follows the OS theme. + (config.width, config.height) = aux.size; + (config.min_width, config.min_height) = (Some(aux.min_size.0), Some(aux.min_size.1)); + config.center = false; + config.visible = false; + config.maximized = false; + config.fullscreen = false; + config.drag_drop_enabled = false; + // Paint the theme's frame colour before the page loads. + if let Some(color) = aux.background.as_deref().and_then(hex_color) { + config.background_color = Some(color); + } + windows.set_launch(&label, aux.launch); + #[cfg(target_os = "linux")] + crate::linux_window_state::prepare(app, &mut config); + let builder = WebviewWindowBuilder::from_config(app, &config); + // Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM + // shadow, leaving the frameless window flat against its parent. + // Ownership is tracked by the `-` label instead + // (parent-close cleanup, menu modality), so only Linux keeps the native + // parent link. AppKit child windows can disappear when dragged to another + // display's Space, so macOS keeps it too. + #[cfg(target_os = "linux")] + let builder = builder.and_then(|b| b.parent(&parent)); + let window = builder.and_then(|b| dev_font_preferences(b).build()).map_err(|e| { + windows.forget(&label); + IdeError::internal(format!("cannot create {} window: {e}", aux.kind.name())) + })?; + #[cfg(target_os = "linux")] + decorate_csd(&window); + apply_zoom(app, &window); + #[cfg(target_os = "macos")] + align_titlebar(app, &window); + // Restore runs during build; fit its size to the parent's current display before showing. + #[cfg(target_os = "macos")] + if let Err(e) = fit_over_parent(&window, &parent, aux.min_size) { + tracing::warn!("cannot fit {} window: {e}", aux.kind.name()); + } + #[cfg(not(target_os = "macos"))] + if let Err(e) = place_over_parent(&window, &parent) { + tracing::warn!("cannot place {} window: {e}", aux.kind.name()); + } + focus(&window); + Ok(()) +} + /// Opens (or focuses) the settings window of `parent` showing `page`: a /// separate window centred over its parent, sized like IDEA's Settings. pub fn open_settings_window( @@ -848,71 +1034,45 @@ pub fn open_settings_window( new_projects: bool, background: Option, ) -> IdeResult<()> { - // Asked from a settings window itself: navigate it. - let parent_label = settings_parent(parent.label()).unwrap_or(parent.label()).to_owned(); - let label = settings_label(&parent_label); - let windows = app.state::(); - if let Some(existing) = app.get_webview_window(&label) { - if page.is_some() || new_projects { - windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() }); - let _ = app.emit_to(label.as_str(), LAUNCH_EVENT, ()); - } - focus(&existing); - return Ok(()); - } - let parent = app.get_webview_window(&parent_label).ok_or_else(|| IdeError::not_found("no parent window"))?; - let mut config = window_config(app)?; - config.label = label.clone(); - config.title = "Settings".into(); - // Frameless on Windows and Linux like project windows (tauri..conf.json): the page draws - // the title bar in the app theme instead of a system caption that follows the OS theme. - (config.width, config.height) = SETTINGS_SIZE; - (config.min_width, config.min_height) = (Some(SETTINGS_MIN_SIZE.0), Some(SETTINGS_MIN_SIZE.1)); - config.center = false; - config.visible = false; - config.maximized = false; - config.fullscreen = false; - config.drag_drop_enabled = false; - // Paint the theme's frame colour before the page loads. - if let Some(color) = background.as_deref().and_then(hex_color) { - config.background_color = Some(color); - } - windows.set_launch(&label, Launch { settings_page: page, new_projects, ..Default::default() }); - #[cfg(target_os = "linux")] - crate::linux_window_state::prepare(app, &mut config); - let builder = WebviewWindowBuilder::from_config(app, &config); - // Windows: an owned child (WS_POPUP) of a borderless parent gets no DWM - // shadow, leaving the frameless settings window flat against its parent. - // Ownership is tracked by the `settings-` label instead - // (parent-close cleanup, menu modality), so only Linux keeps the native - // parent link. AppKit child windows can disappear when dragged to another - // display's Space, so macOS keeps it too. - #[cfg(target_os = "linux")] - let builder = builder.and_then(|b| b.parent(&parent)); - let window = builder - .and_then(|b| dev_font_preferences(b).build()) - .map_err(|e| IdeError::internal(format!("cannot create settings window: {e}")))?; - #[cfg(target_os = "linux")] - decorate_csd(&window); - apply_zoom(app, &window); - #[cfg(target_os = "macos")] - align_titlebar(app, &window); - // Restore runs during build; fit its size to the parent's current display before showing. - #[cfg(target_os = "macos")] - if let Err(e) = fit_settings_window(&window, &parent) { - tracing::warn!("cannot fit settings window: {e}"); - } - #[cfg(not(target_os = "macos"))] - if let Err(e) = place_settings_window(&window, &parent) { - tracing::warn!("cannot place settings window: {e}"); - } - focus(&window); - Ok(()) + open_auxiliary_window( + app, + parent, + AuxWindow { + kind: AuxKind::Settings, + title: "Settings", + size: SETTINGS_SIZE, + min_size: SETTINGS_MIN_SIZE, + launch: Launch { settings_page: page, new_projects, ..Default::default() }, + background, + }, + ) +} + +/// Opens a window listing the files that differ between two revisions (Compare with Local and +/// friends), next to its project window like IDEA's diff windows. +pub fn open_comparison_window( + app: &AppHandle, + parent: &WebviewWindow, + comparison: ComparisonLaunch, + background: Option, +) -> IdeResult<()> { + open_auxiliary_window( + app, + parent, + AuxWindow { + kind: AuxKind::Comparison, + title: "Compare", + size: COMPARISON_SIZE, + min_size: COMPARISON_MIN_SIZE, + launch: Launch { comparison: Some(Box::new(comparison)), ..Default::default() }, + background, + }, + ) } /// Fit outer bounds, including native decorations, using one monitor's logical coordinate space. #[cfg(target_os = "macos")] -fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { +fn fit_over_parent(window: &WebviewWindow, parent: &WebviewWindow, min_size: (f64, f64)) -> tauri::Result<()> { let Some(monitor) = parent.current_monitor()?.or(parent.primary_monitor()?) else { return Ok(()); }; @@ -935,8 +1095,8 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: // A small work area takes precedence over the usual minimum window size. window.set_min_size(Some( tauri::LogicalSize::new( - SETTINGS_MIN_SIZE.0.min((available.width - frame.0).max(1.0)), - SETTINGS_MIN_SIZE.1.min((available.height - frame.1).max(1.0)), + min_size.0.min((available.width - frame.0).max(1.0)), + min_size.1.min((available.height - frame.1).max(1.0)), ) .to_physical::(scale), ))?; @@ -953,7 +1113,7 @@ fn fit_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri: /// may still be on another display with a different scale, so its size is converted with its own /// scale and its position is given in physical pixels. #[cfg(not(target_os = "macos"))] -pub(super) fn place_settings_window(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { +pub(super) fn place_over_parent(window: &WebviewWindow, parent: &WebviewWindow) -> tauri::Result<()> { // Placement setters leave maximized mode. Fit the restored frame, then reapply the saved state. // A restored fullscreen window already covers its display and must not be fitted to the work area. if window.is_fullscreen()? { @@ -1340,12 +1500,17 @@ mod tests { } #[test] - fn settings_windows_map_to_their_parent() { + fn auxiliary_windows_map_to_their_parent() { assert_eq!(settings_label("project-2"), "settings-project-2"); - assert_eq!(settings_parent("settings-main"), Some("main")); - assert_eq!(settings_parent("main"), None); - assert!(is_settings_window("settings-project-1")); - assert!(!is_settings_window("project-1")); + assert_eq!(auxiliary("settings-main"), Some((AuxKind::Settings, "main"))); + assert_eq!(auxiliary("comparison-project-2-14"), Some((AuxKind::Comparison, "project-2"))); + for label in ["main", "project-1", "comparison-main", "comparison-main-x", "settings-", "settingsmain"] { + assert_eq!(auxiliary(label), None, "{label}"); + } + assert_eq!(state_key("comparison-main-3"), "comparison"); + assert_eq!(state_key("project-3"), "project-3"); + let labels = ["comparison-main-0", "project-2", "main", "settings-main", "comparison-project-2-1"]; + assert_eq!(sorted_labels(labels.into_iter().filter(|l| auxiliary_parent(l).is_none())), ["main", "project-2"]); } #[test] diff --git a/src/bindings.ts b/src/bindings.ts index f8cde114..9a433942 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -769,6 +769,11 @@ export const commands = { * `background` (`#rrggbb`) paints a new window until its page has loaded. */ windowOpenSettings: (page: string | null, newProjects: boolean, background: string | null) => typedError(__TAURI_INVOKE("window_open_settings", { page, newProjects, 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. + */ + windowOpenComparison: (comparison: ComparisonLaunch, background: string | null) => typedError(__TAURI_INVOKE("window_open_comparison", { comparison, background })), /** Focuses the next / previous window (⌘` / ⇧⌘`). */ windowCycle: (forward: boolean) => __TAURI_INVOKE("window_cycle", { forward }), windowToggleFullscreen: () => typedError(__TAURI_INVOKE("window_toggle_fullscreen")), @@ -791,6 +796,8 @@ export const commands = { newProjects: boolean, /** Where to place the caret in some of `files` (`openidea --line 12 --column 5 `). */ positions?: FilePosition[], + /** Comparison windows: the revisions to compare (boxed: most launches have none). */ + comparison?: ComparisonLaunch | null, } | null>("window_take_launch"), windowCloseContext: () => __TAURI_INVOKE("window_close_context"), /** Projects open in project windows, in window order (title bar project widget). */ @@ -2126,6 +2133,26 @@ export type CompareScope = { singleFile: boolean, }; +/** Files that differ between `from` and `to` (the working tree when `None`). */ +export type ComparisonLaunch = { + from: ComparisonRevision, + to: ComparisonRevision | null, + scope: ComparisonScope | null, + repo: IpcPath | null, +}; + +/** A Git revision and how the comparison window names it. */ +export type ComparisonRevision = { + rev: string, + label: string, +}; + +/** Limits a comparison to some paths (Compare with Branch on a selection). */ +export type ComparisonScope = { + paths: IpcPath[], + repositories: IpcPath[] | null, +}; + export type CompletionItem = { label: string, /** e.g. `(req: CreateOrderRequest)` shown right after the label. */ @@ -4462,6 +4489,8 @@ export type Launch = { newProjects: boolean, /** Where to place the caret in some of `files` (`openidea --line 12 --column 5 `). */ positions?: FilePosition[], + /** Comparison windows: the revisions to compare (boxed: most launches have none). */ + comparison?: ComparisonLaunch | null, }; export type LaunchConfig = { diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 2eedcb92..c08195de 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -12,6 +12,7 @@ import { t } from "@/platform/i18n"; import { closeProject, provideLeaveProjectGuard, useProject } from "@/platform/project"; import { maybeEditorService, notifications, type EditorCloseSession } from "@/workbench/contributions"; import { ask, type Ask } from "@/workbench/messages"; +import { AUX_CLOSED, auxiliaryWindow, closeDocumentWindows, type AuxCloseResult } from "@/platform/auxWindow"; /** Document ids the backend reports dirty in this window. */ const dirtyDocs = new Set(); @@ -108,6 +109,10 @@ async function onCloseRequested(deps: CloseDeps): Promise { closing = true; try { const ctx = await commands.windowCloseContext(); + if (!(await closeDocumentWindows())) { + await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose()); + return false; + } const s = useProject.getState(); // Closing the last project (or LightEdit) window closes its project and shows the welcome // screen (to open another one); closing the welcome screen, or quitting, closes the window. @@ -137,13 +142,16 @@ async function onCloseRequested(deps: CloseDeps): Promise { } } -/** Unsaved changes before this window opens another project in place or closes it. */ -export function confirmLeave(deps: CloseDeps = defaultDeps): Promise { - const s = useProject.getState(); - return decideClose( - { quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project: s.project?.name ?? null }, - deps, - ); +/** + * Unsaved changes before this window opens another project in place or closes it. `project` names + * the project in the prompt when this window doesn't hold it (a comparison window). + */ +export async function confirmLeave( + deps: CloseDeps = defaultDeps, + project = useProject.getState().project?.name ?? null, +): Promise { + if (!(await closeDocumentWindows())) return false; + return decideClose({ quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project }, deps); } /** Quit: confirm once (if enabled), then let the backend close every window. */ @@ -174,11 +182,26 @@ export function installCloseGuard(deps: CloseDeps = defaultDeps): () => void { let disposed = false; if (isTauri()) { void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => { + // Documents an auxiliary window released are no longer dirty unless this window edits them too. + const offAux = await getCurrentWindow().listen(AUX_CLOSED, ({ payload }) => { + if ( + !payload.closed || + auxiliaryWindow(payload.label)?.parent !== getCurrentWindow().label || + !Array.isArray(payload.documents) + ) + return; + const own = new Set(maybeEditorService()?.dirtyDocumentIds?.() ?? []); + for (const doc of payload.documents) if (!own.has(doc)) dirtyDocs.delete(doc); + }); const off = await getCurrentWindow().onCloseRequested(async (event) => { if (!(await onCloseRequested(deps))) event.preventDefault(); }); - if (disposed) off(); - else unlisten = off; + const stop = () => { + off(); + offAux(); + }; + if (disposed) stop(); + else unlisten = stop; }); } return () => { diff --git a/src/features/app/index.ts b/src/features/app/index.ts index 57cffc85..d86eef2c 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -20,8 +20,11 @@ import { installLaunchHandling, installLightEditAutoClose, windowActions } from import { WorkspaceDialogHost } from "./WorkspaceDialog"; import { installWindowGroups } from "@/platform/windowGroups"; -/** The UI locale of a secondary (Settings) window. */ +/** The UI locale of a secondary (Settings, comparison) window. */ export const activateLocale = installLocaleSync; +/** Native menu items of a secondary window that runs actions itself (comparison). */ +export const activateMenuBridge = installMenuBridge; +export { confirmLeave } from "./closeGuard"; export function activate(): () => void { const disposers = [ diff --git a/src/features/index.ts b/src/features/index.ts index 6286ef08..24df08c5 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -12,7 +12,7 @@ import { activate as projectTree } from "./project-tree"; import { activate as search } from "./search"; import { activate as dependencies } from "./dependencies"; import { activate as codeInsight } from "./code-insight"; -import { activate as vcs, activateSettingsPages as vcsPages } from "./vcs"; +import { activate as vcs, activateComparisonWindow, activateSettingsPages as vcsPages } from "./vcs"; import { activate as terminal } from "./terminal"; import { activate as run } from "./run"; import { activate as runAnything } from "./run-anything"; @@ -24,8 +24,8 @@ import { activate as buildTools } from "./build-tools"; import { activate as build } from "./build"; import { activate as templates, activateSettingsPages as templatesPages } from "./templates"; import { activate as todo, activateSettingsPages as todoPages } from "./todo"; -import { activate as settings, activateSettingsWindow } from "./settings"; -import { activate as app, activateLocale as appLocale } from "./app"; +import { activate as settings, activateRuntimeSettings, activateSettingsWindow } from "./settings"; +import { activate as app, activateLocale as appLocale, activateMenuBridge, confirmLeave } from "./app"; import { activate as perf } from "./perf"; import { activate as plugins, activateSettingsPages as pluginsPages } from "./plugins"; import { activate as tools, activateSettingsPages as toolsPages } from "./tools"; @@ -90,20 +90,40 @@ const features: Array<[string, Activate]> = [ ["remote-dev", remoteDev], ]; -export function activateFeatures(): () => void { +function activateAll(what: string, list: Array<[string, Activate]>): () => void { const disposers: Array<() => void> = []; - for (const [name, activate] of features) { + for (const [name, activate] of list) { try { const d = activate(); if (d) disposers.push(d); } catch (e) { - console.error(`feature ${name} failed to activate`, e); + console.error(`${what} of ${name} failed to activate`, e); } } return () => disposers.reverse().forEach((d) => d()); } -export { SettingsWindow, settingsWindowParent } from "./settings"; +export function activateFeatures(): () => void { + return activateAll("feature", features); +} + +export { SettingsWindow } from "./settings"; +export { ComparisonWindow } from "./vcs"; + +/** + * A comparison window of project window `parent`: the changed files between two revisions and + * their diffs, with the editor and Git but no workbench. + */ +export function activateComparisonWindowFeatures(parent: string): () => void { + return activateAll("comparison window", [ + ["locale", appLocale], + ["settings", activateRuntimeSettings], + ["menu", activateMenuBridge], + ["editor", editor], + ["vcs", vcs], + ["comparison", () => activateComparisonWindow(parent, (project) => confirmLeave(undefined, project))], + ]); +} /** * The native Settings window of project window `parent`: only the settings @@ -122,14 +142,5 @@ export function activateSettingsWindowFeatures(parent: string): () => void { ["vim", vimPages], ["vcs", vcsPages], ]; - const disposers: Array<() => void> = []; - for (const [name, activate] of pages) { - try { - const d = activate(); - if (d) disposers.push(d); - } catch (e) { - console.error(`settings pages of ${name} failed to activate`, e); - } - } - return () => disposers.reverse().forEach((d) => d()); + return activateAll("settings pages", pages); } diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 802a17da..b78ba378 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -40,7 +40,8 @@ import { activatePythonStatus } from "./languages/PythonStatus"; import { nativeToAsciiAction } from "./nativeToAscii"; import { isImeKey } from "@/platform/ime"; -export { settingsWindowParent } from "./window/bridge"; +/** Settings-driven runtime state (zoom, fonts, theme) of a secondary window. */ +export const activateRuntimeSettings = trackRuntimeSettings; export const SettingsWindow = lazyComponent(() => import("./window/SettingsWindow").then((m) => m.SettingsWindow)); /** Same event as `windows::LAUNCH_EVENT`: a pending launch (page) is waiting. */ diff --git a/src/features/vcs/ComparisonWindow.tsx b/src/features/vcs/ComparisonWindow.tsx new file mode 100644 index 00000000..dd7d0dc1 --- /dev/null +++ b/src/features/vcs/ComparisonWindow.tsx @@ -0,0 +1,196 @@ +/** + * The comparison window (`comparison--`): files that differ between two revisions of + * its parent's project, and their diff, in a window of its own (IDEA's Changes Between dialog). + */ +import clsx from "clsx"; +import { useEffect } from "react"; +import { create } from "zustand"; +import { emitTo } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { editorController } from "@/editor/core/controller"; +import { AUX_CLOSED, type AuxCloseResult } from "@/platform/auxWindow"; +import { setContext } from "@/platform/context"; +import { isImeKey } from "@/platform/ime"; +import { call, commands, type ComparisonLaunch } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { restoreRemote } from "@/platform/remote"; +import { usePlatform } from "@/platform/platformInfo"; +import { Button, Spinner, T, isPopupOpen } from "@/ui"; +import { editorService, notifications } from "@/workbench/contributions"; +import { EditorArea } from "@/workbench/EditorArea"; +import { OverlayHost } from "@/workbench/OverlayHost"; +import { TitleBar } from "@/workbench/TitleBar"; +import { useApplyTheme } from "@/workbench/theme"; +import { diffInput, type DiffData } from "./diffInput"; +import { ChangesBetweenDialog } from "./operations"; +import { refreshNow, useVcs } from "./store"; +import styles from "./common.module.css"; + +const useComparison = create<{ + launch: ComparisonLaunch | null; + title: string; + full: boolean; + error: string | null; +}>(() => ({ launch: null, title: "", full: false, error: null })); + +/** + * Loads the comparison of this window's launch. `confirmLeave` settles unsaved changes of the + * documents edited here, in the named project, before the window closes. + */ +export function activateComparisonWindow( + parent: string, + confirmLeave: (project: string | null) => Promise, +): () => void { + let projectName: string | null = null; + const native = getCurrentWindow(); + let disposed = false; + let closing = false; + let stopClose: (() => void) | undefined; + void native + .onCloseRequested(async (event) => { + if (closing) { + event.preventDefault(); + return; + } + closing = true; + const models = [...editorController().manager.byId.values()]; + let closed = false; + try { + await Promise.all(models.map((model) => model.sync.whenIdle())); + closed = await confirmLeave(projectName); + if (closed) { + for (const model of models) model.dispose(); + await Promise.all( + models.map((model) => + commands.docClose(model.doc).catch((error) => console.warn("cannot release comparison document", error)), + ), + ); + } + } catch (error) { + notifications().notify({ level: "error", title: "Could not close comparison", body: String(error) }); + } finally { + if (!closed) event.preventDefault(); + const result: AuxCloseResult = { + label: native.label, + closed, + documents: closed ? models.map((model) => model.doc) : [], + }; + await emitTo(parent, AUX_CLOSED, result).catch((error) => + console.warn("cannot report comparison close", error), + ); + closing = false; + } + }) + .then((off) => { + if (disposed) off(); + else stopClose = off; + }); + const onKey = (event: KeyboardEvent) => { + if (event.defaultPrevented || isImeKey(event) || isPopupOpen()) return; + if (event.key === "Escape") { + event.preventDefault(); + void native.close(); + } + }; + const onCloseShortcut = (event: KeyboardEvent) => { + if (isImeKey(event) || isPopupOpen()) return; + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "w") { + event.preventDefault(); + event.stopImmediatePropagation(); + void native.close(); + } + }; + window.addEventListener("keydown", onKey); + window.addEventListener("keydown", onCloseShortcut, true); + void (async () => { + await restoreRemote(); + const launch = (await commands.windowTakeLaunch())?.comparison; + if (!launch) throw new Error("Missing comparison request"); + const project = await commands.projectCurrent(); + if (!project) throw new Error("The comparison's project is no longer open"); + projectName = project.name; + // Settings only: restoring the project's editor session here would overwrite the main window's tabs. + useProject.setState({ settings: project.settings }); + const roots = await call(commands.vcsRoots()); + useVcs.setState({ + available: roots.length > 0, + root: project.root, + roots: roots.map((root) => root.root), + repo: launch.repo, + }); + setContext("projectOpen", true); + setContext("vcsAvailable", roots.length > 0); + await refreshNow(); + useComparison.setState({ launch }); + })().catch((error) => useComparison.setState({ error: String(error) })); + return () => { + disposed = true; + stopClose?.(); + window.removeEventListener("keydown", onKey); + window.removeEventListener("keydown", onCloseShortcut, true); + }; +} + +/** The comparison's caption, on the title bar and the native window (Window menu, task switcher). */ +function showCaption(caption: string) { + if (useComparison.getState().title === caption) return; + useComparison.setState({ title: caption }); + void getCurrentWindow() + .setTitle(caption) + .catch(() => {}); +} + +export function ComparisonWindow() { + useApplyTheme(); + const { launch, title, full, error } = useComparison(); + // macOS overlays traffic lights on the bar; Windows and Linux are frameless, like Settings. + const os = usePlatform((s) => s.os); + const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar); + const showDiff = (data: DiffData) => { + void editorService() + .openInput(diffInput(data), { focus: true }) + .then(() => useComparison.setState({ full: true })) + .catch((error) => notifications().notify({ level: "error", title: "Could not show diff", body: String(error) })); + }; + useEffect(() => { + if (full) editorService().focus?.(); + }, [full]); + return ( +
+ {ownTitleBar && } + {error ? ( +
+ {error} +
+ ) : !launch ? ( +
+ + Loading… +
+ ) : ( + <> + {full && ( +
+ +
+ )} + + + + )} + +
+ ); +} diff --git a/src/features/vcs/common.module.css b/src/features/vcs/common.module.css index 34685628..d8464a18 100644 --- a/src/features/vcs/common.module.css +++ b/src/features/vcs/common.module.css @@ -280,6 +280,64 @@ background: var(--bg-list-selected); } +.comparisonDialog > [data-dialog-body] { + display: flex; + flex-direction: column; +} +.comparisonDialog .comparison { + flex: 1 1 auto; +} +.comparisonWindow { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--bg-dialog, var(--bg-elev)); + color: var(--fg); +} +/* Linux: frameless X11 windows have no shadow, so mark the edge in CSS (like the Settings window). */ +.comparisonWindowEdge { + position: relative; +} +.comparisonWindowEdge::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + box-shadow: inset 0 0 0 1px var(--border-window); +} +.comparisonWindowLoading { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--fg-muted); +} +.comparisonWindowBody { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} +.comparisonWindowBody[hidden] { + display: none; +} +.comparisonWindowBody > .comparisonWindow { + flex: 1; +} +.comparisonWindowBody > .comparison { + flex: 1 1 auto; +} +.comparisonWindowFooter { + display: flex; + justify-content: flex-end; + flex: none; + gap: 12px; + padding: 12px 16px; +} .comparison { display: flex; height: min(520px, 65vh); diff --git a/src/features/vcs/index.ts b/src/features/vcs/index.ts index 8d2234d8..703f3172 100644 --- a/src/features/vcs/index.ts +++ b/src/features/vcs/index.ts @@ -147,6 +147,8 @@ function conflictsBody(names: string[]): string { } /** Settings › Version Control › Log (also used by the Settings window). */ +export { activateComparisonWindow, ComparisonWindow } from "./ComparisonWindow"; + export function activateSettingsPages(): () => void { return registerSettingsPage({ id: "vcs.log", diff --git a/src/features/vcs/operations.tsx b/src/features/vcs/operations.tsx index 51ac6ec4..4a48ebb5 100644 --- a/src/features/vcs/operations.tsx +++ b/src/features/vcs/operations.tsx @@ -7,9 +7,10 @@ import { commitKey, commitRepo } from "./multiRoot"; import { useEffect, useMemo, useState } from "react"; import type { ChangedFile, Commit, CompareResult, ResetMode, WorkingTreeChange } from "@/bindings"; import { executeAction } from "@/platform/actions"; -import { call, commands, nextRequestId } from "@/platform/ipc"; -import { Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui"; +import { call, commands, isTauri, nextRequestId } from "@/platform/ipc"; +import { Button, Checkbox, Dialog, Icon, Input, Spinner, T } from "@/ui"; import { notifications } from "@/workbench/contributions"; +import { windowBackgroundHex } from "@/workbench/theme"; import { diffInput, openDiff, type DiffData, type DiffSide } from "./diffInput"; import { openDialog, promptText } from "./dialogs"; import { KIND_COLOR, fileCount } from "./model"; @@ -460,31 +461,32 @@ export function showCompareWithLocal(commit: Commit) { /** Files that differ between `rev` (a commit or branch) and the working tree. */ export function showDiffWithWorkingTree(rev: string, label = rev) { - openDialog((close) => ); + showComparison({ rev, label }, null); } /** IDEA's Compare Versions of two Git Log commits: files that differ between them, older first. */ export function showCompareVersions(older: Commit, newer: Commit) { - openDialog((close) => ( - - )); + showComparison({ rev: older.id, label: short(older.id) }, { rev: newer.id, label: short(newer.id) }); } /** Files that differ between two revisions (e.g. two branches from Compare with Current). */ export function showCompareRevisions(from: string, to: string, fromLabel = from, toLabel = to) { - openDialog((close) => ( - - )); + showComparison({ rev: from, label: fromLabel }, { rev: to, label: toLabel }); } export function showPathComparison(paths: string[], rev: string, repositories: string[] | null) { - openDialog((close) => ( - - )); + showComparison({ rev, label: rev }, null, { paths, repositories }); +} + +function showComparison(from: Revision, to: Revision | null, scope?: ScopedComparison) { + if (!isTauri()) { + openDialog((close) => ); + return; + } + const comparison = { from, to, scope: scope ?? null, repo: currentRepo() }; + void call(commands.windowOpenComparison(comparison, windowBackgroundHex())).catch((error) => + notifications().notify({ level: "error", title: "Could not open comparison", body: String(error) }), + ); } interface Revision { @@ -516,11 +518,18 @@ export function ChangesBetweenDialog({ to, scope, onClose, + native = false, + onCaption, + onShowDiff, }: { from: Revision; to: Revision | null; scope?: ScopedComparison; onClose: () => void; + /** Content of the comparison window rather than a dialog: its caption goes to `onCaption`. */ + native?: boolean; + onCaption?: (caption: string) => void; + onShowDiff?: (data: DiffData) => void; }) { const [root] = useState(currentRepoRoot); const [repo] = useState(currentRepo); @@ -607,27 +616,20 @@ export function ChangesBetweenDialog({ const skipped = result?.skippedPaths.map((path) => displayPath(path)) ?? []; const skippedNames = skipped.slice(0, 5).join(", "); const skippedLabel = skipped.length > 5 ? tx`${skippedNames} and ${skipped.length - 5} more` : skippedNames; - return ( - { - if (current) { - void openDiff(diffFor(current)); - onClose(); - } - }, - }, - { label: "Close", role: "cancel" }, - ]} - > + const caption = files ? `${title} — ${count}` : title; + useEffect(() => { + if (native) onCaption?.(caption); + }, [native, onCaption, caption]); + const showDiff = () => { + if (!current) return; + if (onShowDiff) onShowDiff(diffFor(current)); + else { + void openDiff(diffFor(current)); + onClose(); + } + }; + const contents = ( + <> {error && (
{error} @@ -682,6 +684,34 @@ export function ChangesBetweenDialog({ } /> ) : null} + + ); + if (native) + return ( +
+
{contents}
+
+ + +
+
+ ); + return ( + + {contents} ); } diff --git a/src/main.tsx b/src/main.tsx index 31825a44..cb709b39 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,7 +3,14 @@ import ReactDOM from "react-dom/client"; import "./styles/global.css"; import { Workbench } from "./workbench/Workbench"; import { activateSecondaryWindow, activateWorkbench } from "./workbench/activate"; -import { activateFeatures, activateSettingsWindowFeatures, SettingsWindow, settingsWindowParent } from "./features"; +import { + activateComparisonWindowFeatures, + activateFeatures, + activateSettingsWindowFeatures, + ComparisonWindow, + SettingsWindow, +} from "./features"; +import { auxiliaryWindow } from "./platform/auxWindow"; import { installRepeatedDoubleClick } from "./platform/doubleClick"; import { installErrorLogging } from "./platform/errorLog"; import { installKeyboardDispatcher } from "./platform/keymap"; @@ -21,12 +28,16 @@ if (import.meta.env.DEV && isTauri()) { installRepeatedDoubleClick(); installErrorLogging(); -// The native Settings window (`settings-`) loads this page too. -const settingsParent = settingsWindowParent(); +// Auxiliary windows (Settings `settings-`, comparisons `comparison--`) load this page too. +const aux = auxiliaryWindow(); -if (settingsParent !== null) { +if (aux?.kind === "settings") { activateSecondaryWindow(); - activateSettingsWindowFeatures(settingsParent); + activateSettingsWindowFeatures(aux.parent); +} else if (aux?.kind === "comparison") { + activateSecondaryWindow(); + activateComparisonWindowFeatures(aux.parent); + installKeyboardDispatcher(); } else { activateWorkbench(); // `vite build --mode perf` is a production bundle for the performance harness (`perf/`). @@ -46,8 +57,10 @@ if (settingsParent !== null) { } ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - {settingsParent !== null ? : }, + + {aux?.kind === "settings" ? : aux?.kind === "comparison" ? : } + , ); hideSplash(); // Views split out of the start-up bundle load in the background once the UI is up. -if (settingsParent === null) prefetchLazyComponents(); +if (!aux) prefetchLazyComponents(); diff --git a/src/platform/auxWindow.test.ts b/src/platform/auxWindow.test.ts new file mode 100644 index 00000000..27356664 --- /dev/null +++ b/src/platform/auxWindow.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { auxiliaryWindow } from "./auxWindow"; + +describe("auxiliaryWindow", () => { + it("reads the kind and parent from the label", () => { + expect(auxiliaryWindow("settings-main")).toEqual({ kind: "settings", parent: "main" }); + expect(auxiliaryWindow("settings-project-2")).toEqual({ kind: "settings", parent: "project-2" }); + expect(auxiliaryWindow("comparison-main-0")).toEqual({ kind: "comparison", parent: "main" }); + expect(auxiliaryWindow("comparison-project-2-14")).toEqual({ kind: "comparison", parent: "project-2" }); + }); + + it("leaves project windows and malformed labels alone", () => { + for (const label of ["", "main", "project-1", "settings-", "comparison-main", "comparison-main-x"]) { + expect(auxiliaryWindow(label)).toBeNull(); + } + }); +}); diff --git a/src/platform/auxWindow.ts b/src/platform/auxWindow.ts new file mode 100644 index 00000000..24a5b1f1 --- /dev/null +++ b/src/platform/auxWindow.ts @@ -0,0 +1,78 @@ +/** + * Auxiliary windows: Settings and comparison windows that belong + * to a project window (their parent) and share its project. Labels name the + * kind and the parent (`settings-main`, `comparison-project-2-3`), mirroring + * `windows::auxiliary` in src-tauri/src/windows.rs. + */ +import { getAllWindows, getCurrentWindow } from "@tauri-apps/api/window"; +import { TauriEvent } from "@tauri-apps/api/event"; +import { isTauri } from "./ipc"; + +export type AuxKind = "settings" | "comparison"; + +/** Kinds with several windows per parent (`--`); the others have one (`-`). */ +const MULTIPLE: ReadonlySet = new Set(["comparison"]); +const KINDS: readonly AuxKind[] = ["settings", "comparison"]; + +/** An auxiliary window reports to its parent whether it closed, and which documents it released. */ +export const AUX_CLOSED = "openidea://auxiliary/closed"; + +export interface AuxCloseResult { + label: string; + closed: boolean; + documents: number[]; +} + +function currentLabel(): string { + if (!isTauri()) return ""; + try { + return getCurrentWindow().label; + } catch { + return ""; + } +} + +/** The kind and parent of an auxiliary window, or null for a project window. */ +export function auxiliaryWindow(label = currentLabel()): { kind: AuxKind; parent: string } | null { + for (const kind of KINDS) { + if (!label.startsWith(`${kind}-`)) continue; + const rest = label.slice(kind.length + 1); + const parent = MULTIPLE.has(kind) ? /^(.+)-\d+$/.exec(rest)?.[1] : rest; + if (parent) return { kind, parent }; + } + return null; +} + +/** + * Closes this project window's auxiliary windows that show documents, one at a time: each settles + * its own unsaved changes first. False when one stays open (the user cancelled). + */ +export async function closeDocumentWindows(): Promise { + if (!isTauri()) return true; + const parent = getCurrentWindow(); + const children = (await getAllWindows()).filter((window) => { + const aux = auxiliaryWindow(window.label); + return aux !== null && aux.kind !== "settings" && aux.parent === parent.label; + }); + for (const child of children) { + let finish!: (closed: boolean) => void; + const result = new Promise((resolve) => { + finish = resolve; + }); + const offResult = await parent.listen(AUX_CLOSED, ({ payload }) => { + if (payload.label === child.label && !payload.closed) finish(false); + }); + const offDestroyed = await child.once(TauriEvent.WINDOW_DESTROYED, () => finish(true)); + try { + await child.unminimize(); + await child.show(); + await child.setFocus(); + await child.close(); + if (!(await result)) return false; + } finally { + offResult(); + offDestroyed(); + } + } + return true; +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index dcd8bf04..4f4ff1ed 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5924,5 +5924,9 @@ "Couldn't create the worktree for #{number}": "无法为 #{number} 创建工作树", "Couldn't commit the suggested change": "无法提交建议的更改", "Couldn't apply the suggested change": "无法应用建议的更改", - "Couldn't resolve the thread": "无法解决讨论串" + "Couldn't resolve the thread": "无法解决讨论串", + "Could not close comparison": "无法关闭比较窗口", + "Could not open comparison": "无法打开比较窗口", + "Could not show diff": "无法显示差异", + "Back to Changed Files": "返回更改文件列表" }