diff --git a/Cargo.lock b/Cargo.lock index f8d2ab1dc8..8769b59266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3990,7 +3990,9 @@ dependencies = [ "gitbutler-command-context", "gitbutler-filemonitor", "gitbutler-operating-modes", + "gitbutler-oxidize", "gitbutler-project", + "gix", "serde-error", "tokio", "tokio-util", diff --git a/apps/desktop/src/components/ChromeHeader.svelte b/apps/desktop/src/components/ChromeHeader.svelte index 23d2a879b1..d7f3a25e15 100644 --- a/apps/desktop/src/components/ChromeHeader.svelte +++ b/apps/desktop/src/components/ChromeHeader.svelte @@ -44,7 +44,7 @@ const useCustomTitleBar = $derived(!($settingsStore?.ui.useNativeTitleBar ?? false)); const backend = inject(BACKEND); - const mode = $derived(modeService.mode({ projectId })); + const mode = $derived(modeService.mode(projectId)); const currentMode = $derived(mode.response); const currentBranchName = $derived.by(() => { if (currentMode?.type === 'OpenWorkspace') { diff --git a/apps/desktop/src/components/NotOnGitButlerBranch.svelte b/apps/desktop/src/components/NotOnGitButlerBranch.svelte index a80a1f2ae5..5155b12223 100644 --- a/apps/desktop/src/components/NotOnGitButlerBranch.svelte +++ b/apps/desktop/src/components/NotOnGitButlerBranch.svelte @@ -25,7 +25,7 @@ const [setBaseBranchTarget, targetBranchSwitch] = baseBranchService.setTarget; const modeService = inject(MODE_SERVICE); - const mode = $derived(modeService.mode({ projectId })); + const mode = $derived(modeService.mode(projectId)); const worktreeService = inject(WORKTREE_SERVICE); const changes = $derived(worktreeService.treeChanges(projectId)); diff --git a/apps/desktop/src/components/SnapshotCard.svelte b/apps/desktop/src/components/SnapshotCard.svelte index 04a958d83d..d3c72de404 100644 --- a/apps/desktop/src/components/SnapshotCard.svelte +++ b/apps/desktop/src/components/SnapshotCard.svelte @@ -168,7 +168,7 @@ const operation = mapOperation(entry.details); const modeService = inject(MODE_SERVICE); - const mode = $derived(modeService.mode({ projectId })); + const mode = $derived(modeService.mode(projectId)); const historyService = inject(HISTORY_SERVICE); const snapshotDiff = $derived(historyService.snapshotDiff({ projectId, snapshotId: entry.id })); diff --git a/apps/desktop/src/lib/mode/modeService.ts b/apps/desktop/src/lib/mode/modeService.ts index 7fe543eb72..ad141815d1 100644 --- a/apps/desktop/src/lib/mode/modeService.ts +++ b/apps/desktop/src/lib/mode/modeService.ts @@ -32,6 +32,10 @@ interface HeadAndMode { operatingMode?: Mode; } +interface HeadSha { + headSha?: string; +} + export const MODE_SERVICE = new InjectionToken('ModeService'); export class ModeService { @@ -69,8 +73,18 @@ export class ModeService { return this.api.endpoints.changesSinceInitialEditState.useQuery; } - get mode() { - return this.api.endpoints.mode.useQuery; + mode(projectId: string) { + return this.api.endpoints.headAndMode.useQuery( + { projectId }, + { transform: (response) => response.operatingMode } + ); + } + + head(projectId: string) { + return this.api.endpoints.headSha.useQuery( + { projectId }, + { transform: (response) => response.headSha } + ); } } @@ -136,7 +150,7 @@ function injectEndpoints(api: ClientState['backendApi']) { unsubscribe(); } }), - mode: build.query({ + headAndMode: build.query({ extraOptions: { command: 'operating_mode' }, query: (args) => args, providesTags: [providesList(ReduxTag.HeadMetadata)], @@ -148,7 +162,26 @@ function injectEndpoints(api: ClientState['backendApi']) { const unsubscribe = lifecycleApi.extra.backend.listen( `project://${arg.projectId}/git/head`, (event) => { - lifecycleApi.updateCachedData(() => event.payload.operatingMode); + lifecycleApi.updateCachedData(() => event.payload); + } + ); + await lifecycleApi.cacheEntryRemoved; + unsubscribe(); + } + }), + headSha: build.query({ + extraOptions: { command: 'head_sha' }, + query: (args) => args, + providesTags: [providesList(ReduxTag.HeadMetadata)], + async onCacheEntryAdded(arg, lifecycleApi) { + if (!hasBackendExtra(lifecycleApi.extra)) { + throw new Error('Redux dependency Backend not found!'); + } + await lifecycleApi.cacheDataLoaded; + const unsubscribe = lifecycleApi.extra.backend.listen( + `project://${arg.projectId}/git/activity`, + (event) => { + lifecycleApi.updateCachedData(() => event.payload); } ); await lifecycleApi.cacheEntryRemoved; diff --git a/apps/desktop/src/lib/stacks/stackService.svelte.ts b/apps/desktop/src/lib/stacks/stackService.svelte.ts index d2e255f2fb..d140d03bef 100644 --- a/apps/desktop/src/lib/stacks/stackService.svelte.ts +++ b/apps/desktop/src/lib/stacks/stackService.svelte.ts @@ -929,10 +929,20 @@ export class StackService { ]) ); } + invalidateStacks() { this.dispatch(this.api.util.invalidateTags([invalidatesList(ReduxTag.Stacks)])); } + invalidateStacksAndDetails() { + this.dispatch( + this.api.util.invalidateTags([ + invalidatesList(ReduxTag.Stacks), + invalidatesList(ReduxTag.StackDetails) + ]) + ); + } + templates(projectId: string, forgeName: string) { return this.api.endpoints.templates.useQuery({ projectId, forge: forgeName }); } diff --git a/apps/desktop/src/routes/[projectId]/+layout.svelte b/apps/desktop/src/routes/[projectId]/+layout.svelte index 6aad29d482..ff76b4969b 100644 --- a/apps/desktop/src/routes/[projectId]/+layout.svelte +++ b/apps/desktop/src/routes/[projectId]/+layout.svelte @@ -82,7 +82,7 @@ const stackService = inject(STACK_SERVICE); const worktreeService = inject(WORKTREE_SERVICE); - const modeQuery = $derived(modeService.mode({ projectId })); + const modeQuery = $derived(modeService.mode(projectId)); const mode = $derived(modeQuery.response); // Invalidate stacks when switching branches outside workspace @@ -214,6 +214,16 @@ stackService.stackDetailsUpdateListener(projectId); }); + const headResponse = $derived(modeService.head(projectId)); + const head = $derived(headResponse.response); + + // If the head changes, invalidate stacks and details + $effect(() => { + if (head) { + stackService.invalidateStacksAndDetails(); + } + }); + // ============================================================================= // AUTO-REFRESH & SYNCHRONIZATION // ============================================================================= diff --git a/apps/desktop/src/routes/[projectId]/edit/+page.svelte b/apps/desktop/src/routes/[projectId]/edit/+page.svelte index aa2e72a62f..28c8efcd78 100644 --- a/apps/desktop/src/routes/[projectId]/edit/+page.svelte +++ b/apps/desktop/src/routes/[projectId]/edit/+page.svelte @@ -9,7 +9,7 @@ // TODO: Refactor so we don't need non-null assertion. const projectId = $derived(page.params.projectId!); const modeService = inject(MODE_SERVICE); - const mode = $derived(modeService.mode({ projectId })); + const mode = $derived(modeService.mode(projectId)); let editModeMetadata = $state(); diff --git a/apps/desktop/src/routes/[projectId]/workspace/+page.svelte b/apps/desktop/src/routes/[projectId]/workspace/+page.svelte index 61895ef02c..db4265d7e2 100644 --- a/apps/desktop/src/routes/[projectId]/workspace/+page.svelte +++ b/apps/desktop/src/routes/[projectId]/workspace/+page.svelte @@ -10,7 +10,7 @@ const modeService = inject(MODE_SERVICE); const projectId = $derived(page.params.projectId!); - const mode = $derived(modeService.mode({ projectId })); + const mode = $derived(modeService.mode(projectId)); const uiState = inject(UI_STATE); const stackService = inject(STACK_SERVICE); const projectState = $derived(uiState.project(projectId)); diff --git a/crates/but-api/src/commands/modes.rs b/crates/but-api/src/commands/modes.rs index 3734ca9c5d..c18aaf5ee8 100644 --- a/crates/but-api/src/commands/modes.rs +++ b/crates/but-api/src/commands/modes.rs @@ -12,13 +12,50 @@ use tracing::instrument; use crate::error::Error; +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HeadAndMode { + pub head: Option, + pub operating_mode: Option, +} + #[api_cmd] #[cfg_attr(feature = "tauri", tauri::command(async))] #[instrument(err(Debug))] -pub fn operating_mode(project_id: ProjectId) -> Result { +pub fn operating_mode(project_id: ProjectId) -> Result { let project = gitbutler_project::get(project_id)?; let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; - Ok(gitbutler_operating_modes::operating_mode(&ctx)) + let head = match ctx.repo().head() { + Ok(head_ref) => head_ref.shorthand().map(|s| s.to_string()), + Err(_) => None, + }; + + Ok(HeadAndMode { + head, + operating_mode: Some(gitbutler_operating_modes::operating_mode(&ctx)), + }) +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HeadSha { + head_sha: String, +} + +#[api_cmd] +#[cfg_attr(feature = "tauri", tauri::command(async))] +#[instrument(err(Debug))] +pub fn head_sha(project_id: ProjectId) -> Result { + let project = gitbutler_project::get(project_id)?; + let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + let head_ref = ctx.repo().head().context("failed to get head")?; + let head_sha = head_ref + .peel_to_commit() + .context("failed to get head commit")? + .id() + .to_string(); + + Ok(HeadSha { head_sha }) } #[api_cmd] diff --git a/crates/but-server/src/lib.rs b/crates/but-server/src/lib.rs index 822712b7f1..5d5d9e3e1b 100644 --- a/crates/but-server/src/lib.rs +++ b/crates/but-server/src/lib.rs @@ -310,6 +310,7 @@ async fn handle_command( } // Operating modes commands "operating_mode" => modes::operating_mode_cmd(request.params), + "head_sha" => modes::head_sha_cmd(request.params), "enter_edit_mode" => modes::enter_edit_mode_cmd(request.params), "abort_edit_and_return_to_workspace" => { modes::abort_edit_and_return_to_workspace_cmd(request.params) diff --git a/crates/but-server/src/projects.rs b/crates/but-server/src/projects.rs index 06dd7cdaa4..e0fd96e2b5 100644 --- a/crates/but-server/src/projects.rs +++ b/crates/but-server/src/projects.rs @@ -65,9 +65,14 @@ impl ActiveProjects { name: format!("project://{project_id}/git/head"), payload: serde_json::json!({ "head": head, "operatingMode": operating_mode }), }, - Change::GitActivity(project_id) => FrontendEvent { + Change::GitActivity { + project_id, + head_sha, + } => FrontendEvent { name: format!("project://{project_id}/git/activity"), - payload: serde_json::json!({}), + payload: serde_json::json!({ + "headSha": head_sha, + }), }, Change::WorktreeChanges { project_id, diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 7f89a39a14..2862bd75db 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -298,6 +298,7 @@ fn main() -> anyhow::Result<()> { remotes::list_remotes, remotes::add_remote, modes::operating_mode, + modes::head_sha, modes::enter_edit_mode, modes::save_edit_and_return_to_workspace, modes::abort_edit_and_return_to_workspace, diff --git a/crates/gitbutler-tauri/src/window.rs b/crates/gitbutler-tauri/src/window.rs index e328597ad9..0c5a20e94e 100644 --- a/crates/gitbutler-tauri/src/window.rs +++ b/crates/gitbutler-tauri/src/window.rs @@ -42,9 +42,14 @@ pub(crate) mod state { payload: serde_json::json!({ "head": head, "operatingMode": operating_mode }), project_id, }, - Change::GitActivity(project_id) => ChangeForFrontend { + Change::GitActivity { + project_id, + head_sha, + } => ChangeForFrontend { name: format!("project://{project_id}/git/activity"), - payload: serde_json::json!({}), + payload: serde_json::json!({ + "headSha": head_sha, + }), project_id, }, Change::WorktreeChanges { diff --git a/crates/gitbutler-watcher/Cargo.toml b/crates/gitbutler-watcher/Cargo.toml index bf346bacd8..3ff1dd7b50 100644 --- a/crates/gitbutler-watcher/Cargo.toml +++ b/crates/gitbutler-watcher/Cargo.toml @@ -24,6 +24,8 @@ but-settings.workspace = true but-hunk-assignment.workspace = true but-hunk-dependency.workspace = true serde-error = "0.1.3" +gix.workspace = true +gitbutler-oxidize.workspace = true [lints.clippy] all = "deny" diff --git a/crates/gitbutler-watcher/src/events.rs b/crates/gitbutler-watcher/src/events.rs index 7b97d783ca..439d6554bd 100644 --- a/crates/gitbutler-watcher/src/events.rs +++ b/crates/gitbutler-watcher/src/events.rs @@ -11,7 +11,10 @@ pub enum Change { head: String, operating_mode: OperatingMode, }, - GitActivity(ProjectId), + GitActivity { + project_id: ProjectId, + head_sha: String, + }, WorktreeChanges { project_id: ProjectId, changes: but_hunk_assignment::WorktreeChanges, diff --git a/crates/gitbutler-watcher/src/handler.rs b/crates/gitbutler-watcher/src/handler.rs index e34fe91c0e..3f98f532c9 100644 --- a/crates/gitbutler-watcher/src/handler.rs +++ b/crates/gitbutler-watcher/src/handler.rs @@ -140,7 +140,17 @@ impl Handler { self.emit_app_event(Change::GitFetch(ctx.project().id))?; } "logs/HEAD" => { - self.emit_app_event(Change::GitActivity(ctx.project().id))?; + let repo = ctx.repo(); + let head_ref = repo.head().context("failed to get head")?; + let head_sha = head_ref + .peel_to_commit() + .context("failed to get head commit")? + .id() + .to_string(); + self.emit_app_event(Change::GitActivity { + project_id: ctx.project().id, + head_sha, + })?; } "index" => { let _ = self.emit_worktree_changes(ctx);