mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-03 03:33:16 +03:00
Merge pull request #4316 from Byron/multi-window-safety
multi-window safety
This commit is contained in:
commit
c769c08a0d
13
Cargo.lock
generated
13
Cargo.lock
generated
@ -1608,6 +1608,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fslock"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@ -2165,7 +2175,6 @@ name = "gitbutler-project"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"git2",
|
||||
"gitbutler-error",
|
||||
"gitbutler-id",
|
||||
@ -2282,10 +2291,10 @@ name = "gitbutler-tauri"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"backtrace",
|
||||
"console-subscriber",
|
||||
"dirs 5.0.1",
|
||||
"fslock",
|
||||
"futures",
|
||||
"git2",
|
||||
"gitbutler-branch",
|
||||
|
@ -29,6 +29,9 @@ export class Project {
|
||||
use_new_locking!: boolean;
|
||||
ignore_project_semaphore!: boolean;
|
||||
|
||||
// Produced just for the frontend to determine if the project is open in any window.
|
||||
is_open!: boolean;
|
||||
|
||||
get vscodePath() {
|
||||
return this.path.includes('\\') ? '/' + this.path.replace('\\', '/') : this.path;
|
||||
}
|
||||
@ -99,6 +102,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;
|
||||
|
@ -13,7 +13,7 @@
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
icon?: string;
|
||||
onclick: () => void;
|
||||
onclick: (event?: any) => void;
|
||||
}
|
||||
|
||||
interface ProjectsPopupProps {
|
||||
@ -105,13 +105,19 @@
|
||||
<ScrollableContainer maxHeight="20rem">
|
||||
<div class="popup__projects">
|
||||
{#each $projects as project}
|
||||
{@const selected = project.id === $page.params.projectId}
|
||||
{@const selected =
|
||||
project.id === $page.params.projectId ||
|
||||
$projects.some((p) => p.is_open && p.id === project.id)}
|
||||
{@render itemSnippet({
|
||||
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();
|
||||
}
|
||||
})}
|
||||
|
@ -30,10 +30,12 @@ export async function load({ params, parent }) {
|
||||
// Getting the project should be one of few, if not the only await expression in
|
||||
// this function. It delays drawing the page, but currently the benefit from having this
|
||||
// synchronously available are much greater than the cost.
|
||||
// However, what's awaited here is required for proper error handling,
|
||||
// and by now this is fast enough to not be an impediment.
|
||||
let project: Project | undefined = undefined;
|
||||
try {
|
||||
project = await projectService.getProject(projectId);
|
||||
invoke('set_project_active', { id: projectId }).then((_r) => {});
|
||||
await invoke('set_project_active', { id: projectId });
|
||||
} catch (err: any) {
|
||||
throw error(400, {
|
||||
message: err.message
|
||||
|
@ -14,7 +14,6 @@ gitbutler-serde.workspace = true
|
||||
gitbutler-id.workspace = true
|
||||
gitbutler-storage.workspace = true
|
||||
git2.workspace = true
|
||||
async-trait = "0.1.80"
|
||||
gix = { workspace = true, features = ["dirwalk", "credentials", "parallel"] }
|
||||
uuid.workspace = true
|
||||
tracing = "0.1.40"
|
||||
|
@ -1,40 +1,22 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{storage, storage::UpdateRequest, Project, ProjectId};
|
||||
use crate::AuthKey;
|
||||
use gitbutler_error::error;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Watchers {
|
||||
/// Watch for filesystem changes on the given project.
|
||||
fn watch(&self, project: &Project) -> anyhow::Result<()>;
|
||||
/// Stop watching filesystem changes.
|
||||
async fn stop(&self, id: ProjectId);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Controller {
|
||||
local_data_dir: PathBuf,
|
||||
projects_storage: storage::Storage,
|
||||
watchers: Option<Arc<dyn Watchers + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new(
|
||||
local_data_dir: PathBuf,
|
||||
projects_storage: storage::Storage,
|
||||
watchers: Option<impl Watchers + Send + Sync + 'static>,
|
||||
) -> Self {
|
||||
pub fn new(local_data_dir: PathBuf, projects_storage: storage::Storage) -> Self {
|
||||
Self {
|
||||
local_data_dir,
|
||||
projects_storage,
|
||||
watchers: watchers.map(|w| Arc::new(w) as Arc<_>),
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,7 +25,6 @@ impl Controller {
|
||||
Self {
|
||||
projects_storage: storage::Storage::from_path(&path),
|
||||
local_data_dir: path,
|
||||
watchers: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,10 +84,6 @@ impl Controller {
|
||||
tracing::error!(project_id = %project.id, ?error, "failed to create {:?} on project add", project.gb_dir());
|
||||
}
|
||||
|
||||
if let Some(watcher) = &self.watchers {
|
||||
watcher.watch(&project)?;
|
||||
}
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
@ -183,10 +160,6 @@ impl Controller {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(watchers) = &self.watchers {
|
||||
watchers.stop(id).await;
|
||||
}
|
||||
|
||||
self.projects_storage
|
||||
.purge(project.id)
|
||||
.map_err(anyhow::Error::from)?;
|
||||
|
@ -23,10 +23,10 @@ gitbutler-testsupport.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
async-trait = "0.1.80"
|
||||
backtrace = { version = "0.3.72", optional = true }
|
||||
console-subscriber = "0.2.0"
|
||||
dirs = "5.0.1"
|
||||
fslock = "0.2.1"
|
||||
futures = "0.3"
|
||||
git2.workspace = true
|
||||
once_cell = "1.19"
|
||||
|
@ -18,7 +18,8 @@ pub mod commands;
|
||||
|
||||
pub mod logs;
|
||||
pub mod menu;
|
||||
pub mod watcher;
|
||||
pub mod window;
|
||||
pub use window::state::WindowState;
|
||||
|
||||
pub mod askpass;
|
||||
pub mod config;
|
||||
|
@ -17,7 +17,7 @@ use gitbutler_repo::credentials::Helper;
|
||||
use gitbutler_storage::storage;
|
||||
use gitbutler_tauri::{
|
||||
app, askpass, commands, config, github, logs, menu, projects, remotes, repo, secret, undo,
|
||||
users, virtual_branches, watcher, zip,
|
||||
users, virtual_branches, zip, WindowState,
|
||||
};
|
||||
use tauri::{generate_context, Manager};
|
||||
use tauri_plugin_log::LogTarget;
|
||||
@ -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();
|
||||
|
||||
@ -101,8 +90,7 @@ fn main() {
|
||||
let storage_controller = storage::Storage::new(&app_data_dir);
|
||||
app_handle.manage(storage_controller.clone());
|
||||
|
||||
let watcher_controller = watcher::Watchers::new(app_handle.clone());
|
||||
app_handle.manage(watcher_controller.clone());
|
||||
app_handle.manage(WindowState::new(app_handle.clone()));
|
||||
|
||||
let projects_storage_controller = gitbutler_project::storage::Storage::new(storage_controller.clone());
|
||||
app_handle.manage(projects_storage_controller.clone());
|
||||
@ -115,8 +103,7 @@ fn main() {
|
||||
|
||||
let projects_controller = gitbutler_project::Controller::new(
|
||||
app_data_dir.clone(),
|
||||
projects_storage_controller.clone(),
|
||||
Some(watcher_controller.clone())
|
||||
projects_storage_controller.clone()
|
||||
);
|
||||
app_handle.manage(projects_controller.clone());
|
||||
|
||||
@ -164,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,14 +201,27 @@ fn main() {
|
||||
.menu(menu::build(tauri_context.package_info()))
|
||||
.on_menu_event(|event|menu::handle_event(&event))
|
||||
.on_window_event(|event| {
|
||||
if let tauri::WindowEvent::Focused(focused) = event.event() {
|
||||
if *focused {
|
||||
tokio::task::spawn(async move {
|
||||
let _ = event.window().app_handle()
|
||||
.state::<watcher::Watchers>()
|
||||
.flush().await;
|
||||
});
|
||||
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>()
|
||||
.remove(window.label());
|
||||
}
|
||||
tauri::WindowEvent::Focused(focused) if *focused => {
|
||||
window.app_handle()
|
||||
.state::<WindowState>()
|
||||
.flush(window.label()).ok();
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(tauri_context)
|
||||
@ -228,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();
|
||||
}
|
||||
|
||||
@ -240,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(())
|
||||
}
|
||||
|
@ -1,68 +1,111 @@
|
||||
use gitbutler_project::Project;
|
||||
|
||||
pub mod commands {
|
||||
use anyhow::Context;
|
||||
use std::path;
|
||||
|
||||
use gitbutler_project::ProjectId;
|
||||
use gitbutler_project::{self as projects, Controller};
|
||||
use tauri::Manager;
|
||||
use tauri::{State, Window};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::watcher::Watchers;
|
||||
use crate::projects::ProjectForFrontend;
|
||||
use crate::{window, WindowState};
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle), err(Debug))]
|
||||
#[instrument(skip(controller), err(Debug))]
|
||||
pub async fn update_project(
|
||||
handle: tauri::AppHandle,
|
||||
controller: State<'_, Controller>,
|
||||
project: projects::UpdateRequest,
|
||||
) -> Result<projects::Project, Error> {
|
||||
Ok(handle.state::<Controller>().update(&project).await?)
|
||||
Ok(controller.update(&project).await?)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle), err(Debug))]
|
||||
#[instrument(skip(controller), err(Debug))]
|
||||
pub async fn add_project(
|
||||
handle: tauri::AppHandle,
|
||||
controller: State<'_, Controller>,
|
||||
path: &path::Path,
|
||||
) -> Result<projects::Project, Error> {
|
||||
Ok(handle.state::<Controller>().add(path)?)
|
||||
Ok(controller.add(path)?)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle), err(Debug))]
|
||||
#[instrument(skip(controller), err(Debug))]
|
||||
pub async fn get_project(
|
||||
handle: tauri::AppHandle,
|
||||
controller: State<'_, Controller>,
|
||||
id: ProjectId,
|
||||
) -> Result<projects::Project, Error> {
|
||||
Ok(handle.state::<Controller>().get(id)?)
|
||||
Ok(controller.get(id)?)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(handle), err(Debug))]
|
||||
pub async fn list_projects(handle: tauri::AppHandle) -> Result<Vec<projects::Project>, Error> {
|
||||
handle.state::<Controller>().list().map_err(Into::into)
|
||||
#[instrument(skip(controller, window_state), err(Debug))]
|
||||
pub async fn list_projects(
|
||||
window_state: State<'_, WindowState>,
|
||||
controller: State<'_, Controller>,
|
||||
) -> Result<Vec<ProjectForFrontend>, Error> {
|
||||
let open_projects = window_state.open_projects();
|
||||
controller.list().map_err(Into::into).map(|projects| {
|
||||
projects
|
||||
.into_iter()
|
||||
.map(|project| ProjectForFrontend {
|
||||
is_open: open_projects.contains(&project.id),
|
||||
inner: project,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// This trigger is the GUI telling us that the project with `id` is now displayed.
|
||||
///
|
||||
/// We use it to start watching for filesystem events.
|
||||
#[tauri::command(async)]
|
||||
#[instrument(skip(controller, window_state, window), err(Debug))]
|
||||
pub async fn set_project_active(
|
||||
controller: State<'_, Controller>,
|
||||
window_state: State<'_, WindowState>,
|
||||
window: Window,
|
||||
id: ProjectId,
|
||||
) -> Result<(), Error> {
|
||||
let project = controller.get(id).context("project not found")?;
|
||||
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 set_project_active(handle: tauri::AppHandle, id: ProjectId) -> Result<(), Error> {
|
||||
let project = handle
|
||||
.state::<Controller>()
|
||||
.get(id)
|
||||
.context("project not found")?;
|
||||
Ok(handle.state::<Watchers>().watch(&project)?)
|
||||
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)]
|
||||
#[instrument(skip(handle), err(Debug))]
|
||||
pub async fn delete_project(handle: tauri::AppHandle, id: ProjectId) -> Result<(), Error> {
|
||||
handle
|
||||
.state::<Controller>()
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
#[instrument(skip(controller), err(Debug))]
|
||||
pub async fn delete_project(
|
||||
controller: State<'_, Controller>,
|
||||
id: ProjectId,
|
||||
) -> Result<(), Error> {
|
||||
controller.delete(id).await.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
pub struct ProjectForFrontend {
|
||||
#[serde(flatten)]
|
||||
pub inner: Project,
|
||||
/// Tell if the project is known to be open in a Window in the frontend.
|
||||
pub is_open: bool,
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ pub mod commands {
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::watcher;
|
||||
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::<watcher::Watchers>()
|
||||
.state::<WindowState>()
|
||||
.post(gitbutler_watcher::Action::CalculateVirtualBranches(
|
||||
project_id,
|
||||
))
|
||||
|
@ -1,157 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use futures::executor::block_on;
|
||||
use gitbutler_project as projects;
|
||||
use gitbutler_project::{Project, ProjectId};
|
||||
use gitbutler_user as users;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::instrument;
|
||||
|
||||
mod event {
|
||||
use anyhow::{Context, Result};
|
||||
use gitbutler_project::ProjectId;
|
||||
use gitbutler_watcher::Change;
|
||||
use tauri::Manager;
|
||||
|
||||
/// An 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;
|
||||
|
||||
/// Note that this type is managed in Tauri and thus needs to be send and sync.
|
||||
#[derive(Clone)]
|
||||
pub struct Watchers {
|
||||
/// 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 watcher of the currently active project.
|
||||
/// NOTE: This is a `tokio` mutex as this needs to lock the inner option from within async.
|
||||
watcher: Arc<tokio::sync::Mutex<Option<gitbutler_watcher::WatcherHandle>>>,
|
||||
}
|
||||
|
||||
fn handler_from_app(app: &AppHandle) -> anyhow::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 Watchers {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self {
|
||||
app_handle,
|
||||
watcher: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, project), err(Debug))]
|
||||
pub fn watch(&self, project: &projects::Project) -> Result<()> {
|
||||
let handler = handler_from_app(&self.app_handle)?;
|
||||
|
||||
let project_path = project.path.clone();
|
||||
|
||||
let handle = gitbutler_watcher::watch_in_background(handler, project_path, project.id)?;
|
||||
block_on(self.watcher.lock()).replace(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn post(&self, action: gitbutler_watcher::Action) -> Result<()> {
|
||||
let watcher = self.watcher.lock().await;
|
||||
if let Some(handle) = watcher
|
||||
.as_ref()
|
||||
.filter(|watcher| watcher.project_id() == action.project_id())
|
||||
{
|
||||
handle.post(action).await.context("failed to post event")
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"matching watcher to post event not found, wanted {wanted}, got {actual:?}",
|
||||
wanted = action.project_id(),
|
||||
actual = watcher.as_ref().map(|w| w.project_id())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn flush(&self) -> Result<()> {
|
||||
let watcher = self.watcher.lock().await;
|
||||
if let Some(handle) = watcher.as_ref() {
|
||||
handle.flush()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&self, project_id: ProjectId) {
|
||||
let mut handle = self.watcher.lock().await;
|
||||
if handle
|
||||
.as_ref()
|
||||
.map_or(false, |handle| handle.project_id() == project_id)
|
||||
{
|
||||
handle.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl projects::Watchers for Watchers {
|
||||
fn watch(&self, project: &Project) -> Result<()> {
|
||||
Watchers::watch(self, project)
|
||||
}
|
||||
|
||||
async fn stop(&self, id: ProjectId) {
|
||||
Watchers::stop(self, id).await
|
||||
}
|
||||
}
|
262
crates/gitbutler-tauri/src/window.rs
Normal file
262
crates/gitbutler-tauri/src/window.rs
Normal file
@ -0,0 +1,262 @@
|
||||
pub(super) mod state {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{bail, 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 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 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 state_by_label = block_on(self.state.lock());
|
||||
if let Some(state) = state_by_label.get(window) {
|
||||
if state.project_id == project.id {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let mut lock_file =
|
||||
fslock::LockFile::open(project.gb_dir().join(WINDOW_LOCK_FILE).as_os_str())?;
|
||||
let got_lock = lock_file
|
||||
.try_lock()
|
||||
.context("Failed to check if lock is taken")?;
|
||||
if !got_lock {
|
||||
bail!(
|
||||
"Project '{}' is already opened in another window",
|
||||
project.title
|
||||
);
|
||||
}
|
||||
|
||||
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)?;
|
||||
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);
|
||||
}
|
||||
|
||||
/// Return the list of project ids that are currently open.
|
||||
pub fn open_projects(&self) -> Vec<ProjectId> {
|
||||
let state_by_label = block_on(self.state.lock());
|
||||
state_by_label
|
||||
.values()
|
||||
.map(|state| state.project_id)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
@ -114,8 +114,8 @@ pub fn spawn(
|
||||
|
||||
let worktree_path = worktree_path.to_owned();
|
||||
task::spawn_blocking(move || {
|
||||
tracing::debug!(%project_id, "file watcher started");
|
||||
let _runtime = tracing::span!(Level::INFO, "file monitor", %project_id ).entered();
|
||||
tracing::debug!(%project_id, "file watcher started");
|
||||
|
||||
'outer: for result in notify_rx {
|
||||
let stats = tracing::span!(
|
||||
|
@ -112,6 +112,7 @@ pub fn watch_in_background(
|
||||
debounce.flush_nonblocking();
|
||||
}
|
||||
() = cancellation_token.cancelled() => {
|
||||
tracing::debug!(%project_id, "stopped watcher");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user