merge upstream

This commit is contained in:
Nikita Galaiko 2024-02-27 10:21:12 +01:00
commit f147f12db0
72 changed files with 1633 additions and 756 deletions

View File

@ -5,7 +5,6 @@
"rust-lang.rust-analyzer",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ZixuanChen.vitest-explorer",
"EditorConfig.EditorConfig"
]
}

View File

@ -59,6 +59,7 @@ impl Default for Options {
pub fn workdir(
repository: &Repository,
commit_oid: &git::Oid,
context_lines: u32,
) -> Result<HashMap<path::PathBuf, Vec<Hunk>>> {
let commit = repository
.find_commit(*commit_oid)
@ -72,7 +73,7 @@ pub fn workdir(
.show_binary(true)
.show_untracked_content(true)
.ignore_submodules(true)
.context_lines(0);
.context_lines(context_lines);
let diff = repository.diff_tree_to_workdir(Some(&tree), Some(&mut diff_opts))?;
@ -83,6 +84,7 @@ pub fn trees(
repository: &Repository,
old_tree: &git::Tree,
new_tree: &git::Tree,
context_lines: u32,
) -> Result<HashMap<path::PathBuf, Vec<Hunk>>> {
let mut diff_opts = git2::DiffOptions::new();
diff_opts
@ -90,7 +92,7 @@ pub fn trees(
.include_untracked(true)
.show_binary(true)
.ignore_submodules(true)
.context_lines(0)
.context_lines(context_lines)
.show_untracked_content(true);
let diff =
@ -340,7 +342,7 @@ mod tests {
let head_commit_id = repository.head().unwrap().peel_to_commit().unwrap().id();
let diff = workdir(&repository, &head_commit_id).unwrap();
let diff = workdir(&repository, &head_commit_id, 0).unwrap();
assert_eq!(diff.len(), 1);
assert_eq!(
diff[&path::PathBuf::from("file")],
@ -363,7 +365,7 @@ mod tests {
let head_commit_id = repository.head().unwrap().peel_to_commit().unwrap().id();
let diff = workdir(&repository, &head_commit_id).unwrap();
let diff = workdir(&repository, &head_commit_id, 0).unwrap();
assert_eq!(diff.len(), 1);
assert_eq!(
diff[&path::PathBuf::from("first")],
@ -387,7 +389,7 @@ mod tests {
let head_commit_id = repository.head().unwrap().peel_to_commit().unwrap().id();
let diff = workdir(&repository, &head_commit_id).unwrap();
let diff = workdir(&repository, &head_commit_id, 0).unwrap();
assert_eq!(diff.len(), 2);
assert_eq!(
diff[&path::PathBuf::from("first")],
@ -431,7 +433,7 @@ mod tests {
let head_commit_id = repository.head().unwrap().peel_to_commit().unwrap().id();
let diff = workdir(&repository, &head_commit_id).unwrap();
let diff = workdir(&repository, &head_commit_id, 0).unwrap();
assert_eq!(
diff[&path::PathBuf::from("image")],
vec![Hunk {
@ -474,7 +476,7 @@ mod tests {
let head_commit_id = repository.head().unwrap().peel_to_commit().unwrap().id();
let diff = workdir(&repository, &head_commit_id).unwrap();
let diff = workdir(&repository, &head_commit_id, 0).unwrap();
assert_eq!(
diff[&path::PathBuf::from("file")],
vec![Hunk {

View File

@ -365,6 +365,13 @@ impl Repository {
if self.project.omit_certificate_check.unwrap_or(false) {
cbs.certificate_check(|_, _| Ok(git2::CertificateCheckStatus::CertificateOk));
}
cbs.push_update_reference(|_reference: &str, status: Option<&str>| {
if let Some(status) = status {
return Err(git2::Error::from_str(status));
};
Ok(())
});
match remote.push(
&[refspec.as_str()],
Some(&mut git2::PushOptions::new().remote_callbacks(cbs)),

View File

@ -70,6 +70,10 @@ impl From<controller::AddError> for Error {
code: Code::Projects,
message: "Path not found".to_string(),
},
controller::AddError::SubmodulesNotSupported => Error::UserError {
code: Code::Projects,
message: "Repositories with git submodules are not supported".to_string(),
},
controller::AddError::User(error) => error.into(),
controller::AddError::Other(error) => {
tracing::error!(?error, "failed to add project");

View File

@ -66,6 +66,10 @@ impl Controller {
return Err(AddError::NotAGitRepository);
};
if path.join(".gitmodules").exists() {
return Err(AddError::SubmodulesNotSupported);
}
let id = uuid::Uuid::new_v4().to_string();
// title is the base name of the file
@ -79,6 +83,7 @@ impl Controller {
title,
path: path.to_path_buf(),
api: None,
use_diff_context: Some(true),
..Default::default()
};
@ -201,6 +206,10 @@ impl Controller {
tracing::error!(project_id = %id, ?error, "failed to remove project data",);
}
if let Err(error) = std::fs::remove_file(project.path.join(".git/gitbutler.json")) {
tracing::error!(project_id = %project.id, ?error, "failed to remove .git/gitbutler.json data",);
}
Ok(())
}
}
@ -253,6 +262,8 @@ pub enum AddError {
PathNotFound,
#[error("project already exists")]
AlreadyExists,
#[error("submodules not supported")]
SubmodulesNotSupported,
#[error(transparent)]
User(#[from] users::GetError),
#[error(transparent)]

View File

@ -78,6 +78,8 @@ pub struct Project {
pub project_data_last_fetch: Option<FetchResult>,
#[serde(default)]
pub omit_certificate_check: Option<bool>,
#[serde(default)]
pub use_diff_context: Option<bool>,
}
impl AsRef<Project> for Project {

View File

@ -47,6 +47,7 @@ pub struct UpdateRequest {
pub gitbutler_code_push_state: Option<project::CodePushState>,
pub project_data_last_fetched: Option<project::FetchResult>,
pub omit_certificate_check: Option<bool>,
pub use_diff_context: Option<bool>,
}
#[derive(Debug, thiserror::Error)]
@ -139,6 +140,10 @@ impl Storage {
project.omit_certificate_check = Some(omit_certificate_check);
}
if let Some(use_diff_context) = update_request.use_diff_context {
project.use_diff_context = Some(use_diff_context);
}
self.storage
.write(PROJECTS_FILE, &serde_json::to_string_pretty(&projects)?)?;

View File

@ -135,7 +135,12 @@ pub fn set_base_branch(
// if there are any commits on the head branch or uncommitted changes in the working directory, we need to
// put them into a virtual branch
let wd_diff = diff::workdir(repo, &current_head_commit.id())?;
let use_context = project_repository
.project()
.use_diff_context
.unwrap_or(false);
let context_lines = if use_context { 3_u32 } else { 0_u32 };
let wd_diff = diff::workdir(repo, &current_head_commit.id(), context_lines)?;
if !wd_diff.is_empty() || current_head_commit.id() != target.sha {
let hunks_by_filepath =
super::virtual_hunks_by_filepath(&project_repository.project().path, &wd_diff);
@ -308,6 +313,12 @@ pub fn update_base_branch(
let branch_writer =
branch::Writer::new(gb_repository).context("failed to create branch writer")?;
let use_context = project_repository
.project()
.use_diff_context
.unwrap_or(false);
let context_lines = if use_context { 3_u32 } else { 0_u32 };
// try to update every branch
let updated_vbranches = super::get_status_by_branch(gb_repository, project_repository)?
.into_iter()
@ -341,6 +352,7 @@ pub fn update_base_branch(
&project_repository.git_repository,
&branch_head_tree,
&branch_tree,
context_lines,
)?;
if non_commited_files.is_empty() {
// if there are no commited files, then the branch is fully merged

View File

@ -12,7 +12,7 @@ use crate::{
};
use super::{
branch::BranchId,
branch::{self, BranchId},
controller::{Controller, ControllerError},
BaseBranch, RemoteBranchFile,
};
@ -80,11 +80,23 @@ pub async fn list_virtual_branches(
code: Code::Validation,
message: "Malformed project id".to_string(),
})?;
let branches = handle
let (branches, uses_diff_context) = handle
.state::<Controller>()
.list_virtual_branches(&project_id)
.await?;
// Migration: If use_diff_context is not already set and if there are no vbranches, set use_diff_context to true
if !uses_diff_context && branches.is_empty() {
let _ = handle
.state::<projects::Controller>()
.update(&projects::UpdateRequest {
id: project_id,
use_diff_context: Some(true),
..Default::default()
})
.await;
}
let proxy = handle.state::<assets::Proxy>();
let branches = proxy.proxy_virtual_branches(branches).await;
Ok(branches)

View File

@ -124,7 +124,8 @@ impl Controller {
pub async fn list_virtual_branches(
&self,
project_id: &ProjectId,
) -> Result<Vec<super::VirtualBranch>, ControllerError<errors::ListVirtualBranchesError>> {
) -> Result<(Vec<super::VirtualBranch>, bool), ControllerError<errors::ListVirtualBranchesError>>
{
self.inner(project_id)
.await
.list_virtual_branches(project_id)
@ -493,7 +494,8 @@ impl ControllerInner {
pub async fn list_virtual_branches(
&self,
project_id: &ProjectId,
) -> Result<Vec<super::VirtualBranch>, ControllerError<errors::ListVirtualBranchesError>> {
) -> Result<(Vec<super::VirtualBranch>, bool), ControllerError<errors::ListVirtualBranchesError>>
{
let _permit = self.semaphore.acquire().await;
self.with_verify_branch(project_id, |gb_repository, project_repository, _| {
@ -570,9 +572,17 @@ impl ControllerInner {
) -> Result<Vec<RemoteBranchFile>, Error> {
let project = self.projects.get(project_id)?;
let project_repository = project_repository::Repository::open(&project)?;
super::list_remote_commit_files(&project_repository.git_repository, commit_oid)
.map_err(Into::into)
let use_context = project_repository
.project()
.use_diff_context
.unwrap_or(false);
let context_lines = if use_context { 3_u32 } else { 0_u32 };
super::list_remote_commit_files(
&project_repository.git_repository,
commit_oid,
context_lines,
)
.map_err(Into::into)
}
pub fn set_base_branch(

View File

@ -19,6 +19,7 @@ pub struct RemoteBranchFile {
pub fn list_remote_commit_files(
repository: &git::Repository,
commit_oid: git::Oid,
context_lines: u32,
) -> Result<Vec<RemoteBranchFile>, errors::ListRemoteCommitFilesError> {
let commit = match repository.find_commit(commit_oid) {
Ok(commit) => Ok(commit),
@ -35,7 +36,7 @@ pub fn list_remote_commit_files(
let parent = commit.parent(0).context("failed to get parent commit")?;
let commit_tree = commit.tree().context("failed to get commit tree")?;
let parent_tree = parent.tree().context("failed to get parent tree")?;
let diff = diff::trees(repository, &parent_tree, &commit_tree)?;
let diff = diff::trees(repository, &parent_tree, &commit_tree, context_lines)?;
let files = diff
.into_iter()

View File

@ -81,7 +81,7 @@ fn test_commit_on_branch_then_change_file_then_get_status() -> Result<()> {
"line0\nline1\nline2\nline3\nline4\n",
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches[0];
assert_eq!(branch.files.len(), 1);
assert_eq!(branch.commits.len(), 0);
@ -99,7 +99,7 @@ fn test_commit_on_branch_then_change_file_then_get_status() -> Result<()> {
)?;
// status (no files)
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches[0];
assert_eq!(branch.files.len(), 0);
assert_eq!(branch.commits.len(), 1);
@ -110,7 +110,7 @@ fn test_commit_on_branch_then_change_file_then_get_status() -> Result<()> {
)?;
// should have just the last change now, the other line is committed
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches[0];
assert_eq!(branch.files.len(), 1);
assert_eq!(branch.commits.len(), 1);
@ -170,7 +170,7 @@ fn test_signed_commit() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository).unwrap();
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository).unwrap();
let commit_id = &branches[0].commits[0].id;
let commit_obj = project_repository.git_repository.find_commit(*commit_id)?;
// check the raw_header contains the string "SSH SIGNATURE"
@ -235,7 +235,7 @@ fn test_track_binary_files() -> Result<()> {
let mut file = fs::File::create(std::path::Path::new(&project.path).join("image.bin"))?;
file.write_all(&image_data)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches[0];
assert_eq!(branch.files.len(), 2);
let img_file = &branch
@ -262,7 +262,7 @@ fn test_track_binary_files() -> Result<()> {
)?;
// status (no files)
let branches = list_virtual_branches(&gb_repository, &project_repository).unwrap();
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository).unwrap();
let commit_id = &branches[0].commits[0].id;
let commit_obj = project_repository.git_repository.find_commit(*commit_id)?;
let tree = commit_obj.tree()?;
@ -291,7 +291,7 @@ fn test_track_binary_files() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository).unwrap();
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository).unwrap();
let commit_id = &branches[0].commits[0].id;
// get tree from commit_id
let commit_obj = project_repository.git_repository.find_commit(*commit_id)?;
@ -621,7 +621,7 @@ fn test_updated_ownership_should_bubble_up() -> Result<()> {
)?;
get_status_by_branch(&gb_repository, &project_repository).expect("failed to get status");
let files = branch_reader.read(&branch1_id)?.ownership.files;
assert_eq!(files, vec!["test.txt:14-15,1-2".parse()?]);
assert_eq!(files, vec!["test.txt:11-15,1-5".parse()?]);
assert_eq!(
files[0].hunks[0].timestam_ms(),
files[0].hunks[1].timestam_ms(),
@ -641,7 +641,7 @@ fn test_updated_ownership_should_bubble_up() -> Result<()> {
let files1 = branch_reader.read(&branch1_id)?.ownership.files;
assert_eq!(
files1,
vec!["test2.txt:1-2".parse()?, "test.txt:14-15,1-2".parse()?]
vec!["test2.txt:1-2".parse()?, "test.txt:11-15,1-5".parse()?]
);
assert_ne!(
@ -667,7 +667,7 @@ fn test_updated_ownership_should_bubble_up() -> Result<()> {
let files2 = branch_reader.read(&branch1_id)?.ownership.files;
assert_eq!(
files2,
vec!["test.txt:1-3,14-15".parse()?, "test2.txt:1-2".parse()?,]
vec!["test.txt:1-6,11-15".parse()?, "test2.txt:1-2".parse()?,]
);
assert_ne!(
@ -736,12 +736,12 @@ fn test_move_hunks_multiple_sources() -> Result<()> {
let branch_writer = branch::Writer::new(&gb_repository)?;
let mut branch2 = branch_reader.read(&branch2_id)?;
branch2.ownership = Ownership {
files: vec!["test.txt:1-2".parse()?],
files: vec!["test.txt:1-5".parse()?],
};
branch_writer.write(&mut branch2)?;
let mut branch1 = branch_reader.read(&branch1_id)?;
branch1.ownership = Ownership {
files: vec!["test.txt:14-15".parse()?],
files: vec!["test.txt:11-15".parse()?],
};
branch_writer.write(&mut branch1)?;
@ -765,7 +765,7 @@ fn test_move_hunks_multiple_sources() -> Result<()> {
&project_repository,
branch::BranchUpdateRequest {
id: branch3_id,
ownership: Some("test.txt:1-2,14-15".parse()?),
ownership: Some("test.txt:1-5,11-15".parse()?),
..Default::default()
},
)?;
@ -788,11 +788,11 @@ fn test_move_hunks_multiple_sources() -> Result<()> {
);
assert_eq!(
files_by_branch_id[&branch3_id][std::path::Path::new("test.txt")][0].diff,
"@@ -0,0 +1 @@\n+line0\n"
"@@ -1,3 +1,4 @@\n+line0\n line1\n line2\n line3\n"
);
assert_eq!(
files_by_branch_id[&branch3_id][std::path::Path::new("test.txt")][1].diff,
"@@ -12,0 +14 @@ line12\n+line13\n"
"@@ -10,3 +11,4 @@ line9\n line10\n line11\n line12\n+line13\n"
);
Ok(())
}
@ -848,7 +848,7 @@ fn test_move_hunks_partial_explicitly() -> Result<()> {
&project_repository,
branch::BranchUpdateRequest {
id: branch2_id,
ownership: Some("test.txt:1-2".parse()?),
ownership: Some("test.txt:1-5".parse()?),
..Default::default()
},
)?;
@ -869,7 +869,7 @@ fn test_move_hunks_partial_explicitly() -> Result<()> {
);
assert_eq!(
files_by_branch_id[&branch1_id][std::path::Path::new("test.txt")][0].diff,
"@@ -13,0 +15 @@ line13\n+line14\n"
"@@ -11,3 +12,4 @@ line10\n line11\n line12\n line13\n+line14\n"
);
assert_eq!(files_by_branch_id[&branch2_id].len(), 1);
@ -879,7 +879,7 @@ fn test_move_hunks_partial_explicitly() -> Result<()> {
);
assert_eq!(
files_by_branch_id[&branch2_id][std::path::Path::new("test.txt")][0].diff,
"@@ -0,0 +1 @@\n+line0\n"
"@@ -1,3 +1,4 @@\n+line0\n line1\n line2\n line3\n"
);
Ok(())
@ -915,11 +915,7 @@ fn test_add_new_hunk_to_the_end() -> Result<()> {
get_status_by_branch(&gb_repository, &project_repository).expect("failed to get status");
assert_eq!(
statuses[0].1[std::path::Path::new("test.txt")][0].diff,
"@@ -14 +13,0 @@ line13\n-line13\n"
);
assert_eq!(
statuses[0].1[std::path::Path::new("test.txt")][1].diff,
"@@ -15,0 +15 @@ line14\n+line15\n"
"@@ -11,5 +11,5 @@ line10\n line11\n line12\n line13\n-line13\n line14\n+line15\n"
);
std::fs::write(
@ -932,15 +928,11 @@ fn test_add_new_hunk_to_the_end() -> Result<()> {
assert_eq!(
statuses[0].1[std::path::Path::new("test.txt")][0].diff,
"@@ -15,0 +16 @@ line14\n+line15\n"
"@@ -11,5 +12,5 @@ line10\n line11\n line12\n line13\n-line13\n line14\n+line15\n"
);
assert_eq!(
statuses[0].1[std::path::Path::new("test.txt")][1].diff,
"@@ -0,0 +1 @@\n+line0\n"
);
assert_eq!(
statuses[0].1[std::path::Path::new("test.txt")][2].diff,
"@@ -14 +14,0 @@ line13\n-line13\n"
"@@ -1,3 +1,4 @@\n+line0\n line1\n line2\n line3\n"
);
Ok(())
@ -1040,7 +1032,7 @@ fn test_merge_vbranch_upstream_clean_rebase() -> Result<()> {
.context("failed to write target branch after push")?;
// create the branch
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches[0];
assert_eq!(branch1.files.len(), 1);
assert_eq!(branch1.commits.len(), 1);
@ -1054,7 +1046,7 @@ fn test_merge_vbranch_upstream_clean_rebase() -> Result<()> {
None,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches[0];
let contents = std::fs::read(std::path::Path::new(&project.path).join(file_path))?;
@ -1163,7 +1155,7 @@ fn test_merge_vbranch_upstream_conflict() -> Result<()> {
.context("failed to write target branch after push")?;
// create the branch
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches[0];
assert_eq!(branch1.files.len(), 1);
@ -1172,7 +1164,7 @@ fn test_merge_vbranch_upstream_conflict() -> Result<()> {
merge_virtual_branch_upstream(&gb_repository, &project_repository, &branch1.id, None, None)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches[0];
let contents = std::fs::read(std::path::Path::new(&project.path).join(file_path))?;
@ -1192,7 +1184,7 @@ fn test_merge_vbranch_upstream_conflict() -> Result<()> {
)?;
// make gb see the conflict resolution
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
assert!(branches[0].conflicted);
// commit the merge resolution
@ -1207,7 +1199,7 @@ fn test_merge_vbranch_upstream_conflict() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches[0];
assert!(!branch1.conflicted);
assert_eq!(branch1.files.len(), 0);
@ -1247,7 +1239,7 @@ fn test_unapply_ownership_partial() -> Result<()> {
)
.expect("failed to create virtual branch");
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].files.len(), 1);
assert_eq!(branches[0].ownership.files.len(), 1);
@ -1261,11 +1253,11 @@ fn test_unapply_ownership_partial() -> Result<()> {
unapply_ownership(
&gb_repository,
&project_repository,
&"test.txt:5-6".parse().unwrap(),
&"test.txt:2-6".parse().unwrap(),
)
.unwrap();
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].files.len(), 0);
assert_eq!(branches[0].ownership.files.len(), 0);
@ -1339,7 +1331,7 @@ fn test_apply_unapply_branch() -> Result<()> {
let contents = std::fs::read(std::path::Path::new(&project.path).join(file_path2))?;
assert_eq!("line5\nline6\n", String::from_utf8(contents)?);
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1);
assert!(branch.active);
@ -1351,7 +1343,7 @@ fn test_apply_unapply_branch() -> Result<()> {
let contents = std::fs::read(std::path::Path::new(&project.path).join(file_path2))?;
assert_eq!("line5\nline6\n", String::from_utf8(contents)?);
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1);
assert!(!branch.active);
@ -1365,7 +1357,7 @@ fn test_apply_unapply_branch() -> Result<()> {
let contents = std::fs::read(std::path::Path::new(&project.path).join(file_path2))?;
assert_eq!("line5\nline6\n", String::from_utf8(contents)?);
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1);
assert!(branch.active);
@ -1621,7 +1613,7 @@ fn test_detect_mergeable_branch() -> Result<()> {
};
branch_writer.write(&mut branch4)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
assert_eq!(branches.len(), 4);
let branch1 = &branches.iter().find(|b| b.id == branch1_id).unwrap();
@ -1803,7 +1795,7 @@ fn test_upstream_integrated_vbranch() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert!(branch1.commits.iter().any(|c| c.is_integrated));
@ -1850,7 +1842,7 @@ fn test_commit_same_hunk_twice() -> Result<()> {
"line1\npatch1\nline2\nline3\nline4\nline5\nmiddle\nmiddle\nmiddle\nmiddle\nline6\nline7\nline8\nline9\nline10\nmiddle\nmiddle\nmiddle\nline11\nline12\n",
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1);
@ -1869,7 +1861,7 @@ fn test_commit_same_hunk_twice() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 0, "no files expected");
@ -1889,7 +1881,7 @@ fn test_commit_same_hunk_twice() -> Result<()> {
"line1\nPATCH1\nline2\nline3\nline4\nline5\nmiddle\nmiddle\nmiddle\nmiddle\nline6\nline7\nline8\nline9\nline10\nmiddle\nmiddle\nmiddle\nline11\nline12\n",
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1, "one file should be changed");
@ -1906,7 +1898,7 @@ fn test_commit_same_hunk_twice() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(
@ -1951,7 +1943,7 @@ fn test_commit_same_file_twice() -> Result<()> {
"line1\npatch1\nline2\nline3\nline4\nline5\nmiddle\nmiddle\nmiddle\nmiddle\nline6\nline7\nline8\nline9\nline10\nmiddle\nmiddle\nmiddle\nline11\nline12\n",
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1);
@ -1970,7 +1962,7 @@ fn test_commit_same_file_twice() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 0, "no files expected");
@ -1990,7 +1982,7 @@ fn test_commit_same_file_twice() -> Result<()> {
"line1\npatch1\nline2\nline3\nline4\nline5\nmiddle\nmiddle\nmiddle\nmiddle\nline6\nline7\nline8\nline9\nline10\nmiddle\nmiddle\nmiddle\npatch2\nline11\nline12\n",
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1, "one file should be changed");
@ -2007,7 +1999,7 @@ fn test_commit_same_file_twice() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(
@ -2052,7 +2044,7 @@ fn test_commit_partial_by_hunk() -> Result<()> {
"line1\npatch1\nline2\nline3\nline4\nline5\nmiddle\nmiddle\nmiddle\nmiddle\nline6\nline7\nline8\nline9\nline10\nmiddle\nmiddle\nmiddle\npatch2\nline11\nline12\n",
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1);
@ -2065,13 +2057,13 @@ fn test_commit_partial_by_hunk() -> Result<()> {
&project_repository,
&branch1_id,
"first commit to test.txt",
Some(&"test.txt:2-3".parse::<Ownership>().unwrap()),
Some(&"test.txt:1-6".parse::<Ownership>().unwrap()),
None,
None,
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 1);
@ -2085,13 +2077,13 @@ fn test_commit_partial_by_hunk() -> Result<()> {
&project_repository,
&branch1_id,
"second commit to test.txt",
Some(&"test.txt:19-20".parse::<Ownership>().unwrap()),
Some(&"test.txt:16-22".parse::<Ownership>().unwrap()),
None,
None,
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch = &branches.iter().find(|b| b.id == branch1_id).unwrap();
assert_eq!(branch.files.len(), 0);
@ -2158,7 +2150,7 @@ fn test_commit_partial_by_file() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches.iter().find(|b| b.id == branch1_id).unwrap();
// branch one test.txt has just the 1st and 3rd hunks applied
@ -2234,7 +2226,7 @@ fn test_commit_add_and_delete_files() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches.iter().find(|b| b.id == branch1_id).unwrap();
// branch one test.txt has just the 1st and 3rd hunks applied
@ -2305,7 +2297,7 @@ fn test_commit_executable_and_symlinks() -> Result<()> {
false,
)?;
let branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
let branch1 = &branches.iter().find(|b| b.id == branch1_id).unwrap();
let commit = &branch1.commits[0].id;
@ -2407,7 +2399,7 @@ fn test_verify_branch_commits_to_integration() -> Result<()> {
integration::verify_branch(&gb_repository, &project_repository).unwrap();
// one virtual branch with two commits was created
let virtual_branches = list_virtual_branches(&gb_repository, &project_repository)?;
let (virtual_branches, _) = list_virtual_branches(&gb_repository, &project_repository)?;
assert_eq!(virtual_branches.len(), 1);
let branch = &virtual_branches.first().unwrap();

View File

@ -4,8 +4,9 @@ use std::{collections::HashMap, path, time, vec};
use std::os::unix::prelude::*;
use anyhow::{bail, Context, Result};
use diffy::{apply_bytes, Patch};
use git2_hooks::{HookResult, PrepareCommitMsgSource};
use bstr::ByteSlice;
use diffy::{apply, Patch};
use git2_hooks::HookResult;
use regex::Regex;
use serde::Serialize;
@ -728,7 +729,7 @@ fn find_base_tree<'a>(
pub fn list_virtual_branches(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
) -> Result<Vec<VirtualBranch>, errors::ListVirtualBranchesError> {
) -> Result<(Vec<VirtualBranch>, bool), errors::ListVirtualBranchesError> {
let mut branches: Vec<VirtualBranch> = Vec::new();
let default_target = gb_repository
@ -889,21 +890,29 @@ pub fn list_virtual_branches(
let branches = branches_with_large_files_abridged(branches);
let mut branches = branches_with_hunk_locks(branches, project_repository)?;
for branch in &mut branches {
branch.files = files_with_hunk_context(
&project_repository.git_repository,
branch.files.clone(),
3,
branch.head,
)
.context("failed to add hunk context")?;
// If there no context lines are used internally, add them here, before returning to the UI
if context_lines(project_repository) == 0 {
for branch in &mut branches {
branch.files = files_with_hunk_context(
&project_repository.git_repository,
branch.files.clone(),
3,
branch.head,
)
.context("failed to add hunk context")?;
}
}
branches.sort_by(|a, b| a.order.cmp(&b.order));
super::integration::update_gitbutler_integration(gb_repository, project_repository)?;
Ok(branches)
let uses_diff_context = project_repository
.project()
.use_diff_context
.unwrap_or(false);
Ok((branches, uses_diff_context))
}
fn branches_with_large_files_abridged(mut branches: Vec<VirtualBranch>) -> Vec<VirtualBranch> {
@ -940,6 +949,7 @@ fn branches_with_hunk_locks(
&project_repository.git_repository,
&parent_tree,
&commit_tree,
context_lines(project_repository),
)?;
for branch in &mut branches {
for file in &mut branch.files {
@ -1091,6 +1101,7 @@ pub fn calculate_non_commited_diffs(
&project_repository.git_repository,
&branch_head,
&branch_tree,
context_lines(project_repository),
)
.context("failed to diff trees")?;
@ -1132,6 +1143,7 @@ fn list_virtual_commit_files(
&project_repository.git_repository,
&parent_tree,
&commit_tree,
context_lines(project_repository),
)?;
let hunks_by_filepath = virtual_hunks_by_filepath(&project_repository.project().path, &diff);
Ok(virtual_hunks_to_virtual_files(
@ -1895,6 +1907,7 @@ fn get_non_applied_status(
&project_repository.git_repository,
&target_tree,
&branch_tree,
context_lines(project_repository),
)?;
Ok((branch, diff))
@ -1913,8 +1926,12 @@ fn get_applied_status(
default_target: &target::Target,
mut virtual_branches: Vec<branch::Branch>,
) -> Result<AppliedStatuses> {
let mut diff = diff::workdir(&project_repository.git_repository, &default_target.sha)
.context("failed to diff workdir")?;
let mut diff = diff::workdir(
&project_repository.git_repository,
&default_target.sha,
context_lines(project_repository),
)
.context("failed to diff workdir")?;
// sort by order, so that the default branch is first (left in the ui)
virtual_branches.sort_by(|a, b| a.order.cmp(&b.order));
@ -2291,39 +2308,37 @@ pub fn write_tree_onto_tree(
.peel_to_blob()
.context("failed to get blob")?;
// get the contents
let mut blob_contents = blob.content().to_vec();
let mut blob_contents = blob.content().to_str()?.to_string();
let mut hunks = hunks.clone();
hunks.sort_by_key(|hunk| hunk.new_start);
let mut all_diffs = String::new();
for hunk in hunks {
let patch = format!("--- original\n+++ modified\n{}", hunk.diff);
let patch_bytes = patch.as_bytes();
let patch = Patch::from_bytes(patch_bytes)?;
blob_contents = apply_bytes(&blob_contents, &patch)
.context(format!("failed to apply {}", &hunk.diff))?;
all_diffs.push_str(&hunk.diff);
}
let patch = Patch::from_str(&all_diffs)?;
blob_contents = apply(&blob_contents, &patch)
.context(format!("failed to apply {}", &all_diffs))?;
// create a blob
let new_blob_oid = git_repository.blob(&blob_contents)?;
let new_blob_oid = git_repository.blob(blob_contents.as_bytes())?;
// upsert into the builder
builder.upsert(rel_path, new_blob_oid, filemode);
}
} else if is_submodule {
let mut blob_contents = vec![];
let mut blob_contents = String::new();
let mut hunks = hunks.clone();
hunks.sort_by_key(|hunk| hunk.new_start);
for hunk in hunks {
let patch = format!("--- original\n+++ modified\n{}", hunk.diff);
let patch_bytes = patch.as_bytes();
let patch = Patch::from_bytes(patch_bytes)?;
blob_contents = apply_bytes(&blob_contents, &patch)
let patch = Patch::from_str(&hunk.diff)?;
blob_contents = apply(&blob_contents, &patch)
.context(format!("failed to apply {}", &hunk.diff))?;
}
// create a blob
let new_blob_oid = git_repository.blob(&blob_contents)?;
let new_blob_oid = git_repository.blob(blob_contents.as_bytes())?;
// upsert into the builder
builder.upsert(rel_path, new_blob_oid, filemode);
} else {
@ -3636,6 +3651,7 @@ pub fn move_commit(
&project_repository.git_repository,
&source_branch_head_parent_tree,
&source_branch_head_tree,
context_lines(project_repository),
)?;
let is_source_locked = source_branch_non_comitted_files
@ -3836,6 +3852,7 @@ pub fn create_virtual_branch_from_branch(
&project_repository.git_repository,
&merge_base_tree,
&head_commit_tree,
context_lines(project_repository),
)
.context("failed to diff trees")?;
@ -3899,6 +3916,19 @@ pub fn create_virtual_branch_from_branch(
}
}
pub fn context_lines(project_repository: &project_repository::Repository) -> u32 {
let use_context = project_repository
.project()
.use_diff_context
.unwrap_or(false);
if use_context {
3_u32
} else {
0_u32
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -79,7 +79,7 @@ impl InnerHandler {
.list_virtual_branches(project_id)
.await
{
Ok(branches) => Ok(vec![events::Event::Emit(
Ok((branches, _)) => Ok(vec![events::Event::Emit(
app_events::Event::virtual_branches(
project_id,
&self.assets_proxy.proxy_virtual_branches(branches).await,

View File

@ -27,7 +27,7 @@
},
"updater": {
"active": true,
"dialog": true,
"dialog": false,
"endpoints": [
"https://app.gitbutler.com/releases/nightly/{{target}}-{{arch}}/{{current_version}}"
],

View File

@ -27,7 +27,7 @@
},
"updater": {
"active": true,
"dialog": true,
"dialog": false,
"endpoints": [
"https://app.gitbutler.com/releases/release/{{target}}-{{arch}}/{{current_version}}"
],

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@ export type Project = {
preferred_key: Key;
ok_with_force_push: boolean;
omit_certificate_check: boolean | undefined;
use_diff_context: boolean | undefined;
};
export class ProjectService {

View File

@ -0,0 +1,122 @@
import { showToast } from '$lib/notifications/toasts';
import {
checkUpdate,
installUpdate,
onUpdaterEvent,
type UpdateResult,
type UpdateStatus
} from '@tauri-apps/api/updater';
import posthog from 'posthog-js';
import {
BehaviorSubject,
switchMap,
Observable,
from,
map,
shareReplay,
interval,
timeout,
catchError,
of,
startWith,
combineLatestWith,
tap
} from 'rxjs';
// TOOD: Investigate why 'DOWNLOADED' is not in the type provided by Tauri.
export type Update =
| { version?: string; status?: UpdateStatus | 'DOWNLOADED'; body?: string }
| undefined;
export class UpdaterService {
private reload$ = new BehaviorSubject<void>(undefined);
private status$ = new BehaviorSubject<UpdateStatus | undefined>(undefined);
/**
* Example output:
* {version: "0.5.303", date: "2024-02-25 3:09:58.0 +00:00:00", body: "", status: "DOWNLOADED"}
*/
update$: Observable<Update>;
// We don't ever call this because the class is meant to be used as a singleton
unlistenFn: any;
constructor() {
onUpdaterEvent((status) => {
const err = status.error;
if (err) showErrorToast(err);
this.status$.next(status.status);
}).then((unlistenFn) => (this.unlistenFn = unlistenFn));
this.update$ = this.reload$.pipe(
// Now and then every hour indefinitely
switchMap(() => interval(60 * 60 * 1000).pipe(startWith(0))),
tap(() => this.status$.next(undefined)),
// Timeout needed to prevent hanging in dev mode
switchMap(() => from(checkUpdate()).pipe(timeout(10000))),
// The property `shouldUpdate` seems useless, only indicates presence of manifest
map((update: UpdateResult | undefined) => {
if (update?.shouldUpdate) return update.manifest;
}),
// 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);
showErrorToast(err);
console.log(err);
}
return of(undefined);
}),
// Status is irrelevant without a proposed update so we merge the streams
combineLatestWith(this.status$),
map(([update, status]) => {
if (update) return { ...update, status };
return undefined;
}),
shareReplay(1)
);
// Use this for testing component manually (until we have actual tests)
// this.update$ = new BehaviorSubject({
// version: '0.5.303',
// date: '2024-02-25 3:09:58.0 +00:00:00',
// body: '- Improves the performance of virtual branch operations (quicker and lower CPU usage)\n- Large numbers of hunks for a file will only be rendered in the UI after confirmation'
// });
}
async install() {
try {
await installUpdate();
posthog.capture('App Update Successful');
} catch (e: any) {
// We expect toast to be shown by error handling in `onUpdaterEvent`
posthog.capture('App Update Failed', e);
}
}
}
function isOffline(err: any): boolean {
return typeof err == 'string' && err.includes('Could not fetch a valid release');
}
function isTimeoutError(err: any): boolean {
return err?.name == 'TimeoutError';
}
function showErrorToast(err: any) {
if (isOffline(err)) return;
showToast({
title: 'App update failed',
message: `
Something went wrong while updating the app.
You can download the latest release from our
[downloads](https://app.gitbutler.com/downloads) page.
\`\`\`
${err}
\`\`\`
`,
style: 'error'
});
posthog.capture('Updater Status Error', err);
}

View File

@ -5,6 +5,7 @@ import { startWith, switchMap } from 'rxjs/operators';
import type { GitHubService } from '$lib/github/service';
import type { PullRequest } from '$lib/github/types';
import type { RemoteBranchService } from '$lib/stores/remoteBranches';
import type { BranchController } from '$lib/vbranches/branchController';
import type { VirtualBranchService } from '$lib/vbranches/branchStoresCache';
import type { Branch, RemoteBranch } from '$lib/vbranches/types';
@ -14,7 +15,8 @@ export class BranchService {
constructor(
private vbranchService: VirtualBranchService,
remoteBranchService: RemoteBranchService,
private githubService: GitHubService
private githubService: GitHubService,
private branchController: BranchController
) {
const vbranchesWithEmpty$ = vbranchService.branches$.pipe(startWith([]));
const branchesWithEmpty$ = remoteBranchService.branches$.pipe(startWith([]));
@ -47,7 +49,7 @@ export class BranchService {
// Push if local commits
if (branch.commits.some((c) => !c.isRemote)) {
newBranch = await this.vbranchService.pushBranch(branch.id, branch.requiresForce);
newBranch = await this.branchController.pushBranch(branch.id, branch.requiresForce);
} else {
newBranch = branch;
}

View File

@ -49,6 +49,8 @@
color var(--transition-fast),
filter var(--transition-fast);
cursor: pointer;
&.pop {
color: var(--clr-theme-scale-pop-10);
background: color-mix(

View File

@ -1,4 +1,5 @@
<script lang="ts" async="true">
import FullscreenLoading from './FullscreenLoading.svelte';
import NewBranchDropZone from './NewBranchDropZone.svelte';
import BranchLane from '$lib/components/BranchLane.svelte';
import Icon from '$lib/components/Icon.svelte';
@ -39,7 +40,7 @@
{#if branchesError}
<div class="p-4" data-tauri-drag-region>Something went wrong...</div>
{:else if !branches}
<div class="loading" data-tauri-drag-region><Icon name="spinner" /></div>
<FullscreenLoading />
{:else}
<div
class="board"
@ -213,13 +214,6 @@
height: 100%;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
/* Empty board */
.empty-board {

View File

@ -69,15 +69,15 @@
}
}
.branch-files__header {
padding-top: var(--space-12);
padding-top: var(--space-14);
padding-bottom: var(--space-12);
padding-left: var(--space-20);
padding-right: var(--space-12);
padding-left: var(--space-14);
padding-right: var(--space-14);
}
.files-padding {
padding-top: 0;
padding-bottom: var(--space-12);
padding-left: var(--space-12);
padding-right: var(--space-12);
padding-left: var(--space-14);
padding-right: var(--space-14);
}
</style>

View File

@ -407,7 +407,7 @@
display: flex;
gap: var(--space-4);
background: var(--clr-theme-container-pale);
padding: var(--space-12);
padding: var(--space-14);
justify-content: space-between;
border-radius: 0 0 var(--radius-m) var(--radius-m);
user-select: none;
@ -452,7 +452,8 @@
.header__remote-branch {
color: var(--clr-theme-scale-ntrl-50);
padding-left: var(--space-4);
padding-left: var(--space-2);
padding-right: var(--space-2);
display: flex;
gap: var(--space-4);
text-overflow: ellipsis;

View File

@ -139,7 +139,6 @@
width: 100%;
height: var(--space-20);
background: var(--target-branch-background);
/* background-color: red; */
}
.header__info {
display: flex;
@ -152,7 +151,7 @@
display: flex;
gap: var(--space-4);
background: var(--clr-theme-container-pale);
padding: var(--space-12);
padding: var(--space-14);
justify-content: space-between;
border-radius: 0 0 var(--radius-m) var(--radius-m);
user-select: none;
@ -171,7 +170,8 @@
.header__remote-branch {
color: var(--clr-theme-scale-ntrl-50);
padding-left: var(--space-4);
padding-left: var(--space-2);
padding-right: var(--space-2);
display: flex;
gap: var(--space-4);
text-overflow: ellipsis;

View File

@ -4,10 +4,12 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import { pxToRem } from '$lib/utils/pxToRem';
import { tooltip } from '$lib/utils/tooltip';
import { onMount } from 'svelte';
import type iconsJson from '$lib/icons/icons.json';
export let size: 'medium' | 'large' = 'medium';
export let icon: keyof typeof iconsJson | undefined = undefined;
export let iconAlign: 'left' | 'right' = 'right';
export let color: ButtonColor = 'primary';
@ -21,6 +23,7 @@
export let grow = false;
export let align: 'flex-start' | 'center' | 'flex-end' | 'stretch' | 'baseline' | 'auto' = 'auto';
export let help = '';
export let width: number | undefined = undefined;
export let element: HTMLAnchorElement | HTMLButtonElement | HTMLElement | null = null;
@ -37,6 +40,8 @@
<button
class={`btn ${className}`}
class:medium={size == 'medium'}
class:large={size == 'large'}
class:error-outline={color == 'error' && kind == 'outlined'}
class:primary-outline={color == 'primary' && kind == 'outlined'}
class:warn-outline={color == 'warn' && kind == 'outlined'}
@ -51,6 +56,7 @@
class:grow
class:not-clickable={notClickable}
style:align-self={align}
style:width={width ? pxToRem(width) : undefined}
use:tooltip={help}
bind:this={element}
disabled={disabled || loading}
@ -84,7 +90,9 @@
min-width: var(--size-btn-m);
background: transparent;
transition: background-color var(--transition-fast);
cursor: pointer;
&:disabled {
cursor: default;
pointer-events: none;
opacity: 0.6;
}
@ -99,6 +107,7 @@
flex-direction: row-reverse;
}
&.not-clickable {
cursor: default;
pointer-events: none;
}
}
@ -177,4 +186,16 @@
border: 1px solid color-mix(in srgb, var(--clr-theme-err-outline), var(--darken-mid));
}
}
/* SIZE MODIFIERS */
.btn.medium {
height: var(--size-btn-m);
padding: var(--space-4) var(--space-6);
}
.btn.large {
height: var(--size-btn-l);
padding: var(--space-6) var(--space-8);
}
</style>

View File

@ -26,6 +26,7 @@
class="checkbox"
class:small
{value}
id={name}
{name}
{disabled}
/>
@ -33,6 +34,7 @@
<style lang="postcss">
.checkbox {
appearance: none;
cursor: pointer;
width: var(--space-16);
height: var(--space-16);
flex-shrink: 0;

View File

@ -3,19 +3,34 @@
export let padding: string = 'var(--space-16)';
export let disabled = false;
export let checked = false;
export let hasTopRadius = true;
export let hasBottomRadius = true;
export let hasBottomLine = true;
const SLOTS = $$props.$$slots;
const dispatch = createEventDispatcher<{
const dispatchClick = createEventDispatcher<{
click: void;
}>();
const dispatchChange = createEventDispatcher<{
change: boolean;
}>();
</script>
<button
class="clickable-card"
class:has-top-radius={hasTopRadius}
class:has-bottom-radius={hasBottomRadius}
class:has-bottom-line={hasBottomLine}
style="padding: {padding}"
on:click={() => {
dispatch('click');
dispatchClick('click');
dispatchChange('change', checked);
checked = !checked;
}}
class:card-disabled={disabled}
{disabled}
@ -43,8 +58,8 @@
.clickable-card {
display: flex;
gap: var(--space-16);
border-radius: var(--radius-l);
border: 1px solid var(--clr-theme-container-outline-light);
border-left: 1px solid var(--clr-theme-container-outline-light);
border-right: 1px solid var(--clr-theme-container-outline-light);
background-color: var(--clr-theme-container-light);
transition:
background-color var(--transition-fast),
@ -84,4 +99,21 @@
display: flex;
flex-shrink: 0;
}
/* MODIFIERS */
.has-top-radius {
border-top: 1px solid var(--clr-theme-container-outline-light);
border-top-left-radius: var(--radius-l);
border-top-right-radius: var(--radius-l);
}
.has-bottom-radius {
border-bottom-left-radius: var(--radius-l);
border-bottom-right-radius: var(--radius-l);
}
.has-bottom-line {
border-bottom: 1px solid var(--clr-theme-container-outline-light);
}
</style>

View File

@ -1,8 +1,10 @@
<script lang="ts">
import { getCloudApiClient, type User } from '$lib/backend/cloud';
import Checkbox from '$lib/components/Checkbox.svelte';
import ClickableCard from '$lib/components/ClickableCard.svelte';
import Link from '$lib/components/Link.svelte';
import Login from '$lib/components/Login.svelte';
import Spacer from '$lib/components/Spacer.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import WelcomeSigninAction from '$lib/components/WelcomeSigninAction.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import { projectAiGenAutoBranchNamingEnabled } from '$lib/config/config';
import * as toasts from '$lib/utils/toasts';
@ -47,87 +49,82 @@
toasts.error('Failed to update project sync status');
}
};
const aiGenToggle = () => {
$aiGenEnabled = !$aiGenEnabled;
$aiGenAutoBranchNamingEnabled = $aiGenEnabled;
};
const aiGenBranchNamesToggle = () => {
$aiGenAutoBranchNamingEnabled = !$aiGenAutoBranchNamingEnabled;
};
</script>
<section class="space-y-2">
{#if user}
<h2 class="text-xl">GitButler Cloud</h2>
{#if user}
<div class="aigen-wrap">
<ClickableCard on:click={aiGenToggle}>
<svelte:fragment slot="title">Enable branch and commit message generation</svelte:fragment>
<svelte:fragment slot="body">
Uses OpenAI's API. If enabled, diffs will sent to OpenAI's servers when pressing the
"Generate message" button.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle checked={$aiGenEnabled} on:change={aiGenToggle} />
</svelte:fragment>
</ClickableCard>
<header>
<span class="text-text-subdued"> Summary generation </span>
</header>
<ClickableCard disabled={!$aiGenEnabled} on:click={aiGenBranchNamesToggle}>
<svelte:fragment slot="title">Automatically generate branch names</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle
disabled={!$aiGenEnabled}
checked={$aiGenAutoBranchNamingEnabled}
on:change={aiGenBranchNamesToggle}
/>
</svelte:fragment>
</ClickableCard>
</div>
<div
class="flex flex-row items-center justify-between rounded-lg border border-light-400 p-2 dark:border-dark-500"
>
<div class="flex flex-col space-x-3">
<div class="flex flex-row items-center gap-x-1">
<Checkbox
name="sync"
disabled={user === undefined}
checked={$aiGenEnabled}
on:change={() => {
$aiGenEnabled = !$aiGenEnabled;
$aiGenAutoBranchNamingEnabled = $aiGenEnabled;
}}
/>
<label class="ml-2" for="sync">Enable branch and commit message generation.</label>
</div>
<div class="pl-4 pr-8 text-sm text-light-700 dark:text-dark-200">
Uses OpenAI's API. If enabled, diffs will sent to OpenAI's servers when pressing the
"Generate message" button.
</div>
<div class="flex flex-col space-x-3">
<div class="flex flex-row items-center gap-x-1">
<Checkbox
name="sync"
disabled={user === undefined || !$aiGenEnabled}
checked={$aiGenAutoBranchNamingEnabled}
on:change={() => {
$aiGenAutoBranchNamingEnabled = !$aiGenAutoBranchNamingEnabled;
}}
/>
<label class="ml-2" for="sync">Automatically generate branch names.</label>
</div>
</div>
<Spacer />
{#if user.role === 'admin'}
<h3 class="text-base-15 text-bold">Full data synchronization</h3>
<ClickableCard on:change={onSyncChange}>
<svelte:fragment slot="body">
Sync my history, repository and branch data for backup, sharing and team features.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle checked={project.api?.sync || false} on:change={onSyncChange} />
</svelte:fragment>
</ClickableCard>
{#if project.api}
<div class="api-link">
<Link
target="_blank"
rel="noreferrer"
href="{PUBLIC_API_BASE_URL}projects/{project.api?.repository_id}"
>Go to GitButler Cloud Project</Link
>
</div>
</div>
{#if user.role === 'admin'}
<header>
<span class="text-text-subdued"> Full data synchronization </span>
</header>
<div
class="flex flex-row items-center justify-between rounded-lg border border-light-400 p-2 dark:border-dark-500"
>
<div class="flex flex-row space-x-3">
<div class="flex flex-row items-center gap-1">
<Checkbox
name="sync"
disabled={user === undefined}
checked={project.api?.sync || false}
on:change={onSyncChange}
/>
<label class="ml-2" for="sync">
Sync my history, repository and branch data for backup, sharing and team features.
</label>
</div>
</div>
</div>
{#if project.api}
<div class="flex flex-row justify-end space-x-2">
<div class="p-1">
<Link
target="_blank"
rel="noreferrer"
href="{PUBLIC_API_BASE_URL}projects/{project.api?.repository_id}"
>Go to GitButler Cloud Project</Link
>
</div>
</div>
{/if}
{/if}
{:else}
<Login {userService} />
<Spacer />
{/if}
</section>
{:else}
<WelcomeSigninAction {userService} />
<Spacer />
{/if}
<style lang="post-css">
.aigen-wrap {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.api-link {
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -154,7 +154,7 @@
.commit {
display: flex;
flex-direction: column;
cursor: default;
border-radius: var(--space-6);
background-color: var(--clr-theme-container-light);
border: 1px solid var(--clr-theme-container-outline-light);
@ -173,10 +173,11 @@
}
.commit__header {
cursor: pointer;
display: flex;
flex-direction: column;
gap: var(--space-10);
padding: var(--space-12);
padding: var(--space-14);
}
.is-commit-open {
@ -263,7 +264,7 @@
display: flex;
justify-content: flex-end;
gap: var(--space-8);
padding: var(--space-12);
padding: var(--space-14);
border-top: 1px solid var(--clr-theme-container-outline-light);
}
</style>

View File

@ -135,7 +135,7 @@
on:focus={useAutoHeight}
on:change={() => currentCommitMessage.set(commitMessage)}
spellcheck={false}
class="text-input commit-box__textarea"
class="text-input text-base-body-13 commit-box__textarea"
rows="1"
disabled={isGeneratingCommigMessage}
placeholder="Your commit message here"
@ -217,7 +217,7 @@
.commit-box {
display: flex;
flex-direction: column;
padding: var(--space-16);
padding: var(--space-14);
background: var(--clr-theme-container-light);
border-top: 1px solid var(--clr-theme-container-outline-light);
transition: background-color var(--transition-medium);

View File

@ -91,7 +91,7 @@
.commit-list__content {
display: flex;
flex-direction: column;
padding: 0 var(--space-16) var(--space-20) var(--space-16);
padding: 0 var(--space-14) var(--space-20) var(--space-14);
gap: var(--space-8);
}
.upstream-message {

View File

@ -35,7 +35,7 @@
.header {
display: flex;
align-items: center;
padding: var(--space-16) var(--space-12) var(--space-16) var(--space-16);
padding: var(--space-16) var(--space-14) var(--space-16) var(--space-14);
justify-content: space-between;
gap: var(--space-8);

View File

@ -1,4 +1,6 @@
<script lang="ts">
import SectionCard from '$lib/components/SectionCard.svelte';
import Spacer from '$lib/components/Spacer.svelte';
import TextArea from '$lib/components/TextArea.svelte';
import TextBox from '$lib/components/TextBox.svelte';
import { createEventDispatcher } from 'svelte';
@ -14,36 +16,48 @@
}>();
</script>
<form class="flex flex-col gap-3">
<fieldset class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<label for="path">Path</label>
<TextBox readonly id="path" value={project?.path} />
</div>
<div class="flex flex-col gap-1">
<label for="name">Project Name</label>
<TextBox
id="name"
placeholder="Project name can't be empty"
bind:value={title}
required
on:change={(e) => {
project.title = e.detail;
dispatch('updated', project);
}}
/>
</div>
<div class="flex flex-col gap-1">
<label for="description">Project Description</label>
<TextArea
id="description"
rows={3}
bind:value={description}
on:change={(e) => {
project.description = e.detail;
dispatch('updated', project);
}}
/>
</div>
</fieldset>
</form>
<SectionCard>
<form>
<fieldset class="fields-wrapper">
<TextBox label="Path" readonly id="path" value={project?.path} />
<section class="description-wrapper">
<TextBox
label="Project Name"
id="name"
placeholder="Project name can't be empty"
bind:value={title}
required
on:change={(e) => {
project.title = e.detail;
dispatch('updated', project);
}}
/>
<TextArea
id="description"
rows={3}
placeholder="Project description"
bind:value={description}
on:change={(e) => {
project.description = e.detail;
dispatch('updated', project);
}}
/>
</section>
</fieldset>
</form>
</SectionCard>
<Spacer />
<style>
.fields-wrapper {
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.description-wrapper {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
</style>

View File

@ -1,4 +1,5 @@
<script lang="ts">
import Button from './Button.svelte';
import HunkViewer from './HunkViewer.svelte';
import Icon from './Icon.svelte';
import { computeAddedRemovedByHunk } from '$lib/utils/metrics';
@ -30,6 +31,8 @@
$: maxLineNumber = sections[sections.length - 1]?.maxLineNumber;
$: minWidth = getGutterMinWidth(maxLineNumber);
let alwaysShow = false;
</script>
<div class="hunks">
@ -37,6 +40,13 @@
Binary content not shown
{:else if isLarge}
Diff too large to be shown
{:else if sections.length > 50 && !alwaysShow}
<div class="flex flex-col p-1">
Change hidden as large numbers of diffs may slow down the UI
<Button kind="outlined" color="neutral" on:click={() => (alwaysShow = true)}
>show anyways</Button
>
</div>
{:else}
{#each sections as section}
{@const { added, removed } = computeAddedRemovedByHunk(section)}

View File

@ -50,49 +50,46 @@
});
</script>
<div
on:click
on:keydown
on:dragstart={() => {
// Reset selection if the file being dragged is not in the selected list
if ($selectedFiles.length > 0 && !$selectedFiles.find((f) => f.id == file.id)) {
$selectedFiles = [];
}
}}
use:draggable={{
...draggableFile(branchId, file, selectedFiles),
disabled: readonly || isUnapplied,
selector: '.selected-draggable'
}}
role="button"
tabindex="0"
on:contextmenu={(e) =>
popupMenu.openByMouse(e, {
files: $selectedFiles.includes(file) ? $selectedFiles : [file]
})}
>
<div class="list-item-wrapper">
{#if showCheckbox}
<Checkbox
small
{checked}
{indeterminate}
on:change={(e) => {
selectedOwnership.update((ownership) => {
if (e.detail) file.hunks.forEach((h) => ownership.addHunk(file.id, h.id));
if (!e.detail) file.hunks.forEach((h) => ownership.removeHunk(file.id, h.id));
return ownership;
});
}}
/>
{/if}
<div
class="file-list-item"
id={`file-${file.id}`}
class:selected-draggable={selected}
role="listitem"
on:contextmenu|preventDefault
on:click
on:keydown
on:dragstart={() => {
// Reset selection if the file being dragged is not in the selected list
if ($selectedFiles.length > 0 && !$selectedFiles.find((f) => f.id == file.id)) {
$selectedFiles = [];
}
}}
use:draggable={{
...draggableFile(branchId, file, selectedFiles),
disabled: readonly || isUnapplied,
selector: '.selected-draggable'
}}
role="button"
tabindex="0"
on:contextmenu={(e) =>
popupMenu.openByMouse(e, {
files: $selectedFiles.includes(file) ? $selectedFiles : [file]
})}
>
<div class="info-wrap">
{#if showCheckbox}
<Checkbox
small
{checked}
{indeterminate}
on:change={(e) => {
selectedOwnership.update((ownership) => {
if (e.detail) file.hunks.forEach((h) => ownership.addHunk(file.id, h.id));
if (!e.detail) file.hunks.forEach((h) => ownership.removeHunk(file.id, h.id));
return ownership;
});
}}
/>
{/if}
<div class="info">
<img src={getVSIFileIcon(file.path)} alt="js" style="width: var(--space-12)" />
<span class="text-base-12 name">
@ -108,7 +105,14 @@
</div>
<style lang="postcss">
.list-item-wrapper {
display: flex;
align-items: center;
gap: var(--space-8);
}
.file-list-item {
flex: 1;
display: flex;
align-items: center;
height: var(--space-28);

View File

@ -0,0 +1,15 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
</script>
<div class="loading" data-tauri-drag-region><Icon name="spinner" /></div>
<style>
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
</style>

View File

@ -1,4 +1,5 @@
<script lang="ts" context="module">
import { pxToRem } from '$lib/utils/pxToRem';
export type IconColor = 'success' | 'error' | 'pop' | 'warn' | 'neutral' | undefined;
</script>
@ -10,8 +11,6 @@
export let opacity: number | undefined = 1;
export let spinnerRadius: number | undefined = 5;
export let size = 16;
const pxToRem = (px: number) => `${px / 16}rem`;
</script>
<svg

View File

@ -32,9 +32,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--clr-theme-scale-ntrl-40);
border-radius: var(--radius-m);
color: var(--clr-theme-scale-ntrl-50);
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast);
@ -45,6 +45,7 @@
}
.selected {
background-color: color-mix(in srgb, transparent, var(--darken-tint-light));
cursor: default;
}
.large {
height: var(--size-btn-l);

View File

@ -125,4 +125,8 @@
:global(.info-message__text p:not(:last-child)) {
margin-bottom: var(--space-10);
}
:global(.info-message__text ul) {
list-style-type: circle;
padding: 0 0 0 var(--space-16);
}
</style>

View File

@ -1,8 +1,12 @@
<script lang="ts">
import ClickableCard from './ClickableCard.svelte';
import RadioButton from './RadioButton.svelte';
import SectionCard from './SectionCard.svelte';
import Spacer from './Spacer.svelte';
import TextBox from './TextBox.svelte';
import { invoke } from '$lib/backend/ipc';
import Button from '$lib/components/Button.svelte';
import Link from '$lib/components/Link.svelte';
import TextBox from '$lib/components/TextBox.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import { debounce } from '$lib/utils/debounce';
import { createEventDispatcher } from 'svelte';
@ -81,121 +85,199 @@
preferred_key: 'generated'
});
}
let showPassphrase = false;
</script>
<div class="flex flex-col gap-1">
<p>Git Authentication</p>
<div class="pr-8 text-sm text-light-700 dark:text-dark-200">
<div>
Configure the authentication flow for GitButler when authenticating with your Git remote
provider.
</div>
</div>
<section class="git-auth-wrap">
<h3 class="text-base-15 text-bold">Git Authentication</h3>
<p class="text-base-body-12">
Configure the authentication flow for GitButler when authenticating with your Git remote
provider.
</p>
<div class="grid grid-cols-2 gap-2" style="grid-template-columns: max-content 1fr;">
<input type="radio" bind:group={selectedOption} value="default" on:input={setDefaultKey} />
<div class="flex flex-col space-y-2">
<div>Auto detect</div>
{#if selectedOption === 'default'}
<div class="pr-8 text-sm text-light-700 dark:text-dark-200">
<div>GitButler will attempt all available authentication flows automatically.</div>
</div>
{/if}
</div>
<form>
<fieldset class="git-radio">
<ClickableCard
hasBottomRadius={false}
on:click={() => {
if (selectedOption == 'default') return;
<input type="radio" bind:group={selectedOption} value="local" />
<div class="flex flex-col space-y-2">
<div>Use existing SSH key</div>
selectedOption = 'default';
setDefaultKey();
}}
>
<svelte:fragment slot="title">Auto detect</svelte:fragment>
<svelte:fragment slot="actions">
<RadioButton bind:group={selectedOption} value="default" on:input={setDefaultKey} />
</svelte:fragment>
<svelte:fragment slot="body">
GitButler will attempt all available authentication flows automatically.
</svelte:fragment>
</ClickableCard>
<ClickableCard
hasTopRadius={false}
hasBottomRadius={false}
hasBottomLine={selectedOption !== 'local'}
on:click={() => {
selectedOption = 'local';
}}
>
<svelte:fragment slot="title">Use existing SSH key</svelte:fragment>
<svelte:fragment slot="actions">
<RadioButton bind:group={selectedOption} value="local" />
</svelte:fragment>
<svelte:fragment slot="body">
Add the path to an existing SSH key that GitButler can use.
</svelte:fragment>
</ClickableCard>
{#if selectedOption === 'local'}
<div class="pr-8 text-sm text-light-700 dark:text-dark-200">
Add the path to an existing SSH key that GitButler can use.
</div>
<SectionCard hasTopRadius={false} hasBottomRadius={false}>
<div class="inputs-group">
<TextBox
label="Path to private key"
placeholder="for example: ~/.ssh/id_rsa"
bind:value={privateKeyPath}
on:input={debounce(setLocalKey, 600)}
/>
<div
class="grid grid-cols-2 items-center gap-2"
style="grid-template-columns: max-content 1fr;"
>
<label for="path">Path to private key</label>
<TextBox
placeholder="for example: ~/.ssh/id_rsa"
bind:value={privateKeyPath}
on:input={debounce(setLocalKey, 600)}
/>
<label for="passphrase">Passphrase (optional)</label>
<TextBox
type="password"
bind:value={privateKeyPassphrase}
on:input={debounce(setLocalKey, 600)}
/>
</div>
<div class="input-with-button">
<TextBox
label="Passphrase (optional)"
type={showPassphrase ? 'text' : 'password'}
bind:value={privateKeyPassphrase}
on:input={debounce(setLocalKey, 600)}
wide
/>
<Button
size="large"
color="neutral"
kind="outlined"
icon={showPassphrase ? 'eye-shown' : 'eye-hidden'}
on:click={() => (showPassphrase = !showPassphrase)}
width={150}
>
{showPassphrase ? 'Hide passphrase' : 'Show passphrase'}
</Button>
</div>
</div>
</SectionCard>
{/if}
</div>
<input type="radio" bind:group={selectedOption} value="generated" on:input={setGeneratedKey} />
<div class="flex flex-col space-y-2">
<div class="pr-8">
<div>Use locally generated SSH key</div>
</div>
<ClickableCard
hasTopRadius={false}
hasBottomRadius={false}
hasBottomLine={selectedOption !== 'generated'}
on:click={() => {
if (selectedOption == 'generated') return;
selectedOption = 'generated';
setGeneratedKey();
}}
>
<svelte:fragment slot="title">Use locally generated SSH key</svelte:fragment>
<svelte:fragment slot="actions">
<RadioButton bind:group={selectedOption} value="generated" />
</svelte:fragment>
<svelte:fragment slot="body">
GitButler will use a locally generated SSH key. For this to work you need to add the
following public key to your Git remote provider:
</svelte:fragment>
</ClickableCard>
{#if selectedOption === 'generated'}
<div class="pr-8 text-sm text-light-700 dark:text-dark-200">
GitButler will use a locally generated SSH key. For this to work you <b>need</b>
to add the following public key to your Git remote provider:
</div>
<div class="flex-auto overflow-y-scroll">
<input
bind:value={sshKey}
disabled={selectedOption !== 'generated'}
class="whitespece-pre input w-full select-all rounded border p-2 font-mono"
/>
</div>
<div class="flex flex-row justify-end space-x-2">
<div>
<SectionCard hasTopRadius={false} hasBottomRadius={false}>
<TextBox readonly selectall bind:value={sshKey} />
<div class="row-buttons">
<Button
kind="filled"
color="primary"
icon="copy"
on:click={() => copyToClipboard(sshKey)}
disabled={selectedOption !== 'generated'}
>
Copy to Clipboard
</Button>
</div>
<div class="p-1">
<Link
target="_blank"
rel="noreferrer"
href="https://github.com/settings/ssh/new"
disabled={selectedOption !== 'generated'}
<Button
kind="outlined"
color="neutral"
icon="open-link"
on:click={() => {
open('https://github.com/settings/ssh/new');
}}
>
Add key to GitHub
</Link>
</Button>
</div>
</div>
</SectionCard>
{/if}
</div>
<input
type="radio"
bind:group={selectedOption}
value="gitCredentialsHelper"
on:input={setGitCredentialsHelperKey}
/>
<div class="flex flex-col space-y-2">
<div class="pr-8">
<div>
Use a
<Link target="_blank" rel="noreferrer" href="https://git-scm.com/doc/credential-helpers"
>Git credentials helper</Link
>
</div>
</div>
{#if selectedOption === 'gitCredentialsHelper'}
<div class="pr-8 text-sm text-light-700 dark:text-dark-200">
<ClickableCard
hasTopRadius={false}
on:click={() => {
if (selectedOption == 'gitCredentialsHelper') return;
selectedOption = 'gitCredentialsHelper';
setGitCredentialsHelperKey();
}}
>
<svelte:fragment slot="title">Use a Git credentials helper</svelte:fragment>
<svelte:fragment slot="body">
GitButler will use the system's git credentials helper.
</div>
{/if}
</div>
</div>
</div>
<Link target="_blank" rel="noreferrer" href="https://git-scm.com/doc/credential-helpers">
Learn more
</Link>
</svelte:fragment>
<svelte:fragment slot="actions">
<RadioButton bind:group={selectedOption} value="gitCredentialsHelper" />
</svelte:fragment>
</ClickableCard>
</fieldset>
</form>
</section>
<Spacer />
<style>
.git-auth-wrap {
display: flex;
flex-direction: column;
gap: var(--space-16);
& p {
color: var(--clr-theme-scale-ntrl-30);
}
}
.inputs-group {
display: flex;
flex-direction: column;
gap: var(--space-16);
}
.input-with-button {
display: flex;
gap: var(--space-8);
align-items: flex-end;
}
.row-buttons {
display: flex;
justify-content: flex-end;
gap: var(--space-8);
}
.git-radio {
display: flex;
flex-direction: column;
}
</style>

View File

@ -1,5 +1,7 @@
<script lang="ts">
import Checkbox from '$lib/components/Checkbox.svelte';
import Spacer from './Spacer.svelte';
import ClickableCard from '$lib/components/ClickableCard.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import { projectRunCommitHooks } from '$lib/config/config';
import { createEventDispatcher } from 'svelte';
import type { Project } from '$lib/backend/projects';
@ -16,57 +18,70 @@
omit_certificate_check?: boolean;
};
}>();
const onAllowForcePushingChange = () => {
dispatch('updated', { ok_with_force_push: allowForcePushing });
};
const onOmitCertificateCheckChange = () => {
dispatch('updated', { omit_certificate_check: omitCertificateCheck });
};
const onRunCommitHooksChange = () => {
$runCommitHooks = !$runCommitHooks;
};
</script>
<div class="flex flex-1 flex-col gap-1 rounded-lg border border-light-400 p-2 dark:border-dark-500">
<form class="flex items-center gap-1">
<Checkbox
name="allow-force-pushing"
checked={allowForcePushing}
on:change={() => {
allowForcePushing = !allowForcePushing;
dispatch('updated', { ok_with_force_push: allowForcePushing });
}}
/>
<label class="ml-2" for="allow-force-pushing">
<div>Allow force pushing</div>
</label>
</form>
<p class="ml-7 text-light-700 dark:text-dark-200">
Force pushing allows GitButler to override branches even if they were pushed to remote. We will
never force push to the trunk.
</p>
<section class="wrapper">
<ClickableCard
on:click={() => {
allowForcePushing = !allowForcePushing;
onAllowForcePushingChange();
}}
>
<svelte:fragment slot="title">Allow force pushing</svelte:fragment>
<svelte:fragment slot="body">
Force pushing allows GitButler to override branches even if they were pushed to remote. We
will never force push to the trunk.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle bind:checked={allowForcePushing} on:change={onAllowForcePushingChange} />
</svelte:fragment>
</ClickableCard>
<form class="flex items-center gap-1">
<Checkbox
name="omit-certificate-check"
checked={omitCertificateCheck}
on:change={() => {
omitCertificateCheck = !omitCertificateCheck;
dispatch('updated', { omit_certificate_check: omitCertificateCheck });
}}
/>
<label class="ml-2" for="allow-force-pushing">
<div>Ignore host certificate checks</div>
</label>
</form>
<p class="ml-7 text-light-700 dark:text-dark-200">
Enabling this will ignore host certificate checks when authenticating with ssh.
</p>
<ClickableCard
on:click={() => {
omitCertificateCheck = !omitCertificateCheck;
onOmitCertificateCheckChange();
}}
>
<svelte:fragment slot="title">Ignore host certificate checks</svelte:fragment>
<svelte:fragment slot="body">
Enabling this will ignore host certificate checks when authenticating with ssh.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle bind:checked={omitCertificateCheck} on:change={onOmitCertificateCheckChange} />
</svelte:fragment>
</ClickableCard>
<form class="flex items-center gap-1">
<Checkbox
name="run-commit-hooks"
checked={$runCommitHooks}
on:change={() => {
$runCommitHooks = !$runCommitHooks;
}}
/>
<label class="ml-2" for="allow-force-pushing">
<div>Run commit hooks</div>
</label>
</form>
<p class="ml-7 text-light-700 dark:text-dark-200">
Enabling this will run any git pre and post commit hooks you have configured in your repository.
</p>
</div>
<ClickableCard on:click={onRunCommitHooksChange}>
<svelte:fragment slot="title">Run commit hooks</svelte:fragment>
<svelte:fragment slot="body">
Enabling this will run any git pre and post commit hooks you have configured in your
repository.
</svelte:fragment>
<svelte:fragment slot="actions">
<Toggle bind:checked={$runCommitHooks} on:change={onRunCommitHooksChange} />
</svelte:fragment>
</ClickableCard>
</section>
<Spacer />
<style lang="post-css">
.wrapper {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
</style>

View File

@ -29,10 +29,14 @@
(b) => b.name == 'origin/master' || b.name == 'origin/main'
);
function onSetTargetClick() {
async function onSetTargetClick() {
if (!selectedBranch) return;
loading = true;
branchController.setTarget(selectedBranch.name).finally(() => (loading = false));
try {
await branchController.setTarget(selectedBranch.name);
} finally {
loading = false;
}
}
</script>

View File

@ -22,6 +22,7 @@
<style lang="postcss">
.radio {
appearance: none;
cursor: pointer;
width: var(--space-16);
height: var(--space-16);
border-radius: var(--space-16);

View File

@ -25,10 +25,10 @@
modal.show();
}}
>
Remove project
Remove project
</Button>
<Modal bind:this={modal} title="Remove from GitButler">
<Modal bind:this={modal}>
<div class="remove-project-description">
<p class="text-base-body-14">
Are you sure you want to remove

View File

@ -1,9 +1,19 @@
<script lang="ts">
export let orientation: 'row' | 'column' = 'column';
export let hasTopRadius = true;
export let hasBottomRadius = true;
export let hasBottomLine = true;
const SLOTS = $$props.$$slots;
</script>
<section class="section-card" style:flex-direction={orientation}>
<section
class="section-card"
style:flex-direction={orientation}
class:has-top-radius={hasTopRadius}
class:has-bottom-radius={hasBottomRadius}
class:has-bottom-line={hasBottomLine}
>
{#if SLOTS.iconSide}
<div class="section-card__icon-side">
<slot name="iconSide" />
@ -32,8 +42,8 @@
display: flex;
gap: var(--space-16);
padding: var(--space-16);
border-radius: var(--radius-m);
border: 1px solid var(--clr-theme-container-outline-light);
border-left: 1px solid var(--clr-theme-container-outline-light);
border-right: 1px solid var(--clr-theme-container-outline-light);
background-color: var(--clr-theme-container-light);
transition:
background-color var(--transition-fast),
@ -55,4 +65,21 @@
.section-card__text {
color: var(--clr-theme-scale-ntrl-30);
}
/* MODIFIERS */
.has-top-radius {
border-top: 1px solid var(--clr-theme-container-outline-light);
border-top-left-radius: var(--radius-m);
border-top-right-radius: var(--radius-m);
}
.has-bottom-radius {
border-bottom-left-radius: var(--radius-m);
border-bottom-right-radius: var(--radius-m);
}
.has-bottom-line {
border-bottom: 1px solid var(--clr-theme-container-outline-light);
}
</style>

View File

@ -84,6 +84,8 @@
transition: background var(--transition-fast);
cursor: pointer;
&:hover {
background-color: color-mix(
in srgb,
@ -98,11 +100,15 @@
border-right-width: 1px;
border-left-width: 1px;
cursor: default;
& > .label {
color: var(--clr-theme-scale-ntrl-0);
cursor: default;
}
& > .icon {
color: var(--clr-theme-scale-ntrl-30);
cursor: default;
}
&.left {
border-right-width: 1px;
@ -128,9 +134,11 @@
justify-content: center;
align-items: center;
color: var(--clr-theme-scale-ntrl-50);
cursor: pointer;
}
.label {
color: var(--clr-theme-scale-ntrl-40);
cursor: pointer;
}
</style>

View File

@ -16,6 +16,7 @@
.divider {
height: 1px;
width: 100%;
border-bottom: 1px solid var(--clr-theme-container-outline-light);
border-bottom: 1px solid var(--clr-theme-scale-ntrl-0);
opacity: 0.15;
}
</style>

View File

@ -17,6 +17,7 @@
<button
class="sync-btn"
class:sync-btn-busy={$baseServiceBusy$}
on:click={async (e) => {
e.preventDefault();
e.stopPropagation();
@ -53,6 +54,12 @@
background: var(--clr-theme-container-light);
border: 1px solid var(--clr-theme-container-outline-light);
border-radius: var(--radius-m);
cursor: pointer;
&.sync-btn-busy {
cursor: default;
}
transition:
background var(--transition-fast),
border var(--transition-fast);

View File

@ -79,7 +79,7 @@
padding: 0 var(--space-2);
}
.clickable {
cursor: default;
cursor: pointer;
}
/* colors */

View File

@ -10,31 +10,49 @@
export let autocomplete: string | undefined = undefined;
export let autocorrect: string | undefined = undefined;
export let spellcheck = false;
export let label: string | undefined = undefined;
const dispatch = createEventDispatcher<{ input: string; change: string }>();
</script>
<textarea
class="text-input textarea"
bind:value
{disabled}
{id}
name={id}
{placeholder}
{required}
{rows}
{autocomplete}
{autocorrect}
{spellcheck}
on:input={(e) => dispatch('input', e.currentTarget.value)}
on:change={(e) => dispatch('change', e.currentTarget.value)}
/>
<div class="textarea-wrapper">
{#if label}
<label class="textbox__label text-base-13 text-semibold" for={id}>
{label}
</label>
{/if}
<textarea
class="text-input text-base-body-13 textarea"
bind:value
{disabled}
{id}
name={id}
{placeholder}
{required}
{rows}
{autocomplete}
{autocorrect}
{spellcheck}
on:input={(e) => dispatch('input', e.currentTarget.value)}
on:change={(e) => dispatch('change', e.currentTarget.value)}
/>
</div>
<style lang="postcss">
.textarea-wrapper {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.textarea {
width: 100%;
resize: none;
padding-top: var(--space-12);
padding-bottom: var(--space-12);
}
.textbox__label {
color: var(--clr-theme-scale-ntrl-50);
}
</style>

View File

@ -3,27 +3,29 @@
import { createEventDispatcher } from 'svelte';
import type iconsJson from '$lib/icons/icons.json';
export let element: HTMLElement | undefined = undefined;
export let id: string | undefined = undefined; // Required to make label clickable
export let icon: keyof typeof iconsJson | undefined = undefined;
export let value: string | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let label: string | undefined = undefined;
export let reversedDirection: boolean = false;
export let wide: boolean = false;
export let disabled = false;
export let readonly = false;
export let required = false;
export let noselect = false;
export let selectall = false;
export let element: HTMLElement | undefined = undefined;
export let spellcheck = false;
export let type: 'text' | 'password' | 'select' = 'text';
const dispatch = createEventDispatcher<{ input: string; change: string }>();
</script>
<div class="textbox" bind:this={element}>
<div class="textbox" bind:this={element} class:wide>
{#if label}
<label class="textbox__label font-base-13 text-semibold" for={id}>
<label class="textbox__label text-base-13 text-semibold" for={id}>
{label}
</label>
{/if}
@ -38,6 +40,7 @@
<Icon name={icon} />
</div>
{/if}
<input
{id}
{readonly}
@ -47,7 +50,7 @@
{disabled}
{...{ type }}
class="text-input textbox__input text-base-13"
class:textbox__readonly={type !== 'select' && readonly}
class:textbox__readonly={type != 'select' && readonly}
class:select-none={noselect}
class:select-all={selectall}
bind:value
@ -71,6 +74,8 @@
}
.textbox__input {
z-index: 1;
position: relative;
flex-grow: 1;
height: var(--size-btn-l);
width: 100%;
@ -82,11 +87,16 @@
}
}
.textbox__input[type='select'] {
cursor: pointer;
}
.textbox__label {
color: var(--clr-theme-scale-ntrl-50);
}
.textbox__icon {
z-index: 2;
pointer-events: none;
position: absolute;
top: 50%;
@ -118,4 +128,9 @@
background-color: var(--clr-theme-container-pale);
border-color: var(--clr-theme-container-outline-light);
}
.wide {
width: 100%;
flex: 1;
}
</style>

View File

@ -58,6 +58,7 @@
}
.theme-card {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
@ -78,14 +79,14 @@
position: relative;
width: 100%;
height: auto;
border-radius: var(--radius-l);
border-radius: var(--radius-m);
border: 1px solid var(--clr-theme-container-outline-light);
overflow: hidden;
& img {
width: 100%;
height: 100%;
object-fit: cover;
height: auto;
border-radius: var(--radius-m);
}
}
@ -114,7 +115,10 @@
.theme-card.selected .theme-card__preview {
border-color: var(--clr-core-pop-50);
border-width: 2px;
}
.theme-card.selected .theme-card__label {
background-color: color-mix(in srgb, var(--clr-theme-scale-pop-50), transparent 80%);
}
.theme-card.selected .theme-card__icon {

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { tooltip } from '$lib/utils/tooltip';
import { createEventDispatcher } from 'svelte';
export let name = '';
@ -8,12 +9,18 @@
export let checked = false;
export let value = '';
export let help = '';
let input: HTMLInputElement;
const dispatch = createEventDispatcher<{ change: boolean }>();
</script>
<input
bind:this={input}
bind:checked
on:click|stopPropagation
on:change
use:tooltip={help}
on:change={() => {
dispatch('change', checked);
}}
type="checkbox"
class="toggle"
class:small
@ -21,12 +28,13 @@
{name}
id={name}
{disabled}
bind:checked
use:tooltip={help}
/>
<style lang="postcss">
.toggle {
appearance: none;
cursor: pointer;
width: calc(var(--space-24) + var(--space-2));
height: var(--space-16);
border-radius: var(--space-16);

View File

@ -0,0 +1,311 @@
<script lang="ts">
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 { 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))
);
let dismissed = false;
</script>
{#if $update$?.version && $update$.status != 'UPTODATE' && !dismissed}
<div class="update-banner" class:busy={$update$?.status == 'PENDING'}>
<div class="floating-button">
<IconButton icon="cross-small" on:click={() => (dismissed = true)} />
</div>
<div class="img">
<div class="circle-img">
{#if $update$?.status != 'DONE'}
<svg
class="arrow-img"
width="12"
height="34"
viewBox="0 0 12 34"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 21V32.5M6 32.5L1 27.5M6 32.5L11 27.5"
stroke="var(--clr-theme-scale-ntrl-100)"
stroke-width="1.5"
/>
<path
d="M6 0V11.5M6 11.5L1 6.5M6 11.5L11 6.5"
stroke="var(--clr-theme-scale-ntrl-100)"
stroke-width="1.5"
/>
</svg>
{:else}
<svg
class="tick-img"
width="14"
height="11"
viewBox="0 0 14 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 4.07692L5.57143 9L13 1"
stroke="var(--clr-theme-scale-ntrl-100)"
stroke-width="1.5"
/>
</svg>
{/if}
</div>
<svg
width="60"
height="36"
viewBox="0 0 60 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M31.5605 35.5069C31.4488 35.5097 31.3368 35.5112 31.2245 35.5112H12.8571C5.75633 35.5112 0 29.7548 0 22.654C0 15.5532 5.75634 9.79688 12.8571 9.79688H16.123C18.7012 4.02354 24.493 0 31.2245 0C39.7331 0 46.7402 6.42839 47.6541 14.6934H49.5918C55.3401 14.6934 60 19.3533 60 25.1015C60 30.8498 55.3401 35.5097 49.5918 35.5097H32.4489C32.2692 35.5097 32.0906 35.5051 31.913 35.4961C31.7958 35.5009 31.6783 35.5045 31.5605 35.5069Z"
fill="var(--clr-theme-scale-pop-70)"
/>
<g opacity="0.4">
<path
d="M39 35.5102V29.2505H29.25V9.75049H39V19.5005H48.75V29.2505H58.5V30.4877C56.676 33.4983 53.3688 35.5102 49.5918 35.5102H39Z"
fill="var(--clr-theme-scale-pop-50)"
/>
<path
d="M46.3049 9.75049H39V1.93967C42.2175 3.65783 44.8002 6.4091 46.3049 9.75049Z"
fill="var(--clr-theme-scale-pop-50)"
/>
<path
d="M9.75 35.1337C10.745 35.3806 11.7858 35.5117 12.8571 35.5117H29.25V29.2505H9.75V19.5005H19.5V9.75049H29.25V0.117188C25.4568 0.568673 22.0577 2.30464 19.5 4.87786V9.75049H16.144C16.137 9.7661 16.13 9.78173 16.123 9.79737H12.8571C11.7858 9.79737 10.745 9.92841 9.75 10.1753V19.5005H0.389701C0.135193 20.5097 0 21.5663 0 22.6545C0 25.0658 0.663785 27.322 1.81859 29.2505H9.75V35.1337Z"
fill="var(--clr-theme-scale-pop-50)"
/>
</g>
</svg>
</div>
<h4 class="text-base-13 label">
{#if !$update$.status}
New version available
{:else if $update$.status == 'PENDING'}
Downloading update...
{:else if $update$.status == 'DONE'}
Installing update...
{:else if $update$.status == 'ERROR'}
Error occurred...
{/if}
</h4>
<div class="buttons">
<Button
wide
kind="outlined"
on:click={() => {
const notes = $update$?.body?.trim() || 'no release notes available';
showToast({
id: 'release-notes',
title: `Release notes for ${$update$?.version}`,
message: `
${notes}
`
});
}}
>
Release notes
</Button>
<div class="status-section">
<div class="sliding-gradient" />
{#if !$update$.status}
<div class="cta-btn" transition:fade={{ duration: 100 }}>
<Button wide on:click={() => 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>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style lang="postcss">
.update-banner {
cursor: default;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-16);
width: 100%;
max-width: 220px;
position: fixed;
bottom: var(--space-12);
left: var(--space-12);
padding: var(--space-24);
background-color: var(--clr-theme-container-light);
border: 1px solid var(--clr-theme-container-outline-light);
border-radius: var(--radius-m);
}
.label {
color: var(--clr-theme-scale-ntrl-0);
}
.buttons {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
/* STATUS SECTION */
.status-section {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
height: var(--size-btn-m);
width: 100%;
background-color: var(--clr-theme-pop-element);
border-radius: var(--radius-m);
transition:
transform 0.15s ease-in-out,
height 0.15s ease-in-out;
}
.sliding-gradient {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 100%;
mix-blend-mode: overlay;
background: linear-gradient(
80deg,
rgba(255, 255, 255, 0) 9%,
rgba(255, 255, 255, 0.5) 31%,
rgba(255, 255, 255, 0) 75%
);
animation: slide 3s ease-in-out infinite;
transition: width 0.2s ease-in-out;
}
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.cta-btn {
display: flex;
width: 100%;
position: relative;
}
.busy {
& .status-section {
height: var(--space-4);
}
& .sliding-gradient {
width: 100%;
background: linear-gradient(
80deg,
rgba(255, 255, 255, 0) 9%,
rgba(255, 255, 255, 0.9) 31%,
rgba(255, 255, 255, 0) 75%
);
animation: slide 1.6s ease-in infinite;
}
& .arrow-img {
transform: rotate(180deg);
animation: moving-arrow 1s ease-in-out infinite;
}
}
/* IMAGE */
.img {
position: relative;
margin-bottom: 4px;
}
.circle-img {
position: absolute;
overflow: hidden;
bottom: -8px;
left: 17px;
width: 26px;
height: 26px;
border-radius: 50%;
background-color: var(--clr-theme-scale-pop-40);
transition: transform 0.2s ease-in-out;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
box-shadow: inset 0 0 4px 4px var(--clr-theme-scale-pop-40);
border-radius: 50%;
}
}
.arrow-img {
position: absolute;
top: -14px;
left: 7px;
/* transform: translateY(20px); */
}
.tick-img {
position: absolute;
top: 8px;
left: 6px;
}
.floating-button {
position: absolute;
right: var(--space-10);
top: var(--space-10);
}
@keyframes moving-arrow {
0% {
transform: translateY(0);
}
100% {
transform: translateY(21px);
}
}
</style>

View File

@ -31,14 +31,15 @@
<Icon name={icon} />
{/if}
<div class="label text-base-12">
<span class="label text-base-12">
{label}
</div>
</span>
<slot name="control" />
</button>
<style lang="postcss">
.menu-item {
cursor: pointer;
display: flex;
text-align: left;
align-items: center;
@ -54,6 +55,7 @@
}
}
.label {
user-select: none;
flex-grow: 1;
white-space: nowrap;
}

View File

@ -6,13 +6,15 @@
<section class="content-wrapper">
<ScrollableContainer>
<div class="content" data-tauri-drag-region>
{#if title}
<h1 class="title text-head-24">
{title}
</h1>
{/if}
<slot />
<div class="drag-region" data-tauri-drag-region>
<div class="content" data-tauri-drag-region>
{#if title}
<h1 class="title text-head-24">
{title}
</h1>
{/if}
<slot />
</div>
</div>
</ScrollableContainer>
</section>
@ -23,7 +25,12 @@
width: 100%;
height: 100%;
flex: 1;
background-color: var(--clr-theme-container-light);
background-color: var(--clr-theme-container-pale);
}
.drag-region {
width: 100%;
min-height: 100vh;
}
.content {

View File

@ -119,7 +119,7 @@
justify-content: space-between;
padding: calc(var(--space-40) + var(--space-4)) var(--space-20) var(--space-20) var(--space-20);
border-right: 1px solid var(--clr-theme-container-outline-light);
background-color: var(--clr-theme-container-pale);
background-color: var(--clr-theme-container-light);
height: 100%;
width: 16rem;
}
@ -172,7 +172,7 @@
transition: none;
background-color: color-mix(
in srgb,
var(--clr-theme-container-pale),
var(--clr-theme-container-light),
var(--darken-tint-light)
);
}
@ -183,7 +183,11 @@
}
.item_selected {
background-color: color-mix(in srgb, var(--clr-theme-container-pale), var(--darken-tint-light));
background-color: color-mix(
in srgb,
var(--clr-theme-container-light),
var(--darken-tint-light)
);
color: var(--clr-theme-scale-ntrl-0);
}
@ -209,7 +213,7 @@
padding: var(--space-16);
border-radius: var(--radius-m);
border: 1px solid var(--clr-theme-container-outline-light);
background-color: var(--clr-theme-container-pale);
background-color: var(--clr-theme-container-light);
color: var(--clr-theme-scale-ntrl-30);
transition: background-color var(--transition-fast);

View File

@ -6,7 +6,7 @@ import {
type PrStatus,
MergeMethod
} from '$lib/github/types';
import { showToast, type ToastMessage } from '$lib/notifications/toasts';
import { showToast, type Toast } from '$lib/notifications/toasts';
import { sleep } from '$lib/utils/sleep';
import * as toasts from '$lib/utils/toasts';
import lscache from 'lscache';
@ -496,7 +496,7 @@ function loadPrs(
* "status": 422
* }
*/
function mapErrorToToast(err: any): ToastMessage | undefined {
function mapErrorToToast(err: any): Toast | undefined {
// We expect an object to be thrown by octokit.
if (typeof err != 'object') return;

View File

@ -84,5 +84,7 @@
"git": "M9.23744 1.17678C8.55402 0.49336 7.44598 0.493358 6.76257 1.17678L1.17678 6.76256C0.493362 7.44598 0.49336 8.55402 1.17678 9.23744L6.76257 14.8232C7.44598 15.5066 8.55402 15.5066 9.23744 14.8232L14.8232 9.23744C15.5066 8.55402 15.5066 7.44598 14.8232 6.76256L9.23744 1.17678ZM7.82323 2.23744C7.92086 2.13981 8.07915 2.13981 8.17678 2.23744L13.7626 7.82322C13.8602 7.92085 13.8602 8.07914 13.7626 8.17678L8.17678 13.7626C8.07915 13.8602 7.92086 13.8602 7.82323 13.7626L2.23744 8.17678C2.13981 8.07915 2.13981 7.92086 2.23744 7.82322L5.5 4.56066L7.43934 6.5L4.96967 8.96967L6.03033 10.0303L8.5 7.56066L10.4697 9.53033L11.5303 8.46967L9.03033 5.96967L6.56066 3.5L7.82323 2.23744Z",
"chevron-left": "M6.23744 8.17678C6.13981 8.07914 6.13981 7.92085 6.23744 7.82322L10.5303 3.53033L9.46967 2.46967L5.17678 6.76256C4.49336 7.44598 4.49336 8.55402 5.17678 9.23744L9.46967 13.5303L10.5303 12.4697L6.23744 8.17678Z",
"chevron-right": "M9.76256 8.17678C9.86019 8.07914 9.86019 7.92085 9.76256 7.82322L5.46967 3.53033L6.53033 2.46967L10.8232 6.76256C11.5066 7.44598 11.5066 8.55402 10.8232 9.23744L6.53033 13.5303L5.46967 12.4697L9.76256 8.17678Z",
"github": "M8.00579 1C4.13177 1 1 4.20832 1 8.17745C1 11.3502 3.00663 14.0358 5.79036 14.9864C6.1384 15.0578 6.26589 14.8319 6.26589 14.6419C6.26589 14.4755 6.25442 13.9052 6.25442 13.3109C4.30557 13.7388 3.89974 12.4553 3.89974 12.4553C3.58655 11.6235 3.1225 11.4097 3.1225 11.4097C2.48465 10.97 3.16896 10.97 3.16896 10.97C3.87651 11.0175 4.24778 11.7067 4.24778 11.7067C4.87402 12.7999 5.88315 12.491 6.28912 12.3009C6.34705 11.8374 6.53276 11.5166 6.72994 11.3384C5.1756 11.172 3.54023 10.5541 3.54023 7.79712C3.54023 7.01283 3.81844 6.37117 4.25926 5.87213C4.1897 5.69392 3.94606 4.95703 4.32895 3.97076C4.32895 3.97076 4.92048 3.78059 6.25427 4.70751C6.82531 4.55039 7.41422 4.47047 8.00579 4.4698C8.59733 4.4698 9.20034 4.55307 9.75717 4.70751C11.0911 3.78059 11.6826 3.97076 11.6826 3.97076C12.0655 4.95703 11.8217 5.69392 11.7522 5.87213C12.2046 6.37117 12.4714 7.01283 12.4714 7.79712C12.4714 10.5541 10.836 11.16 9.27003 11.3384C9.52529 11.5641 9.74555 11.9919 9.74555 12.6692C9.74555 13.6317 9.73408 14.4042 9.73408 14.6418C9.73408 14.8319 9.86171 15.0578 10.2096 14.9865C12.9933 14.0357 15 11.3502 15 8.17745C15.0114 4.20832 11.8682 1 8.00579 1Z"
"github": "M8.00579 1C4.13177 1 1 4.20832 1 8.17745C1 11.3502 3.00663 14.0358 5.79036 14.9864C6.1384 15.0578 6.26589 14.8319 6.26589 14.6419C6.26589 14.4755 6.25442 13.9052 6.25442 13.3109C4.30557 13.7388 3.89974 12.4553 3.89974 12.4553C3.58655 11.6235 3.1225 11.4097 3.1225 11.4097C2.48465 10.97 3.16896 10.97 3.16896 10.97C3.87651 11.0175 4.24778 11.7067 4.24778 11.7067C4.87402 12.7999 5.88315 12.491 6.28912 12.3009C6.34705 11.8374 6.53276 11.5166 6.72994 11.3384C5.1756 11.172 3.54023 10.5541 3.54023 7.79712C3.54023 7.01283 3.81844 6.37117 4.25926 5.87213C4.1897 5.69392 3.94606 4.95703 4.32895 3.97076C4.32895 3.97076 4.92048 3.78059 6.25427 4.70751C6.82531 4.55039 7.41422 4.47047 8.00579 4.4698C8.59733 4.4698 9.20034 4.55307 9.75717 4.70751C11.0911 3.78059 11.6826 3.97076 11.6826 3.97076C12.0655 4.95703 11.8217 5.69392 11.7522 5.87213C12.2046 6.37117 12.4714 7.01283 12.4714 7.79712C12.4714 10.5541 10.836 11.16 9.27003 11.3384C9.52529 11.5641 9.74555 11.9919 9.74555 12.6692C9.74555 13.6317 9.73408 14.4042 9.73408 14.6418C9.73408 14.8319 9.86171 15.0578 10.2096 14.9865C12.9933 14.0357 15 11.3502 15 8.17745C15.0114 4.20832 11.8682 1 8.00579 1Z",
"eye-shown": "M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z M7.99998 3.25C4.94324 3.25 2.37688 5.33566 1.31608 7.69213L1.17749 8L1.31608 8.30786C2.37687 10.6643 4.94324 12.75 7.99998 12.75C11.0567 12.75 13.6231 10.6643 14.6839 8.30787L14.8225 8.00001L14.6839 7.69214C13.6231 5.33566 11.0567 3.25 7.99998 3.25ZM7.99998 11.25C5.75461 11.25 3.76954 9.7742 2.83424 8C3.76954 6.2258 5.75462 4.75 7.99998 4.75C10.2453 4.75 12.2304 6.2258 13.1657 8C12.2304 9.7742 10.2453 11.25 7.99998 11.25Z",
"eye-hidden": "M10.3292 1.66459L4.32919 13.6646L5.67083 14.3354L6.54358 12.5899C7.01299 12.6938 7.50015 12.75 8.00001 12.75C11.0567 12.75 13.6231 10.6643 14.6839 8.30787L14.8225 8L14.6839 7.69213C13.9768 6.12144 12.6133 4.68765 10.8911 3.89486L11.6708 2.33541L10.3292 1.66459ZM10.2198 5.23744L7.24143 11.1942C7.49004 11.2309 7.74323 11.25 8.00001 11.25C10.2453 11.25 12.2304 9.77424 13.1657 8.00008C12.5601 6.85225 11.5063 5.817 10.2198 5.23744Z M7.1258 4.82412C5.24551 5.14401 3.6433 6.46644 2.83436 7.99994C3.17611 8.64745 3.66354 9.26476 4.26171 9.78496L3.27737 10.9168C2.42955 10.1795 1.75091 9.27374 1.31611 8.30786L1.17752 7.99999L1.31611 7.69213C2.24746 5.62322 4.32664 3.77878 6.87422 3.34537L7.1258 4.82412Z"
}

View File

@ -36,7 +36,7 @@
bottom: var(--space-20);
right: var(--space-20);
gap: var(--space-8);
max-width: 22rem;
max-width: 30rem;
z-index: 50;
}
</style>

View File

@ -2,22 +2,26 @@ import { writable, type Writable } from 'svelte/store';
export type ToastStyle = 'neutral' | 'error' | 'pop' | 'warn';
export interface ToastMessage {
id?: number;
export interface Toast {
id?: string;
message: string;
title?: string;
style?: ToastStyle;
}
export const toastStore: Writable<ToastMessage[]> = writable([]);
export const toastStore: Writable<Toast[]> = writable([]);
let idCounter = 0;
export function showToast(message: ToastMessage) {
message.message = message.message.replace(/^ */gm, '');
toastStore.update((items) => [...items, { id: idCounter++, ...message }]);
export function showToast(toast: Toast) {
toast.message = toast.message.replace(/^ */gm, '');
toastStore.update((items) => [
...items.filter((t) => toast.id == undefined || t.id != toast.id),
{ id: (idCounter++).toString(), ...toast }
]);
}
export function dismissToast(messageId: number | undefined) {
export function dismissToast(messageId: string | undefined) {
if (!messageId) return;
toastStore.update((items) => items.filter((m) => m.id != messageId));
}

View File

@ -0,0 +1 @@
export const pxToRem = (px: number, base: number = 16) => `${px / base}rem`;

View File

@ -1,4 +1,5 @@
import { invoke } from '$lib/backend/ipc';
import { showToast } from '$lib/notifications/toasts';
import * as toasts from '$lib/utils/toasts';
import posthog from 'posthog-js';
import type { RemoteBranchService } from '$lib/stores/remoteBranches';
@ -61,7 +62,7 @@ export class BranchController {
});
posthog.capture('Commit Successful');
} catch (err: any) {
toasts.error('Failed to commit branch');
toasts.error('Failed to commit changes');
posthog.capture('Commit Failed', err);
}
}
@ -193,10 +194,33 @@ export class BranchController {
await this.vbranchService.reload();
return await this.vbranchService.getById(branchId);
} catch (err: any) {
console.error(err);
if (err.code === 'errors.git.authentication') {
toasts.error('Failed to authenticate. Did you setup GitButler ssh keys?');
showToast({
title: 'Git push failed',
message: `
Your branch cannot be pushed due to an authentication failure.
Please check our [documentation](https://docs.gitbutler.com/troubleshooting/fetch-push)
on fetching and pushing for ways to resolve the problem.
\`\`\`${err.message}\`\`\`
`,
style: 'error'
});
} else {
toasts.error(`Failed to push branch: ${err.message}`);
showToast({
title: 'Git push failed',
message: `
Your branch cannot be pushed due to an unforeseen problem.
Please check our [documentation](https://docs.gitbutler.com/troubleshooting/fetch-push)
on fetching and pushing for ways to resolve the problem.
\`\`\`${err.message}\`\`\`
`,
style: 'error'
});
}
}
}

View File

@ -1,8 +1,7 @@
import { BaseBranch, Branch } from './types';
import { invoke, listen } from '$lib/backend/ipc';
import { Code, invoke, listen } from '$lib/backend/ipc';
import * as toasts from '$lib/utils/toasts';
import { plainToInstance } from 'class-transformer';
import posthog from 'posthog-js';
import {
switchMap,
Observable,
@ -101,26 +100,6 @@ export class VirtualBranchService {
)
);
}
async pushBranch(branchId: string, withForce: boolean): Promise<Branch | undefined> {
try {
await invoke<void>('push_virtual_branch', {
projectId: this.projectId,
branchId,
withForce
});
posthog.capture('Push Successful');
await this.reload();
return await this.getById(branchId);
} catch (err: any) {
posthog.capture('Push Failed', { error: err });
if (err.code === 'errors.git.authentication') {
toasts.error('Failed to authenticate. Did you setup GitButler ssh keys?');
} else {
toasts.error(`Failed to push branch: ${err.message}`);
}
}
}
}
function subscribeToVirtualBranches(projectId: string, callback: (branches: Branch[]) => void) {
@ -147,6 +126,9 @@ export class BaseBranchService {
return await getBaseBranch({ projectId });
}),
tap(() => this.busy$.next(false)),
// Start with undefined to prevent delay in updating $baseBranch$ value in
// layout.svelte when navigating between projects.
startWith(undefined),
shareReplay(1),
catchError((e) => {
this.error$.next(e);
@ -162,11 +144,15 @@ export class BaseBranchService {
// trigger a base branch reload. It feels a bit awkward and should be improved.
await invoke<void>('fetch_from_target', { projectId: this.projectId });
} catch (err: any) {
if (err.code === 'errors.git.authentication') {
if (err.message?.includes('does not have a default target')) {
// Swallow this error since user should be taken to project setup page
return;
} else if (err.code === Code.ProjectsGitAuth) {
toasts.error('Failed to authenticate. Did you setup GitButler ssh keys?');
} else {
toasts.error(`Failed to fetch branch: ${err.message}`);
}
console.error(err);
}
}

View File

@ -290,6 +290,13 @@ export class BaseBranch {
}
commitUrl(commitId: string): string | undefined {
// if repoBaseUrl is bitbucket, then the commit url is different
if (this.repoBaseUrl.includes('bitbucket.org')) {
return `${this.repoBaseUrl}/commits/${commitId}`;
}
if (this.repoBaseUrl.includes('gitlab.com')) {
return `${this.repoBaseUrl}/-/commit/${commitId}`;
}
return `${this.repoBaseUrl}/commit/${commitId}`;
}

View File

@ -2,6 +2,7 @@
import '../styles/main.postcss';
import ShareIssueModal from '$lib/components/ShareIssueModal.svelte';
import UpdateButton from '$lib/components/UpdateButton.svelte';
import ToastController from '$lib/notifications/ToastController.svelte';
import { SETTINGS_CONTEXT, loadUserSettings } from '$lib/settings/userSettings';
import * as events from '$lib/utils/events';
@ -14,7 +15,7 @@
import { goto } from '$app/navigation';
export let data: LayoutData;
const { cloud, user$ } = data;
const { cloud, user$, updaterService } = data;
const userSettings = loadUserSettings();
initTheme(userSettings);
@ -51,3 +52,4 @@
<Toaster />
<ShareIssueModal bind:this={shareIssueModal} user={$user$} {cloud} />
<ToastController />
<UpdateButton {updaterService} />

View File

@ -2,6 +2,7 @@ import { initPostHog } from '$lib/analytics/posthog';
import { initSentry } from '$lib/analytics/sentry';
import { getCloudApiClient } from '$lib/backend/cloud';
import { ProjectService } from '$lib/backend/projects';
import { UpdaterService } from '$lib/backend/updater';
import { appMetricsEnabled, appErrorReportingEnabled } from '$lib/config/appSettings';
import { UserService } from '$lib/stores/user';
import lscache from 'lscache';
@ -32,6 +33,7 @@ export const load: LayoutLoad = async ({ fetch: realFetch }: { fetch: typeof fet
if (enabled) initPostHog();
});
const userService = new UserService();
const updaterService = new UpdaterService();
// TODO: Find a workaround to avoid this dynamic import
// https://github.com/sveltejs/kit/issues/905
@ -41,6 +43,7 @@ export const load: LayoutLoad = async ({ fetch: realFetch }: { fetch: typeof fet
return {
projectService: new ProjectService(defaultPath),
cloud: getCloudApiClient({ fetch: realFetch }),
updaterService,
userService,
user$: userService.user$
};

View File

@ -9,11 +9,8 @@
import * as hotkeys from '$lib/utils/hotkeys';
import { unsubscribe } from '$lib/utils/random';
import { getRemoteBranches } from '$lib/vbranches/branchStoresCache';
import { interval, Subscription } from 'rxjs';
import { startWith, tap } from 'rxjs/operators';
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { LayoutData } from './$types';
import { page } from '$app/stores';
export let data: LayoutData;
@ -35,28 +32,31 @@
$: user$ = data.user$;
let trayViewport: HTMLElement;
let intervalId: any;
handleMenuActions(data.projectId);
let lastProjectId: string | undefined = undefined;
onMount(() => {
let fetchSub: Subscription;
// Project is auto-fetched on page load and then every 15 minutes
page.subscribe((page) => {
if (page.params.projectId !== lastProjectId) {
lastProjectId = page.params.projectId;
fetchSub?.unsubscribe();
fetchSub = interval(1000 * 60 * 15)
.pipe(
startWith(0),
tap(() => baseBranchService.fetchFromTarget())
)
.subscribe();
}
});
return unsubscribe(
// Once on load and every time the project id changes
$: if (projectId) setupFetchInterval();
function setupFetchInterval() {
baseBranchService.fetchFromTarget();
clearFetchInterval();
intervalId = setInterval(() => baseBranchService.fetchFromTarget(), 15 * 60 * 1000);
}
function clearFetchInterval() {
if (intervalId) clearInterval(intervalId);
}
onMount(() =>
unsubscribe(
menuSubscribe(data.projectId),
hotkeys.on('Meta+Shift+S', () => syncToCloud($project$?.id))
);
hotkeys.on('Meta+Shift+S', () => syncToCloud(projectId))
)
);
onDestroy(() => {
clearFetchInterval();
});
</script>
@ -65,8 +65,7 @@
{:else if $baseError$}
<ProblemLoadingRepo {projectService} {userService} project={$project$} error={$baseError$} />
{:else if $baseBranch$ === null}
{@const remoteBranches = getRemoteBranches(projectId)}
{#await remoteBranches}
{#await getRemoteBranches(projectId)}
<p>loading...</p>
{:then remoteBranches}
{#if remoteBranches.length == 0}

View File

@ -34,7 +34,12 @@ export const load: LayoutLoad = async ({ params, parent }) => {
);
const githubService = new GitHubService(userService, baseBranchService);
const branchService = new BranchService(vbranchService, remoteBranchService, githubService);
const branchService = new BranchService(
vbranchService,
remoteBranchService,
githubService,
branchController
);
return {
projectId,

View File

@ -1,11 +1,12 @@
<script lang="ts">
import CloudForm from '$lib/components/CloudForm.svelte';
import DetailsForm from '$lib/components/DetailsForm.svelte';
import FullscreenLoading from '$lib/components/FullscreenLoading.svelte';
import KeysForm from '$lib/components/KeysForm.svelte';
import PreferencesForm from '$lib/components/PreferencesForm.svelte';
import RemoveProjectButton from '$lib/components/RemoveProjectButton.svelte';
import ScrollableContainer from '$lib/components/ScrollableContainer.svelte';
import Spacer from '$lib/components/Spacer.svelte';
import SectionCard from '$lib/components/SectionCard.svelte';
import ContentWrapper from '$lib/components/settings/ContentWrapper.svelte';
import * as toasts from '$lib/utils/toasts';
import type { UserError } from '$lib/backend/ipc';
import type { Key, Project } from '$lib/backend/projects';
@ -66,74 +67,28 @@
};
</script>
<ScrollableContainer wide>
<div class="settings" data-tauri-drag-region>
<div class="card">
{#if !$project$}
loading...
{:else}
<div class="card__header">
<span class="card_title text-base-16 text-semibold">Project settings</span>
</div>
<div class="card__content">
<CloudForm project={$project$} user={$user$} {userService} on:updated={onCloudUpdated} />
<Spacer margin={2} />
<DetailsForm project={$project$} on:updated={onDetailsUpdated} />
<Spacer margin={2} />
<KeysForm project={$project$} on:updated={onKeysUpdated} />
<Spacer margin={2} />
<PreferencesForm project={$project$} on:updated={onPreferencesUpdated} />
<Spacer margin={2} />
<div class="flex gap-x-4">
<a
href="https://discord.gg/wDKZCPEjXC"
target="_blank"
rel="noreferrer"
class="flex-1 rounded border border-light-200 bg-white p-4 dark:border-dark-400 dark:bg-dark-700"
>
<p class="mb-2 font-medium">Join our Discord</p>
<p class="text-light-700 dark:text-dark-200">
Join our community and share feedback, requests, or ask a question.
</p>
</a>
<a
href="mailto:hello@gitbutler.com?subject=Feedback or question!"
target="_blank"
class="flex-1 rounded border border-light-200 bg-white p-4 dark:border-dark-400 dark:bg-dark-700"
>
<p class="mb-2 font-medium">Contact us</p>
<p class="text-light-700 dark:text-dark-200">
If you have an issue or any questions, contact us.
</p>
</a>
</div>
</div>
<div class="card__footer">
<RemoveProjectButton
bind:this={deleteConfirmationModal}
projectTitle={$project$?.title}
{isDeleting}
{onDeleteClicked}
/>
</div>
{/if}
</div>
</div>
</ScrollableContainer>
<style lang="postcss">
.settings {
display: flex;
flex-direction: column;
padding: var(--space-16) var(--space-16);
height: 100%;
width: 100%;
}
.card {
max-width: 50rem;
}
.card__content {
gap: var(--space-24);
}
</style>
{#if !$project$}
<FullscreenLoading />
{:else}
<ContentWrapper title="Project settings">
<CloudForm project={$project$} user={$user$} {userService} on:updated={onCloudUpdated} />
<DetailsForm project={$project$} on:updated={onDetailsUpdated} />
<KeysForm project={$project$} on:updated={onKeysUpdated} />
<PreferencesForm project={$project$} on:updated={onPreferencesUpdated} />
<SectionCard>
<svelte:fragment slot="title">Remove project</svelte:fragment>
<svelte:fragment slot="body">
You can remove projects from GitButler, your code remains safe as this only clears
configuration.
</svelte:fragment>
<div>
<RemoveProjectButton
bind:this={deleteConfirmationModal}
projectTitle={$project$?.title}
{isDeleting}
{onDeleteClicked}
/>
</div>
</SectionCard>
</ContentWrapper>
{/if}

View File

@ -71,14 +71,6 @@ button {
-webkit-app-region: no-drag;
}
div,
button,
[role='button'],
select,
a {
cursor: default;
}
/* SCROLL BAR STYLING */
/* We don't use REM here becasue we don't want
the scrollbar to scale with the font size */