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
38 changes: 38 additions & 0 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value, String> {
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>,
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 @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions src/crates/core/src/infrastructure/filesystem/file_operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
Expand Down Expand Up @@ -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<String> {
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,
Expand Down Expand Up @@ -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"
);
}
}
3 changes: 2 additions & 1 deletion src/crates/core/src/infrastructure/filesystem/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/crates/core/src/service/filesystem/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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)
}
}
8 changes: 8 additions & 0 deletions src/web-ui/src/component-library/components/Modal/Modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -303,6 +307,10 @@
padding: 12px 14px;
}

&.modal--content-inset .modal__header {
padding-inline: 14px;
}

&__title {
font-size: 14px;
}
Expand Down
12 changes: 11 additions & 1 deletion src/web-ui/src/component-library/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,17 @@ export const Modal: React.FC<ModalProps> = ({
<div className={`modal-overlay ${placement !== 'center' ? `modal-overlay--${placement}` : ''}`} onClick={onClose}>
<div
ref={modalRef}
className={`modal modal--${size} ${draggable ? 'modal--draggable' : ''} ${isDragging ? 'modal--dragging' : ''} ${resizable ? 'modal--resizable' : ''} ${isResizing ? 'modal--resizing' : ''}`}
className={[
'modal',
`modal--${size}`,
draggable ? 'modal--draggable' : '',
isDragging ? 'modal--dragging' : '',
resizable ? 'modal--resizable' : '',
isResizing ? 'modal--resizing' : '',
contentInset ? 'modal--content-inset' : '',
]
.filter(Boolean)
.join(' ')}
onClick={(e) => e.stopPropagation()}
onMouseDown={handleMouseDown}
style={appliedStyle}
Expand Down
7 changes: 6 additions & 1 deletion src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

.ssh-auth-prompt-dialog {
padding: 8px 0;
padding: 8px 0 16px;

&__description {
display: flex;
Expand Down Expand Up @@ -47,6 +47,11 @@
margin-bottom: 14px;
}

&__browse-key {
flex-shrink: 0;
margin-right: -6px;
}

&__hint {
font-size: 13px;
color: var(--color-text-secondary);
Expand Down
29 changes: 27 additions & 2 deletions src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -108,13 +110,22 @@ export const SSHAuthPromptDialog: React.FC<SSHAuthPromptDialogProps> = ({
}
};

const handleBrowsePrivateKey = useCallback(async () => {
if (isConnecting) return;
const path = await pickSshPrivateKeyPath({
title: t('ssh.remote.pickPrivateKeyDialogTitle'),
});
if (path) setKeyPath(path);
}, [isConnecting, t]);

return (
<Modal
isOpen={open}
onClose={onCancel}
title={t('ssh.remote.authPromptTitle') || 'SSH authentication'}
size="small"
showCloseButton
contentInset
>
<div className="ssh-auth-prompt-dialog" onKeyDown={handleKeyDown}>
<div className="ssh-auth-prompt-dialog__description">
Expand Down Expand Up @@ -173,6 +184,20 @@ export const SSHAuthPromptDialog: React.FC<SSHAuthPromptDialogProps> = ({
onChange={(e) => setKeyPath(e.target.value)}
placeholder="~/.ssh/id_rsa"
prefix={<Key size={16} />}
suffix={
<IconButton
type="button"
variant="ghost"
size="small"
className="ssh-auth-prompt-dialog__browse-key"
tooltip={t('ssh.remote.browsePrivateKey')}
aria-label={t('ssh.remote.browsePrivateKey')}
disabled={isConnecting}
onClick={() => void handleBrowsePrivateKey()}
>
<FolderOpen size={16} />
</IconButton>
}
size="medium"
disabled={isConnecting}
/>
Expand Down
5 changes: 5 additions & 0 deletions src/web-ui/src/features/ssh-remote/SSHConnectionDialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,11 @@
}
}

&__browse-key {
flex-shrink: 0;
margin-right: -6px;
}

&__label {
display: block;
font-size: 12px;
Expand Down
28 changes: 26 additions & 2 deletions src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,14 +12,16 @@ 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,
SavedConnection,
SSHConfigEntry,
} from './types';
import { sshApi } from './sshApi';
import { pickSshPrivateKeyPath } from './pickSshPrivateKeyPath';
import './SSHConnectionDialog.scss';

type CredentialsPromptState =
Expand Down Expand Up @@ -125,6 +127,14 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
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}`;
};
Expand Down Expand Up @@ -610,6 +620,20 @@ export const SSHConnectionDialog: React.FC<SSHConnectionDialogProps> = ({
onChange={(e) => handleInputChange('keyPath', e.target.value)}
placeholder="~/.ssh/id_rsa"
prefix={<Key size={16} />}
suffix={
<IconButton
type="button"
variant="ghost"
size="small"
className="ssh-connection-dialog__browse-key"
tooltip={t('ssh.remote.browsePrivateKey')}
aria-label={t('ssh.remote.browsePrivateKey')}
disabled={isConnecting || status === 'connecting'}
onClick={() => void handleBrowsePrivateKey()}
>
<FolderOpen size={16} />
</IconButton>
}
size="medium"
/>
</div>
Expand Down
Loading
Loading