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
34 changes: 24 additions & 10 deletions src/apps/desktop/src/api/clipboard_file_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardFilesResponse {
pub files: Vec<String>,
pub is_cut: bool,
Expand Down Expand Up @@ -155,25 +156,26 @@ mod windows_clipboard {
return Ok(Vec::new());
}

let file_count = DragQueryFileW(hdrop, 0xFFFFFFFF, std::ptr::null_mut(), 0);
let file_count = DragQueryFileW(hdrop, u32::MAX, std::ptr::null_mut(), 0);
if file_count == 0 {
return Ok(Vec::new());
}

let mut files = Vec::with_capacity(file_count as usize);

for i in 0..file_count {
let len = DragQueryFileW(hdrop, i, std::ptr::null_mut(), 0);
for index in 0..file_count {
let len = DragQueryFileW(hdrop, index, std::ptr::null_mut(), 0);
if len == 0 {
continue;
}

let mut buffer: Vec<u16> = vec![0; (len + 1) as usize];
let actual_len = DragQueryFileW(hdrop, i, buffer.as_mut_ptr(), len + 1);

let mut buffer = vec![0_u16; len as usize + 1];
let actual_len = DragQueryFileW(hdrop, index, buffer.as_mut_ptr(), len + 1);
if actual_len > 0 {
let path = OsString::from_wide(&buffer[..actual_len as usize]);
files.push(path.to_string_lossy().into_owned());
files.push(
OsString::from_wide(&buffer[..actual_len as usize])
.to_string_lossy()
.into_owned(),
);
}
}

Expand Down Expand Up @@ -474,10 +476,22 @@ pub(crate) fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(
mod tests {
use super::{
copy_directory_recursive, decode_file_uri, generate_unique_path,
parse_clipboard_path_segments, parse_uri_list,
parse_clipboard_path_segments, parse_uri_list, ClipboardFilesResponse,
};
use std::path::Path;

#[test]
fn clipboard_files_response_uses_camel_case() {
let value = serde_json::to_value(ClipboardFilesResponse {
files: vec!["C:/example.txt".to_string()],
is_cut: true,
})
.expect("serialize clipboard response");

assert_eq!(value["isCut"], true);
assert!(value.get("is_cut").is_none());
}

#[test]
fn decode_unix_file_uri() {
assert_eq!(
Expand Down
36 changes: 29 additions & 7 deletions src/apps/desktop/src/api/path_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,18 +281,27 @@ pub async fn read_text_file(
resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await?;
match &target {
DesktopPathTarget::Local { resolved_path, .. } => {
if encoding.is_some_and(|value| {
value.eq_ignore_ascii_case("base64") || value.eq_ignore_ascii_case("text-preview")
}) {
let bytes = app_state
.filesystem_service
.read_file_bytes(&resolved_path.to_string_lossy())
.await
.map_err(|e| format!("Failed to read file content: {}", e))?;

if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64")) {
return Ok(BASE64.encode(bytes));
}
return Ok(String::from_utf8_lossy(&bytes).into_owned());
}

let result = app_state
.filesystem_service
.read_file(&resolved_path.to_string_lossy())
.await
.map_err(|e| format!("Failed to read file content: {}", e))?;
if encoding.is_some_and(|value| value.eq_ignore_ascii_case("base64"))
&& !result.encoding.eq_ignore_ascii_case("base64")
{
Ok(BASE64.encode(result.content.as_bytes()))
} else {
Ok(result.content)
}
Ok(result.content)
}
DesktopPathTarget::Remote {
requested_path,
Expand All @@ -316,6 +325,10 @@ fn encode_remote_file_bytes(bytes: Vec<u8>, encoding: Option<&str>) -> Result<St
return Ok(BASE64.encode(bytes));
}

if encoding.is_some_and(|value| value.eq_ignore_ascii_case("text-preview")) {
return Ok(String::from_utf8_lossy(&bytes).into_owned());
}

String::from_utf8(bytes).map_err(|e| format!("File is not valid UTF-8: {}", e))
}

Expand All @@ -332,6 +345,15 @@ mod tests {
);
}

#[test]
fn remote_file_bytes_support_lossy_text_preview() {
assert_eq!(
encode_remote_file_bytes(vec![b'M', b'Z', 0xff], Some("text-preview"))
.expect("text preview should decode lossily"),
"MZ�"
);
}

#[test]
fn remote_file_bytes_preserve_text_default() {
assert_eq!(
Expand Down
45 changes: 28 additions & 17 deletions src/apps/ohos/entry/src/main/ets/utils/CommonUtils.ets
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,12 @@ export class CommonUtils {
/// writes the selected file URIs to the system pasteboard. This reads them
/// back so the project's paste flow can reach them.
///
/// Iterates every record and collects whatever looks like a file URI — the
/// `uri` field when the record's mime hints at a file/URI, and any `plainText`
/// value that parses as a `file://` / `docs://` URI (some sources drop a bare
/// URI list into plain text). Each candidate is run through the same
/// normalization as the picker. Returns the same envelope as
/// `open_file_dialog`: `{ paths: string[] }` / `{ paths: [] }` (no files) /
/// `{ error: string }`.
/// Iterates every record and collects file URIs from both the legacy `uri` /
/// `plainText` fields and API 14+ URI/plain-text MIME entries. Some system
/// file managers store file lists only in those additional entries. Each
/// candidate is run through the same normalization as the picker. Returns the
/// same envelope as `open_file_dialog`: `{ paths: string[] }` /
/// `{ paths: [] }` (no files) / `{ error: string }`.
static async read_clipboard_files(): Promise<string> {
try {
const systemPasteboard = pasteboard.getSystemPasteboard();
Expand All @@ -123,21 +122,33 @@ export class CommonUtils {
for (let index = 0; index < recordCount; index++) {
const record = data.getRecord(index);
const candidates: string[] = [];

const uri = (record.uri || '').trim();
if (uri.length > 0) {
candidates.push(uri);
}

const plainText = (record.plainText || '').trim();
if (plainText.length > 0) {
// A bare URI list may come through as plain text (one per line).
for (const line of plainText.split(/\r?\n/)) {
const addStringCandidates = (value: string): void => {
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length > 0) {
candidates.push(trimmed);
}
}
};

addStringCandidates(record.uri || '');
addStringCandidates(record.plainText || '');

// File Manager can place URI and plain-text values in additional MIME
// entries. Those values are only available through getData().
const supportedTypes = record.getValidTypes([
pasteboard.MIMETYPE_TEXT_URI,
pasteboard.MIMETYPE_TEXT_PLAIN
]);
for (const type of supportedTypes) {
try {
const value = await record.getData(type);
if (typeof value === 'string') {
addStringCandidates(value);
}
} catch (e) {
hilog.warn(0x0000, 'vnext', 'read_clipboard_files: failed to read mime entry ' + type + ': ' + e);
}
}

for (const candidate of candidates) {
Expand Down
8 changes: 8 additions & 0 deletions src/crates/assembly/core/src/service/filesystem/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,14 @@ impl FileSystemService {
.map_err(map_filesystem_error)
}

/// Reads raw file bytes using the shared filesystem access and size checks.
pub async fn read_file_bytes(&self, file_path: &str) -> BitFunResult<Vec<u8>> {
self.inner
.read_file_bytes(file_path)
.await
.map_err(map_filesystem_error)
}

/// Reads a file.
pub async fn read_file(&self, file_path: &str) -> BitFunResult<FileReadResult> {
self.inner
Expand Down
68 changes: 42 additions & 26 deletions src/crates/services/services-core/src/filesystem/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,37 +103,20 @@ impl FileOperationService {
}
}

pub async fn read_file(&self, file_path: &str) -> FileSystemResult<FileReadResult> {
pub async fn read_file_bytes(&self, file_path: &str) -> FileSystemResult<Vec<u8>> {
let path = Path::new(file_path);

self.validate_file_access(path, false).await?;

if !path.exists() {
return Err(FileSystemError::service(format!(
"File does not exist: {}",
file_path
)));
}
self.validate_readable_file(path, file_path).await?;

if path.is_dir() {
return Err(FileSystemError::service(format!(
"Path is a directory: {}",
file_path
)));
}
fs::read(path)
.await
.map_err(|e| FileSystemError::service(format!("Failed to read file: {}", e)))
}

let metadata = fs::metadata(path).await.map_err(|e| {
FileSystemError::service(format!("Failed to read file metadata: {}", e))
})?;
pub async fn read_file(&self, file_path: &str) -> FileSystemResult<FileReadResult> {
let path = Path::new(file_path);

let file_size = metadata.len();
if file_size > self.max_file_size_mb * 1024 * 1024 {
return Err(FileSystemError::service(format!(
"File too large: {}MB (max: {}MB)",
file_size / (1024 * 1024),
self.max_file_size_mb
)));
}
let file_size = self.validate_readable_file(path, file_path).await?;

match fs::read_to_string(path).await {
Ok(content) => {
Expand Down Expand Up @@ -520,6 +503,39 @@ impl FileOperationService {
Path::new(path).is_file()
}

async fn validate_readable_file(&self, path: &Path, file_path: &str) -> FileSystemResult<u64> {
self.validate_file_access(path, false).await?;

if !path.exists() {
return Err(FileSystemError::service(format!(
"File does not exist: {}",
file_path
)));
}

if path.is_dir() {
return Err(FileSystemError::service(format!(
"Path is a directory: {}",
file_path
)));
}

let metadata = fs::metadata(path).await.map_err(|e| {
FileSystemError::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(FileSystemError::service(format!(
"File too large: {}MB (max: {}MB)",
file_size / (1024 * 1024),
self.max_file_size_mb
)));
}

Ok(file_size)
}

async fn validate_file_access(&self, path: &Path, is_write: bool) -> FileSystemResult<()> {
for restricted in &self.restricted_paths {
if path.starts_with(restricted) {
Expand Down
5 changes: 5 additions & 0 deletions src/crates/services/services-core/src/filesystem/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ impl FileSystemService {
Ok(outcome)
}

/// Reads raw file bytes with the same access and size checks as [`Self::read_file`].
pub async fn read_file_bytes(&self, file_path: &str) -> FileSystemResult<Vec<u8>> {
self.file_operation_service.read_file_bytes(file_path).await
}

/// Reads a file.
pub async fn read_file(&self, file_path: &str) -> FileSystemResult<FileReadResult> {
self.file_operation_service.read_file(file_path).await
Expand Down
7 changes: 6 additions & 1 deletion src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Download, Copy, X, AlertCircle } from 'lucide-react';
import { IconButton } from '@/component-library';
import { Markdown as MarkdownRenderer } from '@/component-library/components/Markdown/Markdown';
import { useI18n } from '@/infrastructure/i18n';
import { getFileIconType } from '@/infrastructure/language-detection';
import { createLogger } from '@/shared/utils/logger';
import { globalEventBus } from '@/infrastructure/event-bus';

Expand Down Expand Up @@ -423,6 +424,9 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
const fileName = editorData.fileName || content.title;
const editorLanguage = editorData.language;
const editorWorkspacePath = editorData.workspacePath || workspacePath;
const isBinaryPreview = ['archive', 'binary'].includes(
getFileIconType(fileName)
);
const syncGenerativeWidgetToolResult = async (nextWidgetCode: string, persistToSession: boolean) => {
const source = editorData._source;
if (
Expand Down Expand Up @@ -492,7 +496,8 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
workspacePath={editorWorkspacePath}
fileName={fileName}
language={editorLanguage}
readOnly={editorData.readOnly || false}
readOnly={isBinaryPreview || editorData.readOnly || false}
readEncoding={isBinaryPreview ? 'text-preview' : undefined}
autoSave={editorData.autoSave === true}
autoSaveDelayMs={typeof editorData.autoSaveDelayMs === 'number' ? editorData.autoSaveDelayMs : undefined}
showLineNumbers={editorData.showLineNumbers !== false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ const BUILTIN_LANGUAGES: Language[] = [
id: 'xml',
name: 'XML',
category: 'markup',
extensions: ['xml', 'xsl', 'xslt', 'xsd', 'svg', 'rss', 'atom'],
extensions: ['xml', 'xsl', 'xslt', 'xsd', 'rss', 'atom'],
monacoId: 'xml',
iconType: 'xml',
color: BUILTIN_LANGUAGE_ACCENTS.xml,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { getEditorType, getFileIconType } from './helpers';

describe('language detection editor routing', () => {
it('routes SVG files to the image viewer', () => {
expect(getFileIconType('icon.svg')).toBe('image');
expect(getEditorType('icon.svg')).toBe('image-viewer');
});

it('keeps executable and archive files in the text editor route', () => {
expect(getFileIconType('app.exe')).toBe('binary');
expect(getFileIconType('bundle.zip')).toBe('archive');
expect(getEditorType('app.exe')).toBe('code-editor');
expect(getEditorType('bundle.zip')).toBe('code-editor');
});
});
Loading
Loading