diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 5819949a..72423bc1 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -656,57 +656,73 @@ pub async fn build_note_followup_message( let store = get_store(&store)?; tauri::async_runtime::spawn_blocking(move || { - store - .get_session(&session_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Session not found: {session_id}"))?; - - let linked_commit = store.get_commit_by_session(&session_id).ok().flatten(); - let linked_note = store.get_note_by_session(&session_id).ok().flatten(); - let linked_review = store.get_review_by_session(&session_id).ok().flatten(); - - let branch_from_id = branch_id - .as_deref() - .and_then(|bid| store.get_branch(bid).ok().flatten()); - - let linked_branch = if branch_from_id.is_some() { - branch_from_id - } else if let Some(commit) = &linked_commit { - store.get_branch(&commit.branch_id).ok().flatten() - } else if let Some(note) = &linked_note { - store.get_branch(¬e.branch_id).ok().flatten() - } else if let Some(review) = &linked_review { - store.get_branch(&review.branch_id).ok().flatten() - } else { - None - }; - - let pikchr_grammar_reference = resolve_pikchr_grammar_reference( + build_note_followup_message_impl( + &store, &app_handle, - linked_branch - .as_ref() - .and_then(|branch| branch.workspace_name.as_deref()), - ); - - // Note follow-ups expose the preview tool on local sessions only. A - // project-note follow-up has no linked branch and always runs locally. - let preview_available = local_note_pikchr_preview_available( - true, - linked_branch - .as_ref() - .and_then(|branch| branch.workspace_name.as_deref()), - ); - - Ok(build_note_followup_message_with_pikchr_reference( + &session_id, + branch_id.as_deref(), has_parsed_note, - &pikchr_grammar_reference, - preview_available, - )) + ) }) .await .map_err(|e| format!("Failed to build note follow-up message: {e}"))? } +/// Synchronous body of [`build_note_followup_message`], shared with the web-mode +/// `dispatch()` arm. Callers run it inside `spawn_blocking`. +pub(crate) fn build_note_followup_message_impl( + store: &Arc, + app_handle: &tauri::AppHandle, + session_id: &str, + branch_id: Option<&str>, + has_parsed_note: bool, +) -> Result { + store + .get_session(session_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Session not found: {session_id}"))?; + + let linked_commit = store.get_commit_by_session(session_id).ok().flatten(); + let linked_note = store.get_note_by_session(session_id).ok().flatten(); + let linked_review = store.get_review_by_session(session_id).ok().flatten(); + + let branch_from_id = branch_id.and_then(|bid| store.get_branch(bid).ok().flatten()); + + let linked_branch = if branch_from_id.is_some() { + branch_from_id + } else if let Some(commit) = &linked_commit { + store.get_branch(&commit.branch_id).ok().flatten() + } else if let Some(note) = &linked_note { + store.get_branch(¬e.branch_id).ok().flatten() + } else if let Some(review) = &linked_review { + store.get_branch(&review.branch_id).ok().flatten() + } else { + None + }; + + let pikchr_grammar_reference = resolve_pikchr_grammar_reference( + app_handle, + linked_branch + .as_ref() + .and_then(|branch| branch.workspace_name.as_deref()), + ); + + // Note follow-ups expose the preview tool on local sessions only. A + // project-note follow-up has no linked branch and always runs locally. + let preview_available = local_note_pikchr_preview_available( + true, + linked_branch + .as_ref() + .and_then(|branch| branch.workspace_name.as_deref()), + ); + + Ok(build_note_followup_message_with_pikchr_reference( + has_parsed_note, + &pikchr_grammar_reference, + preview_available, + )) +} + #[tauri::command] pub fn cancel_session( registry: tauri::State<'_, Arc>, diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index 47793473..f7608b47 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -594,74 +594,85 @@ pub async fn refresh_branch_git_state( force: Option, ) -> Result<(), String> { let store = crate::get_store(&store)?; + tauri::async_runtime::spawn_blocking(move || { + refresh_branch_git_state_impl(&app, &store, &branch_id, force) + }) + .await + .map_err(|e| format!("Git state refresh task failed: {e}"))? +} + +/// Synchronous body of [`refresh_branch_git_state`], shared verbatim with the +/// web-mode `dispatch()` arm. Callers run it inside `spawn_blocking`. +pub(crate) fn refresh_branch_git_state_impl( + app: &tauri::AppHandle, + store: &Arc, + branch_id: &str, + force: Option, +) -> Result<(), String> { let fetch_mode = if force.unwrap_or(false) { git::FetchMode::Force } else { git::FetchMode::Ttl }; - tauri::async_runtime::spawn_blocking(move || { - let branch = store - .get_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + let branch = store + .get_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; - let workdir = store - .get_workdir_for_branch(&branch_id) - .map_err(|e| e.to_string())?; + let workdir = store + .get_workdir_for_branch(branch_id) + .map_err(|e| e.to_string())?; - let git_state = if let Some(ref ws_name) = branch.workspace_name { - let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; - let cache_key = remote_git_state_cache_key( - ws_name, - repo_subpath.as_deref(), - &branch.branch_name, - &branch.base_branch, - ); - let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + let git_state = if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(store, &branch)?; + let cache_key = remote_git_state_cache_key( + ws_name, + repo_subpath.as_deref(), + &branch.branch_name, + &branch.base_branch, + ); + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; - Some(git::compute_branch_git_state_batched( - &cache_key, - |script, args| { - branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) - }, - &resolved_path, + Some(git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, + &branch.branch_name, + &branch.base_branch, + fetch_mode, + git::WorktreeStatusScope::Full, + )) + } else if let Some(ref wd) = workdir { + let worktree_path = Path::new(&wd.path); + if worktree_path.exists() { + Some(git::compute_local_branch_git_state( + worktree_path, &branch.branch_name, &branch.base_branch, fetch_mode, git::WorktreeStatusScope::Full, + git::EnvSource::Captured, )) - } else if let Some(ref wd) = workdir { - let worktree_path = Path::new(&wd.path); - if worktree_path.exists() { - Some(git::compute_local_branch_git_state( - worktree_path, - &branch.branch_name, - &branch.base_branch, - fetch_mode, - git::WorktreeStatusScope::Full, - git::EnvSource::Captured, - )) - } else { - None - } } else { None - }; - - if let Some(state) = git_state { - let _ = app.emit( - "git-state-updated", - GitStateUpdatedPayload { - branch_id: branch_id.to_string(), - git_state: state, - }, - ); } - Ok(()) - }) - .await - .map_err(|e| format!("Git state refresh task failed: {e}"))? + } else { + None + }; + + if let Some(state) = git_state { + let _ = app.emit( + "git-state-updated", + GitStateUpdatedPayload { + branch_id: branch_id.to_string(), + git_state: state, + }, + ); + } + Ok(()) } /// Return the commits on the parent branch that aren't yet in this branch's @@ -677,57 +688,39 @@ pub async fn list_parent_branch_commits( let store = crate::get_store(&store)?; tauri::async_runtime::spawn_blocking(move || { - let branch = store - .get_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Branch not found: {branch_id}"))?; - - let base_ref = git::origin_ref_for_branch(&branch.base_branch); - let format_arg = "--format=%H|%h|%s|%an|%ae|%ct"; + list_parent_branch_commits_impl(&store, &branch_id) + }) + .await + .map_err(|e| format!("List parent branch commits task failed: {e}"))? +} - if let Some(ref ws_name) = branch.workspace_name { - let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; - let range = match branches::run_workspace_git( - ws_name, - repo_subpath.as_deref(), - &["merge-base", &base_ref, "HEAD"], - ) { - Ok(mb_output) => format!("{}..{base_ref}", mb_output.trim()), - Err(_) => base_ref.clone(), - }; - let output = match branches::run_workspace_git( - ws_name, - repo_subpath.as_deref(), - &["log", "--max-count=26", format_arg, &range], - ) { - Ok(o) => o, - Err(_) => return Ok(Vec::new()), - }; - let lines: Vec = output - .lines() - .filter(|l| !l.is_empty()) - .map(|l| l.to_string()) - .collect(); - return Ok(parse_parent_commit_lines(&lines)); - } +/// Synchronous body of [`list_parent_branch_commits`], shared verbatim with the +/// web-mode `dispatch()` arm. Callers run it inside `spawn_blocking`. +pub(crate) fn list_parent_branch_commits_impl( + store: &Arc, + branch_id: &str, +) -> Result, String> { + let branch = store + .get_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; - let workdir = store - .get_workdir_for_branch(&branch_id) - .map_err(|e| e.to_string())?; - let Some(wd) = workdir else { - return Ok(Vec::new()); - }; - let worktree_path = Path::new(&wd.path); - if !worktree_path.exists() { - return Ok(Vec::new()); - } + let base_ref = git::origin_ref_for_branch(&branch.base_branch); + let format_arg = "--format=%H|%h|%s|%an|%ae|%ct"; - let range = match git::cli_run_smart(worktree_path, &["merge-base", &base_ref, "HEAD"]) { + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(store, &branch)?; + let range = match branches::run_workspace_git( + ws_name, + repo_subpath.as_deref(), + &["merge-base", &base_ref, "HEAD"], + ) { Ok(mb_output) => format!("{}..{base_ref}", mb_output.trim()), Err(_) => base_ref.clone(), }; - let output = match git::cli_run_smart( - worktree_path, + let output = match branches::run_workspace_git( + ws_name, + repo_subpath.as_deref(), &["log", "--max-count=26", format_arg, &range], ) { Ok(o) => o, @@ -738,10 +731,37 @@ pub async fn list_parent_branch_commits( .filter(|l| !l.is_empty()) .map(|l| l.to_string()) .collect(); - Ok(parse_parent_commit_lines(&lines)) - }) - .await - .map_err(|e| format!("List parent branch commits task failed: {e}"))? + return Ok(parse_parent_commit_lines(&lines)); + } + + let workdir = store + .get_workdir_for_branch(branch_id) + .map_err(|e| e.to_string())?; + let Some(wd) = workdir else { + return Ok(Vec::new()); + }; + let worktree_path = Path::new(&wd.path); + if !worktree_path.exists() { + return Ok(Vec::new()); + } + + let range = match git::cli_run_smart(worktree_path, &["merge-base", &base_ref, "HEAD"]) { + Ok(mb_output) => format!("{}..{base_ref}", mb_output.trim()), + Err(_) => base_ref.clone(), + }; + let output = match git::cli_run_smart( + worktree_path, + &["log", "--max-count=26", format_arg, &range], + ) { + Ok(o) => o, + Err(_) => return Ok(Vec::new()), + }; + let lines: Vec = output + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + Ok(parse_parent_commit_lines(&lines)) } #[tauri::command(rename_all = "camelCase")] @@ -751,61 +771,65 @@ pub async fn pull_branch_ff_only( ) -> Result<(), String> { let store = crate::get_store(&store)?; - tauri::async_runtime::spawn_blocking(move || { - let branch = store - .get_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + tauri::async_runtime::spawn_blocking(move || pull_branch_ff_only_impl(&store, &branch_id)) + .await + .map_err(|e| format!("Pull task failed: {e}"))? +} - if let Some(ref ws_name) = branch.workspace_name { - let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; - let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; - let cache_key = remote_git_state_cache_key( - ws_name, - repo_subpath.as_deref(), - &branch.branch_name, - &branch.base_branch, - ); - let state = git::compute_branch_git_state_batched( - &cache_key, - |script, args| { - branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.branch_name, - &branch.base_branch, - git::FetchMode::Force, - git::WorktreeStatusScope::Full, - ); - git::ensure_fast_forward_pullable(&state)?; - branches::run_workspace_git( - ws_name, - repo_subpath.as_deref(), - &["merge", "--ff-only", &state.upstream.r#ref], - ) - .map_err(|e| e.to_string())?; - return Ok(()); - } +/// Synchronous body of [`pull_branch_ff_only`], shared verbatim with the +/// web-mode `dispatch()` arm. Callers run it inside `spawn_blocking`. +pub(crate) fn pull_branch_ff_only_impl(store: &Arc, branch_id: &str) -> Result<(), String> { + let branch = store + .get_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; - let workdir = store - .get_workdir_for_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; - let worktree = Path::new(&workdir.path); - let state = git::compute_local_branch_git_state( - worktree, + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(store, &branch)?; + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + let cache_key = remote_git_state_cache_key( + ws_name, + repo_subpath.as_deref(), + &branch.branch_name, + &branch.base_branch, + ); + let state = git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, &branch.branch_name, &branch.base_branch, git::FetchMode::Force, git::WorktreeStatusScope::Full, - git::EnvSource::Captured, ); git::ensure_fast_forward_pullable(&state)?; - git::fast_forward_to_ref(worktree, &state.upstream.r#ref).map_err(|e| e.to_string())?; - Ok(()) - }) - .await - .map_err(|e| format!("Pull task failed: {e}"))? + branches::run_workspace_git( + ws_name, + repo_subpath.as_deref(), + &["merge", "--ff-only", &state.upstream.r#ref], + ) + .map_err(|e| e.to_string())?; + return Ok(()); + } + + let workdir = store + .get_workdir_for_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + let worktree = Path::new(&workdir.path); + let state = git::compute_local_branch_git_state( + worktree, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Force, + git::WorktreeStatusScope::Full, + git::EnvSource::Captured, + ); + git::ensure_fast_forward_pullable(&state)?; + git::fast_forward_to_ref(worktree, &state.upstream.r#ref).map_err(|e| e.to_string())?; + Ok(()) } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -893,7 +917,10 @@ fn ensure_reset_to_remote_allowed(state: &git::BranchGitState) -> Result<(), Str Ok(()) } -fn reset_branch_to_remote_impl(store: &Arc, branch_id: &str) -> Result<(), String> { +pub(crate) fn reset_branch_to_remote_impl( + store: &Arc, + branch_id: &str, +) -> Result<(), String> { let branch = store .get_branch(branch_id) .map_err(|e| e.to_string())? @@ -1009,29 +1036,38 @@ pub async fn get_worktree_changes_preview( let store = crate::get_store(&store)?; tauri::async_runtime::spawn_blocking(move || { - let branch = store - .get_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Branch not found: {branch_id}"))?; - - if let Some(ref ws_name) = branch.workspace_name { - let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; - return remote_worktree_change_paths(ws_name, repo_subpath.as_deref()) - .map(preview_from_change_paths); - } - - let workdir = store - .get_workdir_for_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; - let paths = - git::list_worktree_change_paths(Path::new(&workdir.path)).map_err(|e| e.to_string())?; - Ok(preview_from_change_paths(paths)) + get_worktree_changes_preview_impl(&store, &branch_id) }) .await .map_err(|e| format!("Worktree preview task failed: {e}"))? } +/// Synchronous body of [`get_worktree_changes_preview`], shared verbatim with +/// the web-mode `dispatch()` arm. Callers run it inside `spawn_blocking`. +pub(crate) fn get_worktree_changes_preview_impl( + store: &Arc, + branch_id: &str, +) -> Result { + let branch = store + .get_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(store, &branch)?; + return remote_worktree_change_paths(ws_name, repo_subpath.as_deref()) + .map(preview_from_change_paths); + } + + let workdir = store + .get_workdir_for_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + let paths = + git::list_worktree_change_paths(Path::new(&workdir.path)).map_err(|e| e.to_string())?; + Ok(preview_from_change_paths(paths)) +} + #[tauri::command(rename_all = "camelCase")] pub async fn discard_worktree_changes( store: tauri::State<'_, Mutex>>>, @@ -1041,58 +1077,46 @@ pub async fn discard_worktree_changes( let store = crate::get_store(&store)?; tauri::async_runtime::spawn_blocking(move || { - let branch = store - .get_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Branch not found: {branch_id}"))?; + discard_worktree_changes_impl(&store, &branch_id, expected_preview) + }) + .await + .map_err(|e| format!("Discard task failed: {e}"))? +} - if let Some(ref ws_name) = branch.workspace_name { - let repo_subpath = branches::resolve_branch_workspace_subpath(&store, &branch)?; - let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; - let cache_key = remote_git_state_cache_key( - ws_name, - repo_subpath.as_deref(), - &branch.branch_name, - &branch.base_branch, - ); - let state = git::compute_branch_git_state_batched( - &cache_key, - |script, args| { - branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) - }, - &resolved_path, - &branch.branch_name, - &branch.base_branch, - git::FetchMode::Never, - git::WorktreeStatusScope::Full, - ); - ensure_worktree_discardable(&state)?; - let changes = remote_worktree_change_paths(ws_name, repo_subpath.as_deref())?; - ensure_preview_matches(&changes, expected_preview.as_ref())?; - if changes.is_empty() { - return Ok(()); - } - if !changes.conflicted_paths.is_empty() { - return Err("Resolve merge conflicts before discarding changes".to_string()); - } - return discard_remote_worktree_changes(ws_name, repo_subpath.as_deref(), &changes); - } +/// Synchronous body of [`discard_worktree_changes`], shared verbatim with the +/// web-mode `dispatch()` arm. Callers run it inside `spawn_blocking`. +pub(crate) fn discard_worktree_changes_impl( + store: &Arc, + branch_id: &str, + expected_preview: Option, +) -> Result<(), String> { + let branch = store + .get_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Branch not found: {branch_id}"))?; - let workdir = store - .get_workdir_for_branch(&branch_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; - let worktree = Path::new(&workdir.path); - let state = git::compute_local_branch_git_state( - worktree, + if let Some(ref ws_name) = branch.workspace_name { + let repo_subpath = branches::resolve_branch_workspace_subpath(store, &branch)?; + let resolved_path = resolve_repo_path(ws_name, repo_subpath.as_deref())?; + let cache_key = remote_git_state_cache_key( + ws_name, + repo_subpath.as_deref(), + &branch.branch_name, + &branch.base_branch, + ); + let state = git::compute_branch_git_state_batched( + &cache_key, + |script, args| { + branches::run_workspace_shell(ws_name, script, args).map_err(|e| e.to_string()) + }, + &resolved_path, &branch.branch_name, &branch.base_branch, git::FetchMode::Never, git::WorktreeStatusScope::Full, - git::EnvSource::Captured, ); ensure_worktree_discardable(&state)?; - let changes = git::list_worktree_change_paths(worktree).map_err(|e| e.to_string())?; + let changes = remote_worktree_change_paths(ws_name, repo_subpath.as_deref())?; ensure_preview_matches(&changes, expected_preview.as_ref())?; if changes.is_empty() { return Ok(()); @@ -1100,11 +1124,33 @@ pub async fn discard_worktree_changes( if !changes.conflicted_paths.is_empty() { return Err("Resolve merge conflicts before discarding changes".to_string()); } - git::discard_worktree_changes(worktree, &changes).map_err(|e| e.to_string())?; - Ok(()) - }) - .await - .map_err(|e| format!("Discard task failed: {e}"))? + return discard_remote_worktree_changes(ws_name, repo_subpath.as_deref(), &changes); + } + + let workdir = store + .get_workdir_for_branch(branch_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("No worktree for branch: {branch_id}"))?; + let worktree = Path::new(&workdir.path); + let state = git::compute_local_branch_git_state( + worktree, + &branch.branch_name, + &branch.base_branch, + git::FetchMode::Never, + git::WorktreeStatusScope::Full, + git::EnvSource::Captured, + ); + ensure_worktree_discardable(&state)?; + let changes = git::list_worktree_change_paths(worktree).map_err(|e| e.to_string())?; + ensure_preview_matches(&changes, expected_preview.as_ref())?; + if changes.is_empty() { + return Ok(()); + } + if !changes.conflicted_paths.is_empty() { + return Err("Resolve merge conflicts before discarding changes".to_string()); + } + git::discard_worktree_changes(worktree, &changes).map_err(|e| e.to_string())?; + Ok(()) } /// Cancel and delete any reviews (auto or manual) created at or after a commit's diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 05467a3c..da3d55e3 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -44,6 +44,7 @@ use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use crate::actions::{ActionExecutor, ActionRegistry}; +use crate::pr_poll_scheduler::PrPollScheduler; use crate::session_commands; use crate::session_runner::{self, SessionConfig, SessionRegistry}; use crate::store::{self, Store}; @@ -389,6 +390,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result = &app_handle.state::>(); let action_executor: &Arc = &app_handle.state::>(); let action_registry: &Arc = &app_handle.state::>(); + let pr_scheduler: &Arc = &app_handle.state::>(); match command { // ===================================================================== @@ -2222,6 +2224,70 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let force: Option = opt_arg(&args, "force")?; + let app = app_handle.clone(); + tauri::async_runtime::spawn_blocking(move || { + crate::timeline::refresh_branch_git_state_impl(&app, &store, &branch_id, force) + }) + .await + .map_err(|e| format!("Git state refresh task failed: {e}"))??; + Ok(Value::Null) + } + "list_parent_branch_commits" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let commits = tauri::async_runtime::spawn_blocking(move || { + crate::timeline::list_parent_branch_commits_impl(&store, &branch_id) + }) + .await + .map_err(|e| format!("List parent branch commits task failed: {e}"))??; + Ok(serde_json::to_value(commits).unwrap()) + } + "pull_branch_ff_only" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + tauri::async_runtime::spawn_blocking(move || { + crate::timeline::pull_branch_ff_only_impl(&store, &branch_id) + }) + .await + .map_err(|e| format!("Pull task failed: {e}"))??; + Ok(Value::Null) + } + "reset_branch_to_remote" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + tauri::async_runtime::spawn_blocking(move || { + crate::timeline::reset_branch_to_remote_impl(&store, &branch_id) + }) + .await + .map_err(|e| format!("Reset task failed: {e}"))??; + Ok(Value::Null) + } + "get_worktree_changes_preview" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let preview = tauri::async_runtime::spawn_blocking(move || { + crate::timeline::get_worktree_changes_preview_impl(&store, &branch_id) + }) + .await + .map_err(|e| format!("Worktree preview task failed: {e}"))??; + Ok(serde_json::to_value(preview).unwrap()) + } + "discard_worktree_changes" => { + let store = get_store(store_mutex)?; + let branch_id: String = arg(&args, "branchId")?; + let expected_preview: Option = + opt_arg(&args, "expectedPreview")?; + tauri::async_runtime::spawn_blocking(move || { + crate::timeline::discard_worktree_changes_impl(&store, &branch_id, expected_preview) + }) + .await + .map_err(|e| format!("Discard task failed: {e}"))??; + Ok(Value::Null) + } // ===================================================================== // Notes @@ -2655,6 +2721,15 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let after_timestamp: i64 = arg(&args, "afterTimestamp")?; + let count = store + .count_assistant_messages_after(&session_id, after_timestamp) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(count).unwrap()) + } "start_session" => { let store = get_store(store_mutex)?; let prompt: String = arg(&args, "prompt")?; @@ -2873,6 +2948,25 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let branch_id: Option = opt_arg(&args, "branchId")?; + let has_parsed_note: bool = arg(&args, "hasParsedNote")?; + let app = app_handle.clone(); + let message = tauri::async_runtime::spawn_blocking(move || { + session_commands::build_note_followup_message_impl( + &store, + &app, + &session_id, + branch_id.as_deref(), + has_parsed_note, + ) + }) + .await + .map_err(|e| format!("Failed to build note follow-up message: {e}"))??; + Ok(serde_json::to_value(message).unwrap()) + } "start_branch_session" | "start_or_queue_branch_session" => { let store = get_store(store_mutex)?; let branch_id: String = arg(&args, "branchId")?; @@ -3338,6 +3432,42 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let client_id: String = arg(&args, "clientId")?; + let project_id: Option = opt_arg(&args, "projectId")?; + pr_scheduler.set_foreground(client_id, project_id); + Ok(Value::Null) + } + "set_focus" => { + let client_id: String = arg(&args, "clientId")?; + let focused: bool = arg(&args, "focused")?; + pr_scheduler.set_focus(client_id, focused); + Ok(Value::Null) + } + "set_branch_pending" => { + let client_id: String = arg(&args, "clientId")?; + let branch_id: String = arg(&args, "branchId")?; + let project_id: String = arg(&args, "projectId")?; + let pending: bool = arg(&args, "pending")?; + pr_scheduler.set_branch_pending(client_id, branch_id, project_id, pending); + Ok(Value::Null) + } + "refresh_now" => { + let client_id: String = arg(&args, "clientId")?; + let project_id: String = arg(&args, "projectId")?; + pr_scheduler.touch(client_id); + pr_scheduler.force(project_id); + Ok(Value::Null) + } + "disconnect_client" => { + let client_id: String = arg(&args, "clientId")?; + pr_scheduler.disconnect_client(client_id); + Ok(Value::Null) + } + // ===================================================================== // Utilities // ===================================================================== @@ -3413,3 +3543,131 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Err(format!("Unknown command: {command}")), } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + const INTENTIONALLY_UNSUPPORTED_WEB_COMMANDS: &[&str] = &[]; + + #[test] + fn web_dispatch_covers_tauri_commands() { + let tauri_commands = extract_generate_handler_commands(include_str!("lib.rs")); + let dispatch_commands = extract_dispatch_commands(include_str!("web_server.rs")); + let intentionally_unsupported = INTENTIONALLY_UNSUPPORTED_WEB_COMMANDS + .iter() + .copied() + .collect::>(); + + let missing = tauri_commands + .difference(&dispatch_commands) + .filter(|command| !intentionally_unsupported.contains(command.as_str())) + .cloned() + .collect::>(); + + assert!( + missing.is_empty(), + "web_server::dispatch is missing command arms for: {missing:#?}" + ); + } + + fn extract_generate_handler_commands(source: &str) -> BTreeSet { + let marker = "tauri::generate_handler!["; + let start = source + .find(marker) + .expect("generate_handler! command list should exist") + + marker.len(); + let end = source[start..] + .find("])") + .expect("generate_handler! command list should end") + + start; + + source[start..end] + .lines() + .filter_map(|line| { + let entry = line.split("//").next()?.trim().trim_end_matches(',').trim(); + if entry.is_empty() { + None + } else { + Some(entry.rsplit("::").next().unwrap_or(entry).to_string()) + } + }) + .collect() + } + + fn extract_dispatch_commands(source: &str) -> BTreeSet { + let marker = "match command {"; + let start = source + .find(marker) + .expect("dispatch command match should exist") + + marker.len(); + let mut depth = 1isize; + let mut commands = BTreeSet::new(); + + for line in source[start..].lines() { + if depth == 1 { + let trimmed = line.trim_start(); + if trimmed.starts_with("_ =>") { + break; + } + commands.extend(extract_match_pattern_strings(trimmed)); + } + + depth += brace_delta_ignoring_strings(line); + if depth == 0 { + break; + } + } + + commands + } + + fn extract_match_pattern_strings(line: &str) -> Vec { + let Some((patterns, _)) = line.split_once("=>") else { + return Vec::new(); + }; + + let mut names = Vec::new(); + let mut rest = patterns; + while let Some(start) = rest.find('"') { + rest = &rest[start + 1..]; + let Some(end) = rest.find('"') else { + break; + }; + names.push(rest[..end].to_string()); + rest = &rest[end + 1..]; + } + + names + } + + fn brace_delta_ignoring_strings(line: &str) -> isize { + let mut delta = 0; + let mut chars = line.chars().peekable(); + let mut in_string = false; + let mut escaped = false; + + while let Some(ch) = chars.next() { + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => in_string = true, + '/' if chars.peek() == Some(&'/') => break, + '{' => delta += 1, + '}' => delta -= 1, + _ => {} + } + } + + delta + } +}