mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-28 12:05:22 +03:00
test deltas read/write
This commit is contained in:
parent
ea9fa3dca4
commit
0562bcae5d
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@ -1021,6 +1021,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"uuid 1.3.0",
|
||||
"walkdir",
|
||||
"yrs",
|
||||
|
@ -31,6 +31,7 @@ sentry-tauri = "0.1.0"
|
||||
sentry = "0.27"
|
||||
walkdir = "2.3.2"
|
||||
anyhow = "1.0.69"
|
||||
tempfile = "3.3.0"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
@ -1,409 +0,0 @@
|
||||
use crate::{fs, projects, sessions};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use difference::{Changeset, Difference};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, path::Path, time::SystemTime};
|
||||
use yrs::{Doc, GetString, Text, Transact};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Delta {
|
||||
operations: Vec<Operation>,
|
||||
timestamp_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Operation {
|
||||
// corresponds to YText.insert(index, chunk)
|
||||
Insert((u32, String)),
|
||||
// corresponds to YText.remove_range(index, len)
|
||||
Delete((u32, u32)),
|
||||
}
|
||||
|
||||
fn get_delta_operations(initial_text: &str, final_text: &str) -> Vec<Operation> {
|
||||
if initial_text == final_text {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let changeset = Changeset::new(initial_text, final_text, "");
|
||||
let mut offset: u32 = 0;
|
||||
let mut deltas = vec![];
|
||||
|
||||
for edit in changeset.diffs {
|
||||
match edit {
|
||||
Difference::Rem(text) => {
|
||||
deltas.push(Operation::Delete((offset, text.len() as u32)));
|
||||
}
|
||||
Difference::Add(text) => {
|
||||
deltas.push(Operation::Insert((offset, text.to_string())));
|
||||
}
|
||||
Difference::Same(text) => {
|
||||
offset += text.len() as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TextDocument {
|
||||
doc: Doc,
|
||||
deltas: Vec<Delta>,
|
||||
}
|
||||
|
||||
const TEXT_DOCUMENT_NAME: &str = "document";
|
||||
|
||||
impl TextDocument {
|
||||
fn apply_deltas(doc: &Doc, deltas: &Vec<Delta>) {
|
||||
if deltas.len() == 0 {
|
||||
return;
|
||||
}
|
||||
let text = doc.get_or_insert_text(TEXT_DOCUMENT_NAME);
|
||||
let mut txn = doc.transact_mut();
|
||||
for event in deltas {
|
||||
for operation in event.operations.iter() {
|
||||
match operation {
|
||||
Operation::Insert((index, chunk)) => {
|
||||
text.insert(&mut txn, *index, chunk);
|
||||
}
|
||||
Operation::Delete((index, len)) => {
|
||||
text.remove_range(&mut txn, *index, *len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new text document from a deltas.
|
||||
pub fn from_deltas(deltas: Vec<Delta>) -> TextDocument {
|
||||
let doc = Doc::new();
|
||||
Self::apply_deltas(&doc, &deltas);
|
||||
TextDocument {
|
||||
doc: doc.clone(),
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_deltas(&self) -> Vec<Delta> {
|
||||
self.deltas.clone()
|
||||
}
|
||||
|
||||
// returns a text document where internal state is seeded with value, and deltas are applied.
|
||||
pub fn new(value: &str, deltas: Vec<Delta>) -> TextDocument {
|
||||
let doc = Doc::new();
|
||||
let mut all_deltas = vec![Delta {
|
||||
operations: get_delta_operations("", value),
|
||||
timestamp_ms: 0,
|
||||
}];
|
||||
all_deltas.append(&mut deltas.clone());
|
||||
Self::apply_deltas(&doc, &all_deltas);
|
||||
TextDocument {
|
||||
doc: doc.clone(),
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, value: &str) -> bool {
|
||||
let diffs = get_delta_operations(&self.to_string(), value);
|
||||
let event = Delta {
|
||||
operations: diffs,
|
||||
timestamp_ms: SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64,
|
||||
};
|
||||
if event.operations.len() == 0 {
|
||||
return false;
|
||||
}
|
||||
Self::apply_deltas(&self.doc, &vec![event.clone()]);
|
||||
self.deltas.push(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
let doc = &self.doc;
|
||||
let text = doc.get_or_insert_text(TEXT_DOCUMENT_NAME);
|
||||
let txn = doc.transact();
|
||||
text.get_string(&txn)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_current_file_deltas(
|
||||
project: &projects::Project,
|
||||
file_path: &Path,
|
||||
) -> Result<Option<Vec<Delta>>> {
|
||||
let file_deltas_path = project.deltas_path().join(file_path);
|
||||
if !file_deltas_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_deltas = std::fs::read_to_string(&file_deltas_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read file deltas from {}",
|
||||
file_deltas_path.to_str().unwrap()
|
||||
)
|
||||
})?;
|
||||
Ok(Some(serde_json::from_str(&file_deltas)?))
|
||||
}
|
||||
|
||||
pub fn save_current_file_deltas(
|
||||
project: &projects::Project,
|
||||
file_path: &Path,
|
||||
deltas: &Vec<Delta>,
|
||||
) -> Result<()> {
|
||||
let delta_path = project.deltas_path().join(file_path);
|
||||
let delta_dir = delta_path.parent().unwrap();
|
||||
std::fs::create_dir_all(&delta_dir)?;
|
||||
log::info!("mkdir {}", delta_path.to_str().unwrap());
|
||||
log::info!("Writing deltas to {}", delta_path.to_str().unwrap());
|
||||
let raw_deltas = serde_json::to_string(&deltas)?;
|
||||
std::fs::write(delta_path.clone(), raw_deltas).with_context(|| {
|
||||
format!(
|
||||
"Failed to write file deltas to {}",
|
||||
delta_path.to_str().unwrap()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// returns deltas for a current session from .gb/session/deltas tree
|
||||
fn list_current_deltas(project: &projects::Project) -> Result<HashMap<String, Vec<Delta>>> {
|
||||
let deltas_path = project.deltas_path();
|
||||
if !deltas_path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let file_paths = fs::list_files(&deltas_path)
|
||||
.with_context(|| format!("Failed to list files in {}", deltas_path.to_str().unwrap()))?;
|
||||
|
||||
let deltas = file_paths
|
||||
.iter()
|
||||
.map_while(|file_path| {
|
||||
let file_deltas = get_current_file_deltas(project, Path::new(file_path));
|
||||
match file_deltas {
|
||||
Ok(Some(file_deltas)) => Some(Ok((file_path.to_owned(), file_deltas))),
|
||||
Ok(None) => None,
|
||||
Err(err) => Some(Err(err)),
|
||||
}
|
||||
})
|
||||
.collect::<Result<HashMap<String, Vec<Delta>>>>()?;
|
||||
|
||||
Ok(deltas)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
repo: &git2::Repository,
|
||||
project: &projects::Project,
|
||||
reference: &git2::Reference,
|
||||
session_id: &str,
|
||||
) -> Result<HashMap<String, Vec<Delta>>> {
|
||||
let session = match sessions::get(repo, project, reference, session_id)? {
|
||||
Some(session) => Ok(session),
|
||||
None => Err(anyhow!("Session {} not found", session_id)),
|
||||
}?;
|
||||
|
||||
if session.hash.is_none() {
|
||||
list_current_deltas(project)
|
||||
.with_context(|| format!("Failed to list current deltas for session {}", session_id))
|
||||
} else {
|
||||
list_commit_deltas(repo, &session.hash.unwrap())
|
||||
.with_context(|| format!("Failed to list commit deltas for session {}", session_id))
|
||||
}
|
||||
}
|
||||
|
||||
// returns deltas from gitbutler commit's session/deltas tree
|
||||
pub fn list_commit_deltas(
|
||||
repo: &git2::Repository,
|
||||
commit_hash: &str,
|
||||
) -> Result<HashMap<String, Vec<Delta>>> {
|
||||
let commit_id = git2::Oid::from_str(commit_hash)?;
|
||||
let commit = repo.find_commit(commit_id)?;
|
||||
let tree = commit.tree()?;
|
||||
|
||||
let mut blobs = HashMap::new();
|
||||
tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
|
||||
if entry.name().is_none() {
|
||||
return git2::TreeWalkResult::Ok;
|
||||
}
|
||||
let entry_path = Path::new(root).join(entry.name().unwrap());
|
||||
if !entry_path.starts_with("session/deltas") {
|
||||
return git2::TreeWalkResult::Ok;
|
||||
}
|
||||
if entry.kind() != Some(git2::ObjectType::Blob) {
|
||||
return git2::TreeWalkResult::Ok;
|
||||
}
|
||||
let blob = entry.to_object(repo).and_then(|obj| obj.peel_to_blob());
|
||||
let content = blob.map(|blob| blob.content().to_vec());
|
||||
|
||||
match content {
|
||||
Ok(content) => {
|
||||
let deltas: Result<Vec<Delta>> =
|
||||
serde_json::from_slice(&content).map_err(|e| e.into());
|
||||
blobs.insert(
|
||||
entry_path
|
||||
.strip_prefix("session/deltas")
|
||||
.unwrap()
|
||||
.to_owned(),
|
||||
deltas,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not get blob for {}: {}", entry_path.display(), e);
|
||||
}
|
||||
}
|
||||
git2::TreeWalkResult::Ok
|
||||
})?;
|
||||
|
||||
let deltas = blobs
|
||||
.into_iter()
|
||||
.map(|(path, deltas)| (path.to_str().unwrap().to_owned(), deltas.unwrap()))
|
||||
.collect();
|
||||
Ok(deltas)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_end() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "hello world!";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Insert((11, "!".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_middle() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "hello, world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Insert((5, ",".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_begin() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = ": hello world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Insert((0, ": ".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_end() {
|
||||
let initial_text = "hello world!";
|
||||
let final_text = "hello world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((11, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_middle() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "helloworld";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((5, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_begin() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "ello world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_new() {
|
||||
let document = TextDocument::new("hello world", vec![]);
|
||||
assert_eq!(document.to_string(), "hello world");
|
||||
assert_eq!(document.get_deltas().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_update() {
|
||||
let mut document = TextDocument::new("hello world", vec![]);
|
||||
document.update("hello world!");
|
||||
assert_eq!(document.to_string(), "hello world!");
|
||||
assert_eq!(document.get_deltas().len(), 1);
|
||||
assert_eq!(document.get_deltas()[0].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[0].operations[0],
|
||||
Operation::Insert((11, "!".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_empty() {
|
||||
let mut document = TextDocument::from_deltas(vec![]);
|
||||
document.update("hello world!");
|
||||
assert_eq!(document.to_string(), "hello world!");
|
||||
assert_eq!(document.get_deltas().len(), 1);
|
||||
assert_eq!(document.get_deltas()[0].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[0].operations[0],
|
||||
Operation::Insert((0, "hello world!".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_from_deltas() {
|
||||
let document = TextDocument::from_deltas(vec![
|
||||
Delta {
|
||||
timestamp_ms: 0,
|
||||
operations: vec![Operation::Insert((0, "hello".to_string()))],
|
||||
},
|
||||
Delta {
|
||||
timestamp_ms: 1,
|
||||
operations: vec![Operation::Insert((5, " world".to_string()))],
|
||||
},
|
||||
Delta {
|
||||
timestamp_ms: 2,
|
||||
operations: vec![
|
||||
Operation::Delete((3, 7)),
|
||||
Operation::Insert((4, "!".to_string())),
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert_eq!(document.to_string(), "held!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_complex() {
|
||||
let mut document = TextDocument::from_deltas(vec![]);
|
||||
|
||||
document.update("hello");
|
||||
assert_eq!(document.to_string(), "hello");
|
||||
assert_eq!(document.get_deltas().len(), 1);
|
||||
assert_eq!(document.get_deltas()[0].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[0].operations[0],
|
||||
Operation::Insert((0, "hello".to_string()))
|
||||
);
|
||||
|
||||
document.update("hello world");
|
||||
assert_eq!(document.to_string(), "hello world");
|
||||
assert_eq!(document.get_deltas().len(), 2);
|
||||
assert_eq!(document.get_deltas()[1].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[1].operations[0],
|
||||
Operation::Insert((5, " world".to_string()))
|
||||
);
|
||||
|
||||
document.update("held!");
|
||||
assert_eq!(document.to_string(), "held!");
|
||||
assert_eq!(document.get_deltas().len(), 3);
|
||||
assert_eq!(document.get_deltas()[2].operations.len(), 2);
|
||||
assert_eq!(
|
||||
document.get_deltas()[2].operations[0],
|
||||
Operation::Delete((3, 7))
|
||||
);
|
||||
assert_eq!(
|
||||
document.get_deltas()[2].operations[1],
|
||||
Operation::Insert((4, "!".to_string())),
|
||||
);
|
||||
}
|
139
src-tauri/src/deltas/deltas.rs
Normal file
139
src-tauri/src/deltas/deltas.rs
Normal file
@ -0,0 +1,139 @@
|
||||
use super::operations;
|
||||
use crate::{fs, projects, sessions};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Delta {
|
||||
pub operations: Vec<operations::Operation>,
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
pub fn read(project: &projects::Project, file_path: &Path) -> Result<Option<Vec<Delta>>> {
|
||||
let file_deltas_path = project.deltas_path().join(file_path);
|
||||
if !file_deltas_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_deltas = std::fs::read_to_string(&file_deltas_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read file deltas from {}",
|
||||
file_deltas_path.to_str().unwrap()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Some(serde_json::from_str(&file_deltas)?))
|
||||
}
|
||||
|
||||
pub fn write(project: &projects::Project, file_path: &Path, deltas: &Vec<Delta>) -> Result<()> {
|
||||
let delta_path = project.deltas_path().join(file_path);
|
||||
let delta_dir = delta_path.parent().unwrap();
|
||||
std::fs::create_dir_all(&delta_dir)?;
|
||||
log::info!("mkdir {}", delta_path.to_str().unwrap());
|
||||
log::info!("Writing deltas to {}", delta_path.to_str().unwrap());
|
||||
let raw_deltas = serde_json::to_string(&deltas)?;
|
||||
std::fs::write(delta_path.clone(), raw_deltas).with_context(|| {
|
||||
format!(
|
||||
"Failed to write file deltas to {}",
|
||||
delta_path.to_str().unwrap()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// returns deltas for a current session from .gb/session/deltas tree
|
||||
fn list_current_deltas(project: &projects::Project) -> Result<HashMap<String, Vec<Delta>>> {
|
||||
let deltas_path = project.deltas_path();
|
||||
if !deltas_path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let file_paths = fs::list_files(&deltas_path)
|
||||
.with_context(|| format!("Failed to list files in {}", deltas_path.to_str().unwrap()))?;
|
||||
|
||||
let deltas = file_paths
|
||||
.iter()
|
||||
.map_while(|file_path| {
|
||||
let file_deltas = read(project, Path::new(file_path));
|
||||
match file_deltas {
|
||||
Ok(Some(file_deltas)) => Some(Ok((file_path.to_owned(), file_deltas))),
|
||||
Ok(None) => None,
|
||||
Err(err) => Some(Err(err)),
|
||||
}
|
||||
})
|
||||
.collect::<Result<HashMap<String, Vec<Delta>>>>()?;
|
||||
|
||||
Ok(deltas)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
repo: &git2::Repository,
|
||||
project: &projects::Project,
|
||||
reference: &git2::Reference,
|
||||
session_id: &str,
|
||||
) -> Result<HashMap<String, Vec<Delta>>> {
|
||||
let session = match sessions::get(repo, project, reference, session_id)? {
|
||||
Some(session) => Ok(session),
|
||||
None => Err(anyhow!("Session {} not found", session_id)),
|
||||
}?;
|
||||
|
||||
if session.hash.is_none() {
|
||||
list_current_deltas(project)
|
||||
.with_context(|| format!("Failed to list current deltas for session {}", session_id))
|
||||
} else {
|
||||
list_commit_deltas(repo, &session.hash.unwrap())
|
||||
.with_context(|| format!("Failed to list commit deltas for session {}", session_id))
|
||||
}
|
||||
}
|
||||
|
||||
// returns deltas from gitbutler commit's session/deltas tree
|
||||
fn list_commit_deltas(
|
||||
repo: &git2::Repository,
|
||||
commit_hash: &str,
|
||||
) -> Result<HashMap<String, Vec<Delta>>> {
|
||||
let commit_id = git2::Oid::from_str(commit_hash)?;
|
||||
let commit = repo.find_commit(commit_id)?;
|
||||
let tree = commit.tree()?;
|
||||
|
||||
let mut blobs = HashMap::new();
|
||||
tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
|
||||
if entry.name().is_none() {
|
||||
return git2::TreeWalkResult::Ok;
|
||||
}
|
||||
let entry_path = Path::new(root).join(entry.name().unwrap());
|
||||
if !entry_path.starts_with("session/deltas") {
|
||||
return git2::TreeWalkResult::Ok;
|
||||
}
|
||||
if entry.kind() != Some(git2::ObjectType::Blob) {
|
||||
return git2::TreeWalkResult::Ok;
|
||||
}
|
||||
let blob = entry.to_object(repo).and_then(|obj| obj.peel_to_blob());
|
||||
let content = blob.map(|blob| blob.content().to_vec());
|
||||
|
||||
match content {
|
||||
Ok(content) => {
|
||||
let deltas: Result<Vec<Delta>> =
|
||||
serde_json::from_slice(&content).map_err(|e| e.into());
|
||||
blobs.insert(
|
||||
entry_path
|
||||
.strip_prefix("session/deltas")
|
||||
.unwrap()
|
||||
.to_owned(),
|
||||
deltas,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not get blob for {}: {}", entry_path.display(), e);
|
||||
}
|
||||
}
|
||||
git2::TreeWalkResult::Ok
|
||||
})?;
|
||||
|
||||
let deltas = blobs
|
||||
.into_iter()
|
||||
.map(|(path, deltas)| (path.to_str().unwrap().to_owned(), deltas.unwrap()))
|
||||
.collect();
|
||||
Ok(deltas)
|
||||
}
|
61
src-tauri/src/deltas/deltas_tests.rs
Normal file
61
src-tauri/src/deltas/deltas_tests.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use crate::{deltas::operations::Operation, projects};
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_read_none() {
|
||||
let project = projects::Project {
|
||||
id: "test".to_string(),
|
||||
path: tempdir().unwrap().path().to_str().unwrap().to_string(),
|
||||
title: "Test".to_string(),
|
||||
api: None,
|
||||
};
|
||||
let file_path = Path::new("test.txt");
|
||||
let deltas = super::read(&project, file_path);
|
||||
println!("{:?}", deltas);
|
||||
assert!(deltas.is_ok());
|
||||
assert!(deltas.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_invalid() {
|
||||
let project = projects::Project {
|
||||
id: "test".to_string(),
|
||||
path: tempdir().unwrap().path().to_str().unwrap().to_string(),
|
||||
title: "Test".to_string(),
|
||||
api: None,
|
||||
};
|
||||
let file_path = Path::new("test.txt");
|
||||
let full_file_path = project.deltas_path().join(file_path);
|
||||
|
||||
std::fs::create_dir_all(full_file_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(full_file_path, "invalid").unwrap();
|
||||
|
||||
let deltas = super::read(&project, file_path);
|
||||
assert!(deltas.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read() {
|
||||
let project = projects::Project {
|
||||
id: "test".to_string(),
|
||||
path: tempdir().unwrap().path().to_str().unwrap().to_string(),
|
||||
title: "Test".to_string(),
|
||||
api: None,
|
||||
};
|
||||
let file_path = Path::new("test.txt");
|
||||
let full_file_path = project.deltas_path().join(file_path);
|
||||
|
||||
std::fs::create_dir_all(full_file_path.parent().unwrap()).unwrap();
|
||||
|
||||
let deltas = vec![super::Delta {
|
||||
operations: vec![Operation::Insert((0, "Hello, world!".to_string()))],
|
||||
timestamp_ms: 0,
|
||||
}];
|
||||
let write_result = super::write(&project, file_path, &deltas);
|
||||
assert!(write_result.is_ok());
|
||||
|
||||
let read_result = super::read(&project, file_path);
|
||||
assert!(read_result.is_ok());
|
||||
assert_eq!(read_result.unwrap().unwrap(), deltas);
|
||||
}
|
11
src-tauri/src/deltas/mod.rs
Normal file
11
src-tauri/src/deltas/mod.rs
Normal file
@ -0,0 +1,11 @@
|
||||
mod deltas;
|
||||
mod operations;
|
||||
mod text_document;
|
||||
|
||||
pub use deltas::{list, read, write, Delta};
|
||||
pub use text_document::TextDocument;
|
||||
|
||||
#[cfg(test)]
|
||||
mod deltas_tests;
|
||||
#[cfg(test)]
|
||||
mod text_document_tests;
|
6
src-tauri/src/deltas/operations/mod.rs
Normal file
6
src-tauri/src/deltas/operations/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
mod operations;
|
||||
|
||||
pub use operations::{get_delta_operations, Operation};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
37
src-tauri/src/deltas/operations/operations.rs
Normal file
37
src-tauri/src/deltas/operations/operations.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use difference::{Changeset, Difference};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Operation {
|
||||
// corresponds to YText.insert(index, chunk)
|
||||
Insert((u32, String)),
|
||||
// corresponds to YText.remove_range(index, len)
|
||||
Delete((u32, u32)),
|
||||
}
|
||||
|
||||
pub fn get_delta_operations(initial_text: &str, final_text: &str) -> Vec<Operation> {
|
||||
if initial_text == final_text {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let changeset = Changeset::new(initial_text, final_text, "");
|
||||
let mut offset: u32 = 0;
|
||||
let mut deltas = vec![];
|
||||
|
||||
for edit in changeset.diffs {
|
||||
match edit {
|
||||
Difference::Rem(text) => {
|
||||
deltas.push(Operation::Delete((offset, text.len() as u32)));
|
||||
}
|
||||
Difference::Add(text) => {
|
||||
deltas.push(Operation::Insert((offset, text.to_string())));
|
||||
}
|
||||
Difference::Same(text) => {
|
||||
offset += text.len() as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
55
src-tauri/src/deltas/operations/tests.rs
Normal file
55
src-tauri/src/deltas/operations/tests.rs
Normal file
@ -0,0 +1,55 @@
|
||||
use crate::deltas::operations::{get_delta_operations, Operation};
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_end() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "hello world!";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Insert((11, "!".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_middle() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "hello, world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Insert((5, ",".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_begin() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = ": hello world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Insert((0, ": ".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_end() {
|
||||
let initial_text = "hello world!";
|
||||
let final_text = "hello world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((11, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_middle() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "helloworld";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((5, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_begin() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "ello world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((0, 1)));
|
||||
}
|
87
src-tauri/src/deltas/text_document.rs
Normal file
87
src-tauri/src/deltas/text_document.rs
Normal file
@ -0,0 +1,87 @@
|
||||
use std::time::SystemTime;
|
||||
use yrs::{Doc, GetString, Text, Transact};
|
||||
|
||||
use super::{deltas, operations};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TextDocument {
|
||||
doc: Doc,
|
||||
deltas: Vec<deltas::Delta>,
|
||||
}
|
||||
|
||||
const TEXT_DOCUMENT_NAME: &str = "document";
|
||||
|
||||
impl TextDocument {
|
||||
fn apply_deltas(doc: &Doc, deltas: &Vec<deltas::Delta>) {
|
||||
if deltas.len() == 0 {
|
||||
return;
|
||||
}
|
||||
let text = doc.get_or_insert_text(TEXT_DOCUMENT_NAME);
|
||||
let mut txn = doc.transact_mut();
|
||||
for event in deltas {
|
||||
for operation in event.operations.iter() {
|
||||
match operation {
|
||||
operations::Operation::Insert((index, chunk)) => {
|
||||
text.insert(&mut txn, *index, chunk);
|
||||
}
|
||||
operations::Operation::Delete((index, len)) => {
|
||||
text.remove_range(&mut txn, *index, *len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// creates a new text document from a deltas.
|
||||
pub fn from_deltas(deltas: Vec<deltas::Delta>) -> TextDocument {
|
||||
let doc = Doc::new();
|
||||
Self::apply_deltas(&doc, &deltas);
|
||||
TextDocument {
|
||||
doc: doc.clone(),
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_deltas(&self) -> Vec<deltas::Delta> {
|
||||
self.deltas.clone()
|
||||
}
|
||||
|
||||
// returns a text document where internal state is seeded with value, and deltas are applied.
|
||||
pub fn new(value: &str, deltas: Vec<deltas::Delta>) -> TextDocument {
|
||||
let doc = Doc::new();
|
||||
let mut all_deltas = vec![deltas::Delta {
|
||||
operations: operations::get_delta_operations("", value),
|
||||
timestamp_ms: 0,
|
||||
}];
|
||||
all_deltas.append(&mut deltas.clone());
|
||||
Self::apply_deltas(&doc, &all_deltas);
|
||||
TextDocument {
|
||||
doc: doc.clone(),
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, value: &str) -> bool {
|
||||
let diffs = operations::get_delta_operations(&self.to_string(), value);
|
||||
let event = deltas::Delta {
|
||||
operations: diffs,
|
||||
timestamp_ms: SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64,
|
||||
};
|
||||
if event.operations.len() == 0 {
|
||||
return false;
|
||||
}
|
||||
Self::apply_deltas(&self.doc, &vec![event.clone()]);
|
||||
self.deltas.push(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
let doc = &self.doc;
|
||||
let text = doc.get_or_insert_text(TEXT_DOCUMENT_NAME);
|
||||
let txn = doc.transact();
|
||||
text.get_string(&txn)
|
||||
}
|
||||
}
|
92
src-tauri/src/deltas/text_document_tests.rs
Normal file
92
src-tauri/src/deltas/text_document_tests.rs
Normal file
@ -0,0 +1,92 @@
|
||||
use crate::deltas::{operations::Operation, text_document::TextDocument, Delta};
|
||||
|
||||
#[test]
|
||||
fn test_new() {
|
||||
let document = TextDocument::new("hello world", vec![]);
|
||||
assert_eq!(document.to_string(), "hello world");
|
||||
assert_eq!(document.get_deltas().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update() {
|
||||
let mut document = TextDocument::new("hello world", vec![]);
|
||||
document.update("hello world!");
|
||||
assert_eq!(document.to_string(), "hello world!");
|
||||
assert_eq!(document.get_deltas().len(), 1);
|
||||
assert_eq!(document.get_deltas()[0].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[0].operations[0],
|
||||
Operation::Insert((11, "!".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
let mut document = TextDocument::from_deltas(vec![]);
|
||||
document.update("hello world!");
|
||||
assert_eq!(document.to_string(), "hello world!");
|
||||
assert_eq!(document.get_deltas().len(), 1);
|
||||
assert_eq!(document.get_deltas()[0].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[0].operations[0],
|
||||
Operation::Insert((0, "hello world!".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_deltas() {
|
||||
let document = TextDocument::from_deltas(vec![
|
||||
Delta {
|
||||
timestamp_ms: 0,
|
||||
operations: vec![Operation::Insert((0, "hello".to_string()))],
|
||||
},
|
||||
Delta {
|
||||
timestamp_ms: 1,
|
||||
operations: vec![Operation::Insert((5, " world".to_string()))],
|
||||
},
|
||||
Delta {
|
||||
timestamp_ms: 2,
|
||||
operations: vec![
|
||||
Operation::Delete((3, 7)),
|
||||
Operation::Insert((4, "!".to_string())),
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert_eq!(document.to_string(), "held!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex() {
|
||||
let mut document = TextDocument::from_deltas(vec![]);
|
||||
|
||||
document.update("hello");
|
||||
assert_eq!(document.to_string(), "hello");
|
||||
assert_eq!(document.get_deltas().len(), 1);
|
||||
assert_eq!(document.get_deltas()[0].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[0].operations[0],
|
||||
Operation::Insert((0, "hello".to_string()))
|
||||
);
|
||||
|
||||
document.update("hello world");
|
||||
assert_eq!(document.to_string(), "hello world");
|
||||
assert_eq!(document.get_deltas().len(), 2);
|
||||
assert_eq!(document.get_deltas()[1].operations.len(), 1);
|
||||
assert_eq!(
|
||||
document.get_deltas()[1].operations[0],
|
||||
Operation::Insert((5, " world".to_string()))
|
||||
);
|
||||
|
||||
document.update("held!");
|
||||
assert_eq!(document.to_string(), "held!");
|
||||
assert_eq!(document.get_deltas().len(), 3);
|
||||
assert_eq!(document.get_deltas()[2].operations.len(), 2);
|
||||
assert_eq!(
|
||||
document.get_deltas()[2].operations[0],
|
||||
Operation::Delete((3, 7))
|
||||
);
|
||||
assert_eq!(
|
||||
document.get_deltas()[2].operations[1],
|
||||
Operation::Insert((4, "!".to_string())),
|
||||
);
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
use crate::deltas::{get_current_file_deltas, save_current_file_deltas, Delta, TextDocument};
|
||||
use crate::deltas::{read, write, Delta, TextDocument};
|
||||
use crate::projects;
|
||||
use crate::{events, sessions};
|
||||
use anyhow::{Context, Result};
|
||||
@ -142,7 +142,7 @@ fn register_file_change<R: tauri::Runtime>(
|
||||
})?;
|
||||
|
||||
// second, get non-flushed file deltas
|
||||
let deltas = get_current_file_deltas(project, relative_file_path).with_context(|| {
|
||||
let deltas = read(project, relative_file_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to get current file deltas for {}",
|
||||
relative_file_path.display()
|
||||
@ -163,7 +163,7 @@ fn register_file_change<R: tauri::Runtime>(
|
||||
|
||||
// if the file was modified, save the deltas
|
||||
let deltas = text_doc.get_deltas();
|
||||
save_current_file_deltas(project, relative_file_path, &deltas)?;
|
||||
write(project, relative_file_path, &deltas)?;
|
||||
return Ok(Some((session, deltas)));
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user