connect ui to rust crdts

This commit is contained in:
Nikita Galaiko 2023-02-07 14:19:29 +01:00
parent b95202cbdb
commit 66d526acd4
No known key found for this signature in database
GPG Key ID: EBAB54E845BA519D
19 changed files with 261 additions and 402 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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(_) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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