diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index 53566c75..a0c6c153 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -509,6 +509,8 @@ impl WrappedTool { debug!("Creating new file: file_path={}", file_path.display()); } + let file_existed_before = file_path.exists(); + let operation_type = self.get_operation_type_internal(file_existed_before); let turn_index = self.extract_turn_index(context); let snapshot_service = snapshot_manager.get_snapshot_service(); @@ -520,7 +522,7 @@ impl WrappedTool { self.name(), input.clone(), &file_path, - self.get_operation_type_internal(), + operation_type, context.tool_call_id.clone(), ) .await @@ -577,8 +579,15 @@ impl WrappedTool { } /// Returns the operation type. - fn get_operation_type_internal(&self) -> OperationType { + fn get_operation_type_internal(&self, file_existed_before: bool) -> OperationType { match self.name() { + "Write" | "write_file" => { + if file_existed_before { + OperationType::Modify + } else { + OperationType::Create + } + } "create_file" => OperationType::Create, "delete_file" | "Delete" => OperationType::Delete, "rename_file" | "move_file" => OperationType::Rename, diff --git a/src/crates/core/src/service/snapshot/snapshot_core.rs b/src/crates/core/src/service/snapshot/snapshot_core.rs index 734bef1e..c5bcbce8 100644 --- a/src/crates/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/core/src/service/snapshot/snapshot_core.rs @@ -130,9 +130,9 @@ impl SnapshotCore { None }; - if let Some(before_id) = &before_snapshot_id { - if !self.snapshot_system.has_baseline(&file_path).await { - match self + if !self.snapshot_system.has_baseline(&file_path).await { + match &before_snapshot_id { + Some(before_id) => match self .snapshot_system .create_baseline_from_snapshot(&file_path, before_id) .await @@ -149,13 +149,30 @@ impl SnapshotCore { file_path, e ); } + }, + None if operation_type == OperationType::Create => { + match self.snapshot_system.create_empty_baseline(&file_path).await { + Ok(baseline_id) => { + debug!( + "Created empty baseline snapshot for new file: file_path={:?} baseline_id={}", + file_path, baseline_id + ); + } + Err(e) => { + warn!( + "Failed to create empty baseline snapshot: file_path={:?} error={}", + file_path, e + ); + } + } } - } else { - debug!( - "Baseline snapshot already exists: file_path={:?}", - file_path - ); + None => {} } + } else { + debug!( + "Baseline snapshot already exists: file_path={:?}", + file_path + ); } let session = self @@ -431,6 +448,11 @@ impl SnapshotCore { return Err(SnapshotError::SessionNotFound(session_id.to_string())); }; + let first_op_for_file = session + .all_operations_iter() + .find(|op| op.file_path == file_path); + let file_created_in_session = matches!(first_op_for_file, Some(op) if op.before_snapshot_id.is_none()); + let load_first_before = || async { let first_before = session .all_operations_iter() @@ -447,31 +469,39 @@ impl SnapshotCore { .unwrap_or_default() }; - let before = if let Some(baseline_id) = self - .snapshot_system - .get_baseline_snapshot_id(file_path) - .await - { + let before = if file_created_in_session { debug!( - "Using baseline snapshot for diff: file_path={:?} baseline_id={}", - file_path, baseline_id + "Using empty baseline for session-created file diff: file_path={:?} session_id={}", + file_path, session_id ); - match self + String::new() + } else { + if let Some(baseline_id) = self .snapshot_system - .get_snapshot_content(&baseline_id) + .get_baseline_snapshot_id(file_path) .await { - Ok(content) => content, - Err(e) => { - warn!( - "Failed to read baseline snapshot, falling back to first before snapshot: baseline_id={} error={}", - baseline_id, e - ); - load_first_before().await + debug!( + "Using baseline snapshot for diff: file_path={:?} baseline_id={}", + file_path, baseline_id + ); + match self + .snapshot_system + .get_snapshot_content(&baseline_id) + .await + { + Ok(content) => content, + Err(e) => { + warn!( + "Failed to read baseline snapshot, falling back to first before snapshot: baseline_id={} error={}", + baseline_id, e + ); + load_first_before().await + } } + } else { + load_first_before().await } - } else { - load_first_before().await }; let after = if file_path.exists() { diff --git a/src/crates/core/src/service/snapshot/snapshot_system.rs b/src/crates/core/src/service/snapshot/snapshot_system.rs index f6e4efda..f1d7a22c 100644 --- a/src/crates/core/src/service/snapshot/snapshot_system.rs +++ b/src/crates/core/src/service/snapshot/snapshot_system.rs @@ -164,6 +164,46 @@ impl BaselineCache { Ok(baseline_id) } + + /// Creates an empty baseline for files that are first introduced during the session. + pub async fn create_empty( + &self, + file_path: &Path, + empty_content_hash: &str, + content_path: &Path, + ) -> SnapshotResult { + let baseline_id = format!("baseline_empty_{}", Uuid::new_v4()); + + if !content_path.exists() { + fs::write(content_path, [])?; + } + + let baseline_metadata = FileSnapshot { + snapshot_id: baseline_id.clone(), + file_path: file_path.to_path_buf(), + content_hash: empty_content_hash.to_string(), + snapshot_type: SnapshotType::Baseline, + compressed_content: Vec::new(), + timestamp: SystemTime::now(), + metadata: FileMetadata { + size: 0, + permissions: None, + last_modified: SystemTime::now(), + encoding: "utf-8".to_string(), + }, + }; + + let baseline_meta_path = self.baseline_dir.join(format!("{}.json", baseline_id)); + let metadata_json = serde_json::to_string_pretty(&baseline_metadata)?; + fs::write(&baseline_meta_path, metadata_json)?; + + { + let mut cache = self.cache.write().await; + cache.insert(file_path.to_path_buf(), Some(baseline_id.clone())); + } + + Ok(baseline_id) + } } /// Simplified file snapshot system @@ -302,11 +342,18 @@ impl FileSnapshotSystem { let content_hash = self.calculate_content_hash(&content); if self.dedup_enabled && self.hash_to_path.contains_key(&content_hash) { + if let Some(snapshot_id) = self.find_snapshot_by_hash(&content_hash) { + debug!( + "Found duplicate content, reusing existing snapshot: content_hash={}", + content_hash + ); + return Ok(snapshot_id); + } + debug!( - "Found duplicate content, reusing existing snapshot: content_hash={}", + "Found reusable content without active snapshot metadata, creating new snapshot metadata: content_hash={}", content_hash ); - return Ok(self.find_snapshot_by_hash(&content_hash)?); } let optimized_content = self.optimize_content(&content); @@ -473,17 +520,13 @@ impl FileSnapshotSystem { } /// Finds a snapshot ID by hash. - fn find_snapshot_by_hash(&self, content_hash: &str) -> SnapshotResult { + fn find_snapshot_by_hash(&self, content_hash: &str) -> Option { for (snapshot_id, snapshot) in &self.active_snapshots { if snapshot.content_hash == content_hash { - return Ok(snapshot_id.clone()); + return Some(snapshot_id.clone()); } } - - Err(SnapshotError::SnapshotNotFound(format!( - "hash: {}", - content_hash - ))) + None } /// Loads snapshot metadata from disk (without using in-memory cache). @@ -778,6 +821,21 @@ impl FileSnapshotSystem { .await } + /// Creates an empty baseline for files that did not exist before the session. + pub async fn create_empty_baseline(&mut self, file_path: &Path) -> SnapshotResult { + let empty_content_hash = self.calculate_content_hash(&[]); + let content_path = self.get_content_path(&empty_content_hash); + + if !self.hash_to_path.contains_key(&empty_content_hash) { + self.hash_to_path + .insert(empty_content_hash.clone(), content_path.clone()); + } + + self.baseline_cache + .create_empty(file_path, &empty_content_hash, &content_path) + .await + } + /// Checks whether the file has a baseline. pub async fn has_baseline(&self, file_path: &Path) -> bool { self.get_baseline_snapshot_id(file_path).await.is_some() diff --git a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx index f68b3803..0fd730cc 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionFileModificationsBar.tsx @@ -239,6 +239,10 @@ export const SessionFileModificationsBar: React.FC = ({ try { const diffData = await snapshotAPI.getOperationDiff(sessionId, filePath); + if ((diffData.originalContent || '') === (diffData.modifiedContent || '')) { + log.debug('Skipping empty session diff', { filePath, sessionId }); + setIsExpanded(false); + return; + } const fileName = filePath.split(/[/\\]/).pop() || filePath; // Expand the right panel. @@ -252,7 +257,13 @@ export const SessionFilesBadge: React.FC = ({ diffData.modifiedContent || '', false, 'agent', - currentWorkspace?.rootPath + currentWorkspace?.rootPath, + undefined, + false, + { + titleKind: 'diff', + duplicateKeyPrefix: 'diff' + } ); }, 250); diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index 2475d3b6..89af680a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -322,38 +322,56 @@ export const FileOperationToolCard: React.FC = ({ }, [currentFilePath, onOpenInEditor, isFailed, sessionId, status, handleOpenInCodeEditor, toolItem.toolName]); const handleOpenBaselineDiff = useCallback(async () => { - if (!currentFile || !currentWorkspace) { - log.warn('Cannot open Baseline Diff: missing required info', { hasFile: !!currentFile, hasWorkspace: !!currentWorkspace }); + if (!currentFilePath || !currentWorkspace || !sessionId) { + log.warn('Cannot open diff: missing required info', { + hasFilePath: !!currentFilePath, + hasWorkspace: !!currentWorkspace, + hasSessionId: !!sessionId + }); return; } - const fileName = currentFile.filePath.split(/[/\\]/).pop() || currentFile.filePath; + const diffFilePath = currentFile?.filePath || currentFilePath; + const fileName = diffFilePath.split(/[/\\]/).pop() || diffFilePath; try { const { snapshotAPI } = await import('../../infrastructure/api'); - const diffData = await snapshotAPI.getBaselineSnapshotDiff( - currentFile.filePath, + const diffData = await snapshotAPI.getOperationDiff( + sessionId, + diffFilePath, + toolCall?.id, currentWorkspace.rootPath ); + if ((diffData.originalContent || '') === (diffData.modifiedContent || '')) { + log.debug('Skipping empty baseline diff', { filePath: diffFilePath }); + return; + } + window.dispatchEvent(new CustomEvent('expand-right-panel')); setTimeout(() => { createDiffEditorTab( - currentFile.filePath, + diffFilePath, fileName, diffData.originalContent || '', diffData.modifiedContent || '', false, 'agent', - currentWorkspace.rootPath + currentWorkspace.rootPath, + undefined, + false, + { + titleKind: 'diff', + duplicateKeyPrefix: 'diff' + } ); }, 250); } catch (error) { - log.error('Failed to open Baseline Diff', { filePath: currentFile?.filePath, error }); + log.error('Failed to open Baseline Diff', { filePath: currentFilePath, error }); } - }, [currentFile, currentWorkspace]); + }, [currentFile, currentFilePath, currentWorkspace, sessionId, toolCall?.id]); const getToolIconInfo = () => { const iconMap: Record = { @@ -426,7 +444,7 @@ export const FileOperationToolCard: React.FC = ({ )} - {!isDeleteTool && !isFailed && !isLoading && status === 'completed' && ( + {!isDeleteTool && !isFailed && !isLoading && status === 'completed' && currentFilePath && (
e.stopPropagation()}> diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 15bf3978..4cc9a2db 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -741,6 +741,7 @@ }, "tabs": { "configCenter": "Config Center", + "diff": "Diff", "fixPreview": "Fix Preview", "gitDiff": "Git Diff", "gitSettings": "Git Settings", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 6d11380b..bcc6105d 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -741,6 +741,7 @@ }, "tabs": { "configCenter": "配置中心", + "diff": "Diff", "fixPreview": "修复预览", "gitDiff": "Git Diff", "gitSettings": "Git 设置", diff --git a/src/web-ui/src/shared/utils/tabUtils.ts b/src/web-ui/src/shared/utils/tabUtils.ts index 0d32062e..953e6086 100644 --- a/src/web-ui/src/shared/utils/tabUtils.ts +++ b/src/web-ui/src/shared/utils/tabUtils.ts @@ -130,15 +130,27 @@ export function createDiffEditorTab( mode: TabTargetMode = 'agent', repositoryPath?: string, revealLine?: number, - replaceExisting?: boolean + replaceExisting?: boolean, + options?: { + titleKind?: 'git-diff' | 'diff' | 'fix-preview'; + duplicateKeyPrefix?: 'git-diff' | 'diff' | 'fix-diff'; + } ): void { + const titleKind = options?.titleKind ?? (repositoryPath ? 'git-diff' : 'fix-preview'); + const duplicateKeyPrefix = options?.duplicateKeyPrefix ?? (repositoryPath ? 'git-diff' : 'fix-diff'); const duplicateKey = repositoryPath - ? `git-diff:${repositoryPath}:${filePath}` - : `fix-diff:${filePath}`; + ? `${duplicateKeyPrefix}:${repositoryPath}:${filePath}` + : `${duplicateKeyPrefix}:${filePath}`; + const titleSuffix = + titleKind === 'git-diff' + ? i18nService.getT()('common:tabs.gitDiff') + : titleKind === 'diff' + ? i18nService.getT()('common:tabs.diff') + : i18nService.getT()('common:tabs.fixPreview'); createTab({ type: 'diff-code-editor', - title: `${fileName} - ${repositoryPath ? i18nService.getT()('common:tabs.gitDiff') : i18nService.getT()('common:tabs.fixPreview')}`, + title: `${fileName} - ${titleSuffix}`, data: { fileName, filePath,