mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-26 23:59:19 +03:00
connect ui to rust crdts
This commit is contained in:
parent
b95202cbdb
commit
66d526acd4
@ -16,12 +16,9 @@
|
||||
"@codemirror/state": "^6.2.0",
|
||||
"@codemirror/view": "^6.7.3",
|
||||
"@tauri-apps/api": "^1.2.0",
|
||||
"diff": "^5.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"mm-jsr": "^3.0.2",
|
||||
"nanoid": "^4.0.0",
|
||||
"svelte-icons": "^2.1.0",
|
||||
"tauri-plugin-fs-watch-api": "github:tauri-apps/tauri-plugin-fs-watch",
|
||||
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log",
|
||||
"yjs": "^13.5.45"
|
||||
},
|
||||
|
@ -10,10 +10,8 @@ specifiers:
|
||||
'@tauri-apps/cli': ^1.2.2
|
||||
'@types/diff': ^5.0.2
|
||||
autoprefixer: ^10.4.7
|
||||
diff: ^5.1.0
|
||||
idb-keyval: ^6.2.0
|
||||
mm-jsr: ^3.0.2
|
||||
nanoid: ^4.0.0
|
||||
postcss: ^8.4.14
|
||||
postcss-load-config: ^4.0.1
|
||||
svelte: ^3.54.0
|
||||
@ -21,7 +19,6 @@ specifiers:
|
||||
svelte-icons: ^2.1.0
|
||||
svelte-preprocess: ^4.10.7
|
||||
tailwindcss: ^3.1.5
|
||||
tauri-plugin-fs-watch-api: github:tauri-apps/tauri-plugin-fs-watch
|
||||
tauri-plugin-log-api: github:tauri-apps/tauri-plugin-log
|
||||
tslib: ^2.4.1
|
||||
typescript: ^4.8.4
|
||||
@ -33,12 +30,9 @@ dependencies:
|
||||
'@codemirror/state': 6.2.0
|
||||
'@codemirror/view': 6.7.3
|
||||
'@tauri-apps/api': 1.2.0
|
||||
diff: 5.1.0
|
||||
idb-keyval: 6.2.0
|
||||
mm-jsr: 3.0.2
|
||||
nanoid: 4.0.0
|
||||
svelte-icons: 2.1.0
|
||||
tauri-plugin-fs-watch-api: github.com/tauri-apps/tauri-plugin-fs-watch/7d63ad9dfd72ae6c2bb05c148f344adb0521ec3a
|
||||
tauri-plugin-log-api: github.com/tauri-apps/tauri-plugin-log/33d9b712e9058ed82c110cb186345215f82b88e2
|
||||
yjs: 13.5.45
|
||||
|
||||
@ -710,11 +704,6 @@ 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
|
||||
|
||||
/dlv/1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
dev: true
|
||||
@ -1008,12 +997,6 @@ packages:
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/nanoid/4.0.0:
|
||||
resolution: {integrity: sha512-IgBP8piMxe/gf73RTQx7hmnhwz0aaEXYakvqZyE302IXW3HyVNhdNGC+O2MwMAVhLEnvXlvKtGbtJf6wvHihCg==}
|
||||
engines: {node: ^14 || ^16 || >=18}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/node-releases/2.0.9:
|
||||
resolution: {integrity: sha512-2xfmOrRkGogbTK9R6Leda0DGiXeY3p2NJpy4+gNCffdUvV6mdEJnaDEic1i3Ec2djAo8jWYoJMR5PB0MSMpxUA==}
|
||||
dev: true
|
||||
@ -1608,14 +1591,6 @@ packages:
|
||||
lib0: 0.2.60
|
||||
dev: false
|
||||
|
||||
github.com/tauri-apps/tauri-plugin-fs-watch/7d63ad9dfd72ae6c2bb05c148f344adb0521ec3a:
|
||||
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-fs-watch/tar.gz/7d63ad9dfd72ae6c2bb05c148f344adb0521ec3a}
|
||||
name: tauri-plugin-fs-watch-api
|
||||
version: 0.0.0
|
||||
dependencies:
|
||||
'@tauri-apps/api': 1.2.0
|
||||
dev: false
|
||||
|
||||
github.com/tauri-apps/tauri-plugin-log/33d9b712e9058ed82c110cb186345215f82b88e2:
|
||||
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/33d9b712e9058ed82c110cb186345215f82b88e2}
|
||||
name: tauri-plugin-log-api
|
||||
|
27
src-tauri/Cargo.lock
generated
27
src-tauri/Cargo.lock
generated
@ -1003,7 +1003,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-fs-watch",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-window-state",
|
||||
"uuid 1.3.0",
|
||||
@ -1727,22 +1726,10 @@ dependencies = [
|
||||
"kqueue",
|
||||
"libc",
|
||||
"mio",
|
||||
"serde",
|
||||
"walkdir",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-mini"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e23e9fa24f094b143c1eb61f90ac6457de87be6987bc70746e0179f7dbc9007b"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"notify",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
@ -2894,20 +2881,6 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs-watch"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/tauri-apps/plugins-workspace?branch=dev#bf1106a0a5e178ce38ecde56751ba037307a7ae8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"notify-debouncer-mini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-log"
|
||||
version = "0.1.0"
|
||||
|
@ -16,7 +16,6 @@ tauri-build = { version = "1.2", features = [] }
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "1.2", features = ["dialog-open", "fs-create-dir", "fs-exists", "fs-read-file", "fs-write-file", "path-all", "shell-sidecar", "window-start-dragging"] }
|
||||
tauri-plugin-fs-watch = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
|
||||
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
|
||||
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev", features = ["colored"] }
|
||||
log = "0.4.17"
|
||||
|
@ -4,12 +4,14 @@ use std::time::SystemTime;
|
||||
use yrs::{Doc, GetString, Text, Transact};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Delta {
|
||||
operations: Vec<Operation>,
|
||||
timestamp_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Operation {
|
||||
// corresponds to YText.insert(index, chunk)
|
||||
Insert((u32, String)),
|
||||
@ -232,9 +234,6 @@ fn test_document_from_deltas() {
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert_eq!(document.at(0).to_string(), "hello");
|
||||
assert_eq!(document.at(1).to_string(), "hello world");
|
||||
assert_eq!(document.at(2).to_string(), "held!");
|
||||
assert_eq!(document.to_string(), "held!");
|
||||
}
|
||||
|
||||
|
@ -22,8 +22,8 @@ pub fn unwatch(watchers: &WatcherCollection, project: Project) {
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DeltasEvent {
|
||||
project_id: String,
|
||||
file_path: String,
|
||||
deltas: Vec<Delta>,
|
||||
}
|
||||
@ -74,7 +74,6 @@ pub fn watch<R: Runtime>(
|
||||
&event_name,
|
||||
&DeltasEvent {
|
||||
deltas,
|
||||
project_id: project.id.clone(),
|
||||
file_path: relative_file_path.to_string(),
|
||||
},
|
||||
)
|
||||
@ -162,7 +161,8 @@ fn register_file_change(
|
||||
}
|
||||
|
||||
// get commit from refs/gitbutler/current or fall back to HEAD
|
||||
fn get_meta_commit(repo: &Repository) -> Commit {
|
||||
// TODO: make this private as soon as possible
|
||||
pub fn get_meta_commit(repo: &Repository) -> Commit {
|
||||
match repo.revparse_single("refs/gitbutler/current") {
|
||||
Ok(object) => repo.find_commit(object.id()).unwrap(),
|
||||
Err(_) => {
|
||||
|
@ -6,6 +6,7 @@ mod storage;
|
||||
|
||||
use crdt::Delta;
|
||||
use fs::list_files;
|
||||
use git2::Repository;
|
||||
use log;
|
||||
use projects::Project;
|
||||
use std::collections::HashMap;
|
||||
@ -22,26 +23,68 @@ struct AppState {
|
||||
projects_storage: projects::Storage,
|
||||
}
|
||||
|
||||
// returns a list of files in directory recursively
|
||||
#[tauri::command]
|
||||
fn read_dir(path: &str) -> Result<Vec<String>, InvokeError> {
|
||||
let path = Path::new(path);
|
||||
if path.is_dir() {
|
||||
let files = list_files(path);
|
||||
return Ok(files);
|
||||
fn list_project_files(
|
||||
state: State<'_, AppState>,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<String>, InvokeError> {
|
||||
log::debug!("Listing project files for project: {}", project_id);
|
||||
if let Some(project) = state.projects_storage.get_project(project_id)? {
|
||||
let project_path = Path::new(&project.path);
|
||||
let repo = match Repository::open(project_path) {
|
||||
Ok(repo) => repo,
|
||||
Err(e) => panic!("failed to open: {}", e),
|
||||
};
|
||||
let files = list_files(project_path);
|
||||
let meta_commit = delta_watchers::get_meta_commit(&repo);
|
||||
let tree = meta_commit.tree().unwrap();
|
||||
let non_ignored_files: Vec<String> = files
|
||||
.into_iter()
|
||||
.filter_map(|file| {
|
||||
let file_path = Path::new(&file);
|
||||
let relative_file_path = file_path.strip_prefix(project_path).unwrap();
|
||||
let relative_file_path = relative_file_path.to_str().unwrap();
|
||||
if let Ok(_object) = tree.get_path(Path::new(&relative_file_path)) {
|
||||
Some(relative_file_path.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(non_ignored_files)
|
||||
} else {
|
||||
return Err("Path is not a directory".into());
|
||||
Err("Project not found".into())
|
||||
}
|
||||
}
|
||||
|
||||
// reads file contents and returns it
|
||||
#[tauri::command]
|
||||
fn read_file(file_path: &str) -> Result<String, InvokeError> {
|
||||
let contents = read_to_string(file_path);
|
||||
if contents.is_ok() {
|
||||
return Ok(contents.unwrap());
|
||||
fn read_project_file(
|
||||
state: State<'_, AppState>,
|
||||
project_id: &str,
|
||||
file_path: &str,
|
||||
) -> Result<Option<String>, InvokeError> {
|
||||
log::debug!(
|
||||
"Reading project file for project: {} and file: {}",
|
||||
project_id,
|
||||
file_path
|
||||
);
|
||||
if let Some(project) = state.projects_storage.get_project(project_id)? {
|
||||
let project_path = Path::new(&project.path);
|
||||
let repo = match Repository::open(project_path) {
|
||||
Ok(repo) => repo,
|
||||
Err(e) => panic!("failed to open: {}", e),
|
||||
};
|
||||
let meta_commit = delta_watchers::get_meta_commit(&repo);
|
||||
let tree = meta_commit.tree().unwrap();
|
||||
if let Ok(object) = tree.get_path(Path::new(&file_path)) {
|
||||
let blob = object.to_object(&repo).unwrap().into_blob().unwrap();
|
||||
let contents = String::from_utf8(blob.content().to_vec()).unwrap();
|
||||
Ok(Some(contents))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
return Err(contents.err().unwrap().to_string().into());
|
||||
Err("Project not found".into())
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,7 +173,6 @@ fn main() {
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs_watch::init())
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Debug)
|
||||
@ -139,8 +181,8 @@ fn main() {
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
read_file,
|
||||
read_dir,
|
||||
read_project_file,
|
||||
list_project_files,
|
||||
add_project,
|
||||
list_projects,
|
||||
delete_project,
|
||||
|
@ -79,8 +79,12 @@ impl Project {
|
||||
for file_path in file_paths {
|
||||
let file_path = Path::new(&file_path);
|
||||
let file_deltas = self.get_file_deltas(file_path);
|
||||
let relative_file_path = file_path.strip_prefix(&deltas_path).unwrap();
|
||||
if let Some(file_deltas) = file_deltas {
|
||||
deltas.insert(file_path.to_str().unwrap().to_string(), file_deltas);
|
||||
deltas.insert(
|
||||
relative_file_path.to_str().unwrap().to_string(),
|
||||
file_deltas,
|
||||
);
|
||||
}
|
||||
}
|
||||
deltas
|
||||
|
@ -3,54 +3,53 @@
|
||||
|
||||
import { EditorState, StateField, StateEffect } from "@codemirror/state";
|
||||
import { EditorView, lineNumbers, Decoration } from "@codemirror/view";
|
||||
import type { TextDocument } from "$lib/crdt";
|
||||
|
||||
export let doc: TextDocument | null | undefined = null;
|
||||
$: value = doc?.toString();
|
||||
$: lastInserts = doc
|
||||
?.getHistory()
|
||||
.filter((e) => e.deltas.some((x: any) => x["insert"]))
|
||||
.slice(1)
|
||||
.slice(-10)
|
||||
.map((e) => e["deltas"]);
|
||||
export let value: string;
|
||||
//$: value = doc?.toString();
|
||||
//$: lastInserts = doc
|
||||
//?.getHistory()
|
||||
//.filter((e) => e.deltas.some((x: any) => x["insert"]))
|
||||
//.slice(1)
|
||||
//.slice(-10)
|
||||
//.map((e) => e["deltas"]);
|
||||
|
||||
let element: HTMLDivElement;
|
||||
let view: EditorView;
|
||||
|
||||
$: view && update(value);
|
||||
$: view && col(lastInserts, "bg-fuchsia-500");
|
||||
//$: view && col(lastInserts, "bg-fuchsia-500");
|
||||
|
||||
function col(inserts: any[] | undefined, c: string) {
|
||||
if (!inserts) return;
|
||||
let cs = [
|
||||
"bg-fuchsia-900",
|
||||
"bg-fuchsia-800",
|
||||
"bg-fuchsia-700",
|
||||
"bg-fuchsia-600",
|
||||
"bg-fuchsia-500",
|
||||
"bg-fuchsia-400",
|
||||
"bg-fuchsia-300",
|
||||
"bg-fuchsia-200",
|
||||
"bg-fuchsia-100",
|
||||
"bg-fuchsia-50",
|
||||
];
|
||||
for (let i of inserts.reverse()) {
|
||||
let op = cs.shift();
|
||||
let cls = op ? op : "bg-fuchsia-50";
|
||||
console.log(cls);
|
||||
colorLastEdit(i, cls);
|
||||
}
|
||||
}
|
||||
//function col(inserts: any[] | undefined, c: string) {
|
||||
//if (!inserts) return;
|
||||
//let cs = [
|
||||
//"bg-fuchsia-900",
|
||||
//"bg-fuchsia-800",
|
||||
//"bg-fuchsia-700",
|
||||
//"bg-fuchsia-600",
|
||||
//"bg-fuchsia-500",
|
||||
//"bg-fuchsia-400",
|
||||
//"bg-fuchsia-300",
|
||||
//"bg-fuchsia-200",
|
||||
//"bg-fuchsia-100",
|
||||
//"bg-fuchsia-50",
|
||||
//];
|
||||
//for (let i of inserts.reverse()) {
|
||||
//let op = cs.shift();
|
||||
//let cls = op ? op : "bg-fuchsia-50";
|
||||
//console.log(cls);
|
||||
//colorLastEdit(i, cls);
|
||||
//}
|
||||
//}
|
||||
|
||||
function colorLastEdit(e: any[] | undefined, c: string) {
|
||||
let retain = e?.filter((e) => e["retain"])[0];
|
||||
let insert = e?.filter((e) => e["insert"])[0];
|
||||
if (retain && insert) {
|
||||
let start = retain["retain"];
|
||||
let end = start + insert["insert"].length;
|
||||
triggerColor(view, [{ from: start, to: end, c: c }]);
|
||||
}
|
||||
}
|
||||
//function colorLastEdit(e: any[] | undefined, c: string) {
|
||||
//let retain = e?.filter((e) => e["retain"])[0];
|
||||
//let insert = e?.filter((e) => e["insert"])[0];
|
||||
//if (retain && insert) {
|
||||
//let start = retain["retain"];
|
||||
//let end = start + insert["insert"].length;
|
||||
//triggerColor(view, [{ from: start, to: end, c: c }]);
|
||||
//}
|
||||
//}
|
||||
|
||||
onMount(() => (view = create_editor_view()));
|
||||
onDestroy(() => view?.destroy());
|
||||
@ -111,14 +110,14 @@
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
const triggerColor = (view: EditorView, positions: any[]) => {
|
||||
for (const position of positions) {
|
||||
const effect = addColor.of(position);
|
||||
view.dispatch({
|
||||
effects: [effect],
|
||||
});
|
||||
}
|
||||
};
|
||||
//const triggerColor = (view: EditorView, positions: any[]) => {
|
||||
//for (const position of positions) {
|
||||
//const effect = addColor.of(position);
|
||||
//view.dispatch({
|
||||
//effects: [effect],
|
||||
//});
|
||||
//}
|
||||
//};
|
||||
|
||||
let state_extensions = [
|
||||
EditorView.editable.of(false),
|
||||
|
@ -6,13 +6,16 @@
|
||||
ModuleLabel,
|
||||
ModuleGrid,
|
||||
} from "mm-jsr";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let min: number,
|
||||
max: number,
|
||||
step: number = 1,
|
||||
value: number | undefined,
|
||||
value: number | undefined = undefined,
|
||||
formatter = (value: number): string => `${value}`;
|
||||
|
||||
const dispatch = createEventDispatcher<{ value: number }>();
|
||||
|
||||
const jsr = (
|
||||
container: HTMLElement,
|
||||
config: {
|
||||
@ -40,6 +43,7 @@
|
||||
});
|
||||
jsr.onValueChange(({ real }) => {
|
||||
value = real;
|
||||
dispatch("value", real);
|
||||
});
|
||||
};
|
||||
|
||||
|
154
src/lib/crdt.ts
154
src/lib/crdt.ts
@ -1,98 +1,84 @@
|
||||
import { Doc } from "yjs";
|
||||
import { diffChars } from "diff";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { appWindow } from "@tauri-apps/api/window";
|
||||
import { writable, type Subscriber } from "svelte/store";
|
||||
|
||||
type DeltaRetain = { retain: number };
|
||||
type DeltaDelete = { delete: number };
|
||||
type DeltaInsert = { insert: string };
|
||||
export type OperationDelete = { delete: [number, number] };
|
||||
export type OperationInsert = { insert: [number, string] };
|
||||
|
||||
export type Delta = DeltaRetain | DeltaDelete | DeltaInsert;
|
||||
export type Operation = OperationDelete | OperationInsert;
|
||||
|
||||
export namespace Delta {
|
||||
export const isRetain = (delta: Delta): delta is DeltaRetain =>
|
||||
"retain" in delta;
|
||||
export namespace Operation {
|
||||
export const isDelete = (
|
||||
operation: Operation
|
||||
): operation is OperationDelete => "delete" in operation;
|
||||
|
||||
export const isDelete = (delta: Delta): delta is DeltaDelete =>
|
||||
"delete" in delta;
|
||||
|
||||
export const isInsert = (delta: Delta): delta is DeltaInsert =>
|
||||
"insert" in delta;
|
||||
export const isInsert = (
|
||||
operation: Operation
|
||||
): operation is OperationInsert => "insert" in operation;
|
||||
}
|
||||
|
||||
// Compute the set of Yjs delta operations (that is, `insert` and
|
||||
// `delete`) required to go from initialText to finalText.
|
||||
// Based on https://github.com/kpdecker/jsdiff.
|
||||
const getDeltaOperations = (
|
||||
initialText: string,
|
||||
finalText: string
|
||||
): Delta[] => {
|
||||
if (initialText === finalText) {
|
||||
return [];
|
||||
}
|
||||
export type Delta = { timestampMs: number; operations: Operation[] };
|
||||
|
||||
const edits = diffChars(initialText, finalText);
|
||||
let prevOffset = 0;
|
||||
let deltas: Delta[] = [];
|
||||
|
||||
for (const edit of edits) {
|
||||
if (edit.removed && edit.value) {
|
||||
deltas = [
|
||||
...deltas,
|
||||
...[
|
||||
...(prevOffset > 0 ? [{ retain: prevOffset }] : []),
|
||||
{ delete: edit.value.length },
|
||||
],
|
||||
];
|
||||
prevOffset = 0;
|
||||
} else if (edit.added && edit.value) {
|
||||
deltas = [...deltas, ...[{ retain: prevOffset }, { insert: edit.value }]];
|
||||
prevOffset = edit.value.length;
|
||||
} else {
|
||||
prevOffset = edit.value.length;
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
type DeltasEvent = {
|
||||
deltas: Delta[];
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
export type HistoryEntry = { time: number; deltas: Delta[] };
|
||||
const list = (params: { projectId: string }) =>
|
||||
invoke<Record<string, Delta[]>>("list_deltas", params);
|
||||
|
||||
export class TextDocument {
|
||||
private doc: Doc = new Doc();
|
||||
private history: HistoryEntry[] = [];
|
||||
export default async (params: { projectId: string }) => {
|
||||
const files = await invoke<string[]>("list_project_files", params);
|
||||
const contents = await Promise.all(
|
||||
files.map((filePath) =>
|
||||
invoke<string>("read_project_file", { ...params, filePath })
|
||||
)
|
||||
);
|
||||
|
||||
private constructor(...history: HistoryEntry[]) {
|
||||
this.doc
|
||||
.getText()
|
||||
.applyDelta(
|
||||
history.sort((a, b) => a.time - b.time).flatMap((h) => h.deltas)
|
||||
);
|
||||
this.history = history;
|
||||
}
|
||||
// this is a temporary workaround to get the initial state of the document
|
||||
// TODO: remove this once sessions api is implemented
|
||||
const tmpState: Record<string, Delta[]> = Object.fromEntries(
|
||||
files.map((filePath, index) => [
|
||||
filePath,
|
||||
[
|
||||
{
|
||||
timestampMs: 0,
|
||||
operations: [{ insert: [0, contents[index]] } as OperationInsert],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
static new(content?: string) {
|
||||
return new TextDocument({
|
||||
time: new Date().getTime(),
|
||||
deltas: content ? [{ insert: content }] : [],
|
||||
});
|
||||
}
|
||||
const init = await list(params);
|
||||
|
||||
update(content: string) {
|
||||
const deltas = getDeltaOperations(this.toString(), content);
|
||||
if (deltas.length == 0) return;
|
||||
this.doc.getText().applyDelta(deltas);
|
||||
this.history.push({ time: new Date().getTime(), deltas });
|
||||
}
|
||||
const tmpInit = Object.fromEntries(
|
||||
Object.entries(tmpState).map(([filePath, deltas]) => [
|
||||
filePath,
|
||||
[...deltas, ...(filePath in init ? init[filePath] : [])],
|
||||
])
|
||||
);
|
||||
|
||||
getHistory() {
|
||||
return this.history.slice();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.doc.getText().toString();
|
||||
}
|
||||
|
||||
at(time: number) {
|
||||
return new TextDocument(
|
||||
...this.history.filter((entry) => entry.time <= time)
|
||||
);
|
||||
}
|
||||
}
|
||||
const store = writable<Record<string, Delta[]>>(tmpInit);
|
||||
const eventName = `deltas://${params.projectId}`;
|
||||
const unlisten = await appWindow.listen<DeltasEvent>(eventName, (event) => {
|
||||
store.update((deltas) => ({
|
||||
...deltas,
|
||||
[event.payload.filePath]: [
|
||||
...(event.payload.filePath in tmpState
|
||||
? tmpState[event.payload.filePath]
|
||||
: []),
|
||||
...event.payload.deltas,
|
||||
],
|
||||
}));
|
||||
});
|
||||
return {
|
||||
subscribe: (
|
||||
run: Subscriber<Record<string, Delta[]>>,
|
||||
invalidate?: (value?: Record<string, Delta[]>) => void
|
||||
) =>
|
||||
store.subscribe(run, (value) => {
|
||||
if (invalidate) invalidate(value);
|
||||
// unlisten();
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -1,5 +1,3 @@
|
||||
export * as tauri from "./tauri";
|
||||
export * as watch from "./watch";
|
||||
export * as crdt from "./crdt";
|
||||
export * as database from "./database";
|
||||
export * as projects from "./projects";
|
||||
|
@ -1,8 +1,5 @@
|
||||
import { database } from "$lib";
|
||||
import { writable, type Readable } from "svelte/store";
|
||||
import { TextDocument } from "./crdt";
|
||||
import { readFile, readDir, NoSuchFileOrDirectoryError } from "./tauri";
|
||||
import { EventType, type Event, watch as fsWatch } from "./watch";
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
@ -10,75 +7,20 @@ export type Project = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const watch = (
|
||||
project: Project
|
||||
): Readable<Record<string, TextDocument>> => {
|
||||
const tree = writable<Record<string, TextDocument>>({});
|
||||
const list = () => invoke<Project[]>("list_projects");
|
||||
|
||||
// TODO (NB: we can probably use git ls-files)
|
||||
const shouldIgnore = (filepath: string) => {
|
||||
if (filepath.includes(".git")) return true;
|
||||
if (filepath.includes("node_modules")) return true;
|
||||
if (filepath.includes("env")) return true;
|
||||
if (filepath.includes("__pycache__")) return true;
|
||||
return false;
|
||||
const add = (params: { path: string }) =>
|
||||
invoke<Project>("add_project", params);
|
||||
|
||||
export default async () => {
|
||||
const init = await list();
|
||||
const store = writable<Project[]>(init);
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
add: (params: { path: string }) =>
|
||||
add(params).then((project) => {
|
||||
store.update((projects) => [...projects, project]);
|
||||
return project;
|
||||
}),
|
||||
};
|
||||
|
||||
const upsertDoc = async (filepath: string) => {
|
||||
if (shouldIgnore(filepath)) return;
|
||||
|
||||
const content = await readFile(filepath).catch((err) => {
|
||||
if (err instanceof NoSuchFileOrDirectoryError) {
|
||||
return undefined;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
tree.update((tree) => {
|
||||
if (content === undefined) {
|
||||
delete tree[filepath];
|
||||
return tree;
|
||||
} else if (filepath in tree) {
|
||||
tree[filepath].update(content);
|
||||
return tree;
|
||||
} else {
|
||||
tree[filepath] = TextDocument.new(content);
|
||||
return tree;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
readDir(project.path).then((filepaths) => filepaths.forEach(upsertDoc));
|
||||
|
||||
fsWatch(project.path, async (event: Event) => {
|
||||
const isFileCreate =
|
||||
EventType.isCreate(event.type) && event.type.create.kind === "file";
|
||||
const isFileUpdate =
|
||||
EventType.isModify(event.type) && event.type.modify.kind === "data";
|
||||
const isFileRemove = EventType.isRemove(event.type);
|
||||
|
||||
if (isFileCreate || isFileUpdate) {
|
||||
for (const path of event.paths) {
|
||||
await upsertDoc(path);
|
||||
}
|
||||
} else if (isFileRemove) {
|
||||
tree.update((tree) => {
|
||||
for (const path of event.paths) {
|
||||
delete tree[path];
|
||||
}
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
export const store = async () => {
|
||||
const db = await database.json<Project[]>("projects.json");
|
||||
const fromDisk = await db.read();
|
||||
const store = writable<Project[]>(fromDisk || []);
|
||||
store.subscribe(db.write);
|
||||
return store;
|
||||
};
|
||||
|
@ -1,30 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import { log } from "$lib";
|
||||
|
||||
export class NoSuchFileOrDirectoryError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const readFile = async (filePath: string) => {
|
||||
log.info("readFile", { path: filePath });
|
||||
return invoke<string>("read_file", { filePath }).catch((err) => {
|
||||
if (err.message === "No such file or directory (os error 2)") {
|
||||
throw new NoSuchFileOrDirectoryError(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const readDir = async (path: string) => {
|
||||
log.info("readDir", { path });
|
||||
return invoke<string[]>("read_dir", { path }).catch((err) => {
|
||||
if (err.message === "No such file or directory (os error 2)") {
|
||||
throw new NoSuchFileOrDirectoryError(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
};
|
@ -1,52 +0,0 @@
|
||||
import { watchImmediate } from "tauri-plugin-fs-watch-api";
|
||||
|
||||
export type EventTypeModify = {
|
||||
modify:
|
||||
| { kind: "metadata"; mode: "ownership" | "any" }
|
||||
| { kind: "data"; mode: "content" };
|
||||
};
|
||||
|
||||
export type EventTypeRemove = {
|
||||
remove: {
|
||||
kind: "file" | "folder";
|
||||
};
|
||||
};
|
||||
|
||||
export type EventTypeCreate = {
|
||||
create: {
|
||||
kind: "file" | "folder";
|
||||
};
|
||||
};
|
||||
|
||||
export type EventType =
|
||||
| EventTypeCreate
|
||||
| EventTypeRemove
|
||||
| EventTypeModify
|
||||
| any;
|
||||
|
||||
export namespace EventType {
|
||||
export const isCreate = (
|
||||
eventType: EventType
|
||||
): eventType is EventTypeCreate =>
|
||||
(eventType as EventTypeCreate).create !== undefined;
|
||||
|
||||
export const isRemove = (
|
||||
eventType: EventType
|
||||
): eventType is EventTypeRemove =>
|
||||
(eventType as EventTypeRemove).remove !== undefined;
|
||||
|
||||
export const isModify = (
|
||||
eventType: EventType
|
||||
): eventType is EventTypeModify =>
|
||||
(eventType as EventTypeModify).modify !== undefined;
|
||||
}
|
||||
|
||||
export type Event = {
|
||||
type: EventType;
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
export const watch = (
|
||||
path: string | string[],
|
||||
onEvent: (event: Event) => void
|
||||
) => watchImmediate(path, { recursive: true }, onEvent);
|
@ -3,8 +3,6 @@
|
||||
|
||||
import { open } from "@tauri-apps/api/dialog";
|
||||
import type { LayoutData } from "./$types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { path } from "@tauri-apps/api";
|
||||
import { log } from "$lib";
|
||||
import { onMount } from "svelte";
|
||||
import { BackForwardButtons } from "$lib/components";
|
||||
@ -12,7 +10,7 @@
|
||||
onMount(log.setup);
|
||||
|
||||
export let data: LayoutData;
|
||||
const projects = data.projects;
|
||||
const { projects } = data;
|
||||
|
||||
const onSelectProjectClick = async () => {
|
||||
const selectedPath = await open({
|
||||
@ -28,15 +26,7 @@
|
||||
const projectExists = $projects.some((p) => p.path === projectPath);
|
||||
if (projectExists) return;
|
||||
|
||||
const title = await path.basename(projectPath);
|
||||
$projects = [
|
||||
...$projects,
|
||||
{
|
||||
id: nanoid(),
|
||||
title,
|
||||
path: projectPath,
|
||||
},
|
||||
];
|
||||
await projects.add({ path: projectPath });
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -70,5 +60,6 @@
|
||||
<a href="/wip">wip</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<slot />
|
||||
</main>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { readable } from "svelte/store";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
import { building } from "$app/environment";
|
||||
import type { Project } from "$lib/projects";
|
||||
@ -7,9 +7,21 @@ export const ssr = false;
|
||||
export const prerender = true;
|
||||
export const csr = true;
|
||||
|
||||
export const load: LayoutLoad = async () => ({
|
||||
// tauri apis require window reference which doesn't exist during ssr, so dynamic import here.
|
||||
projects: building
|
||||
? writable<Project[]>([])
|
||||
: (await import("$lib/projects")).store(),
|
||||
});
|
||||
export const load: LayoutLoad = async () => {
|
||||
// tauri apis require window reference which doesn't exist during ssr, so we do not import it here.
|
||||
if (building) {
|
||||
return {
|
||||
projects: {
|
||||
...readable<Project[]>([]),
|
||||
add: () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const Projects = await import("$lib/projects");
|
||||
return {
|
||||
projects: await Projects.default(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -1,17 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { derived, readable } from "svelte/store";
|
||||
import { Doc } from "yjs";
|
||||
import { derived, writable } from "svelte/store";
|
||||
import type { PageData } from "./$types";
|
||||
import { Timeline, CodeViewer } from "$lib/components";
|
||||
import { projects } from "$lib";
|
||||
import { Operation } from "$lib/crdt";
|
||||
|
||||
export let data: PageData;
|
||||
const { project } = data;
|
||||
const { deltas } = data;
|
||||
|
||||
const docs = $project ? projects.watch($project) : readable({});
|
||||
const value = writable(new Date().getTime());
|
||||
|
||||
const timestamps = derived(docs, (docs) =>
|
||||
Object.values(docs).flatMap((doc) =>
|
||||
doc.getHistory().map((h) => h.time)
|
||||
const docs = derived([deltas, value], ([deltas, value]) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(deltas).map(([filePath, deltas]) => {
|
||||
const doc = new Doc();
|
||||
const text = doc.getText();
|
||||
const operations = deltas
|
||||
.filter((delta) => delta.timestampMs <= value)
|
||||
.flatMap((delta) => delta.operations);
|
||||
operations.forEach((operation) => {
|
||||
if (Operation.isInsert(operation)) {
|
||||
text.insert(operation.insert[0], operation.insert[1]);
|
||||
} else if (Operation.isDelete(operation)) {
|
||||
text.delete(operation.delete[0], operation.delete[1]);
|
||||
}
|
||||
});
|
||||
return [filePath, text.toString()];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const timestamps = derived(deltas, (deltas) =>
|
||||
Object.values(deltas).flatMap((deltas) =>
|
||||
Object.values(deltas).map((delta) => delta.timestampMs)
|
||||
)
|
||||
);
|
||||
|
||||
@ -22,21 +43,18 @@
|
||||
[min, max],
|
||||
([min, max]) => isFinite(min) && isFinite(max)
|
||||
);
|
||||
|
||||
let value: number | undefined;
|
||||
|
||||
</script>
|
||||
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#if $showTimeline}
|
||||
<Timeline min={$min} max={$max} bind:value />
|
||||
<Timeline min={$min} max={$max} on:value={(e) => value.set(e.detail)} />
|
||||
{/if}
|
||||
|
||||
{#each Object.entries($docs) as [filepath, doc]}
|
||||
{#each Object.entries($docs) as [filepath, value]}
|
||||
<li>
|
||||
<details open>
|
||||
<summary>{filepath}</summary>
|
||||
<CodeViewer doc={value ? doc.at(value) : doc}/>
|
||||
<CodeViewer {value} />
|
||||
</details>
|
||||
</li>
|
||||
{/each}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { derived } from "svelte/store";
|
||||
import type { PageLoad } from "./$types";
|
||||
import crdt from "$lib/crdt";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@ -9,5 +10,6 @@ export const load: PageLoad = async ({ parent, params }) => {
|
||||
project: derived(projects, (projects) =>
|
||||
projects.find((project) => project.id === params.id)
|
||||
),
|
||||
deltas: await crdt({ projectId: params.id }),
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user