Merge branch 'master' into id-visual-design-updates

This commit is contained in:
Ian Donahue 2023-03-09 18:43:37 +01:00
commit 10cf93a8d6
30 changed files with 1041 additions and 166 deletions

View File

@ -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:

View File

@ -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",

View File

@ -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
View File

@ -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"

View File

@ -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

View File

@ -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);
}

View File

@ -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)));
}

View File

@ -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,

View File

@ -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,

View File

@ -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: &notify::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(&notify_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)))
}
}

View 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);
}

View File

@ -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,

View 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());
}

View File

@ -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" />

View File

@ -1,5 +1,4 @@
<script>
import { IconArrowBigLeftFilled, IconArrowBigRightFilled } from '@tabler/icons-svelte';
let history = window.history;
</script>

View File

@ -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}

View File

@ -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

View File

@ -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' },

View File

@ -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;

View File

@ -18,7 +18,7 @@ export namespace Operation {
export type Delta = { timestampMs: number; operations: Operation[] };
type DeltasEvent = {
export type DeltasEvent = {
deltas: Delta[];
filePath: string;
};

View File

@ -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
View 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 }));
};
};

View File

@ -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>

View File

@ -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"
>&#8984;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>

View File

@ -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
};
};

View File

@ -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>

View 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>

View 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
};
};

View File

@ -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>

View File

@ -83,8 +83,6 @@
});
}
let animatingOut = false;
const timeStampToCol = (deltaTimestamp: Date, start: Date, end: Date) => {
if (deltaTimestamp < start || deltaTimestamp > end) {
console.error(