mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-29 12:33:49 +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-build",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
|
"tempfile",
|
||||||
"uuid 1.3.0",
|
"uuid 1.3.0",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"yrs",
|
"yrs",
|
||||||
|
@ -31,6 +31,7 @@ sentry-tauri = "0.1.0"
|
|||||||
sentry = "0.27"
|
sentry = "0.27"
|
||||||
walkdir = "2.3.2"
|
walkdir = "2.3.2"
|
||||||
anyhow = "1.0.69"
|
anyhow = "1.0.69"
|
||||||
|
tempfile = "3.3.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# 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::projects;
|
||||||
use crate::{events, sessions};
|
use crate::{events, sessions};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@ -142,7 +142,7 @@ fn register_file_change<R: tauri::Runtime>(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// second, get non-flushed file deltas
|
// 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!(
|
format!(
|
||||||
"Failed to get current file deltas for {}",
|
"Failed to get current file deltas for {}",
|
||||||
relative_file_path.display()
|
relative_file_path.display()
|
||||||
@ -163,7 +163,7 @@ fn register_file_change<R: tauri::Runtime>(
|
|||||||
|
|
||||||
// if the file was modified, save the deltas
|
// if the file was modified, save the deltas
|
||||||
let deltas = text_doc.get_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)));
|
return Ok(Some((session, deltas)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user