Open new existing projects with Alt + click in project popup.

This is intentionally non-obvious for now while it's new.
This commit is contained in:
Sebastian Thiel 2024-07-10 21:19:10 +02:00
parent c310942b12
commit 24b8bdaa86
No known key found for this signature in database
GPG Key ID: 9CB5EE7895E8268B
7 changed files with 270 additions and 234 deletions

View File

@ -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;

View File

@ -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();
}
})}

View File

@ -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;

View File

@ -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::<WindowState>()
@ -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<tauri::Window> {
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<tauri::Window> {
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(())
}

View File

@ -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)]

View File

@ -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::<window::WindowState>()
.state::<WindowState>()
.post(gitbutler_watcher::Action::CalculateVirtualBranches(
project_id,
))

View File

@ -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<Change> 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<Change> 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<tokio::sync::Mutex<BTreeMap<WindowLabel, State>>>,
}
fn handler_from_app(app: &AppHandle) -> Result<gitbutler_watcher::Handler> {
let projects = app.state::<projects::Controller>().inner().clone();
let users = app.state::<users::Controller>().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<tokio::sync::Mutex<BTreeMap<WindowLabel, State>>>,
#[cfg(not(target_os = "macos"))]
pub fn create(
handle: &tauri::AppHandle,
label: &state::WindowLabelRef,
window_relative_url: String,
) -> tauri::Result<tauri::Window> {
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<gitbutler_watcher::Handler> {
let projects = app.state::<projects::Controller>().inner().clone();
let users = app.state::<users::Controller>().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<tauri::Window> {
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)
}