From 0a70b0af01373bc0fdc9aa2a44ff47f7b5de0d05 Mon Sep 17 00:00:00 2001 From: xdpan1234 Date: Wed, 4 Nov 2026 12:27:45 +0800 Subject: [PATCH] fix(vcs): open comparisons in independently resizable native windows (#818) Compare with Local, Compare Versions and the other revision comparisons open in a separate window that belongs to their project window. The window can be moved outside the main window, resized from its edges and corners, and shows the full diff. Edits made there and in the project window are mirrored both ways. Root cause of #798: the comparison was an in-window dialog with a fixed size. Settings and comparison windows now share one auxiliary-window mechanism (`AuxKind`, `open_auxiliary_window`). It handles labels, the title bar, placement over the parent, event forwarding, document-edit mirroring, menu routing, closing together with the parent, and remembered geometry per kind. Co-authored-by: xdpan1234 <277608284+xdpan1234@users.noreply.github.com> --- src-tauri/capabilities/default.json | 7 +- src-tauri/capabilities/e2e.json | 2 +- src-tauri/src/commands/app.rs | 6 +- src-tauri/src/commands/doc.rs | 12 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 13 + src-tauri/src/events.rs | 37 ++- src-tauri/src/lib.rs | 33 +-- src-tauri/src/linux_window_state.rs | 16 +- src-tauri/src/macos.rs | 2 +- src-tauri/src/remote_dev.rs | 29 +++ src-tauri/src/state.rs | 2 +- src-tauri/src/windows.rs | 345 +++++++++++++++++++------- src/bindings.ts | 29 +++ src/features/app/closeGuard.ts | 41 ++- src/features/app/index.ts | 5 +- src/features/index.ts | 45 ++-- src/features/settings/index.ts | 3 +- src/features/vcs/ComparisonWindow.tsx | 196 +++++++++++++++ src/features/vcs/common.module.css | 58 +++++ src/features/vcs/index.ts | 2 + src/features/vcs/operations.tsx | 104 +++++--- src/main.tsx | 27 +- src/platform/auxWindow.test.ts | 17 ++ src/platform/auxWindow.ts | 78 ++++++ src/platform/locales/text/zh.json | 6 +- 26 files changed, 918 insertions(+), 198 deletions(-) create mode 100644 src/features/vcs/ComparisonWindow.tsx create mode 100644 src/platform/auxWindow.test.ts create mode 100644 src/platform/auxWindow.ts 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": "返回更改文件列表" }