Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/apps/desktop/src/api/snapshot_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ pub struct GetOperationDiffRequest {
pub workspace_path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetSessionFileDiffStatsRequest {
pub sessionId: String,
pub filePath: String,
#[serde(alias = "workspacePath")]
pub workspace_path: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetOperationSummaryRequest {
pub sessionId: String,
Expand Down Expand Up @@ -668,6 +676,20 @@ pub async fn get_operation_diff(
}))
}

#[tauri::command]
pub async fn get_session_file_diff_stats(
request: GetSessionFileDiffStatsRequest,
) -> Result<serde_json::Value, String> {
let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?;

let stats = manager
.get_session_file_diff_stats(&request.sessionId, &request.filePath)
.await
.map_err(|e| format!("Failed to get session file diff stats: {}", e))?;

serde_json::to_value(&stats).map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn get_operation_summary(
request: GetOperationSummaryRequest,
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ pub async fn run() {
get_turn_files,
get_file_diff,
get_operation_diff,
get_session_file_diff_stats,
get_operation_summary,
get_session_operations,
accept_operation,
Expand Down
54 changes: 31 additions & 23 deletions src/apps/desktop/src/theme.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Theme System

use std::sync::OnceLock;

use bitfun_core::infrastructure::try_get_path_manager_arc;
use bitfun_core::service::config::types::GlobalConfig;
use dark_light::Mode;
Expand All @@ -12,6 +14,12 @@ const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 360.0;
const AGENT_COMPANION_WINDOW_MAX_HEIGHT: f64 = 240.0;
const AGENT_COMPANION_WINDOW_MARGIN: i32 = 64;

static AGENT_COMPANION_WINDOW_OPS: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();

fn agent_companion_window_ops() -> &'static tokio::sync::Mutex<()> {
AGENT_COMPANION_WINDOW_OPS.get_or_init(|| tokio::sync::Mutex::new(()))
}

#[derive(Debug, Clone)]
pub struct ThemeConfig {
pub id: String,
Expand Down Expand Up @@ -403,32 +411,30 @@ fn resize_agent_companion_window(
return;
}

let next_position = old_position
.zip(old_size)
.map(|(position, size)| {
tauri::LogicalPosition::new(
position.x + size.width - width,
position.y + size.height - height,
)
})
.or_else(|| agent_companion_default_position(app, window));

let Some(position) = next_position else {
return;
};

if let Err(e) = window.set_position(tauri::LogicalPosition::new(
position.x,
position.y,
)) {
warn!("Failed to position Agent companion window: {}", e);
// Keep the bottom-right corner fixed when bubbles change height. If we cannot
// read the previous geometry (e.g. transient platform errors), avoid snapping
// back to the default corner — that would feel like the pet "jumped".
if let Some((position, size)) = old_position.zip(old_size) {
let next_position = tauri::LogicalPosition::new(
position.x + size.width - width,
position.y + size.height - height,
);
if let Err(e) = window.set_position(next_position) {
warn!("Failed to position Agent companion window: {}", e);
}
}
}

#[tauri::command]
pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> {
let _guard = agent_companion_window_ops().lock().await;

// Reuse any existing window: never destroy here. A previous implementation destroyed
// whenever `is_visible` was false, which raced with another `show` that had built the
// window but not called `show()` yet (or with `hide`), producing duplicate pets or
// stuck windows.
if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) {
position_agent_companion_window(&app, &window);
let _ = window.unminimize();
window.show().map_err(|e| {
error!("Failed to show Agent companion window: {}", e);
format!("Failed to show Agent companion window: {}", e)
Expand Down Expand Up @@ -481,6 +487,7 @@ pub async fn resize_agent_companion_desktop_pet(
width: f64,
height: f64,
) -> Result<(), String> {
let _guard = agent_companion_window_ops().lock().await;
if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) {
resize_agent_companion_window(&app, &window, width, height);
}
Expand All @@ -489,10 +496,11 @@ pub async fn resize_agent_companion_desktop_pet(

#[tauri::command]
pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> {
let _guard = agent_companion_window_ops().lock().await;
if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) {
window.close().map_err(|e| {
error!("Failed to close Agent companion window: {}", e);
format!("Failed to close Agent companion window: {}", e)
window.destroy().map_err(|e| {
error!("Failed to destroy Agent companion window: {}", e);
format!("Failed to destroy Agent companion window: {}", e)
})?;
}
Ok(())
Expand Down
20 changes: 10 additions & 10 deletions src/crates/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1713,12 +1713,12 @@ mod tests {
"enable_agent_companion": true,
"agent_companion_display_mode": "desktop",
"agent_companion_pet": {
"id": "pixel-panda",
"displayName": "Pixel Panda",
"description": "A gentle panda holding a tiny pixel brush.",
"id": "boxcat",
"displayName": "Boxcat",
"description": "A tiny cat tucked inside a cardboard box for cozy coding sessions.",
"source": "preset",
"packagePath": "/agent-companion-pets/pixel-panda",
"spritesheetPath": "/agent-companion-pets/pixel-panda/spritesheet.webp",
"packagePath": "/agent-companion-pets/boxcat",
"spritesheetPath": "/agent-companion-pets/boxcat/spritesheet.webp",
"spritesheetMimeType": "image/webp"
}
}))
Expand All @@ -1728,19 +1728,19 @@ mod tests {
.agent_companion_pet
.as_ref()
.expect("selected companion pet should be retained");
assert_eq!(pet.id, "pixel-panda");
assert_eq!(pet.display_name, "Pixel Panda");
assert_eq!(pet.package_path, "/agent-companion-pets/pixel-panda");
assert_eq!(pet.id, "boxcat");
assert_eq!(pet.display_name, "Boxcat");
assert_eq!(pet.package_path, "/agent-companion-pets/boxcat");
assert_eq!(config.agent_companion_display_mode, "desktop");

let serialized = serde_json::to_value(&config).expect("config should serialize");
assert_eq!(
serialized["agent_companion_pet"]["displayName"],
"Pixel Panda"
"Boxcat"
);
assert_eq!(
serialized["agent_companion_pet"]["spritesheetPath"],
"/agent-companion-pets/pixel-panda/spritesheet.webp"
"/agent-companion-pets/boxcat/spritesheet.webp"
);
}

Expand Down
12 changes: 12 additions & 0 deletions src/crates/core/src/service/snapshot/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,18 @@ impl SnapshotManager {
}))
}

pub async fn get_session_file_diff_stats(
&self,
session_id: &str,
file_path: &str,
) -> SnapshotResult<crate::service::snapshot::types::SessionFileDiffStats> {
let snapshot_service = self.snapshot_service.read().await;
let file_path = std::path::Path::new(file_path);
snapshot_service
.get_session_file_diff_stats(session_id, file_path)
.await
}

pub async fn get_operation_summary(
&self,
session_id: &str,
Expand Down
12 changes: 12 additions & 0 deletions src/crates/core/src/service/snapshot/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ impl SnapshotService {
.await
}

pub async fn get_session_file_diff_stats(
&self,
session_id: &str,
file_path: &Path,
) -> SnapshotResult<crate::service::snapshot::types::SessionFileDiffStats> {
self.ensure_initialized().await?;
let snapshot_core = self.snapshot_core.read().await;
snapshot_core
.get_session_file_diff_stats(session_id, file_path)
.await
}

pub async fn get_operation_summary(
&self,
session_id: &str,
Expand Down
146 changes: 145 additions & 1 deletion src/crates/core/src/service/snapshot/snapshot_core.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::service::snapshot::snapshot_system::FileSnapshotSystem;
use crate::service::snapshot::types::{
DiffSummary, FileOperation, OperationType, SnapshotError, SnapshotResult, ToolContext,
DiffSummary, FileOperation, OperationType, SessionFileDiffStats, SnapshotError, SnapshotResult,
ToolContext,
};
use crate::service::workspace_runtime::WorkspaceRuntimeContext;
use log::{debug, info, warn};
Expand Down Expand Up @@ -48,6 +49,9 @@ struct SessionHistory {
last_updated: SystemTime,
}

/// Per-side size budget: above this we avoid loading baseline/disk texts for UI badge stats.
const SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES: u64 = 512 * 1024;

impl SessionHistory {
fn new(session_id: String) -> Self {
let now = SystemTime::now();
Expand Down Expand Up @@ -570,6 +574,99 @@ impl SnapshotCore {
Ok((before, after, mapped_anchor))
}

/// Line insert/delete counts versus session baseline vs workspace, without returning file bodies.
/// Large files skip full reads and aggregate per-operation diff summaries (`approximate: true`).
pub async fn get_session_file_diff_stats(
&self,
session_id: &str,
file_path: &Path,
) -> SnapshotResult<SessionFileDiffStats> {
let Some(session) = self.sessions.get(session_id) else {
return Err(SnapshotError::SessionNotFound(session_id.to_string()));
};

let file_created = session_file_created_in_session(session, file_path);

let workspace_bytes = if file_path.exists() {
tokio::fs::metadata(file_path)
.await
.map(|m| m.len())
.unwrap_or(SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES.saturating_add(1))
} else {
0
};

let before_bytes = if file_created {
0
} else {
let before_snapshot_id = if let Some(baseline_id) = self
.snapshot_system
.get_baseline_snapshot_id(file_path)
.await
{
Some(baseline_id)
} else {
session
.all_operations_iter()
.find(|op| op.file_path == file_path)
.and_then(|op| op.before_snapshot_id.clone())
};

match before_snapshot_id {
None => 0,
Some(id) => self
.snapshot_system
.get_snapshot_recorded_size_bytes(&id)
.await
.unwrap_or(SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES.saturating_add(1)),
}
};

let too_large = workspace_bytes > SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES
|| before_bytes > SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES;

let path_exists = file_path.exists();

if too_large {
let agg = aggregate_operations_diff_summary_for_file(session, file_path);
let change_kind = change_kind_for_aggregate_path(file_created, path_exists);
debug!(
"get_session_file_diff_stats: approximate session_id={} file_path={:?} workspace_bytes={} before_bytes={} lines_added={} lines_removed={}",
session_id,
file_path,
workspace_bytes,
before_bytes,
agg.lines_added,
agg.lines_removed
);
return Ok(SessionFileDiffStats {
file_path: file_path.to_string_lossy().to_string(),
lines_added: agg.lines_added,
lines_removed: agg.lines_removed,
approximate: true,
change_kind: change_kind.to_string(),
});
}

let (before, after) = self.get_file_diff(file_path, session_id).await?;
let summary = compute_diff_summary(&before, &after);
let change_kind = change_kind_from_diff_content(file_created, &before, &after);
debug!(
"get_session_file_diff_stats: exact session_id={} file_path={:?} lines_added={} lines_removed={}",
session_id,
file_path,
summary.lines_added,
summary.lines_removed
);
Ok(SessionFileDiffStats {
file_path: file_path.to_string_lossy().to_string(),
lines_added: summary.lines_added,
lines_removed: summary.lines_removed,
approximate: false,
change_kind: change_kind.to_string(),
})
}

pub fn get_file_change_history(&self, file_path: &Path) -> Vec<FileChangeEntry> {
let mut entries = Vec::new();
for session in self.sessions.values() {
Expand Down Expand Up @@ -903,6 +1000,53 @@ impl SnapshotCore {
}
}

fn session_file_created_in_session(session: &SessionHistory, file_path: &Path) -> bool {
session
.all_operations_iter()
.find(|op| op.file_path == file_path)
.map(|op| op.before_snapshot_id.is_none())
.unwrap_or(false)
}

fn aggregate_operations_diff_summary_for_file(
session: &SessionHistory,
file_path: &Path,
) -> DiffSummary {
let mut out = DiffSummary::default();
for op in session.all_operations_iter() {
if op.file_path.as_path() == file_path {
out.lines_added += op.diff_summary.lines_added;
out.lines_removed += op.diff_summary.lines_removed;
out.lines_modified += op.diff_summary.lines_modified;
}
}
out
}

fn change_kind_for_aggregate_path(file_created_in_session: bool, path_exists: bool) -> &'static str {
if file_created_in_session {
"create"
} else if !path_exists {
"delete"
} else {
"modify"
}
}

fn change_kind_from_diff_content(
file_created_in_session: bool,
before: &str,
after: &str,
) -> &'static str {
if file_created_in_session {
return "create";
}
if !before.is_empty() && after.is_empty() {
return "delete";
}
"modify"
}

fn sanitize_id(id: &str) -> String {
id.chars()
.map(|c| {
Expand Down
Loading
Loading