diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 58564a45c2f..fcdee150609 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -93,7 +93,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { validate_hex64(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); - let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let auth = + resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?; let builder = build_archive_identity_request( &target_pubkey, &content, @@ -124,7 +125,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } => { validate_hex64(&target_pubkey)?; let signer_hex = client.keys().public_key().to_hex(); - let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let auth = + resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?; let builder = build_unarchive_identity_request( &target_pubkey, &content, @@ -160,19 +162,175 @@ fn require_owner(client: &BuzzClient) -> Result { PublicKey::parse(&hex).map_err(|e| CliError::Auth(format!("invalid owner attestation: {e}"))) } +/// Typed reason why NIP-OA owner-auth could not be extracted from a kind:0. +/// +/// Produced by [`classify_owner_auth_tag`] and formatted into a JSON warning +/// by [`resolve_auth`]. One variant per distinguishable failure cause so the +/// diagnostic is always accurate and never duplicates validation logic. +#[derive(Debug, PartialEq)] +enum AuthFailure { + /// kind:0 has no `tags` array or the array is empty of `auth`-labelled entries. + NoAuthTag, + /// kind:0 has more than one `auth`-labelled tag; count included. + AmbiguousAuthTag(usize), + /// Sole `auth` tag has wrong element count; actual count included. + WrongArity(usize), + /// Sole `auth` tag contains a non-string element. + NonStringElement, + /// Sole `auth` tag owner field is not a valid 64-hex pubkey; value included. + InvalidOwnerHex(String), + /// Sole `auth` tag sig field is not a valid 128-hex signature. + InvalidSigHex, + /// Tag is structurally valid but names a different owner; actual owner included. + OwnerMismatch(String), +} + +impl AuthFailure { + /// Human-readable description suitable for the `"warning"` JSON field. + fn message(&self) -> String { + match self { + AuthFailure::NoAuthTag => "target kind:0 has no \"auth\" tag".to_owned(), + AuthFailure::AmbiguousAuthTag(n) => format!( + "target kind:0 has {n} \"auth\" tags (expected exactly 1) — ambiguous ownership" + ), + AuthFailure::WrongArity(n) => format!( + "sole \"auth\" tag has {n} element(s) (expected 4: label, owner, conditions, sig)" + ), + AuthFailure::NonStringElement => { + "sole \"auth\" tag contains a non-string element".to_owned() + } + AuthFailure::InvalidOwnerHex(v) => { + format!("sole \"auth\" tag owner field is not a valid 64-hex pubkey: {v}") + } + AuthFailure::InvalidSigHex => { + "sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned() + } + AuthFailure::OwnerMismatch(actual) => { + format!("sole \"auth\" tag names owner {actual} which does not match your key") + } + } + } +} + +/// Single classifier: either extract the auth tag or return the typed reason +/// for failure. [`extract_owner_auth_tag`] is a thin `.ok()` wrapper kept for +/// the existing tests that assert on `Option`. +fn classify_owner_auth_tag( + tags: &[serde_json::Value], + signer_hex: &str, +) -> Result<[String; 4], AuthFailure> { + let auth_tags: Vec<&serde_json::Value> = tags + .iter() + .filter(|tag| { + tag.as_array() + .and_then(|elems| elems.first()) + .and_then(|v| v.as_str()) + == Some("auth") + }) + .collect(); + match auth_tags.len() { + 0 => return Err(AuthFailure::NoAuthTag), + n if n > 1 => return Err(AuthFailure::AmbiguousAuthTag(n)), + _ => {} + } + + // Exactly one auth tag. + let elems = auth_tags[0] + .as_array() + .ok_or(AuthFailure::NonStringElement)?; + if elems.len() != 4 { + return Err(AuthFailure::WrongArity(elems.len())); + } + let label = elems[0].as_str().ok_or(AuthFailure::NonStringElement)?; + let owner = elems[1].as_str().ok_or(AuthFailure::NonStringElement)?; + let conditions = elems[2].as_str().ok_or(AuthFailure::NonStringElement)?; + let sig = elems[3].as_str().ok_or(AuthFailure::NonStringElement)?; + if owner.len() != 64 || !owner.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AuthFailure::InvalidOwnerHex(owner.to_owned())); + } + if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(AuthFailure::InvalidSigHex); + } + if !owner.eq_ignore_ascii_case(signer_hex) { + return Err(AuthFailure::OwnerMismatch(owner.to_owned())); + } + Ok([ + label.to_owned(), + owner.to_owned(), + conditions.to_owned(), + sig.to_owned(), + ]) +} + +/// Pure sync core of auth resolution: given a fetched kind:0 profile (or +/// `None` when no event was found), either return the extracted auth tag or +/// emit one `{"warning":"..."}` JSON line to `warn_sink` and return `None`. +/// +/// Separated from [`resolve_auth`] so unit tests can call this directly with +/// a `Vec` sink and assert on exactly what hits the wire — without needing +/// a live `BuzzClient` or async runtime. +/// +/// Three warning branches, one success path: +/// 1. `profile == None` → no kind:0 found for target. +/// 2. `profile.get("tags")` absent or non-array → no tags array. +/// 3. [`classify_owner_auth_tag`] returns `Err` → typed failure reason. +/// 4. `classify_owner_auth_tag` returns `Ok` → `Some(tag)`, no warning. +fn resolve_auth_from_profile( + profile: Option<&serde_json::Value>, + target_hex: &str, + signer_hex: &str, + warn_sink: &mut dyn std::io::Write, +) -> Option<[String; 4]> { + let event = match profile { + Some(e) => e, + None => { + let msg = format!( + "no kind:0 profile found for target {target_hex}; \ + proceeding without owner attestation — this succeeds only if your key is a relay admin" + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + return None; + } + }; + let tags = match event.get("tags").and_then(|v| v.as_array()) { + Some(t) => t, + None => { + let msg = format!( + "target {target_hex} kind:0 has no tags array; \ + proceeding without owner attestation — this succeeds only if your key is a relay admin" + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + return None; + } + }; + match classify_owner_auth_tag(tags, signer_hex) { + Ok(tag) => Some(tag), + Err(failure) => { + let msg = format!( + "{}; proceeding without owner attestation — \ + this succeeds only if your key is a relay admin", + failure.message() + ); + let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg})); + None + } + } +} + /// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests. /// /// Mirrors the desktop's `maybe_owner_auth_tag`: -/// - `target == signer`: self path — no auth needed → `Ok(None)`. -/// - Otherwise: fetch target's kind:0, look for an `auth` tag whose owner -/// (index 1) matches the signer. Return it when present; `Ok(None)` when -/// absent or structurally malformed. Query/network failures surface as -/// `Err` — silent degradation to bare would make the relay reject the -/// request with a misleading error. +/// - `target == signer`: self path — no auth needed → `Ok(None)`, silent. +/// - Otherwise: fetch target's kind:0, delegate to [`resolve_auth_from_profile`] +/// which either returns the extracted tag or emits one `{"warning":"..."}` JSON +/// line to `warn_sink` and returns `None` — the bare request is still sent so +/// relay admins can succeed without owner attestation. Query/network failures +/// surface as `Err`. async fn resolve_auth( client: &BuzzClient, target_hex: &str, signer_hex: &str, + warn_sink: &mut dyn std::io::Write, ) -> Result, CliError> { if target_hex.eq_ignore_ascii_case(signer_hex) { return Ok(None); @@ -184,15 +342,13 @@ async fn resolve_auth( .map_err(|e| CliError::Other(format!("failed to fetch target kind:0: {e}")))?; let events: Vec = serde_json::from_str(&raw) .map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?; - let event = match events.into_iter().next() { - Some(e) => e, - None => return Ok(None), - }; - let tags = match event.get("tags").and_then(|v| v.as_array()) { - Some(t) => t, - None => return Ok(None), - }; - Ok(extract_owner_auth_tag(tags, signer_hex)) + let profile = events.into_iter().next(); + Ok(resolve_auth_from_profile( + profile.as_ref(), + target_hex, + signer_hex, + warn_sink, + )) } /// Pure extraction helper: require exactly one kind:0 tag whose first @@ -201,46 +357,11 @@ async fn resolve_auth( /// then structurally validate that sole tag as /// `["auth", owner, conditions, sig]` matching `signer_hex`. /// -/// Malformed tags (wrong arity, non-string elements, non-hex fields) are -/// silently skipped — the contract is "bare" (None), not error. +/// Thin wrapper around [`classify_owner_auth_tag`] that collapses the typed +/// failure reason to `None`. Malformed tags → `None`; valid tag → `Some`. +#[cfg(test)] fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Option<[String; 4]> { - let auth_tags: Vec<&serde_json::Value> = tags - .iter() - .filter(|tag| { - tag.as_array() - .and_then(|elems| elems.first()) - .and_then(|v| v.as_str()) - == Some("auth") - }) - .collect(); - if auth_tags.len() != 1 { - return None; - } - - let elems = auth_tags[0].as_array()?; - if elems.len() != 4 { - return None; - } - let label = elems[0].as_str()?; - let owner = elems[1].as_str()?; - if !owner.eq_ignore_ascii_case(signer_hex) { - return None; - } - let conditions = elems[2].as_str()?; - let sig = elems[3].as_str()?; - if owner.len() != 64 - || !owner.chars().all(|c| c.is_ascii_hexdigit()) - || sig.len() != 128 - || !sig.chars().all(|c| c.is_ascii_hexdigit()) - { - return None; - } - Some([ - label.to_owned(), - owner.to_owned(), - conditions.to_owned(), - sig.to_owned(), - ]) + classify_owner_auth_tag(tags, signer_hex).ok() } /// Validate the NIP-11 relay-info `self` field is a 64-hex pubkey and @@ -521,6 +642,242 @@ mod tests { assert!(extract_owner_auth_tag(&tags, &signer).is_none()); } + // --- (c) auth-failure classifier: classify_owner_auth_tag --- + // + // Tests the typed failure taxonomy. Each case asserts the exact + // AuthFailure variant so a wrong classification causes a compile-time or + // assertion failure — not just a message-substring miss. + + #[test] + fn classify_no_auth_tag_returns_no_auth_tag() { + // Case 3 (zero auth tags): tags array has entries but none labelled "auth". + let signer = hex64('a'); + let tags = vec![json!(["p", hex64('b')]), json!(["e", hex64('c')])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::NoAuthTag) + ); + } + + #[test] + fn classify_empty_tags_returns_no_auth_tag() { + assert_eq!( + classify_owner_auth_tag(&[], &hex64('a')), + Err(AuthFailure::NoAuthTag) + ); + } + + #[test] + fn classify_duplicate_auth_tags_returns_ambiguous() { + let signer = hex64('a'); + let sig = hex128('b'); + let tags = vec![ + json!(["auth", signer, "conditions", sig]), + json!(["auth", signer, "conditions", sig]), + ]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::AmbiguousAuthTag(2)) + ); + } + + #[test] + fn classify_wrong_arity_returns_wrong_arity() { + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, "conditions"])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::WrongArity(3)) + ); + } + + #[test] + fn classify_non_string_element_returns_non_string() { + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, 42, hex128('b')])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::NonStringElement) + ); + } + + #[test] + fn classify_invalid_owner_hex_returns_invalid_owner_hex() { + let bad_owner = "z".repeat(64); + let tags = vec![json!(["auth", bad_owner, "", hex128('a')])]; + assert_eq!( + classify_owner_auth_tag(&tags, &bad_owner), + Err(AuthFailure::InvalidOwnerHex(bad_owner)) + ); + } + + #[test] + fn classify_invalid_sig_hex_returns_invalid_sig_hex() { + let signer = hex64('a'); + let bad_sig = "z".repeat(128); + let tags = vec![json!(["auth", signer, "", bad_sig])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::InvalidSigHex) + ); + } + + #[test] + fn classify_owner_mismatch_returns_owner_mismatch_with_actual_owner() { + // Case 4: structurally valid tag but owner ≠ signer. The failure must + // carry the actual owner so resolve_auth can print it in the warning. + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let tags = vec![json!(["auth", actual_owner, "conditions", sig])]; + assert_eq!( + classify_owner_auth_tag(&tags, &signer), + Err(AuthFailure::OwnerMismatch(actual_owner.clone())) + ); + // Message must include the actual owner for actionability. + let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message(); + assert!( + msg.contains(&actual_owner), + "OwnerMismatch message must include actual owner, got: {msg}" + ); + } + + // --- (c2) resolve_auth_from_profile emission boundary --- + // + // Observable-boundary tests: each test calls the production function + // `resolve_auth_from_profile` directly with a `Vec` sink and asserts + // on exactly what the production code writes. Deleting any `writeln!` + // call in that function makes at least one of these tests fail. + // + // `resolve_auth` is async and requires a live `BuzzClient`; the sync + // decomposition lets us test the warning logic without a relay connection. + + fn assert_one_json_warning(sink: &[u8], expected_fragment: &str) { + let text = std::str::from_utf8(sink).expect("sink is valid UTF-8"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!( + lines.len(), + 1, + "expected exactly one warning line, got: {text:?}" + ); + let parsed: serde_json::Value = + serde_json::from_str(lines[0]).expect("warning line must be parseable JSON"); + let warning = parsed["warning"] + .as_str() + .expect("warning line must have a string 'warning' field"); + assert!( + warning.contains(expected_fragment), + "warning must contain {expected_fragment:?}, got: {warning}" + ); + } + + fn assert_no_warning(sink: &[u8]) { + let text = std::str::from_utf8(sink).expect("sink is valid UTF-8"); + assert!(text.is_empty(), "expected no warning output, got: {text:?}"); + } + + // Branch 1: profile == None → no kind:0 found. + #[test] + fn resolve_auth_from_profile_no_kind0_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(None, &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no kind:0 profile found"); + } + + // Branch 2: profile present but no tags array. + #[test] + fn resolve_auth_from_profile_no_tags_array_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let profile = json!({"kind": 0, "content": "{}"}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no tags array"); + } + + // Branch 3a: tags present but no auth tag (NoAuthTag). + #[test] + fn resolve_auth_from_profile_no_auth_tag_emits_json_warning() { + let target = hex64('t'); + let signer = hex64('s'); + let profile = json!({"tags": [["p", hex64('b')]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "no \"auth\" tag"); + } + + // Branch 3b: duplicate auth tags (AmbiguousAuthTag). + #[test] + fn resolve_auth_from_profile_ambiguous_auth_tag_emits_json_warning() { + let signer = hex64('s'); + let sig = hex128('b'); + let profile = json!({"tags": [ + ["auth", signer, "conditions", sig], + ["auth", signer, "conditions", sig], + ]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "ambiguous"); + } + + // Branch 3c: sole auth tag malformed (WrongArity). + #[test] + fn resolve_auth_from_profile_malformed_tag_emits_json_warning() { + let signer = hex64('s'); + // arity 3 — missing sig field + let profile = json!({"tags": [["auth", signer, "conditions"]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, "element"); + } + + // Branch 3d: owner mismatch — warning must include the actual owner pubkey. + #[test] + fn resolve_auth_from_profile_owner_mismatch_emits_json_warning_with_actual_owner() { + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_none(), "bare path: must return None"); + assert_one_json_warning(&sink, &actual_owner); + } + + // Success path: valid auth tag → Some returned, sink stays empty. + #[test] + fn resolve_auth_from_profile_valid_auth_tag_returns_some_emits_nothing() { + let signer = hex64('a'); + let sig = hex128('b'); + let profile = json!({"tags": [["auth", signer, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + assert!(result.is_some(), "must return the extracted tag"); + assert_no_warning(&sink); + } + + // Warning output must be valid JSON (serde_json serializes safely). + #[test] + fn resolve_auth_from_profile_warning_is_valid_json() { + let actual_owner = hex64('a'); + let signer = hex64('b'); + let sig = hex128('c'); + let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]}); + let mut sink: Vec = Vec::new(); + let _ = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink); + let text = std::str::from_utf8(&sink).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(text.trim()).expect("warning output must be valid JSON"); + assert!(parsed["warning"].is_string()); + } + // --- (d) NIP-11 self normalization: normalize_relay_self_hex --- #[test] diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b9..d1477f4bc6b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -105,6 +105,7 @@ export default defineConfig({ "**/inbox-edit.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", + "**/project-file-tree.spec.ts", "**/project-inbox.spec.ts", "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 322834630a3..17440a8057e 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -47,6 +47,7 @@ mod project_git; mod project_git_branches; mod project_git_diff; mod project_git_exec; +mod project_git_files; mod project_git_merge_error; mod project_git_push; mod project_git_workflow; diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index 201f3a05079..a9eccf85525 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -2,11 +2,11 @@ use super::project_git_exec::{ build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url, GitAuthConfig, }; +use super::project_git_files::{parse_ls_tree, parse_worktree_files, ParsedProjectRepoFiles}; use super::project_git_push::push_project_local_repository_blocking; use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir}; use crate::app_state::AppState; use serde::Serialize; -use std::time::UNIX_EPOCH; use tauri::State; #[derive(Clone, Serialize)] pub struct ProjectRepoCommitInfo { @@ -38,6 +38,8 @@ pub struct ProjectRepoSnapshotInfo { pub latest_commit: Option, pub commits: Vec, pub files: Vec, + /// Complete file count before the snapshot payload is capped. + pub total_file_count: usize, pub contributors: Vec, } #[derive(Serialize)] @@ -134,30 +136,6 @@ fn has_untracked_files(output: &str) -> bool { output.lines().any(|line| line.starts_with("??")) } -fn read_preview_content( - repo_dir: &std::path::Path, - path: &str, - size: Option, -) -> Option { - const MAX_PREVIEW_BYTES: u64 = 64 * 1024; - if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { - return None; - } - - let full_path = repo_dir.join(path); - let normalized = full_path.canonicalize().ok()?; - let repo_root = repo_dir.canonicalize().ok()?; - if !normalized.starts_with(repo_root) { - return None; - } - - let bytes = std::fs::read(normalized).ok()?; - if bytes.contains(&0) { - return None; - } - String::from_utf8(bytes).ok() -} - fn parse_commits(output: &str) -> Vec { output .lines() @@ -235,46 +213,6 @@ fn parse_latest_commit_by_path( result } -fn path_modified_at(path: &std::path::Path) -> Option { - let modified = std::fs::metadata(path).ok()?.modified().ok()?; - modified - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| i64::try_from(duration.as_secs()).ok()) -} - -fn parse_worktree_files( - repo_dir: &std::path::Path, - output: &str, - latest_commit_by_path: &std::collections::HashMap, -) -> Vec { - output - .split('\0') - .filter(|path| !path.trim().is_empty()) - .filter_map(|path| { - let full_path = repo_dir.join(path); - let metadata = std::fs::metadata(&full_path).ok()?; - if !metadata.is_file() { - return None; - } - let size = Some(metadata.len()); - let latest_commit = latest_commit_by_path.get(path).cloned(); - Some(ProjectRepoFileInfo { - path: path.to_string(), - kind: "blob".to_string(), - size, - preview_content: read_preview_content(repo_dir, path, size), - last_changed_at: latest_commit - .as_ref() - .map(|commit| commit.timestamp) - .or_else(|| path_modified_at(&full_path)), - latest_commit, - }) - }) - .take(250) - .collect() -} - fn normalize_branch_name(branch: &str) -> &str { branch .trim() @@ -309,40 +247,6 @@ fn branch_activity_range( Some(format!("origin/{base_branch}..HEAD")) } -fn parse_ls_tree( - repo_dir: &std::path::Path, - output: &str, - latest_commit_by_path: &std::collections::HashMap, -) -> Vec { - output - .lines() - .filter_map(|line| { - let (meta, path) = line.split_once('\t')?; - let mut parts = meta.split_whitespace(); - let _mode = parts.next()?; - let kind = parts.next()?.to_string(); - let _object = parts.next()?; - let size = parts.next().and_then(|value| value.parse::().ok()); - let preview_content = if kind == "blob" { - read_preview_content(repo_dir, path, size) - } else { - None - }; - Some(ProjectRepoFileInfo { - path: path.to_string(), - kind, - size, - preview_content, - last_changed_at: latest_commit_by_path - .get(path) - .map(|commit| commit.timestamp), - latest_commit: latest_commit_by_path.get(path).cloned(), - }) - }) - .take(250) - .collect() -} - fn snapshot_from_repo( repo_dir: &std::path::Path, auth: &GitAuthConfig, @@ -383,7 +287,7 @@ fn snapshot_from_repo( (Vec::new(), Vec::new()) }; - let files = if latest_commit.is_some() { + let parsed_files = if latest_commit.is_some() { let latest_commit_by_path = run_git( &[ "log", @@ -402,13 +306,14 @@ fn snapshot_from_repo( .map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path)) .unwrap_or_default() } else { - Vec::new() + ParsedProjectRepoFiles::default() }; ProjectRepoSnapshotInfo { latest_commit, commits, - files, + files: parsed_files.files, + total_file_count: parsed_files.total_file_count, contributors, } } @@ -466,7 +371,7 @@ fn snapshot_from_worktree( (Vec::new(), Vec::new(), std::collections::HashMap::new()) }; - let files = run_git( + let parsed_files = run_git( &[ "ls-files", "--cached", @@ -483,7 +388,8 @@ fn snapshot_from_worktree( ProjectRepoSnapshotInfo { latest_commit, commits, - files, + files: parsed_files.files, + total_file_count: parsed_files.total_file_count, contributors, } } diff --git a/desktop/src-tauri/src/commands/project_git_files.rs b/desktop/src-tauri/src/commands/project_git_files.rs new file mode 100644 index 00000000000..a1bc47a0ea2 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_files.rs @@ -0,0 +1,152 @@ +use super::project_git::{ProjectRepoCommitInfo, ProjectRepoFileInfo}; +use std::{collections::HashMap, path::Path, time::UNIX_EPOCH}; + +const MAX_REPOSITORY_FILE_PAYLOAD: usize = 250; + +#[derive(Default)] +pub(super) struct ParsedProjectRepoFiles { + pub(super) files: Vec, + pub(super) total_file_count: usize, +} + +fn read_preview_content(repo_dir: &Path, path: &str, size: Option) -> Option { + const MAX_PREVIEW_BYTES: u64 = 64 * 1024; + if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) { + return None; + } + + let normalized = repo_dir.join(path).canonicalize().ok()?; + let repo_root = repo_dir.canonicalize().ok()?; + if !normalized.starts_with(repo_root) { + return None; + } + + let bytes = std::fs::read(normalized).ok()?; + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok() +} + +fn path_modified_at(path: &Path) -> Option { + let modified = std::fs::metadata(path).ok()?.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) +} + +pub(super) fn parse_worktree_files( + repo_dir: &Path, + output: &str, + latest_commit_by_path: &HashMap, +) -> ParsedProjectRepoFiles { + let mut parsed = ParsedProjectRepoFiles::default(); + + for path in output.split('\0').filter(|path| !path.trim().is_empty()) { + let full_path = repo_dir.join(path); + let Ok(metadata) = std::fs::metadata(&full_path) else { + continue; + }; + if !metadata.is_file() { + continue; + } + + parsed.total_file_count += 1; + if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD { + continue; + } + + let size = Some(metadata.len()); + let latest_commit = latest_commit_by_path.get(path).cloned(); + parsed.files.push(ProjectRepoFileInfo { + path: path.to_string(), + kind: "blob".to_string(), + size, + preview_content: read_preview_content(repo_dir, path, size), + last_changed_at: latest_commit + .as_ref() + .map(|commit| commit.timestamp) + .or_else(|| path_modified_at(&full_path)), + latest_commit, + }); + } + + parsed +} + +pub(super) fn parse_ls_tree( + repo_dir: &Path, + output: &str, + latest_commit_by_path: &HashMap, +) -> ParsedProjectRepoFiles { + let mut parsed = ParsedProjectRepoFiles::default(); + + for line in output.lines() { + let Some((meta, path)) = line.split_once('\t') else { + continue; + }; + let mut parts = meta.split_whitespace(); + let (Some(_mode), Some(kind), Some(_object)) = (parts.next(), parts.next(), parts.next()) + else { + continue; + }; + let size = parts.next().and_then(|value| value.parse::().ok()); + + parsed.total_file_count += 1; + if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD { + continue; + } + + let preview_content = (kind == "blob") + .then(|| read_preview_content(repo_dir, path, size)) + .flatten(); + parsed.files.push(ProjectRepoFileInfo { + path: path.to_string(), + kind: kind.to_string(), + size, + preview_content, + last_changed_at: latest_commit_by_path + .get(path) + .map(|commit| commit.timestamp), + latest_commit: latest_commit_by_path.get(path).cloned(), + }); + } + + parsed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ls_tree_reports_the_full_count_when_the_payload_is_capped() { + let output = (0..251) + .map(|index| format!("100644 blob {} 1\tfile-{index:03}.txt", "0".repeat(40))) + .collect::>() + .join("\n"); + + let parsed = parse_ls_tree(Path::new("/missing-repository"), &output, &HashMap::new()); + + assert_eq!(parsed.files.len(), 250); + assert_eq!(parsed.total_file_count, 251); + } + + #[test] + fn worktree_reports_the_full_count_when_the_payload_is_capped() { + let repo = tempfile::tempdir().expect("create temporary repository"); + let mut paths = Vec::new(); + for index in 0..251 { + let path = format!("file-{index:03}.txt"); + std::fs::write(repo.path().join(&path), "x").expect("write fixture file"); + paths.push(path); + } + let output = format!("{}\0", paths.join("\0")); + + let parsed = parse_worktree_files(repo.path(), &output, &HashMap::new()); + + assert_eq!(parsed.files.len(), 250); + assert_eq!(parsed.total_file_count, 251); + } +} diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index b929dbb6131..b578326eba3 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -113,13 +113,16 @@ with a TypeScript lookup table or an id comparison in a component. Once the Advanced toggle is visible, its expanded state is exclusively user-controlled: provider, harness, and required-env changes must never open it automatically in defaults, create, or edit flows. In Create mode, - the defaults summary follows preferred-harness changes saved while the - dialog is open, and its configured state includes required credentials as - well as provider/model values. If no available harness can resolve, Create - starts in Customize and lets unavailable catalog entries be selected only - to expose their setup guidance; submission remains blocked. - Advanced-only required credentials mark the collapsed Advanced toggle - without opening it in Global Defaults and Edit, and block incomplete saves. + `Run on` belongs in Advanced directly after **Who can send instructions**; + keep it out of the basic create fields. The defaults summary follows + preferred-harness changes saved while the dialog is open, and its configured + state includes required credentials as well as provider/model values. If no + available harness can resolve, Create starts in Customize and lets unavailable + catalog entries be selected only to expose their setup guidance; submission + remains blocked. + Advanced-only required credentials and incomplete remote **Run on** setup + mark the collapsed Advanced toggle without opening it, and block incomplete + saves. Runtime-file credentials satisfy Global Defaults just as they do Create and Edit. In Edit, selecting Custom command keeps its required command field beside the harness diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 12702f45ac4..409ae6a8214 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -109,7 +109,6 @@ type AgentDefinitionDialogProps = { ) => Promise; /** Publishes saved changes when the edited agent is shared in the catalog. */ publishCatalogUpdatesOnSave?: boolean; - /** Rendered below the form fields in create mode only ("Where to run"). */ createRunSection?: React.ReactNode; /** Extra create-mode submit gate (e.g. incomplete provider config). */ createSubmitBlocked?: boolean; @@ -962,9 +961,6 @@ export function AgentDefinitionDialog({ onSaved={selectSavedHarness} open={isAddHarnessOpen} /> - - {isCreateMode ? createRunSection : null} -
) : null} @@ -1109,33 +1111,22 @@ export function VideoPlayer({ ) : null} - {/* Slide (not fade) the pill out: animating opacity on an ancestor - of a backdrop-filter flattens the glass into a plain fill - mid-transition, which reads as a flicker. The video container's - overflow-hidden clips the slid-out pill. */} - {showControls ? ( + {!hasError ? (
-
- - + +
(null); + const [hasVisibleFrame, setHasVisibleFrame] = React.useState(false); const [videoAreaSize, setVideoAreaSize] = React.useState<{ height: number; width: number; @@ -1364,6 +1356,7 @@ function VideoReviewDialog({ React.useEffect(() => { if (!open) { setIsComposerMounted(false); + setHasVisibleFrame(false); return; } // Two frames: one for the dialog to paint, one for the browser to @@ -1715,7 +1708,7 @@ function VideoReviewDialog({ className="h-full w-full min-h-0 object-contain" playsInline poster={poster} - preload="metadata" + preload="auto" src={src} onClick={togglePlay} onDurationChange={(event) => @@ -1740,6 +1733,7 @@ function VideoReviewDialog({ syncCurrentTime(pendingSeekSeconds); } }} + onLoadedData={() => setHasVisibleFrame(true)} onPause={(event) => { syncCurrentTime(event.currentTarget.currentTime); setIsPlaying(false); @@ -1748,7 +1742,15 @@ function VideoReviewDialog({ syncCurrentTime(event.currentTarget.currentTime); setIsPlaying(true); }} - onSeeked={reviewSeek.handleSeeked} + onSeeked={(event) => { + reviewSeek.handleSeeked(); + if ( + event.currentTarget.readyState >= + HTMLMediaElement.HAVE_CURRENT_DATA + ) { + setHasVisibleFrame(true); + } + }} onTimeUpdate={(event) => { syncCurrentTime(event.currentTarget.currentTime); }} @@ -1757,6 +1759,10 @@ function VideoReviewDialog({ setMuted(event.currentTarget.muted); }} /> +
@@ -1903,12 +1909,12 @@ function VideoReviewDialog({ {showCommentsPanel ? (