first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
/// A model for operating on files.
|
||||
///
|
||||
/// Allows opening and saving files in a single, central model. Subscribers can watch for content
|
||||
@@ -6,34 +6,25 @@ use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use galaxy_core::HostId;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use remote_server::client::RemoteServerClient;
|
||||
use remote_server::manager::RemoteServerManager;
|
||||
|
||||
use futures::io::{AsyncBufReadExt, BufReader};
|
||||
use futures::StreamExt;
|
||||
|
||||
use async_channel::Sender;
|
||||
use galaxy_util::content_version::ContentVersion;
|
||||
use galaxy_util::file::FileSaveError;
|
||||
use galaxy_util::file::{FileId, FileLoadError};
|
||||
use galaxyui::ModelHandle;
|
||||
use galaxyui::{r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext, SingletonEntity};
|
||||
use futures::io::{AsyncBufReadExt, BufReader};
|
||||
use futures::StreamExt;
|
||||
use notify_debouncer_full::notify::{RecursiveMode, WatchFilter};
|
||||
use repo_metadata::{
|
||||
repositories::DetectedRepositories,
|
||||
repository::{RepositorySubscriber, SubscriberId},
|
||||
CanonicalizedPath, Repository, RepositoryUpdate,
|
||||
};
|
||||
use remote_server::manager::RemoteServerManager;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::repository::{RepositorySubscriber, SubscriberId};
|
||||
use repo_metadata::{CanonicalizedPath, Repository, RepositoryUpdate};
|
||||
use galaxy_core::HostId;
|
||||
use galaxy_util::content_version::ContentVersion;
|
||||
use galaxy_util::file::{FileId, FileLoadError, FileSaveError};
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui_core::r#async::SpawnedFutureHandle;
|
||||
use galaxyui_core::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
use watcher::{BulkFilesystemWatcher, BulkFilesystemWatcherEvent};
|
||||
|
||||
pub mod text_file_reader;
|
||||
@@ -91,13 +82,14 @@ enum WatcherType {
|
||||
}
|
||||
|
||||
/// Per-file backing store.
|
||||
/// Remote files dispatch through [`RemoteServerClient`] via [`RemoteServerManager`].
|
||||
/// Remote files dispatch host-scoped requests through a
|
||||
/// [`RemoteServerManager`] `HostRequestHandle`.
|
||||
enum FileBackend {
|
||||
Local(LocalFile),
|
||||
Remote {
|
||||
/// Identifies the remote host. The actual client is looked up from
|
||||
/// Identifies the remote host. A `HostRequestHandle` is resolved from
|
||||
/// [`RemoteServerManager`] at call time, which naturally handles
|
||||
/// disconnect (lookup returns `Err`) without holding an `Arc` alive
|
||||
/// disconnect (the request fails) without holding an `Arc` alive
|
||||
/// per file.
|
||||
host_id: HostId,
|
||||
/// Platform-aware path on the remote host.
|
||||
@@ -309,7 +301,7 @@ impl FileModel {
|
||||
let watcher =
|
||||
ctx.add_model(|ctx| BulkFilesystemWatcher::new(Duration::from_millis(200), ctx));
|
||||
|
||||
ctx.subscribe_to_model(&watcher, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&watcher, |me, _, event, ctx| {
|
||||
me.handle_watcher_event(event, ctx);
|
||||
});
|
||||
|
||||
@@ -345,7 +337,7 @@ impl FileModel {
|
||||
/// Register a remote file path and return a `FileId`.
|
||||
///
|
||||
/// The returned `FileId` can be used with `save()` and `delete()` which
|
||||
/// will dispatch to the remote backend via [`RemoteServerClient`].
|
||||
/// will dispatch to the remote backend via `RemoteServerClient`.
|
||||
pub fn register_remote_file(&mut self, host_id: HostId, path: StandardizedPath) -> FileId {
|
||||
let file_id = FileId::new();
|
||||
self.file_state.insert_remote(file_id, host_id, path);
|
||||
@@ -429,13 +421,22 @@ impl FileModel {
|
||||
let version = ContentVersion::new();
|
||||
me.set_version(file_id, version);
|
||||
|
||||
// Only register individual watcher if not using repo subscription
|
||||
// Only register individual watcher if not using repo subscription.
|
||||
// Watch the parent directory (NonRecursive) instead of the file
|
||||
// itself so the watch survives editors that use a
|
||||
// delete+create/rename pattern (vim, sed -i, etc.). Watching
|
||||
// the file directly would lose the inotify watch when the
|
||||
// original inode is deleted.
|
||||
if use_individual_watcher {
|
||||
let watch_path = file_path_clone
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| file_path_clone.clone());
|
||||
me.watcher.update(ctx, |watcher, _ctx| {
|
||||
std::mem::drop(watcher.register_path(
|
||||
&file_path_clone,
|
||||
&watch_path,
|
||||
WatchFilter::accept_all(),
|
||||
RecursiveMode::Recursive,
|
||||
RecursiveMode::NonRecursive,
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -638,9 +639,28 @@ impl FileModel {
|
||||
if !path_still_used {
|
||||
match watcher_type {
|
||||
WatcherType::Individual => {
|
||||
self.watcher.update(ctx, |watcher, _ctx| {
|
||||
std::mem::drop(watcher.unregister_path(path.as_path()));
|
||||
});
|
||||
// Unwatch the parent directory (matching the register
|
||||
// in open() which watches the parent, not the file).
|
||||
// Only unregister if no other individually-watched
|
||||
// files share the same parent directory.
|
||||
let watch_path = path
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| path.clone());
|
||||
let other_files_share_parent =
|
||||
self.file_state.local_values().any(|f| {
|
||||
f.watcher_type == WatcherType::Individual
|
||||
&& f.path
|
||||
.as_deref()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p == watch_path)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !other_files_share_parent {
|
||||
self.watcher.update(ctx, |watcher, _ctx| {
|
||||
std::mem::drop(watcher.unregister_path(&watch_path));
|
||||
});
|
||||
}
|
||||
}
|
||||
WatcherType::Repository => {
|
||||
if let Some((repo_root, unused_repo)) =
|
||||
@@ -710,17 +730,11 @@ impl FileModel {
|
||||
);
|
||||
}
|
||||
FileBackend::Remote { host_id, path } => {
|
||||
let client = Self::resolve_remote_client(host_id, ctx)?;
|
||||
let handle = RemoteServerManager::as_ref(ctx).host_request_handle(host_id);
|
||||
let path = path.as_str().to_string();
|
||||
let future = async move {
|
||||
client
|
||||
.write_file(path, content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
ctx.spawn(
|
||||
future,
|
||||
move |me, result: Result<(), String>, ctx| match result {
|
||||
async move { handle.write_file(path, content).await },
|
||||
move |me, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
me.set_version(file_id, version);
|
||||
ctx.emit(FileModelEvent::FileSaved {
|
||||
@@ -728,10 +742,10 @@ impl FileModel {
|
||||
version,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
Err(e) => {
|
||||
ctx.emit(FileModelEvent::FailedToSave {
|
||||
id: file_id,
|
||||
error: Rc::new(FileSaveError::RemoteError(err)),
|
||||
error: Rc::new(FileSaveError::RemoteError(e.to_string())),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -861,13 +875,11 @@ impl FileModel {
|
||||
);
|
||||
}
|
||||
FileBackend::Remote { host_id, path } => {
|
||||
let client = Self::resolve_remote_client(host_id, ctx)?;
|
||||
let handle = RemoteServerManager::as_ref(ctx).host_request_handle(host_id);
|
||||
let path = path.as_str().to_string();
|
||||
let future =
|
||||
async move { client.delete_file(path).await.map_err(|e| e.to_string()) };
|
||||
ctx.spawn(
|
||||
future,
|
||||
move |me, result: Result<(), String>, ctx| match result {
|
||||
async move { handle.delete_file(path).await },
|
||||
move |me, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
me.set_version(file_id, version);
|
||||
ctx.emit(FileModelEvent::FileSaved {
|
||||
@@ -875,10 +887,10 @@ impl FileModel {
|
||||
version,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
Err(e) => {
|
||||
ctx.emit(FileModelEvent::FailedToSave {
|
||||
id: file_id,
|
||||
error: Rc::new(FileSaveError::RemoteError(err)),
|
||||
error: Rc::new(FileSaveError::RemoteError(e.to_string())),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -889,19 +901,6 @@ impl FileModel {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up the `RemoteServerClient` for a given host at call time.
|
||||
fn resolve_remote_client(
|
||||
host_id: &HostId,
|
||||
ctx: &AppContext,
|
||||
) -> Result<std::sync::Arc<RemoteServerClient>, FileSaveError> {
|
||||
RemoteServerManager::as_ref(ctx)
|
||||
.client_for_host(host_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
FileSaveError::RemoteError(format!("Remote host {host_id} is not connected"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_version(&mut self, file_id: FileId, version: ContentVersion) {
|
||||
if let Some(backend) = self.file_state.get_mut(file_id) {
|
||||
backend.set_version(version);
|
||||
@@ -928,7 +927,7 @@ impl FileModel {
|
||||
|
||||
// Try to find a repository for this path
|
||||
let repository =
|
||||
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(file_path, ctx)?;
|
||||
DetectedRepositories::as_ref(ctx).get_local_watched_repo_for_path(file_path, ctx)?;
|
||||
|
||||
let repo_root = repository.as_ref(ctx).root_dir().to_local_path_lossy();
|
||||
|
||||
@@ -1195,5 +1194,5 @@ impl RepositorySubscriber for FileRepositorySubscriber {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lib_test.rs"]
|
||||
#[path = "lib_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use async_channel::{unbounded, Receiver};
|
||||
use galaxyui::{r#async::block_on, App, ModelHandle};
|
||||
use galaxyui_core::r#async::block_on;
|
||||
use galaxyui_core::{App, ModelHandle};
|
||||
|
||||
// lib_tests.rs
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
use async_channel::{unbounded, Receiver};
|
||||
use galaxyui_core::r#async::block_on;
|
||||
use galaxyui_core::{App, ModelHandle};
|
||||
|
||||
// lib_tests.rs
|
||||
use super::*;
|
||||
|
||||
const WRITE_TEST_PATH: &str = "test_data/test_write/";
|
||||
|
||||
/// This enum is used so that we can pass the event through the async channel.
|
||||
/// io::Error is not clonable, so we can't clone the FileModelEvent.
|
||||
#[derive(Debug)]
|
||||
enum TestFileModelEvent {
|
||||
FileLoaded {
|
||||
id: FileId,
|
||||
content: String,
|
||||
_version: ContentVersion,
|
||||
},
|
||||
FileSaved,
|
||||
FailedToLoad(String),
|
||||
FailedToSave,
|
||||
}
|
||||
|
||||
impl From<&FileModelEvent> for TestFileModelEvent {
|
||||
fn from(event: &FileModelEvent) -> Self {
|
||||
match event {
|
||||
FileModelEvent::FileLoaded {
|
||||
id,
|
||||
content,
|
||||
version,
|
||||
} => TestFileModelEvent::FileLoaded {
|
||||
id: *id,
|
||||
content: content.clone(),
|
||||
_version: *version,
|
||||
},
|
||||
FileModelEvent::FileSaved { .. } => TestFileModelEvent::FileSaved,
|
||||
FileModelEvent::FailedToLoad {
|
||||
id: _id,
|
||||
error: err,
|
||||
} => TestFileModelEvent::FailedToLoad(format!("{err:?}")),
|
||||
FileModelEvent::FailedToSave { .. } => TestFileModelEvent::FailedToSave,
|
||||
FileModelEvent::FileUpdated { .. } => {
|
||||
// For now, we don't handle file updated events in tests
|
||||
// This could be extended to include a FileUpdated variant in TestFileModelEvent if needed
|
||||
TestFileModelEvent::FileLoaded {
|
||||
id: event.file_id(),
|
||||
content: String::new(),
|
||||
_version: ContentVersion::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup a Tokio channel that will forward any events from the FileModel to the receiver.
|
||||
fn setup_event_channel(
|
||||
app: &mut App,
|
||||
files: &ModelHandle<FileModel>,
|
||||
) -> Receiver<TestFileModelEvent> {
|
||||
let (sender, receiver) = unbounded();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(files, move |_model, event, _ctx| {
|
||||
block_on(sender.send(TestFileModelEvent::from(event)))
|
||||
.expect("Could not send the result");
|
||||
});
|
||||
});
|
||||
receiver
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Load the test file.
|
||||
files.update(app, |model, ctx| {
|
||||
model.open(Path::new("test_data/test_file.rs"), false, ctx);
|
||||
});
|
||||
|
||||
// Check that the first event out is the file loaded event.
|
||||
let event = receiver.recv().await.expect("Could not receive the result");
|
||||
match event {
|
||||
TestFileModelEvent::FileLoaded { content, .. } => {
|
||||
assert_eq!(content.as_bytes(), TEST_FILE_CONTENT)
|
||||
}
|
||||
_ => panic!("Failed to load file"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_uninitialized_file() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let id = FileId::new();
|
||||
|
||||
// This file has not been initialized with the model. Make sure trying to save it fails immediately.
|
||||
files.update(app, |model, ctx| {
|
||||
let result = model.save(
|
||||
id,
|
||||
"This file doesn't exist".to_string(),
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
|
||||
let e = result.unwrap_err();
|
||||
assert!(matches!(e, FileSaveError::NoFilePath(file_id) if file_id == id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_file() {
|
||||
// Create the test write directory if it doesn't exist.
|
||||
std::fs::create_dir_all(WRITE_TEST_PATH).unwrap();
|
||||
|
||||
// Write the test file content to a random file in the test write directory.
|
||||
let path = PathBuf::from(WRITE_TEST_PATH).join("test_save_file.rs");
|
||||
std::fs::write(&path, TEST_FILE_CONTENT).unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Open the newly created file.
|
||||
let path_clone = path.clone();
|
||||
files.update(app, |model, ctx| {
|
||||
model.open(&path_clone, false, ctx);
|
||||
});
|
||||
|
||||
let file_id = match receiver.recv().await.expect("Could not receive the result") {
|
||||
TestFileModelEvent::FileLoaded { id, .. } => id,
|
||||
_ => panic!("Failed to load file"),
|
||||
};
|
||||
|
||||
let old_version = files.read(app, |files, _ctx| files.version(file_id));
|
||||
let new_version = ContentVersion::new();
|
||||
|
||||
// Save new content to the file.
|
||||
files.update(app, |model, ctx| {
|
||||
let result = model.save(file_id, "Overwrite content".to_string(), new_version, ctx);
|
||||
assert!(result.is_ok());
|
||||
});
|
||||
|
||||
// Make sure that the file saved event was emitted.
|
||||
match receiver.recv().await.expect("Could not receive the result") {
|
||||
TestFileModelEvent::FileSaved => (),
|
||||
_ => panic!("Failed to save file"),
|
||||
}
|
||||
|
||||
// Make sure the content on disk matches the content we saved.
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "Overwrite content");
|
||||
|
||||
// Make sure the version was updated.
|
||||
let model_version = files.read(app, |files, _ctx| files.version(file_id));
|
||||
assert_ne!(old_version, model_version);
|
||||
assert_eq!(Some(new_version), model_version);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_missing_file() {
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Load a file that doesn't exist.
|
||||
files.update(app, |model, ctx| {
|
||||
model.open(Path::new("test_data/missing_file.rs"), false, ctx);
|
||||
});
|
||||
|
||||
// Check that the first event out is the failed to load event.
|
||||
let event = receiver.recv().await.expect("Could not receive the result");
|
||||
match event {
|
||||
TestFileModelEvent::FailedToLoad(err) => {
|
||||
// File not found error strings differ across operating systems.
|
||||
#[cfg(not(windows))]
|
||||
let os_error_message = "No such file or directory";
|
||||
#[cfg(windows)]
|
||||
let os_error_message = "The system cannot find the file specified.";
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
format!(
|
||||
"IOError(Os {{ code: 2, kind: NotFound, message: \"{os_error_message}\" }})"
|
||||
)
|
||||
);
|
||||
}
|
||||
_ => panic!("Failed to load file"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_missing_directory() {
|
||||
// Create the test write directory if it doesn't exist.
|
||||
let directory = PathBuf::from(WRITE_TEST_PATH).join("missing-directory");
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
|
||||
// Write the test file content to a random file in the test write directory.
|
||||
let path = directory.join("test_save_missing_directory.rs");
|
||||
std::fs::write(&path, TEST_FILE_CONTENT).unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let app = &mut app;
|
||||
let files = app.add_singleton_model(FileModel::new);
|
||||
let receiver = setup_event_channel(app, &files);
|
||||
|
||||
// Save a file to a directory that doesn't exist.
|
||||
let file_id = files.update(app, |model, ctx| model.open(&path, false, ctx));
|
||||
|
||||
// Check that the first event out is the successful load.
|
||||
let event = receiver.recv().await.expect("Could not receive the result");
|
||||
match event {
|
||||
TestFileModelEvent::FileLoaded { content, .. } => {
|
||||
assert_eq!(content.as_bytes(), TEST_FILE_CONTENT)
|
||||
}
|
||||
event => panic!("Failed to load file {event:?}"),
|
||||
}
|
||||
|
||||
// Delete the directory that the file is in.
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
|
||||
// Save new content to the file.
|
||||
files.update(app, |model, ctx| {
|
||||
let result = model.save(
|
||||
file_id,
|
||||
"Overwrite content".to_string(),
|
||||
ContentVersion::new(),
|
||||
ctx,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
});
|
||||
|
||||
// Now we expect the save to succeed because ensure_parent_directories will create the missing directory
|
||||
match receiver.recv().await.expect("Could not receive the result") {
|
||||
TestFileModelEvent::FileSaved => {
|
||||
// Make sure the content on disk matches the content we saved.
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "Overwrite content");
|
||||
}
|
||||
event => panic!("Save should have succeeded but got event: {event:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static TEST_FILE_CONTENT: &[u8] = include_bytes!("../test_data/test_file.rs");
|
||||
@@ -126,48 +126,50 @@ impl TextFileAccumulator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a [`TextFileSegment`] for the current range (if non-empty, or if
|
||||
/// this is the final flush of a whole-file read) and resets per-range state.
|
||||
/// Emits a [`TextFileSegment`] for the current range and resets per-range
|
||||
/// state. Always produces a segment — even when the buffer is empty (e.g.
|
||||
/// the requested range was entirely past EOF).
|
||||
fn flush_range(&mut self, final_flush: bool) {
|
||||
let should_emit = !self.buf.is_empty() || (final_flush && self.whole_file);
|
||||
let range = self.effective_ranges[self.range_idx].clone();
|
||||
let line_range = if self.whole_file && !self.truncated {
|
||||
None
|
||||
} else if self.truncated {
|
||||
// `last_line` is the last line that was successfully buffered, or 0
|
||||
// when the first in-range line already exceeded the byte budget (so
|
||||
// nothing was buffered). Clamp to `range.start` so a truncated
|
||||
// segment with no buffered lines yields an empty `start..start`
|
||||
// range instead of a reversed `start..0`.
|
||||
Some(range.start..self.last_line.max(range.start))
|
||||
} else {
|
||||
Some(range)
|
||||
};
|
||||
|
||||
if should_emit {
|
||||
let range = self.effective_ranges[self.range_idx].clone();
|
||||
let line_range = if self.whole_file && !self.truncated {
|
||||
None
|
||||
} else if self.truncated {
|
||||
Some(range.start..self.last_line)
|
||||
} else {
|
||||
Some(range)
|
||||
};
|
||||
self.total_bytes_read += self.buf_bytes;
|
||||
let mut content = std::mem::take(&mut self.buf).join("\n");
|
||||
|
||||
self.total_bytes_read += self.buf_bytes;
|
||||
let mut content = std::mem::take(&mut self.buf).join("\n");
|
||||
|
||||
// If this is the final flush of a non-truncated whole-file read
|
||||
// and the last line in the file had a trailing newline, preserve
|
||||
// it. This ensures round-tripping file content through the
|
||||
// accumulator doesn't silently drop a trailing newline (which
|
||||
// would otherwise cause data loss when the content is written
|
||||
// back to disk, e.g. during remote diff application).
|
||||
//
|
||||
// We skip this for truncated reads (the content is incomplete, so
|
||||
// appending a newline would be misleading) and for ranged reads
|
||||
// (which extract a slice, not the full file).
|
||||
if final_flush && self.whole_file && !self.truncated && self.last_line_had_newline {
|
||||
content.push('\n');
|
||||
self.total_bytes_read += 1;
|
||||
}
|
||||
|
||||
self.segments.push(TextFileSegment {
|
||||
file_name: self.file_name.clone(),
|
||||
content,
|
||||
line_range,
|
||||
last_modified: self.last_modified,
|
||||
line_count: 0, // Set in finalize()
|
||||
});
|
||||
// If this is the final flush of a non-truncated whole-file read
|
||||
// and the last line in the file had a trailing newline, preserve
|
||||
// it. This ensures round-tripping file content through the
|
||||
// accumulator doesn't silently drop a trailing newline (which
|
||||
// would otherwise cause data loss when the content is written
|
||||
// back to disk, e.g. during remote diff application).
|
||||
//
|
||||
// We skip this for truncated reads (the content is incomplete, so
|
||||
// appending a newline would be misleading) and for ranged reads
|
||||
// (which extract a slice, not the full file).
|
||||
if final_flush && self.whole_file && !self.truncated && self.last_line_had_newline {
|
||||
content.push('\n');
|
||||
self.total_bytes_read += 1;
|
||||
}
|
||||
|
||||
self.segments.push(TextFileSegment {
|
||||
file_name: self.file_name.clone(),
|
||||
content,
|
||||
line_range,
|
||||
last_modified: self.last_modified,
|
||||
line_count: 0, // Set in finalize()
|
||||
});
|
||||
|
||||
self.buf.clear();
|
||||
self.buf_bytes = 0;
|
||||
self.truncated = false;
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use crate::FileModel;
|
||||
|
||||
use super::*;
|
||||
use crate::FileModel;
|
||||
|
||||
fn make_accumulator(ranges: &[std::ops::Range<usize>], max_bytes: usize) -> TextFileAccumulator {
|
||||
TextFileAccumulator::new("test.txt".to_string(), None, ranges, max_bytes)
|
||||
@@ -118,14 +117,52 @@ fn unsorted_ranges_are_sorted() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_with_ranges_produces_no_segment() {
|
||||
fn empty_file_with_ranges_produces_empty_segment() {
|
||||
let acc = make_accumulator(&[1..5], 1000);
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 0);
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "");
|
||||
assert_eq!(segments[0].line_range, Some(1..5));
|
||||
assert_eq!(segments[0].line_count, 0);
|
||||
assert_eq!(bytes_read, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_past_eof_produces_empty_segment() {
|
||||
// File has 5 lines, but range requests lines 10..15 which are all past EOF.
|
||||
let mut acc = make_accumulator(&[10..15], 1000);
|
||||
for i in 1..=5 {
|
||||
push(&mut acc, &format!("line{i}"));
|
||||
}
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "");
|
||||
assert_eq!(segments[0].line_range, Some(10..15));
|
||||
assert_eq!(segments[0].line_count, 5);
|
||||
assert_eq!(bytes_read, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_ranges_some_past_eof() {
|
||||
// First range is valid, second is entirely past EOF.
|
||||
let mut acc = make_accumulator(&[1..3, 10..15], 1000);
|
||||
for i in 1..=5 {
|
||||
push(&mut acc, &format!("line{i}"));
|
||||
}
|
||||
let (segments, _bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert_eq!(segments[0].content, "line1\nline2");
|
||||
assert_eq!(segments[0].line_range, Some(1..3));
|
||||
assert_eq!(segments[1].content, "");
|
||||
assert_eq!(segments[1].line_range, Some(10..15));
|
||||
for seg in &segments {
|
||||
assert_eq!(seg.line_count, 5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_reflects_total_file_lines() {
|
||||
let mut acc = make_accumulator(&[2..4], 1000);
|
||||
@@ -156,6 +193,43 @@ fn range_truncated_at_byte_limit() {
|
||||
assert_eq!(bytes_read, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_truncated_on_first_line_yields_empty_range() {
|
||||
// Budget smaller than the very first line, so nothing is ever buffered.
|
||||
// The truncated range must be a valid empty `1..1`, not a reversed `1..0`.
|
||||
let mut acc = make_accumulator(&[], 4);
|
||||
push(&mut acc, "hello"); // 5 bytes > 4 → truncated, nothing buffered
|
||||
push(&mut acc, "world");
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "");
|
||||
assert_eq!(segments[0].line_range, Some(1..1));
|
||||
assert_eq!(segments[0].line_count, 2);
|
||||
assert_eq!(bytes_read, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_truncated_on_first_in_range_line_yields_empty_range() {
|
||||
// The first line that falls inside the requested range already exceeds the
|
||||
// budget, so nothing is buffered for this range. The range must be the
|
||||
// empty `2..2`, not a reversed `2..0`.
|
||||
let mut acc = make_accumulator(&[2..6], 4);
|
||||
push(&mut acc, "skip"); // line 1, outside range
|
||||
push(&mut acc, "hello"); // line 2, 5 bytes > 4 → truncated, nothing buffered
|
||||
push(&mut acc, "world"); // line 3
|
||||
let (segments, bytes_read) = acc.finalize();
|
||||
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0].content, "");
|
||||
assert_eq!(segments[0].line_range, Some(2..2));
|
||||
let Some(range) = &segments[0].line_range else {
|
||||
panic!("expected a line range");
|
||||
};
|
||||
assert!(range.start <= range.end, "range must not be reversed");
|
||||
assert_eq!(bytes_read, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_budget_shared_across_ranges() {
|
||||
// Budget of 12 bytes. First range uses 5, leaving 7 for the second.
|
||||
|
||||
Reference in New Issue
Block a user