From 9529a2bf95a212b45f00beebb0c28ef2a729263e Mon Sep 17 00:00:00 2001 From: bowen628 Date: Tue, 24 Mar 2026 15:50:42 +0800 Subject: [PATCH] feat(desktop,web-ui): editor sync hash and SSH private key picker - Add get_file_editor_sync_hash Tauri command and core filesystem helpers for normalized SHA-256 over local and remote file bytes. - Wire CodeEditor/MarkdownEditor disk version utilities for external sync checks. - SSH connection/auth dialogs: native key file picker with default ~/.ssh. - Modal tweaks for SSH flows; en-US/zh-CN strings; diskFileVersion unit tests. --- src/apps/desktop/src/api/commands.rs | 38 +++++++ src/apps/desktop/src/lib.rs | 1 + .../filesystem/file_operations.rs | 84 ++++++++++++++++ .../core/src/infrastructure/filesystem/mod.rs | 3 +- .../core/src/service/filesystem/service.rs | 12 +++ .../components/Modal/Modal.scss | 8 ++ .../components/Modal/Modal.tsx | 12 ++- .../ssh-remote/SSHAuthPromptDialog.scss | 7 +- .../ssh-remote/SSHAuthPromptDialog.tsx | 29 +++++- .../ssh-remote/SSHConnectionDialog.scss | 5 + .../ssh-remote/SSHConnectionDialog.tsx | 28 +++++- .../ssh-remote/pickSshPrivateKeyPath.ts | 26 +++++ src/web-ui/src/locales/en-US/common.json | 2 + src/web-ui/src/locales/zh-CN/common.json | 2 + .../tools/editor/components/CodeEditor.tsx | 99 ++++++++++++++++++- .../editor/components/MarkdownEditor.tsx | 4 + .../editor/utils/diskFileVersion.test.ts | 56 +++++++++++ .../src/tools/editor/utils/diskFileVersion.ts | 40 ++++++++ 18 files changed, 445 insertions(+), 11 deletions(-) create mode 100644 src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts create mode 100644 src/web-ui/src/tools/editor/utils/diskFileVersion.test.ts diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 1140b4a7..bc242a71 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -1627,6 +1627,44 @@ pub async fn get_file_metadata( } } +/// Returns SHA-256 hex (lowercase) of file bytes after the same normalization as the web editor +/// external-sync check, so the UI can compare with a local hash without transferring file contents. +#[tauri::command] +pub async fn get_file_editor_sync_hash( + state: State<'_, AppState>, + request: GetFileMetadataRequest, +) -> Result { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { + let remote_fs = state + .get_remote_file_service_async() + .await + .map_err(|e| format!("Remote file service not available: {}", e))?; + let bytes = remote_fs + .read_file(&entry.connection_id, &request.path) + .await + .map_err(|e| format!("Failed to read remote file: {}", e))?; + let hash = state + .filesystem_service + .editor_sync_sha256_hex_from_raw_bytes(&bytes); + return Ok(serde_json::json!({ + "path": request.path, + "hash": hash, + "is_remote": true + })); + } + + let hash = state + .filesystem_service + .editor_sync_content_sha256_hex(&request.path) + .await + .map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "path": request.path, + "hash": hash + })) +} + #[tauri::command] pub async fn rename_file( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 954efe84..a4c64562 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -339,6 +339,7 @@ pub async fn run() { reset_workspace_persona_files, check_path_exists, get_file_metadata, + get_file_editor_sync_hash, rename_file, export_local_file_to_path, reveal_in_explorer, diff --git a/src/crates/core/src/infrastructure/filesystem/file_operations.rs b/src/crates/core/src/infrastructure/filesystem/file_operations.rs index 514e4c48..906d8688 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_operations.rs +++ b/src/crates/core/src/infrastructure/filesystem/file_operations.rs @@ -4,9 +4,20 @@ use crate::util::errors::*; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use tokio::fs; +/// Same rules as web `normalizeTextForDiskSyncComparison` (BOM strip, CRLF/CR → LF). +pub fn normalize_text_for_editor_disk_sync(text: &str) -> String { + let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); + text.replace("\r\n", "\n").replace('\r', "\n") +} + +fn sha256_hex(data: &[u8]) -> String { + hex::encode(Sha256::digest(data)) +} + pub struct FileOperationService { max_file_size_mb: u64, allowed_extensions: Option>, @@ -163,6 +174,58 @@ impl FileOperationService { } } + /// SHA-256 (hex, lowercase) of `bytes` using the same normalization as the web editor sync check, + /// or raw-byte hash when content is treated as binary (matches `read_file` heuristics). + pub fn editor_sync_sha256_hex_from_raw_bytes(&self, bytes: &[u8]) -> String { + if self.is_binary_content(bytes) { + sha256_hex(bytes) + } else { + let content = String::from_utf8_lossy(bytes); + let normalized = normalize_text_for_editor_disk_sync(content.as_ref()); + sha256_hex(normalized.as_bytes()) + } + } + + /// Reads the file from disk and returns the editor-sync hash (see `editor_sync_sha256_hex_from_raw_bytes`). + pub async fn editor_sync_content_sha256_hex(&self, file_path: &str) -> BitFunResult { + let path = Path::new(file_path); + + self.validate_file_access(path, false).await?; + + if !path.exists() { + return Err(BitFunError::service(format!( + "File does not exist: {}", + file_path + ))); + } + + if path.is_dir() { + return Err(BitFunError::service(format!( + "Path is a directory: {}", + file_path + ))); + } + + let metadata = fs::metadata(path) + .await + .map_err(|e| BitFunError::service(format!("Failed to read file metadata: {}", e)))?; + + let file_size = metadata.len(); + if file_size > self.max_file_size_mb * 1024 * 1024 { + return Err(BitFunError::service(format!( + "File too large: {}MB (max: {}MB)", + file_size / (1024 * 1024), + self.max_file_size_mb + ))); + } + + let bytes = fs::read(path) + .await + .map_err(|e| BitFunError::service(format!("Failed to read file: {}", e)))?; + + Ok(self.editor_sync_sha256_hex_from_raw_bytes(&bytes)) + } + pub async fn write_file( &self, file_path: &str, @@ -586,3 +649,24 @@ impl FileOperationService { } } } + +#[cfg(test)] +mod editor_sync_hash_tests { + use super::*; + + #[test] + fn normalize_matches_web_contract() { + assert_eq!(normalize_text_for_editor_disk_sync("\u{FEFF}a\r\nb"), "a\nb"); + assert_eq!(normalize_text_for_editor_disk_sync("x\ry"), "x\ny"); + } + + #[test] + fn hello_utf8_hash_matches_known_sha256() { + let svc = FileOperationService::default(); + let h = svc.editor_sync_sha256_hex_from_raw_bytes(b"hello"); + assert_eq!( + h, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } +} diff --git a/src/crates/core/src/infrastructure/filesystem/mod.rs b/src/crates/core/src/infrastructure/filesystem/mod.rs index 96b03549..3f5f85db 100644 --- a/src/crates/core/src/infrastructure/filesystem/mod.rs +++ b/src/crates/core/src/infrastructure/filesystem/mod.rs @@ -8,7 +8,8 @@ pub mod file_watcher; pub mod path_manager; pub use file_operations::{ - FileInfo, FileOperationOptions, FileOperationService, FileReadResult, FileWriteResult, + normalize_text_for_editor_disk_sync, FileInfo, FileOperationOptions, FileOperationService, + FileReadResult, FileWriteResult, }; pub use file_tree::{ FileSearchResult, FileTreeNode, FileTreeOptions, FileTreeService, FileTreeStatistics, diff --git a/src/crates/core/src/service/filesystem/service.rs b/src/crates/core/src/service/filesystem/service.rs index cc5540c0..4783ee17 100644 --- a/src/crates/core/src/service/filesystem/service.rs +++ b/src/crates/core/src/service/filesystem/service.rs @@ -271,4 +271,16 @@ impl FileSystemService { symlinks_count: stats.symlinks_count, }) } + + /// SHA-256 hex of on-disk content after editor-sync normalization (see `FileOperationService`). + pub async fn editor_sync_content_sha256_hex(&self, file_path: &str) -> BitFunResult { + self.file_operation_service + .editor_sync_content_sha256_hex(file_path) + .await + } + + pub fn editor_sync_sha256_hex_from_raw_bytes(&self, bytes: &[u8]) -> String { + self.file_operation_service + .editor_sync_sha256_hex_from_raw_bytes(bytes) + } } diff --git a/src/web-ui/src/component-library/components/Modal/Modal.scss b/src/web-ui/src/component-library/components/Modal/Modal.scss index 87553801..e5475360 100644 --- a/src/web-ui/src/component-library/components/Modal/Modal.scss +++ b/src/web-ui/src/component-library/components/Modal/Modal.scss @@ -108,6 +108,10 @@ border-bottom: 1px solid var(--border-subtle); background: transparent; + .modal--content-inset & { + padding-inline: 28px; + } + &--draggable { cursor: move; user-select: none; @@ -303,6 +307,10 @@ padding: 12px 14px; } + &.modal--content-inset .modal__header { + padding-inline: 14px; + } + &__title { font-size: 14px; } diff --git a/src/web-ui/src/component-library/components/Modal/Modal.tsx b/src/web-ui/src/component-library/components/Modal/Modal.tsx index 9b7d0f8a..19731000 100644 --- a/src/web-ui/src/component-library/components/Modal/Modal.tsx +++ b/src/web-ui/src/component-library/components/Modal/Modal.tsx @@ -249,7 +249,17 @@ export const Modal: React.FC = ({
e.stopPropagation()} onMouseDown={handleMouseDown} style={appliedStyle} diff --git a/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss index 67c7ffad..e3f4dbe1 100644 --- a/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss +++ b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss @@ -3,7 +3,7 @@ */ .ssh-auth-prompt-dialog { - padding: 8px 0; + padding: 8px 0 16px; &__description { display: flex; @@ -47,6 +47,11 @@ margin-bottom: 14px; } + &__browse-key { + flex-shrink: 0; + margin-right: -6px; + } + &__hint { font-size: 13px; color: var(--color-text-secondary); diff --git a/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx index e6880210..d4051411 100644 --- a/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx @@ -2,14 +2,16 @@ * Unified SSH authentication prompt: password, private key, or SSH agent. */ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { Modal } from '@/component-library'; import { Button } from '@/component-library'; import { Input } from '@/component-library'; import { Select } from '@/component-library'; -import { Key, Loader2, Lock, Server, Terminal, User } from 'lucide-react'; +import { IconButton } from '@/component-library'; +import { FolderOpen, Key, Loader2, Lock, Server, Terminal, User } from 'lucide-react'; import type { SSHAuthMethod } from './types'; +import { pickSshPrivateKeyPath } from './pickSshPrivateKeyPath'; import './SSHAuthPromptDialog.scss'; export interface SSHAuthPromptSubmitPayload { @@ -108,6 +110,14 @@ export const SSHAuthPromptDialog: React.FC = ({ } }; + const handleBrowsePrivateKey = useCallback(async () => { + if (isConnecting) return; + const path = await pickSshPrivateKeyPath({ + title: t('ssh.remote.pickPrivateKeyDialogTitle'), + }); + if (path) setKeyPath(path); + }, [isConnecting, t]); + return ( = ({ title={t('ssh.remote.authPromptTitle') || 'SSH authentication'} size="small" showCloseButton + contentInset >
@@ -173,6 +184,20 @@ export const SSHAuthPromptDialog: React.FC = ({ onChange={(e) => setKeyPath(e.target.value)} placeholder="~/.ssh/id_rsa" prefix={} + suffix={ + void handleBrowsePrivateKey()} + > + + + } size="medium" disabled={isConnecting} /> diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss index 3a2311c1..c0967614 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss @@ -212,6 +212,11 @@ } } + &__browse-key { + flex-shrink: 0; + margin-right: -6px; + } + &__label { display: block; font-size: 12px; diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 16070d18..7c632a5d 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -3,7 +3,7 @@ * Professional SSH connection dialog following BitFun design patterns */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { useSSHRemoteContext } from './SSHRemoteProvider'; import { SSHAuthPromptDialog, type SSHAuthPromptSubmitPayload } from './SSHAuthPromptDialog'; @@ -12,7 +12,8 @@ import { Button } from '@/component-library'; import { Input } from '@/component-library'; import { Select } from '@/component-library'; import { Alert } from '@/component-library'; -import { Loader2, Server, User, Key, Lock, Terminal, Trash2, Plus, Pencil, Play } from 'lucide-react'; +import { IconButton } from '@/component-library'; +import { FolderOpen, Loader2, Server, User, Key, Lock, Terminal, Trash2, Plus, Pencil, Play } from 'lucide-react'; import type { SSHConnectionConfig, SSHAuthMethod, @@ -20,6 +21,7 @@ import type { SSHConfigEntry, } from './types'; import { sshApi } from './sshApi'; +import { pickSshPrivateKeyPath } from './pickSshPrivateKeyPath'; import './SSHConnectionDialog.scss'; type CredentialsPromptState = @@ -125,6 +127,14 @@ export const SSHConnectionDialog: React.FC = ({ setFormData((prev) => ({ ...prev, [field]: value })); }; + const handleBrowsePrivateKey = useCallback(async () => { + if (isConnecting || status === 'connecting') return; + const path = await pickSshPrivateKeyPath({ + title: t('ssh.remote.pickPrivateKeyDialogTitle'), + }); + if (path) setFormData((prev) => ({ ...prev, keyPath: path })); + }, [isConnecting, status, t]); + const generateConnectionId = (host: string, port: number, username: string) => { return `ssh-${username}@${host}:${port}`; }; @@ -610,6 +620,20 @@ export const SSHConnectionDialog: React.FC = ({ onChange={(e) => handleInputChange('keyPath', e.target.value)} placeholder="~/.ssh/id_rsa" prefix={} + suffix={ + void handleBrowsePrivateKey()} + > + + + } size="medium" />
diff --git a/src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts b/src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts new file mode 100644 index 00000000..242004bb --- /dev/null +++ b/src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts @@ -0,0 +1,26 @@ +/** + * Native file picker for SSH private keys; default folder is ~/.ssh (via Tauri homeDir + join). + */ + +import { open } from '@tauri-apps/plugin-dialog'; +import { homeDir, join } from '@tauri-apps/api/path'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('pickSshPrivateKeyPath'); + +export async function pickSshPrivateKeyPath(options: { title?: string } = {}): Promise { + try { + const home = await homeDir(); + const defaultPath = await join(home, '.ssh'); + const selected = await open({ + multiple: false, + directory: false, + defaultPath, + title: options.title, + }); + return selected ?? null; + } catch (e) { + log.error('SSH private key file picker failed', e); + return null; + } +} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index a63fbf29..8ed8a24f 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -915,6 +915,8 @@ "privateKey": "Private Key", "sshAgent": "SSH Agent", "privateKeyPath": "Private Key Path", + "browsePrivateKey": "Choose private key file", + "pickPrivateKeyDialogTitle": "Select SSH private key", "passphrase": "Passphrase", "passphraseOptional": "Leave empty if none", "selectWorkspace": "Select Workspace Directory", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index dd2e7a90..6b60e5ca 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -915,6 +915,8 @@ "privateKey": "私钥", "sshAgent": "SSH Agent", "privateKeyPath": "私钥路径", + "browsePrivateKey": "选择私钥文件", + "pickPrivateKeyDialogTitle": "选择 SSH 私钥", "passphrase": "密码短语", "passphraseOptional": "留空表示无密码短语", "selectWorkspace": "选择工作区目录", diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 733d38e3..730b9334 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -26,8 +26,10 @@ import { getMonacoLanguage } from '@/infrastructure/language-detection'; import { createLogger } from '@/shared/utils/logger'; import { isSamePath } from '@/shared/utils/pathUtils'; import { + diskContentMatchesEditorForExternalSync, diskVersionFromMetadata, diskVersionsDiffer, + editorSyncContentSha256Hex, type DiskFileVersion, } from '../utils/diskFileVersion'; import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService'; @@ -1514,10 +1516,50 @@ const CodeEditor: React.FC = ({ return; } + const bufferBeforeRead = modelRef.current?.getValue(); + try { + const hashRes: any = await invoke('get_file_editor_sync_hash', { + request: { path: filePath }, + }); + const diskHash = + typeof hashRes?.hash === 'string' ? hashRes.hash.toLowerCase() : ''; + const editorMid = modelRef.current?.getValue(); + if ( + bufferBeforeRead !== undefined && + editorMid !== undefined && + bufferBeforeRead !== editorMid + ) { + return; + } + if (diskHash && editorMid !== undefined) { + const editorHash = await editorSyncContentSha256Hex(editorMid); + if (editorHash === diskHash) { + diskVersionRef.current = currentVersion; + return; + } + } + } catch (hashErr) { + log.warn('get_file_editor_sync_hash failed, falling back to full read', { + filePath, + error: hashErr, + }); + } + const { workspaceAPI } = await import('@/infrastructure/api'); - const fileContent = await workspaceAPI.readFileContent(filePath); const editorBuffer = modelRef.current?.getValue(); - if (editorBuffer !== undefined && fileContent === editorBuffer) { + if ( + bufferBeforeRead !== undefined && + editorBuffer !== undefined && + bufferBeforeRead !== editorBuffer + ) { + return; + } + if (editorBuffer === undefined) { + return; + } + + const fileContent = await workspaceAPI.readFileContent(filePath); + if (diskContentMatchesEditorForExternalSync(fileContent, editorBuffer)) { diskVersionRef.current = currentVersion; return; } @@ -1758,12 +1800,61 @@ const CodeEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); + const bufferBeforeRead = modelRef.current?.getValue(); + try { + const hashRes: any = await invoke('get_file_editor_sync_hash', { + request: { path: filePath }, + }); + const diskHash = + typeof hashRes?.hash === 'string' ? hashRes.hash.toLowerCase() : ''; + const editorMid = modelRef.current?.getValue(); + if ( + bufferBeforeRead !== undefined && + editorMid !== undefined && + bufferBeforeRead !== editorMid + ) { + return; + } + if (diskHash && editorMid !== undefined) { + const editorHash = await editorSyncContentSha256Hex(editorMid); + if (editorHash === diskHash) { + try { + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } catch (err) { + log.warn('Failed to sync disk version after noop file-changed', err); + } + return; + } + } + } catch (hashErr) { + log.warn('get_file_editor_sync_hash failed in file-changed handler', { + filePath, + error: hashErr, + }); + } + const diskContent = await workspaceAPI.readFileContent(filePath); const editorBuffer = modelRef.current?.getValue(); - if (editorBuffer !== undefined && diskContent === editorBuffer) { + if ( + bufferBeforeRead !== undefined && + editorBuffer !== undefined && + bufferBeforeRead !== editorBuffer + ) { + return; + } + if ( + editorBuffer !== undefined && + diskContentMatchesEditorForExternalSync(diskContent, editorBuffer) + ) { try { const fileInfo: any = await invoke('get_file_metadata', { - request: { path: filePath } + request: { path: filePath }, }); const v = diskVersionFromMetadata(fileInfo); if (v) { diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index aae16037..c40ce227 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -290,7 +290,11 @@ const MarkdownEditor: React.FC = ({ return; } + const localBefore = contentRef.current; const raw = await workspaceAPI.readFileContent(filePath); + if (localBefore !== contentRef.current) { + return; + } const { nextEditability, nextContent } = toNormalizedMarkdown(raw); if (nextContent === contentRef.current) { diskVersionRef.current = currentVersion; diff --git a/src/web-ui/src/tools/editor/utils/diskFileVersion.test.ts b/src/web-ui/src/tools/editor/utils/diskFileVersion.test.ts new file mode 100644 index 00000000..582caeed --- /dev/null +++ b/src/web-ui/src/tools/editor/utils/diskFileVersion.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + diskContentMatchesEditorForExternalSync, + diskVersionsDiffer, + diskVersionFromMetadata, + editorSyncContentSha256Hex, + normalizeTextForDiskSyncComparison, +} from './diskFileVersion'; + +describe('diskVersionFromMetadata', () => { + it('parses modified and size', () => { + expect(diskVersionFromMetadata({ modified: 1, size: 2 })).toEqual({ modified: 1, size: 2 }); + }); + + it('defaults missing size to 0', () => { + expect(diskVersionFromMetadata({ modified: 1 })).toEqual({ modified: 1, size: 0 }); + }); +}); + +describe('diskVersionsDiffer', () => { + it('detects mtime or size change', () => { + expect(diskVersionsDiffer({ modified: 1, size: 1 }, { modified: 2, size: 1 })).toBe(true); + expect(diskVersionsDiffer({ modified: 1, size: 1 }, { modified: 1, size: 2 })).toBe(true); + expect(diskVersionsDiffer({ modified: 1, size: 1 }, { modified: 1, size: 1 })).toBe(false); + }); +}); + +describe('normalizeTextForDiskSyncComparison', () => { + it('strips BOM and normalizes newlines', () => { + expect(normalizeTextForDiskSyncComparison('\uFEFFa\r\nb')).toBe('a\nb'); + expect(normalizeTextForDiskSyncComparison('x\ry')).toBe('x\ny'); + }); +}); + +describe('diskContentMatchesEditorForExternalSync', () => { + it('treats CRLF disk and LF editor as equal', () => { + expect(diskContentMatchesEditorForExternalSync('a\r\nb', 'a\nb')).toBe(true); + }); + + it('treats BOM on disk as ignorable when editor has none', () => { + expect(diskContentMatchesEditorForExternalSync('\uFEFFhello', 'hello')).toBe(true); + }); + + it('still distinguishes real content changes', () => { + expect(diskContentMatchesEditorForExternalSync('a', 'b')).toBe(false); + }); +}); + +describe('editorSyncContentSha256Hex', () => { + it('matches known UTF-8 digest for hello', async () => { + const h = await editorSyncContentSha256Hex('hello'); + expect(h).toBe( + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + ); + }); +}); diff --git a/src/web-ui/src/tools/editor/utils/diskFileVersion.ts b/src/web-ui/src/tools/editor/utils/diskFileVersion.ts index c21ebb32..8c8a231a 100644 --- a/src/web-ui/src/tools/editor/utils/diskFileVersion.ts +++ b/src/web-ui/src/tools/editor/utils/diskFileVersion.ts @@ -21,3 +21,43 @@ export function diskVersionFromMetadata(fileInfo: unknown): DiskFileVersion | nu export function diskVersionsDiffer(a: DiskFileVersion, b: DiskFileVersion): boolean { return a.modified !== b.modified || a.size !== b.size; } + +/** + * Normalize text so BOM / newline style differences do not false-trigger + * "file changed on disk" flows (metadata can change from saves or tooling while + * logical content matches the editor buffer). + */ +export function normalizeTextForDiskSyncComparison(text: string): string { + let s = text; + if (s.length > 0 && s.charCodeAt(0) === 0xfeff) { + s = s.slice(1); + } + return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +/** True if disk-read text and editor buffer are the same for external-sync purposes. */ +export function diskContentMatchesEditorForExternalSync( + diskText: string, + editorText: string +): boolean { + return ( + normalizeTextForDiskSyncComparison(diskText) === + normalizeTextForDiskSyncComparison(editorText) + ); +} + +/** + * SHA-256 hex (lowercase) of UTF-8 bytes of normalized text. + * Must match desktop `get_file_editor_sync_hash` for non-binary files. + */ +export async function editorSyncContentSha256Hex(text: string): Promise { + const normalized = normalizeTextForDiskSyncComparison(text); + const data = new TextEncoder().encode(normalized); + if (typeof crypto === 'undefined' || !crypto.subtle?.digest) { + throw new Error('crypto.subtle.digest is not available'); + } + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +}