mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-28 13:26:16 +03:00
Merge branch 'master' into id-visual-design-updates
This commit is contained in:
commit
10cf93a8d6
23
.github/workflows/push.yaml
vendored
23
.github/workflows/push.yaml
vendored
@ -20,9 +20,30 @@ jobs:
|
||||
run_install: |
|
||||
args: ["--frozen-lockfile"]
|
||||
|
||||
- name: Lint and check frontend
|
||||
- name: Lint frontend
|
||||
run: |
|
||||
pnpm lint
|
||||
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7.x.x
|
||||
run_install: |
|
||||
args: ["--frozen-lockfile"]
|
||||
|
||||
- name: check frontend
|
||||
run: |
|
||||
pnpm check
|
||||
|
||||
test:
|
||||
|
@ -41,6 +41,7 @@
|
||||
"@tabler/icons-svelte": "^2.6.0",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"date-fns": "^2.29.3",
|
||||
"diff": "^5.1.0",
|
||||
"fluent-svelte": "^1.6.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"inter-ui": "^3.19.3",
|
||||
|
@ -36,6 +36,7 @@ specifiers:
|
||||
'@typescript-eslint/parser': ^5.45.0
|
||||
autoprefixer: ^10.4.7
|
||||
date-fns: ^2.29.3
|
||||
diff: ^5.1.0
|
||||
eslint: ^8.28.0
|
||||
eslint-config-prettier: ^8.5.0
|
||||
eslint-plugin-svelte3: ^4.0.0
|
||||
@ -88,6 +89,7 @@ dependencies:
|
||||
'@tabler/icons-svelte': 2.6.0_svelte@3.55.1
|
||||
'@tauri-apps/api': 1.2.0
|
||||
date-fns: 2.29.3
|
||||
diff: 5.1.0
|
||||
fluent-svelte: 1.6.0
|
||||
idb-keyval: 6.2.0
|
||||
inter-ui: 3.19.3
|
||||
@ -1369,6 +1371,11 @@ packages:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
dev: true
|
||||
|
||||
/diff/5.1.0:
|
||||
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
dev: false
|
||||
|
||||
/dir-glob/3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
14
src-tauri/Cargo.lock
generated
14
src-tauri/Cargo.lock
generated
@ -644,12 +644,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "difference"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.6"
|
||||
@ -1125,7 +1119,6 @@ name = "git-butler-tauri"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"difference",
|
||||
"filetime",
|
||||
"git2",
|
||||
"log",
|
||||
@ -1137,6 +1130,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"similar",
|
||||
"tantivy",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@ -3358,6 +3352,12 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "420acb44afdae038210c99e69aae24109f32f15500aa708e81d46c9f29d55fcf"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.10"
|
||||
|
@ -22,7 +22,6 @@ log = "0.4.17"
|
||||
notify = "5.1.0"
|
||||
serde_json = {version = "1.0.92", features = [ "std", "arbitrary_precision" ] }
|
||||
uuid = "1.3.0"
|
||||
difference = "2.0.0"
|
||||
git2 = { version = "0.16.1", features = ["vendored-openssl", "vendored-libgit2"] }
|
||||
filetime = "0.2.19"
|
||||
sha2 = "0.10.6"
|
||||
@ -36,6 +35,7 @@ md5 = "0.7.0"
|
||||
urlencoding = "2.1.2"
|
||||
thiserror = "1.0.38"
|
||||
tantivy = "0.19.2"
|
||||
similar = "2.2.1"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use difference::{Changeset, Difference};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@ -51,29 +51,63 @@ impl Operation {
|
||||
}
|
||||
}
|
||||
|
||||
// merges touching operations of the same type in to one operation
|
||||
// e.g. [Insert((0, "hello")), Insert((5, " world"))] -> [Insert((0, "hello world"))]
|
||||
// e.g. [Delete((0, 5)), Delete((5, 5))] -> [Delete((0, 10))]
|
||||
// e.g. [Insert((0, "hello")), Delete((0, 5))] -> [Insert((0, "hello")), Delete((0, 5))]
|
||||
fn merge_touching(ops: &Vec<Operation>) -> Vec<Operation> {
|
||||
let mut merged = vec![];
|
||||
|
||||
for op in ops {
|
||||
match (merged.last_mut(), op) {
|
||||
(Some(Operation::Insert((index, chunk))), Operation::Insert((index2, chunk2))) => {
|
||||
if *index + chunk.len() as u32 == *index2 {
|
||||
chunk.push_str(chunk2);
|
||||
} else {
|
||||
merged.push(op.clone());
|
||||
}
|
||||
}
|
||||
(Some(Operation::Delete((index, len))), Operation::Delete((index2, len2))) => {
|
||||
if *index == *index2 {
|
||||
*len += len2;
|
||||
} else {
|
||||
merged.push(op.clone());
|
||||
}
|
||||
}
|
||||
_ => merged.push(op.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
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 changeset = TextDiff::configure().diff_chars(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)));
|
||||
for change in changeset.iter_all_changes() {
|
||||
match change.tag() {
|
||||
ChangeTag::Delete => {
|
||||
deltas.push(Operation::Delete((
|
||||
offset,
|
||||
change.as_str().unwrap_or("").len() as u32,
|
||||
)));
|
||||
}
|
||||
Difference::Add(text) => {
|
||||
ChangeTag::Insert => {
|
||||
let text = change.as_str().unwrap_or("");
|
||||
deltas.push(Operation::Insert((offset, text.to_string())));
|
||||
offset += text.len() as u32;
|
||||
}
|
||||
Difference::Same(text) => {
|
||||
offset += text.len() as u32;
|
||||
ChangeTag::Equal => {
|
||||
offset += change.as_str().unwrap_or("").len() as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deltas;
|
||||
return merge_touching(&deltas);
|
||||
}
|
||||
|
@ -2,54 +2,54 @@ use crate::deltas::operations::{get_delta_operations, Operation};
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_end() {
|
||||
let initial_text = "hello world";
|
||||
let initial_text = "hello";
|
||||
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())));
|
||||
assert_eq!(operations[0], Operation::Insert((5, " world!".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_insert_middle() {
|
||||
let initial_text = "hello world";
|
||||
let initial_text = "helloworld";
|
||||
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())));
|
||||
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 initial_text = "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())));
|
||||
assert_eq!(operations[0], Operation::Insert((0, "hello ".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_end() {
|
||||
let initial_text = "hello world!";
|
||||
let final_text = "hello world";
|
||||
let final_text = "hello";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((11, 1)));
|
||||
assert_eq!(operations[0], Operation::Delete((5, 7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_middle() {
|
||||
let initial_text = "hello world";
|
||||
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)));
|
||||
assert_eq!(operations[0], Operation::Delete((5, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_delta_operations_delete_begin() {
|
||||
let initial_text = "hello world";
|
||||
let final_text = "ello world";
|
||||
let final_text = "world";
|
||||
let operations = get_delta_operations(initial_text, final_text);
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0], Operation::Delete((0, 1)));
|
||||
assert_eq!(operations[0], Operation::Delete((0, 6)));
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
use crate::{deltas, projects, sessions, storage};
|
||||
use anyhow::{Context, Result};
|
||||
use difference::Changeset;
|
||||
use serde::Serialize;
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
use std::ops::Range;
|
||||
use std::{
|
||||
fs,
|
||||
@ -73,7 +73,7 @@ impl Deltas {
|
||||
.open_or_create(mmap_dir)?;
|
||||
|
||||
let reader = index.reader()?;
|
||||
let writer = index.writer(WRITE_BUFFER_SIZE)?;
|
||||
let writer = index.writer_with_num_threads(1, WRITE_BUFFER_SIZE)?;
|
||||
|
||||
Ok(Self {
|
||||
meta_storage: MetaStorage::new(path),
|
||||
@ -121,10 +121,14 @@ impl Deltas {
|
||||
let session = sessions::Session::from_commit(repo, &commit).with_context(|| {
|
||||
format!("Could not parse commit {} in project", oid.to_string())
|
||||
})?;
|
||||
self.index_session(repo, project, &session)
|
||||
.with_context(|| {
|
||||
format!("Could not index commit {} in project", oid.to_string())
|
||||
})?;
|
||||
if let Err(e) = self.index_session(repo, project, &session) {
|
||||
log::error!(
|
||||
"Could not index commit {} in {}: {:#}",
|
||||
oid,
|
||||
project.path,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"Reindexing project {} done, took {}ms",
|
||||
@ -141,7 +145,7 @@ impl Deltas {
|
||||
session: &sessions::Session,
|
||||
) -> Result<()> {
|
||||
log::info!("Indexing session {} in {}", session.id, project.path);
|
||||
index(
|
||||
index_session(
|
||||
&self.index,
|
||||
&mut self.writer.lock().unwrap(),
|
||||
session,
|
||||
@ -179,7 +183,7 @@ pub struct SearchResult {
|
||||
pub index: u64,
|
||||
}
|
||||
|
||||
fn index(
|
||||
fn index_session(
|
||||
index: &tantivy::Index,
|
||||
writer: &mut IndexWriter,
|
||||
session: &sessions::Session,
|
||||
@ -208,68 +212,83 @@ fn index(
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.collect();
|
||||
let mut prev_file_text = file_text.clone();
|
||||
// for every deltas for the file
|
||||
for (i, delta) in deltas.into_iter().enumerate() {
|
||||
let mut doc = tantivy::Document::default();
|
||||
doc.add_u64(
|
||||
index.schema().get_field("version").unwrap(),
|
||||
CURRENT_VERSION.try_into()?,
|
||||
);
|
||||
doc.add_u64(index.schema().get_field("index").unwrap(), i.try_into()?);
|
||||
doc.add_text(
|
||||
index.schema().get_field("session_id").unwrap(),
|
||||
session.id.clone(),
|
||||
);
|
||||
doc.add_text(
|
||||
index.schema().get_field("file_path").unwrap(),
|
||||
file_path.as_str(),
|
||||
);
|
||||
doc.add_text(
|
||||
index.schema().get_field("project_id").unwrap(),
|
||||
project.id.clone(),
|
||||
);
|
||||
doc.add_u64(
|
||||
index.schema().get_field("timestamp_ms").unwrap(),
|
||||
delta.timestamp_ms.try_into()?,
|
||||
);
|
||||
|
||||
// for every operation in the delta
|
||||
for operation in &delta.operations {
|
||||
// don't forget to apply the operation to the file_text
|
||||
if let Err(e) = operation.apply(&mut file_text) {
|
||||
log::error!("failed to apply operation: {:#}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut changeset = Changeset::new(
|
||||
&prev_file_text.iter().collect::<String>(),
|
||||
&file_text.iter().collect::<String>(),
|
||||
" ",
|
||||
);
|
||||
|
||||
changeset.diffs = changeset
|
||||
.diffs
|
||||
.into_iter()
|
||||
.filter(|d| match d {
|
||||
difference::Difference::Add(_) => true,
|
||||
difference::Difference::Rem(_) => true,
|
||||
difference::Difference::Same(_) => false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
doc.add_text(index.schema().get_field("diff").unwrap(), changeset);
|
||||
|
||||
prev_file_text = file_text.clone();
|
||||
|
||||
writer.add_document(doc)?;
|
||||
index_delta(
|
||||
index,
|
||||
writer,
|
||||
session,
|
||||
project,
|
||||
&mut file_text,
|
||||
&file_path,
|
||||
i,
|
||||
&delta,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
writer.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn index_delta(
|
||||
index: &tantivy::Index,
|
||||
writer: &mut IndexWriter,
|
||||
session: &sessions::Session,
|
||||
project: &projects::Project,
|
||||
file_text: &mut Vec<char>,
|
||||
file_path: &str,
|
||||
i: usize,
|
||||
delta: &deltas::Delta,
|
||||
) -> Result<()> {
|
||||
let mut doc = tantivy::Document::default();
|
||||
doc.add_u64(
|
||||
index.schema().get_field("version").unwrap(),
|
||||
CURRENT_VERSION.try_into()?,
|
||||
);
|
||||
doc.add_u64(index.schema().get_field("index").unwrap(), i.try_into()?);
|
||||
doc.add_text(
|
||||
index.schema().get_field("session_id").unwrap(),
|
||||
session.id.clone(),
|
||||
);
|
||||
doc.add_text(index.schema().get_field("file_path").unwrap(), file_path);
|
||||
doc.add_text(
|
||||
index.schema().get_field("project_id").unwrap(),
|
||||
project.id.clone(),
|
||||
);
|
||||
doc.add_u64(
|
||||
index.schema().get_field("timestamp_ms").unwrap(),
|
||||
delta.timestamp_ms.try_into()?,
|
||||
);
|
||||
|
||||
let prev_file_text = file_text.clone();
|
||||
// for every operation in the delta
|
||||
for operation in &delta.operations {
|
||||
// don't forget to apply the operation to the file_text
|
||||
operation
|
||||
.apply(file_text)
|
||||
.with_context(|| format!("Could not apply operation to file {}", file_path))?;
|
||||
}
|
||||
|
||||
let old = &prev_file_text.iter().collect::<String>();
|
||||
let new = &file_text.iter().collect::<String>();
|
||||
|
||||
let all_changes = TextDiff::from_words(old, new);
|
||||
let changes = all_changes
|
||||
.iter_all_changes()
|
||||
.filter_map(|change| match change.tag() {
|
||||
ChangeTag::Delete => change.as_str(),
|
||||
ChangeTag::Insert => change.as_str(),
|
||||
ChangeTag::Equal => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
doc.add_text(index.schema().get_field("diff").unwrap(), changes);
|
||||
|
||||
writer.add_document(doc)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SearchQuery {
|
||||
pub q: String,
|
||||
|
@ -73,7 +73,7 @@ fn test_filter_by_timestamp() {
|
||||
|
||||
let search_result_to = searcher.search(&super::SearchQuery {
|
||||
project_id: project.id.clone(),
|
||||
q: "hello world".to_string(),
|
||||
q: "test.txt".to_string(),
|
||||
limit: 10,
|
||||
range: Range { start: 0, end: 1 },
|
||||
offset: None,
|
||||
@ -85,7 +85,7 @@ fn test_filter_by_timestamp() {
|
||||
|
||||
let search_result_from_to = searcher.search(&super::SearchQuery {
|
||||
project_id: project.id.clone(),
|
||||
q: "hello world".to_string(),
|
||||
q: "test.txt".to_string(),
|
||||
limit: 10,
|
||||
range: Range { start: 1, end: 2 },
|
||||
offset: None,
|
||||
|
@ -3,7 +3,7 @@ use crate::projects;
|
||||
use crate::{events, sessions};
|
||||
use anyhow::{Context, Result};
|
||||
use git2;
|
||||
use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@ -13,6 +13,24 @@ pub struct DeltaWatchers {
|
||||
watchers: HashMap<String, RecommendedWatcher>,
|
||||
}
|
||||
|
||||
fn is_interesting_event(kind: ¬ify::EventKind) -> Option<String> {
|
||||
match kind {
|
||||
notify::EventKind::Create(notify::event::CreateKind::File) => {
|
||||
Some("file created".to_string())
|
||||
}
|
||||
notify::EventKind::Modify(notify::event::ModifyKind::Data(_)) => {
|
||||
Some("file modified".to_string())
|
||||
}
|
||||
notify::EventKind::Modify(notify::event::ModifyKind::Name(_)) => {
|
||||
Some("file renamed".to_string())
|
||||
}
|
||||
notify::EventKind::Remove(notify::event::RemoveKind::File) => {
|
||||
Some("file removed".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl DeltaWatchers {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@ -35,26 +53,31 @@ impl DeltaWatchers {
|
||||
|
||||
self.watchers.insert(project.path.clone(), watcher);
|
||||
|
||||
let repo = git2::Repository::open(project_path)?;
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
while let Ok(event) = rx.recv() {
|
||||
match event {
|
||||
Ok(notify_event) => {
|
||||
for file_path in notify_event.paths {
|
||||
let relative_file_path =
|
||||
file_path.strip_prefix(repo.workdir().unwrap()).unwrap();
|
||||
file_path.strip_prefix(project.path.clone()).unwrap();
|
||||
let repo = git2::Repository::open(&project.path).expect(
|
||||
format!("failed to open repo at {}", project.path).as_str(),
|
||||
);
|
||||
|
||||
match notify_event.kind {
|
||||
EventKind::Modify(_) => {
|
||||
log::info!("File modified: {}", file_path.display());
|
||||
}
|
||||
EventKind::Create(_) => {
|
||||
log::info!("File created: {}", file_path.display());
|
||||
}
|
||||
EventKind::Remove(_) => {
|
||||
log::info!("File removed: {}", file_path.display());
|
||||
}
|
||||
_ => {}
|
||||
if repo.is_path_ignored(&relative_file_path).unwrap_or(true) {
|
||||
// make sure we're not watching ignored files
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(kind_string) = is_interesting_event(¬ify_event.kind) {
|
||||
log::info!(
|
||||
"{}: \"{}\" {}",
|
||||
project.id,
|
||||
relative_file_path.display(),
|
||||
kind_string
|
||||
);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
match register_file_change(&project, &repo, &relative_file_path) {
|
||||
@ -99,16 +122,11 @@ impl DeltaWatchers {
|
||||
// it should figure out delta data (crdt) and update the file at .git/gb/session/deltas/path/to/file
|
||||
// it also writes the metadata stuff which marks the beginning of a session if a session is not yet started
|
||||
// returns updated project deltas and sessions to which they belong
|
||||
fn register_file_change(
|
||||
pub(crate) fn register_file_change(
|
||||
project: &projects::Project,
|
||||
repo: &git2::Repository,
|
||||
relative_file_path: &Path,
|
||||
) -> Result<Option<(sessions::Session, Vec<Delta>)>, Box<dyn std::error::Error>> {
|
||||
if repo.is_path_ignored(&relative_file_path).unwrap_or(true) {
|
||||
// make sure we're not watching ignored files
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
) -> Result<Option<(sessions::Session, Vec<Delta>)>> {
|
||||
let file_path = repo.workdir().unwrap().join(relative_file_path);
|
||||
let file_contents = match fs::read_to_string(&file_path) {
|
||||
Ok(contents) => contents,
|
||||
@ -126,7 +144,7 @@ fn register_file_change(
|
||||
};
|
||||
|
||||
// first, we need to check if the file exists in the meta commit
|
||||
let latest_contents = get_latest_file_contents(repo, project, relative_file_path)
|
||||
let latest_contents = get_latest_file_contents(&repo, project, relative_file_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to get latest file contents for {}",
|
||||
@ -155,7 +173,7 @@ fn register_file_change(
|
||||
} else {
|
||||
// if the file was modified, save the deltas
|
||||
let deltas = text_doc.get_deltas();
|
||||
let session = write(repo, project, relative_file_path, &deltas)?;
|
||||
let session = write(&repo, project, relative_file_path, &deltas)?;
|
||||
Ok(Some((session, deltas)))
|
||||
}
|
||||
}
|
||||
|
65
src-tauri/src/watchers/delta_test.rs
Normal file
65
src-tauri/src/watchers/delta_test.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use crate::projects;
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_project() -> Result<(git2::Repository, projects::Project)> {
|
||||
let path = tempdir()?.path().to_str().unwrap().to_string();
|
||||
std::fs::create_dir_all(&path)?;
|
||||
let repo = git2::Repository::init(&path)?;
|
||||
let mut index = repo.index()?;
|
||||
let oid = index.write_tree()?;
|
||||
let sig = git2::Signature::now("test", "test@email.com").unwrap();
|
||||
let _commit = repo.commit(
|
||||
Some("HEAD"),
|
||||
&sig,
|
||||
&sig,
|
||||
"initial commit",
|
||||
&repo.find_tree(oid)?,
|
||||
&[],
|
||||
)?;
|
||||
let project = projects::Project::from_path(path)?;
|
||||
Ok((repo, project))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_file_change_must_create_session() {
|
||||
let (repo, project) = test_project().unwrap();
|
||||
|
||||
let relative_file_path = Path::new("test.txt");
|
||||
std::fs::write(Path::new(&project.path).join(relative_file_path), "test").unwrap();
|
||||
|
||||
let result = super::delta::register_file_change(&project, &repo, &relative_file_path);
|
||||
assert!(result.is_ok());
|
||||
let maybe_session_deltas = result.unwrap();
|
||||
assert!(maybe_session_deltas.is_some());
|
||||
let (session, deltas) = maybe_session_deltas.unwrap();
|
||||
assert_eq!(deltas.len(), 1);
|
||||
assert_eq!(session.hash, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_register_file_change_must_not_change_session() {
|
||||
let (repo, project) = test_project().unwrap();
|
||||
|
||||
let relative_file_path = Path::new("test.txt");
|
||||
std::fs::write(Path::new(&project.path).join(relative_file_path), "test").unwrap();
|
||||
|
||||
let result = super::delta::register_file_change(&project, &repo, &relative_file_path);
|
||||
assert!(result.is_ok());
|
||||
let maybe_session_deltas = result.unwrap();
|
||||
assert!(maybe_session_deltas.is_some());
|
||||
let (session1, deltas1) = maybe_session_deltas.unwrap();
|
||||
assert_eq!(deltas1.len(), 1);
|
||||
|
||||
std::fs::write(Path::new(&project.path).join(relative_file_path), "test2").unwrap();
|
||||
|
||||
let result = super::delta::register_file_change(&project, &repo, &relative_file_path);
|
||||
assert!(result.is_ok());
|
||||
let maybe_session_deltas = result.unwrap();
|
||||
assert!(maybe_session_deltas.is_some());
|
||||
let (session2, deltas2) = maybe_session_deltas.unwrap();
|
||||
assert_eq!(deltas2.len(), 2);
|
||||
assert_eq!(deltas2[0], deltas1[0]);
|
||||
assert_eq!(session1.id, session2.id);
|
||||
}
|
@ -1,6 +1,11 @@
|
||||
mod delta;
|
||||
mod session;
|
||||
|
||||
#[cfg(test)]
|
||||
mod delta_test;
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
use crate::{events, projects, search, users};
|
||||
use anyhow::Result;
|
||||
use std::sync::mpsc;
|
||||
@ -16,7 +21,8 @@ impl Watcher {
|
||||
users_storage: users::Storage,
|
||||
deltas_searcher: search::Deltas,
|
||||
) -> Self {
|
||||
let session_watcher = session::SessionWatcher::new(projects_storage, users_storage, deltas_searcher);
|
||||
let session_watcher =
|
||||
session::SessionWatcher::new(projects_storage, users_storage, deltas_searcher);
|
||||
let delta_watcher = delta::DeltaWatchers::new();
|
||||
Self {
|
||||
session_watcher,
|
||||
|
60
src-tauri/src/watchers/test.rs
Normal file
60
src-tauri/src/watchers/test.rs
Normal file
@ -0,0 +1,60 @@
|
||||
use crate::projects;
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_project() -> Result<(git2::Repository, projects::Project)> {
|
||||
let path = tempdir()?.path().to_str().unwrap().to_string();
|
||||
std::fs::create_dir_all(&path)?;
|
||||
let repo = git2::Repository::init(&path)?;
|
||||
let mut index = repo.index()?;
|
||||
let oid = index.write_tree()?;
|
||||
let sig = git2::Signature::now("test", "test@email.com").unwrap();
|
||||
let _commit = repo.commit(
|
||||
Some("HEAD"),
|
||||
&sig,
|
||||
&sig,
|
||||
"initial commit",
|
||||
&repo.find_tree(oid)?,
|
||||
&[],
|
||||
)?;
|
||||
let project = projects::Project::from_path(path)?;
|
||||
Ok((repo, project))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flush_session() {
|
||||
let (repo, project) = test_project().unwrap();
|
||||
|
||||
let relative_file_path = Path::new("test.txt");
|
||||
std::fs::write(Path::new(&project.path).join(relative_file_path), "hello").unwrap();
|
||||
|
||||
let result = super::delta::register_file_change(&project, &repo, &relative_file_path);
|
||||
assert!(result.is_ok());
|
||||
let maybe_session_deltas = result.unwrap();
|
||||
assert!(maybe_session_deltas.is_some());
|
||||
let (mut session1, deltas1) = maybe_session_deltas.unwrap();
|
||||
assert_eq!(session1.hash, None);
|
||||
assert_eq!(deltas1.len(), 1);
|
||||
|
||||
session1.flush(&repo, &None, &project).unwrap();
|
||||
assert!(session1.hash.is_some());
|
||||
|
||||
std::fs::write(
|
||||
Path::new(&project.path).join(relative_file_path),
|
||||
"hello world",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = super::delta::register_file_change(&project, &repo, &relative_file_path);
|
||||
assert!(result.is_ok());
|
||||
let maybe_session_deltas = result.unwrap();
|
||||
assert!(maybe_session_deltas.is_some());
|
||||
let (mut session2, deltas2) = maybe_session_deltas.unwrap();
|
||||
assert_eq!(session2.hash, None);
|
||||
assert_eq!(deltas2.len(), 1);
|
||||
assert_ne!(session1.id, session2.id);
|
||||
|
||||
session2.flush(&repo, &None, &project).unwrap();
|
||||
assert!(session2.hash.is_some());
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en" class="dark" style="color-scheme: dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
@ -1,5 +1,4 @@
|
||||
<script>
|
||||
import { IconArrowBigLeftFilled, IconArrowBigRightFilled } from '@tabler/icons-svelte';
|
||||
let history = window.history;
|
||||
</script>
|
||||
|
||||
|
@ -5,6 +5,13 @@
|
||||
export let doc: string;
|
||||
export let deltas: Delta[];
|
||||
export let filepath: string;
|
||||
export let highlightLatest = false;
|
||||
</script>
|
||||
|
||||
<code class="h-full w-full" use:codeviewer={{ doc, deltas, filepath }} />
|
||||
{#key doc + filepath}
|
||||
<code
|
||||
style:color-scheme="dark"
|
||||
class="h-full w-full"
|
||||
use:codeviewer={{ doc, deltas, filepath, highlightLatest }}
|
||||
/>
|
||||
{/key}
|
||||
|
@ -28,7 +28,7 @@ const toChangeSpec = (operation: Operation): ChangeSpec => {
|
||||
}
|
||||
};
|
||||
|
||||
type Params = { doc: string; deltas: Delta[]; filepath: string };
|
||||
type Params = { doc: string; deltas: Delta[]; filepath: string; highlightLatest: boolean };
|
||||
|
||||
const makeBaseState = (doc: string, filepath: string) => {
|
||||
const language = getLanguage(filepath);
|
||||
@ -74,14 +74,23 @@ const toSelection = (changes: ChangeSet, delta: Delta | undefined): EditorSelect
|
||||
// this action assumes:
|
||||
// * that deltas list is append only.
|
||||
// * that each (filepath, doc) pair never changes.
|
||||
export default (parent: HTMLElement, { doc, deltas, filepath }: Params) => {
|
||||
export default (parent: HTMLElement, { doc, deltas, filepath, highlightLatest }: Params) => {
|
||||
const view = new EditorView({ state: makeBaseState(doc, filepath), parent });
|
||||
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
let transactionSpec;
|
||||
if (highlightLatest) {
|
||||
const selection = toSelection(toChangeSet(deltas, doc.length), deltas[deltas.length - 1]);
|
||||
transactionSpec = {
|
||||
changes: toChangeSet(deltas, doc.length),
|
||||
selection: selection,
|
||||
effects: markChanges(selection)
|
||||
};
|
||||
} else {
|
||||
transactionSpec = {
|
||||
changes: toChangeSet(deltas, doc.length)
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
view.dispatch(view.state.update(transactionSpec));
|
||||
|
||||
let currentFilepath = filepath;
|
||||
const stateCache: Record<string, EditorState> = {};
|
||||
@ -116,7 +125,7 @@ export default (parent: HTMLElement, { doc, deltas, filepath }: Params) => {
|
||||
scrollIntoView: true,
|
||||
effects: markChanges(selection)
|
||||
});
|
||||
} else {
|
||||
} else if (currentDeltas.length < newDeltas.length) {
|
||||
// rewind forward
|
||||
|
||||
// verify that deltas are append only
|
||||
|
@ -11,7 +11,7 @@ export const colorTheme = (theme: Theme, options?: { dark: boolean }) =>
|
||||
backgroundColor: theme.bg0
|
||||
},
|
||||
'.cm-gutters': {
|
||||
color: theme.fg0,
|
||||
color: theme.gray,
|
||||
backgroundColor: theme.bg0,
|
||||
border: 'none'
|
||||
},
|
||||
@ -22,6 +22,7 @@ export const colorTheme = (theme: Theme, options?: { dark: boolean }) =>
|
||||
|
||||
export const highlightStyle = (theme: Theme) =>
|
||||
HighlightStyle.define([
|
||||
{ tag: t.tagName, color: theme.orange },
|
||||
{ tag: t.keyword, color: theme.red },
|
||||
{ tag: [t.propertyName, t.name, t.deleted, t.character, t.macroName], color: theme.blue },
|
||||
{ tag: [t.function(t.variableName), t.labelName], color: theme.green, fontWeight: 'bold' },
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let showPopover: boolean = false;
|
||||
let showPopover = false;
|
||||
let anchor: HTMLButtonElement | undefined = undefined;
|
||||
let bottom: number;
|
||||
let left: number;
|
||||
|
@ -18,7 +18,7 @@ export namespace Operation {
|
||||
|
||||
export type Delta = { timestampMs: number; operations: Operation[] };
|
||||
|
||||
type DeltasEvent = {
|
||||
export type DeltasEvent = {
|
||||
deltas: Delta[];
|
||||
filePath: string;
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { appWindow } from '@tauri-apps/api/window';
|
||||
import { writable } from 'svelte/store';
|
||||
import { writable, type Readable } from 'svelte/store';
|
||||
import { log } from '$lib';
|
||||
|
||||
export type Activity = {
|
||||
@ -9,6 +9,14 @@ export type Activity = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export namespace Session {
|
||||
export const within = (session: Session | undefined, timestampMs: number) => {
|
||||
if (!session) return false;
|
||||
const { startTimestampMs, lastTimestampMs } = session.meta;
|
||||
return startTimestampMs <= timestampMs && timestampMs <= lastTimestampMs;
|
||||
};
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
id: string;
|
||||
hash?: string;
|
||||
@ -27,23 +35,21 @@ export const listFiles = (params: { projectId: string; sessionId: string; paths?
|
||||
const list = (params: { projectId: string }) => invoke<Session[]>('list_sessions', params);
|
||||
|
||||
export default async (params: { projectId: string }) => {
|
||||
const init = await list(params);
|
||||
const store = writable(init);
|
||||
const eventName = `project://${params.projectId}/sessions`;
|
||||
const sessions = await list(params);
|
||||
const store = writable(sessions);
|
||||
|
||||
await appWindow.listen<Session>(eventName, (event) => {
|
||||
log.info(`Received sessions event ${eventName}`);
|
||||
appWindow.listen<Session>(`project://${params.projectId}/sessions`, async (event) => {
|
||||
log.info(`Received sessions event, projectId: ${params.projectId}`);
|
||||
const session = event.payload;
|
||||
store.update((sessions) => {
|
||||
const index = sessions.findIndex((session) => session.id === event.payload.id);
|
||||
if (index === -1) {
|
||||
return [...sessions, event.payload];
|
||||
return [...sessions, session];
|
||||
} else {
|
||||
return [...sessions.slice(0, index), event.payload, ...sessions.slice(index + 1)];
|
||||
return [...sessions.slice(0, index), session, ...sessions.slice(index + 1)];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe
|
||||
};
|
||||
return store as Readable<Session[]>;
|
||||
};
|
||||
|
47
src/lib/slider.ts
Normal file
47
src/lib/slider.ts
Normal file
@ -0,0 +1,47 @@
|
||||
export default (node: HTMLElement) => {
|
||||
const onDown = getOnDown(node);
|
||||
|
||||
node.addEventListener('touchstart', onDown);
|
||||
node.addEventListener('mousedown', onDown);
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('touchstart', onDown);
|
||||
node.removeEventListener('mousedown', onDown);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getOnDown = (node: HTMLElement) => {
|
||||
const onMove = getOnMove(node);
|
||||
|
||||
return (e: Event) => {
|
||||
e.preventDefault();
|
||||
node.dispatchEvent(new CustomEvent('dragstart'));
|
||||
|
||||
const moveevent = 'touches' in e ? 'touchmove' : 'mousemove';
|
||||
const upevent = 'touches' in e ? 'touchend' : 'mouseup';
|
||||
|
||||
const onUp = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
||||
document.removeEventListener(moveevent, onMove);
|
||||
document.removeEventListener(upevent, onUp);
|
||||
|
||||
node.dispatchEvent(new CustomEvent('dragend'));
|
||||
};
|
||||
|
||||
document.addEventListener(moveevent, onMove);
|
||||
document.addEventListener(upevent, onUp);
|
||||
};
|
||||
};
|
||||
|
||||
const getOnMove = (node: HTMLElement) => {
|
||||
const track = node.parentNode as HTMLElement;
|
||||
|
||||
return (e: TouchEvent | MouseEvent) => {
|
||||
const { left, width } = track.getBoundingClientRect();
|
||||
const clickOffset = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clickPos = Math.min(Math.max((clickOffset - left) / width, 0), 1) || 0;
|
||||
node.dispatchEvent(new CustomEvent('drag', { detail: clickPos }));
|
||||
};
|
||||
};
|
@ -203,12 +203,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full">
|
||||
<div class="h-18 flex flex-shrink-0 select-none items-center border-t border-zinc-700 p-4">
|
||||
<div class="text-sm text-zinc-300">Timeline</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -33,7 +33,47 @@
|
||||
<nav
|
||||
class="flex flex-none select-none items-center justify-between space-x-3 border-b border-zinc-700 py-1 px-8 text-zinc-300"
|
||||
>
|
||||
<div />
|
||||
<div class="flex flex-row items-center space-x-2">
|
||||
<form action="/projects/{$project?.id}/search" method="GET">
|
||||
<div class="flex w-48 max-w-lg rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
id="search"
|
||||
placeholder="search"
|
||||
class="block w-full pl-3 min-w-0 flex-1 rounded-none bg-zinc-900 border-r-0 rounded-l-md border-0 py-1.5 text-zinc-200 ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-1 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<span
|
||||
class="inline-flex items-center rounded-r-md bg-zinc-900 border border-l-0 border-zinc-700 px-3 text-gray-500 sm:text-sm"
|
||||
>⌘K</span
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<a href="/projects/{$project?.id}/player" class="text-zinc-400 hover:text-zinc-200">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/projects/{$project?.id}/timeline" class="text-orange-400 hover:text-zinc-200"
|
||||
>Timeline</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
|
@ -1,9 +1,79 @@
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { building } from '$app/environment';
|
||||
import { readable, derived } from 'svelte/store';
|
||||
import type { Session } from '$lib/sessions';
|
||||
import type { UISession } from '$lib/uisessions';
|
||||
import { asyncDerived } from '@square/svelte-store';
|
||||
import type { Delta } from '$lib/deltas';
|
||||
import { startOfDay } from 'date-fns';
|
||||
|
||||
export const prerender = false;
|
||||
export const load: LayoutLoad = async ({ parent, params }) => {
|
||||
const { projects } = await parent();
|
||||
|
||||
const sessions = building
|
||||
? readable<Session[]>([])
|
||||
: await (await import('$lib/sessions')).default({ projectId: params.projectId });
|
||||
const orderedSessions = derived(sessions, (sessions) => {
|
||||
return sessions.slice().sort((a, b) => a.meta.startTimestampMs - b.meta.startTimestampMs);
|
||||
});
|
||||
|
||||
let dateSessions = readable<Record<number, UISession[]>>({});
|
||||
if (!building) {
|
||||
const listDeltas = (await import('$lib/deltas')).list;
|
||||
dateSessions = asyncDerived([orderedSessions], async ([sessions]) => {
|
||||
const deltas = await Promise.all(
|
||||
sessions.map((session) => {
|
||||
return listDeltas({
|
||||
projectId: params.projectId ?? '',
|
||||
sessionId: session.id
|
||||
});
|
||||
})
|
||||
);
|
||||
// Sort deltas by timestamp
|
||||
deltas.forEach((delta) => {
|
||||
Object.keys(delta).forEach((key) => {
|
||||
delta[key].sort((a, b) => a.timestampMs - b.timestampMs).reverse();
|
||||
});
|
||||
});
|
||||
|
||||
const uiSessions = sessions
|
||||
.map((session, i) => {
|
||||
return { session, deltas: deltas[i] } as UISession;
|
||||
})
|
||||
.filter((uiSession) => {
|
||||
return Object.keys(uiSession.deltas).length > 0;
|
||||
});
|
||||
|
||||
const dateSessions: Record<number, UISession[]> = {};
|
||||
uiSessions.forEach((uiSession) => {
|
||||
const date = startOfDay(new Date(uiSession.session.meta.startTimestampMs));
|
||||
if (dateSessions[date.getTime()]) {
|
||||
dateSessions[date.getTime()]?.push(uiSession);
|
||||
} else {
|
||||
dateSessions[date.getTime()] = [uiSession];
|
||||
}
|
||||
})
|
||||
|
||||
// For each UISession in dateSessions, set the earliestDeltaTimestampMs and latestDeltaTimestampMs
|
||||
Object.keys(dateSessions).forEach((date: any) => {
|
||||
dateSessions[date].forEach((uiSession: any) => {
|
||||
const deltaTimestamps = Object.keys(uiSession.deltas).reduce((acc, key) => {
|
||||
return acc.concat(uiSession.deltas[key].map((delta: Delta) => delta.timestampMs));
|
||||
}, []);
|
||||
uiSession.earliestDeltaTimestampMs = Math.min(...deltaTimestamps);
|
||||
uiSession.latestDeltaTimestampMs = Math.max(...deltaTimestamps);
|
||||
});
|
||||
});
|
||||
|
||||
return dateSessions;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
project: projects.get(params.projectId)
|
||||
project: projects.get(params.projectId),
|
||||
projectId: params.projectId,
|
||||
sessions: orderedSessions,
|
||||
dateSessions: dateSessions
|
||||
};
|
||||
};
|
||||
|
@ -3,18 +3,128 @@
|
||||
|
||||
export let data: LayoutData;
|
||||
$: project = data.project;
|
||||
$: dateSessions = data.dateSessions;
|
||||
|
||||
// convert a list of timestamps to a sparkline
|
||||
function timestampsToSpark(tsArray) {
|
||||
let range = tsArray[0] - tsArray[tsArray.length - 1];
|
||||
console.log(range);
|
||||
|
||||
let totalBuckets = 18;
|
||||
let bucketSize = range / totalBuckets;
|
||||
let buckets = [];
|
||||
for (let i = 0; i <= totalBuckets; i++) {
|
||||
buckets.push([]);
|
||||
}
|
||||
tsArray.forEach((ts) => {
|
||||
let bucket = Math.floor((tsArray[0] - ts) / bucketSize);
|
||||
if (bucket && ts) {
|
||||
buckets[bucket].push(ts);
|
||||
}
|
||||
});
|
||||
console.log(buckets);
|
||||
|
||||
let spark = '';
|
||||
buckets.forEach((entries) => {
|
||||
let size = entries.length;
|
||||
if (size < 1) {
|
||||
spark += '<span class="text-zinc-600">▁</span>';
|
||||
} else if (size < 2) {
|
||||
spark += '<span class="text-blue-200">▂</span>';
|
||||
} else if (size < 3) {
|
||||
spark += '<span class="text-blue-200">▃</span>';
|
||||
} else if (size < 4) {
|
||||
spark += '<span class="text-blue-200">▄</span>';
|
||||
} else if (size < 5) {
|
||||
spark += '<span class="text-blue-200">▅</span>';
|
||||
} else if (size < 6) {
|
||||
spark += '<span class="text-blue-200">▆</span>';
|
||||
} else if (size < 7) {
|
||||
spark += '<span class="text-blue-200">▇</span>';
|
||||
} else {
|
||||
spark += '<span class="text-blue-200">█</span>';
|
||||
}
|
||||
});
|
||||
return spark;
|
||||
}
|
||||
|
||||
// reduce a group of sessions to a map of filename to timestamps array
|
||||
function sessionFileMap(sessions: any[]) {
|
||||
let sessionsByFile = {};
|
||||
sessions.forEach((session) => {
|
||||
Object.entries(session.deltas).forEach((deltas) => {
|
||||
let filename = deltas[0];
|
||||
let timestamps = deltas[1].map((delta: any) => {
|
||||
return delta.timestampMs;
|
||||
});
|
||||
if (sessionsByFile[filename]) {
|
||||
sessionsByFile[filename] = sessionsByFile[filename].concat(timestamps).sort();
|
||||
} else {
|
||||
sessionsByFile[filename] = timestamps;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return sessionsByFile;
|
||||
}
|
||||
|
||||
// order the sessions and summarize the changes by file
|
||||
function orderedSessions(dateSessions: Record<string, any>) {
|
||||
return Object.entries(dateSessions)
|
||||
.sort((a, b) => {
|
||||
return parseInt(b[0]) - parseInt(a[0]);
|
||||
})
|
||||
.map(([date, sessions]) => {
|
||||
return [date, sessionFileMap(sessions)];
|
||||
})
|
||||
.slice(0, 3);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-12 flex flex-col">
|
||||
<h1 class="flex justify-center text-xl text-zinc-200">
|
||||
Overview of {$project?.title}
|
||||
<div class="mt-4 px-8 flex flex-col">
|
||||
<h1 class="flex text-xl text-zinc-200">
|
||||
{$project?.title} <span class="text-zinc-600 ml-2">Project</span>
|
||||
</h1>
|
||||
<div class="flex justify-center space-x-2 text-lg">
|
||||
<a href="/projects/{$project?.id}/timeline" class="text-orange-400 hover:text-zinc-200"
|
||||
>Timeline</a
|
||||
>
|
||||
<a href="/projects/{$project?.id}/search" class="text-orange-400 hover:text-zinc-200"
|
||||
>search (test)</a
|
||||
>
|
||||
<div class="grid grid-cols-3 mt-4">
|
||||
<div class="col-span-2 pr-6">
|
||||
<h2 class="text-lg font-bold text-zinc-500 mb-4">Recent File Changes</h2>
|
||||
{#if $dateSessions === undefined}
|
||||
<span>Loading...</span>
|
||||
{:else}
|
||||
<div class="flex flex-col space-y-4">
|
||||
{#each orderedSessions($dateSessions) as [dateMilliseconds, fileSessions]}
|
||||
<div class="flex flex-col">
|
||||
<div class="text-zinc-400 text-lg text-zinc-200 mb-1">
|
||||
{new Date(parseInt(dateMilliseconds)).toLocaleDateString('en-us', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
<div class="bg-zinc-700 rounded p-4">
|
||||
{#each Object.entries(fileSessions) as filetime}
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="text-zinc-200 font-mono">{filetime[0]}</div>
|
||||
<div class="text-zinc-400">{@html timestampsToSpark(filetime[1])}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-span-1 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-zinc-500">Work in Progress</h2>
|
||||
<div class="text-zinc-400 mt-4 mb-1 bg-zinc-700 rounded p-4">No uncommitted work</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-zinc-500">Recent Activity</h2>
|
||||
<div class="text-zinc-400 mt-4 mb-1 bg-zinc-700 rounded p-4">No recent activity</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
202
src/routes/projects/[projectId]/player/+page.svelte
Normal file
202
src/routes/projects/[projectId]/player/+page.svelte
Normal file
@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { listFiles, Session } from '$lib/sessions';
|
||||
import { type Delta, list as listDeltas } from '$lib/deltas';
|
||||
import { CodeViewer } from '$lib/components';
|
||||
import { IconPlayerPauseFilled, IconPlayerPlayFilled } from '@tabler/icons-svelte';
|
||||
import slider from '$lib/slider';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const { sessions } = data;
|
||||
|
||||
let currentTimestamp = new Date().getTime();
|
||||
|
||||
let currentSessionIndex = 0;
|
||||
|
||||
$: if (!Session.within($sessions.at(currentTimestamp), currentTimestamp)) {
|
||||
currentSessionIndex = $sessions.findIndex(
|
||||
(session) => session.meta.startTimestampMs <= currentTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
let currentSessionFileByFilepath = {} as Record<string, string>;
|
||||
$: {
|
||||
listFiles({ projectId: data.projectId, sessionId: $sessions.at(currentSessionIndex)!.id }).then(
|
||||
(r) => (currentSessionFileByFilepath = r)
|
||||
);
|
||||
}
|
||||
|
||||
let currentSessionDeltasByFilepath = {} as Record<string, Delta[]>;
|
||||
$: {
|
||||
listDeltas({
|
||||
projectId: data.projectId,
|
||||
sessionId: $sessions.at(currentSessionIndex)!.id
|
||||
}).then((r) => (currentSessionDeltasByFilepath = r));
|
||||
}
|
||||
|
||||
$: currentFilepath =
|
||||
Object.entries(currentSessionDeltasByFilepath)
|
||||
.map(
|
||||
([filepath, deltas]) =>
|
||||
[filepath, deltas.filter((delta) => delta.timestampMs <= currentTimestamp)] as [
|
||||
string,
|
||||
Delta[]
|
||||
]
|
||||
)
|
||||
.filter(([_, deltas]) => deltas.length > 0)
|
||||
.map(([filepath, deltas]) => [filepath, deltas.at(-1)!.timestampMs] as [string, number])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.at(0)?.[0] ?? null;
|
||||
|
||||
$: currentDeltas = currentFilepath
|
||||
? (currentSessionDeltasByFilepath[currentFilepath] ?? []).filter(
|
||||
(delta) => delta.timestampMs <= currentTimestamp
|
||||
)
|
||||
: null;
|
||||
|
||||
$: currentDoc = currentFilepath ? currentSessionFileByFilepath[currentFilepath] ?? '' : null;
|
||||
|
||||
// player
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
let direction: -1 | 1 = 1;
|
||||
let speed = 1;
|
||||
let oneSecond = 1000;
|
||||
|
||||
const stop = () => {
|
||||
clearInterval(interval);
|
||||
interval = undefined;
|
||||
speed = 1;
|
||||
};
|
||||
const play = () => start({ direction, speed });
|
||||
|
||||
const start = (params: { direction: 1 | -1; speed: number }) => {
|
||||
if (interval) clearInterval(interval);
|
||||
interval = setInterval(() => {
|
||||
currentTimestamp += oneSecond * params.direction;
|
||||
}, oneSecond / params.speed);
|
||||
};
|
||||
|
||||
const speedUp = () => {
|
||||
speed = speed * 2;
|
||||
start({ direction, speed });
|
||||
};
|
||||
|
||||
// timeline
|
||||
$: sessionRanges = $sessions.map(
|
||||
({ meta }) => [meta.startTimestampMs, meta.lastTimestampMs] as [number, number]
|
||||
);
|
||||
|
||||
$: minVisibleTimestamp = currentTimestamp - 12 * 60 * 60 * 1000;
|
||||
let maxVisibleTimestamp = new Date().getTime();
|
||||
onMount(() => {
|
||||
const inverval = setInterval(() => {
|
||||
maxVisibleTimestamp = new Date().getTime();
|
||||
}, 1000);
|
||||
return () => clearInterval(inverval);
|
||||
});
|
||||
|
||||
$: visibleRanges = sessionRanges
|
||||
.filter(([from, to]) => from >= minVisibleTimestamp || to >= minVisibleTimestamp)
|
||||
.map(([from, to]) => [Math.max(from, minVisibleTimestamp), Math.min(to, maxVisibleTimestamp)])
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.reduce((timeline, range) => {
|
||||
const [from, to] = range;
|
||||
const last = timeline.at(-1);
|
||||
if (last) timeline.push([last[1], from, false]);
|
||||
timeline.push([from, to, true]);
|
||||
return timeline;
|
||||
}, [] as [number, number, boolean][]);
|
||||
|
||||
const rangeWidth = (range: [number, number]) =>
|
||||
(100 * (range[1] - range[0])) / (maxVisibleTimestamp - minVisibleTimestamp) + '%';
|
||||
|
||||
const timestampToOffset = (timestamp: number) =>
|
||||
((timestamp - minVisibleTimestamp) / (maxVisibleTimestamp - minVisibleTimestamp)) * 100 + '%';
|
||||
|
||||
const offsetToTimestamp = (offset: number) =>
|
||||
offset * (maxVisibleTimestamp - minVisibleTimestamp) + minVisibleTimestamp;
|
||||
|
||||
let timeline: HTMLElement;
|
||||
|
||||
const onSelectTimestamp = (e: MouseEvent) => {
|
||||
const { left, width } = timeline.getBoundingClientRect();
|
||||
const clickOffset = e.clientX;
|
||||
const clickPos = Math.min(Math.max((clickOffset - left) / width, 0), 1) || 0;
|
||||
currentTimestamp = offsetToTimestamp(clickPos);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-2 px-4">
|
||||
<div>
|
||||
<div>current session id {$sessions.at(currentSessionIndex)?.id}</div>
|
||||
<div>current session hash {$sessions.at(currentSessionIndex)?.hash}</div>
|
||||
<div>current filepath {currentFilepath}</div>
|
||||
<div>current deltas.length {currentDeltas?.length}</div>
|
||||
<div>current doc.length {currentDoc?.length}</div>
|
||||
<div>current timestamp {new Date(currentTimestamp)}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-auto overflow-auto">
|
||||
{#if currentDoc !== null && currentDeltas !== null && currentFilepath !== null}
|
||||
<CodeViewer doc={currentDoc} filepath={currentFilepath} deltas={currentDeltas} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div id="timeline" class="relative w-full py-4" bind:this={timeline}>
|
||||
<div
|
||||
id="cursor"
|
||||
use:slider
|
||||
on:drag={({ detail: v }) => (currentTimestamp = offsetToTimestamp(v))}
|
||||
class="absolute flex h-12 w-4 cursor-pointer items-center justify-around transition hover:scale-150"
|
||||
style:left="calc({timestampToOffset(currentTimestamp)} - 0.5rem)"
|
||||
>
|
||||
<div class="h-5 w-0.5 rounded-sm bg-white" />
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<div id="from">
|
||||
{new Date(minVisibleTimestamp).toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div id="to">
|
||||
{new Date(maxVisibleTimestamp).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<div id="ranges" class="flex w-full items-center gap-1" on:mousedown={onSelectTimestamp}>
|
||||
<div
|
||||
class="h-2 rounded-sm"
|
||||
style:background-color="inherit"
|
||||
style:width={rangeWidth([minVisibleTimestamp, visibleRanges[0][0]])}
|
||||
/>
|
||||
{#each visibleRanges as [from, to, filled]}
|
||||
<div
|
||||
class="h-2 rounded-sm"
|
||||
style:background-color={filled ? '#D9D9D9' : 'inherit'}
|
||||
style:width={rangeWidth([from, to])}
|
||||
/>
|
||||
{/each}
|
||||
<div
|
||||
class="h-2 rounded-sm"
|
||||
style:background-color="inherit"
|
||||
style:width={rangeWidth([
|
||||
visibleRanges[visibleRanges.length - 1][1],
|
||||
maxVisibleTimestamp
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto flex items-center gap-2">
|
||||
{#if interval}
|
||||
<button on:click={stop}><IconPlayerPauseFilled class="h-6 w-6" /></button>
|
||||
{:else}
|
||||
<button on:click={play}><IconPlayerPlayFilled class="h-6 w-6" /></button>
|
||||
{/if}
|
||||
<button on:click={speedUp}>{speed}x</button>
|
||||
</div>
|
||||
</div>
|
14
src/routes/projects/[projectId]/player/+page.ts
Normal file
14
src/routes/projects/[projectId]/player/+page.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { building } from '$app/environment';
|
||||
import type { Session } from '$lib/sessions';
|
||||
import { readable, type Readable } from 'svelte/store';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const sessions: Readable<Session[]> = building
|
||||
? readable<Session[]>([])
|
||||
: await import('$lib/sessions').then((m) => m.default({ projectId: params.projectId }));
|
||||
return {
|
||||
sessions,
|
||||
projectId: params.projectId
|
||||
};
|
||||
};
|
@ -1,13 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { search, type SearchResult } from '$lib';
|
||||
import { listFiles } from '$lib/sessions';
|
||||
import { list as listDeltas } from '$lib/deltas';
|
||||
import { writable } from 'svelte/store';
|
||||
import { Operation } from '$lib/deltas';
|
||||
import type { Delta } from '$lib/deltas';
|
||||
import { structuredPatch } from 'diff';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let data: PageData;
|
||||
const { project } = data;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
console.log(urlParams.get('search'));
|
||||
|
||||
let query: string;
|
||||
|
||||
onMount(async () => {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
query = urlParams.get('search');
|
||||
fetchResults();
|
||||
});
|
||||
|
||||
const results = writable<SearchResult[]>([]);
|
||||
|
||||
const debounce = <T extends (...args: any[]) => any>(fn: T, delay: number) => {
|
||||
@ -22,7 +38,93 @@
|
||||
if (!$project) return;
|
||||
if (!query) return results.set([]);
|
||||
search({ projectId: $project.id, query }).then(results.set);
|
||||
}, 100);
|
||||
}, 1000);
|
||||
|
||||
const applyDeltas = (text: string, deltas: Delta[]) => {
|
||||
const operations = deltas.flatMap((delta) => delta.operations);
|
||||
|
||||
operations.forEach((operation) => {
|
||||
if (Operation.isInsert(operation)) {
|
||||
text =
|
||||
text.slice(0, operation.insert[0]) +
|
||||
operation.insert[1] +
|
||||
text.slice(operation.insert[0]);
|
||||
} else if (Operation.isDelete(operation)) {
|
||||
text =
|
||||
text.slice(0, operation.delete[0]) +
|
||||
text.slice(operation.delete[0] + operation.delete[1]);
|
||||
}
|
||||
});
|
||||
return text;
|
||||
};
|
||||
|
||||
const getDiffHunksWithSearchTerm = (original: string, deltas: Delta[], idx: number) => {
|
||||
if (!original) return [];
|
||||
return structuredPatch(
|
||||
'file',
|
||||
'file',
|
||||
applyDeltas(original, deltas.slice(0, idx)),
|
||||
applyDeltas(original, [deltas[idx]]),
|
||||
'header',
|
||||
'header',
|
||||
{ context: 1 }
|
||||
).hunks.filter((hunk) => hunk.lines.some((l) => l.includes(query)));
|
||||
};
|
||||
|
||||
const processHunkLines = (lines: string[], newStart: number) => {
|
||||
let outLines = [];
|
||||
|
||||
let lineNumber = newStart;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
|
||||
let content = '';
|
||||
if (!line.includes(query)) {
|
||||
content = line.slice(1);
|
||||
} else {
|
||||
let firstCharIndex = line.indexOf(query);
|
||||
let lastCharIndex = firstCharIndex + query.length - 1;
|
||||
let beforeQuery = line.slice(1, firstCharIndex);
|
||||
let querySubstring = line.slice(firstCharIndex, lastCharIndex + 1);
|
||||
let afterQuery = line.slice(lastCharIndex + 1);
|
||||
|
||||
content =
|
||||
beforeQuery + `<span class="bg-zinc-400/50">${querySubstring}</span>` + afterQuery;
|
||||
}
|
||||
|
||||
outLines.push({
|
||||
hidden: false,
|
||||
content: content,
|
||||
operation: line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'unmodified',
|
||||
lineNumber: !line.startsWith('-') ? lineNumber : undefined,
|
||||
hasKeyword: line.includes(query)
|
||||
});
|
||||
|
||||
if (!line.startsWith('-')) {
|
||||
lineNumber++;
|
||||
}
|
||||
}
|
||||
|
||||
let out = [];
|
||||
for (let i = 0; i < outLines.length; i++) {
|
||||
let prevLine = outLines[i - 1];
|
||||
let nextLine = outLines[i + 1];
|
||||
let line = outLines[i];
|
||||
if (line.hasKeyword) {
|
||||
out.push(line);
|
||||
} else if (nextLine && nextLine.hasKeyword) {
|
||||
// One line of context before the relevant line
|
||||
out.push(line);
|
||||
} else if (prevLine && prevLine.hasKeyword) {
|
||||
// One line of context after the relevant line
|
||||
out.push(line);
|
||||
} else {
|
||||
line.hidden = true;
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
</script>
|
||||
|
||||
<figure class="flex flex-col gap-2">
|
||||
@ -32,7 +134,52 @@
|
||||
|
||||
<ul class="gap-q flex flex-col">
|
||||
{#each $results as result}
|
||||
<li>{JSON.stringify(result)}</li>
|
||||
<li>
|
||||
{#await listFiles( { projectId: result.projectId, sessionId: result.sessionId, paths: [result.filePath] } ) then files}
|
||||
{#await listDeltas( { projectId: result.projectId, sessionId: result.sessionId } ) then deltas}
|
||||
<div class="m-4 flex flex-col">
|
||||
<p class="mb-2 flex text-lg text-zinc-400">
|
||||
<span>{result.filePath}</span>
|
||||
<span class="flex-grow" />
|
||||
<span
|
||||
>{formatDistanceToNow(
|
||||
new Date(deltas[result.filePath][result.index].timestampMs)
|
||||
)}</span
|
||||
>
|
||||
</p>
|
||||
<div class="rounded-lg bg-zinc-700 text-[#EBDBB2]">
|
||||
{#each getDiffHunksWithSearchTerm(files[result.filePath], deltas[result.filePath], result.index) as hunk, i}
|
||||
{#if i > 0}
|
||||
<div class="border-b border-zinc-400" />
|
||||
{/if}
|
||||
<div class="m-4 flex flex-col">
|
||||
{#each processHunkLines(hunk.lines, hunk.newStart) as line}
|
||||
{#if !line.hidden}
|
||||
<div class="flex">
|
||||
<span class="w-6 flex-shrink text-zinc-400"
|
||||
>{line.lineNumber ? line.lineNumber : ''}</span
|
||||
>
|
||||
<pre
|
||||
class="
|
||||
flex-grow
|
||||
{line.operation === 'add'
|
||||
? 'bg-[#14FF00]/20'
|
||||
: line.operation === 'remove'
|
||||
? 'bg-[#FF0000]/20'
|
||||
: ''}
|
||||
">{@html line.content}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- <span>hidden</span> -->
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
{/await}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</figure>
|
||||
|
@ -83,8 +83,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
let animatingOut = false;
|
||||
|
||||
const timeStampToCol = (deltaTimestamp: Date, start: Date, end: Date) => {
|
||||
if (deltaTimestamp < start || deltaTimestamp > end) {
|
||||
console.error(
|
||||
|
Loading…
Reference in New Issue
Block a user