From 24b8bdaa8676142fde59ba4f2b844cb6a81aad99 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Wed, 10 Jul 2024 21:19:10 +0200 Subject: [PATCH] Open new existing projects with `Alt + click` in project popup. This is intentionally non-obvious for now while it's new. --- app/src/lib/backend/projects.ts | 4 + app/src/lib/navigation/ProjectsPopup.svelte | 10 +- crates/gitbutler-tauri/src/lib.rs | 4 +- crates/gitbutler-tauri/src/main.rs | 64 +-- crates/gitbutler-tauri/src/projects.rs | 27 +- .../gitbutler-tauri/src/virtual_branches.rs | 4 +- crates/gitbutler-tauri/src/window.rs | 391 ++++++++++-------- 7 files changed, 270 insertions(+), 234 deletions(-) diff --git a/app/src/lib/backend/projects.ts b/app/src/lib/backend/projects.ts index 5bca58ee3..10ee580a8 100644 --- a/app/src/lib/backend/projects.ts +++ b/app/src/lib/backend/projects.ts @@ -99,6 +99,10 @@ export class ProjectService { } } + async openProjectInNewWindow(projectId: string) { + await invoke('open_project_in_window', { id: projectId }); + } + async addProject() { const path = await this.promptForDirectory(); if (!path) return; diff --git a/app/src/lib/navigation/ProjectsPopup.svelte b/app/src/lib/navigation/ProjectsPopup.svelte index e6822ef0a..d0e0112a7 100644 --- a/app/src/lib/navigation/ProjectsPopup.svelte +++ b/app/src/lib/navigation/ProjectsPopup.svelte @@ -13,7 +13,7 @@ label: string; selected?: boolean; icon?: string; - onclick: () => void; + onclick: (event?: any) => void; } interface ProjectsPopupProps { @@ -110,8 +110,12 @@ label: project.title, selected, icon: selected ? 'tick' : undefined, - onclick: () => { - goto(`/${project.id}/`); + onclick: async (event: any) => { + if (event.altKey) { + await projectService.openProjectInNewWindow(project.id); + } else { + goto(`/${project.id}/`); + } hide(); } })} diff --git a/crates/gitbutler-tauri/src/lib.rs b/crates/gitbutler-tauri/src/lib.rs index 2e552ee15..43f79ea82 100644 --- a/crates/gitbutler-tauri/src/lib.rs +++ b/crates/gitbutler-tauri/src/lib.rs @@ -18,8 +18,8 @@ pub mod commands; pub mod logs; pub mod menu; -mod window; -pub use window::WindowState; +pub mod window; +pub use window::state::WindowState; pub mod askpass; pub mod config; diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index c5a6d6589..7c88c3819 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -40,21 +40,10 @@ fn main() { .target(LogTarget::LogDir) .level(log::LevelFilter::Error); - let builder = tauri::Builder::default(); - - #[cfg(target_os = "macos")] - let builder = builder - .on_window_event(|event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event.event() { - hide_window(&event.window().app_handle()).expect("Failed to hide window"); - api.prevent_close(); - } - }); - - builder + tauri::Builder::default() .setup(move |tauri_app| { let window = - create_window(&tauri_app.handle()).expect("Failed to create window"); + gitbutler_tauri::window::create(&tauri_app.handle(), "main", "index.html".into()).expect("Failed to create window"); #[cfg(debug_assertions)] window.open_devtools(); @@ -162,6 +151,7 @@ fn main() { projects::commands::delete_project, projects::commands::list_projects, projects::commands::set_project_active, + projects::commands::open_project_in_window, repo::commands::git_get_local_config, repo::commands::git_set_local_config, repo::commands::check_signing_settings, @@ -213,6 +203,14 @@ fn main() { .on_window_event(|event| { let window = event.window(); match event.event() { + #[cfg(target_os = "macos")] + tauri::WindowEvent::CloseRequested { api, .. } => { + if window.app_handle().windows().len() == 1 { + tracing::debug!("Hiding all application windows and preventing exit"); + window.app_handle().hide().ok(); + api.prevent_close(); + } + } tauri::WindowEvent::Destroyed => { window.app_handle() .state::() @@ -231,7 +229,8 @@ fn main() { .run(|app_handle, event| { #[cfg(target_os = "macos")] if let tauri::RunEvent::ExitRequested { api, .. } = event { - hide_window(app_handle).expect("Failed to hide window"); + tracing::debug!("Hiding all windows and preventing exit"); + app_handle.hide().ok(); api.prevent_exit(); } @@ -243,40 +242,3 @@ fn main() { }); }); } - -#[cfg(not(target_os = "macos"))] -fn create_window(handle: &tauri::AppHandle) -> tauri::Result { - let app_title = handle.package_info().name.clone(); - let window = - tauri::WindowBuilder::new(handle, "main", tauri::WindowUrl::App("index.html".into())) - .resizable(true) - .title(app_title) - .disable_file_drop_handler() - .min_inner_size(800.0, 600.0) - .inner_size(1160.0, 720.0) - .build()?; - tracing::info!("app window created"); - Ok(window) -} - -#[cfg(target_os = "macos")] -fn create_window(handle: &tauri::AppHandle) -> tauri::Result { - let window = - tauri::WindowBuilder::new(handle, "main", tauri::WindowUrl::App("index.html".into())) - .resizable(true) - .title(handle.package_info().name.clone()) - .min_inner_size(800.0, 600.0) - .inner_size(1160.0, 720.0) - .hidden_title(true) - .disable_file_drop_handler() - .title_bar_style(tauri::TitleBarStyle::Overlay) - .build()?; - tracing::info!("window created"); - Ok(window) -} - -#[cfg(target_os = "macos")] -fn hide_window(handle: &tauri::AppHandle) -> tauri::Result<()> { - handle.hide()?; - Ok(()) -} diff --git a/crates/gitbutler-tauri/src/projects.rs b/crates/gitbutler-tauri/src/projects.rs index 7ff1ca2d3..9c8ec56dc 100644 --- a/crates/gitbutler-tauri/src/projects.rs +++ b/crates/gitbutler-tauri/src/projects.rs @@ -8,7 +8,7 @@ pub mod commands { use tracing::instrument; use crate::error::Error; - use crate::window::WindowState; + use crate::{window, WindowState}; #[tauri::command(async)] #[instrument(skip(controller), err(Debug))] @@ -49,15 +49,34 @@ pub mod commands { /// /// We use it to start watching for filesystem events. #[tauri::command(async)] - #[instrument(skip(controller, watchers, window), err(Debug))] + #[instrument(skip(controller, window_state, window), err(Debug))] pub async fn set_project_active( controller: State<'_, Controller>, - watchers: State<'_, WindowState>, + window_state: State<'_, WindowState>, window: Window, id: ProjectId, ) -> Result<(), Error> { let project = controller.get(id).context("project not found")?; - Ok(watchers.set_project_to_window(window.label(), &project)?) + Ok(window_state.set_project_to_window(window.label(), &project)?) + } + + /// Open the project with the given ID in a new Window, or focus an existing one. + /// + /// Note that this command is blocking the main thread just to prevent the chance for races + /// without haveing to lock explicitly. + #[tauri::command] + #[instrument(skip(handle), err(Debug))] + pub async fn open_project_in_window( + handle: tauri::AppHandle, + id: ProjectId, + ) -> Result<(), Error> { + let label = std::time::UNIX_EPOCH + .elapsed() + .or_else(|_| std::time::UNIX_EPOCH.duration_since(std::time::SystemTime::now())) + .map(|d| d.as_millis().to_string()) + .context("didn't manage to get any time-based unique ID")?; + window::create(&handle, &label, format!("{id}/board")).map_err(anyhow::Error::from)?; + Ok(()) } #[tauri::command(async)] diff --git a/crates/gitbutler-tauri/src/virtual_branches.rs b/crates/gitbutler-tauri/src/virtual_branches.rs index fd3bc3506..f454d8adc 100644 --- a/crates/gitbutler-tauri/src/virtual_branches.rs +++ b/crates/gitbutler-tauri/src/virtual_branches.rs @@ -15,7 +15,7 @@ pub mod commands { use tauri::{AppHandle, Manager}; use tracing::instrument; - use crate::window; + use crate::WindowState; #[tauri::command(async)] #[instrument(skip(handle), err(Debug))] @@ -511,7 +511,7 @@ pub mod commands { async fn emit_vbranches(handle: &AppHandle, project_id: projects::ProjectId) { if let Err(error) = handle - .state::() + .state::() .post(gitbutler_watcher::Action::CalculateVirtualBranches( project_id, )) diff --git a/crates/gitbutler-tauri/src/window.rs b/crates/gitbutler-tauri/src/window.rs index 84c5acf20..1065ad24b 100644 --- a/crates/gitbutler-tauri/src/window.rs +++ b/crates/gitbutler-tauri/src/window.rs @@ -1,195 +1,242 @@ -use std::collections::BTreeMap; -use std::sync::Arc; +pub(super) mod state { + use std::collections::BTreeMap; + use std::sync::Arc; -use anyhow::{Context, Result}; -use futures::executor::block_on; -use gitbutler_project as projects; -use gitbutler_project::ProjectId; -use gitbutler_user as users; -use tauri::{AppHandle, Manager}; -use tracing::instrument; - -mod event { use anyhow::{Context, Result}; + use futures::executor::block_on; + use gitbutler_project as projects; use gitbutler_project::ProjectId; - use gitbutler_watcher::Change; - use tauri::Manager; + use gitbutler_user as users; + use tauri::{AppHandle, Manager}; + use tracing::instrument; - /// A change we want to inform the frontend about. - #[derive(Debug, Clone, PartialEq, Eq)] - pub(super) struct ChangeForFrontend { - name: String, - payload: serde_json::Value, + mod event { + use anyhow::{Context, Result}; + use gitbutler_project::ProjectId; + use gitbutler_watcher::Change; + use tauri::Manager; + + /// A change we want to inform the frontend about. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(super) struct ChangeForFrontend { + name: String, + payload: serde_json::Value, + project_id: ProjectId, + } + + impl From for ChangeForFrontend { + fn from(value: Change) -> Self { + match value { + Change::GitFetch(project_id) => ChangeForFrontend { + name: format!("project://{}/git/fetch", project_id), + payload: serde_json::json!({}), + project_id, + }, + Change::GitHead { project_id, head } => ChangeForFrontend { + name: format!("project://{}/git/head", project_id), + payload: serde_json::json!({ "head": head }), + project_id, + }, + Change::GitActivity(project_id) => ChangeForFrontend { + name: format!("project://{}/git/activity", project_id), + payload: serde_json::json!({}), + project_id, + }, + Change::VirtualBranches { + project_id, + virtual_branches, + } => ChangeForFrontend { + name: format!("project://{}/virtual-branches", project_id), + payload: serde_json::json!(virtual_branches), + project_id, + }, + } + } + } + + impl ChangeForFrontend { + pub(super) fn send(&self, app_handle: &tauri::AppHandle) -> Result<()> { + app_handle + .emit_all(&self.name, Some(&self.payload)) + .context("emit event")?; + tracing::trace!(event_name = self.name); + Ok(()) + } + } + } + use event::ChangeForFrontend; + + /// The name of the lock file to signal exclusive access to other windows. + const WINDOW_LOCK_FILE: &str = "window.lock"; + + struct State { + /// The id of the project displayed by the window. project_id: ProjectId, + /// The watcher of the currently active project. + watcher: gitbutler_watcher::WatcherHandle, + /// An active lock to signal that the entire project is locked for the Window this state belongs to. + exclusive_access: fslock::LockFile, } - impl From for ChangeForFrontend { - fn from(value: Change) -> Self { - match value { - Change::GitFetch(project_id) => ChangeForFrontend { - name: format!("project://{}/git/fetch", project_id), - payload: serde_json::json!({}), - project_id, - }, - Change::GitHead { project_id, head } => ChangeForFrontend { - name: format!("project://{}/git/head", project_id), - payload: serde_json::json!({ "head": head }), - project_id, - }, - Change::GitActivity(project_id) => ChangeForFrontend { - name: format!("project://{}/git/activity", project_id), - payload: serde_json::json!({}), - project_id, - }, - Change::VirtualBranches { - project_id, - virtual_branches, - } => ChangeForFrontend { - name: format!("project://{}/virtual-branches", project_id), - payload: serde_json::json!(virtual_branches), - project_id, - }, + impl Drop for State { + fn drop(&mut self) { + // We only do this to display an error if it fails - `LockFile` also implements `Drop`. + if let Err(err) = self.exclusive_access.unlock() { + tracing::error!(err = ?err, "Failed to release the project-wide lock"); } } } - impl ChangeForFrontend { - pub(super) fn send(&self, app_handle: &tauri::AppHandle) -> Result<()> { - app_handle - .emit_all(&self.name, Some(&self.payload)) - .context("emit event")?; - tracing::trace!(event_name = self.name); + type WindowLabel = String; + pub(super) type WindowLabelRef = str; + + /// State associated to windows + /// Note that this type is managed in Tauri and thus needs to be `Send` and `Sync`. + #[derive(Clone)] + pub struct WindowState { + /// NOTE: This handle is required for this type to be self-contained as it's used by `core` through a trait. + app_handle: AppHandle, + /// The state for every open application window. + /// NOTE: This is a `tokio` mutex as this needs to lock the inner option from within async. + state: Arc>>, + } + + fn handler_from_app(app: &AppHandle) -> Result { + let projects = app.state::().inner().clone(); + let users = app.state::().inner().clone(); + let vbranches = gitbutler_branch_actions::VirtualBranchActions::default(); + + Ok(gitbutler_watcher::Handler::new( + projects, + users, + vbranches, + { + let app = app.clone(); + move |change| ChangeForFrontend::from(change).send(&app) + }, + )) + } + + impl WindowState { + pub fn new(app_handle: AppHandle) -> Self { + Self { + app_handle, + state: Default::default(), + } + } + + /// Watch the `project`, assure no other instance can access it, and associate it with the window + /// uniquely identified by `window`. + /// + /// Previous state will be removed and its resources cleaned up. + #[instrument(skip(self, project), err(Debug))] + pub fn set_project_to_window( + &self, + window: &WindowLabelRef, + project: &projects::Project, + ) -> Result<()> { + let mut lock_file = + fslock::LockFile::open(project.gb_dir().join(WINDOW_LOCK_FILE).as_os_str())?; + lock_file + .try_lock() + .context("Another GitButler Window already has the project opened")?; + + let handler = handler_from_app(&self.app_handle)?; + let worktree_dir = project.path.clone(); + let project_id = project.id; + let watcher = + gitbutler_watcher::watch_in_background(handler, worktree_dir, project_id)?; + let mut state_by_label = block_on(self.state.lock()); + state_by_label.insert( + window.to_owned(), + State { + project_id, + watcher, + exclusive_access: lock_file, + }, + ); + tracing::debug!("Maintaining {} Windows", state_by_label.len()); Ok(()) } - } -} -use event::ChangeForFrontend; -/// The name of the lock file to signal exclusive access to other windows. -const WINDOW_LOCK_FILE: &str = "window.lock"; + pub async fn post(&self, action: gitbutler_watcher::Action) -> Result<()> { + let mut state_by_label = self.state.lock().await; + let state = state_by_label + .values_mut() + .find(|state| state.project_id == action.project_id()); + if let Some(state) = state { + state + .watcher + .post(action) + .await + .context("failed to post event") + } else { + Err(anyhow::anyhow!( + "matching watcher to post event not found, wanted {wanted}", + wanted = action.project_id(), + )) + } + } -struct State { - /// The id of the project displayed by the window. - project_id: ProjectId, - /// The watcher of the currently active project. - watcher: gitbutler_watcher::WatcherHandle, - /// An active lock to signal that the entire project is locked for the Window this state belongs to. - exclusive_access: fslock::LockFile, -} + /// Flush file-monitor watcher events once the windows regains focus for it to respond instantly + /// instead of according to the tick-rate. + pub fn flush(&self, window: &WindowLabelRef) -> Result<()> { + let state_by_label = block_on(self.state.lock()); + if let Some(state) = state_by_label.get(window) { + state.watcher.flush()?; + } -impl Drop for State { - fn drop(&mut self) { - // We only do this to display an error if it fails - `LockFile` also implements `Drop`. - if let Err(err) = self.exclusive_access.unlock() { - tracing::error!(err = ?err, "Failed to release the project-wide lock"); + Ok(()) + } + + /// Remove the state associated with `window`, typically upon its destruction. + pub fn remove(&self, window: &WindowLabelRef) { + let mut state_by_label = block_on(self.state.lock()); + state_by_label.remove(window); } } } -type WindowLabel = String; -type WindowLabelRef = str; - -/// State associated to windows -/// Note that this type is managed in Tauri and thus needs to be `Send` and `Sync`. -#[derive(Clone)] -pub struct WindowState { - /// NOTE: This handle is required for this type to be self-contained as it's used by `core` through a trait. - app_handle: AppHandle, - /// The state for every open application window. - /// NOTE: This is a `tokio` mutex as this needs to lock the inner option from within async. - state: Arc>>, +#[cfg(not(target_os = "macos"))] +pub fn create( + handle: &tauri::AppHandle, + label: &state::WindowLabelRef, + window_relative_url: String, +) -> tauri::Result { + tracing::info!("creating window '{label}' created at '{window_relative_url}'"); + let window = tauri::WindowBuilder::new( + handle, + label, + tauri::WindowUrl::App(window_relative_url.into()), + ) + .resizable(true) + .title(handle.package_info().name.clone()) + .disable_file_drop_handler() + .min_inner_size(800.0, 600.0) + .inner_size(1160.0, 720.0) + .build()?; + Ok(window) } -fn handler_from_app(app: &AppHandle) -> Result { - let projects = app.state::().inner().clone(); - let users = app.state::().inner().clone(); - let vbranches = gitbutler_branch_actions::VirtualBranchActions::default(); - - Ok(gitbutler_watcher::Handler::new( - projects, - users, - vbranches, - { - let app = app.clone(); - move |change| ChangeForFrontend::from(change).send(&app) - }, - )) -} - -impl WindowState { - pub fn new(app_handle: AppHandle) -> Self { - Self { - app_handle, - state: Default::default(), - } - } - - /// Watch the `project`, assure no other instance can access it, and associate it with the window - /// uniquely identified by `window`. - /// - /// Previous state will be removed and its resources cleaned up. - #[instrument(skip(self, project), err(Debug))] - pub fn set_project_to_window( - &self, - window: &WindowLabelRef, - project: &projects::Project, - ) -> Result<()> { - let mut lock_file = - fslock::LockFile::open(project.gb_dir().join(WINDOW_LOCK_FILE).as_os_str())?; - lock_file - .lock() - .context("Another GitButler Window already has the project opened")?; - - let handler = handler_from_app(&self.app_handle)?; - let worktree_dir = project.path.clone(); - let project_id = project.id; - let watcher = gitbutler_watcher::watch_in_background(handler, worktree_dir, project_id)?; - let mut state_by_label = block_on(self.state.lock()); - state_by_label.insert( - window.to_owned(), - State { - project_id, - watcher, - exclusive_access: lock_file, - }, - ); - tracing::debug!("Maintaining {} Windows", state_by_label.len()); - Ok(()) - } - - pub async fn post(&self, action: gitbutler_watcher::Action) -> Result<()> { - let mut state_by_label = self.state.lock().await; - let state = state_by_label - .values_mut() - .find(|state| state.project_id == action.project_id()); - if let Some(state) = state { - state - .watcher - .post(action) - .await - .context("failed to post event") - } else { - Err(anyhow::anyhow!( - "matching watcher to post event not found, wanted {wanted}", - wanted = action.project_id(), - )) - } - } - - /// Flush file-monitor watcher events once the windows regains focus for it to respond instantly - /// instead of according to the tick-rate. - pub fn flush(&self, window: &WindowLabelRef) -> Result<()> { - let state_by_label = block_on(self.state.lock()); - if let Some(state) = state_by_label.get(window) { - state.watcher.flush()?; - } - - Ok(()) - } - - /// Remove the state associated with `window`, typically upon its destruction. - pub fn remove(&self, window: &WindowLabelRef) { - let mut state_by_label = block_on(self.state.lock()); - state_by_label.remove(window); - } +#[cfg(target_os = "macos")] +pub fn create( + handle: &tauri::AppHandle, + label: &state::WindowLabelRef, + window_relative_url: String, +) -> tauri::Result { + tracing::info!("creating window '{label}' created at '{window_relative_url}'"); + let window = tauri::WindowBuilder::new( + handle, + label, + tauri::WindowUrl::App(window_relative_url.into()), + ) + .resizable(true) + .title(handle.package_info().name.clone()) + .min_inner_size(800.0, 600.0) + .inner_size(1160.0, 720.0) + .hidden_title(true) + .disable_file_drop_handler() + .title_bar_style(tauri::TitleBarStyle::Overlay) + .build()?; + Ok(window) }