Merge branch 'master' into this-is-a-branch-with-a-really-long-name-as-an-example

This commit is contained in:
Ian Donahue 2023-03-13 17:20:34 +01:00
commit ef728d5627
73 changed files with 3391 additions and 663 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

@ -3,7 +3,7 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

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",
@ -65,6 +66,7 @@
"postcss-load-config": "^4.0.1",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.8.1",
"prettier-plugin-tailwindcss": "^0.2.4",
"svelte": "^3.55.1",
"svelte-check": "^3.0.1",
"tailwindcss": "^3.1.5",

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
@ -48,6 +49,7 @@ specifiers:
posthog-js: ^1.46.1
prettier: ^2.8.0
prettier-plugin-svelte: ^2.8.1
prettier-plugin-tailwindcss: ^0.2.4
seti-icons: ^0.0.4
svelte: ^3.55.1
svelte-check: ^3.0.1
@ -62,7 +64,7 @@ dependencies:
'@codemirror/autocomplete': 6.4.2_yom6siklgbeshd7shgtg2sdiku
'@codemirror/commands': 6.2.0
'@codemirror/lang-angular': github.com/codemirror/lang-angular/ee6151b55668b2941c71b0d1db9f5a526ef29710
'@codemirror/lang-css': github.com/codemirror/lang-css/9f5b41703dff289d94731c5caba72cf3b57fff43_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-css': github.com/codemirror/lang-css/2cde46bf378ae36413e7fca5e24a2606f3cdf840_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-html': github.com/codemirror/lang-html/0420487e1ac04bfd59129c243e3b7b802ffca30c
'@codemirror/lang-java': github.com/codemirror/lang-java/834c534e7d689b0d88c15e3af55b0ee551d985d2
'@codemirror/lang-javascript': 6.1.4
@ -82,11 +84,12 @@ dependencies:
'@lezer/lr': 1.3.3
'@nextjournal/lang-clojure': 1.0.0
'@replit/codemirror-lang-csharp': 6.1.0_dbd6aqsmfpystthdbxnrle4dwe
'@replit/codemirror-lang-svelte': 6.0.0_zlhskyhbzw2pn2yfpaeuivpmfu
'@replit/codemirror-lang-svelte': 6.0.0_yycrqtt34ynecrgehdjoffcaie
'@square/svelte-store': 1.0.14
'@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
@ -94,7 +97,7 @@ dependencies:
posthog-js: 1.46.1
seti-icons: 0.0.4
svelte-french-toast: 1.0.3_svelte@3.55.1
tauri-plugin-log-api: github.com/tauri-apps/tauri-plugin-log/921afb3366b14ac43e3d8041a7def4b85d4d7192
tauri-plugin-log-api: github.com/tauri-apps/tauri-plugin-log/05a9bfd9edb9b5f4ab95412bb607691708b65a25
devDependencies:
'@sveltejs/adapter-static': 1.0.0-next.50_l5ueyfihz3gpzzvvyo2ean5u3e
@ -111,6 +114,7 @@ devDependencies:
postcss-load-config: 4.0.1_postcss@8.4.21
prettier: 2.8.4
prettier-plugin-svelte: 2.9.0_jrsxveqmsx2uadbqiuq74wlc4u
prettier-plugin-tailwindcss: 0.2.4_yjdjpc7im5hvpskij45owfsns4
svelte: 3.55.1
svelte-check: 3.0.3_gqx7lw3sljhsd4bstor5m2aa2u
tailwindcss: 3.2.4_postcss@8.4.21
@ -143,8 +147,8 @@ packages:
'@lezer/common': 1.0.2
dev: false
/@codemirror/lang-css/6.0.2_nzpoxphwgc7witc3f5hdaoweju:
resolution: {integrity: sha512-4V4zmUOl2Glx0GWw0HiO1oGD4zvMlIQ3zx5hXOE6ipCjhohig2bhWRAasrZylH9pRNTcl1VMa59Lsl8lZWlTzw==}
/@codemirror/lang-css/6.1.0_nzpoxphwgc7witc3f5hdaoweju:
resolution: {integrity: sha512-GYn4TyMvQLrkrhdisFh8HCTDAjPY/9pzwN12hG9UdrTUxRUMicF+8GS24sFEYaleaG1KZClIFLCj0Rol/WO24w==}
dependencies:
'@codemirror/autocomplete': 6.4.2_yom6siklgbeshd7shgtg2sdiku
'@codemirror/language': 6.6.0
@ -159,7 +163,7 @@ packages:
resolution: {integrity: sha512-bqCBASkteKySwtIbiV/WCtGnn/khLRbbiV5TE+d9S9eQJD7BA4c5dTRm2b3bVmSpilff5EYxvB4PQaZzM/7cNw==}
dependencies:
'@codemirror/autocomplete': 6.4.2_yom6siklgbeshd7shgtg2sdiku
'@codemirror/lang-css': 6.0.2_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-css': 6.1.0_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-javascript': 6.1.4
'@codemirror/language': 6.6.0
'@codemirror/state': 6.2.0
@ -608,7 +612,7 @@ packages:
'@lezer/lr': 1.3.3
dev: false
/@replit/codemirror-lang-svelte/6.0.0_zlhskyhbzw2pn2yfpaeuivpmfu:
/@replit/codemirror-lang-svelte/6.0.0_yycrqtt34ynecrgehdjoffcaie:
resolution: {integrity: sha512-U2OqqgMM6jKelL0GNWbAmqlu1S078zZNoBqlJBW+retTc5M4Mha6/Y2cf4SVg6ddgloJvmcSpt4hHrVoM4ePRA==}
peerDependencies:
'@codemirror/autocomplete': ^6.0.0
@ -624,7 +628,7 @@ packages:
'@lezer/lr': ^1.0.0
dependencies:
'@codemirror/autocomplete': 6.4.2_yom6siklgbeshd7shgtg2sdiku
'@codemirror/lang-css': github.com/codemirror/lang-css/9f5b41703dff289d94731c5caba72cf3b57fff43_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-css': github.com/codemirror/lang-css/2cde46bf378ae36413e7fca5e24a2606f3cdf840_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-html': github.com/codemirror/lang-html/0420487e1ac04bfd59129c243e3b7b802ffca30c
'@codemirror/lang-javascript': 6.1.4
'@codemirror/language': 6.6.0
@ -1367,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'}
@ -2505,6 +2514,62 @@ packages:
svelte: 3.55.1
dev: true
/prettier-plugin-tailwindcss/0.2.4_yjdjpc7im5hvpskij45owfsns4:
resolution: {integrity: sha512-wMyugRI2yD8gqmMpZSS8kTA0gGeKozX/R+w8iWE+yiCZL09zY0SvfiHfHabNhjGhzxlQ2S2VuTxPE3T72vppCQ==}
engines: {node: '>=12.17.0'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-php': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@shufo/prettier-plugin-blade': '*'
'@trivago/prettier-plugin-sort-imports': '*'
prettier: '>=2.2.0'
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-import-sort: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-style-order: '*'
prettier-plugin-svelte: '*'
prettier-plugin-twig-melody: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-php':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@shufo/prettier-plugin-blade':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-import-sort:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-style-order:
optional: true
prettier-plugin-svelte:
optional: true
prettier-plugin-twig-melody:
optional: true
dependencies:
prettier: 2.8.4
prettier-plugin-svelte: 2.9.0_jrsxveqmsx2uadbqiuq74wlc4u
dev: true
/prettier/2.8.4:
resolution: {integrity: sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==}
engines: {node: '>=10.13.0'}
@ -3192,11 +3257,11 @@ packages:
'@lezer/highlight': 1.1.3
dev: false
github.com/codemirror/lang-css/9f5b41703dff289d94731c5caba72cf3b57fff43_nzpoxphwgc7witc3f5hdaoweju:
resolution: {tarball: https://codeload.github.com/codemirror/lang-css/tar.gz/9f5b41703dff289d94731c5caba72cf3b57fff43}
id: github.com/codemirror/lang-css/9f5b41703dff289d94731c5caba72cf3b57fff43
github.com/codemirror/lang-css/2cde46bf378ae36413e7fca5e24a2606f3cdf840_nzpoxphwgc7witc3f5hdaoweju:
resolution: {tarball: https://codeload.github.com/codemirror/lang-css/tar.gz/2cde46bf378ae36413e7fca5e24a2606f3cdf840}
id: github.com/codemirror/lang-css/2cde46bf378ae36413e7fca5e24a2606f3cdf840
name: '@codemirror/lang-css'
version: 6.0.2
version: 6.1.0
prepare: true
requiresBuild: true
dependencies:
@ -3217,7 +3282,7 @@ packages:
requiresBuild: true
dependencies:
'@codemirror/autocomplete': 6.4.2_yom6siklgbeshd7shgtg2sdiku
'@codemirror/lang-css': 6.0.2_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-css': 6.1.0_nzpoxphwgc7witc3f5hdaoweju
'@codemirror/lang-javascript': 6.1.4
'@codemirror/language': 6.6.0
'@codemirror/state': 6.2.0
@ -3321,8 +3386,8 @@ packages:
'@lezer/lr': 1.3.3
dev: false
github.com/tauri-apps/tauri-plugin-log/921afb3366b14ac43e3d8041a7def4b85d4d7192:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/921afb3366b14ac43e3d8041a7def4b85d4d7192}
github.com/tauri-apps/tauri-plugin-log/05a9bfd9edb9b5f4ab95412bb607691708b65a25:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/05a9bfd9edb9b5f4ab95412bb607691708b65a25}
name: tauri-plugin-log-api
version: 0.0.0
dependencies:

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

@ -19,12 +19,19 @@ pub fn read(project: &projects::Project, file_path: &Path) -> Result<Option<Vec<
let file_deltas = std::fs::read_to_string(&file_deltas_path).with_context(|| {
format!(
"Failed to read file deltas from {}",
"failed to read file deltas from {}",
file_deltas_path.to_str().unwrap()
)
})?;
Ok(Some(serde_json::from_str(&file_deltas)?))
let deltas: Vec<Delta> = serde_json::from_str(&file_deltas).with_context(|| {
format!(
"failed to parse file deltas from {}",
file_deltas_path.to_str().unwrap()
)
})?;
Ok(Some(deltas))
}
pub fn write(
@ -34,27 +41,32 @@ pub fn write(
deltas: &Vec<Delta>,
) -> Result<sessions::Session> {
// make sure we always have a session before writing deltas
let mut session = match sessions::Session::current(repo, project)? {
Some(session) => Ok(session),
let session = match sessions::Session::current(repo, project)? {
Some(mut session) => {
session
.touch(project)
.with_context(|| format!("failed to touch session {}", session.id))?;
Ok(session)
}
None => sessions::Session::from_head(repo, project),
}?;
let delta_path = project.deltas_path().join(file_path);
let delta_dir = delta_path.parent().unwrap();
std::fs::create_dir_all(&delta_dir)?;
log::info!("mkdir {}", delta_path.to_str().unwrap());
log::info!("Writing deltas to {}", delta_path.to_str().unwrap());
log::info!(
"{}: writing deltas to {}",
project.id,
delta_path.to_str().unwrap()
);
let raw_deltas = serde_json::to_string(&deltas)?;
std::fs::write(delta_path.clone(), raw_deltas).with_context(|| {
format!(
"Failed to write file deltas to {}",
"failed to write file deltas to {}",
delta_path.to_str().unwrap()
)
})?;
// update last session activity timestamp
session.update(project)?;
Ok(session)
}

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

@ -18,23 +18,19 @@ fn apply_deltas(doc: &mut Vec<char>, deltas: &Vec<deltas::Delta>) -> Result<()>
}
impl TextDocument {
// creates a new text document from a deltas.
pub fn from_deltas(deltas: Vec<deltas::Delta>) -> Result<TextDocument> {
let mut doc = vec![];
apply_deltas(&mut doc, &deltas)?;
Ok(TextDocument { doc, deltas })
}
pub fn get_deltas(&self) -> Vec<deltas::Delta> {
self.deltas.clone()
}
// returns a text document where internal state is seeded with value, and deltas are applied.
pub fn new(value: &str, deltas: Vec<deltas::Delta>) -> Result<TextDocument> {
let mut all_deltas = vec![deltas::Delta {
operations: operations::get_delta_operations("", value),
timestamp_ms: 0,
}];
pub fn new(value: Option<&str>, deltas: Vec<deltas::Delta>) -> Result<TextDocument> {
let mut all_deltas = vec![];
if let Some(value) = value {
all_deltas.push(deltas::Delta {
operations: operations::get_delta_operations("", value),
timestamp_ms: 0,
});
}
all_deltas.append(&mut deltas.clone());
let mut doc = vec![];
apply_deltas(&mut doc, &all_deltas)?;

View File

@ -2,7 +2,7 @@ use crate::deltas::{operations::Operation, text_document::TextDocument, Delta};
#[test]
fn test_new() {
let document = TextDocument::new("hello world", vec![]);
let document = TextDocument::new(Some("hello world"), vec![]);
assert_eq!(document.is_ok(), true);
let document = document.unwrap();
assert_eq!(document.to_string(), "hello world");
@ -11,7 +11,7 @@ fn test_new() {
#[test]
fn test_update() {
let document = TextDocument::new("hello world", vec![]);
let document = TextDocument::new(Some("hello world"), vec![]);
assert_eq!(document.is_ok(), true);
let mut document = document.unwrap();
document.update("hello world!").unwrap();
@ -26,7 +26,7 @@ fn test_update() {
#[test]
fn test_empty() {
let document = TextDocument::from_deltas(vec![]);
let document = TextDocument::new(None, vec![]);
assert_eq!(document.is_ok(), true);
let mut document = document.unwrap();
document.update("hello world!").unwrap();
@ -41,23 +41,26 @@ fn test_empty() {
#[test]
fn test_from_deltas() {
let document = TextDocument::from_deltas(vec![
Delta {
timestamp_ms: 0,
operations: vec![Operation::Insert((0, "hello".to_string()))],
},
Delta {
timestamp_ms: 1,
operations: vec![Operation::Insert((5, " world".to_string()))],
},
Delta {
timestamp_ms: 2,
operations: vec![
Operation::Delete((3, 7)),
Operation::Insert((4, "!".to_string())),
],
},
]);
let document = TextDocument::new(
None,
vec![
Delta {
timestamp_ms: 0,
operations: vec![Operation::Insert((0, "hello".to_string()))],
},
Delta {
timestamp_ms: 1,
operations: vec![Operation::Insert((5, " world".to_string()))],
},
Delta {
timestamp_ms: 2,
operations: vec![
Operation::Delete((3, 7)),
Operation::Insert((4, "!".to_string())),
],
},
],
);
assert_eq!(document.is_ok(), true);
let document = document.unwrap();
assert_eq!(document.to_string(), "held!");
@ -65,7 +68,7 @@ fn test_from_deltas() {
#[test]
fn test_complex_line() {
let document = TextDocument::from_deltas(vec![]);
let document = TextDocument::new(None, vec![]);
assert_eq!(document.is_ok(), true);
let mut document = document.unwrap();
@ -103,7 +106,7 @@ fn test_complex_line() {
#[test]
fn test_multiline_add() {
let document = TextDocument::from_deltas(vec![]);
let document = TextDocument::new(None, vec![]);
assert_eq!(document.is_ok(), true);
let mut document = document.unwrap();
@ -141,7 +144,7 @@ fn test_multiline_add() {
#[test]
fn test_multiline_remove() {
let document = TextDocument::from_deltas(vec![]);
let document = TextDocument::new(None, vec![]);
assert_eq!(document.is_ok(), true);
let mut document = document.unwrap();

View File

@ -15,6 +15,7 @@ use log;
use serde::{ser::SerializeMap, Serialize};
use std::{
collections::HashMap,
ops::Range,
sync::{mpsc, Mutex},
};
use storage::Storage;
@ -67,24 +68,24 @@ struct App {
}
impl App {
pub fn new(resolver: tauri::PathResolver) -> Self {
pub fn new(resolver: tauri::PathResolver) -> Result<Self> {
let local_data_dir = resolver.app_local_data_dir().unwrap();
log::info!("Local data dir: {:?}", local_data_dir,);
let storage = Storage::from_path_resolver(&resolver);
let projects_storage = projects::Storage::new(storage.clone());
let users_storage = users::Storage::new(storage.clone());
let deltas_searcher = search::Deltas::at(local_data_dir);
let deltas_searcher = search::Deltas::at(local_data_dir)?;
let watchers = watchers::Watcher::new(
projects_storage.clone(),
users_storage.clone(),
deltas_searcher.clone(),
);
Self {
Ok(Self {
projects_storage,
users_storage,
deltas_searcher: deltas_searcher.into(),
watchers: watchers.into(),
}
})
}
}
@ -137,20 +138,46 @@ fn proxy_image(handle: tauri::AppHandle, src: &str) -> Result<String> {
Ok(build_asset_url(&save_to.display().to_string()))
}
#[tauri::command]
fn search(
handle: tauri::AppHandle,
project_id: &str,
query: &str,
limit: Option<usize>,
offset: Option<usize>,
timestamp_ms_gte: Option<u64>,
timestamp_ms_lt: Option<u64>,
) -> Result<Vec<search::SearchResult>, Error> {
let app_state = handle.state::<App>();
let query = search::SearchQuery {
project_id: project_id.to_string(),
q: query.to_string(),
limit: limit.unwrap_or(100),
offset,
range: Range {
start: timestamp_ms_gte.unwrap_or(0),
end: timestamp_ms_lt.unwrap_or(u64::MAX),
},
};
let deltas_lock = app_state
.deltas_searcher
.lock()
.map_err(|poison_err| anyhow::anyhow!("Lock poisoned: {:?}", poison_err))?;
let deltas = deltas_lock
.search(&query)
.with_context(|| format!("Failed to search for {:?}", query))?;
Ok(deltas)
}
#[tauri::command]
fn list_sessions(
handle: tauri::AppHandle,
project_id: &str,
) -> Result<Vec<sessions::Session>, Error> {
let app_state = handle.state::<App>();
let repo = repositories::Repository::open(
&app_state.projects_storage,
&app_state.users_storage,
project_id,
)
.with_context(|| format!("Failed to open repository for project {}", project_id))?;
let repo = repo_for_project(handle, project_id)?;
let sessions = repo
.sessions()
.with_context(|| format!("Failed to list sessions for project {}", project_id))?;
@ -303,16 +330,8 @@ fn list_session_files(
session_id: &str,
paths: Option<Vec<&str>>,
) -> Result<HashMap<String, String>, Error> {
let app_state = handle.state::<App>();
let repo = repositories::Repository::open(
&app_state.projects_storage,
&app_state.users_storage,
project_id,
)?;
let repo = repo_for_project(handle, project_id)?;
let files = repo.files(session_id, paths)?;
Ok(files)
}
@ -322,6 +341,49 @@ fn list_deltas(
project_id: &str,
session_id: &str,
) -> Result<HashMap<String, Vec<Delta>>, Error> {
let repo = repo_for_project(handle, project_id)?;
let deltas = repo.deltas(session_id)?;
Ok(deltas)
}
#[tauri::command]
fn git_status(
handle: tauri::AppHandle,
project_id: &str,
) -> Result<HashMap<String, String>, Error> {
let repo = repo_for_project(handle, project_id)?;
let files = repo.status().with_context(|| "Failed to get git status")?;
Ok(files)
}
#[tauri::command]
fn git_file_paths(handle: tauri::AppHandle, project_id: &str) -> Result<Vec<String>, Error> {
let repo = repo_for_project(handle, project_id)?;
let files = repo
.file_paths()
.with_context(|| "Failed to get file paths")?;
Ok(files)
}
#[tauri::command]
fn git_match_paths(
handle: tauri::AppHandle,
project_id: &str,
match_pattern: &str,
) -> Result<Vec<String>, Error> {
let repo = repo_for_project(handle, project_id)?;
let files = repo
.match_file_paths(match_pattern)
.with_context(|| "Failed to get file paths")?;
Ok(files)
}
fn repo_for_project(
handle: tauri::AppHandle,
project_id: &str,
) -> Result<repositories::Repository, Error> {
let app_state = handle.state::<App>();
let repo = repositories::Repository::open(
@ -330,9 +392,54 @@ fn list_deltas(
project_id,
)?;
let deltas = repo.deltas(session_id)?;
Ok(repo)
}
Ok(deltas)
#[tauri::command]
fn git_branches(handle: tauri::AppHandle, project_id: &str) -> Result<Vec<String>, Error> {
let repo = repo_for_project(handle, project_id)?;
let files = repo
.branches()
.with_context(|| "Failed to get file paths")?;
Ok(files)
}
#[tauri::command]
fn git_branch(handle: tauri::AppHandle, project_id: &str) -> Result<String, Error> {
let repo = repo_for_project(handle, project_id)?;
let files = repo
.branch()
.with_context(|| "Failed to get the git branch ref name")?;
Ok(files)
}
#[tauri::command]
fn git_switch_branch(
handle: tauri::AppHandle,
project_id: &str,
branch: &str,
) -> Result<bool, Error> {
let repo = repo_for_project(handle, project_id)?;
let result = repo
.switch_branch(branch)
.with_context(|| "Failed to get file paths")?;
Ok(result)
}
#[tauri::command]
fn git_commit(
handle: tauri::AppHandle,
project_id: &str,
message: &str,
files: Vec<&str>,
push: bool,
) -> Result<bool, Error> {
let repo = repo_for_project(handle, project_id)?;
let success = repo
.commit(message, files, push)
.with_context(|| "Failed to commit")?;
Ok(success)
}
fn main() {
@ -398,7 +505,8 @@ fn main() {
#[cfg(debug_assertions)]
window.open_devtools();
let app_state: App = App::new(app.path_resolver());
let app_state: App =
App::new(app.path_resolver()).expect("Failed to initialize app state");
app.manage(app_state);
@ -443,7 +551,15 @@ fn main() {
list_session_files,
set_user,
delete_user,
get_user
get_user,
search,
git_status,
git_file_paths,
git_match_paths,
git_branches,
git_branch,
git_switch_branch,
git_commit
]);
let tauri_context = generate_context!();
@ -504,18 +620,19 @@ fn init(app_handle: tauri::AppHandle) -> Result<()> {
.lock()
.unwrap()
.watch(tx.clone(), &project)
.with_context(|| format!("Failed to watch project: {}", project.id))?;
.with_context(|| format!("{}: failed to watch project", project.id))?;
let repo = git2::Repository::open(&project.path)
.with_context(|| format!("Failed to open git repository: {}", project.path))?;
.with_context(|| format!("{}: failed to open repository", project.path))?;
app_state
if let Err(err) = app_state
.deltas_searcher
.lock()
.unwrap()
.reindex_project(&repo, &project)
.with_context(|| format!("Failed to reindex project: {}", project.id))
.unwrap();
{
log::error!("{}: failed to reindex project: {:#}", project.id, err);
}
}
watch_events(app_handle, rx);
@ -583,3 +700,51 @@ fn hide_window(handle: &tauri::AppHandle) -> tauri::Result<()> {
None => Ok(()),
}
}
fn debug_test_consistency(app_state: &App, project_id: &str) -> Result<()> {
let repo = repositories::Repository::open(
&app_state.projects_storage,
&app_state.users_storage,
project_id,
)?;
let sessions = repo.sessions()?;
let session_deltas: Vec<HashMap<String, Vec<Delta>>> = sessions
.iter()
.map(|session| {
let deltas = repo.deltas(&session.id).expect("Failed to list deltas");
deltas
})
.collect();
let deltas: HashMap<String, Vec<Delta>> =
session_deltas
.iter()
.fold(HashMap::new(), |mut acc, deltas| {
for (path, deltas) in deltas {
acc.entry(path.to_string())
.or_insert_with(Vec::new)
.extend(deltas.clone());
}
acc
});
let first_session = &sessions[sessions.len() - 1];
let files = repo.files(&first_session.id, None)?;
files.iter().for_each(|(path, content)| {
println!("Testing consistency for {}", path);
let mut file_deltas = deltas.get(path).unwrap_or(&Vec::new()).clone();
file_deltas.sort_by(|a, b| a.timestamp_ms.cmp(&b.timestamp_ms));
let mut text: Vec<char> = content.chars().collect();
for delta in file_deltas {
for operation in delta.operations {
operation
.apply(&mut text)
.expect("Failed to apply operation");
}
}
});
Ok(())
}

View File

@ -1,6 +1,6 @@
use crate::projects::project;
use crate::storage;
use anyhow::Result;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const PROJECTS_FILE: &str = "projects.json";
@ -26,7 +26,8 @@ impl Storage {
pub fn list_projects(&self) -> Result<Vec<project::Project>> {
match self.storage.read(PROJECTS_FILE)? {
Some(projects) => {
let all_projects: Vec<project::Project> = serde_json::from_str(&projects)?;
let all_projects: Vec<project::Project> = serde_json::from_str(&projects)
.with_context(|| format!("Failed to parse projects from {}", PROJECTS_FILE))?;
let non_deleted_projects = all_projects
.into_iter()
.filter(|p: &project::Project| !p.deleted)

View File

@ -1,10 +1,13 @@
use crate::{deltas, projects, sessions, users};
use crate::{deltas, fs, projects, sessions, users};
use anyhow::{Context, Result};
use std::collections::HashMap;
use git2::{BranchType, Cred, Signature};
use std::{collections::HashMap, env, path::Path};
use tauri::regex::Regex;
use walkdir::WalkDir;
pub struct Repository {
project: projects::Project,
git_repository: git2::Repository,
pub project: projects::Project,
pub git_repository: git2::Repository,
}
impl Repository {
@ -22,6 +25,14 @@ impl Repository {
.with_context(|| "failed to get user for project")?;
let git_repository =
git2::Repository::open(&project.path).with_context(|| "failed to open repository")?;
Self::new(project, git_repository, user)
}
pub fn new(
project: projects::Project,
git_repository: git2::Repository,
user: Option<users::User>,
) -> Result<Self> {
init(&git_repository, &project, &user).with_context(|| "failed to init repository")?;
Ok(Repository {
project,
@ -69,6 +80,266 @@ impl Repository {
session_id,
)
}
// get a list of all files in the working directory
pub fn file_paths(&self) -> Result<Vec<String>> {
let workdir = &self
.git_repository
.workdir()
.with_context(|| "failed to get working directory")?;
let all_files = fs::list_files(&workdir)
.with_context(|| format!("Failed to list files in {}", workdir.to_str().unwrap()))?;
let mut files = Vec::new();
for file in all_files {
if !&self.git_repository.is_path_ignored(&file).unwrap_or(true) {
files.push(file);
}
}
return Ok(files);
}
// get a list of all files in the working directory
pub fn match_file_paths(&self, match_pattern: &str) -> Result<Vec<String>> {
let workdir = &self
.git_repository
.workdir()
.with_context(|| "failed to get working directory")?;
let pattern = Regex::new(match_pattern).with_context(|| "regex parse error");
match pattern {
Ok(pattern) => {
let mut files = vec![];
for entry in WalkDir::new(workdir)
.into_iter()
.filter_entry(|e| {
// need to remove workdir so we're not matching it
let match_string = e
.path()
.strip_prefix::<&Path>(workdir.as_ref())
.unwrap()
.to_str()
.unwrap();
// this is to make it faster, so we dont have to traverse every directory if it is ignored by git
e.path().to_str() == workdir.to_str() // but we need to traverse the first one
|| ((e.file_type().is_dir() // traverse all directories if they are not ignored by git
|| pattern.is_match(match_string)) // but only pass on files that match the regex
&& !&self
.git_repository
.is_path_ignored(e.path())
.unwrap_or(true))
})
.filter_map(Result::ok)
{
if entry.file_type().is_file() {
// only save the matching files, not the directories
let path = entry.path();
let path =
path.strip_prefix::<&Path>(workdir.as_ref())
.with_context(|| {
format!(
"failed to strip prefix from path {}",
path.to_str().unwrap()
)
})?;
let path = path.to_str().unwrap().to_string();
files.push(path);
}
}
files.sort();
return Ok(files);
}
Err(e) => {
return Err(e);
}
}
}
pub fn branches(&self) -> Result<Vec<String>> {
let mut branches = vec![];
for branch in self.git_repository.branches(Some(BranchType::Local))? {
let (branch, _) = branch?;
branches.push(branch.name()?.unwrap().to_string());
}
Ok(branches)
}
// return current branch name
pub fn branch(&self) -> Result<String> {
print!("getting branch name... ");
let repo = &self.git_repository;
let head = repo.head()?;
let branch = head.name().unwrap();
Ok(branch.to_string())
}
pub fn switch_branch(&self, branch_name: &str) -> Result<bool> {
self.flush_session(&None)
.with_context(|| "failed to flush session before switching branch")?;
let branch = self
.git_repository
.find_branch(branch_name, git2::BranchType::Local)?;
let branch = branch.into_reference();
self.git_repository
.set_head(branch.name().unwrap())
.with_context(|| "failed to set head")?;
// checkout head
self.git_repository
.checkout_head(Some(&mut git2::build::CheckoutBuilder::default().force()))
.with_context(|| "failed to checkout head")?;
Ok(true)
}
// get file status from git
pub fn status(&self) -> Result<HashMap<String, String>> {
let mut options = git2::StatusOptions::new();
options.include_untracked(true);
options.include_ignored(false);
options.recurse_untracked_dirs(true);
// get the status of the repository
let statuses = self
.git_repository
.statuses(Some(&mut options))
.with_context(|| "failed to get repository status");
let mut files = HashMap::new();
match statuses {
Ok(statuses) => {
// iterate over the statuses
for entry in statuses.iter() {
// get the path of the entry
let path = entry.path().unwrap();
// get the status as a string
let istatus = match entry.status() {
s if s.contains(git2::Status::WT_NEW) => "added",
s if s.contains(git2::Status::WT_MODIFIED) => "modified",
s if s.contains(git2::Status::WT_DELETED) => "deleted",
s if s.contains(git2::Status::WT_RENAMED) => "renamed",
s if s.contains(git2::Status::WT_TYPECHANGE) => "typechange",
_ => continue,
};
files.insert(path.to_string(), istatus.to_string());
}
}
Err(_) => {
println!("Error getting status");
}
}
return Ok(files);
}
// commit method
pub fn commit(&self, message: &str, files: Vec<&str>, push: bool) -> Result<bool> {
println!("Git Commit");
let repo = &self.git_repository;
let config = repo.config()?;
let name = config.get_string("user.name")?;
let email = config.get_string("user.email")?;
// Get the repository's index
let mut index = repo.index()?;
// Add the specified files to the index
for path_str in files {
let path = Path::new(path_str);
index.add_path(path)?;
}
// Write the updated index to disk
index.write()?;
// Get the default signature for the repository
let signature = Signature::now(&name, &email)?;
// Create the commit with the updated index
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let parent_commit = repo.head()?.peel_to_commit()?;
let commit = repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&[&parent_commit],
)?;
println!("Created commit {}", commit);
if push {
println!("Pushing to remote");
// Get a reference to the current branch
let head = repo.head()?;
let branch = head.name().unwrap();
println!("Branch: {:?}", branch);
let branch_remote = repo.branch_upstream_remote(branch)?;
let branch_remote_name = branch_remote.as_str().unwrap();
let branch_name = repo.branch_upstream_name(branch)?;
println!(
"Branch remote: {:?}, {:?}",
branch_remote.as_str(),
branch_name.as_str()
);
// Set the remote's callbacks
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.push_update_reference(move |refname, message| {
log::info!("pushing reference '{}': {:?}", refname, message);
Ok(())
});
callbacks.push_transfer_progress(move |one, two, three| {
log::info!("transferred {}/{}/{} objects", one, two, three);
});
// create ssh key if it's not there
// try to auth with creds from an ssh-agent
callbacks.credentials(|_url, username_from_url, _allowed_types| {
print!("Trying to auth with ssh... {:?} ", username_from_url);
Cred::ssh_key(
username_from_url.unwrap(),
None,
std::path::Path::new(&format!("{}/.ssh/id_ed25519", env::var("HOME").unwrap())),
None,
)
});
let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks);
// Push to the remote
let mut remote = repo.find_remote(branch_remote_name)?;
remote
.push(&[branch], Some(&mut push_options))
.with_context(|| {
format!("failed to push {:?} to {:?}", branch, branch_remote_name)
})?;
}
return Ok(true);
}
pub fn flush_session(&self, user: &Option<users::User>) -> Result<()> {
// if the reference doesn't exist, we create it by creating a flushing a new session
let mut current_session =
match sessions::Session::current(&self.git_repository, &self.project)? {
Some(session) => session,
None => sessions::Session::from_head(&self.git_repository, &self.project)?,
};
current_session
.flush(&self.git_repository, user, &self.project)
.with_context(|| format!("{}: failed to flush session", &self.project.id))?;
Ok(())
}
}
fn init(
@ -89,15 +360,12 @@ fn init(
Some(session) => session,
None => sessions::Session::from_head(git_repository, project)?,
};
current_session.flush(git_repository, user, project)?;
current_session
.flush(git_repository, user, project)
.with_context(|| format!("{}: failed to flush session", project.id))?;
Ok(())
} else {
Err(error).with_context(|| {
format!(
"failed to find reference {} in repository {}",
reference_name, project.path
)
})
Err(error.into())
}
}
}

View File

@ -25,7 +25,7 @@ fn test_project() -> Result<projects::Project> {
}
#[test]
fn test_open_always_with_session() {
fn test_open_creates_reference() {
let storage_path = tempdir().unwrap();
let storage = storage::Storage::from_path(storage_path.path().to_path_buf());
@ -39,7 +39,8 @@ fn test_open_always_with_session() {
assert!(repository.is_ok());
let repository = repository.unwrap();
let sessions = repository.sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert!(sessions[0].hash.is_some());
assert!(repository
.git_repository
.find_reference(&project.refname())
.is_ok());
}

View File

@ -1,7 +1,9 @@
use crate::{deltas, projects, sessions, storage};
use anyhow::{Context, Result};
use serde::Serialize;
use similar::{ChangeTag, TextDiff};
use std::ops::Range;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
@ -9,6 +11,8 @@ use std::{
};
use tantivy::{collector, directory::MmapDirectory, schema, IndexWriter};
const CURRENT_VERSION: u64 = 2; // should not decrease
#[derive(Clone)]
struct MetaStorage {
storage: storage::Storage,
@ -21,73 +25,66 @@ impl MetaStorage {
}
}
pub fn get(&self, project_id: &str, session_hash: &str) -> Result<Option<u128>> {
pub fn get(&self, project_id: &str, session_hash: &str) -> Result<Option<u64>> {
let filepath = Path::new("indexes")
.join("meta")
.join(project_id)
.join(session_hash);
let meta = match self.storage.read(&filepath.to_str().unwrap())? {
None => None,
Some(meta) => meta.parse::<u128>().ok(),
Some(meta) => meta.parse::<u64>().ok(),
};
Ok(meta)
}
pub fn set(&self, project_id: &str, session_hash: &str, ts: u128) -> Result<()> {
pub fn set(&self, project_id: &str, session_hash: &str, version: u64) -> Result<()> {
let filepath = Path::new("indexes")
.join("meta")
.join(project_id)
.join(session_hash);
self.storage
.write(&filepath.to_str().unwrap(), &ts.to_string())?;
.write(&filepath.to_str().unwrap(), &version.to_string())?;
Ok(())
}
}
#[derive(Clone)]
pub struct Deltas {
base_path: PathBuf,
meta_storage: MetaStorage,
indexes: HashMap<String, tantivy::Index>,
readers: HashMap<String, tantivy::IndexReader>,
writers: HashMap<String, Arc<Mutex<tantivy::IndexWriter>>>,
index: tantivy::Index,
reader: tantivy::IndexReader,
writer: Arc<Mutex<tantivy::IndexWriter>>,
}
impl Deltas {
pub fn at(path: PathBuf) -> Self {
Self {
base_path: path.clone(),
meta_storage: MetaStorage::new(path),
readers: HashMap::new(),
writers: HashMap::new(),
indexes: HashMap::new(),
}
}
pub fn at(path: PathBuf) -> Result<Self> {
let dir = path.join("indexes").join("deltas");
fs::create_dir_all(&dir)?;
fn init(&mut self, project_id: &str) -> Result<()> {
if self.indexes.contains_key(project_id) {
return Ok(());
}
let mmap_dir = MmapDirectory::open(dir)?;
let schema = build_schema();
let index_settings = tantivy::IndexSettings {
..Default::default()
};
let index = tantivy::IndexBuilder::new()
.schema(schema)
.settings(index_settings)
.open_or_create(mmap_dir)?;
let index = open_or_create(Path::new(&self.base_path), project_id)?;
let reader = index.reader()?;
let writer = index.writer(WRITE_BUFFER_SIZE)?;
self.readers.insert(project_id.to_string(), reader);
self.writers
.insert(project_id.to_string(), Arc::new(Mutex::new(writer)));
self.indexes.insert(project_id.to_string(), index);
Ok(())
let writer = index.writer_with_num_threads(1, WRITE_BUFFER_SIZE)?;
Ok(Self {
meta_storage: MetaStorage::new(path),
reader,
writer: Arc::new(Mutex::new(writer)),
index,
})
}
pub fn search(&self, project_id: &str, query: &str) -> Result<Vec<SearchResult>> {
match self.readers.get(project_id) {
None => Ok(vec![]),
Some(reader) => {
let index = self.indexes.get(project_id).unwrap();
search(index, reader, query)
}
}
pub fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>> {
search(&self.index, &self.reader, query)
}
pub fn reindex_project(
@ -111,18 +108,26 @@ impl Deltas {
.find_commit(oid)
.with_context(|| format!("Could not find commit {}", oid.to_string()))?;
let session_id = sessions::id_from_commit(repo, &commit)?;
match self.meta_storage.get(&project.id, &session_id)? {
None => {
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())
})?;
}
Some(_) => {}
let version = self
.meta_storage
.get(&project.id, &session_id)?
.unwrap_or(0);
if version == CURRENT_VERSION {
continue;
}
let session = sessions::Session::from_commit(repo, &commit).with_context(|| {
format!("Could not parse 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!(
@ -140,79 +145,45 @@ impl Deltas {
session: &sessions::Session,
) -> Result<()> {
log::info!("Indexing session {} in {}", session.id, project.path);
self.init(&project.id)?;
index(
&self.indexes.get(&project.id).unwrap(),
&mut self.writers.get(&project.id).unwrap().lock().unwrap(),
index_session(
&self.index,
&mut self.writer.lock().unwrap(),
session,
repo,
project,
)?;
self.meta_storage.set(
&project.id,
&session.id,
time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)?
.as_millis(),
)?;
self.meta_storage
.set(&project.id, &session.id, CURRENT_VERSION)?;
Ok(())
}
}
fn build_schema() -> schema::Schema {
let mut schema_builder = schema::Schema::builder();
schema_builder.add_text_field(
"session_id",
schema::STORED, // store the value so we can retrieve it from search results
);
schema_builder.add_u64_field(
"index",
schema::STORED, // store the value so we can retrieve it from search results
);
schema_builder.add_text_field(
"file_path",
schema::TEXT // we want to search on this field, tokenize and index it
| schema::STORED // store the value so we can retrieve it from search results
| schema::FAST, // makes the field faster to filter / sort on
);
schema_builder.add_text_field(
"diff",
schema::TEXT, // we want to search on this field, tokenize and index it
);
schema_builder.add_bool_field(
"is_addition",
schema::FAST, // we want to filter on the field
);
schema_builder.add_u64_field(
"is_deletion",
schema::FAST, // we want to filter on the field
);
schema_builder.add_u64_field("version", schema::INDEXED | schema::FAST);
schema_builder.add_text_field("project_id", schema::TEXT | schema::STORED | schema::FAST);
schema_builder.add_text_field("session_id", schema::STORED);
schema_builder.add_u64_field("index", schema::STORED);
schema_builder.add_text_field("file_path", schema::TEXT | schema::STORED | schema::FAST);
schema_builder.add_text_field("diff", schema::TEXT);
schema_builder.add_bool_field("is_addition", schema::FAST);
schema_builder.add_bool_field("is_deletion", schema::FAST);
schema_builder.add_u64_field("timestamp_ms", schema::INDEXED | schema::FAST);
schema_builder.build()
}
const WRITE_BUFFER_SIZE: usize = 10_000_000; // 10MB
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResult {
pub project_id: String,
pub session_id: String,
pub file_path: String,
pub index: u64,
}
fn open_or_create<P: AsRef<Path>>(base_path: P, project_id: &str) -> Result<tantivy::Index> {
let dir = base_path
.as_ref()
.join("indexes")
.join(&project_id)
.join("deltas");
fs::create_dir_all(&dir)?;
let mmap_dir = MmapDirectory::open(dir)?;
let schema = build_schema();
let index = tantivy::Index::open_or_create(mmap_dir, schema)?;
Ok(index)
}
fn index(
fn index_session(
index: &tantivy::Index,
writer: &mut IndexWriter,
session: &sessions::Session,
@ -243,71 +214,129 @@ fn index(
.collect();
// for every deltas for the file
for (i, delta) in deltas.into_iter().enumerate() {
// for every operation in the delta
for operation in &delta.operations {
let mut doc = tantivy::Document::default();
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(),
);
match operation {
deltas::Operation::Delete((from, len)) => {
// here we use the file_text to calculate the diff
let diff = file_text
.iter()
.skip((*from).try_into()?)
.take((*len).try_into()?)
.collect::<String>();
doc.add_text(index.schema().get_field("diff").unwrap(), diff);
doc.add_bool(index.schema().get_field("is_deletion").unwrap(), true);
}
deltas::Operation::Insert((_from, value)) => {
doc.add_text(index.schema().get_field("diff").unwrap(), value);
doc.add_bool(index.schema().get_field("is_addition").unwrap(), true);
}
}
writer.add_document(doc)?;
// 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;
}
}
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,
pub project_id: String,
pub limit: usize,
pub offset: Option<usize>,
pub range: Range<u64>,
}
pub fn search(
index: &tantivy::Index,
reader: &tantivy::IndexReader,
q: &str,
q: &SearchQuery,
) -> Result<Vec<SearchResult>> {
let query_parser = &tantivy::query::QueryParser::for_index(
let query = tantivy::query::QueryParser::for_index(
index,
vec![
index.schema().get_field("diff").unwrap(),
index.schema().get_field("file_path").unwrap(),
],
);
let query = query_parser.parse_query(q)?;
)
.parse_query(
format!(
"version:\"{}\" AND project_id:\"{}\" AND timestamp_ms:[{} TO {}}} AND ({})",
CURRENT_VERSION, q.project_id, q.range.start, q.range.end, q.q,
)
.as_str(),
)?;
reader.reload()?;
let searcher = reader.searcher();
let top_docs = searcher.search(&query, &collector::TopDocs::with_limit(10))?;
let top_docs = searcher.search(
&query,
&collector::TopDocs::with_limit(q.limit)
.and_offset(q.offset.unwrap_or(0))
.order_by_u64_field(index.schema().get_field("timestamp_ms").unwrap()),
)?;
let results = top_docs
.iter()
.map(|(_score, doc_address)| {
let retrieved_doc = searcher.doc(*doc_address)?;
let project_id = retrieved_doc
.get_first(index.schema().get_field("project_id").unwrap())
.unwrap()
.as_text()
.unwrap();
let file_path = retrieved_doc
.get_first(index.schema().get_field("file_path").unwrap())
.unwrap()
@ -324,6 +353,7 @@ pub fn search(
.as_u64()
.unwrap();
Ok(SearchResult {
project_id: project_id.to_string(),
file_path: file_path.to_string(),
session_id: session_id.to_string(),
index,

View File

@ -1,10 +1,10 @@
use std::path::Path;
use crate::{
deltas::{self, Operation},
projects, sessions,
};
use anyhow::Result;
use core::ops::Range;
use std::path::Path;
use tempfile::tempdir;
fn test_project() -> Result<(git2::Repository, projects::Project)> {
@ -26,6 +26,120 @@ fn test_project() -> Result<(git2::Repository, projects::Project)> {
Ok((repo, project))
}
#[test]
fn test_filter_by_timestamp() {
let (repo, project) = test_project().unwrap();
let index_path = tempdir().unwrap().path().to_str().unwrap().to_string();
let mut session = sessions::Session::from_head(&repo, &project).unwrap();
deltas::write(
&repo,
&project,
Path::new("test.txt"),
&vec![
deltas::Delta {
operations: vec![Operation::Insert((0, "Hello".to_string()))],
timestamp_ms: 0,
},
deltas::Delta {
operations: vec![Operation::Insert((5, "World".to_string()))],
timestamp_ms: 1,
},
deltas::Delta {
operations: vec![Operation::Insert((5, " ".to_string()))],
timestamp_ms: 2,
},
],
)
.unwrap();
session.flush(&repo, &None, &project).unwrap();
let mut searcher = super::Deltas::at(index_path.into()).unwrap();
let write_result = searcher.index_session(&repo, &project, &session);
assert!(write_result.is_ok());
let search_result_from = searcher.search(&super::SearchQuery {
project_id: project.id.clone(),
q: "test.txt".to_string(),
limit: 10,
range: Range { start: 2, end: 10 },
offset: None,
});
assert!(search_result_from.is_ok());
let search_result_from = search_result_from.unwrap();
assert_eq!(search_result_from.len(), 1);
assert_eq!(search_result_from[0].index, 2);
let search_result_to = searcher.search(&super::SearchQuery {
project_id: project.id.clone(),
q: "test.txt".to_string(),
limit: 10,
range: Range { start: 0, end: 1 },
offset: None,
});
assert!(search_result_to.is_ok());
let search_result_to = search_result_to.unwrap();
assert_eq!(search_result_to.len(), 1);
assert_eq!(search_result_to[0].index, 0);
let search_result_from_to = searcher.search(&super::SearchQuery {
project_id: project.id.clone(),
q: "test.txt".to_string(),
limit: 10,
range: Range { start: 1, end: 2 },
offset: None,
});
assert!(search_result_from_to.is_ok());
let search_result_from_to = search_result_from_to.unwrap();
assert_eq!(search_result_from_to.len(), 1);
assert_eq!(search_result_from_to[0].index, 1);
}
#[test]
fn test_sorted_by_timestamp() {
let (repo, project) = test_project().unwrap();
let index_path = tempdir().unwrap().path().to_str().unwrap().to_string();
let mut session = sessions::Session::from_head(&repo, &project).unwrap();
deltas::write(
&repo,
&project,
Path::new("test.txt"),
&vec![
deltas::Delta {
operations: vec![Operation::Insert((0, "Hello".to_string()))],
timestamp_ms: 0,
},
deltas::Delta {
operations: vec![Operation::Insert((5, " World".to_string()))],
timestamp_ms: 1,
},
],
)
.unwrap();
session.flush(&repo, &None, &project).unwrap();
let mut searcher = super::Deltas::at(index_path.into()).unwrap();
let write_result = searcher.index_session(&repo, &project, &session);
assert!(write_result.is_ok());
let search_result = searcher.search(&super::SearchQuery {
project_id: project.id,
q: "hello world".to_string(),
limit: 10,
range: Range { start: 0, end: 10 },
offset: None,
});
assert!(search_result.is_ok());
let search_result = search_result.unwrap();
println!("{:?}", search_result);
assert_eq!(search_result.len(), 2);
assert_eq!(search_result[0].index, 1);
assert_eq!(search_result[1].index, 0);
}
#[test]
fn test_simple() {
let (repo, project) = test_project().unwrap();
@ -50,44 +164,80 @@ fn test_simple() {
.unwrap();
session.flush(&repo, &None, &project).unwrap();
let mut searcher = super::Deltas::at(index_path.into());
let mut searcher = super::Deltas::at(index_path.into()).unwrap();
let write_result = searcher.index_session(&repo, &project, &session);
assert!(write_result.is_ok());
let search_result1 = searcher.search(&project.id, "hello");
let search_result1 = searcher.search(&super::SearchQuery {
project_id: project.id.clone(),
q: "hello".to_string(),
limit: 10,
offset: None,
range: Range { start: 0, end: 10 },
});
println!("{:?}", search_result1);
assert!(search_result1.is_ok());
let search_result1 = search_result1.unwrap();
assert_eq!(search_result1.len(), 1);
assert_eq!(search_result1[0].session_id, session.id);
assert_eq!(search_result1[0].project_id, project.id);
assert_eq!(search_result1[0].file_path, "test.txt");
assert_eq!(search_result1[0].index, 0);
let search_result2 = searcher.search(&project.id, "world");
let search_result2 = searcher.search(&super::SearchQuery {
project_id: project.id.clone(),
q: "world".to_string(),
limit: 10,
offset: None,
range: Range { start: 0, end: 10 },
});
assert!(search_result2.is_ok());
let search_result2 = search_result2.unwrap();
assert_eq!(search_result2.len(), 1);
assert_eq!(search_result2[0].session_id, session.id);
assert_eq!(search_result2[0].project_id, project.id);
assert_eq!(search_result2[0].file_path, "test.txt");
assert_eq!(search_result2[0].index, 1);
let search_result3 = searcher.search(&project.id, "hello world");
let search_result3 = searcher.search(&super::SearchQuery {
project_id: project.id.clone(),
q: "hello world".to_string(),
limit: 10,
offset: None,
range: Range { start: 0, end: 10 },
});
assert!(search_result3.is_ok());
let search_result3 = search_result3.unwrap();
assert_eq!(search_result3.len(), 2);
assert_eq!(search_result3[0].project_id, project.id);
assert_eq!(search_result3[0].session_id, session.id);
assert_eq!(search_result3[0].file_path, "test.txt");
assert_eq!(search_result3[1].session_id, session.id);
assert_eq!(search_result3[1].project_id, project.id);
assert_eq!(search_result3[1].file_path, "test.txt");
let search_by_filename_result = searcher.search(&project.id, "test.txt");
let search_by_filename_result = searcher.search(&super::SearchQuery {
project_id: project.id.clone(),
q: "test.txt".to_string(),
limit: 10,
offset: None,
range: Range { start: 0, end: 10 },
});
assert!(search_by_filename_result.is_ok());
let search_by_filename_result = search_by_filename_result.unwrap();
assert_eq!(search_by_filename_result.len(), 2);
assert_eq!(search_by_filename_result[0].session_id, session.id);
assert_eq!(search_by_filename_result[0].project_id, project.id);
assert_eq!(search_by_filename_result[0].file_path, "test.txt");
let not_found_result = searcher.search("404", "hello world");
let not_found_result = searcher.search(&super::SearchQuery {
project_id: "not found".to_string(),
q: "test.txt".to_string(),
limit: 10,
offset: None,
range: Range { start: 0, end: 10 },
});
assert!(not_found_result.is_ok());
let not_found_result = not_found_result.unwrap();
assert_eq!(not_found_result.len(), 0);

View File

@ -1,6 +1,6 @@
mod deltas;
pub use deltas::Deltas;
pub use deltas::{Deltas, SearchQuery, SearchResult};
#[cfg(test)]
mod deltas_test;

View File

@ -154,7 +154,7 @@ impl Session {
meta,
activity,
};
create(project, &session)?;
create(project, &session).with_context(|| "failed to create current session from head")?;
Ok(session)
}
@ -239,7 +239,7 @@ impl Session {
})
}
pub fn update(&mut self, project: &projects::Project) -> Result<()> {
pub fn touch(&mut self, project: &projects::Project) -> Result<()> {
update(project, self)
}
@ -325,11 +325,11 @@ fn update(project: &projects::Project, session: &mut Session) -> Result<()> {
.as_millis();
let session_path = project.session_path();
log::debug!("{}: Updating current session", session_path.display());
log::debug!("{}: updating current session", project.id);
if session_path.exists() {
write(&session_path, session)
} else {
Err(anyhow!("session does not exist"))
Err(anyhow!("\"{}\" does not exist", session_path.display()))
}
}
@ -346,7 +346,7 @@ fn create(project: &projects::Project, session: &Session) -> Result<()> {
fn delete(project: &projects::Project) -> Result<()> {
let session_path = project.session_path();
log::debug!("{}: Deleting current session", session_path.display());
log::debug!("{}: deleting current session", project.id);
if session_path.exists() {
std::fs::remove_dir_all(session_path)?;
}
@ -410,13 +410,17 @@ pub fn list(
Ok(sessions)
}
// returns list of sessions in reverse chronological order
// except for the first session. The first created session
// is special and used to bootstrap the gitbutler state inside a repo.
// see crate::repositories::init
fn list_persistent(repo: &git2::Repository, reference: &git2::Reference) -> Result<Vec<Session>> {
let head = repo.find_commit(reference.target().unwrap())?;
// list all commits from gitbutler head to the first commit
let mut walker = repo.revwalk()?;
walker.push(head.id())?;
walker.set_sorting(git2::Sort::TIME)?;
walker.set_sorting(git2::Sort::TOPOLOGICAL)?;
let mut sessions: Vec<Session> = vec![];
for id in walker {
@ -428,9 +432,13 @@ fn list_persistent(repo: &git2::Repository, reference: &git2::Reference) -> Resu
repo.path().display()
)
})?;
sessions.push(Session::from_commit(repo, &commit)?);
let session = Session::from_commit(repo, &commit)?;
sessions.push(session);
}
// drop the first session, which is the bootstrap session
sessions.pop();
Ok(sessions)
}
@ -461,7 +469,7 @@ pub fn list_files(
let head_commit = reference.peel_to_commit()?;
let mut walker = repo.revwalk()?;
walker.push(head_commit.id())?;
walker.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?;
walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
let mut session_commit = None;
let mut previous_session_commit = None;
@ -537,8 +545,8 @@ fn flush(
}
session
.update(project)
.with_context(|| format!("failed to update session"))?;
.touch(project)
.with_context(|| format!("failed to touch session"))?;
let wd_index = &mut git2::Index::new()
.with_context(|| format!("failed to create index for working directory"))?;
@ -586,17 +594,24 @@ fn flush(
)
})?;
log::debug!(
"{}: wrote gb commit {}",
repo.workdir().unwrap().display(),
commit_oid
log::info!(
"{}: flushed session {} into commit {}",
project.id,
session.id,
commit_oid,
);
session.hash = Some(commit_oid.to_string());
delete(project).with_context(|| format!("failed to delete session"))?;
delete(project)?;
if let Err(e) = push_to_remote(repo, user, project) {
log::error!("failed to push gb commit {} to remote: {:#}", commit_oid, e);
log::error!(
"{}: failed to push gb commit {} to remote: {:#}",
project.id,
commit_oid,
e
);
}
Ok(())
@ -679,10 +694,18 @@ fn push_to_remote(
// it ignores files that are in the .gitignore
fn build_wd_index(repo: &git2::Repository, index: &mut git2::Index) -> Result<()> {
// create a new in-memory git2 index and open the working one so we can cheat if none of the metadata of an entry has changed
let repo_index = &mut repo.index()?;
let repo_index = &mut repo
.index()
.with_context(|| format!("failed to open repo index"))?;
// add all files in the working directory to the in-memory index, skipping for matching entries in the repo index
let all_files = fs::list_files(repo.workdir().unwrap())?;
let all_files = fs::list_files(repo.workdir().unwrap()).with_context(|| {
format!(
"failed to list files in {}",
repo.workdir().unwrap().display()
)
})?;
for file in all_files {
let file_path = Path::new(&file);
if !repo.is_path_ignored(&file).unwrap_or(true) {
@ -724,7 +747,7 @@ fn add_wd_path(
&& entry.file_size == u32::try_from(metadata.len())?
&& entry.mode == metadata.mode()
{
log::debug!("Using existing entry for {}", file_path.display());
log::debug!("using existing entry for {}", file_path.display());
index.add(&entry).unwrap();
return Ok(());
}
@ -732,7 +755,7 @@ fn add_wd_path(
// something is different, or not found, so we need to create a new entry
log::debug!("Adding wd path: {}", file_path.display());
log::debug!("adding wd path: {}", file_path.display());
// look for files that are bigger than 4GB, which are not supported by git
// insert a pointer as the blob content instead
@ -770,29 +793,30 @@ fn add_wd_path(
};
// create a new IndexEntry from the file metadata
match index.add(&git2::IndexEntry {
ctime: git2::IndexTime::new(
ctime.seconds().try_into()?,
ctime.nanoseconds().try_into().unwrap(),
),
mtime: git2::IndexTime::new(
mtime.seconds().try_into()?,
mtime.nanoseconds().try_into().unwrap(),
),
dev: metadata.dev().try_into()?,
ino: metadata.ino().try_into()?,
mode: 33188,
uid: metadata.uid().try_into().unwrap(),
gid: metadata.gid().try_into().unwrap(),
file_size: metadata.len().try_into().unwrap(),
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
flags_extended: 0, // no extended flags
path: rel_file_path.to_str().unwrap().to_string().into(),
id: blob,
}) {
Ok(_) => Ok(()),
Err(e) => Err(e).with_context(|| "failed to add working directory path".to_string()),
}
index
.add(&git2::IndexEntry {
ctime: git2::IndexTime::new(
ctime.seconds().try_into()?,
ctime.nanoseconds().try_into().unwrap(),
),
mtime: git2::IndexTime::new(
mtime.seconds().try_into()?,
mtime.nanoseconds().try_into().unwrap(),
),
dev: metadata.dev().try_into()?,
ino: metadata.ino().try_into()?,
mode: 33188,
uid: metadata.uid().try_into().unwrap(),
gid: metadata.gid().try_into().unwrap(),
file_size: metadata.len().try_into().unwrap(),
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
flags_extended: 0, // no extended flags
path: rel_file_path.to_str().unwrap().to_string().into(),
id: blob,
})
.with_context(|| format!("failed to add index entry for {}", file_path.display()))?;
Ok(())
}
/// calculates sha256 digest of a large file as lowercase hex string via streaming buffer
@ -874,12 +898,8 @@ fn build_session_index(
let session_dir = project.session_path();
for session_file in fs::list_files(&session_dir)? {
let file_path = Path::new(&session_file);
add_session_path(&repo, index, project, &file_path).with_context(|| {
format!(
"Failed to add session file to index: {}",
file_path.display()
)
})?;
add_session_path(&repo, index, project, &file_path)
.with_context(|| format!("failed to add session file: {}", file_path.display()))?;
}
Ok(())
@ -894,7 +914,7 @@ fn add_session_path(
) -> Result<()> {
let file_path = project.session_path().join(rel_file_path);
log::debug!("Adding session path: {}", file_path.display());
log::debug!("adding session path: {}", file_path.display());
let blob = repo.blob_path(&file_path)?;
let metadata = file_path.metadata()?;
@ -902,26 +922,33 @@ fn add_session_path(
let ctime = FileTime::from_creation_time(&metadata).unwrap_or(mtime);
// create a new IndexEntry from the file metadata
index.add(&git2::IndexEntry {
ctime: git2::IndexTime::new(
ctime.seconds().try_into()?,
ctime.nanoseconds().try_into().unwrap(),
),
mtime: git2::IndexTime::new(
mtime.seconds().try_into()?,
mtime.nanoseconds().try_into().unwrap(),
),
dev: metadata.dev().try_into()?,
ino: metadata.ino().try_into()?,
mode: metadata.mode(),
uid: metadata.uid().try_into().unwrap(),
gid: metadata.gid().try_into().unwrap(),
file_size: metadata.len().try_into()?,
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
flags_extended: 0, // no extended flags
path: rel_file_path.to_str().unwrap().into(),
id: blob,
})?;
index
.add(&git2::IndexEntry {
ctime: git2::IndexTime::new(
ctime.seconds().try_into()?,
ctime.nanoseconds().try_into().unwrap(),
),
mtime: git2::IndexTime::new(
mtime.seconds().try_into()?,
mtime.nanoseconds().try_into().unwrap(),
),
dev: metadata.dev().try_into()?,
ino: metadata.ino().try_into()?,
mode: metadata.mode(),
uid: metadata.uid().try_into().unwrap(),
gid: metadata.gid().try_into().unwrap(),
file_size: metadata.len().try_into()?,
flags: 10, // normal flags for normal file (for the curious: https://git-scm.com/docs/index-format)
flags_extended: 0, // no extended flags
path: rel_file_path.to_str().unwrap().into(),
id: blob,
})
.with_context(|| {
format!(
"Failed to add session file to index: {}",
file_path.display()
)
})?;
Ok(())
}
@ -958,16 +985,20 @@ fn write_gb_commit(
)?;
Ok(new_commit)
}
Err(_) => {
let new_commit = repo.commit(
Some(refname.as_str()),
&author, // author
&comitter, // committer
"gitbutler check", // commit message
&repo.find_tree(gb_tree).unwrap(), // tree
&[], // parents
)?;
Ok(new_commit)
Err(e) => {
if e.code() == git2::ErrorCode::NotFound {
let new_commit = repo.commit(
Some(refname.as_str()),
&author, // author
&comitter, // committer
"gitbutler check", // commit message
&repo.find_tree(gb_tree).unwrap(), // tree
&[], // parents
)?;
Ok(new_commit)
} else {
return Err(e.into());
}
}
}
}

View File

@ -235,16 +235,12 @@ fn test_list() {
first.flush(&repo, &None, &project).unwrap();
assert!(first.hash.is_some());
std::thread::sleep(std::time::Duration::from_millis(1));
let second = super::sessions::Session::from_head(&repo, &project);
assert!(second.is_ok());
let mut second = second.unwrap();
second.flush(&repo, &None, &project).unwrap();
assert!(second.hash.is_some());
std::thread::sleep(std::time::Duration::from_millis(1));
let current_session = super::sessions::Session::from_head(&repo, &project);
assert!(current_session.is_ok());
let current = current_session.unwrap();
@ -254,10 +250,10 @@ fn test_list() {
assert!(sessions.is_ok());
let sessions = sessions.unwrap();
assert_eq!(sessions.len(), 3);
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0], current);
assert_eq!(sessions[1], second);
assert_eq!(sessions[2], first);
// NOTE: first session is not included in the list
}
#[test]

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,35 @@ 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.id, 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) {
@ -62,7 +89,11 @@ impl DeltaWatchers {
if let Err(e) =
sender.send(events::Event::session(&project, &session))
{
log::error!("filed to send session event: {:#}", e)
log::error!(
"{}: failed to send session event: {:#}",
project.id,
e
)
}
if let Err(e) = sender.send(events::Event::detlas(
@ -71,15 +102,23 @@ impl DeltaWatchers {
&deltas,
&relative_file_path,
)) {
log::error!("failed to send deltas event: {:#}", e)
log::error!(
"{}: failed to send deltas event: {:#}",
project.id,
e
)
}
}
Ok(None) => {}
Err(e) => log::error!("failed to register file change: {:#}", e),
Err(e) => log::error!(
"{}: failed to register file change: {:#}",
project.id,
e
),
}
}
}
Err(e) => log::error!("notify event error: {:#}", e),
Err(e) => log::error!("{}: notify event error: {:#}", project.id, e),
}
}
});
@ -97,18 +136,12 @@ impl DeltaWatchers {
// this is what is called when the FS watcher detects a change
// 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(
// returns current project session and calculated deltas, if any.
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,
@ -119,17 +152,21 @@ fn register_file_change(
} else {
// file exists, but content is not utf-8, it's a noop
// TODO: support binary files
log::info!("File is not utf-8, ignoring: {:?}", file_path);
log::info!(
"{}: \"{}\" is not utf-8, ignoring",
project.id,
file_path.display()
);
return Ok(None);
}
}
};
// 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)
// first, get latest file contens to compare with
let latest_contents = get_latest_file_contents(&repo, project, relative_file_path)
.with_context(|| {
format!(
"Failed to get latest file contents for {}",
"failed to get latest file contents for {}",
relative_file_path.display()
)
})?;
@ -137,32 +174,32 @@ fn register_file_change(
// second, get non-flushed file deltas
let deltas = read(project, relative_file_path).with_context(|| {
format!(
"Failed to get current file deltas for {}",
"failed to get current file deltas for {}",
relative_file_path.display()
)
})?;
// depending on the above, we can create TextDocument suitable for calculating deltas
// depending on the above, we can create TextDocument suitable for calculating _new_ deltas
let mut text_doc = match (latest_contents, deltas) {
(Some(latest_contents), Some(deltas)) => TextDocument::new(&latest_contents, deltas)?,
(Some(latest_contents), None) => TextDocument::new(&latest_contents, vec![])?,
(None, Some(deltas)) => TextDocument::from_deltas(deltas)?,
(None, None) => TextDocument::from_deltas(vec![])?,
(Some(latest_contents), Some(deltas)) => TextDocument::new(Some(&latest_contents), deltas)?,
(Some(latest_contents), None) => TextDocument::new(Some(&latest_contents), vec![])?,
(None, Some(deltas)) => TextDocument::new(None, deltas)?,
(None, None) => TextDocument::new(None, vec![])?,
};
if !text_doc.update(&file_contents)? {
return Ok(None);
} else {
// if the file was modified, save the deltas
let deltas = text_doc.get_deltas();
let session = write(repo, project, relative_file_path, &deltas)?;
Ok(Some((session, deltas)))
}
// if the file was modified, save the deltas
let deltas = text_doc.get_deltas();
let session = write(&repo, project, relative_file_path, &deltas)?;
Ok(Some((session, deltas)))
}
// returns last commited file contents from refs/gitbutler/current ref
// if it doesn't exists, fallsback to HEAD
// returns None if file doesn't exist in HEAD
// if ref doesn't exists, returns file contents from the HEAD repository commit
// returns None if file is not found in either of trees
// returns None if file is not UTF-8
// TODO: handle binary files
fn get_latest_file_contents(
@ -172,17 +209,14 @@ fn get_latest_file_contents(
) -> Result<Option<String>> {
let tree_entry = match repo.find_reference(&project.refname()) {
Ok(reference) => {
let gitbutler_tree = reference.peel_to_tree()?;
let gitbutler_tree_path = &Path::new("wd").join(relative_file_path);
let tree_entry = gitbutler_tree.get_path(gitbutler_tree_path);
tree_entry
// "wd/<file_path>" contents from gitbutler HEAD
reference.peel_to_tree()?.get_path(gitbutler_tree_path)
}
Err(e) => {
if e.code() == git2::ErrorCode::NotFound {
let head = repo.head()?;
let tree = head.peel_to_tree()?;
let tree_entry = tree.get_path(relative_file_path);
tree_entry
// "<file_path>" contents from repository HEAD
repo.head()?.peel_to_tree()?.get_path(relative_file_path)
} else {
Err(e)
}
@ -190,28 +224,34 @@ fn get_latest_file_contents(
};
match tree_entry {
Ok(tree_entry) => {
// if file found, check if delta file exists
let blob = tree_entry.to_object(&repo)?.into_blob().unwrap();
// parse blob as utf-8.
// if it's not utf8, return None
let contents = match String::from_utf8(blob.content().to_vec()) {
Ok(contents) => Some(contents),
Err(_) => {
log::info!("File is not utf-8, ignoring: {:?}", relative_file_path);
None
}
};
Ok(contents)
}
Err(e) => {
if e.code() == git2::ErrorCode::NotFound {
// file not found, return None
// file not found in the chosen tree, return None
Ok(None)
} else {
Err(e.into())
}
}
Ok(tree_entry) => {
let blob = tree_entry.to_object(&repo)?.into_blob().expect(&format!(
"{}: failed to get blob for {}",
project.id,
relative_file_path.display()
));
let text_content = match String::from_utf8(blob.content().to_vec()) {
Ok(contents) => Some(contents),
Err(_) => {
log::info!(
"{}: \"{}\" is not utf-8, ignoring",
project.id,
relative_file_path.display()
);
None
}
};
Ok(text_content)
}
}
}

View File

@ -0,0 +1,66 @@
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);
println!("{:?}", result);
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

@ -34,37 +34,27 @@ impl<'a> SessionWatcher {
match self
.projects_storage
.get_project(&project_id)
.with_context(|| {
format!("Error while getting project {} for git watcher", project_id)
})? {
.with_context(|| format!("{}: failed to get project", project_id))?
{
Some(project) => {
log::info!("Checking for session to commit in {}", project.path);
let user = self
.users_storage
.get()
.with_context(|| format!("{}: failed to get user", project.id))?;
let user = self.users_storage.get().with_context(|| {
format!(
"Error while getting user for git watcher in {}",
project.path
)
})?;
match self.check_for_changes(&project, &user)? {
Some(session) => {
sender
.send(events::Event::session(&project, &session))
.with_context(|| {
format!(
"Error while sending session event for git watcher in {}",
project.path
)
format!("{}: failed to send session event", project.id)
})?;
Ok(())
}
None => Ok(()),
}
}
None => {
log::error!("Project {} not found for git watcher", project_id);
Ok(())
}
None => Err(anyhow::anyhow!("project not found")),
}
}
@ -73,7 +63,7 @@ impl<'a> SessionWatcher {
sender: mpsc::Sender<events::Event>,
project: projects::Project,
) -> Result<()> {
log::info!("Watching sessions for {}", project.path);
log::info!("{}: watching sessions in {}", project.id, project.path);
let shared_self = self.clone();
let mut self_copy = shared_self.clone();
@ -81,9 +71,11 @@ impl<'a> SessionWatcher {
tauri::async_runtime::spawn_blocking(move || loop {
let local_self = &mut self_copy;
if let Err(e) = local_self.run(&project_id, sender.clone()) {
log::error!("Error while running git watcher: {:#}", e);
log::error!("{}: error while running git watcher: {:#}", project_id, e);
}
thread::sleep(Duration::from_secs(10));
});
@ -103,18 +95,19 @@ impl<'a> SessionWatcher {
project: &projects::Project,
user: &Option<users::User>,
) -> Result<Option<sessions::Session>> {
let repo = git2::Repository::open(project.path.clone())?;
let repo = git2::Repository::open(project.path.clone())
.with_context(|| format!("{}: failed to open repository", project.id))?;
match session_to_commit(&repo, project)
.with_context(|| "Error while checking for session to commit")?
.with_context(|| "failed to check for session to comit")?
{
None => Ok(None),
Some(mut session) => {
session
.flush(&repo, user, project)
.with_context(|| "Error while flushing session")?;
.with_context(|| format!("failed to flush session {}", session.id))?;
self.deltas_searcher
.index_session(&repo, &project, &session)
.with_context(|| format!("Error while indexing session {}", session.id))?;
.with_context(|| format!("failed to index session {}", session.id))?;
Ok(Some(session))
}
}
@ -128,14 +121,10 @@ fn session_to_commit(
repo: &Repository,
project: &projects::Project,
) -> Result<Option<sessions::Session>> {
match sessions::Session::current(repo, project)? {
None => {
log::debug!(
"No current session to commit for {}",
repo.workdir().unwrap().display()
);
Ok(None)
}
match sessions::Session::current(repo, project)
.with_context(|| format!("{}: failed to get current session", project.id))?
{
None => Ok(None),
Some(current_session) => {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
@ -146,11 +135,19 @@ fn session_to_commit(
let elapsed_start = now - current_session.meta.start_timestamp_ms;
if (elapsed_last > FIVE_MINUTES) || (elapsed_start > ONE_HOUR) {
log::info!(
"{}: ready to commit {} ({} seconds elapsed, {} seconds since start)",
project.id,
project.path,
elapsed_last / 1000,
elapsed_start / 1000
);
Ok(Some(current_session))
} else {
log::debug!(
"Not ready to commit {} yet. ({} seconds elapsed, {} seconds since start)",
repo.workdir().unwrap().display(),
"{}: not ready to commit {} yet. ({} seconds elapsed, {} seconds since start)",
project.id,
project.path,
elapsed_last / 1000,
elapsed_start / 1000
);

View File

@ -0,0 +1,172 @@
use crate::{
deltas::{self, Operation},
projects, repositories, sessions,
};
use anyhow::Result;
use std::path::Path;
use tempfile::tempdir;
fn test_project() -> Result<repositories::Repository> {
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)?;
repositories::Repository::new(project.clone(), repo, None)
}
#[test]
fn test_flush_session() {
let repo = test_project().unwrap();
let relative_file_path = Path::new("test.txt");
std::fs::write(
Path::new(&repo.project.path).join(relative_file_path),
"hello",
)
.unwrap();
let result = super::delta::register_file_change(
&repo.project,
&repo.git_repository,
&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.git_repository, &None, &repo.project)
.unwrap();
assert!(session1.hash.is_some());
std::fs::write(
Path::new(&repo.project.path).join(relative_file_path),
"hello world",
)
.unwrap();
let result = super::delta::register_file_change(
&repo.project,
&repo.git_repository,
&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.git_repository, &None, &repo.project)
.unwrap();
assert!(session2.hash.is_some());
}
#[test]
fn test_flow() {
let repo = test_project().unwrap();
let size = 10;
let relative_file_path = Path::new("test.txt");
for i in 1..=size {
// create a session with a single file change and flush it
std::fs::write(
Path::new(&repo.project.path).join(relative_file_path),
i.to_string(),
)
.unwrap();
let result = super::delta::register_file_change(
&repo.project,
&repo.git_repository,
&relative_file_path,
);
assert!(result.is_ok());
let maybe_session_deltas = result.unwrap();
assert!(maybe_session_deltas.is_some());
let (mut session, deltas) = maybe_session_deltas.unwrap();
assert_eq!(session.hash, None);
assert_eq!(deltas.len(), 1);
session
.flush(&repo.git_repository, &None, &repo.project)
.unwrap();
assert!(session.hash.is_some());
}
// get all the created sessions
let reference = repo
.git_repository
.find_reference(&repo.project.refname())
.unwrap();
let mut sessions = sessions::list(&repo.git_repository, &repo.project, &reference).unwrap();
assert_eq!(sessions.len(), size);
// verify sessions order is correct
let mut last_start = sessions[0].meta.start_timestamp_ms;
let mut last_end = sessions[0].meta.start_timestamp_ms;
sessions[1..].iter().for_each(|session| {
assert!(session.meta.start_timestamp_ms < last_start);
assert!(session.meta.last_timestamp_ms < last_end);
last_start = session.meta.start_timestamp_ms;
last_end = session.meta.last_timestamp_ms;
});
sessions.reverse();
// try to reconstruct file state from operations for every session slice
for i in 0..=sessions.len() - 1 {
let sessions_slice = &mut sessions[i..];
// collect all operations from sessions in the reverse order
let mut operations: Vec<Operation> = vec![];
sessions_slice.iter().for_each(|session| {
let deltas_by_filepath =
deltas::list(&repo.git_repository, &repo.project, &reference, &session.id).unwrap();
for deltas in deltas_by_filepath.values() {
deltas.iter().for_each(|delta| {
delta.operations.iter().for_each(|operation| {
operations.push(operation.clone());
});
});
}
});
let files = sessions::list_files(
&repo.git_repository,
&repo.project,
&reference,
&sessions_slice.first().unwrap().id,
None,
)
.unwrap();
let base_file = files.get(&relative_file_path.to_str().unwrap().to_string());
let mut text: Vec<char> = match base_file {
Some(file) => file.chars().collect(),
None => vec![],
};
for operation in operations {
operation.apply(&mut text).unwrap();
}
assert_eq!(text.iter().collect::<String>(), size.to_string());
}
}

View File

@ -1,13 +1,14 @@
<!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" />
<link rel="stylesheet" type="text/css" href="styles.css" />
%sveltekit.head%
<link rel="stylesheet" href="styles.css">
</head>
<body class="fixed h-full w-full overflow-hidden font-sans antialiased text-base bg-zinc-800">
<body class="fixed h-full w-full overflow-hidden bg-zinc-800 font-sans text-base antialiased">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -1,15 +1,14 @@
<script>
import { IconArrowBigLeftFilled, IconArrowBigRightFilled } from '@tabler/icons-svelte';
let history = window.history;
</script>
<div class="flex items-center justify-center space-x-3 text-zinc-400">
<button
class="p-2 rounded-md hover:text-zinc-200 hover:bg-zinc-700"
class="group rounded-md p-2 hover:bg-zinc-700 hover:text-zinc-200"
title="Go back"
on:click={() => history.back()}
>
<div class="w-4 h-4 flex justify-center items-center">
<div class="flex h-4 w-4 items-center justify-center">
<svg
width="16"
height="12"
@ -22,16 +21,17 @@
clip-rule="evenodd"
d="M14.9998 5H3.41376L6.70676 1.707C7.09776 1.316 7.09776 0.684 6.70676 0.293C6.31576 -0.0979999 5.68376 -0.0979999 5.29276 0.293L0.292762 5.293C-0.0982383 5.684 -0.0982383 6.316 0.292762 6.707L5.29276 11.707C5.48776 11.902 5.74376 12 5.99976 12C6.25576 12 6.51176 11.902 6.70676 11.707C7.09776 11.316 7.09776 10.684 6.70676 10.293L3.41376 7H14.9998C15.5528 7 15.9998 6.552 15.9998 6C15.9998 5.448 15.5528 5 14.9998 5Z"
fill="#5C5F62"
class="group-hover:fill-zinc-50"
/>
</svg>
</div>
</button>
<button
class="p-2 rounded-md hover:text-zinc-200 hover:bg-zinc-700"
class="group rounded-md p-2 hover:bg-zinc-700 hover:text-zinc-200"
title="Go forward"
on:click={() => history.forward()}
>
<div class="w-4 h-4 flex justify-center items-center">
<div class="flex h-4 w-4 items-center justify-center">
<svg
width="16"
height="12"
@ -44,6 +44,7 @@
clip-rule="evenodd"
d="M15.707 5.293L10.707 0.293C10.316 -0.0979999 9.684 -0.0979999 9.293 0.293C8.902 0.684 8.902 1.316 9.293 1.707L12.586 5H1C0.447 5 0 5.448 0 6C0 6.552 0.447 7 1 7H12.586L9.293 10.293C8.902 10.684 8.902 11.316 9.293 11.707C9.488 11.902 9.744 12 10 12C10.256 12 10.512 11.902 10.707 11.707L15.707 6.707C16.098 6.316 16.098 5.684 15.707 5.293Z"
fill="#5C5F62"
class="group-hover:fill-zinc-50"
/>
</svg>
</div>

View File

@ -12,18 +12,20 @@
</script>
<div class="flex flex-row items-center text-zinc-400">
<a class="p-2 rounded-md hover:text-zinc-200 hover:bg-zinc-700" href="/">
<div class="w-4 h-4 flex justify-center items-center">
<a class="button-home group rounded-md p-2 hover:bg-zinc-700 hover:text-zinc-200" href="/">
<div class="flex h-4 w-4 items-center justify-center">
<svg
width="14"
height="14"
viewBox="0 0 14 14"
class="group-hover:fill-zinc-50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.5547 0.16795C7.2188 -0.0559832 6.7812 -0.0559832 6.4453 0.16795L0.8906 3.87108C0.334202 4.24201 0 4.86648 0 5.53518V12C0 13.1046 0.895431 14 2 14H4C4.55228 14 5 13.5523 5 13V9H9V13C9 13.5523 9.44771 14 10 14H12C13.1046 14 14 13.1046 14 12V5.53518C14 4.86648 13.6658 4.24202 13.1094 3.87108L7.5547 0.16795Z"
fill="#5C5F62"
class="group-hover:fill-zinc-300"
/>
</svg>
</div>
@ -31,19 +33,19 @@
{#if $project}
<div class="ml-1">
<Popover>
<div slot="button" class="flex align-item-centerh-5 py-2 px-2 rounded-md hover:bg-zinc-700">
<div class="h-4 flex items-center">
<div slot="button" class="align-item-centerh-5 flex rounded-md py-2 px-2 hover:bg-zinc-700">
<div class="flex h-4 items-center">
{$project.title}
</div>
</div>
<div class="flex flex-col">
<ul class="flex flex-col overflow-y-auto p-2 max-h-[289px]">
<ul class="flex max-h-[289px] flex-col overflow-y-auto p-2">
{#each $projects || [] as p}
<a
href="/projects/{p.id}"
class="
flex items-center
p-2 rounded hover:bg-zinc-700 cursor-pointer"
flex cursor-pointer
items-center rounded p-2 hover:bg-zinc-700"
>
<span class="truncate">
{p.title}
@ -73,7 +75,7 @@
<span class="w-full border-t border-zinc-700" />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="m-2 flex">
<a href="/" class="p-2 w-full rounded hover:bg-zinc-700 cursor-pointer"
<a href="/" class="w-full cursor-pointer rounded p-2 hover:bg-zinc-700"
>Add repository...</a
>
</div>

View File

@ -7,4 +7,10 @@
export let filepath: string;
</script>
<code class="w-full h-full" use:codeviewer={{ doc, deltas, filepath }} />
{#key doc + filepath}
<code
style:color-scheme="dark"
class="h-full w-full"
use:codeviewer={{ doc, deltas, filepath }}
/>
{/key}

View File

@ -77,9 +77,14 @@ const toSelection = (changes: ChangeSet, delta: Delta | undefined): EditorSelect
export default (parent: HTMLElement, { doc, deltas, filepath }: Params) => {
const view = new EditorView({ state: makeBaseState(doc, filepath), parent });
const changes = toChangeSet(deltas, doc.length);
const selection = toSelection(changes, deltas[deltas.length - 1]);
view.dispatch(
view.state.update({
changes: toChangeSet(deltas, doc.length)
scrollIntoView: true,
changes,
selection,
effects: markChanges(selection)
})
);
@ -116,7 +121,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

@ -21,7 +21,7 @@ const changes = StateField.define<DecorationSet>({
export const markChanges = (selection: EditorSelection | undefined) => {
if (selection === undefined) return undefined;
const effects: StateEffect<unknown>[] = selection.ranges
const effects: StateEffect<{ from: number; to: number }>[] = selection.ranges
.filter((r) => !r.empty)
.map(({ from, to }) => addMark.of({ from, to }));

View File

@ -0,0 +1,488 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import FileIcon from './icons/FileIcon.svelte';
import CommitIcon from './icons/CommitIcon.svelte';
import BookmarkIcon from './icons/BookmarkIcon.svelte';
import BranchIcon from './icons/BranchIcon.svelte';
import ContactIcon from './icons/ContactIcon.svelte';
import ProjectIcon from './icons/ProjectIcon.svelte';
import { invoke } from '@tauri-apps/api';
import { goto } from '$app/navigation';
import { shortPath } from '$lib/paths';
import { currentProject } from '$lib/current_project';
import type { Project } from '$lib/projects';
import toast from 'svelte-french-toast';
let showPalette = <string | false>false;
let keysDown = <string[]>[];
let palette: HTMLElement;
let changedFiles = {};
let commitMessage = '';
let commitMessageInput: HTMLElement;
let paletteMode = 'command';
const listFiles = (params: { projectId: string }) =>
invoke<Record<string, string>>('git_status', params);
const matchFiles = (params: { projectId: string; matchPattern: string }) =>
invoke<Array<string>>('git_match_paths', params);
const listBranches = (params: { projectId: string }) =>
invoke<Array<string>>('git_branches', params);
const switchBranch = (params: { projectId: string; branch: string }) =>
invoke<Array<string>>('git_switch_branch', params);
const listProjects = () => invoke<Project[]>('list_projects');
const commit = (params: {
projectId: string;
message: string;
files: Array<string>;
push: boolean;
}) => invoke<boolean>('git_commit', params);
function onKeyDown(event: KeyboardEvent) {
if (event.repeat) return;
keysDown.push(event.key);
switch (event.key) {
case 'Meta':
event.preventDefault();
break;
case 'Escape':
showPalette = false;
paletteMode = 'command';
break;
case 'ArrowDown':
if (showPalette == 'command') {
event.preventDefault();
downMenu();
}
break;
case 'ArrowUp':
if (showPalette == 'command') {
event.preventDefault();
upMenu();
}
break;
case 'Enter':
if (showPalette == 'command') {
event.preventDefault();
selectItem();
}
break;
}
if (keysDown.includes('Meta')) {
if (keysDown.includes('k')) {
showPalette = 'command';
setTimeout(function () {
document.getElementById('command')?.focus();
}, 100);
}
if (keysDown.includes('c')) {
showPalette = 'commit';
executeCommand('commit');
}
if (keysDown.includes('e')) {
executeCommand('contact');
}
if (keysDown.includes('r')) {
executeCommand('branch');
}
}
}
function onKeyUp(event: KeyboardEvent) {
keysDown = keysDown.filter((key) => key !== event.key);
}
function checkPaletteModal(event: Event) {
const target = event.target as HTMLElement;
if (showPalette !== false && !palette.contains(target)) {
showPalette = false;
}
}
let activeClass = ['active', 'bg-zinc-700/50', 'text-white'];
function upMenu() {
const menu = document.getElementById('commandMenu');
if (menu) {
const items = menu.querySelectorAll('li.item');
const active = menu.querySelector('li.active');
if (active) {
const index = Array.from(items).indexOf(active);
if (index > 0) {
items[index - 1].classList.add(...activeClass);
}
active.classList.remove(...activeClass);
} else {
items[items.length - 1].classList.add(...activeClass);
}
// scroll into view
const active2 = menu.querySelector('li.active');
if (active2) {
active2.scrollIntoView({ block: 'nearest' });
}
}
}
function downMenu() {
const menu = document.getElementById('commandMenu');
if (menu) {
const items = menu.querySelectorAll('li.item');
const active = menu.querySelector('li.active');
if (active) {
const index = Array.from(items).indexOf(active);
if (index < items.length - 1) {
items[index + 1].classList.add(...activeClass);
active.classList.remove(...activeClass);
}
} else {
items[0].classList.add(...activeClass);
}
// scroll into view
const active2 = menu.querySelector('li.active');
if (active2) {
active2.scrollIntoView({ block: 'nearest' });
}
}
}
function selectItem() {
showPalette = false;
const menu = document.getElementById('commandMenu');
if (menu) {
const active = menu.querySelector('li.active');
if (active) {
const command = active.getAttribute('data-command');
const context = active.getAttribute('data-context');
if (command) {
executeCommand(command, context);
}
} else {
if ($currentProject) {
goto('/projects/' + $currentProject.id + '/search?search=' + search);
}
}
}
}
function executeCommand(command: string, context?: string | null) {
switch (command) {
case 'commit':
if ($currentProject) {
listFiles({ projectId: $currentProject.id }).then((files) => {
changedFiles = files;
});
showPalette = 'commit';
setTimeout(function () {
commitMessageInput.focus();
}, 100);
}
break;
case 'contact':
goto('/contact');
break;
case 'switch':
goto('/projects/' + context);
break;
case 'bookmark':
break;
case 'branch':
showPalette = 'command';
branchSwitcher();
break;
case 'switchBranch':
if ($currentProject) {
toast.success('Not implelmented yet. :(', {
icon: '🛠️'
});
/*
this is a little dangerous right now, so lets ice it for a bit
switchBranch({ projectId: $currentProject.id, branch: context || '' }).then(() => {
});
*/
}
break;
}
}
let search = '';
$: {
searchChanged(search, showPalette == 'command');
}
let projectCommands = [
{ text: 'Commit', key: 'C', icon: CommitIcon, command: 'commit' },
{ text: 'Bookmark', key: 'B', icon: BookmarkIcon, command: 'bookmark' },
{ text: 'Branch', key: 'R', icon: BranchIcon, command: 'branch' }
];
let switchCommands = [];
$: {
listProjects().then((projects) => {
switchCommands = [];
projects.forEach((p) => {
if (p.id !== $currentProject?.id) {
switchCommands.push({
text: p.title,
icon: ProjectIcon,
command: 'switch',
context: p.id
});
}
});
});
}
let baseCommands = [{ text: 'Contact Us', key: 'E', icon: ContactIcon, command: 'contact' }];
function commandList() {
let commands = [];
let divider = [{ type: 'divider' }];
if ($currentProject) {
commands = projectCommands.concat(divider).concat(switchCommands);
} else {
commands = switchCommands;
}
commands = commands.concat(divider).concat(baseCommands);
return commands;
}
$: menuItems = commandList();
function searchChanged(searchValue: string, showCommand: boolean) {
if (!showCommand) {
search = '';
}
if (searchValue.length == 0 && paletteMode == 'command') {
updateMenu([]);
return;
}
if ($currentProject && searchValue.length > 0) {
const searchPattern = '.*' + Array.from(searchValue).join('(.*)');
matchFiles({ projectId: $currentProject.id, matchPattern: searchPattern }).then((files) => {
let searchResults = [];
files.slice(0, 5).forEach((f) => {
searchResults.push({ text: f, icon: FileIcon });
});
updateMenu(searchResults);
});
}
}
function branchSwitcher() {
paletteMode = 'branch';
if ($currentProject) {
listBranches({ projectId: $currentProject.id }).then((refs) => {
let branches = <Object[]>[];
refs.forEach((b) => {
branches.push({ text: b, icon: BranchIcon, command: 'switchBranch', context: b });
});
menuItems = branches;
});
}
}
function updateMenu(searchResults: Array<{ text: string }>) {
if (searchResults.length == 0) {
menuItems = commandList();
} else {
menuItems = searchResults;
}
}
function doCommit() {
// get checked files
let changedFiles: Array<string> = [];
let doc = document.getElementsByClassName('file-checkbox');
Array.from(doc).forEach((c) => {
if (c.checked) {
changedFiles.push(c.dataset['file']);
}
});
if ($currentProject) {
commit({
projectId: $currentProject.id,
message: commitMessage,
files: changedFiles,
push: false
}).then((result) => {
commitMessage = '';
showPalette = false;
});
}
}
</script>
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:click={checkPaletteModal} />
<div>
{#if showPalette}
<div class="relative z-10" role="dialog" aria-modal="true">
<div
class="fixed inset-0 bg-zinc-900 bg-opacity-80 transition-opacity"
in:fade={{ duration: 50 }}
out:fade={{ duration: 50 }}
/>
<div class="command-palette-modal fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
{#if showPalette == 'command'}
<div
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
bind:this={palette}
class="mx-auto max-w-2xl transform divide-y divide-zinc-500 divide-opacity-20 overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl transition-all"
style="
height: auto;
max-height: 420px;
border-width: 0.5px;
-webkit-backdrop-filter: blur(20px) saturate(190%) contrast(70%) brightness(80%);
backdrop-filter: blur(20px) saturate(190%) contrast(70%) brightness(80%);
background-color: rgba(24, 24, 27, 0.60);
border: 0.5px solid rgba(63, 63, 70, 0.50);"
>
{#if paletteMode == 'command'}
<div class="relative">
<svg
class="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-zinc-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
<input
id="command"
type="text"
bind:value={search}
class="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-white focus:ring-0 sm:text-sm"
placeholder="Search..."
/>
</div>
{/if}
{#if paletteMode == 'branch'}
<div class="p-4 text-lg">Branch Switcher</div>
{/if}
<!-- Default state, show/hide based on command palette state. -->
<ul class="scroll-py-2 divide-y divide-zinc-500 divide-opacity-20 overflow-y-auto">
<li class="p-1">
<ul id="commandMenu" class="text-sm text-zinc-400">
{#each menuItems as item}
{#if item.type == 'divider'}
<li class="my-2 border-t border-zinc-500 border-opacity-20" />
{:else}
<!-- Active: "bg-zinc-800 text-white" -->
<li
class="item group flex cursor-default select-none items-center rounded-md px-3 py-2"
on:click={() => {
executeCommand(item.command);
}}
data-command={item.command}
data-context={item.context}
>
<!-- Active: "text-white", Not Active: "text-zinc-500" -->
<svelte:component this={item.icon} />
<span class="ml-3 flex-auto truncate">{item.text}</span>
{#if item.key}
<span
class="ml-3 flex-none rounded border-b border-black bg-zinc-800 px-1 py-1 text-xs font-semibold text-zinc-400"
>
<kbd class="font-sans"></kbd><kbd class="font-sans">{item.key}</kbd>
</span>
{/if}
</li>
{/if}
{/each}
</ul>
</li>
</ul>
</div>
{/if}
{#if showPalette == 'commit'}
<div
in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }}
bind:this={palette}
class="mx-auto max-w-2xl transform overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl transition-all"
style="
border-width: 0.5px;
border: 0.5px solid rgba(63, 63, 70, 0.50);
-webkit-backdrop-filter: blur(20px) saturate(190%) contrast(70%) brightness(80%);
background-color: rgba(24, 24, 27, 0.6);
"
>
<div class="mb-4 w-full border-b border-zinc-700 p-4 text-lg text-white">
Commit Your Changes
</div>
<div
class="relative m-auto transform overflow-hidden p-2 text-left transition-all sm:w-full sm:max-w-sm"
>
{#if Object.entries(changedFiles).length > 0}
<div>
<div class="">
<h3 class="text-base font-semibold text-zinc-200" id="modal-title">
Commit Message
</h3>
<div class="mt-2">
<div class="mt-2">
<textarea
rows="4"
name="message"
id="commit-message"
bind:this={commitMessageInput}
bind:value={commitMessage}
class="block w-full rounded-md border-0 p-4 text-zinc-200 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:py-1.5 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
on:click={doCommit}
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>Commit Your Changes</button
>
</div>
<div class="mt-4 py-4 text-zinc-200">
<h3 class="text-base font-semibold text-zinc-200" id="modal-title">
Changed Files
</h3>
{#each Object.entries(changedFiles) as file}
<div class="flex flex-row space-x-2">
<div>
<input type="checkbox" class="file-checkbox" data-file={file[0]} checked />
</div>
<div>
{file[1]}
</div>
<div class="font-mono">
{shortPath(file[0])}
</div>
</div>
{/each}
</div>
{:else}
<div class="mx-auto text-center text-white">No changes to commit</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>

View File

@ -27,7 +27,7 @@
<div>
{#if $user}
<button class="text-zinc-400 hover:underline hover:text-red-400" on:click={() => user.delete()}
<button class="text-zinc-400 hover:text-red-400 hover:underline" on:click={() => user.delete()}
>Log out</button
>
{:else if $token !== null}
@ -40,7 +40,7 @@
</p>
{:else}
<button
class="py-1 px-3 rounded text-white bg-blue-400"
class="rounded bg-blue-400 py-1 px-3 text-white"
on:click={() => api.login.token.create().then(token.set)}>Sign up or Log in</button
>
{/if}

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;
@ -66,7 +66,7 @@
in:fadeAndZoomIn={{ duration: 150 }}
out:fade={{ duration: 100 }}
on:mouseup={() => (showPopover = false)}
class="wrapper z-[999] bg-zinc-800 border border-zinc-700 text-zinc-50 rounded shadow-2xl min-w-[180px] max-w-[512px]"
class="wrapper z-[999] min-w-[180px] max-w-[512px] rounded border border-zinc-700 bg-zinc-800 text-zinc-50 shadow-2xl"
style="--popover-top: {`${bottom}px`}; --popover-left: {`${left}px`}"
>
<slot />

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0111.186 0z"
/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 6.878V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0118 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 004.5 9v.878m13.5-3A2.25 2.25 0 0119.5 9v.878m0 0a2.246 2.246 0 00-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0121 12v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6c0-.98.626-1.813 1.5-2.122"
/>
</svg>

After

Width:  |  Height:  |  Size: 565 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z"
/>
</svg>

After

Width:  |  Height:  |  Size: 674 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@ -0,0 +1,14 @@
<svg
class="h-6 w-6 flex-none text-zinc-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1,14 @@
<svg
class="h-6 w-6 flex-none text-zinc-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5l-3.9 19.5m-2.1-19.5l-3.9 19.5"
/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -0,0 +1,15 @@
<svg
class="h-6 w-6 flex-none text-zinc-500"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { formatDistanceToNow } from 'date-fns';
import type { ProcessedSearchResult } from '.';
export let processedResult: ProcessedSearchResult;
</script>
<div class="flex flex-col">
<div class="mb-4">
<p class="mb-2 flex text-lg text-zinc-400">
<span>{processedResult.searchResult.filePath}</span>
<span class="flex-grow" />
<span>{formatDistanceToNow(processedResult.timestamp)} ago</span>
</p>
<div class="rounded-lg border border-zinc-700 bg-[#2F2F33] text-[#EBDBB2] drop-shadow-lg">
{#each processedResult.hunks as hunk, i}
{#if i > 0}
<div class="border-b border-[#52525B]" />
{/if}
<div class="flex flex-col px-6 py-3">
{#each hunk.lines as line}
{#if !line.hidden}
<div class="mb-px flex font-mono leading-4">
<span class="w-6 flex-shrink text-[#928374]"
>{line.lineNumber ? line.lineNumber : ''}</span
>
<pre
class="flex-grow rounded-sm
{line.operation === 'add'
? 'bg-[#14FF00]/20'
: line.operation === 'remove'
? 'bg-[#FF0000]/20'
: ''}
">{line.contentBeforeHit}<span class="rounded-sm bg-[#AC8F2F]">{line.contentAtHit}</span
>{line.contentAfterHit}</pre>
</div>
{:else}
<!-- <span>hidden</span> -->
{/if}
{/each}
</div>
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
export { default as RenderedSearchResult } from './RenderedSearchResult.svelte';
import type { SearchResult } from '$lib/search';
export type ProcessedSearchResultLine = {
hidden: boolean;
contentBeforeHit: string;
contentAtHit: string;
contentAfterHit: string;
operation: string;
lineNumber: number | undefined;
hasKeyword: boolean;
};
export type ProcessedSearchRestultHunk = {
lines: ProcessedSearchResultLine[];
};
export type ProcessedSearchResult = {
searchResult: SearchResult;
hunks: ProcessedSearchRestultHunk[];
timestamp: Date;
};

View File

@ -0,0 +1,134 @@
import { listFiles } from '$lib/sessions';
import { list as listDeltas } from '$lib/deltas';
import type { SearchResult } from '$lib';
import { structuredPatch } from 'diff';
import type { Delta } from '$lib/deltas';
import { Operation } from '$lib/deltas';
import type { ProcessedSearchResult, ProcessedSearchRestultHunk } from '.';
export const processSearchResult = async (
searchResult: SearchResult,
query: string
): Promise<ProcessedSearchResult> => {
const [files, deltas] = await Promise.all([
listFiles({
projectId: searchResult.projectId,
sessionId: searchResult.sessionId,
paths: [searchResult.filePath]
}),
listDeltas({
projectId: searchResult.projectId,
sessionId: searchResult.sessionId
})
]);
const hunks = getDiffHunksWithSearchTerm(
files[searchResult.filePath],
deltas[searchResult.filePath],
searchResult.index,
query
);
const processedHunks = [];
for (let i = 0; i < hunks.length; i++) {
const processedHunk: ProcessedSearchRestultHunk = {
lines: processHunkLines(hunks[i].lines, hunks[i].newStart, query)
};
processedHunks.push(processedHunk);
}
const processedSearchResult: ProcessedSearchResult = {
searchResult: searchResult,
hunks: processedHunks,
timestamp: new Date(deltas[searchResult.filePath][searchResult.index].timestampMs)
};
return processedSearchResult;
};
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,
query: string
) => {
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, query: string) => {
const outLines = [];
let lineNumber = newStart;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
let contentBeforeHit = '';
let querySubstring = '';
let contentAfterHit = '';
if (!line.includes(query)) {
contentBeforeHit = line.slice(1);
} else {
const firstCharIndex = line.indexOf(query);
const lastCharIndex = firstCharIndex + query.length - 1;
contentBeforeHit = line.slice(1, firstCharIndex);
querySubstring = line.slice(firstCharIndex, lastCharIndex + 1);
contentAfterHit = line.slice(lastCharIndex + 1);
}
outLines.push({
hidden: false,
contentBeforeHit: contentBeforeHit,
contentAtHit: querySubstring,
contentAfterHit: contentAfterHit,
operation: line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'unmodified',
lineNumber: !line.startsWith('-') ? lineNumber : undefined,
hasKeyword: line.includes(query)
});
if (!line.startsWith('-')) {
lineNumber++;
}
}
const out = [];
for (let i = 0; i < outLines.length; i++) {
const prevLine = outLines[i - 1];
const nextLine = outLines[i + 1];
const 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;
};

View File

@ -53,7 +53,7 @@
<span class="relative inline-flex">
<a
id="block"
class="inline-flex flex-grow items-center truncate transition ease-in-out duration-150 border px-4 py-2 text-slate-50 rounded-lg {colorFromBranchName(
class="inline-flex flex-grow items-center truncate rounded-lg border px-4 py-2 text-slate-50 transition duration-150 ease-in-out {colorFromBranchName(
session.meta.branch
)}"
title={session.meta.branch}
@ -62,12 +62,12 @@
{toHumanBranchName(session.meta.branch)}
</a>
{#if !session.hash}
<span class="flex absolute h-3 w-3 top-0 right-0 -mt-1 -mr-1" title="Current session">
<span class="absolute top-0 right-0 -mt-1 -mr-1 flex h-3 w-3" title="Current session">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-200 opacity-75"
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-200 opacity-75"
/>
<span
class="relative inline-flex rounded-full h-3 w-3 bg-zinc-200 border border-orange-200"
class="relative inline-flex h-3 w-3 rounded-full border border-orange-200 bg-zinc-200"
/>
</span>
{/if}
@ -91,11 +91,11 @@
<div id="files">
{#await list({ projectId: projectId, sessionId: session.id }) then deltas}
{#each Object.keys(deltas) as delta}
<div class="flex flex-row w-32 items-center">
<div class="w-6 h-6 text-white fill-blue-400">
<div class="flex w-32 flex-row items-center">
<div class="h-6 w-6 fill-blue-400 text-white">
{@html pathToIconSvg(delta)}
</div>
<div class="text-white w-24 truncate">
<div class="w-24 truncate text-white">
{pathToName(delta)}
</div>
</div>

View File

@ -25,26 +25,26 @@
</script>
<div class="relative">
<hr class="h-px bg-slate-400 border-0 z-0" />
<hr class="z-0 h-px border-0 bg-slate-400" />
<div class="absolute inset-0 -mt-1.5">
{#each activities as activity}
<div
class="flex -mx-1.5"
class="-mx-1.5 flex"
style="position:relative; left: {proportionOfTime(activity.timestampMs)}%;"
>
<div
class="w-3 h-3 text-slate-700 z-50 absolute inset-0"
class="absolute inset-0 z-50 h-3 w-3 text-slate-700"
style=""
title="{activity.type}: {activity.message} at {toHumanReadableTime(activity.timestampMs)}"
>
{#if activity.type.startsWith('commit')}
<IconSquareRoundedFilled class="w-3 h-3 text-sky-500 hover:text-sky-600" />
<IconSquareRoundedFilled class="h-3 w-3 text-sky-500 hover:text-sky-600" />
{:else if activity.type.startsWith('merge')}
<IconMapPinFilled class="w-3 h-3 text-green-500 hover:text-green-600" />
<IconMapPinFilled class="h-3 w-3 text-green-500 hover:text-green-600" />
{:else if activity.type.startsWith('rebase')}
<IconCircleHalf2 class="w-3 h-3 text-orange-500 hover:text-orange-600" />
<IconCircleHalf2 class="h-3 w-3 text-orange-500 hover:text-orange-600" />
{:else if activity.type.startsWith('push')}
<IconCircleFilled class="w-3 h-3 text-purple-500 hover:text-purple-600" />
<IconCircleFilled class="h-3 w-3 text-purple-500 hover:text-purple-600" />
{/if}
</div>
</div>

View File

@ -38,7 +38,7 @@
<a
{href}
title={startTime.toLocaleTimeString()}
class="group absolute inset-1 flex flex-col items-center justify-center rounded-lg bg-zinc-300 p-3 leading-5 hover:bg-zinc-200 shadow"
class="group absolute inset-1 flex flex-col items-center justify-center rounded-lg bg-zinc-300 p-3 leading-5 shadow hover:bg-zinc-200"
>
<p class="order-1 font-semibold text-zinc-800">
{toHumanBranchName(label)}

View File

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
import type { Project } from '$lib/projects';
export const currentProject = writable<Project | undefined>(undefined);

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;
};
@ -26,18 +26,32 @@ type DeltasEvent = {
export const list = (params: { projectId: string; sessionId: string }) =>
invoke<Record<string, Delta[]>>('list_deltas', params);
export const subscribe = (
params: { projectId: string; sessionId: string },
callback: (filepath: string, deltas: Delta[]) => void
) => {
log.info(`Subscribing to deltas for ${params.projectId}, ${params.sessionId}`);
return appWindow.listen<DeltasEvent>(
`project://${params.projectId}/sessions/${params.sessionId}/deltas`,
(event) => {
log.info(
`Received deltas for ${params.projectId}, ${params.sessionId}, ${event.payload.filePath}`
);
callback(event.payload.filePath, event.payload.deltas);
}
);
};
export default async (params: { projectId: string; sessionId: string }) => {
const init = await list(params);
const store = writable<Record<string, Delta[]>>(init);
const eventName = `project://${params.projectId}/sessions/${params.sessionId}/deltas`;
await appWindow.listen<DeltasEvent>(eventName, (event) => {
log.info(`Received deltas event ${eventName}`);
subscribe(params, (filepath, newDeltas) =>
store.update((deltas) => ({
...deltas,
[event.payload.filePath]: event.payload.deltas
}));
});
[filepath]: newDeltas
}))
);
return store as Readable<Record<string, Delta[]>>;
};

View File

@ -5,3 +5,4 @@ export * as toasts from './toasts';
export * as sessions from './sessions';
export * as week from './week';
export * as uisessions from './uisessions';
export * from './search';

12
src/lib/paths.ts Normal file
View File

@ -0,0 +1,12 @@
export function shortPath(path: string, max = 3) {
if (path.length < 30) {
return path;
}
const pathParts = path.split('/');
const file = pathParts.pop();
if (pathParts.length > 0) {
const pp = pathParts.map((p) => p.slice(0, max)).join('/');
return `${pp}/${file}`;
}
return file;
}

18
src/lib/search.ts Normal file
View File

@ -0,0 +1,18 @@
import { invoke } from '@tauri-apps/api';
export type SearchResult = {
projectId: string;
sessionId: string;
filePath: string;
// index of the delta in the session.
index: number;
timestampMsGte?: number;
timestampMsLt?: number;
};
export const search = (params: {
projectId: string;
query: string;
limit?: number;
offset?: number;
}) => invoke<SearchResult[]>('search', params);

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

38
src/lib/statuses.ts Normal file
View File

@ -0,0 +1,38 @@
import { invoke } from '@tauri-apps/api';
import { appWindow } from '@tauri-apps/api/window';
import { writable, type Readable } from 'svelte/store';
import { log } from '$lib';
import type { Session } from '$lib/sessions';
export type Status = {
path: string;
status: string;
};
const listFiles = (params: { projectId: string }) =>
invoke<Record<string, string>>('git_status', params);
function convertToStatuses(statusesGit: Record<string, string>): Status[] {
return Object.entries(statusesGit).map((status) => {
return {
path: status[0],
status: status[1]
};
});
}
export default async (params: { projectId: string }) => {
const statusesGit = await listFiles(params);
const statuses = convertToStatuses(statusesGit);
const store = writable(statuses);
appWindow.listen<Session>(`project://${params.projectId}/sessions`, async (event) => {
log.info(`Status: Received sessions event, projectId: ${params.projectId}`);
const statusesGit = await listFiles(params);
const statuses = convertToStatuses(statusesGit);
store.set(statuses);
});
return store as Readable<Status[]>;
};

View File

@ -2,7 +2,7 @@ import toast, { type ToastOptions, type ToastPosition } from 'svelte-french-toas
const defaultOptions = {
position: 'bottom-center' as ToastPosition,
style: 'border-radius: 200px; background: #333; color: #fff;'
style: 'border-radius: 8px; background: black; color: #fff;'
};
export const error = (msg: string, options: ToastOptions = {}) =>

View File

@ -15,7 +15,7 @@
: 'Something went wrong';
</script>
<div class="flex flex-1 h-full">
<div class="flex h-full flex-1">
<h1 class="m-auto text-xl">
{message} :(
</h1>

View File

@ -7,6 +7,8 @@
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
import Breadcrumbs from '$lib/components/Breadcrumbs.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import { currentProject } from '$lib/current_project';
export let data: LayoutData;
const { user, posthog, projects } = data;
@ -18,20 +20,20 @@
user.subscribe(posthog.identify);
</script>
<div class="flex flex-col min-h-full max-h-full h-full text-zinc-400">
<div class="flex h-full max-h-full min-h-full flex-col text-zinc-400">
<header
data-tauri-drag-region
class="flex flex-row items-center border-b select-none pt-1 pb-1 text-zinc-400 border-zinc-700"
class="flex select-none flex-row items-center border-b border-zinc-700 pt-1 pb-1 text-zinc-400"
>
<div class="ml-24">
<BackForwardButtons />
</div>
<div class="ml-6"><Breadcrumbs /></div>
<div class="flex-grow" />
<a href="/users/" class="flex items-center gap-1 mr-4 font-medium hover:text-zinc-200">
<a href="/users/" class="mr-4 flex items-center gap-1 font-medium hover:text-zinc-200">
{#if $user}
{#if $user.picture}
<img class="inline-block w-5 h-5 rounded-full" src={$user.picture} alt="Avatar" />
<img class="inline-block h-5 w-5 rounded-full" src={$user.picture} alt="Avatar" />
{/if}
<span>{$user.name}</span>
{:else}
@ -44,4 +46,5 @@
<slot />
</div>
<Toaster />
<CommandPalette />
</div>

View File

@ -2,11 +2,14 @@
import { open } from '@tauri-apps/api/dialog';
import type { LayoutData } from './$types';
import { toasts } from '$lib';
import { currentProject } from '$lib/current_project';
export let data: LayoutData;
const { projects } = data;
$: currentProject.set(undefined);
const onAddLocalRepositoryClick = async () => {
const selectedPath = await open({
directory: true,
@ -24,15 +27,15 @@
};
</script>
<div class="w-full h-full p-8">
<div class="flex flex-col h-full">
<div class="h-full w-full p-8">
<div class="flex h-full flex-col">
{#if $projects.length == 0}
<div class="h-fill grid grid-cols-2 gap-4 items-center h-full">
<div class="h-fill grid h-full grid-cols-2 items-center gap-4">
<!-- right box, welcome text -->
<div class="flex flex-col space-y-4 content-center p-4">
<div class="text-xl text-zinc-300 p-0 m-0">
<div class="flex flex-col content-center space-y-4 p-4">
<div class="m-0 p-0 text-xl text-zinc-300">
<div class="font-bold">Welcome to GitButler.</div>
<div class="text-lg text-zinc-300 mb-1">More than just version control.</div>
<div class="mb-1 text-lg text-zinc-300">More than just version control.</div>
</div>
<div class="">
GitButler is a tool to help you manage all the local work you do on your code projects.
@ -41,7 +44,7 @@
Think of us as a <strong>code concierge</strong>, a smart assistant for all the coding
related tasks you need to do every day.
</div>
<ul class="text-zinc-400 pt-2 pb-4 space-y-4">
<ul class="space-y-4 pt-2 pb-4 text-zinc-400">
<li class="flex flex-row space-x-3">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -49,7 +52,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8 flex-none"
class="h-8 w-8 flex-none"
>
<path
stroke-linecap="round"
@ -69,7 +72,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8 flex-none"
class="h-8 w-8 flex-none"
>
<path
stroke-linecap="round"
@ -90,7 +93,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-8 h-8 flex-none"
class="h-8 w-8 flex-none"
>
<path
stroke-linecap="round"
@ -109,8 +112,8 @@
<a
rel="noreferrer"
target="_blank"
href="https://help.gitbutler.com"
class="text-base font-semibold leading-7 text-white bg-zinc-700 px-4 py-3 rounded-lg mt-4"
href="https://docs.gitbutler.com"
class="mt-4 rounded-lg bg-zinc-700 px-4 py-3 text-base font-semibold leading-7 text-white"
>
Learn more <span aria-hidden="true"></span></a
>
@ -150,9 +153,9 @@
<div class="select-none p-8">
<div class="flex flex-col">
<div class="flex flex-row justify-between">
<div class="text-xl text-zinc-300 mb-1">
<div class="mb-1 text-xl text-zinc-300">
My Projects
<div class="text-lg text-zinc-500 mb-1">
<div class="mb-1 text-lg text-zinc-500">
All the projects that I am currently assisting you with.
</div>
</div>
@ -167,32 +170,35 @@
</div>
</div>
<div class="h-full max-h-screen overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each $projects as project}
<a class="hover:text-zinc-200 text-zinc-300 text-lg" href="/projects/{project.id}/">
<a
class="text-lg text-zinc-300 hover:text-zinc-200 "
href="/projects/{project.id}/"
>
<div
class="flex flex-col justify-between space-y-1 bg-zinc-700 rounded-lg shadow"
class="flex flex-col justify-between space-y-1 rounded-lg border border-zinc-700 border-t-zinc-600 border-t-[1] bg-zinc-700 shadow"
>
<div class="px-4 py-4 flex-grow-0">
<div class="hover:text-zinc-200 text-zinc-300 text-lg">
<div class="flex-grow-0 px-4 py-4">
<div class="text-lg text-zinc-300 hover:text-zinc-200">
{project.title}
</div>
<div class="text-zinc-500 font-mono break-words">
<div class="break-words text-base text-zinc-500">
{project.path}
</div>
</div>
<div
class="flex-grow-0 text-zinc-500 font-mono border-t border-zinc-600 bg-zinc-600 rounded-b-lg px-3 py-1"
class="flex-grow-0 rounded-b-lg border-t border-zinc-600 bg-zinc-600 px-3 py-1 font-mono text-sm text-zinc-300"
>
{#if project.api}
<div class="flex flex-row items-center space-x-2 ">
<div class="w-2 h-2 bg-green-700 rounded-full" />
<div class="text-zinc-400">syncing</div>
<div class="h-2 w-2 rounded-full bg-green-700" />
<div class="text-zinc-400">Backed-up</div>
</div>
{:else}
<div class="flex flex-row items-center space-x-2 ">
<div class="w-2 h-2 bg-gray-400 rounded-full" />
<div class="text-zinc-400">offline</div>
<div class="h-2 w-2 rounded-full bg-gray-400" />
<div class="text-zinc-400">Offline</div>
</div>
{/if}
</div>
@ -203,12 +209,6 @@
</div>
</div>
</div>
<div class="absolute bottom-0 left-0 w-full">
<div class="flex items-center flex-shrink-0 p-4 h-18 border-t select-none border-zinc-700">
<div class="text-sm text-zinc-300">Timeline</div>
</div>
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,74 @@
<div class="isolate py-8 text-zinc-200">
<div class="mx-auto max-w-2xl sm:text-center">
<h2 class="text-xl font-bold tracking-tight">Contact Us</h2>
<p class="mt-2 text-lg leading-8 text-zinc-300">
Thanks for using GitButler! We would love to help with any questions, problems or requests.
Select your weapon of choice.
</p>
</div>
<div class="mx-auto mt-8 max-w-lg space-y-16">
<div class="flex gap-x-6">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-600">
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold leading-7 text-zinc-100">Email Us</h3>
<p class="mt-2 text-zinc-400">
We love our emails. For bugs, feature requests, general questions or anything else, just
fire off an emain from your favorite client.
</p>
<p class="mt-4">
<a class="text-blue-200" href="mailto:hello@gitbutler.com">hello@gitbutler.com</a>
</p>
</div>
</div>
<div class="flex gap-x-6">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-600">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold leading-7 text-zinc-100">Chat with Us</h3>
<p class="mt-2 text-zinc-400">
If you prefer chatting in public, join our Discord community for something a little more
real time.
</p>
<p class="mt-4">
<a
target="_blank"
rel="noopener noreferrer"
href="https://discord.com/channels/1060193121130000425/1060193121666863156"
class="text-sm font-semibold leading-6 text-blue-200"
>Join our Discord <span aria-hidden="true">&rarr;</span></a
>
</p>
</div>
</div>
</div>
</div>

View File

@ -5,10 +5,12 @@
import type { Project } from '$lib/projects';
import { onDestroy } from 'svelte';
import { page } from '$app/stores';
import { currentProject } from '$lib/current_project';
export let data: LayoutData;
$: project = data.project;
$: currentProject.set($project);
function projectUrl(project: Project) {
const gitUrl = project.api?.git_url;
@ -29,16 +31,56 @@
$: selection = $page?.route?.id?.split('/')?.[3];
</script>
<div class="flex w-full h-full flex-col">
<div class="flex h-full w-full flex-col">
<nav
class="flex items-center flex-none justify-between py-1 px-8 space-x-3 border-b select-none text-zinc-300 border-zinc-700"
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 min-w-0 flex-1 rounded-none rounded-l-md border-0 border-r-0 bg-zinc-900 py-1.5 pl-3 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 border border-l-0 border-zinc-700 bg-zinc-900 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="h-6 w-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>
<a href="/projects/{$project?.id}/settings" class="text-zinc-400 hover:text-zinc-300">
<div class="p-1 rounded-md hover:text-zinc-200 hover:bg-zinc-700 hover:bg-zinc-700">
<div class="rounded-md p-1 hover:bg-zinc-700 hover:bg-zinc-700 hover:text-zinc-200">
<div class="h-6 w-6 ">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -46,7 +88,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
class="h-6 w-6"
>
<path
stroke-linecap="round"
@ -66,28 +108,37 @@
</ul>
</nav>
<div class="flex-auto overflow-auto">
<div class="project-container flex-auto overflow-auto">
<slot />
</div>
<footer class="w-full text-sm font-medium">
<div
class="flex items-center flex-shrink-0 h-6 border-t select-none border-zinc-700 bg-zinc-800 "
class="flex h-8 flex-shrink-0 select-none items-center border-t border-zinc-700 bg-zinc-800 "
>
<div class="flex flex-row mx-4 items-center space-x-2 justify-between w-full">
<div class="mx-4 flex w-full flex-row items-center justify-between space-x-2">
{#if $project?.api?.sync}
<a href="/projects/{$project?.id}/settings" class="text-zinc-400 hover:text-zinc-300">
<div class="flex flex-row items-center space-x-2 ">
<div class="w-2 h-2 bg-green-700 rounded-full" />
<div class="h-2 w-2 rounded-full bg-green-700" />
<div>Syncing</div>
</div>
</a>
<a target="_blank" rel="noreferrer" href={projectUrl($project)}>Open in GitButler Cloud</a
>
<a target="_blank" rel="noreferrer" href={projectUrl($project)} class="flex">
<div class="leading-5">Open in GitButler Cloud</div>
<div class="icon ml-1 h-5 w-5">
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
><path
fill="#52525B"
d="M14 13v1a1 1 0 01-1 1H6c-.575 0-1-.484-1-1V7a1 1 0 011-1h1c1.037 0 1.04 1.5 0 1.5-.178.005-.353 0-.5 0v6h6V13c0-1 1.5-1 1.5 0zm-3.75-7.25A.75.75 0 0111 5h4v4a.75.75 0 01-1.5 0V7.56l-3.22 3.22a.75.75 0 11-1.06-1.06l3.22-3.22H11a.75.75 0 01-.75-.75z"
/></svg
>
</div>
</a>
{:else}
<a href="/projects/{$project?.id}/settings" class="text-zinc-400 hover:text-zinc-300">
<div class="flex flex-row items-center space-x-2 ">
<div class="w-2 h-2 bg-red-700 rounded-full" />
<div class="h-2 w-2 rounded-full bg-red-700" />
<div>Offline</div>
</div>
</a>

View File

@ -1,9 +1,42 @@
import type { LayoutLoad } from './$types';
import { building } from '$app/environment';
import { readable, derived } from 'svelte/store';
import type { Session } from '$lib/sessions';
import type { Status } from '$lib/statuses';
import type { Activity } from '$lib/sessions';
export const prerender = false;
export const load: LayoutLoad = async ({ parent, params }) => {
const { projects } = await parent();
const filesStatus = building
? readable<Status[]>([])
: await (await import('$lib/statuses')).default({ projectId: params.projectId });
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);
});
const recentActivity = derived(sessions, (sessions) => {
const recentActivity: Activity[] = [];
sessions.forEach((session) => {
session.activity.forEach((activity) => {
recentActivity.push(activity);
});
});
const activitySorted = recentActivity.sort((a, b) => {
return b.timestampMs - a.timestampMs;
});
return activitySorted.slice(0, 20);
});
return {
project: projects.get(params.projectId)
project: projects.get(params.projectId),
projectId: params.projectId,
sessions: orderedSessions,
filesStatus: filesStatus,
recentActivity: recentActivity
};
};

View File

@ -1,17 +1,263 @@
<script lang="ts">
import type { LayoutData } from './$types';
import type { Readable } from 'svelte/store';
import type { Session } from '$lib/sessions';
import { startOfDay } from 'date-fns';
import type { Activity } from '$lib/sessions';
import type { Delta } from '$lib/deltas';
import { shortPath } from '$lib/paths';
import { invoke } from '@tauri-apps/api';
import { toHumanBranchName } from '$lib/branch';
import { list as listDeltas } from '$lib/deltas';
const getBranch = (params: { projectId: string }) => invoke<string>('git_branch', params);
export let data: LayoutData;
$: project = data.project;
$: filesStatus = data.filesStatus;
$: recentActivity = data.recentActivity as Readable<Activity[]>;
$: sessions = data.sessions;
let latestDeltasByDateByFile: Record<number, Record<string, Delta[][]>[]> = {};
$: if ($project) {
latestDeltasByDateByFile = {};
const dateSessions: Record<number, Session[]> = {};
$sessions.forEach((session) => {
const date = startOfDay(new Date(session.meta.startTimestampMs));
if (dateSessions[date.getTime()]) {
dateSessions[date.getTime()]?.push(session);
} else {
dateSessions[date.getTime()] = [session];
}
});
const latestDateSessions: Record<number, Session[]> = Object.fromEntries(
Object.entries(dateSessions)
.sort((a, b) => parseInt(b[0]) - parseInt(a[0]))
.slice(0, 3)
); // Only show the last 3 days
Object.keys(latestDateSessions).forEach((date: string) => {
Promise.all(
latestDateSessions[parseInt(date)].map(async (session) => {
const sessionDeltas = await listDeltas({
projectId: $project?.id ?? '',
sessionId: session.id
});
const fileDeltas: Record<string, Delta[][]> = {};
Object.keys(sessionDeltas).forEach((filePath) => {
if (sessionDeltas[filePath].length > 0) {
if (fileDeltas[filePath]) {
fileDeltas[filePath]?.push(sessionDeltas[filePath]);
} else {
fileDeltas[filePath] = [sessionDeltas[filePath]];
}
}
});
return fileDeltas;
})
).then((sessionsByFile) => {
latestDeltasByDateByFile[parseInt(date)] = sessionsByFile;
});
});
}
let gitBranch = <string | undefined>undefined;
$: if ($project) {
getBranch({ projectId: $project?.id }).then((branch) => {
gitBranch = branch;
});
}
// convert a list of timestamps to a sparkline
function timestampsToSpark(tsArray: number[]) {
let range = tsArray[0] - tsArray[tsArray.length - 1];
let totalBuckets = 18;
let bucketSize = range / totalBuckets;
let buckets: number[][] = [];
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);
}
});
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: Record<string, Delta[][]>[]): Record<string, number[]> {
let sessionsByFile: Record<string, number[]> = {};
for (const s of sessions) {
for (const [filename, deltas] of Object.entries(s)) {
let timestamps = deltas.flatMap((d) => d.map((dd) => dd.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<number, Record<string, Delta[][]>[]>) {
return Object.entries(dateSessions).map(([date, sessions]) => {
return [date, sessionFileMap(sessions)];
});
}
</script>
<div class="flex flex-col mt-12">
<h1 class="text-zinc-200 text-xl flex justify-center">
Overview of {$project?.title}
</h1>
<div class="flex justify-center space-x-2 text-lg">
<a href="/projects/{$project?.id}/timeline" class="hover:text-zinc-200 text-orange-400"
>Timeline</a
<div class="project-section-component" style="height: calc(100vh - 118px); overflow: hidden;">
<div class="flex h-full">
<div
class="main-column-containercol-span-2 mt-4"
style="width: calc(100% * 0.66); height: calc(-126px + 100vh)"
>
<h1 class="flex py-4 px-8 text-xl text-zinc-300">
{$project?.title} <span class="ml-2 text-zinc-600">Project</span>
</h1>
<div class="mt-4">
<div class="recent-file-changes-container h-full w-full">
<h2 class="mb-4 px-8 text-lg font-bold text-zinc-300">Recent File Changes</h2>
{#if latestDeltasByDateByFile === undefined}
<div class="p-8 text-center text-zinc-400">Loading...</div>
{:else}
<div
class="flex flex-col space-y-4 overflow-y-auto px-8 pb-8"
style="height: calc(100vh - 253px);"
>
{#each orderedSessions(latestDeltasByDateByFile) as [dateMilliseconds, fileSessions]}
<div class="flex flex-col">
<div class="mb-1 text-zinc-300">
{new Date(parseInt(dateMilliseconds)).toLocaleDateString('en-us', {
weekday: 'long',
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
<div
class="results-card rounded border border-zinc-700 bg-[#2F2F33] p-4 drop-shadow-lg"
>
{#each Object.entries(fileSessions) as filetime}
<div class="flex flex-row justify-between">
<div class="font-mono text-zinc-100">{filetime[0]}</div>
<div class="font-mono text-zinc-400">
{@html timestampsToSpark(filetime[1])}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<div
class="secondary-column-container col-span-1 flex flex-col border-l border-l-zinc-700"
style="width: 37%;"
>
<div class="work-in-progress-container border-b border-zinc-700 py-4 px-4">
<h2 class="mb-2 text-lg font-bold text-zinc-300">Work in Progress</h2>
{#if gitBranch}
<div class="pb-3">
<div class="text-zinc-500 leading-none">Branch:</div>
<div class="text-zinc-300 font-mono">{toHumanBranchName(gitBranch)}</div>
</div>
{/if}
{#if $filesStatus.length == 0}
<div
class="flex rounded border border-green-700 bg-green-900 p-4 align-middle text-green-400"
>
<div class="icon mr-2 h-5 w-5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
><path
fill="#4ADE80"
fill-rule="evenodd"
d="M2 10a8 8 0 1 0 16 0 8 8 0 0 0-16 0Zm12.16-1.44a.8.8 0 0 0-1.12-1.12L9.2 11.28 7.36 9.44a.8.8 0 0 0-1.12 1.12l2.4 2.4c.32.32.8.32 1.12 0l4.4-4.4Z"
/></svg
>
</div>
Everything is committed
</div>
{:else}
<div class="rounded border border-yellow-400 bg-yellow-500 p-4 font-mono text-yellow-900">
<ul class="pl-4">
{#each $filesStatus as activity}
<li class="list-disc ">
{activity.status.slice(0, 1)}
{shortPath(activity.path)}
</li>
{/each}
</ul>
</div>
<!-- TODO: Button needs to be hooked up -->
<div class="w-100 flex flex-row-reverse">
<button class="button mt-2 rounded bg-blue-600 py-2 px-3 text-white"
>Commit changes</button
>
</div>
{/if}
</div>
<div
class="recent-activity-container p-4"
style="height: calc(100vh - 110px); overflow-y: auto;"
>
<h2 class="text-lg font-bold text-zinc-300">Recent Activity</h2>
{#each $recentActivity as activity}
<div
class="recent-activity-card mt-4 mb-1 rounded border border-zinc-700 text-zinc-400 drop-shadow-lg"
>
<div class="flex flex-col rounded bg-[#2F2F33] p-3">
<div class="flex flex-row justify-between pb-2 text-zinc-500">
<div class="">
{new Date(activity.timestampMs).toLocaleDateString('en-us', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
<div class="text-right font-mono ">{activity.type}</div>
</div>
<div class="rounded-b bg-[#2F2F33] text-zinc-100">{activity.message}</div>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,233 @@
<script lang="ts">
import type { PageData } from './$types';
import { listFiles } 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();
$: minVisibleTimestamp = Math.max(
Math.min(currentTimestamp - 12 * 60 * 60 * 1000, $sessions[0].meta.startTimestampMs),
$sessions.at(-1)!.meta.startTimestampMs
);
let maxVisibleTimestamp = new Date().getTime();
onMount(() => {
const inverval = setInterval(() => {
maxVisibleTimestamp = new Date().getTime();
}, 1000);
return () => clearInterval(inverval);
});
$: visibleSessions = $sessions.filter(
(session) =>
session.meta.startTimestampMs >= minVisibleTimestamp ||
session.meta.lastTimestampMs >= minVisibleTimestamp
);
$: earliestVisibleSession = visibleSessions.at(-1)!;
let deltasBySessionId: Record<string, Promise<Record<string, Delta[]>>> = {};
$: visibleSessions
.filter((s) => deltasBySessionId[s.id] === undefined)
.forEach((s) => {
deltasBySessionId[s.id] = listDeltas({
projectId: data.projectId,
sessionId: s.id
});
});
let docsBySessionId: Record<string, Promise<Record<string, string>>> = {};
$: if (docsBySessionId[earliestVisibleSession.id] === undefined) {
docsBySessionId[earliestVisibleSession.id] = listFiles({
projectId: data.projectId,
sessionId: earliestVisibleSession.id
});
}
$: visibleDeltasByFilepath = Promise.all(
visibleSessions.map((s) => deltasBySessionId[s.id])
).then((deltasBySessionId) =>
Object.values(deltasBySessionId).reduce((acc, deltasByFilepath) => {
Object.entries(deltasByFilepath).forEach(([filepath, deltas]) => {
deltas = deltas.filter((delta) => delta.timestampMs <= currentTimestamp);
if (acc[filepath] === undefined) acc[filepath] = [];
acc[filepath].push(...deltas);
});
return acc;
}, {} as Record<string, Delta[]>)
);
let frame: {
filepath: string;
doc: string;
deltas: Delta[];
} | null = null;
$: visibleDeltasByFilepath
.then(
(visibleDeltasByFilepath) =>
Object.entries(visibleDeltasByFilepath)
.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
)
.then(async (visibleFilepath) => {
if (visibleFilepath !== null) {
frame = {
deltas:
(await visibleDeltasByFilepath.then((deltasByFilepath) =>
deltasByFilepath[visibleFilepath].sort((a, b) => a.timestampMs - b.timestampMs)
)) || [],
doc:
(await docsBySessionId[earliestVisibleSession.id].then(
(docsByFilepath) => docsByFilepath[visibleFilepath]
)) || '',
filepath: visibleFilepath
};
}
});
// 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 });
};
$: visibleRanges = visibleSessions
.map(
({ meta }) =>
[
Math.max(meta.startTimestampMs, minVisibleTimestamp),
Math.min(meta.lastTimestampMs, maxVisibleTimestamp)
] as [number, number]
)
.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>
{#if $sessions.length === 0}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<h2 class="text-xl">I haven't seen any changes yet</h2>
<p class="text-gray-500">Go code something!</p>
</div>
</div>
{:else}
<div class="flex h-full flex-col gap-2">
{#if frame !== null}
<header>
<h2 class="px-4 pt-2 text-xl text-zinc-300">{frame.filepath}</h2>
</header>
<div class="project-container flex-auto overflow-auto">
<CodeViewer filepath={frame.filepath} doc={frame.doc} deltas={frame.deltas} />
</div>
{/if}
<div id="timeline" class="relative w-full py-4 px-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>
{/if}

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

@ -0,0 +1,47 @@
<script lang="ts">
import type { PageData } from './$types';
import { search } from '$lib';
import { onMount } from 'svelte';
import { RenderedSearchResult, type ProcessedSearchResult } from '$lib/components/search';
import { processSearchResult } from '$lib/components/search/process';
export let data: PageData;
const { project } = data;
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('search');
onMount(async () => {
if (!$project) return;
if (query) fetchResults($project.id, query);
});
let processedResults = [] as ProcessedSearchResult[];
const fetchResults = async (projectId: string, query: string) => {
const result = await search({ projectId, query });
for (const r of result) {
const processedResult = await processSearchResult(r, query);
processedResults = [...processedResults, processedResult];
}
};
</script>
<figure class="flex flex-col gap-2">
<div class="mx-14 ">
{#if processedResults.length > 0}
<div class="mb-10 mt-14">
<p class="mb-2 text-xl text-[#D4D4D8]">Results for "{query}"</p>
<p class="text-lg text-[#717179]">{processedResults.length} change instances</p>
</div>
{/if}
<ul class="flex flex-col gap-4">
{#each processedResults as r}
<li>
<RenderedSearchResult processedResult={r} />
</li>
{/each}
</ul>
</div>
</figure>

View File

@ -78,9 +78,9 @@
};
</script>
<div class="p-4 mx-auto h-full overflow-auto">
<div class="max-w-2xl mx-auto p-4">
<div class="flex flex-col text-zinc-100 space-y-6">
<div class="mx-auto h-full overflow-auto p-4">
<div class="mx-auto max-w-2xl p-4">
<div class="flex flex-col space-y-6 text-zinc-100">
<div class="space-y-0">
<div class="text-xl font-medium">Project Settings</div>
<div class="text-zinc-400">
@ -92,7 +92,7 @@
<div class="space-y-2">
<div class="ml-1">GitButler Cloud</div>
<div
class="flex flex-row justify-between border border-zinc-600 rounded-lg p-2 items-center"
class="flex flex-row items-center justify-between rounded-lg border border-zinc-600 p-2"
>
<div class="flex flex-row space-x-3">
<svg
@ -101,7 +101,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="white"
class="w-6 h-6"
class="h-6 w-6"
>
<path
stroke-linecap="round"
@ -113,11 +113,11 @@
{#if $project?.api?.git_url}
<div class="flex flex-col">
<div class="text-zinc-300">Git Host</div>
<div class="text-zinc-400 font-mono">
<div class="font-mono text-zinc-400">
{hostname($project?.api?.git_url)}
</div>
<div class="text-zinc-300 mt-3">Repository ID</div>
<div class="text-zinc-400 font-mono">
<div class="mt-3 text-zinc-300">Repository ID</div>
<div class="font-mono text-zinc-400">
{repo_id($project?.api?.git_url)}
</div>
</div>
@ -140,7 +140,7 @@
</div>
{:else}
<div class="space-y-2">
<div class="flex flex-row space-x-2 items-end">
<div class="flex flex-row items-end space-x-2">
<div class="">GitButler Cloud</div>
<div class="text-zinc-400">backup your work and access advanced features</div>
</div>
@ -158,7 +158,7 @@
id="path"
name="path"
type="text"
class="p-2 text-zinc-300 bg-black border border-zinc-600 rounded-lg w-full"
class="w-full rounded-lg border border-zinc-600 bg-zinc-900 p-2 text-zinc-300"
value={$project?.path}
/>
</div>
@ -168,7 +168,7 @@
id="name"
name="name"
type="text"
class="p-2 text-zinc-300 bg-black border border-zinc-600 rounded-lg w-full"
class="w-full rounded-lg border border-zinc-600 bg-zinc-900 p-2 text-zinc-300"
value={$project?.title}
required
/>
@ -179,7 +179,7 @@
id="description"
name="description"
rows="3"
class="p-2 text-zinc-300 bg-black border border-zinc-600 rounded-lg w-full"
class="w-full rounded-lg border border-zinc-600 bg-zinc-900 p-2 text-zinc-300"
value={$project?.api?.description}
/>
</div>
@ -188,13 +188,13 @@
<footer>
{#if saving}
<div
class="flex w-32 flex-row w-content items-center gap-1 justify-center py-2 px-3 rounded text-white bg-blue-400"
class="w-content flex w-32 flex-row items-center justify-center gap-1 rounded bg-blue-400 py-2 px-3 text-white"
>
<IconRotateClockwise2 class="w-5 h-5 animate-spin" />
<IconRotateClockwise2 class="h-5 w-5 animate-spin" />
<span>Updating...</span>
</div>
{:else}
<button type="submit" class="py-2 px-3 rounded text-white bg-blue-600"
<button type="submit" class="rounded bg-blue-600 py-2 px-3 text-white"
>Update profile</button
>
{/if}

View File

@ -83,8 +83,6 @@
});
}
let animatingOut = false;
const timeStampToCol = (deltaTimestamp: Date, start: Date, end: Date) => {
if (deltaTimestamp < start || deltaTimestamp > end) {
console.error(
@ -151,31 +149,31 @@
{#if $dateSessions === undefined}
<span>Loading...</span>
{:else}
<div class="h-full flex flex-row space-x-12 px-4 py-4 pb-6">
<div class="flex h-full flex-row space-x-12 px-4 py-4 pb-6">
{#each Object.entries($dateSessions) as [dateMilliseconds, uiSessions]}
<!-- Day -->
<div
id={dateMilliseconds}
class="session-day-component flex flex-col bg-zinc-800/50 rounded-lg border border-zinc-700"
class="session-day-component flex flex-col rounded-lg border border-zinc-700 bg-[#2F2F33]"
class:min-w-full={selection.dateMilliseconds == +dateMilliseconds}
>
<div
class="session-day-container font-medium border-b border-zinc-700 bg-zinc-700/30 flex items-center py-2 px-4"
class="session-day-container flex items-center border-b border-zinc-700 bg-zinc-700/30 py-2 px-4 font-medium"
>
<span class="session-day-header text-zinc-200 font-bold">
<span class="session-day-header font-bold text-zinc-200">
{formatDate(new Date(+dateMilliseconds))}
</span>
</div>
{#if selection.dateMilliseconds !== +dateMilliseconds}
<div class="flex flex-col flex-auto">
<div class="h-2/3 flex space-x-2 p-3">
<div class="flex flex-auto flex-col">
<div class="flex h-2/3 space-x-2 p-3">
{#each uiSessions as uiSession, i}
<!-- Session (overview) -->
<div class="session-column-container flex flex-col w-40">
<div class="session-column-container flex w-40 flex-col">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="repository-name cursor-pointer text-sm text-center font-bold rounded borded text-zinc-800 p-1 border bg-orange-400 border-orange-400 hover:bg-[#fdbc87]"
class="repository-name borded cursor-pointer rounded border border-orange-400 bg-orange-400 p-1 text-center text-sm font-bold text-zinc-800 hover:bg-[#fdbc87]"
on:click={() => expandSession(i, uiSession, +dateMilliseconds)}
>
{toHumanBranchName(uiSession.session.meta.branch)}
@ -192,12 +190,12 @@
</div>
<div class="flex flex-col p-1" id="sessions-details">
<div class="text-zinc-400 font-medium">
<div class="font-medium text-zinc-400">
{formatTime(new Date(uiSession.earliestDeltaTimestampMs))}
-
{formatTime(new Date(uiSession.latestDeltaTimestampMs))}
</div>
<div class="text-zinc-500 text-sm" title="Session duration">
<div class="text-sm text-zinc-500" title="Session duration">
{Math.round(
(uiSession.latestDeltaTimestampMs - uiSession.earliestDeltaTimestampMs) /
1000 /
@ -208,12 +206,12 @@
{#each Object.keys(uiSession.deltas) as filePath}
<button
on:click={() => expandSession(i, uiSession, +dateMilliseconds, filePath)}
class="cursor-pointer flex flex-row w-32 items-center"
class="flex w-32 cursor-pointer flex-row items-center"
>
<div class="w-6 h-6 text-zinc-200 fill-blue-400">
<div class="h-6 w-6 fill-blue-400 text-zinc-200">
{@html pathToIconSvg(filePath)}
</div>
<div class="file-name text-zinc-300 hover:text-zinc-50 w-24 truncate">
<div class="file-name w-24 truncate text-zinc-300 hover:text-zinc-50">
{pathToName(filePath)}
</div>
</button>
@ -223,12 +221,12 @@
</div>
{/each}
</div>
<div class="day-summary-container h-1/3 p-4 border-t border-zinc-400 ">
<div class="day-summary-container h-1/3 border-t border-zinc-400 p-4 ">
<div class="day-summary-header font-bold text-zinc-200">Day summary</div>
</div>
</div>
{:else}
<div class="my-2 flex-auto overflow-auto flex flex-row space-x-2">
<div class="my-2 flex flex-auto flex-row space-x-2 overflow-auto">
<div class="">
<button
on:click={() => {
@ -242,14 +240,14 @@
}}
class="{selection.sessionIdx == 0
? 'disabled cursor-default brightness-50'
: 'hover:bg-[#fdbc87]'} rounded-r bg-orange-400 border border-orange-400 text-zinc-800 p-1 text-center text-sm font-medium "
: 'hover:bg-[#fdbc87]'} rounded-r border border-orange-400 bg-orange-400 p-1 text-center text-sm font-medium text-zinc-800 "
>
</button>
</div>
<div class="w-full flex flex-col border rounded-t border-orange-400">
<div class="session-container flex w-full flex-col rounded-t border border-orange-400">
<div
class="session-header px-4 bg-orange-400 border border-orange-400 p-1 rounded-t-sm text-zinc-800 text-sm font-bold flex items-center justify-between"
class="session-header flex items-center justify-between rounded-t-sm border border-orange-400 bg-orange-400 p-1 px-4 text-sm font-bold text-zinc-800"
>
<span class="cursor-default"
>{format(selection.start, 'hh:mm')} - {format(selection.end, 'hh:mm')}</span
@ -257,10 +255,9 @@
<span>{toHumanBranchName(selection.branch)}</span>
<button on:click={resetSelection}>Close</button>
</div>
<div class="flex-auto overflow-auto flex flex-col">
<div class="shadow shadow-zinc-700 ring-1 ring-zinc-700 ring-opacity-5 mb-1">
<div class="grid-cols-11 -mr-px border-zinc-700 grid text-xs font-medium">
<div class="timeline-container flex flex-auto flex-col overflow-auto">
<div class="mb-1 shadow shadow-zinc-700 ring-1 ring-zinc-700 ring-opacity-5">
<div class="-mr-px grid grid-cols-11 border-zinc-700 text-xs font-medium">
<div class="col-span-2 flex items-center justify-center py-1">
<span>{format(selection.start, 'hh:mm')}</span>
</div>
@ -301,7 +298,7 @@
<!-- needle -->
<div class="grid grid-cols-11">
<div class="col-span-2 flex items-center justify-center" />
<div class="-mx-1 col-span-8 flex items-center justify-center">
<div class="col-span-8 -mx-1 flex items-center justify-center">
<Slider min={17} max={80} step={1} bind:value={selection.selectedColumn}>
<svelte:fragment slot="tooltip" let:value>
{format(colToTimestamp(value, selection.start, selection.end), 'hh:mm')}
@ -311,7 +308,7 @@
<div class="col-span-1 flex items-center justify-center" />
</div>
</div>
<div class="timeline-file-list flex mb-1 border-b-zinc-700">
<div class="timeline-file-list mb-1 flex border-b-2 border-b-zinc-700">
<div class="grid flex-auto grid-cols-1 grid-rows-1">
<!-- file names list -->
<div
@ -324,15 +321,15 @@
{#each Object.keys(selection.deltas) as filePath}
<div
class="flex {filePath === selection.selectedFilePath
? 'bg-blue-500/70 font-bold rounded-sm mx-1'
? 'mx-1 rounded-sm bg-blue-500/70 font-bold'
: ''}"
>
<button
class="text-xs z-20 flex justify-end items-center overflow-hidden sticky left-0 w-1/6 leading-5
class="sticky left-0 z-20 flex w-1/6 items-center justify-end overflow-hidden text-xs leading-5
{selection.selectedFilePath ===
filePath
? 'text-zinc-200 cursor-default'
: 'text-zinc-400 hover:text-zinc-200 cursor-pointer'}"
? 'cursor-default text-zinc-200'
: 'cursor-pointer text-zinc-400 hover:text-zinc-200'}"
on:click={() => (selection.selectedFilePath = filePath)}
title={filePath}
>
@ -354,7 +351,7 @@
</div>
<!-- time vertical lines -->
<div
class="col-start-1 col-end-2 row-start-1 grid-rows-1 divide-x divide-zinc-700/50 grid grid-cols-11"
class="col-start-1 col-end-2 row-start-1 grid grid-cols-11 grid-rows-1 divide-x divide-zinc-700/50"
>
<div class="col-span-2 row-span-full" />
<div class="col-span-2 row-span-full" />
@ -376,7 +373,7 @@
{#each Object.entries(selection.deltas) as [filePath, fileDeltas], idx}
{#each fileDeltas as delta}
<li
class="relative flex items-center bg-zinc-300 hover:bg-zinc-100 rounded m-0.5 cursor-pointer"
class="relative m-0.5 flex cursor-pointer items-center rounded bg-zinc-300 hover:bg-zinc-100"
style="
grid-row: {idx +
1} / span 1;
@ -387,7 +384,7 @@
)} / span 1;"
>
<button
class="z-20 flex flex-col w-full items-center justify-center"
class="z-20 flex w-full flex-col items-center justify-center"
on:click={() => {
selection.selectedColumn = timeStampToCol(
new Date(delta.timestampMs),
@ -429,7 +426,7 @@
}}
class="{selection.sessionIdx < uiSessions.length - 1
? 'hover:bg-[#fdbc87]'
: 'disabled cursor-default brightness-50'} rounded-r bg-orange-400 border border-orange-400 text-zinc-800 p-1 text-center text-sm font-medium "
: 'disabled cursor-default brightness-50'} rounded-r border border-orange-400 bg-orange-400 p-1 text-center text-sm font-medium text-zinc-800 "
>
</button>

View File

@ -54,8 +54,8 @@
};
</script>
<div class="p-4 mx-auto">
<div class="max-w-xl mx-auto p-4">
<div class="mx-auto p-4">
<div class="mx-auto max-w-xl p-4">
{#if $user}
<div class="flex flex-col gap-6 text-zinc-100">
<header class="flex items-center justify-between">
@ -68,7 +68,7 @@
<form
on:submit={onSubmit}
class="flex flex-row gap-12 justify-between rounded-lg p-2 items-start"
class="user-form flex flex-row items-start justify-between gap-12 rounded-lg py-2"
>
<fields id="left" class="flex flex-1 flex-col gap-3">
<div class="flex flex-col gap-1">
@ -78,7 +78,7 @@
name="name"
bind:value={userName}
type="text"
class="px-2 py-1 text-zinc-300 bg-black border border-zinc-600 rounded-lg w-full"
class="w-full rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-zinc-300"
required
/>
</div>
@ -91,29 +91,29 @@
name="email"
bind:value={$user.email}
type="text"
class="px-2 py-1 text-zinc-300 bg-black border border-zinc-600 rounded-lg w-full"
class="w-full rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-zinc-300"
/>
</div>
<footer class="pt-4">
{#if saving}
<div
class="flex w-32 flex-row w-content items-center gap-1 justify-center px-4 py-2 rounded text-white bg-blue-600"
class="w-content flex w-32 flex-row items-center justify-center gap-1 rounded bg-blue-600 px-4 py-2 text-white"
>
<IconRotateClockwise2 class="w-5 h-5 animate-spin" />
<IconRotateClockwise2 class="h-5 w-5 animate-spin" />
<span>Updating...</span>
</div>
{:else}
<button
type="submit"
class="px-4 py-2 rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none"
class="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 focus:outline-none"
>Update profile</button
>
{/if}
</footer>
</fields>
<fields id="right" class="flex flex-col gap-2 items-center">
<fields id="right" class="flex flex-col items-center gap-2">
{#if $user.picture}
<img
class="h-28 w-28 rounded-full border-zinc-300"
@ -124,7 +124,7 @@
<label
for="picture"
class="px-2 -mt-6 -ml-16 cursor-pointer text-center font-sm text-zinc-300 bg-zinc-800 border border-zinc-600 rounded-lg hover:text-zinc-50 bg-zinc-800 hover:bg-zinc-900"
class="font-sm -mt-6 -ml-16 cursor-pointer rounded-lg border border-zinc-600 bg-zinc-800 bg-zinc-800 px-2 text-center text-zinc-300 hover:bg-zinc-900 hover:text-zinc-50"
>
Edit
<input
@ -140,10 +140,10 @@
</form>
</div>
{:else}
<div class="flex flex-col text-white space-y-6 items-center justify-items-center">
<div class="flex flex-col items-center justify-items-center space-y-6 text-white">
<div class="text-3xl font-bold text-white">Connect to GitButler Cloud</div>
<div>Sign up or log in to GitButler Cloud for more tools and features:</div>
<ul class="text-zinc-400 pb-4 space-y-2">
<ul class="space-y-2 pb-4 text-zinc-400">
<li class="flex flex-row space-x-3">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -151,7 +151,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="white"
class="w-6 h-6"
class="h-6 w-6"
>
<path
stroke-linecap="round"
@ -168,7 +168,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="white"
class="w-6 h-6"
class="h-6 w-6"
>
<path
stroke-linecap="round"
@ -186,7 +186,7 @@
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="white"
class="w-6 h-6"
class="h-6 w-6"
>
<path
stroke-linecap="round"
@ -200,15 +200,15 @@
<div class="mt-8 text-center">
<Login {user} {api} />
</div>
<div class="text-zinc-300 text-center">
<div class="text-center text-zinc-300">
You will still need to give us permission for each project before we transfer any data to
our servers. You can revoke this permission at any time.
</div>
</div>
{/if}
<div class="flex flex-col mt-8 border-t border-zinc-400 pt-4">
<h2 class="text-lg text-zinc-100 font-medium">Get Support</h2>
<div class="mt-8 flex flex-col border-t border-zinc-400 pt-4">
<h2 class="text-lg font-medium text-zinc-100">Get Support</h2>
<div class="text-sm text-zinc-300">
If you have an issue or any questions, please email us.
</div>

View File

@ -4,7 +4,8 @@ const config = {
theme: {
fontFamily: {
sans: ['Inter', 'SF Pro', '-apple-system', 'system-ui']
sans: ['Inter', 'SF Pro', '-apple-system', 'system-ui'],
mono: ['SF Mono', 'Consolas', 'Liberation Mono', 'monospace']
},
fontSize: {
xs: '10px',