Merge branch 'master' into pr/2936

This commit is contained in:
Pavel Laptev 2024-03-01 14:46:33 +01:00
commit a746e2d5c5
65 changed files with 1206 additions and 873 deletions

View File

@ -10,10 +10,6 @@ runs:
prefix-key: gitbutler-client
shared-key: rust
- name: Placeholder for ui assets
shell: bash
run: mkdir gitbutler-ui/build
- name: Check versions
shell: bash
run: |

View File

@ -14,8 +14,7 @@ jobs:
rust: ${{ steps.filter.outputs.rust }}
gitbutler-app: ${{ steps.filter.outputs.gitbutler-app }}
gitbutler-core: ${{ steps.filter.outputs.gitbutler-core }}
gitbutler-git: ${{ steps.filter.outputs.gitbutler-git }}
gitbutler-diff: ${{ steps.filter.outputs.gitbutler-diff }}
gitbutler-changeset: ${{ steps.filter.outputs.gitbutler-changeset }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
@ -38,12 +37,9 @@ jobs:
gitbutler-core:
- *rust
- 'gitbutler-core/**'
gitbutler-git:
gitbutler-changeset:
- *rust
- 'gitbutler-git/**'
gitbutler-diff:
- *rust
- 'gitbutler-diff/**'
- 'gitbutler-changeset/**'
lint-node:
needs: changes
@ -95,7 +91,7 @@ jobs:
- uses: ./.github/actions/init-env-rust
# TODO(qix-): we have to exclude the app here for now because for some
# TODO(qix-): reason it doesn't build with the docs feature enabled.
- run: cargo doc --no-deps --all-features --document-private-items -p gitbutler-core -p gitbutler-git -p gitbutler-diff
- run: cargo doc --no-deps --all-features --document-private-items -p gitbutler-core -p gitbutler-changeset
env:
RUSTDOCFLAGS: -Dwarnings
@ -121,39 +117,9 @@ jobs:
features: ${{ toJson(matrix.features) }}
action: ${{ matrix.action }}
check-gitbutler-git:
check-gitbutler-changeset:
needs: [changes, rust-init]
if: ${{ needs.changes.outputs.gitbutler-git == 'true' }}
runs-on: ubuntu-latest
container:
image: ghcr.io/gitbutlerapp/ci-base-image:latest
strategy:
matrix:
action:
- test
- check
- check-tests
features:
- ''
- '*'
- []
- [cli]
- [cli, tokio]
- [serde]
- [git2]
steps:
- uses: actions/checkout@v4
# FIXME(qix-): figure out a way to make these build automatically with tests
- run: cargo build --locked -p gitbutler-git --bin gitbutler-git-askpass --bin gitbutler-git-setsid
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-git
features: ${{ toJson(matrix.features) }}
action: ${{ matrix.action }}
check-gitbutler-diff:
needs: [changes, rust-init]
if: ${{ needs.changes.outputs.gitbutler-diff == 'true' }}
if: ${{ needs.changes.outputs.gitbutler-changeset == 'true' }}
runs-on: ubuntu-latest
container:
image: ghcr.io/gitbutlerapp/ci-base-image:latest
@ -173,7 +139,7 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/check-crate
with:
crate: gitbutler-diff
crate: gitbutler-changeset
features: ${{ toJson(matrix.features) }}
action: ${{ matrix.action }}
@ -208,8 +174,7 @@ jobs:
needs:
- changes
- check-gitbutler-app
- check-gitbutler-git
- check-gitbutler-diff
- check-gitbutler-changeset
- check-gitbutler-core
runs-on: ubuntu-latest
if: ${{ needs.changes.outputs.rust == 'true' }}

View File

@ -1,10 +0,0 @@
{
"recommendations": [
"svelte.svelte-vscode",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"EditorConfig.EditorConfig"
]
}

17
.vscode/launch.json vendored
View File

@ -1,17 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "GitButler Dev",
"program": "${workspaceFolder}/target/debug/GitButler Dev",
"args": [],
"cwd": "${workspaceFolder}",
"preLaunchTask": "ui:dev",
}
]
}

View File

@ -1,5 +0,0 @@
{
"prettier.configPath": "./gitbutler-ui/.prettierrc",
"prettier.prettierPath": "./node_modules/prettier/index.cjs",
"prettier.useEditorConfig": false
}

14
.vscode/tasks.json vendored
View File

@ -1,14 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "ui:dev",
"type": "shell",
"isBackground": true,
"command": "pnpm",
"args": [
"dev"
],
},
]
}

664
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,14 +2,12 @@
members = [
"gitbutler-app",
"gitbutler-core",
"gitbutler-diff",
"gitbutler-git",
"gitbutler-changeset",
]
resolver = "2"
[workspace.dependencies]
gitbutler-core = { path = "gitbutler-core" }
gitbutler-git = { path = "gitbutler-git" }
git2 = { version = "0.18.2", features = ["vendored-openssl", "vendored-libgit2"] }
uuid = "1.7.0"
serde = { version = "1.0", features = ["derive"] }

View File

@ -30,7 +30,7 @@
[s2]: https://img.shields.io/discord/1060193121130000425?label=Discord&color=5865F2
[l2]: https://discord.gg/MmFkmaJ42D
[s3]: https://img.shields.io/badge/Instagram-E4405F?logo=instagram&logoColor=white
[l3]: https://instagram.com/gitbutlerapp
[l3]: https://www.instagram.com/gitbutler/
[s5]: https://img.shields.io/youtube/channel/subscribers/UCEwkZIHGqsTGYvX8wgD0LoQ
[l5]: https://www.youtube.com/@gitbutlerapp
[s6]: https://img.shields.io/badge/GitButler-%23B9F4F2?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMzkiIGhlaWdodD0iMjgiIHZpZXdCb3g9IjAgMCAzOSAyOCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTI1LjIxNDUgMTIuMTk5N0wyLjg3MTA3IDEuMzg5MTJDMS41NDI5NSAwLjc0NjUzMiAwIDEuNzE0MDYgMCAzLjE4OTQ3VjI0LjgxMDVDMCAyNi4yODU5IDEuNTQyOTUgMjcuMjUzNSAyLjg3MTA3IDI2LjYxMDlMMjUuMjE0NSAxNS44MDAzQzI2LjcxOTcgMTUuMDcyMSAyNi43MTk3IDEyLjkyNzkgMjUuMjE0NSAxMi4xOTk3WiIgZmlsbD0iYmxhY2siLz4KPHBhdGggZD0iTTEzLjc4NTUgMTIuMTk5N0wzNi4xMjg5IDEuMzg5MTJDMzcuNDU3MSAwLjc0NjUzMiAzOSAxLjcxNDA2IDM5IDMuMTg5NDdWMjQuODEwNUMzOSAyNi4yODU5IDM3LjQ1NzEgMjcuMjUzNSAzNi4xMjg5IDI2LjYxMDlMMTMuNzg1NSAxNS44MDAzQzEyLjI4MDMgMTUuMDcyMSAxMi4yODAzIDEyLjkyNzkgMTMuNzg1NSAxMi4xOTk3WiIgZmlsbD0idXJsKCNwYWludDBfcmFkaWFsXzMxMF8xMjkpIi8%2BCjxkZWZzPgo8cmFkaWFsR3JhZGllbnQgaWQ9InBhaW50MF9yYWRpYWxfMzEwXzEyOSIgY3g9IjAiIGN5PSIwIiByPSIxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09InRyYW5zbGF0ZSgxNi41NzAxIDE0KSBzY2FsZSgxOS44NjQxIDE5LjgzODMpIj4KPHN0b3Agb2Zmc2V0PSIwLjMwMTA1NiIgc3RvcC1vcGFjaXR5PSIwIi8%2BCjxzdG9wIG9mZnNldD0iMSIvPgo8L3JhZGlhbEdyYWRpZW50Pgo8L2RlZnM%2BCjwvc3ZnPgo%3D

View File

@ -22,7 +22,6 @@ pretty_assertions = "1.4"
tempfile = "3.10"
[dependencies]
gitbutler-git.workspace = true
anyhow = "1.0.79"
async-trait = "0.1.77"
backoff = "0.4.0"
@ -65,11 +64,13 @@ similar = { version = "2.4.0", features = ["unicode"] }
slug = "0.1.5"
ssh-key = { version = "0.6.4", features = [ "alloc", "ed25519" ] }
ssh2 = { version = "0.9.4", features = ["vendored-openssl"] }
tauri = { version = "1.5.4", features = ["dialog-open", "fs-read-file", "path-all", "process-relaunch", "protocol-asset", "shell-open", "window-maximize", "window-start-dragging", "window-unmaximize"] }
tauri = { version = "1.5.4", features = [ "os-all", "dialog-open", "fs-read-file", "path-all", "process-relaunch", "protocol-asset", "shell-open", "window-maximize", "window-start-dragging", "window-unmaximize"] }
tauri-plugin-context-menu = { git = "https://github.com/c2r0b/tauri-plugin-context-menu", branch = "main" }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-log = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
log = "^0.4"
thiserror.workspace = true
tokio = { workspace = true, features = [ "full", "sync", "tracing" ] }
tokio-util = "0.7.10"

View File

@ -1,3 +1,23 @@
fn main() {
// Make the UI build directory if it doesn't already exist.
// We do this here because the tauri context macro expects it to
// exist at build time, and it's otherwise manually required to create
// it before building.
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
assert_eq!(manifest_dir.file_name().unwrap(), "gitbutler-app");
let build_dir = manifest_dir.parent().unwrap().join("gitbutler-ui/build");
if !build_dir.exists() {
// NOTE(qix-): Do not use `create_dir_all` here - the parent directory
// NOTE(qix-): already exists, and we want to fail if not (for some reason).
#[allow(clippy::expect_fun_call, clippy::create_dir)]
std::fs::create_dir(&build_dir).expect(
format!(
"failed to create gitbutler-ui build directory: {:?}",
build_dir
)
.as_str(),
);
}
tauri_build::build();
}

View File

@ -7,6 +7,7 @@ use gblib::{
analytics, app, assets, commands, database, deltas, github, keys, logs, menu, projects, sentry,
sessions, storage, users, virtual_branches, watcher, zip,
};
use tauri_plugin_log::LogTarget;
use tauri_plugin_store::{with_store, JsonValue, StoreCollection};
fn main() {
@ -21,6 +22,11 @@ fn main() {
.block_on(async {
tauri::async_runtime::set(tokio::runtime::Handle::current());
let log = tauri_plugin_log::Builder::default()
.log_name("ui-logs")
.target(LogTarget::LogDir)
.level(log::LevelFilter::Error);
tauri::Builder::default()
.on_window_event(|event| {
#[cfg(target_os = "macos")]
@ -137,6 +143,7 @@ fn main() {
.plugin(tauri_plugin_single_instance::init(|_, _, _| {}))
.plugin(tauri_plugin_context_menu::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(log.build())
.invoke_handler(tauri::generate_handler![
commands::list_session_files,
commands::git_remote_branches,

View File

@ -1,7 +1,6 @@
use std::{collections::HashMap, path};
use anyhow::Context;
use git2::ErrorCode;
use tauri::Manager;
use tracing::instrument;
@ -66,42 +65,13 @@ pub async fn git_remote_branches(
#[tauri::command(async)]
#[instrument(skip(handle))]
pub async fn git_head(handle: tauri::AppHandle, project_id: &str) -> Result<String, Error> {
use gitbutler_git::Repository;
let app = handle.state::<app::App>();
let project_id = project_id.parse().map_err(|_| Error::UserError {
code: Code::Validation,
message: "Malformed project id".to_string(),
})?;
let project = handle.state::<projects::Controller>().get(&project_id)?;
let repo =
gitbutler_git::git2::Repository::<gitbutler_git::git2::tokio::TokioThreadedResource>::open(
&project.path,
)
.await
.map_err(|e| Error::UserError {
code: Code::Projects,
message: format!("could not open repository: {e}"),
})?;
repo.symbolic_head().await.map_err(|e| match &e {
gitbutler_git::Error::Backend(err) => {
if err.code() == ErrorCode::UnbornBranch {
return Error::UserError {
code: Code::ProjectHead,
message:
"could not get symbolic head: Cannot load a git repository with 0 commits"
.to_string(),
};
}
Error::UserError {
code: Code::ProjectHead,
message: format!("could not get symbolic head: {e}"),
}
}
_ => Error::UserError {
code: Code::ProjectHead,
message: format!("could not get symbolic head: {e}"),
},
})
let head = app.git_head(&project_id)?;
Ok(head)
}
#[tauri::command(async)]

View File

@ -4,8 +4,8 @@ pub fn dedup(existing: &[&str], new: &str) -> String {
dedup_fmt(existing, new, " ")
}
// dedup makes sure that _new_ is not in _existing_ by adding a number to it.
// the number is increased until the name is unique.
/// Makes sure that _new_ is not in _existing_ by adding a number to it.
/// the number is increased until the name is unique.
pub fn dedup_fmt(existing: &[&str], new: &str, separator: &str) -> String {
let used_numbers = existing
.iter()

View File

@ -57,8 +57,8 @@ impl Repository {
}
let projects_dir = root.join("projects");
let path = projects_dir.join(project.id.to_string());
let lock_path = projects_dir.join(format!("{}.lock", project.id));
if path.exists() {
@ -75,6 +75,8 @@ impl Repository {
lock_path,
})
} else {
std::fs::create_dir_all(&path).context("failed to create project directory")?;
let git_repository = git::Repository::init_opts(
&path,
git2::RepositoryInitOptions::new()

View File

@ -12,7 +12,7 @@ use crate::{
};
use super::{
branch::{self, BranchId},
branch::BranchId,
controller::{Controller, ControllerError},
BaseBranch, RemoteBranchFile,
};

View File

@ -1,5 +1,5 @@
use crate::git::diff;
use anyhow::{Context, Result};
use anyhow::Result;
pub fn hunk_with_context(
hunk_diff: &str,
@ -143,16 +143,16 @@ mod tests {
)
.unwrap();
let expected = r#"@@ -5,7 +5,7 @@
[features]
default = ["serde", "rusqlite"]
-serde = ["dep:serde", "uuid/serde"]
+SERDE = ["dep:serde", "uuid/serde"]
rusqlite = ["dep:rusqlite"]
[dependencies]
"#;
assert_eq!(with_ctx.diff, expected);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), expected);
assert_eq!(with_ctx.old_start, 5);
assert_eq!(with_ctx.old_lines, 7);
assert_eq!(with_ctx.new_start, 5);
@ -176,14 +176,14 @@ mod tests {
)
.unwrap();
assert_eq!(
with_ctx.diff,
with_ctx.diff.replace("\n \n", "\n\n"),
r#"@@ -1,5 +1,5 @@
[package]
-name = "gitbutler-core"
+NAME = "gitbutler-core"
version = "0.0.0"
edition = "2021"
"#
);
assert_eq!(with_ctx.old_start, 1);
@ -209,7 +209,7 @@ mod tests {
)
.unwrap();
assert_eq!(
with_ctx.diff,
with_ctx.diff.replace("\n \n", "\n\n"),
r#"@@ -1,4 +1,4 @@
-[package]
+[PACKAGE]
@ -241,9 +241,9 @@ mod tests {
)
.unwrap();
assert_eq!(
with_ctx.diff,
with_ctx.diff.replace("\n \n", "\n\n"),
r#"@@ -10,5 +10,5 @@
[dependencies]
rusqlite = { workspace = true, optional = true }
-serde = { workspace = true, optional = true }
@ -277,9 +277,9 @@ mod tests {
)
.unwrap();
assert_eq!(
with_ctx.diff,
with_ctx.diff.replace("\n \n", "\n\n"),
r#"@@ -5,7 +5,10 @@
[features]
default = ["serde", "rusqlite"]
-serde = ["dep:serde", "uuid/serde"]
@ -288,7 +288,7 @@ mod tests {
+three
+four
rusqlite = ["dep:rusqlite"]
[dependencies]
"#
);
@ -317,16 +317,16 @@ mod tests {
)
.unwrap();
assert_eq!(
with_ctx.diff,
with_ctx.diff.replace("\n \n", "\n\n"),
r#"@@ -4,9 +4,7 @@
edition = "2021"
[features]
-default = ["serde", "rusqlite"]
-serde = ["dep:serde", "uuid/serde"]
-rusqlite = ["dep:rusqlite"]
+foo = ["foo"]
[dependencies]
rusqlite = { workspace = true, optional = true }
"#
@ -381,7 +381,7 @@ mod tests {
diff::ChangeType::Added,
)
.unwrap();
assert_eq!(with_ctx.diff, hunk_diff);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), hunk_diff);
assert_eq!(with_ctx.old_start, 1);
assert_eq!(with_ctx.old_lines, 14);
assert_eq!(with_ctx.new_start, 0);
@ -406,7 +406,7 @@ mod tests {
diff::ChangeType::Added,
)
.unwrap();
assert_eq!(with_ctx.diff, hunk_diff);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), hunk_diff);
assert_eq!(with_ctx.old_start, 0);
assert_eq!(with_ctx.old_lines, 0);
assert_eq!(with_ctx.new_start, 1);
@ -438,10 +438,10 @@ mod tests {
+two
+three
rusqlite = ["dep:rusqlite"]
[dependencies]
"#;
assert_eq!(with_ctx.diff, expected);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), expected);
assert_eq!(with_ctx.old_start, 6);
assert_eq!(with_ctx.old_lines, 6);
assert_eq!(with_ctx.new_start, 6);
@ -473,10 +473,10 @@ mod tests {
+two
+three
rusqlite = ["dep:rusqlite"]
[dependencies]
"#;
assert_eq!(with_ctx.diff, expected);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), expected);
assert_eq!(with_ctx.old_start, 6);
assert_eq!(with_ctx.old_lines, 6);
assert_eq!(with_ctx.new_start, 10);
@ -492,12 +492,12 @@ mod tests {
"#;
let expected = r#"@@ -4,9 +4,6 @@
edition = "2021"
[features]
-default = ["serde", "rusqlite"]
-serde = ["dep:serde", "uuid/serde"]
-rusqlite = ["dep:rusqlite"]
[dependencies]
rusqlite = { workspace = true, optional = true }
"#;
@ -511,7 +511,7 @@ mod tests {
diff::ChangeType::Added,
)
.unwrap();
assert!(with_ctx.diff == expected);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), expected);
assert_eq!(with_ctx.old_start, 4);
assert_eq!(with_ctx.old_lines, 9);
assert_eq!(with_ctx.new_start, 4);
@ -527,12 +527,12 @@ mod tests {
"#;
let expected = r#"@@ -4,9 +8,6 @@
edition = "2021"
[features]
-default = ["serde", "rusqlite"]
-serde = ["dep:serde", "uuid/serde"]
-rusqlite = ["dep:rusqlite"]
[dependencies]
rusqlite = { workspace = true, optional = true }
"#;
@ -546,7 +546,7 @@ mod tests {
diff::ChangeType::Added,
)
.unwrap();
assert_eq!(with_ctx.diff, expected);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), expected);
assert_eq!(with_ctx.old_start, 4);
assert_eq!(with_ctx.old_lines, 9);
assert_eq!(with_ctx.new_start, 8);
@ -576,10 +576,10 @@ mod tests {
-
- @waiting_users = User.where(approved: false).count
end
def invite
";
assert_eq!(with_ctx.diff, expected);
assert_eq!(with_ctx.diff.replace("\n \n", "\n\n"), expected);
assert_eq!(with_ctx.old_start, 8);
assert_eq!(with_ctx.old_lines, 8);
assert_eq!(with_ctx.new_start, 8);

View File

@ -939,7 +939,7 @@ fn test_add_new_hunk_to_the_end() -> Result<()> {
}
#[test]
fn test_merge_vbranch_upstream_clean() -> Result<()> {
fn test_merge_vbranch_upstream_clean_rebase() -> Result<()> {
let suite = Suite::default();
let Case {
project_repository,
@ -1056,15 +1056,10 @@ fn test_merge_vbranch_upstream_clean() -> Result<()> {
);
let contents = std::fs::read(std::path::Path::new(&project.path).join(file_path2))?;
assert_eq!("file2\n", String::from_utf8(contents)?);
assert_eq!(branch1.files.len(), 0);
assert_eq!(branch1.commits.len(), 3);
assert_eq!(branch1.files.len(), 1);
assert_eq!(branch1.commits.len(), 2);
// assert_eq!(branch1.upstream.as_ref().unwrap().commits.len(), 0);
// make sure the last commit was signed
let last_id = &branch1.commits[0].id;
let last_commit = project_repository.git_repository.find_commit(*last_id)?;
assert!(last_commit.raw_header().unwrap().contains("SSH SIGNATURE"));
Ok(())
}
@ -1633,7 +1628,7 @@ fn test_detect_mergeable_branch() -> Result<()> {
let remotes =
list_remote_branches(&gb_repository, &project_repository).expect("failed to list remotes");
let remote1 = &remotes
let _remote1 = &remotes
.iter()
.find(|b| b.name.to_string() == "refs/remotes/origin/remote_branch")
.unwrap();
@ -1645,7 +1640,7 @@ fn test_detect_mergeable_branch() -> Result<()> {
.unwrap());
// assert_eq!(remote1.commits.len(), 1);
let remote2 = &remotes
let _remote2 = &remotes
.iter()
.find(|b| b.name.to_string() == "refs/remotes/origin/remote_branch2")
.unwrap();

View File

@ -1442,20 +1442,88 @@ pub fn merge_virtual_branch_upstream(
Some(upstream_commit.id()),
)?;
} else {
// get the merge tree oid from writing the index out
let merge_tree_oid = merge_index
.write_tree_to(repo)
.context("failed to write tree")?;
let merge_tree = repo
.find_tree(merge_tree_oid)
.context("failed to find merge tree")?;
let branch_writer =
branch::Writer::new(gb_repository).context("failed to create writer")?;
if *project_repository.project().ok_with_force_push {
// attempt a rebase
let (_, committer) = project_repository.git_signatures(user)?;
let mut rebase_options = git2::RebaseOptions::new();
rebase_options.quiet(true);
rebase_options.inmemory(true);
let mut rebase = repo
.rebase(
Some(branch.head),
Some(upstream_commit.id()),
None,
Some(&mut rebase_options),
)
.context("failed to rebase")?;
let mut rebase_success = true;
// check to see if these commits have already been pushed
let mut last_rebase_head = upstream_commit.id();
while rebase.next().is_some() {
let index = rebase
.inmemory_index()
.context("failed to get inmemory index")?;
if index.has_conflicts() {
rebase_success = false;
break;
}
if let Ok(commit_id) = rebase.commit(None, &committer.clone().into(), None) {
last_rebase_head = commit_id.into();
} else {
rebase_success = false;
break;
}
}
if rebase_success {
// rebase worked out, rewrite the branch head
rebase.finish(None).context("failed to finish rebase")?;
project_repository
.git_repository
.checkout_tree(&merge_tree)
.force()
.checkout()
.context("failed to checkout tree")?;
branch.head = last_rebase_head;
branch.tree = merge_tree_oid;
branch_writer.write(&mut branch)?;
super::integration::update_gitbutler_integration(
gb_repository,
project_repository,
)?;
return Ok(());
}
rebase.abort().context("failed to abort rebase")?;
}
let head_commit = repo
.find_commit(branch.head)
.context("failed to find head commit")?;
let merge_tree = repo
.find_tree(merge_tree_oid)
.context("failed to find merge tree")?;
let new_branch_head = project_repository.commit(
user,
"merged from upstream",
format!(
"Merged {}/{} into {}",
upstream_branch.remote(),
upstream_branch.branch(),
branch.name
)
.as_str(),
&merge_tree,
&[&head_commit, &upstream_commit],
signing_key,
@ -1468,8 +1536,6 @@ pub fn merge_virtual_branch_upstream(
.context("failed to checkout tree")?;
// write the branch data
let branch_writer =
branch::Writer::new(gb_repository).context("failed to create writer")?;
branch.head = new_branch_head;
branch.tree = merge_tree_oid;
branch_writer.write(&mut branch)?;

View File

@ -21,6 +21,9 @@
"dialog": {
"open": true
},
"os": {
"all": true
},
"protocol": {
"asset": true,
"assetScope": ["$APPCACHE/images/*"]

View File

@ -1,5 +1,5 @@
[package]
name = "gitbutler-diff"
name = "gitbutler-changeset"
version = "0.0.0"
edition = "2021"

View File

@ -14,15 +14,11 @@
//! Otherwise, neither the length prefix imposed by `(de)serialize_bytes()` nor the
//! terrible compaction and optimization of `(de)serialize_tuple()` are acceptable.
// FIXME(qix-): There are a ton of identifiers in here that make no sense and
// FIXME(qix-): were copied over from the exploratory data-science-ey code that
// FIXME(qix-): need to be cleaned up. PR welcome!
const BITS: usize = 3;
const SHIFT: usize = 8 - BITS;
const SIG_ENTRIES: usize = (1 << BITS) * (1 << BITS);
const SIG_BYTES: usize = SIG_ENTRIES * ::core::mem::size_of::<SigBucket>();
const TOTAL_BYTES: usize = SIG_BYTES + 4 + 1; // we encode a 4-byte length at the beginning, along with a version byte
const FINGERPRINT_ENTRIES: usize = (1 << BITS) * (1 << BITS);
const FINGERPRINT_BYTES: usize = FINGERPRINT_ENTRIES * ::core::mem::size_of::<SigBucket>();
const TOTAL_BYTES: usize = 1 + 4 + FINGERPRINT_BYTES; // we encode a version byte and a 4-byte length at the beginning
// NOTE: This is not efficient if `SigBucket` is 1 byte (u8).
// NOTE: If `SigBucket` is changed to a u8, then the implementation
@ -80,42 +76,44 @@ impl Signature {
/// about the signature or the original file contents.
///
/// Do not use for any security-related purposes.
pub fn score_str<S: AsRef<str>>(&self, s: S) -> f64 {
pub fn score_str<S: AsRef<str>>(&self, other: S) -> f64 {
if self.0[0] != 0 {
panic!("unsupported signature version");
}
let original_length = u32::from_le_bytes(self.0[1..5].try_into().unwrap());
let s = s.as_ref();
let s_s: String = s.chars().filter(|&x| !char::is_whitespace(x)).collect();
let s = s_s.as_bytes();
if original_length < 2 || s.len() < 2 {
let original_length = u32::from_le_bytes(self.0[1..5].try_into().expect("invalid length"));
if original_length < 2 {
return 0.0;
}
let mut intersection_size = 0usize;
let other = other.as_ref();
let other_string: String = other.chars().filter(|&x| !char::is_whitespace(x)).collect();
let other = other_string.as_bytes();
let mut wb = self.bucket_iter().collect::<Vec<_>>();
if other.len() < 2 {
return 0.0;
}
for (b1, b2) in bigrams(s) {
let b1 = b1 >> SHIFT;
let b2 = b2 >> SHIFT;
let ix = ((b1 as usize) << BITS) | (b2 as usize);
if wb[ix] > 0 {
wb[ix] = wb[ix].saturating_sub(1);
intersection_size += 1;
let mut matching_bigrams: usize = 0;
let mut self_buckets = self.bucket_iter().collect::<Vec<_>>();
for (left, right) in bigrams(other) {
let left = left >> SHIFT;
let right = right >> SHIFT;
let index = ((left as usize) << BITS) | (right as usize);
if self_buckets[index] > 0 {
self_buckets[index] -= 1;
matching_bigrams += 1;
}
}
(2 * intersection_size) as f64 / (original_length as usize + s.len() - 2) as f64
(2 * matching_bigrams) as f64 / (original_length as usize + other.len() - 2) as f64
}
fn bucket_iter(&self) -> impl Iterator<Item = SigBucket> + '_ {
unsafe {
self.0[(TOTAL_BYTES - SIG_BYTES)..]
self.0[(TOTAL_BYTES - FINGERPRINT_BYTES)..]
.as_chunks_unchecked::<{ ::core::mem::size_of::<SigBucket>() }>()
.iter()
.map(|ch: &[u8; ::core::mem::size_of::<SigBucket>()]| SigBucket::from_le_bytes(*ch))
@ -125,45 +123,50 @@ impl Signature {
impl<S: AsRef<str>> From<S> for Signature {
#[inline]
fn from(s: S) -> Self {
let s = s.as_ref();
fn from(source: S) -> Self {
let source = source.as_ref();
let source_string: String = source
.chars()
.filter(|&x| !char::is_whitespace(x))
.collect();
let source = source_string.as_bytes();
let a_s: String = s.chars().filter(|&x| !char::is_whitespace(x)).collect();
let a = a_s.as_bytes();
let a_len: u32 = a
let source_len: u32 = source
.len()
.try_into()
.expect("strings with a byte-length above u32::MAX are not supported");
let mut a_res = [0; TOTAL_BYTES];
a_res[0] = 0; // version byte
a_res[1..5].copy_from_slice(&a_len.to_le_bytes()); // length
let mut bytes = [0; TOTAL_BYTES];
bytes[0] = 0; // version byte (0)
bytes[1..5].copy_from_slice(&source_len.to_le_bytes()); // next 4 bytes are the length
if a_len >= 2 {
let mut a_bigrams = [0 as SigBucket; SIG_ENTRIES];
if source_len >= 2 {
let mut buckets = [0 as SigBucket; FINGERPRINT_ENTRIES];
for (b1, b2) in bigrams(a) {
let b1 = b1 >> SHIFT;
let b2 = b2 >> SHIFT;
let encoded_bigram = ((b1 as usize) << BITS) | (b2 as usize);
a_bigrams[encoded_bigram] = a_bigrams[encoded_bigram].saturating_add(1);
for (left, right) in bigrams(source) {
let left = left >> SHIFT;
let right = right >> SHIFT;
let index = ((left as usize) << BITS) | (right as usize);
buckets[index] = buckets[index].saturating_add(1);
}
// NOTE: This is not efficient if `SigBucket` is 1 byte (u8).
let mut offset = TOTAL_BYTES - SIG_BYTES;
for bucket in a_bigrams {
let mut offset = TOTAL_BYTES - FINGERPRINT_BYTES;
for bucket in buckets {
let start = offset;
let end = start + ::core::mem::size_of::<SigBucket>();
a_res[start..end].copy_from_slice(&bucket.to_le_bytes());
bytes[start..end].copy_from_slice(&bucket.to_le_bytes());
offset = end;
}
}
Self(a_res)
Self(bytes)
}
}
/// Copies the passed bytes twice and zips them together with a one-byte offset.
/// This produces an iterator of the bigrams (pairs of consecutive bytes) in the input.
/// For example, the bigrams of 1, 2, 3, 4, 5 would be (1, 2), (2, 3), (3, 4), (4, 5).
#[inline]
fn bigrams(s: &[u8]) -> impl Iterator<Item = (u8, u8)> + '_ {
s.iter().copied().zip(s.iter().skip(1).copied())
@ -173,61 +176,47 @@ fn bigrams(s: &[u8]) -> impl Iterator<Item = (u8, u8)> + '_ {
mod tests {
use super::*;
macro_rules! assert_score {
($sig:ident, $s:expr, $e:expr) => {
let score = $sig.score_str($s);
if (score - $e).abs() >= 0.1 {
panic!(
"expected score of {} for string {:?}, got {}",
$e, $s, score
);
}
};
}
#[test]
fn score_signature() {
let sig = Signature::from("hello world");
macro_rules! assert_score {
($s:expr, $e:expr) => {
if (sig.score_str($s) - $e).abs() >= 0.1 {
panic!(
"expected score of {} for string {:?}, got {}",
$e,
$s,
sig.score_str($s)
);
}
};
}
// NOTE: The scores here are not exact, but are close enough
// to be useful for testing purposes, hence why some have the same
// "score" but different strings.
assert_score!("hello world", 1.0);
assert_score!("hello world!", 0.95);
assert_score!("hello world!!", 0.9);
assert_score!("hello world!!!", 0.85);
assert_score!("hello world!!!!", 0.8);
assert_score!("hello world!!!!!", 0.75);
assert_score!("hello world!!!!!!", 0.7);
assert_score!("hello world!!!!!!!", 0.65);
assert_score!("hello world!!!!!!!!", 0.62);
assert_score!("hello world!!!!!!!!!", 0.6);
assert_score!("hello world!!!!!!!!!!", 0.55);
assert_score!(sig, "hello world", 1.0);
assert_score!(sig, "hello world!", 0.95);
assert_score!(sig, "hello world!!", 0.9);
assert_score!(sig, "hello world!!!", 0.85);
assert_score!(sig, "hello world!!!!", 0.8);
assert_score!(sig, "hello world!!!!!", 0.75);
assert_score!(sig, "hello world!!!!!!", 0.7);
assert_score!(sig, "hello world!!!!!!!", 0.65);
assert_score!(sig, "hello world!!!!!!!!", 0.62);
assert_score!(sig, "hello world!!!!!!!!!", 0.6);
assert_score!(sig, "hello world!!!!!!!!!!", 0.55);
}
#[test]
fn score_ignores_whitespace() {
let sig = Signature::from("hello world");
macro_rules! assert_score {
($s:expr, $e:expr) => {
if (sig.score_str($s) - $e).abs() >= 0.1 {
panic!(
"expected score of {} for string {:?}, got {}",
$e,
$s,
sig.score_str($s)
);
}
};
}
assert_score!("hello world", 1.0);
assert_score!("hello world ", 1.0);
assert_score!("hello\nworld ", 1.0);
assert_score!("hello\n\tworld ", 1.0);
assert_score!("\t\t hel lo\n\two rld \t\t", 1.0);
assert_score!(sig, "hello world", 1.0);
assert_score!(sig, "hello world ", 1.0);
assert_score!(sig, "hello\nworld ", 1.0);
assert_score!(sig, "hello\n\tworld ", 1.0);
assert_score!(sig, "\t\t hel lo\n\two rld \t\t", 1.0);
}
const TEXT1: &str = include_str!("../fixture/text1.txt");

View File

@ -1,8 +1,8 @@
//! A [Tokio](https://tokio.rs)-based [`super::GitExecutor`] implementation.
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::{collections::HashMap, fs::Permissions, os::unix::fs::PermissionsExt, time::Duration};
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::{collections::HashMap, fs::Permissions, time::Duration};
use tokio::process::Command;
/// A [`super::GitExecutor`] implementation using the `git` command-line tool

View File

@ -85,6 +85,7 @@
"svelte-resize-observer": "^2.0.0",
"tailwindcss": "^3.4.1",
"tauri-plugin-context-menu": "^0.7.0",
"tauri-plugin-log-api": "github:tauri-apps/tauri-plugin-log#v1",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
"tinykeys": "^2.1.0",
"tslib": "^2.6.2",

View File

@ -1,7 +1,9 @@
import { handleErrorWithSentry } from '@sentry/sveltekit';
import { error as logErrorToFile } from 'tauri-plugin-log-api';
import type { NavigationEvent } from '@sveltejs/kit';
function myErrorHandler({ error, event }: { error: any; event: NavigationEvent }) {
logErrorToFile(error);
console.error('An error occurred on the client side:', error, event);
}
@ -16,6 +18,7 @@ export const handleError = handleErrorWithSentry(myErrorHandler);
*/
const originalUnhandledHandler = window.onunhandledrejection;
window.onunhandledrejection = (event: PromiseRejectionEvent) => {
logErrorToFile('Unhandled exception: ' + event?.reason?.message + ' ' + event?.reason?.sourceURL);
console.log('Unhandled exception', event.reason);
originalUnhandledHandler?.bind(window)(event);
};

View File

@ -1,4 +1,5 @@
import { showToast } from '$lib/notifications/toasts';
import { relaunch } from '@tauri-apps/api/process';
import {
checkUpdate,
installUpdate,
@ -8,19 +9,20 @@ import {
} from '@tauri-apps/api/updater';
import posthog from 'posthog-js';
import {
BehaviorSubject,
switchMap,
Observable,
from,
map,
shareReplay,
interval,
timeout,
catchError,
of,
tap,
map,
from,
timeout,
interval,
switchMap,
shareReplay,
catchError,
startWith,
combineLatestWith,
tap
distinctUntilChanged,
Observable,
BehaviorSubject
} from 'rxjs';
// TOOD: Investigate why 'DOWNLOADED' is not in the type provided by Tauri.
@ -44,7 +46,10 @@ export class UpdaterService {
constructor() {
onUpdaterEvent((status) => {
const err = status.error;
if (err) showErrorToast(err);
if (err) {
showErrorToast(err);
posthog.capture('App Update Status Error', { error: err });
}
this.status$.next(status.status);
}).then((unlistenFn) => (this.unlistenFn = unlistenFn));
@ -58,10 +63,12 @@ export class UpdaterService {
map((update: UpdateResult | undefined) => {
if (update?.shouldUpdate) return update.manifest;
}),
// We don't need the stream to emit if the result is the same version
distinctUntilChanged((prev, curr) => prev?.version == curr?.version),
// Hide offline/timeout errors since no app ever notifies you about this
catchError((err) => {
if (!isOffline(err) && !isTimeoutError(err)) {
posthog.capture('Updater Check Error', err);
posthog.capture('App Update Check Error', { error: err });
showErrorToast(err);
console.log(err);
}
@ -83,15 +90,19 @@ export class UpdaterService {
// });
}
async install() {
async installUpdate() {
try {
await installUpdate();
posthog.capture('App Update Successful');
} catch (e: any) {
} catch (err: any) {
// We expect toast to be shown by error handling in `onUpdaterEvent`
posthog.capture('App Update Failed', e);
posthog.capture('App Update Install Error', { error: err });
}
}
relaunchApp() {
relaunch();
}
}
function isOffline(err: any): boolean {
@ -118,5 +129,4 @@ function showErrorToast(err: any) {
`,
style: 'error'
});
posthog.capture('Updater Status Error', err);
}

View File

@ -5,22 +5,25 @@
export let user: User | undefined;
export let pop = false;
export let isNavCollapsed = false;
</script>
<button class="btn" class:pop on:click={() => goto('/settings/')}>
<span class="name text-base-13 text-semibold">
{#if user}
{#if user.name}
{user.name}
{:else if user.given_name}
{user.given_name}
{:else if user.email}
{user.email}
<button class="btn" class:pop on:click={() => goto('/settings/')} class:collapsed={isNavCollapsed}>
{#if !isNavCollapsed}
<span class="name text-base-13 text-semibold">
{#if user}
{#if user.name}
{user.name}
{:else if user.given_name}
{user.given_name}
{:else if user.email}
{user.email}
{/if}
{:else}
Account
{/if}
{:else}
Account
{/if}
</span>
</span>
{/if}
{#if user?.picture}
<img class="profile-picture" src={user.picture} alt="Avatar" />
{:else}
@ -30,8 +33,6 @@
{/if}
</button>
<!-- REMOVE IF IT'S NOT NEEDED -->
<style lang="postcss">
.btn {
display: flex;
@ -93,4 +94,17 @@
background: var(--clr-theme-pop-element);
color: var(--clr-theme-pop-on-element);
}
/* MODIFIERS */
.btn.collapsed {
overflow-x: initial;
padding: var(--space-8);
height: auto;
& .anon-icon,
.profile-picture {
width: var(--space-24);
height: var(--space-24);
}
}
</style>

View File

@ -2,18 +2,13 @@
import Button from './Button.svelte';
import IconButton from './IconButton.svelte';
import { showToast } from '$lib/notifications/toasts';
import { relaunch } from '@tauri-apps/api/process';
import { installUpdate } from '@tauri-apps/api/updater';
import { distinctUntilChanged, tap } from 'rxjs';
import { tap } from 'rxjs';
import { fade } from 'svelte/transition';
import type { UpdaterService } from '$lib/backend/updater';
export let updaterService: UpdaterService;
// Extrend update stream to allow dismissing updater by version
$: update$ = updaterService.update$.pipe(
// Only run operators after this one once per version
distinctUntilChanged((prev, curr) => prev?.version == curr?.version),
// Reset dismissed boolean when a new version becomes available
tap(() => (dismissed = false))
);
@ -99,8 +94,10 @@
New version available
{:else if $update$.status == 'PENDING'}
Downloading update...
{:else if $update$.status == 'DONE'}
{:else if $update$.status == 'DOWNLOADED'}
Installing update...
{:else if $update$.status == 'DONE'}
Install complete
{:else if $update$.status == 'ERROR'}
Error occurred...
{/if}
@ -128,11 +125,18 @@
{#if !$update$.status}
<div class="cta-btn" transition:fade={{ duration: 100 }}>
<Button wide on:click={() => installUpdate()}>Download {$update$.version}</Button>
<Button
wide
on:click={async () => {
await updaterService.installUpdate();
}}
>
Download {$update$.version}
</Button>
</div>
{:else if $update$.status == 'DONE'}
<div class="cta-btn" transition:fade={{ duration: 100 }}>
<Button wide on:click={() => relaunch()}>Restart to update</Button>
<Button wide on:click={() => updaterService.relaunchApp()}>Restart</Button>
</div>
{/if}
</div>

View File

@ -2,6 +2,7 @@
import SyncButton from './SyncButton.svelte';
import Badge from '$lib/components/Badge.svelte';
import Icon from '$lib/components/Icon.svelte';
import { tooltip } from '$lib/utils/tooltip';
import type { Project } from '$lib/backend/projects';
import type { GitHubService } from '$lib/github/service';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
@ -10,6 +11,7 @@
export let project: Project;
export let baseBranchService: BaseBranchService;
export let githubService: GitHubService;
export let isNavCollapsed: boolean;
$: base$ = baseBranchService.base$;
$: selected = $page.url.href.endsWith('/base');
@ -17,56 +19,67 @@
let baseContents: HTMLElement;
</script>
<a href="/{project.id}/base" class="base-branch-card" class:selected bind:this={baseContents}>
<div class="icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="4" fill="#FB7D61" />
<path d="M8 4L12 8L8 12L4 8L8 4Z" fill="white" />
</svg>
</div>
<a
use:tooltip={isNavCollapsed ? 'Trunk' : ''}
href="/{project.id}/base"
class="base-branch-card"
class:selected
bind:this={baseContents}
>
{#if isNavCollapsed}
{#if ($base$?.behind || 0) > 0}
<div class="small-count-badge">
<span class="text-base-9 text-bold">{$base$?.behind || 0}</span>
</div>
{/if}
{/if}
<img class="icon" src="/images/domain-icons/trunk.svg" alt="" />
<div class="content">
<div class="row_1">
<span class="text-base-14 text-semibold trunk-label">Trunk</span>
{#if ($base$?.behind || 0) > 0}
<Badge count={$base$?.behind || 0} help="Unmerged upstream commits" />
{/if}
<SyncButton
projectId={project.id}
{baseBranchService}
{githubService}
cloudEnabled={project?.api?.sync || false}
/>
{#if !isNavCollapsed}
<div class="content">
<div class="row_1">
<span class="text-base-14 text-semibold trunk-label">Trunk</span>
{#if ($base$?.behind || 0) > 0}
<Badge count={$base$?.behind || 0} help="Unmerged upstream commits" />
{/if}
<SyncButton
projectId={project.id}
{baseBranchService}
{githubService}
cloudEnabled={project?.api?.sync || false}
/>
</div>
<div class="row_2 text-base-12">
{#if $base$?.remoteUrl.includes('github.com')}
<!-- GitHub logo -->
<svg
style="width:0.75rem; height: 0.75rem"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.98091 0.599976C3.45242 0.599976 0.599976 3.47344 0.599976 7.02832C0.599976 9.86992 2.42763 12.2753 4.96308 13.1266C5.28007 13.1906 5.39619 12.9883 5.39619 12.8181C5.39619 12.6691 5.38574 12.1582 5.38574 11.626C3.61072 12.0092 3.24109 10.8597 3.24109 10.8597C2.95583 10.1147 2.53317 9.92321 2.53317 9.92321C1.9522 9.52941 2.57549 9.52941 2.57549 9.52941C3.21993 9.57199 3.55808 10.1893 3.55808 10.1893C4.12847 11.1683 5.04758 10.8917 5.41735 10.7214C5.47011 10.3063 5.63926 10.0189 5.81885 9.85934C4.40314 9.71031 2.91364 9.15691 2.91364 6.68768C2.91364 5.98525 3.16703 5.41055 3.56853 4.9636C3.50518 4.80399 3.28327 4.14401 3.63201 3.26068C3.63201 3.26068 4.17078 3.09036 5.38561 3.92053C5.90572 3.77982 6.4421 3.70824 6.98091 3.70763C7.51968 3.70763 8.06891 3.78221 8.57607 3.92053C9.79103 3.09036 10.3298 3.26068 10.3298 3.26068C10.6785 4.14401 10.4565 4.80399 10.3932 4.9636C10.8052 5.41055 11.0482 5.98525 11.0482 6.68768C11.0482 9.15691 9.55867 9.6996 8.13238 9.85934C8.36487 10.0615 8.56549 10.4446 8.56549 11.0513C8.56549 11.9133 8.55504 12.6052 8.55504 12.818C8.55504 12.9883 8.67129 13.1906 8.98815 13.1267C11.5236 12.2751 13.3513 9.86992 13.3513 7.02832C13.3617 3.47344 10.4988 0.599976 6.98091 0.599976Z"
fill="currentColor"
/>
</svg>
{:else}
<Icon name="branch" />
{/if}
{$base$?.branchName}
</div>
</div>
<div class="row_2 text-base-12">
{#if $base$?.remoteUrl.includes('github.com')}
<!-- GitHub logo -->
<svg
style="width:0.75rem; height: 0.75rem"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.98091 0.599976C3.45242 0.599976 0.599976 3.47344 0.599976 7.02832C0.599976 9.86992 2.42763 12.2753 4.96308 13.1266C5.28007 13.1906 5.39619 12.9883 5.39619 12.8181C5.39619 12.6691 5.38574 12.1582 5.38574 11.626C3.61072 12.0092 3.24109 10.8597 3.24109 10.8597C2.95583 10.1147 2.53317 9.92321 2.53317 9.92321C1.9522 9.52941 2.57549 9.52941 2.57549 9.52941C3.21993 9.57199 3.55808 10.1893 3.55808 10.1893C4.12847 11.1683 5.04758 10.8917 5.41735 10.7214C5.47011 10.3063 5.63926 10.0189 5.81885 9.85934C4.40314 9.71031 2.91364 9.15691 2.91364 6.68768C2.91364 5.98525 3.16703 5.41055 3.56853 4.9636C3.50518 4.80399 3.28327 4.14401 3.63201 3.26068C3.63201 3.26068 4.17078 3.09036 5.38561 3.92053C5.90572 3.77982 6.4421 3.70824 6.98091 3.70763C7.51968 3.70763 8.06891 3.78221 8.57607 3.92053C9.79103 3.09036 10.3298 3.26068 10.3298 3.26068C10.6785 4.14401 10.4565 4.80399 10.3932 4.9636C10.8052 5.41055 11.0482 5.98525 11.0482 6.68768C11.0482 9.15691 9.55867 9.6996 8.13238 9.85934C8.36487 10.0615 8.56549 10.4446 8.56549 11.0513C8.56549 11.9133 8.55504 12.6052 8.55504 12.818C8.55504 12.9883 8.67129 13.1906 8.98815 13.1267C11.5236 12.2751 13.3513 9.86992 13.3513 7.02832C13.3617 3.47344 10.4988 0.599976 6.98091 0.599976Z"
fill="currentColor"
/>
</svg>
{:else}
<Icon name="branch" />
{/if}
{$base$?.branchName}
</div>
</div>
{/if}
</a>
<style lang="postcss">
.base-branch-card {
position: relative;
display: flex;
gap: var(--space-10);
padding: var(--space-10) var(--space-8);
padding: var(--space-10);
border-radius: var(--radius-m);
transition: background-color var(--transition-fast);
}
@ -78,6 +91,9 @@
}
.icon {
border-radius: var(--radius-s);
height: var(--space-20);
width: var(--space-20);
flex-shrink: 0;
}
.content {
@ -100,4 +116,18 @@
gap: var(--space-4);
color: var(--clr-theme-scale-ntrl-40);
}
.small-count-badge {
position: absolute;
top: 10%;
right: 10%;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
min-width: var(--space-14);
background-color: var(--clr-theme-err-element);
color: var(--clr-theme-scale-ntrl-100);
border-radius: var(--radius-m);
}
</style>

View File

@ -350,9 +350,11 @@
<Resizer
viewport={rsViewport}
direction="right"
inside={$selectedFiles.length > 0}
minWidth={320}
sticky
defaultLineColor={$selectedFiles.length > 0
? 'transparent'
: 'var(--clr-theme-container-outline-light)'}
on:width={(e) => {
laneWidth = e.detail / (16 * $userSettings.zoom);
lscache.set(laneWidthKey + branch.id, laneWidth, 7 * 1440); // 7 day ttl
@ -386,21 +388,8 @@
position: absolute;
top: 0;
right: 0;
width: var(--space-4);
height: 100%;
transform: translateX(var(--selected-resize-shift));
&:after {
pointer-events: none;
content: '';
position: absolute;
top: 0;
right: 0;
width: 1px;
height: 100%;
opacity: var(--selected-opacity);
background-color: var(--clr-theme-container-outline-light);
}
}
.branch-card__dropzone-wrapper {

View File

@ -116,8 +116,8 @@
<Resizer
viewport={rsViewport}
direction="right"
inside
minWidth={240}
defaultLineColor="var(--clr-theme-container-outline-light)"
on:width={(e) => {
fileWidth = e.detail / (16 * $userSettings.zoom);
lscache.set(fileWidthKey + branch.id, fileWidth, 7 * 1440); // 7 day ttl
@ -151,7 +151,7 @@
}
.file-selected {
--selected-resize-shift: calc(var(--space-6) * -1);
--selected-resize-shift: calc((var(--space-6) + 0.0625rem) * -1);
--selected-target-branch-right-padding: calc(var(--space-4) * -1);
--selected-opacity: 0;
}
@ -165,7 +165,6 @@
align-items: self-start;
padding: var(--space-12) var(--space-12) var(--space-12) 0;
border-right: 1px solid var(--clr-theme-container-outline-light);
margin-left: var(--selected-target-branch-right-padding);
}
</style>

View File

@ -3,7 +3,6 @@
import BranchesHeader from './BranchesHeader.svelte';
import FilterPopupMenu from '$lib/components/FilterPopupMenu.svelte';
import ImgThemed from '$lib/components/ImgThemed.svelte';
import Resizer from '$lib/components/Resizer.svelte';
import ScrollableContainer from '$lib/components/ScrollableContainer.svelte';
import TextBox from '$lib/components/TextBox.svelte';
import { persisted } from '$lib/persisted/persisted';
@ -64,7 +63,6 @@
let resizeGuard: HTMLElement;
let viewport: HTMLDivElement;
let rsViewport: HTMLElement;
let contents: HTMLElement;
let observer: ResizeObserver;
@ -137,21 +135,7 @@
</script>
<div class="resize-guard" bind:this={resizeGuard}>
<div
class="branch-list"
bind:this={rsViewport}
style:height={$height ? `${$height}rem` : undefined}
style:max-height={maxHeight ? `${maxHeight}rem` : undefined}
>
<Resizer
viewport={rsViewport}
direction="up"
inside
minHeight={90}
on:height={(e) => {
$height = Math.min(maxHeight, e.detail / (16 * $userSettings.zoom));
}}
/>
<div class="branch-list">
<BranchesHeader count={$filteredBranches$?.length ?? 0} filtersActive={$filtersActive}>
<FilterPopupMenu
slot="context-menu"
@ -228,13 +212,16 @@
gap: var(--space-12);
width: 100%;
padding-bottom: var(--space-16);
padding-left: var(--space-12);
padding-right: var(--space-12);
padding-left: var(--space-14);
padding-right: var(--space-14);
}
.branch-list {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
border-top: 1px solid var(--clr-theme-container-outline-light);
}
.content {
display: flex;

View File

@ -49,9 +49,8 @@
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-12);
padding: var(--space-14) var(--space-14) var(--space-12) var(--space-14);
gap: var(--space-8);
border-top: 1px solid var(--clr-theme-container-outline-light);
border-bottom: 1px solid transparent;
transition: border-bottom var(--transition-fast);
position: relative;

View File

@ -1,12 +1,41 @@
<script lang="ts">
import UpdateBaseButton from './UpdateBaseButton.svelte';
import { tooltip } from '$lib/utils/tooltip';
import type { BranchController } from '$lib/vbranches/branchController';
import type { BaseBranchService } from '$lib/vbranches/branchStoresCache';
import { page } from '$app/stores';
export let href: string;
export let domain: string;
export let label: string;
export let iconSrc: string;
export let branchController: BranchController;
export let baseBranchService: BaseBranchService;
export let isNavCollapsed: boolean;
$: base$ = baseBranchService.base$;
$: selected = $page.url.href.includes(href);
</script>
<a {href} class="domain-button text-base-14 text-semibold" class:selected>
<slot />
<a
use:tooltip={isNavCollapsed ? label : ''}
{href}
class="domain-button text-base-14 text-semibold"
class:selected
>
{#if domain === 'workspace'}
<img class="icon" src={iconSrc} alt="" />
{#if !isNavCollapsed}
<span class="text-base-14 text-semibold" class:collapsed-txt={isNavCollapsed}>{label}</span>
{#if ($base$?.behind || 0) > 0 && !isNavCollapsed}
<UpdateBaseButton {branchController} />
{/if}
{/if}
{:else}
<slot />
{/if}
</a>
<style lang="postcss">
@ -15,12 +44,18 @@
align-items: center;
gap: var(--space-10);
border-radius: var(--radius-m);
padding: var(--space-10) var(--space-8);
padding: var(--space-10);
color: var(--clr-theme-scale-ntrl-0);
height: var(--space-36);
transition: background-color var(--transition-fast);
}
.icon {
border-radius: var(--radius-s);
height: var(--space-20);
width: var(--space-20);
flex-shrink: 0;
}
.domain-button:not(.selected):hover,
.domain-button:not(.selected):focus,
.selected {

View File

@ -84,7 +84,7 @@
}}
role="button"
tabindex="0"
on:contextmenu={(e) =>
on:contextmenu|preventDefault={(e) =>
popupMenu.openByMouse(e, {
files: $selectedFiles.includes(file) ? $selectedFiles : [file]
})}

View File

@ -7,32 +7,53 @@
export let user: User | undefined;
export let projectId: string | undefined;
export let isNavCollapsed: boolean;
</script>
<div class="footer" style:border-color="var(--clr-theme-container-outline-light)">
<div class="footer" class:collapsed={isNavCollapsed}>
<div class="left-btns">
<IconButton
icon="mail"
help="Send feedback"
size={isNavCollapsed ? 'xl' : 'l'}
width={isNavCollapsed ? '100%' : undefined}
on:click={() => events.emit('openSendIssueModal')}
/>
<Link href={`/${projectId}/settings`}>
<IconButton icon="settings" help="Project settings" />
<IconButton
icon="settings"
help="Project settings"
size={isNavCollapsed ? 'xl' : 'l'}
width={isNavCollapsed ? '100%' : undefined}
/>
</Link>
</div>
<AccountLink {user} />
<AccountLink {user} {isNavCollapsed} />
</div>
<style lang="postcss">
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-12);
border-top: 1px solid var(--clr-theme-container-outline-light);
border-color: var(--clr-theme-container-outline-light);
}
.left-btns {
display: flex;
align-items: center;
gap: var(--space-2);
}
.footer.collapsed {
flex-direction: column;
padding: 0 var(--space-14);
align-items: flex-start;
gap: var(--space-4);
border: none;
& .left-btns {
flex-direction: column;
}
}
</style>

View File

@ -4,9 +4,10 @@
import type iconsJson from '$lib/icons/icons.json';
export let icon: keyof typeof iconsJson;
export let size: 's' | 'm' | 'l' = 'l';
export let size: 's' | 'm' | 'l' | 'xl' = 'l';
export let loading = false;
export let help = '';
export let width: string | undefined = undefined;
let className = '';
let selected = false;
@ -20,9 +21,11 @@
class:small={size == 's'}
class:medium={size == 'm'}
class:large={size == 'l'}
class:x-large={size == 'xl'}
use:tooltip={help}
{title}
on:click
style:width
>
<Icon name={loading ? 'spinner' : icon} />
</button>
@ -47,6 +50,11 @@
background-color: color-mix(in srgb, transparent, var(--darken-tint-light));
cursor: default;
}
.x-large {
height: var(--size-btn-xl);
width: var(--size-btn-xl);
padding: var(--space-12);
}
.large {
height: var(--size-btn-l);
width: var(--size-btn-l);

View File

@ -1,13 +1,17 @@
<script lang="ts">
import BaseBranchCard from './BaseBranchCard.svelte';
import Branches from './Branches.svelte';
import DomainButton from './DomainButton.svelte';
import Footer from './Footer.svelte';
import ProjectSelector from './ProjectSelector.svelte';
import UpdateBaseButton from './UpdateBaseButton.svelte';
import BaseBranchCard from '$lib/components/BaseBranchCard.svelte';
import Resizer from '$lib/components/Resizer.svelte';
import Resizer from './Resizer.svelte';
import { persisted } from '$lib/persisted/persisted';
import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/settings/userSettings';
import * as hotkeys from '$lib/utils/hotkeys';
import { unsubscribe } from '$lib/utils/random';
import { platform } from '@tauri-apps/api/os';
import { from } from 'rxjs';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import type { User } from '$lib/backend/cloud';
import type { Project, ProjectService } from '$lib/backend/projects';
@ -24,79 +28,217 @@
export let githubService: GitHubService;
export let projectService: ProjectService;
const minResizerWidth = 280;
const minResizerRatio = 150;
const platformName = from(platform());
const userSettings = getContext<SettingsStore>(SETTINGS_CONTEXT);
const defaultTrayWidthRem = persisted<number | undefined>(
undefined,
'defaulTrayWidth_ ' + project.id
);
$: base$ = baseBranchService.base$;
let viewport: HTMLDivElement;
let isResizerDragging = false;
$: isNavCollapsed = persisted<boolean>(false, 'projectNavCollapsed_' + project.id);
function toggleNavCollapse() {
$isNavCollapsed = !$isNavCollapsed;
}
onMount(() =>
unsubscribe(
hotkeys.on('Meta+/', () => {
toggleNavCollapse();
})
)
);
</script>
<div
class="navigation relative flex w-80 shrink-0 flex-col border-r"
style:width={$defaultTrayWidthRem ? $defaultTrayWidthRem + 'rem' : null}
bind:this={viewport}
role="menu"
tabindex="0"
>
<div class="drag-region" data-tauri-drag-region></div>
<div class="domains">
<ProjectSelector {project} {projectService} />
<div class="flex flex-col gap-1">
<BaseBranchCard {project} {baseBranchService} {githubService} />
<DomainButton href={`/${project.id}/board`}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 6.64C0 4.17295 0 2.93942 0.525474 2.01817C0.880399 1.39592 1.39592 0.880399 2.01817 0.525474C2.93942 0 4.17295 0 6.64 0H9.36C11.8271 0 13.0606 0 13.9818 0.525474C14.6041 0.880399 15.1196 1.39592 15.4745 2.01817C16 2.93942 16 4.17295 16 6.64V9.36C16 11.8271 16 13.0606 15.4745 13.9818C15.1196 14.6041 14.6041 15.1196 13.9818 15.4745C13.0606 16 11.8271 16 9.36 16H6.64C4.17295 16 2.93942 16 2.01817 15.4745C1.39592 15.1196 0.880399 14.6041 0.525474 13.9818C0 13.0606 0 11.8271 0 9.36V6.64Z"
fill="#48B0AA"
/>
<rect x="2" y="3" width="6" height="10" rx="2" fill="#D9F3F2" />
<rect opacity="0.7" x="10" y="3" width="4" height="10" rx="2" fill="#D9F3F2" />
</svg>
<aside class="navigation-wrapper">
<div class="resizer-wrapper" class:resizerDragging={isResizerDragging} tabindex="0" role="button">
<button
class="folding-button"
on:click={toggleNavCollapse}
class:folding-button_folded={$isNavCollapsed}
>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 8 12"
fill="none"
><path
d="M6,0L0,6l6,6"
transform="translate(1 0)"
stroke-width="1.5"
stroke-linejoin="round"
/></svg
>
</button>
<Resizer
{viewport}
direction="right"
minWidth={minResizerWidth}
defaultLineColor="var(--clr-theme-container-outline-light)"
on:click={() => $isNavCollapsed && toggleNavCollapse()}
on:dblclick={() => !$isNavCollapsed && toggleNavCollapse()}
on:width={(e) => {
$defaultTrayWidthRem = e.detail / (16 * $userSettings.zoom);
}}
on:resizing={(e) => (isResizerDragging = e.detail)}
on:overflowValue={(e) => {
const overflowValue = e.detail;
<span>Workspace</span>
{#if ($base$?.behind || 0) > 0}
<UpdateBaseButton {branchController} />
{/if}
</DomainButton>
</div>
if (!$isNavCollapsed && overflowValue > minResizerRatio) {
$isNavCollapsed = true;
}
if ($isNavCollapsed && overflowValue < minResizerRatio) {
$isNavCollapsed = false;
}
}}
/>
</div>
<Branches projectId={project.id} {branchService} {githubService} />
<Footer {user} projectId={project.id} />
<Resizer
{viewport}
direction="right"
minWidth={320}
on:width={(e) => {
$defaultTrayWidthRem = e.detail / (16 * $userSettings.zoom);
}}
/>
</div>
<div
class="navigation"
class:collapsed={$isNavCollapsed}
style:width={$defaultTrayWidthRem && !$isNavCollapsed ? $defaultTrayWidthRem + 'rem' : null}
bind:this={viewport}
role="menu"
tabindex="0"
>
<!-- condition prevents split second UI shift -->
{#if $platformName}
<div class="navigation-top">
{#if $platformName == 'darwin'}
<div class="drag-region" data-tauri-drag-region />
{/if}
<ProjectSelector {project} {projectService} isNavCollapsed={$isNavCollapsed} />
<div class="domains">
<BaseBranchCard
{project}
{baseBranchService}
{githubService}
isNavCollapsed={$isNavCollapsed}
/>
<DomainButton
href={`/${project.id}/board`}
domain="workspace"
label="Workspace"
iconSrc="/images/domain-icons/working-branches.svg"
{branchController}
{baseBranchService}
isNavCollapsed={$isNavCollapsed}
></DomainButton>
</div>
</div>
{#if !$isNavCollapsed}
<Branches projectId={project.id} {branchService} {githubService} />
{/if}
<Footer {user} projectId={project.id} isNavCollapsed={$isNavCollapsed} />
{/if}
</div>
</aside>
<style lang="postcss">
.navigation-wrapper {
display: flex;
position: relative;
&:hover {
& .folding-button {
opacity: 1;
transform: translateY(-50%);
right: calc(var(--space-6) * -1);
transition-delay: 0.1s;
& svg {
transition-delay: 0.1s;
}
}
}
}
.navigation {
border-right: 1px solid var(--clr-theme-container-outline-light);
width: 17.5rem;
display: flex;
flex-direction: column;
position: relative;
background: var(--clr-theme-container-light);
max-height: 100%;
user-select: none;
}
.drag-region {
flex-shrink: 0;
height: var(--space-24);
height: var(--space-20);
}
.navigation-top {
display: flex;
flex-direction: column;
padding-bottom: var(--space-24);
padding-left: var(--space-14);
padding-right: var(--space-14);
}
.domains {
padding-bottom: var(--space-24);
padding-left: var(--space-12);
padding-right: var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.resizer-wrapper {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: var(--space-4);
&:hover,
&.resizerDragging {
& .folding-button {
background-color: var(--resizer-color);
border: 1px solid var(--resizer-color);
& svg {
stroke: var(--clr-theme-scale-ntrl-100);
}
}
}
}
.folding-button {
z-index: 42;
position: absolute;
right: calc(var(--space-2) * -1);
top: 50%;
transform: translateY(-50%);
width: var(--space-16);
height: var(--space-36);
padding: var(--space-4);
background: var(--clr-theme-container-light);
border-radius: var(--radius-m);
border: 1px solid var(--clr-theme-container-outline-light);
opacity: 0;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
opacity var(--transition-medium),
all var(--transition-medium);
& svg {
stroke: var(--clr-theme-scale-ntrl-50);
transition: stroke var(--transition-fast);
}
}
.folding-button_folded {
& svg {
transform: rotate(180deg) translateX(-0.0625rem);
}
}
.navigation.collapsed {
width: auto;
justify-content: space-between;
padding-bottom: var(--space-16);
}
</style>

View File

@ -0,0 +1,62 @@
<script lang="ts">
export let name: string | undefined;
const colors = [
'#E78D8D',
'#62CDCD',
'#EC90D2',
'#7DC8D8',
'#F1BC55',
'#6B6B4C',
'#9785DE',
'#99CE63',
'#636ECE',
'#5FD2B0'
];
function nameToColor(name: string | undefined) {
const trimmed = name?.replace(/\s/g, '');
if (!trimmed) {
return `linear-gradient(45deg, ${colors[0][0]} 15%, ${colors[0][1]} 90%)`;
}
const startHash = trimmed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[startHash % colors.length];
}
function getFirstLetter(name: string | undefined) {
return name ? name[0].toUpperCase() : '';
}
$: firstLetter = getFirstLetter(name);
</script>
<div class="project-avatar" style:background-color={nameToColor(name)}>
<svg class="avatar-letter" viewBox="0 0 24 24">
<text x="50%" y="54%" text-anchor="middle" alignment-baseline="middle">
{firstLetter.toUpperCase()}
</text>
</svg>
</div>
<style>
.project-avatar {
flex-shrink: 0;
width: var(--space-20);
height: var(--space-20);
border-radius: var(--radius-m);
}
.avatar-letter {
width: 100%;
height: 100%;
}
.avatar-letter text {
font-family: 'Inter', sans-serif;
font-weight: 800;
font-size: 16px;
line-height: 1;
fill: var(--clr-core-ntrl-100);
}
</style>

View File

@ -1,53 +1,61 @@
<script lang="ts">
import ProjectAvatar from './ProjectAvatar.svelte';
import ProjectsPopup from './ProjectsPopup.svelte';
import { clickOutside } from '$lib/clickOutside';
import Icon from '$lib/components/Icon.svelte';
import { tooltip } from '$lib/utils/tooltip';
import type { Project, ProjectService } from '$lib/backend/projects';
export let project: Project | undefined;
export let projectService: ProjectService;
export let isNavCollapsed: boolean;
let popup: ProjectsPopup;
let visible: boolean = false;
</script>
<div class="wrapper">
<div
class="relative"
use:clickOutside={{
handler: () => {
popup.hide();
visible = false;
},
enabled: visible
<div
class="wrapper"
use:clickOutside={{
handler: () => {
popup.hide();
visible = false;
},
enabled: visible
}}
>
<button
class="button"
use:tooltip={isNavCollapsed ? project?.title : ''}
on:click={(e) => {
visible = popup.toggle();
e.preventDefault();
}}
>
<button
class="button"
on:click={(e) => {
visible = popup.toggle();
e.preventDefault();
}}
>
<ProjectAvatar name={project?.title} />
{#if !isNavCollapsed}
<span class="button__label text-base-14 text-bold">{project?.title}</span>
<div class="button__icon">
<Icon name="select-chevron" />
</div>
</button>
<ProjectsPopup bind:this={popup} {projectService} />
</div>
{/if}
</button>
<ProjectsPopup bind:this={popup} {projectService} {isNavCollapsed} />
</div>
<style lang="postcss">
.wrapper {
margin-top: var(--space-10);
position: relative;
margin-top: var(--space-14);
margin-bottom: var(--space-16);
height: fit-content;
}
.button {
display: flex;
gap: var(--space-10);
width: 100%;
padding: var(--space-12);
padding: var(--space-10);
border-radius: var(--radius-m);
background-color: var(--clr-theme-container-pale);
@ -75,6 +83,9 @@
flex-grow: 1;
color: var(--clr-theme-scale-ntrl-0);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.button__icon {

View File

@ -5,6 +5,7 @@
import { page } from '$app/stores';
export let projectService: ProjectService;
export let isNavCollapsed: boolean;
$: projects$ = projectService.projects$;
@ -22,7 +23,7 @@
</script>
{#if !hidden}
<div class="popup">
<div class="popup" class:collapsed={isNavCollapsed}>
{#if $projects$.length > 0}
<div class="popup__projects">
{#each $projects$ as project}
@ -81,4 +82,9 @@
gap: var(--space-2);
padding: var(--space-8);
}
/* MODIFIERS */
.popup.collapsed {
width: 240px;
}
</style>

View File

@ -1,4 +1,5 @@
<script lang="ts">
import { pxToRem } from '$lib/utils/pxToRem';
import { createEventDispatcher } from 'svelte';
// The element that is being resized
@ -7,11 +8,17 @@
// Sets direction of resizing for viewport
export let direction: 'left' | 'right' | 'up' | 'down';
// Needed when overflow is hidden
export let inside = false;
// Sets the color of the line
export let defaultLineColor: string = 'none';
export let defaultLineThickness: number = 1;
export let hoverLineThickness: number = 2;
// Needed when overflow is hidden
export let sticky = false;
// Custom z-index in case of overlapping with other elements
export let zIndex = 40;
//
export let minWidth = 0;
export let minHeight = 0;
@ -25,6 +32,7 @@
height: number;
width: number;
resizing: boolean;
overflowValue: number;
}>();
function onMouseDown(e: MouseEvent) {
@ -41,23 +49,37 @@
dispatch('resizing', true);
}
function onOverflowValue(currentValue: number, minVal: number) {
if (currentValue < minVal) {
dispatch('overflowValue', minVal - currentValue);
}
}
function onMouseMove(e: MouseEvent) {
dragging = true;
if (direction == 'down') {
let height = e.clientY - initial;
dispatch('height', Math.max(height, minHeight));
onOverflowValue(height, minHeight);
}
if (direction == 'up') {
let height = document.body.scrollHeight - e.clientY - initial;
dispatch('height', Math.max(height, minHeight));
onOverflowValue(height, minHeight);
}
if (direction == 'right') {
let width = e.clientX - initial + 2;
dispatch('width', Math.max(width, minWidth));
onOverflowValue(width, minWidth);
}
if (direction == 'left') {
let width = document.body.scrollWidth - e.clientX - initial;
dispatch('width', Math.max(width, minWidth));
onOverflowValue(width, minWidth);
}
}
@ -71,11 +93,13 @@
<div
on:mousedown={onMouseDown}
class="resizer"
on:click|stopPropagation
on:dblclick|stopPropagation
on:keydown|stopPropagation
tabindex="0"
role="slider"
aria-valuenow={viewport?.clientHeight}
class:inside
class="resizer"
class:dragging
class:vertical={orientation == 'vertical'}
class:horizontal={orientation == 'horizontal'}
@ -84,68 +108,111 @@
class:left={direction == 'left'}
class:right={direction == 'right'}
class:sticky
/>
style:z-index={zIndex}
>
<div
class="resizer-line"
style="--resizer-default-line-color: {defaultLineColor}; --resizer-default-line-thickness: {pxToRem(
defaultLineThickness
)}; --resizer-hover-line-thickness: {pxToRem(hoverLineThickness)}"
/>
</div>
<style lang="postcss">
.resizer {
--resizer-frame-thickness: var(--space-4);
--resizer-default-line-thickness: var(--space-2);
--resizer-hover-line-thickness: var(--space-8);
--resizer-default-line-color: none;
position: absolute;
transition: background-color 0.1s ease-out;
/* background-color: var(--clr-theme-container-outline-light); */
&:hover {
transition-delay: 0.1s;
}
z-index: 40;
&:hover,
&:focus,
&.dragging {
background-color: var(--clr-theme-container-outline-light);
outline: none;
& .resizer-line {
transition-delay: 0.1s;
background-color: var(--resizer-color);
}
&:not(.vertical) {
& .resizer-line {
width: var(--resizer-hover-line-thickness);
}
}
&:not(.horizontal) {
& .resizer-line {
height: var(--resizer-hover-line-thickness);
}
}
}
}
.resizer-line {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--resizer-default-line-color);
pointer-events: none;
transition:
background-color 0.1s ease-out,
width 0.1s ease-out,
height 0.1s ease-out;
}
.horizontal {
width: var(--space-4);
height: 100%;
cursor: col-resize;
top: 0;
&:hover {
width: var(--space-4);
& .resizer-line {
width: var(--resizer-default-line-thickness);
}
}
.vertical {
height: var(--space-4);
width: 100%;
cursor: row-resize;
&:hover {
height: var(--space-4);
left: 0;
& .resizer-line {
height: var(--resizer-default-line-thickness);
}
}
.right {
right: calc(-1 * var(--space-2));
&.inside {
right: 0;
right: 0;
& .resizer-line {
left: auto;
}
}
.left {
left: 0;
&:hover {
width: var(--space-4);
& .resizer-line {
right: auto;
}
}
.up {
top: 0;
&:hover {
height: var(--space-4);
& .resizer-line {
bottom: auto;
}
}
.down {
bottom: calc(-1 * var(--space-2));
&:hover {
height: var(--space-4);
}
&.inside {
bottom: 0;
bottom: 0;
& .resizer-line {
top: auto;
}
}
.sticky {
position: sticky;
top: 0;

View File

@ -67,7 +67,7 @@
}}
on:click
on:keydown
on:contextmenu={(e) =>
on:contextmenu|preventDefault={(e) =>
popupMenu.openByMouse(e, {
files: $selectedFiles.includes(file) ? $selectedFiles : [file]
})}

View File

@ -81,11 +81,6 @@ export function draggable(node: HTMLElement, opts: Partial<DraggableOptions> | u
dragHandle = e.target as HTMLElement;
}
/**
* The problem with the ghost element is that it gets clipped after rotation unless we enclose
* it within a larger bounding box. This means we have an extra `<div>` in the html that is
* only present to support the rotation
*/
function handleDragStart(e: DragEvent) {
let elt: HTMLElement | null = dragHandle;
while (elt) {

View File

@ -71,6 +71,7 @@
name: newName,
picture: picture
});
updatedUser.github_access_token = $user$?.github_access_token; // prevent overwriting with null
userService.setUser(updatedUser);
toasts.success('Profile updated');
} catch (e) {

View File

@ -36,6 +36,7 @@
/* TODO: add focus color */
--focus-color: var(--clr-theme-scale-pop-50);
--resizer-color: var(--clr-theme-scale-pop-50);
}
:root.dark {
@ -121,12 +122,6 @@ the scrollbar to scale with the font size */
pointer-events: none;
}
/* Accordion uses this */
.collapsed {
display: none;
}
/* FOCUS STATE */
.focus-state {

View File

@ -172,6 +172,7 @@
--size-btn-s: 1.25rem;
--size-btn-m: 1.625rem;
--size-btn-l: 2rem;
--size-btn-xl: 2.25rem;
--space-2: 0.125rem; /* 2px / 16px = 0.125rem */
--space-4: 0.25rem; /* 4px / 16px = 0.25rem */
--space-6: 0.375rem; /* 6px / 16px = 0.375rem */

View File

@ -4,10 +4,7 @@
border: 1px solid var(--clr-core-ntrl-30);
color: var(--clr-core-ntrl-60);
display: inline-block;
padding-left: var(--space-8);
padding-right: var(--space-8);
padding-top: var(--space-4);
padding-bottom: var(--space-4);
padding: var(--space-6);
z-index: 50;
max-width: 11.25rem;

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" fill="#FB7D61"/>
<path d="M8 4L12 8L8 12L4 8L8 4Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" fill="#48B0AA"/>
<rect x="2" y="3" width="6" height="10" rx="2" fill="#D9F3F2"/>
<rect opacity="0.7" x="10" y="3" width="4" height="10" rx="2" fill="#D9F3F2"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@ -215,6 +215,9 @@ importers:
tauri-plugin-context-menu:
specifier: ^0.7.0
version: 0.7.0
tauri-plugin-log-api:
specifier: github:tauri-apps/tauri-plugin-log#v1
version: github.com/tauri-apps/tauri-plugin-log/19f5dcc0425e9127d2c591780e5047b83e77a7c2
tauri-plugin-store-api:
specifier: github:tauri-apps/tauri-plugin-store#v1
version: github.com/tauri-apps/tauri-plugin-store/7d2632996f290b0f18cc5f8a2b2791046400690e
@ -4768,6 +4771,14 @@ packages:
engines: {node: '>=12.20'}
dev: true
github.com/tauri-apps/tauri-plugin-log/19f5dcc0425e9127d2c591780e5047b83e77a7c2:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/19f5dcc0425e9127d2c591780e5047b83e77a7c2}
name: tauri-plugin-log-api
version: 0.0.0
dependencies:
'@tauri-apps/api': 1.5.3
dev: true
github.com/tauri-apps/tauri-plugin-store/7d2632996f290b0f18cc5f8a2b2791046400690e:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-store/tar.gz/7d2632996f290b0f18cc5f8a2b2791046400690e}
name: tauri-plugin-store-api