Skip to content
Draft
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
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_Globalization",
"Win32_Storage_FileSystem",
"Win32_System_Com",
"Win32_System_Threading",
"Win32_UI_Shell",
Expand Down
169 changes: 166 additions & 3 deletions src-tauri/src/commands/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::io::{self, Write};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

const DEFAULT_FILE_MENTION_LIMIT: usize = 12;
const MAX_FILE_MENTION_LIMIT: usize = 32;
Expand Down Expand Up @@ -844,6 +844,120 @@ pub struct TextFilePayload {
pub mime_type: Option<String>,
}

#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FileStatPayload {
/// Decimal strings preserve exact identity across the JSON/JavaScript
/// boundary, including nanosecond timestamp precision and large files.
pub byte_size: String,
pub modified_at_ns: String,
/// Change time catches same-size rewrites whose modification time was
/// restored. It is available on Unix and Windows; other platforms omit it.
#[serde(skip_serializing_if = "Option::is_none")]
pub changed_at_ns: Option<String>,
}

fn signed_unix_timestamp_ns(time: SystemTime) -> String {
match time.duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos().to_string(),
Err(error) => format!("-{}", error.duration().as_nanos()),
}
}

#[cfg(windows)]
fn windows_file_change_time_ns(path: &Path) -> Result<String, String> {
use std::fs::File;
use std::mem::{size_of, zeroed};
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
FileBasicInfo, GetFileInformationByHandleEx, FILE_BASIC_INFO,
};

let file = File::open(path).map_err(|error| {
format!(
"Failed to open '{}' for change time: {}",
path.display(),
error
)
})?;
let mut info: FILE_BASIC_INFO = unsafe { zeroed() };
let succeeded = unsafe {
GetFileInformationByHandleEx(
file.as_raw_handle(),
FileBasicInfo,
(&raw mut info).cast(),
size_of::<FILE_BASIC_INFO>() as u32,
)
};
if succeeded == 0 {
return Err(format!(
"Failed to read change time for '{}': {}",
path.display(),
io::Error::last_os_error()
));
}

// Windows reports signed 100ns ticks from 1601. It is an opaque token for
// equality comparisons, so preserving that epoch avoids lossy conversion.
Ok((i128::from(info.ChangeTime) * 100).to_string())
}

fn stat_file_blocking(path: String) -> Result<FileStatPayload, String> {
let target = Path::new(&path);
let metadata = fs::metadata(target)
.map_err(|error| format!("Failed to inspect '{}': {}", target.display(), error))?;
if !metadata.is_file() {
return Err(format!("Path is not a file: {}", target.display()));
}

let modified_at_ns = metadata
.modified()
.map(signed_unix_timestamp_ns)
.map_err(|error| {
format!(
"Failed to read modification time for '{}': {}",
target.display(),
error
)
})?;

#[cfg(unix)]
let changed_at_ns = {
use std::os::unix::fs::MetadataExt;
let nanoseconds =
i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
Some(nanoseconds.to_string())
};
#[cfg(windows)]
let changed_at_ns = Some(windows_file_change_time_ns(target)?);
#[cfg(not(any(unix, windows)))]
let changed_at_ns = None;

Ok(FileStatPayload {
byte_size: metadata.len().to_string(),
modified_at_ns,
changed_at_ns,
})
}

async fn stat_file_with<F>(path: String, operation: F) -> Result<FileStatPayload, String>
where
F: FnOnce(String) -> Result<FileStatPayload, String> + Send + 'static,
{
tokio::task::spawn_blocking(move || operation(path))
.await
.map_err(|error| format!("Failed to inspect file metadata: {error}"))?
}

/// Return the metadata identity used by open artifact viewers to detect writes
/// that do not appear in the main ACP session's tool events. Filesystem metadata
/// calls are blocking and may wait on remote or removable filesystems, so keep
/// them off Tauri's async command thread.
#[tauri::command]
pub async fn stat_file(path: String) -> Result<FileStatPayload, String> {
stat_file_with(path, stat_file_blocking).await
}

fn looks_binary(bytes: &[u8]) -> bool {
bytes
.iter()
Expand Down Expand Up @@ -1994,7 +2108,8 @@ mod tests {
get_or_build_file_mention_index_from_cache, inspect_attachment_path,
inspect_attachment_paths, normalize_attachment_paths, normalize_roots, open_in_chrome_with,
read_directory_entries, read_image_attachment, read_text_file,
search_file_mentions_blocking, write_agent_image_atomically, write_sibling_then_replace,
search_file_mentions_blocking, signed_unix_timestamp_ns, stat_file_blocking,
stat_file_with, write_agent_image_atomically, write_sibling_then_replace,
FileMentionIndexCache, MAX_IMAGE_ATTACHMENT_BYTES, MAX_TEXT_FILE_BYTES,
};
use base64::Engine;
Expand All @@ -2010,7 +2125,7 @@ mod tests {
Arc, Barrier, Mutex,
};
use std::thread;
use std::time::Duration;
use std::time::{Duration, UNIX_EPOCH};
use tempfile::tempdir;

/// Create a temp dir with `git init` so the ignore crate picks up `.gitignore`.
Expand Down Expand Up @@ -2848,6 +2963,54 @@ mod tests {
assert!(!payload.base64.is_empty());
}

#[tokio::test(flavor = "current_thread")]
async fn stat_file_async_command_moves_metadata_work_off_the_runtime_thread() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("notes.md");
fs::write(&path, "hello").expect("write");
let runtime_thread = std::thread::current().id();

let payload = stat_file_with(path.to_string_lossy().into_owned(), move |path| {
assert_ne!(std::thread::current().id(), runtime_thread);
stat_file_blocking(path)
})
.await
.expect("stat file");
assert_eq!(payload.byte_size, "5");
assert!(payload.modified_at_ns.parse::<i128>().expect("timestamp") > 0);
#[cfg(unix)]
assert!(payload.changed_at_ns.is_some());
}

#[test]
fn serializes_pre_epoch_times_as_signed_nanoseconds() {
let timestamp = UNIX_EPOCH - Duration::from_nanos(42);
assert_eq!(signed_unix_timestamp_ns(timestamp), "-42");
}

#[test]
fn stat_file_accepts_pre_epoch_modification_times() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("old-notes.md");
fs::write(&path, "hello").expect("write");
let file = fs::File::open(&path).expect("open");
let old_timestamp = UNIX_EPOCH - Duration::from_secs(1);
file.set_times(fs::FileTimes::new().set_modified(old_timestamp))
.expect("set pre-epoch mtime");

let payload =
stat_file_blocking(path.to_string_lossy().into_owned()).expect("stat old file");
assert_eq!(payload.modified_at_ns, "-1000000000");
}

#[test]
fn stat_file_rejects_directories() {
let dir = tempdir().expect("tempdir");
let error = stat_file_blocking(dir.path().to_string_lossy().into_owned())
.expect_err("directory should error");
assert!(error.contains("not a file"), "unexpected error: {error}");
}

#[test]
fn read_text_file_returns_utf8_contents() {
let dir = tempdir().expect("tempdir");
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ pub fn run() {
commands::system::search_file_mentions,
commands::system::read_image_attachment,
commands::system::read_text_file,
commands::system::stat_file,
commands::terminal::start_terminal,
commands::terminal::write_terminal,
commands::terminal::resize_terminal,
Expand Down
Loading
Loading