many trunk changes

This commit is contained in:
Pavel Laptev 2024-01-24 10:27:51 +01:00 committed by GitButler
commit c1364a1fe5
27 changed files with 536 additions and 240 deletions

View File

@ -71,6 +71,10 @@ jobs:
gpg_private_key: ${{ secrets.APPIMAGE_PRIVATE_KEY }}
passphrase: ${{ secrets.APPIMAGE_KEY_PASSPHRASE }}
- name: install linux dependencies
if: runner.os == 'Linux'
run: sudo apt-get install -y libwebkit2gtk-4.0-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
- name: Build binary
run: |
./scripts/release.sh \

20
Cargo.lock generated
View File

@ -1876,6 +1876,8 @@ name = "gitbutler-git"
version = "0.0.0"
dependencies = [
"git2",
"nix 0.27.1",
"rand 0.8.5",
"serde",
"thiserror",
"tokio",
@ -2881,7 +2883,7 @@ dependencies = [
"combine",
"libc",
"mach2",
"nix",
"nix 0.26.4",
"sysctl",
"thiserror",
"widestring",
@ -2953,6 +2955,18 @@ dependencies = [
"pin-utils",
]
[[package]]
name = "nix"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
dependencies = [
"bitflags 2.4.0",
"cfg-if",
"libc",
"memoffset 0.9.0",
]
[[package]]
name = "no-std-compat"
version = "0.4.1"
@ -6531,7 +6545,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2769203cd13a0c6015d515be729c526d041e9cf2c0cc478d57faee85f40c6dcd"
dependencies = [
"nix",
"nix 0.26.4",
"winapi",
]
@ -6565,7 +6579,7 @@ dependencies = [
"futures-sink",
"futures-util",
"hex",
"nix",
"nix 0.26.4",
"once_cell",
"ordered-stream",
"rand 0.8.5",

View File

@ -3,9 +3,22 @@ name = "gitbutler-git"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[[bin]]
name = "gitbutler-git-askpass"
path = "src/backend/cli/bin/askpass.rs"
required-features = ["cli"]
[[bin]]
name = "gitbutler-git-setsid"
path = "src/backend/cli/bin/setsid.rs"
required-features = ["cli"]
[features]
default = ["git2", "cli", "serde", "tokio"]
cli = ["std"]
cli = ["std", "dep:nix", "dep:rand"]
git2 = ["dep:git2", "std"]
serde = ["dep:serde"]
std = ["dep:thiserror"]
@ -16,6 +29,10 @@ git2 = { workspace = true, optional = true }
thiserror = { workspace = true, optional = true }
serde = { workspace = true, optional = true }
tokio = { workspace = true, optional = true, features = ["process"]}
rand = { version = "0.8.5", optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "process"]}
[target."cfg(unix)".dependencies]
nix = { version = "0.27.1", optional = true, features = ["process", "socket"] }

View File

@ -22,7 +22,7 @@ mod tests {
.join(test_name);
let _ = std::fs::remove_dir_all(&repo_path);
std::fs::create_dir_all(&repo_path).unwrap();
Repository::open_or_init(executor::tokio::TokioExecutor, repo_path)
Repository::open_or_init(executor::tokio::TokioExecutor, repo_path.to_str().unwrap())
.await
.unwrap()
}

View File

@ -0,0 +1,36 @@
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
pub fn main(sock_path: &str, secret: &str, prompt: &str) {
let mut stream = UnixStream::connect(sock_path).expect("connect():");
// Set a timer for 10s.
stream
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.expect("set_read_timeout():");
// Write the secret.
stream
.write_all(secret.as_bytes())
.expect("write_all(secret):");
// Write the prompt that Git gave us.
stream
.write_all(prompt.as_bytes())
.expect("write_all(prompt):");
// Wait for the response.
let mut buf = [0; 2048];
let n = stream.read(&mut buf).expect("read():");
// TODO(qix-): Figure out a way to do a single timeout
// TODO(qix-): but allow any response size.
if n == buf.len() {
panic!("response too long");
}
// Write the response back to Git.
std::io::stdout()
.write_all(&buf[..n])
.expect("write_all(stdout):");
}

View File

@ -0,0 +1,15 @@
#[cfg(not(target_os = "windows"))]
#[path = "askpass-unix.rs"]
mod unix;
#[cfg(target_os = "windows")]
compile_error!("Windows support is not yet implemented.");
pub fn main() {
let pipe_name = std::env::var("GITBUTLER_ASKPASS_PIPE").expect("do not run this binary yourself; it's only meant to be run by GitButler (missing GITBUTLER_ASKPASS_PIPE env var)");
let pipe_secret = std::env::var("GITBUTLER_ASKPASS_SECRET").expect("do not run this binary yourself; it's only meant to be run by GitButler (missing GITBUTLER_ASKPASS_SECRET env var)");
let prompt = std::env::args().nth(1).expect("do not run this binary yourself; it's only meant to be run by GitButler (missing prompt arg)");
#[cfg(not(target_os = "windows"))]
unix::main(&pipe_name, &pipe_secret, &prompt);
}

View File

@ -0,0 +1,54 @@
#[cfg(not(target_os = "windows"))]
use nix::{
libc::{c_int, wait, EXIT_FAILURE, WEXITSTATUS, WIFEXITED, WIFSIGNALED, WTERMSIG},
unistd::{fork, setsid, ForkResult},
};
use std::{os::unix::process::CommandExt, process};
#[cfg(target_os = "windows")]
pub fn main() {
panic!("This binary is only meant to be run on Unix-like systems. It exists on Windows only because Cargo cannot switch off bins based on target platform.");
}
#[cfg(not(target_os = "windows"))]
pub fn main() {
let has_pipe_var = std::env::var("GITBUTLER_ASKPASS_PIPE")
.map(|v| v != "")
.unwrap_or(false);
if !has_pipe_var {
panic!("This binary is only meant to be run by GitButler; please do not use it yourself as it's entirely unstable.");
}
let args = std::env::args().skip(1).collect::<Vec<_>>();
match unsafe { fork() }.unwrap() {
ForkResult::Parent { child, .. } => {
let mut status: c_int = 0;
let waited_pid = unsafe { wait(&mut status as *mut _) };
if waited_pid != child.as_raw() {
panic!(
"wait(): unexpected child process; got {}, expected {}",
waited_pid, child
);
}
if WIFEXITED(status) {
let exit_status = WEXITSTATUS(status);
process::exit(exit_status);
} else if WIFSIGNALED(status) {
let signal = WTERMSIG(status);
process::exit(128 + signal);
} else {
process::exit(EXIT_FAILURE);
}
}
ForkResult::Child => {
setsid().expect("setsid():");
let err = process::Command::new(&args[0]).args(&args[1..]).exec();
panic!("exec(): {}", err);
}
}
}

View File

@ -1,3 +1,5 @@
use crate::prelude::*;
#[cfg(any(test, feature = "tokio"))]
pub mod tokio;
@ -21,5 +23,32 @@ pub trait GitExecutor {
///
/// `Err` is returned if the command could not be executed,
/// **not** if the command returned a non-zero exit code.
async fn execute(&self, args: &[&str]) -> Result<(usize, String, String), Self::Error>;
async fn execute_raw(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
) -> Result<(usize, String, String), Self::Error>;
/// Executes the given Git command with sane defaults.
/// `git` is never passed as the first argument (arg 0).
///
/// Implementers should use this method over [`Self::execute_raw`]
/// when possible.
async fn execute(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
) -> Result<(usize, String, String), Self::Error> {
let mut args = args.as_ref().to_vec();
args.insert(0, "--no-pager");
// TODO(qix-): Test the performance impact of this.
args.insert(0, "--no-optional-locks");
let mut envs = envs.unwrap_or_default();
envs.insert("GIT_TERMINAL_PROMPT".into(), "0".into());
envs.insert("LC_ALL".into(), "C".into()); // Force English. We need this for parsing output.
self.execute_raw(&args, Some(envs)).await
}
}

View File

@ -1,6 +1,6 @@
//! A [Tokio](https://tokio.rs)-based [`GitExecutor`] implementation.
use std::collections::HashMap;
use crate::prelude::*;
use tokio::process::Command;
/// A [`GitExecutor`] implementation using the `git` command-line tool
@ -10,9 +10,16 @@ pub struct TokioExecutor;
impl super::GitExecutor for TokioExecutor {
type Error = std::io::Error;
async fn execute(&self, args: &[&str]) -> Result<(usize, String, String), Self::Error> {
async fn execute_raw(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
) -> Result<(usize, String, String), Self::Error> {
let mut cmd = Command::new("git");
cmd.args(args);
if let Some(envs) = envs {
cmd.envs(envs);
}
let output = cmd.output().await?;
@ -23,82 +30,3 @@ impl super::GitExecutor for TokioExecutor {
))
}
}
/// A [`GitExecutor`] implementation using the `git` command-line tool
/// via [`tokio::process::Command`], with the given environment variables.
pub struct TokioExecutorEnv {
env: HashMap<String, String>,
}
impl super::GitExecutor for TokioExecutorEnv {
type Error = std::io::Error;
async fn execute(&self, args: &[&str]) -> Result<(usize, String, String), Self::Error> {
let mut cmd = Command::new("git");
cmd.args(args);
cmd.envs(&self.env);
let output = cmd.output().await?;
Ok((
output.status.code().unwrap_or(127) as usize,
String::from_utf8_lossy(&output.stdout).trim().into(),
String::from_utf8_lossy(&output.stderr).trim().into(),
))
}
}
/// Allows executors to create (or modify) a [`TokioExecutorEnv`],
/// with added/modified environment variables, set for each execution
/// of `git`.
pub trait WithEnv: Sized {
/// Sets the given environment variable.
fn with_env<K: AsRef<str>, V: AsRef<str>>(self, key: K, value: V) -> TokioExecutorEnv;
/// Creates a new [`TokioExecutorEnv`] with the given additional environment variables.
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
self,
envs: I,
) -> TokioExecutorEnv;
}
impl WithEnv for TokioExecutor {
fn with_env<K: AsRef<str>, V: AsRef<str>>(self, key: K, value: V) -> TokioExecutorEnv {
TokioExecutorEnv {
env: [(key.as_ref().into(), value.as_ref().into())]
.iter()
.cloned()
.collect(),
}
}
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
self,
envs: I,
) -> TokioExecutorEnv {
TokioExecutorEnv {
env: envs
.into_iter()
.map(|(k, v)| (k.as_ref().into(), v.as_ref().into()))
.collect(),
}
}
}
impl WithEnv for TokioExecutorEnv {
fn with_env<K: AsRef<str>, V: AsRef<str>>(mut self, key: K, value: V) -> TokioExecutorEnv {
self.env.insert(key.as_ref().into(), value.as_ref().into());
self
}
fn with_envs<K: AsRef<str>, V: AsRef<str>, I: IntoIterator<Item = (K, V)>>(
mut self,
envs: I,
) -> TokioExecutorEnv {
self.env.extend(
envs.into_iter()
.map(|(k, v)| (k.as_ref().into(), v.as_ref().into())),
);
self
}
}

View File

@ -1,9 +1,10 @@
//! NOTE: Doesn't support `no_std` yet.
use std::path::Path;
use super::executor::GitExecutor;
use crate::ConfigScope;
use crate::{prelude::*, ConfigScope};
use rand::Rng;
/// The number of characters in the secret used for checking
/// askpass invocations by ssh/git when connecting to our process.
const ASKPASS_SECRET_LENGTH: usize = 24;
/// Higher level errors that can occur when interacting with the CLI.
#[derive(Debug, thiserror::Error)]
@ -14,6 +15,8 @@ pub enum Error<E: core::error::Error + core::fmt::Debug + Send + Sync + 'static>
"git command exited with non-zero exit code {0}: {1:?}\n\nSTDOUT:\n{2}\n\nSTDERR:\n{3}"
)]
Failed(usize, Vec<String>, String, String),
#[error("failed to determine path to this executable: {0}")]
NoSelfExe(std::io::Error),
}
/// A [`crate::Repository`] implementation using the `git` CLI
@ -29,20 +32,20 @@ impl<E: GitExecutor> Repository<E> {
/// Note that this **does not** check if the repository exists,
/// but assumes it does.
#[inline]
pub fn open_unchecked<P: AsRef<Path>>(exec: E, path: P) -> Self {
pub fn open_unchecked<P: AsRef<str>>(exec: E, path: P) -> Self {
Self {
exec,
path: path.as_ref().to_str().unwrap().to_string(),
path: path.as_ref().to_owned(),
}
}
/// (Re-)initializes a repository at the given path
/// using the given [`GitExecutor`].
pub async fn open_or_init<P: AsRef<Path>>(exec: E, path: P) -> Result<Self, Error<E::Error>> {
let path = path.as_ref().to_str().unwrap().to_string();
pub async fn open_or_init<P: AsRef<str>>(exec: E, path: P) -> Result<Self, Error<E::Error>> {
let path = path.as_ref().to_owned();
let args = vec!["init", "--quiet", &path];
let (exit_code, stdout, stderr) = exec.execute(&args).await.map_err(Error::Exec)?;
let (exit_code, stdout, stderr) = exec.execute(&args, None).await.map_err(Error::Exec)?;
if exit_code == 0 {
Ok(Self { exec, path })
@ -55,6 +58,65 @@ impl<E: GitExecutor> Repository<E> {
))
}
}
async fn execute_with_auth_harness(
&self,
args: &[&str],
envs: Option<BTreeMap<String, String>>,
) -> Result<(usize, String, String), Error<E::Error>> {
let path = std::env::current_exe().map_err(|e| Error::NoSelfExe(e))?;
let our_pid = std::process::id();
let askpath_path = path.with_file_name("gitbutler-git-askpass");
#[cfg(not(target_os = "windows"))]
let setsid_path = path.with_file_name("gitbutler-git-setsid");
let sock_path = std::env::temp_dir().join(format!("gitbutler-git-{our_pid}.sock"));
// FIXME(qix-): This is probably not cryptographically secure, did this in a bit
// FIXME(qix-): of a hurry. We should probably use a proper CSPRNG here, but this
// FIXME(qix-): is probably fine for now (as this security mechanism is probably
// FIXME(qix-): overkill to begin with).
let secret = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(ASKPASS_SECRET_LENGTH)
.map(char::from)
.collect::<String>();
let mut envs = envs.unwrap_or_default();
envs.insert(
"GITBUTLER_ASKPASS_PIPE".into(),
sock_path.to_string_lossy().into_owned(),
);
envs.insert("GITBUTLER_ASKPASS_SECRET".into(), secret.clone());
envs.insert(
"SSH_ASKPASS".into(),
askpath_path.to_string_lossy().into_owned(),
);
// DISPLAY is required by SSH to check SSH_ASKPASS.
// Please don't ask us why, it's unclear.
if !std::env::var("DISPLAY").map(|v| v != "").unwrap_or(false) {
envs.insert("DISPLAY".into(), ":".into());
}
#[cfg(not(target_os = "windows"))]
envs.insert(
"GIT_SSH_COMMAND".into(),
format!(
"{} {}",
setsid_path.to_string_lossy(),
envs.get("GIT_SSH_COMMAND").unwrap_or(&"ssh".into())
),
);
// TODO(qix-): implement the actual socket server code (right now this won't work)
self.exec
.execute(args, Some(envs))
.await
.map_err(Error::Exec)
}
}
impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
@ -81,7 +143,8 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
args.push(key);
let (exit_code, stdout, stderr) = self.exec.execute(&args).await.map_err(Error::Exec)?;
let (exit_code, stdout, stderr) =
self.exec.execute(&args, None).await.map_err(Error::Exec)?;
if exit_code == 0 {
Ok(Some(stdout))
@ -120,7 +183,8 @@ impl<E: GitExecutor + 'static> crate::Repository for Repository<E> {
args.push(key);
args.push(value);
let (exit_code, stdout, stderr) = self.exec.execute(&args).await.map_err(Error::Exec)?;
let (exit_code, stdout, stderr) =
self.exec.execute(&args, None).await.map_err(Error::Exec)?;
if exit_code == 0 {
Ok(())

View File

@ -3,6 +3,26 @@
//! This library houses a number of Git implementations,
//! over which we abstract a common interface and provide
//! higher-level operations that are implementation-agnostic.
//!
//! # Libgit2 Support
//! This library supports libgit2 via the `git2` feature.
//! Not much in the way of assumptions are made about the environment;
//! it's a fairly clean and safe Git backend.
//!
//! # Fork/Exec (CLI) Support
//! This library supports the Git CLI via the `cli` feature.
//! Note that this is a fairly experimental implementation that
//! uses some (ideally portable) hacks for authentication,
//! including a custom executable (or two, in the case of
//! *nix systems) for handling automatic authentication
//! via the API.
//!
//! This means those executables must be situated next to
//! the executable that is running them (as sibling files),
//! for security purposes. They may not be symlinked.
//!
//! This hampers certain use cases, such as implementing
//! [`cli::GitExecutor`] for e.g. remote connections.
#![cfg_attr(not(feature = "std"), no_std)] // must be first
#![feature(error_in_core)]

View File

@ -5,3 +5,7 @@ pub use alloc::{
vec,
vec::Vec,
};
#[cfg(feature = "std")]
#[allow(unused_imports)]
pub use std::collections::BTreeMap;

View File

@ -1,7 +1,8 @@
use core::fmt;
/// An error that can occur while parsing a refspec from a string.
#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
#[derive(Debug, PartialEq, Eq, Clone)]
#[cfg_attr(feature = "std", derive(thiserror::Error))]
pub enum Error {
/// Encountered an unexpected character when parsing a [`RefSpec`] from a string.
#[error("unexpected character {0:?} (offset {1})")]

View File

@ -0,0 +1,9 @@
pub enum Authorization {
Basic {
pub username: String,
pub password: String,
},
PublicKey {
pub path: PathBuf
}
}

View File

@ -18,7 +18,8 @@ import {
catchError,
of,
startWith,
combineLatestWith
combineLatestWith,
tap
} from 'rxjs';
export type Update = { version?: string; status?: UpdateStatus } | undefined;
@ -42,6 +43,7 @@ export class UpdaterService {
this.update$ = this.reload$.pipe(
switchMap(() => interval(60 * 1000).pipe(startWith(0))),
tap(() => this.status$.next(undefined)),
switchMap(() =>
from(checkUpdate()).pipe(
timeout(10000), // In dev mode the promise hangs indefinitely.
@ -61,7 +63,7 @@ export class UpdaterService {
}),
shareReplay(1)
);
// this.update$ = of({ version: '1.0.0', status: 'DONE' });
this.update$ = of({ version: '1.0.0', status: 'UPTODATE' });
}
async install() {

View File

@ -2,7 +2,7 @@ import type { PullRequest } from '$lib/github/types';
import type { Branch, RemoteBranch } from '$lib/vbranches/types';
import { CombinedBranch } from '$lib/branches/types';
import { Observable, combineLatest } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import { map, startWith, switchMap } from 'rxjs/operators';
import type { RemoteBranchService } from '$lib/stores/remoteBranches';
import type { GitHubService } from '$lib/github/service';
import type { VirtualBranchService } from '$lib/vbranches/branchStoresCache';
@ -30,7 +30,8 @@ export class BranchService {
);
observer.next(contributions);
})
)
),
map((branches) => branches.filter((b) => !b.vbranch || b.vbranch.active))
);
}
}

View File

@ -57,11 +57,11 @@
flex-direction: column;
position: relative;
overflow: hidden;
height: 100%;
}
.viewport {
height: 100%;
width: 100%;
user-select: none;
}
.contents {
display: block;

View File

@ -10,7 +10,7 @@
$: update$ = updaterService.update$;
</script>
{#if $update$?.version}
{#if $update$?.version && $update$.status != 'UPTODATE'}
<div class="update-banner" class:busy={$update$?.status == 'PENDING'}>
<div class="img">
<div class="circle-img">

View File

@ -17,7 +17,7 @@
$: githubService = data.githubService;
$: project$ = data.project$;
$: activeBranches$ = vbranchService.activeBranches$;
$: branches = vbranchService.branches$;
$: error$ = vbranchService.branchesError$;
$: githubEnabled$ = githubService.isEnabled$;
@ -61,7 +61,7 @@
project={$project$}
{cloud}
base={$base$}
branches={$activeBranches$}
branches={$branches}
projectPath={$project$?.path}
branchesError={$error$}
user={$user$}
@ -78,7 +78,6 @@
overflow-x: scroll;
height: 100%;
width: 100%;
user-select: none;
}
.scroll-contents {
display: flex;

View File

@ -115,6 +115,7 @@
{projectPath}
{user}
{githubService}
readonly={!branch.active}
></BranchLane>
</div>
{/each}
@ -205,8 +206,7 @@
flex-shrink: 1;
align-items: flex-start;
height: 100%;
/* padding: 0 var(--space-8); */
user-select: none;
padding: 0 var(--space-8);
}
.loading {

View File

@ -173,13 +173,15 @@
hover: 'cherrypick-dz-hover',
active: 'cherrypick-dz-active',
accepts: acceptCherrypick,
onDrop: onCherrypicked
onDrop: onCherrypicked,
disabled: readonly
}}
use:dropzone={{
hover: 'lane-dz-hover',
active: 'lane-dz-active',
accepts: acceptBranchDrop,
onDrop: onBranchDrop
onDrop: onBranchDrop,
disabled: readonly
}}
>
<DropzoneOverlay class="cherrypick-dz-marker" label="Apply here" />

View File

@ -63,7 +63,7 @@
</div>
{/if}
<div class="branch-files">
<div class="branch-files" class:readonly>
<div class="header" bind:this={headerElement}>
<div class="header__left">
{#if showCheckboxes && selectedListMode == 'list' && branch.files.length > 1}
@ -115,6 +115,9 @@
flex: 1;
background: var(--clr-theme-container-light);
border-radius: var(--radius-m) var(--radius-m) 0 0;
&.readonly {
border-radius: var(--radius-m);
}
}
.scroll-container {
display: flex;

View File

@ -10,6 +10,8 @@
import type { GitHubService } from '$lib/github/service';
import { open } from '@tauri-apps/api/shell';
import Button from '$lib/components/Button.svelte';
import toast from 'svelte-french-toast';
import Tooltip from '$lib/components/Tooltip.svelte';
export let readonly = false;
export let branch: Branch;
@ -24,6 +26,7 @@
let meatballButton: HTMLDivElement;
let visible = false;
let container: HTMLDivElement;
let isApplying = false;
function handleBranchNameChange() {
branchController.updateBranchName(branch.id, branch.name);
@ -36,23 +39,115 @@
$: hasIntegratedCommits = branch.commits?.some((b) => b.isIntegrated);
</script>
<!-- <div class="header__wrapper"> -->
<div class="header card" bind:this={container}>
{#if !readonly}
<div class="header__wrapper">
<div class="header card" bind:this={container} class:readonly>
<div class="header__info">
<div class="header__label">
<BranchLabel
bind:name={branch.name}
on:change={handleBranchNameChange}
disabled={readonly}
/>
</div>
<div class="header__remote-branch">
{#if !branch.upstream}
{#if !branch.active}
<Tooltip label="These changes are stashed away. Apply the lane to bring them back.">
<div class="status-tag text-base-11 text-semibold unapplied">
<Icon name="removed-branch-small" /> unapplied
</div>
</Tooltip>
{:else if hasIntegratedCommits}
<Tooltip
label="These changes have been integrated upstream, update your applied branches to make this lane disappear."
>
<div class="status-tag text-base-11 text-semibold integrated">
<Icon name="removed-branch-small" /> integrated
</div>
</Tooltip>
{:else}
<Tooltip label="These changes are in a virtual branch.">
<div class="status-tag text-base-11 text-semibold pending">
<Icon name="virtual-branch-small" /> virtual
</div>
</Tooltip>
{/if}
{#if !readonly}
<div class="pending-name">
<Tooltip
label="Branch name that will be used when pushing. You can change it from the lane menu."
>
<span class="text-base-11 text-semibold">
origin/{branch.upstreamName
? branch.upstreamName
: normalizeBranchName(branch.name)}
</span>
</Tooltip>
</div>
{/if}
{:else}
<Tooltip label="At least some of your changes have been pushed">
<div class="status-tag text-base-11 text-semibold remote">
<Icon name="remote-branch-small" /> remote
</div>
</Tooltip>
<Tag
icon="open-link"
color="ghost"
border
clickable
shrinkable
on:click={(e) => {
const url = branchUrl(base, branch.upstream?.name);
if (url) open(url);
e.preventDefault();
e.stopPropagation();
}}
>
origin/{branch.upstreamName}
</Tag>
{#if $pr$?.htmlUrl}
<Tag
icon="pr-small"
color="ghost"
border
clickable
on:click={(e) => {
const url = $pr$?.htmlUrl;
if (url) open(url);
e.preventDefault();
e.stopPropagation();
}}
>
View PR
</Tag>
{/if}
{/if}
{#await branch.isMergeable then isMergeable}
{#if !isMergeable}
<Tooltip
timeoutMilliseconds={100}
label="Applying this branch will add merge conflict markers that you will have to resolve"
>
<Tag icon="locked-small" color="warning">Conflict</Tag>
</Tooltip>
{/if}
{/await}
</div>
<div class="draggable" data-drag-handle>
<Icon name="draggable-narrow" />
</div>
</div>
<div class="header__actions">
<div class="header__buttons">
{#if !readonly}
<div class="draggable" data-drag-handle>
<Icon name="draggable-narrow" />
</div>
{/if}
{#if branch.selectedForChanges}
<Button icon="target" notClickable>Target branch</Button>
<Button icon="target" notClickable disabled={readonly}>Target branch</Button>
{:else}
<Button
icon="target"
kind="outlined"
color="neutral"
disabled={readonly}
on:click={async () => {
await branchController.setSelectedForChanges(branch.id);
}}
@ -60,106 +155,88 @@
Make target
</Button>
{/if}
</div>
<div class="header__buttons">
<div class="relative" bind:this={meatballButton}>
{#if !readonly}
<Button
icon="kebab"
icon="cross-small"
color="primary"
kind="outlined"
color="neutral"
on:click={() => (visible = !visible)}
/>
<div
class="branch-popup-menu"
use:clickOutside={{
trigger: meatballButton,
handler: () => (visible = false)
loading={isApplying}
on:click={async () => {
isApplying = true;
try {
await branchController.unapplyBranch(branch.id);
} catch (e) {
const err = 'Failed to apply branch';
toast.error(err);
console.error(err, e);
} finally {
isApplying = false;
}
}}
>
<BranchLanePopupMenu {branchController} {branch} {projectId} bind:visible on:action />
</div>
</div>
</div>
</div>
{/if}
<div class="header__info">
<div class="header__label">
<BranchLabel bind:name={branch.name} on:change={handleBranchNameChange} />
</div>
<div class="header__remote-branch">
{#if !branch.upstream}
{#if hasIntegratedCommits}
<div class="status-tag text-base-11 text-semibold integrated">
<Icon name="pr-small" /> integrated
</div>
Unapply
</Button>
{:else}
<div class="status-tag text-base-11 text-semibold pending">
<Icon name="virtual-branch-small" /> virtual
</div>
{/if}
<div class="pending-name">
<span class="text-base-11 text-semibold"
>origin/{branch.upstreamName
? branch.upstreamName
: normalizeBranchName(branch.name)}</span
<Button
icon="plus-small"
color="primary"
kind="outlined"
loading={isApplying}
on:click={async () => {
isApplying = true;
try {
await branchController.applyBranch(branch.id);
} catch (e) {
const err = 'Failed to apply branch';
toast.error(err);
console.error(err, e);
} finally {
isApplying = false;
}
}}
>
</div>
{:else}
<div class="status-tag text-base-11 text-semibold remote">
<Icon name="remote-branch-small" /> remote
</div>
<Tag
icon="open-link"
color="ghost"
border
clickable
shrinkable
on:click={(e) => {
const url = branchUrl(base, branch.upstream?.name);
if (url) open(url);
e.preventDefault();
e.stopPropagation();
Apply
</Button>
{/if}
</div>
<div class="relative" bind:this={meatballButton}>
<Button
icon="kebab"
kind="outlined"
color="neutral"
on:click={() => (visible = !visible)}
/>
<div
class="branch-popup-menu"
use:clickOutside={{
trigger: meatballButton,
handler: () => (visible = false)
}}
>
origin/{branch.upstreamName}
</Tag>
{#if $pr$?.htmlUrl}
<Tag
icon="pr-small"
color="ghost"
border
clickable
on:click={(e) => {
const url = $pr$?.htmlUrl;
if (url) open(url);
e.preventDefault();
e.stopPropagation();
}}
>
View PR
</Tag>
{/if}
{/if}
<BranchLanePopupMenu
{branchController}
{branch}
{projectId}
{readonly}
bind:visible
on:action
/>
</div>
</div>
</div>
</div>
<div class="header__top-overlay" data-remove-from-draggable data-tauri-drag-region />
</div>
<!-- <div class="header__top-overlay" data-remove-from-draggable data-tauri-drag-region />
</div> -->
<style lang="postcss">
.header__wrapper {
z-index: 10;
position: sticky;
top: var(--space-8);
top: var(--space-16);
}
.header {
z-index: 2;
position: relative;
user-select: none;
flex-direction: column;
gap: var(--space-2);
@ -168,6 +245,9 @@
opacity: 1;
}
}
&.readonly {
background: var(--clr-theme-container-pale);
}
}
.header__top-overlay {
z-index: 1;
@ -176,15 +256,14 @@
left: 0;
width: 100%;
height: var(--space-20);
background: var(--target-branch-background);
background: var(--clr-theme-container-pale);
/* background-color: red; */
}
.header__info {
position: relative;
display: flex;
flex-direction: column;
transition: margin var(--transition-slow);
padding: var(--space-12) var(--space-12) var(--space-16) var(--space-12);
padding: var(--space-12);
gap: var(--space-10);
}
.header__actions {
@ -193,11 +272,14 @@
background: var(--clr-theme-container-pale);
padding: var(--space-12);
justify-content: space-between;
border-radius: var(--radius-m) var(--radius-m) 0 0;
border-radius: 0 0 var(--radius-m) var(--radius-m);
user-select: none;
}
.readonly .header__actions {
background: var(--clr-theme-container-dim);
}
.header__buttons {
display: flex;
align-items: center;
position: relative;
gap: var(--space-4);
}
@ -208,11 +290,10 @@
gap: var(--space-4);
}
.draggable {
/* position: absolute; */
/* right: var(--space-4);
top: var(--space-6); */
/* opacity: 0; */
margin-right: var(--space-4);
position: absolute;
right: var(--space-4);
top: var(--space-6);
opacity: 0;
display: flex;
cursor: grab;
color: var(--clr-theme-scale-ntrl-50);
@ -291,4 +372,9 @@
color: var(--clr-theme-scale-ntrl-100);
background: var(--clr-theme-scale-ntrl-40);
}
.unapplied {
color: var(--clr-theme-scale-ntrl-30);
background: var(--clr-theme-scale-ntrl-80);
}
</style>

View File

@ -1,10 +1,12 @@
<script lang="ts">
export let name: string;
export let disabled = false;
let inputActive = false;
let label: HTMLDivElement;
let input: HTMLInputElement;
function activateInput() {
if (disabled) return;
inputActive = true;
setTimeout(() => input.select(), 0);
}
@ -25,6 +27,7 @@
<span class="branch-name-mesure-el text-base-13" bind:this={mesureEl}>{name}</span>
<input
type="text"
{disabled}
bind:this={input}
bind:value={name}
on:change

View File

@ -49,26 +49,25 @@
{user}
{selectedFiles}
{githubService}
>
<svelte:fragment slot="file-view">
{#if selected}
<FileCard
conflicted={selected.conflicted}
branchId={branch.id}
file={selected}
projectId={project.id}
{projectPath}
{branchController}
{selectedOwnership}
selectable={$commitBoxOpen && !readonly}
on:close={() => {
const selectedId = selected?.id;
selectedFiles.update((fileIds) => fileIds.filter((file) => file.id != selectedId));
}}
/>
{/if}
</svelte:fragment>
</BranchCard>
/>
{#if selected}
<FileCard
conflicted={selected.conflicted}
branchId={branch.id}
file={selected}
projectId={project.id}
{projectPath}
{branchController}
{selectedOwnership}
{readonly}
selectable={$commitBoxOpen && !readonly}
on:close={() => {
const selectedId = selected?.id;
selectedFiles.update((fileIds) => fileIds.filter((file) => file.id != selectedId));
}}
/>
{/if}
</div>
<style lang="postcss">

View File

@ -14,6 +14,7 @@
export let branch: Branch;
export let projectId: string;
export let visible: boolean;
export let readonly = false;
let deleteBranchModal: Modal;
let renameRemoteModal: Modal;
@ -33,10 +34,15 @@
{#if visible}
<ContextMenu>
<ContextMenuSection>
<ContextMenuItem
label="Unapply"
on:click={() => branch.id && branchController.unapplyBranch(branch.id)}
/>
{#if !readonly}
<ContextMenuItem
label="Unapply"
on:click={() => {
if (branch.id) branchController.unapplyBranch(branch.id);
visible = false;
}}
/>
{/if}
<ContextMenuItem
label="Delete"
@ -52,13 +58,13 @@
dispatch('action', 'generate-branch-name');
visible = false;
}}
disabled={!$aiGenEnabled || branch.files?.length == 0 || !branch.active}
disabled={readonly || !$aiGenEnabled || branch.files?.length == 0 || !branch.active}
/>
</ContextMenuSection>
<ContextMenuSection>
<ContextMenuItem
label="Set branch name"
disabled={hasIntegratedCommits}
disabled={readonly || hasIntegratedCommits}
on:click={() => {
newRemoteName = branch.upstreamName || '';
visible = false;

View File

@ -27,7 +27,7 @@
export let conflicted: boolean;
export let projectPath: string | undefined;
export let branchController: BranchController;
export let readonly = false;
export let readonly: boolean;
export let selectable = false;
export let selectedOwnership: Writable<Ownership>;