diff --git a/apps/desktop/src/lib/uncommitedFiles/watcher.ts b/apps/desktop/src/lib/uncommitedFiles/watcher.ts new file mode 100644 index 000000000..000fcc976 --- /dev/null +++ b/apps/desktop/src/lib/uncommitedFiles/watcher.ts @@ -0,0 +1,48 @@ +import { listen } from '$lib/backend/ipc'; +import { parseRemoteFiles } from '$lib/vbranches/remoteCommits'; +import { RemoteFile } from '$lib/vbranches/types'; +import { invoke } from '@tauri-apps/api/tauri'; +import { plainToInstance } from 'class-transformer'; +import { readable, type Readable } from 'svelte/store'; +import type { Project } from '$lib/backend/projects'; +import type { ContentSection, HunkSection } from '$lib/utils/fileSections'; + +type ParsedFiles = [RemoteFile, (ContentSection | HunkSection)[]][]; + +export class UncommitedFilesWatcher { + uncommitedFiles: Readable; + + constructor(private project: Project) { + this.uncommitedFiles = readable([] as ParsedFiles, (set) => { + this.getUncommitedFiles().then((files) => { + set(files); + }); + + const unsubscribe = this.listen(set); + + return unsubscribe; + }); + } + + private async getUncommitedFiles() { + const uncommitedFiles = await invoke('get_uncommited_files', { + id: this.project.id + }); + + const orderedFiles = plainToInstance(RemoteFile, uncommitedFiles).sort((a, b) => + a.path?.localeCompare(b.path) + ); + + return parseRemoteFiles(orderedFiles); + } + + private listen(callback: (files: ParsedFiles) => void) { + return listen(`project://${this.project.id}/uncommited-files`, (event) => { + const orderedFiles = plainToInstance(RemoteFile, event.payload).sort((a, b) => + a.path?.localeCompare(b.path) + ); + + callback(parseRemoteFiles(orderedFiles)); + }); + } +} diff --git a/apps/desktop/src/routes/[projectId]/+layout.svelte b/apps/desktop/src/routes/[projectId]/+layout.svelte index 811b2f3e8..fafb7aebe 100644 --- a/apps/desktop/src/routes/[projectId]/+layout.svelte +++ b/apps/desktop/src/routes/[projectId]/+layout.svelte @@ -26,6 +26,7 @@ import Navigation from '$lib/navigation/Navigation.svelte'; import { persisted } from '$lib/persisted/persisted'; import { RemoteBranchService } from '$lib/stores/remoteBranches'; + import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher'; import { parseRemoteUrl } from '$lib/url/gitUrl'; import { debounce } from '$lib/utils/debounce'; import { BranchController } from '$lib/vbranches/branchController'; @@ -73,6 +74,7 @@ setContext(RemoteBranchService, data.remoteBranchService); setContext(BranchListingService, data.branchListingService); setContext(ModeService, data.modeService); + setContext(UncommitedFilesWatcher, data.uncommitedFileWatcher); }); let intervalId: any; diff --git a/apps/desktop/src/routes/[projectId]/+layout.ts b/apps/desktop/src/routes/[projectId]/+layout.ts index f3929b0b9..16c880c11 100644 --- a/apps/desktop/src/routes/[projectId]/+layout.ts +++ b/apps/desktop/src/routes/[projectId]/+layout.ts @@ -9,6 +9,7 @@ import { HistoryService } from '$lib/history/history'; import { ProjectMetrics } from '$lib/metrics/projectMetrics'; import { ModeService } from '$lib/modes/service'; import { RemoteBranchService } from '$lib/stores/remoteBranches'; +import { UncommitedFilesWatcher } from '$lib/uncommitedFiles/watcher'; import { BranchController } from '$lib/vbranches/branchController'; import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import { error } from '@sveltejs/kit'; @@ -76,6 +77,8 @@ export const load: LayoutLoad = async ({ params, parent }) => { const commitDragActionsFactory = new CommitDragActionsFactory(branchController, project); const reorderDropzoneManagerFactory = new ReorderDropzoneManagerFactory(branchController); + const uncommitedFileWatcher = new UncommitedFilesWatcher(project); + return { authService, baseBranchService, @@ -93,6 +96,7 @@ export const load: LayoutLoad = async ({ params, parent }) => { branchDragActionsFactory, commitDragActionsFactory, reorderDropzoneManagerFactory, - branchListingService + branchListingService, + uncommitedFileWatcher }; }; diff --git a/crates/gitbutler-branch-actions/src/actions.rs b/crates/gitbutler-branch-actions/src/actions.rs index 147e944c6..7b428d835 100644 --- a/crates/gitbutler-branch-actions/src/actions.rs +++ b/crates/gitbutler-branch-actions/src/actions.rs @@ -17,6 +17,7 @@ use crate::{ get_base_branch_data, set_base_branch, set_target_push_remote, update_base_branch, BaseBranch, }, + branch::get_uncommited_files, branch_manager::BranchManagerExt, file::RemoteBranchFile, remote::{get_branch_data, list_remote_branches, RemoteBranch, RemoteBranchData}, @@ -539,6 +540,14 @@ impl VirtualBranchActions { .create_virtual_branch_from_branch(branch, remote, guard.write_permission()) .map_err(Into::into) } + + pub fn get_uncommited_files(&self, project: &Project) -> Result> { + let context = CommandContext::open(project)?; + + let guard = project.exclusive_worktree_access(); + + get_uncommited_files(&context, guard.read_permission()) + } } fn open_with_verify(project: &Project) -> Result { diff --git a/crates/gitbutler-branch-actions/src/branch.rs b/crates/gitbutler-branch-actions/src/branch.rs index bfdf4d6a5..e135a25db 100644 --- a/crates/gitbutler-branch-actions/src/branch.rs +++ b/crates/gitbutler-branch-actions/src/branch.rs @@ -1,4 +1,4 @@ -use crate::VirtualBranchesExt; +use crate::{RemoteBranchFile, VirtualBranchesExt}; use anyhow::{bail, Context, Result}; use bstr::{BStr, ByteSlice}; use core::fmt; @@ -6,6 +6,7 @@ use gitbutler_branch::{ Branch as GitButlerBranch, BranchId, BranchIdentity, ReferenceExtGix, Target, }; use gitbutler_command_context::{CommandContext, GixRepositoryExt}; +use gitbutler_project::access::WorktreeReadPermission; use gitbutler_reference::normalize_branch_name; use gitbutler_serde::BStringForFrontend; use gix::object::tree::diff::Action; @@ -21,6 +22,33 @@ use std::{ vec, }; +pub(crate) fn get_uncommited_files( + context: &CommandContext, + _permission: &WorktreeReadPermission, +) -> Result> { + let repository = context.repository(); + let head_commit = repository + .head() + .context("Failed to get head")? + .peel_to_commit() + .context("Failed to get head commit")?; + + let files = gitbutler_diff::workdir(repository, &head_commit.id()) + .context("Failed to list uncommited files")? + .into_iter() + .map(|(path, file)| { + let binary = file.hunks.iter().any(|h| h.binary); + RemoteBranchFile { + path, + hunks: file.hunks, + binary, + } + }) + .collect(); + + Ok(files) +} + /// Returns a list of branches associated with this project. pub fn list_branches( ctx: &CommandContext, diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 36a1a1fb6..e24592608 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -148,6 +148,7 @@ fn main() { repo::commands::git_set_local_config, repo::commands::check_signing_settings, repo::commands::git_clone_repository, + repo::commands::get_uncommited_files, virtual_branches::commands::list_virtual_branches, virtual_branches::commands::create_virtual_branch, virtual_branches::commands::delete_local_branch, diff --git a/crates/gitbutler-tauri/src/repo.rs b/crates/gitbutler-tauri/src/repo.rs index 75cf5e9cc..a6879c8d4 100644 --- a/crates/gitbutler-tauri/src/repo.rs +++ b/crates/gitbutler-tauri/src/repo.rs @@ -1,5 +1,6 @@ pub mod commands { use anyhow::{Context, Result}; + use gitbutler_branch_actions::{RemoteBranchFile, VirtualBranchActions}; use gitbutler_project as projects; use gitbutler_project::ProjectId; use gitbutler_repo::RepoCommands; @@ -60,4 +61,14 @@ pub mod commands { .context("Failed to checkout main worktree")?; Ok(()) } + + #[tauri::command(async)] + pub fn get_uncommited_files( + projects: State<'_, projects::Controller>, + id: ProjectId, + ) -> Result, Error> { + let project = projects.get(id)?; + + Ok(VirtualBranchActions.get_uncommited_files(&project)?) + } } diff --git a/crates/gitbutler-tauri/src/window.rs b/crates/gitbutler-tauri/src/window.rs index 495ef9370..5455b073d 100644 --- a/crates/gitbutler-tauri/src/window.rs +++ b/crates/gitbutler-tauri/src/window.rs @@ -52,6 +52,11 @@ pub(super) mod state { payload: serde_json::json!(virtual_branches), project_id, }, + Change::UncommitedFiles { project_id, files } => ChangeForFrontend { + name: format!("project://{}/uncommited-files", project_id), + payload: serde_json::json!(files), + project_id, + }, } } } diff --git a/crates/gitbutler-watcher/src/events.rs b/crates/gitbutler-watcher/src/events.rs index 78fc9a2be..ed053536b 100644 --- a/crates/gitbutler-watcher/src/events.rs +++ b/crates/gitbutler-watcher/src/events.rs @@ -1,6 +1,6 @@ use std::{fmt::Display, path::PathBuf}; -use gitbutler_branch_actions::VirtualBranches; +use gitbutler_branch_actions::{RemoteBranchFile, VirtualBranches}; use gitbutler_operating_modes::OperatingMode; use gitbutler_project::ProjectId; @@ -101,4 +101,8 @@ pub enum Change { project_id: ProjectId, virtual_branches: VirtualBranches, }, + UncommitedFiles { + project_id: ProjectId, + files: Vec, + }, } diff --git a/crates/gitbutler-watcher/src/handler.rs b/crates/gitbutler-watcher/src/handler.rs index b3d556567..53fd9d001 100644 --- a/crates/gitbutler-watcher/src/handler.rs +++ b/crates/gitbutler-watcher/src/handler.rs @@ -11,8 +11,8 @@ use gitbutler_oplog::{ entry::{OperationKind, SnapshotDetails}, OplogExt, }; -use gitbutler_project as projects; use gitbutler_project::ProjectId; +use gitbutler_project::{self as projects, Project}; use gitbutler_reference::{LocalRefname, Refname}; use gitbutler_sync::cloud::{push_oplog, push_repo}; use gitbutler_user as users; @@ -125,16 +125,29 @@ impl Handler { #[instrument(skip(self, paths, project_id), fields(paths = paths.len()))] fn recalculate_everything(&self, paths: Vec, project_id: ProjectId) -> Result<()> { let ctx = self.open_command_context(project_id)?; - // Skip if we're not on the open workspace mode - if !in_open_workspace_mode(&ctx) { - return Ok(()); + + self.emit_uncommited_files(ctx.project()); + + if in_open_workspace_mode(&ctx) { + self.maybe_create_snapshot(project_id).ok(); + self.calculate_virtual_branches(project_id)?; } - self.maybe_create_snapshot(project_id).ok(); - self.calculate_virtual_branches(project_id)?; Ok(()) } + /// Try to emit uncommited files. Swollow errors if they arrise. + fn emit_uncommited_files(&self, project: &Project) { + let Ok(files) = VirtualBranchActions.get_uncommited_files(project) else { + return; + }; + + let _ = self.emit_app_event(Change::UncommitedFiles { + project_id: project.id, + files, + }); + } + fn maybe_create_snapshot(&self, project_id: ProjectId) -> anyhow::Result<()> { let project = self .projects