Saving changes

This commit is contained in:
Sampo Kivistö 2026-01-23 21:32:16 +02:00
parent 82f12f0af3
commit 024dd01d41
No known key found for this signature in database
GPG key ID: 3B426F446F481CFF
48 changed files with 2440 additions and 901 deletions

1038
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,14 @@ Run (opens the repo passed as the first arg, or falls back to the current direct
cargo run -p gitgpui-app --features ui-gpui,gix -- /path/to/repo
```
### Profiling (Callgrind)
To profile the app with Valgrind Callgrind (interactive on/off instrumentation):
```bash
bash scripts/profile-callgrind.sh --open -- /path/to/repo
```
### Crash logs
If the app crashes due to a Rust panic, GitGpui writes a crash log to:

43
assets/gitgpui_logo.svg Normal file
View file

@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
<title>gitgpui</title>
<defs>
<linearGradient id="bg" x1="64" y1="48" x2="464" y2="480" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8B5CF6" />
<stop offset="0.52" stop-color="#6D28D9" />
<stop offset="1" stop-color="#3B0764" />
</linearGradient>
<radialGradient id="hl" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="translate(160 120) rotate(55) scale(420)">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.35" />
<stop offset="0.45" stop-color="#FFFFFF" stop-opacity="0.12" />
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
</radialGradient>
<radialGradient id="shade" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="translate(420 420) rotate(225) scale(460)">
<stop offset="0" stop-color="#000000" stop-opacity="0.35" />
<stop offset="0.55" stop-color="#000000" stop-opacity="0.10" />
<stop offset="1" stop-color="#000000" stop-opacity="0" />
</radialGradient>
<filter id="markShadow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="14" stdDeviation="14" flood-color="#000000" flood-opacity="0.35" />
</filter>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)" />
<rect width="512" height="512" rx="112" fill="url(#hl)" />
<rect width="512" height="512" rx="112" fill="url(#shade)" />
<rect x="18" y="18" width="476" height="476" rx="104" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="6" />
<g filter="url(#markShadow)">
<path
d="M196 188V324M196 236C250 236 256 176 352 176"
stroke="#FFFFFF"
stroke-width="44"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="196" cy="144" r="46" fill="#FFFFFF" />
<circle cx="352" cy="176" r="46" fill="#FFFFFF" />
<circle cx="196" cy="368" r="46" fill="#FFFFFF" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<rect width="16" height="16" rx="3.5" fill="#6D28D9" />
<path
d="M6 6v4M6 8c1.6 0 1.8-1.8 4.2-1.8"
stroke="#FFFFFF"
stroke-width="1.4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="6" cy="4.5" r="1.4" fill="#FFFFFF" />
<circle cx="10.2" cy="6.2" r="1.4" fill="#FFFFFF" />
<circle cx="6" cy="11.5" r="1.4" fill="#FFFFFF" />
</svg>

After

Width:  |  Height:  |  Size: 451 B

View file

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=GitGpui
Comment=Git UI built with GPUI
Exec=gitgpui-app
Icon=gitgpui
StartupWMClass=gitgpui
Terminal=false
Categories=Development;VersionControl;

View file

@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
<title>gitgpui</title>
<defs>
<linearGradient id="bg" x1="64" y1="48" x2="464" y2="480" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8B5CF6" />
<stop offset="0.52" stop-color="#6D28D9" />
<stop offset="1" stop-color="#3B0764" />
</linearGradient>
<radialGradient id="hl" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="translate(160 120) rotate(55) scale(420)">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.35" />
<stop offset="0.45" stop-color="#FFFFFF" stop-opacity="0.12" />
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
</radialGradient>
<radialGradient id="shade" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="translate(420 420) rotate(225) scale(460)">
<stop offset="0" stop-color="#000000" stop-opacity="0.35" />
<stop offset="0.55" stop-color="#000000" stop-opacity="0.10" />
<stop offset="1" stop-color="#000000" stop-opacity="0" />
</radialGradient>
<filter id="markShadow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="14" stdDeviation="14" flood-color="#000000" flood-opacity="0.35" />
</filter>
</defs>
<rect width="512" height="512" rx="112" fill="url(#bg)" />
<rect width="512" height="512" rx="112" fill="url(#hl)" />
<rect width="512" height="512" rx="112" fill="url(#shade)" />
<rect x="18" y="18" width="476" height="476" rx="104" stroke="#FFFFFF" stroke-opacity="0.18" stroke-width="6" />
<g filter="url(#markShadow)">
<path
d="M196 188V324M196 236C250 236 256 176 352 176"
stroke="#FFFFFF"
stroke-width="44"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="196" cy="144" r="46" fill="#FFFFFF" />
<circle cx="352" cy="176" r="46" fill="#FFFFFF" />
<circle cx="196" cy="368" r="46" fill="#FFFFFF" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -86,6 +86,11 @@ pub trait GitRepository: Send + Sync {
fn create_branch(&self, name: &str, target: &CommitId) -> Result<()>;
fn delete_branch(&self, name: &str) -> Result<()>;
fn checkout_branch(&self, name: &str) -> Result<()>;
fn checkout_remote_branch(&self, _remote: &str, _branch: &str) -> Result<()> {
Err(Error::new(ErrorKind::Unsupported(
"remote branch checkout is not implemented for this backend",
)))
}
fn checkout_commit(&self, id: &CommitId) -> Result<()>;
fn cherry_pick(&self, id: &CommitId) -> Result<()>;
fn revert(&self, id: &CommitId) -> Result<()>;
@ -101,6 +106,11 @@ pub trait GitRepository: Send + Sync {
fn fetch_all(&self) -> Result<()>;
fn pull(&self, mode: PullMode) -> Result<()>;
fn push(&self) -> Result<()>;
fn push_set_upstream(&self, _remote: &str, _branch: &str) -> Result<()> {
Err(Error::new(ErrorKind::Unsupported(
"pushing with --set-upstream is not implemented for this backend",
)))
}
fn fetch_all_with_output(&self) -> Result<CommandOutput> {
self.fetch_all()?;
@ -117,6 +127,13 @@ pub trait GitRepository: Send + Sync {
Ok(CommandOutput::empty_success("git push"))
}
fn push_set_upstream_with_output(&self, remote: &str, branch: &str) -> Result<CommandOutput> {
self.push_set_upstream(remote, branch)?;
Ok(CommandOutput::empty_success(format!(
"git push --set-upstream {remote} HEAD:refs/heads/{branch}"
)))
}
fn pull_branch_with_output(&self, _remote: &str, _branch: &str) -> Result<CommandOutput> {
Err(Error::new(ErrorKind::Unsupported(
"pulling a specific remote branch is not implemented for this backend",

View file

@ -10,8 +10,8 @@ workspace = true
[dependencies]
gitgpui-core = { path = "../gitgpui-core" }
gix = "0.77"
gix-diff = "0.57"
gix = "0.78"
gix-diff = "0.58"
[dev-dependencies]
tempfile = "3.24.0"

View file

@ -165,6 +165,21 @@ impl GitRepository for GixRepo {
}
}
// Remote tracking branches (often where "other branches" live)
let branches = refs
.remote_branches()
.map_err(|e| Error::new(ErrorKind::Backend(format!("gix remote_branches: {e}"))))?
.peeled()
.map_err(|e| Error::new(ErrorKind::Backend(format!("gix peel refs: {e}"))))?;
for reference in branches {
let reference = reference
.map_err(|e| Error::new(ErrorKind::Backend(format!("gix ref iter: {e}"))))?;
let id = reference.id().detach();
if id != head_id && !tips.iter().any(|t| *t == id) {
tips.push(id);
}
}
let mut walk = repo
.rev_walk(tips)
.sorting(gix::revision::walk::Sorting::ByCommitTime(
@ -855,6 +870,54 @@ impl GitRepository for GixRepo {
run_git_simple(cmd, "git checkout")
}
fn checkout_remote_branch(&self, remote: &str, branch: &str) -> Result<()> {
let upstream = format!("{remote}/{branch}");
let output = Command::new("git")
.arg("-C")
.arg(&self.spec.workdir)
.arg("checkout")
.arg("--track")
.arg("-b")
.arg(branch)
.arg(&upstream)
.output()
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let already_exists =
stderr.contains("already exists") || stderr.contains("fatal: a branch named");
if !already_exists {
return Err(Error::new(ErrorKind::Backend(format!(
"git checkout --track failed: {}",
stderr.trim()
))));
}
// If the local branch already exists, check it out and update its upstream.
let mut checkout = Command::new("git");
checkout
.arg("-C")
.arg(&self.spec.workdir)
.arg("checkout")
.arg(branch);
run_git_simple(checkout, "git checkout")?;
let mut set_upstream = Command::new("git");
set_upstream
.arg("-C")
.arg(&self.spec.workdir)
.arg("branch")
.arg(format!("--set-upstream-to={upstream}"))
.arg(branch);
run_git_simple(set_upstream, "git branch --set-upstream-to")
}
fn checkout_commit(&self, id: &CommitId) -> Result<()> {
let mut cmd = Command::new("git");
cmd.arg("-C")
@ -1095,6 +1158,34 @@ impl GitRepository for GixRepo {
run_git_with_output(cmd, "git push")
}
fn push_set_upstream(&self, remote: &str, branch: &str) -> Result<()> {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(&self.spec.workdir)
.arg("push")
.arg("--set-upstream")
.arg(remote)
.arg(format!("HEAD:refs/heads/{branch}"));
run_git_simple(
cmd,
&format!("git push --set-upstream {remote} HEAD:refs/heads/{branch}"),
)
}
fn push_set_upstream_with_output(&self, remote: &str, branch: &str) -> Result<CommandOutput> {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(&self.spec.workdir)
.arg("push")
.arg("--set-upstream")
.arg(remote)
.arg(format!("HEAD:refs/heads/{branch}"));
run_git_with_output(
cmd,
&format!("git push --set-upstream {remote} HEAD:refs/heads/{branch}"),
)
}
fn blame_file(&self, path: &Path, rev: Option<&str>) -> Result<Vec<BlameLine>> {
let mut cmd = Command::new("git");
cmd.arg("-C")
@ -1229,8 +1320,7 @@ fn run_git_simple(mut cmd: Command, label: &str) -> Result<()> {
.output()
.map_err(|e| Error::new(ErrorKind::Io(e.kind())))?;
let ok_exit = output.status.success() || output.status.code() == Some(1);
if !ok_exit {
if !output.status.success() {
let stderr = str::from_utf8(&output.stderr).unwrap_or("<non-utf8 stderr>");
return Err(Error::new(ErrorKind::Backend(format!(
"{label} failed: {stderr}"
@ -1249,8 +1339,7 @@ fn run_git_with_output(mut cmd: Command, label: &str) -> Result<CommandOutput> {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let ok_exit = output.status.success() || output.status.code() == Some(1);
if !ok_exit {
if !output.status.success() {
return Err(Error::new(ErrorKind::Backend(format!(
"{label} failed: {}",
stderr.trim()

View file

@ -0,0 +1,72 @@
use gitgpui_core::services::GitBackend;
use gitgpui_git_gix::GixBackend;
use std::path::Path;
use std::process::Command;
fn run_git(repo: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.status()
.expect("git command to run");
assert!(status.success(), "git {:?} failed", args);
}
#[test]
fn log_all_branches_includes_remote_tracking_branches() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
std::fs::create_dir_all(&repo).unwrap();
run_git(&repo, &["init", "-b", "main"]);
run_git(&repo, &["config", "user.email", "you@example.com"]);
run_git(&repo, &["config", "user.name", "You"]);
run_git(&repo, &["config", "commit.gpgsign", "false"]);
std::fs::write(repo.join("a.txt"), "one\n").unwrap();
run_git(&repo, &["add", "a.txt"]);
run_git(&repo, &["-c", "commit.gpgsign=false", "commit", "-m", "A"]);
run_git(&repo, &["checkout", "-b", "feature"]);
std::fs::write(repo.join("b.txt"), "two\n").unwrap();
run_git(&repo, &["add", "b.txt"]);
run_git(&repo, &["-c", "commit.gpgsign=false", "commit", "-m", "C"]);
let feature_tip = {
let out = Command::new("git")
.arg("-C")
.arg(&repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("git rev-parse to run");
assert!(out.status.success());
String::from_utf8(out.stdout).unwrap().trim().to_string()
};
run_git(
dir.path(),
&["init", "--bare", "-b", "main", origin.to_str().unwrap()],
);
run_git(&repo, &["remote", "add", "origin", origin.to_str().unwrap()]);
run_git(&repo, &["push", "-u", "origin", "feature"]);
run_git(&repo, &["checkout", "main"]);
run_git(&repo, &["branch", "-D", "feature"]);
run_git(&repo, &["fetch", "origin"]);
let backend = GixBackend::default();
let opened = backend.open(&repo).unwrap();
let head = opened.log_head_page(200, None).unwrap();
assert!(
!head.commits.iter().any(|c| c.id.0 == feature_tip),
"head log unexpectedly contains feature commit"
);
let all = opened.log_all_branches_page(200, None).unwrap();
assert!(
all.commits.iter().any(|c| c.id.0 == feature_tip),
"all-branches log should include remote-tracking branch commit"
);
}

View file

@ -14,6 +14,7 @@ pub enum RepoCommandKind {
PullBranch { remote: String, branch: String },
MergeRef { reference: String },
Push,
PushSetUpstream { remote: String, branch: String },
CheckoutConflict { path: PathBuf, side: ConflictSide },
}
@ -63,6 +64,11 @@ pub enum Msg {
repo_id: RepoId,
name: String,
},
CheckoutRemoteBranch {
repo_id: RepoId,
remote: String,
name: String,
},
CheckoutCommit {
repo_id: RepoId,
commit_id: CommitId,
@ -118,6 +124,11 @@ pub enum Msg {
Push {
repo_id: RepoId,
},
PushSetUpstream {
repo_id: RepoId,
remote: String,
branch: String,
},
CheckoutConflictSide {
repo_id: RepoId,
path: PathBuf,
@ -287,6 +298,16 @@ impl std::fmt::Debug for Msg {
.field("repo_id", repo_id)
.field("name", name)
.finish(),
Msg::CheckoutRemoteBranch {
repo_id,
remote,
name,
} => f
.debug_struct("CheckoutRemoteBranch")
.field("repo_id", repo_id)
.field("remote", remote)
.field("name", name)
.finish(),
Msg::CheckoutCommit { repo_id, commit_id } => f
.debug_struct("CheckoutCommit")
.field("repo_id", repo_id)
@ -357,6 +378,16 @@ impl std::fmt::Debug for Msg {
.field("reference", reference)
.finish(),
Msg::Push { repo_id } => f.debug_struct("Push").field("repo_id", repo_id).finish(),
Msg::PushSetUpstream {
repo_id,
remote,
branch,
} => f
.debug_struct("PushSetUpstream")
.field("repo_id", repo_id)
.field("remote", remote)
.field("branch", branch)
.finish(),
Msg::CheckoutConflictSide {
repo_id,
path,
@ -570,6 +601,11 @@ pub enum Effect {
repo_id: RepoId,
name: String,
},
CheckoutRemoteBranch {
repo_id: RepoId,
remote: String,
name: String,
},
CheckoutCommit {
repo_id: RepoId,
commit_id: CommitId,
@ -625,6 +661,11 @@ pub enum Effect {
Push {
repo_id: RepoId,
},
PushSetUpstream {
repo_id: RepoId,
remote: String,
branch: String,
},
CheckoutConflictSide {
repo_id: RepoId,
path: PathBuf,

View file

@ -218,6 +218,21 @@ pub(super) fn schedule_effect(
}
}
Effect::CheckoutRemoteBranch {
repo_id,
remote,
name,
} => {
if let Some(repo) = repos.get(&repo_id).cloned() {
executor.spawn(move || {
let _ = msg_tx.send(Msg::RepoActionFinished {
repo_id,
result: repo.checkout_remote_branch(&remote, &name),
});
});
}
}
Effect::CheckoutCommit { repo_id, commit_id } => {
if let Some(repo) = repos.get(&repo_id).cloned() {
executor.spawn(move || {
@ -397,6 +412,25 @@ pub(super) fn schedule_effect(
}
}
Effect::PushSetUpstream {
repo_id,
remote,
branch,
} => {
if let Some(repo) = repos.get(&repo_id).cloned() {
executor.spawn(move || {
let _ = msg_tx.send(Msg::RepoCommandFinished {
repo_id,
command: crate::msg::RepoCommandKind::PushSetUpstream {
remote: remote.clone(),
branch: branch.clone(),
},
result: repo.push_set_upstream_with_output(&remote, &branch),
});
});
}
}
Effect::CheckoutConflictSide {
repo_id,
path,

View file

@ -289,6 +289,15 @@ pub(super) fn reduce(
}
Msg::CheckoutBranch { repo_id, name } => vec![Effect::CheckoutBranch { repo_id, name }],
Msg::CheckoutRemoteBranch {
repo_id,
remote,
name,
} => vec![Effect::CheckoutRemoteBranch {
repo_id,
remote,
name,
}],
Msg::CheckoutCommit { repo_id, commit_id } => {
vec![Effect::CheckoutCommit { repo_id, commit_id }]
}
@ -317,6 +326,15 @@ pub(super) fn reduce(
}],
Msg::MergeRef { repo_id, reference } => vec![Effect::MergeRef { repo_id, reference }],
Msg::Push { repo_id } => vec![Effect::Push { repo_id }],
Msg::PushSetUpstream {
repo_id,
remote,
branch,
} => vec![Effect::PushSetUpstream {
repo_id,
remote,
branch,
}],
Msg::CheckoutConflictSide {
repo_id,
path,
@ -484,9 +502,7 @@ pub(super) fn reduce(
match result {
Ok(mut page) => {
if loading_more
&& let Loadable::Ready(existing) = &mut repo_state.log
{
if loading_more && let Loadable::Ready(existing) = &mut repo_state.log {
existing.commits.extend(page.commits.drain(..));
existing.next_cursor = page.next_cursor;
} else {
@ -823,6 +839,7 @@ fn summarize_command(
RepoCommandKind::PullBranch { .. } => "Pull",
RepoCommandKind::MergeRef { .. } => "Merge",
RepoCommandKind::Push => "Push",
RepoCommandKind::PushSetUpstream { .. } => "Push",
RepoCommandKind::CheckoutConflict { side, .. } => match side {
ConflictSide::Ours => "Checkout ours",
ConflictSide::Theirs => "Checkout theirs",
@ -890,6 +907,14 @@ fn summarize_command(
"Push: Completed".to_string()
}
}
RepoCommandKind::PushSetUpstream { remote, branch } => {
let base = if output.stderr.contains("Everything up-to-date") {
"Everything up-to-date"
} else {
"Completed"
};
format!("Push -u {remote}/{branch}: {base}")
}
RepoCommandKind::CheckoutConflict { side, .. } => match side {
ConflictSide::Ours => "Resolved using ours".to_string(),
ConflictSide::Theirs => "Resolved using theirs".to_string(),

View file

@ -889,6 +889,24 @@ fn repo_operations_emit_effects() {
[Effect::Push { repo_id: RepoId(1) }]
));
let push_set_upstream = reduce(
&mut repos,
&id_alloc,
&mut state,
Msg::PushSetUpstream {
repo_id: RepoId(1),
remote: "origin".to_string(),
branch: "feature/foo".to_string(),
},
);
assert!(matches!(
push_set_upstream.as_slice(),
[Effect::PushSetUpstream {
repo_id: RepoId(1),
..
}]
));
let stash = reduce(
&mut repos,
&id_alloc,

View file

@ -15,24 +15,20 @@ gitgpui-ui = { path = "../gitgpui-ui" }
gpui = "0.2.2"
tree-sitter = "0.26"
tree-sitter-bash = "0.25.1"
tree-sitter-css = "0.23.2"
tree-sitter-go = "0.23"
tree-sitter-css = "0.25.0"
tree-sitter-go = "0.25"
tree-sitter-html = "0.23.2"
tree-sitter-json = "0.24"
tree-sitter-python = "0.25"
tree-sitter-rust = "0.24"
tree-sitter-typescript = "0.23.2"
tree-sitter-yaml = "0.6.1"
tree-sitter-yaml = "0.7.2"
unicode-segmentation = "1.12.0"
[dev-dependencies]
gpui = { version = "0.2.2", features = ["test-support"] }
criterion = "0.5.1"
[features]
bench = []
criterion = "0.8.1"
[[bench]]
name = "performance"
harness = false
required-features = ["bench"]

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
<path
d="M196 188V324M196 236C250 236 256 176 352 176"
stroke="currentColor"
stroke-width="44"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="196" cy="144" r="46" fill="currentColor" />
<circle cx="352" cy="176" r="46" fill="currentColor" />
<circle cx="196" cy="368" r="46" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 450 B

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.5H13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<path d="M3 8H13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<path d="M3 11.5H13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 369 B

View file

@ -1,6 +1,9 @@
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use gitgpui_ui_gpui::benchmarks::{CommitDetailsFixture, LargeFileDiffScrollFixture, OpenRepoFixture};
use gitgpui_ui_gpui::benchmarks::{
CommitDetailsFixture, LargeFileDiffScrollFixture, OpenRepoFixture,
};
use std::env;
use std::time::Duration;
fn env_usize(key: &str, default: usize) -> usize {
env::var(key)
@ -10,14 +13,18 @@ fn env_usize(key: &str, default: usize) -> usize {
}
fn bench_open_repo(c: &mut Criterion) {
let commits = env_usize("GITGPUI_BENCH_COMMITS", 100_000);
let local_branches = env_usize("GITGPUI_BENCH_LOCAL_BRANCHES", 5_000);
let remote_branches = env_usize("GITGPUI_BENCH_REMOTE_BRANCHES", 20_000);
let remotes = env_usize("GITGPUI_BENCH_REMOTES", 3);
// Note: Criterion's "Warming up for Xs" can look "stuck" if a single iteration takes longer
// than the warm-up duration. Keep defaults moderate; scale up via env vars for stress runs.
let commits = env_usize("GITGPUI_BENCH_COMMITS", 5_000);
let local_branches = env_usize("GITGPUI_BENCH_LOCAL_BRANCHES", 200);
let remote_branches = env_usize("GITGPUI_BENCH_REMOTE_BRANCHES", 800);
let remotes = env_usize("GITGPUI_BENCH_REMOTES", 2);
let fixture = OpenRepoFixture::new(commits, local_branches, remote_branches, remotes);
let mut group = c.benchmark_group("open_repo");
group.sample_size(10);
group.warm_up_time(Duration::from_secs(1));
group.bench_with_input(
BenchmarkId::new("long_history_and_branches", commits),
&commits,
@ -27,25 +34,27 @@ fn bench_open_repo(c: &mut Criterion) {
}
fn bench_commit_details(c: &mut Criterion) {
let files = env_usize("GITGPUI_BENCH_COMMIT_FILES", 50_000);
let files = env_usize("GITGPUI_BENCH_COMMIT_FILES", 5_000);
let depth = env_usize("GITGPUI_BENCH_COMMIT_PATH_DEPTH", 4);
let fixture = CommitDetailsFixture::new(files, depth);
let mut group = c.benchmark_group("commit_details");
group.bench_with_input(
BenchmarkId::new("many_files", files),
&files,
|b, _| b.iter(|| fixture.run()),
);
group.sample_size(10);
group.warm_up_time(Duration::from_secs(1));
group.bench_with_input(BenchmarkId::new("many_files", files), &files, |b, _| {
b.iter(|| fixture.run())
});
group.finish();
}
fn bench_large_file_diff_scroll(c: &mut Criterion) {
let lines = env_usize("GITGPUI_BENCH_DIFF_LINES", 100_000);
let lines = env_usize("GITGPUI_BENCH_DIFF_LINES", 10_000);
let window = env_usize("GITGPUI_BENCH_DIFF_WINDOW", 200);
let fixture = LargeFileDiffScrollFixture::new(lines);
let mut group = c.benchmark_group("diff_scroll");
group.sample_size(10);
group.warm_up_time(Duration::from_secs(1));
group.bench_with_input(
BenchmarkId::new("style_window", window),
&window,

View file

@ -74,6 +74,7 @@ pub fn run(backend: Arc<dyn GitBackend>) {
appears_transparent: true,
traffic_light_position: Some(point(px(9.0), px(9.0))),
}),
app_id: Some("gitgpui".to_string()),
window_decorations: Some(WindowDecorations::Client),
is_movable: true,
is_resizable: true,

View file

@ -6,6 +6,12 @@ pub struct GitGpuiAssets;
impl GitGpuiAssets {
fn load_static(path: &str) -> Option<Cow<'static, [u8]>> {
match path {
"gitgpui_logo.svg" => Some(Cow::Borrowed(include_bytes!(
"../../../assets/gitgpui_logo.svg"
))),
"gitgpui_logo_window.svg" => Some(Cow::Borrowed(include_bytes!(
"../../../assets/gitgpui_logo_window.svg"
))),
"icons/arrow_down.svg" => Some(Cow::Borrowed(include_bytes!(
"../assets/icons/arrow_down.svg"
))),
@ -36,13 +42,21 @@ impl GitGpuiAssets {
"icons/git_branch.svg" => Some(Cow::Borrowed(include_bytes!(
"../assets/icons/git_branch.svg"
))),
"icons/gitgpui_mark.svg" => Some(Cow::Borrowed(include_bytes!(
"../assets/icons/gitgpui_mark.svg"
))),
"icons/menu.svg" => Some(Cow::Borrowed(include_bytes!("../assets/icons/menu.svg"))),
_ => None,
}
}
fn list_static(dir: &str) -> Vec<SharedString> {
match dir.trim_end_matches('/') {
"" => vec!["icons".into()],
"" => vec![
"gitgpui_logo.svg".into(),
"gitgpui_logo_window.svg".into(),
"icons".into(),
],
"icons" => vec![
"icons/arrow_down.svg".into(),
"icons/arrow_up.svg".into(),
@ -56,6 +70,8 @@ impl GitGpuiAssets {
"icons/generic_restore.svg".into(),
"icons/generic_close.svg".into(),
"icons/git_branch.svg".into(),
"icons/gitgpui_mark.svg".into(),
"icons/menu.svg".into(),
],
_ => vec![],
}

View file

@ -32,29 +32,63 @@ actions!(
#[derive(Clone, Copy, Debug)]
struct TextInputStyle {
is_dark: bool,
background: Rgba,
border: Rgba,
hover_border: Rgba,
focus_border: Rgba,
radius: f32,
text: gpui::Hsla,
placeholder: gpui::Hsla,
cursor: Rgba,
selection: Rgba,
}
impl TextInputStyle {
fn from_theme(theme: AppTheme) -> Self {
fn mix(mut a: Rgba, b: Rgba, t: f32) -> Rgba {
let t = t.clamp(0.0, 1.0);
a.r = a.r + (b.r - a.r) * t;
a.g = a.g + (b.g - a.g) * t;
a.b = a.b + (b.b - a.b) * t;
a.a = a.a + (b.a - a.a) * t;
a
}
// Ensure inputs look like inputs even in themes where `surface_bg` and `surface_bg_elevated`
// are equal (Ayu/One).
let background = if theme.is_dark {
mix(
theme.colors.surface_bg_elevated,
gpui::rgba(0xFFFFFFFF),
0.03,
)
} else {
mix(
theme.colors.surface_bg_elevated,
gpui::rgba(0x000000FF),
0.03,
)
};
let base_border = theme.colors.border;
let hover_border = with_alpha(
theme.colors.text_muted,
if theme.is_dark { 0.55 } else { 0.40 },
);
let focus_border = with_alpha(theme.colors.accent, if theme.is_dark { 0.98 } else { 0.92 });
let placeholder = if theme.is_dark {
hsla(0., 0., 1., 0.35)
} else {
hsla(0., 0., 0., 0.2)
};
Self {
is_dark: theme.is_dark,
background: theme.colors.surface_bg_elevated,
border: theme.colors.border,
background,
border: base_border,
hover_border,
focus_border: theme.colors.focus_ring,
focus_border,
radius: theme.radii.row,
text: theme.colors.text.into(),
placeholder,
cursor: with_alpha(theme.colors.text, if theme.is_dark { 0.78 } else { 0.62 }),
selection: with_alpha(theme.colors.accent, if theme.is_dark { 0.28 } else { 0.18 }),
}
@ -780,16 +814,10 @@ impl Element for TextElement {
let soft_wrap = input.soft_wrap && input.multiline;
let style = window.text_style();
let placeholder_color = if style_colors.is_dark {
hsla(0., 0., 1., 0.35)
} else {
hsla(0., 0., 0., 0.2)
};
let (display_text, text_color) = if content.is_empty() {
(input.placeholder.clone(), placeholder_color)
(input.placeholder.clone(), style_colors.placeholder)
} else {
(content, style.color)
(content, style_colors.text)
};
let font_size = style.font_size.to_pixels(window.rem_size());

View file

@ -7,7 +7,7 @@ mod zed_port;
pub use app::run;
#[cfg(feature = "bench")]
#[doc(hidden)]
pub mod benchmarks {
pub use crate::view::rows::benchmarks::*;
}

View file

@ -104,7 +104,7 @@ impl SmokeView {
let input = cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Enter".into(),
placeholder: "Enter".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -191,7 +191,7 @@ fn text_input_constructs_without_panicking(cx: &mut gpui::TestAppContext) {
cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Commit message".into(),
placeholder: "Commit message".into(),
multiline: false,
read_only: false,
chromeless: false,

View file

@ -1,4 +1,5 @@
use super::*;
use gpui::ObjectFit;
pub(super) const CLIENT_SIDE_DECORATION_INSET: Pixels = px(10.0);
@ -10,6 +11,38 @@ fn titlebar_control_icon(theme: AppTheme, path: &'static str) -> gpui::Svg {
.text_color(theme.colors.text)
}
fn titlebar_app_icon(theme: AppTheme) -> AnyElement {
gpui::image_cache(gpui::retain_all("titlebar_icon_cache"))
.child(
div()
.id("titlebar_app_icon")
.size(px(16.0))
.rounded(px(4.0))
.bg(with_alpha(
theme.colors.text,
if theme.is_dark { 0.12 } else { 0.08 },
))
.overflow_hidden()
.child(
gpui::img("gitgpui_logo_window.svg")
.size(px(16.0))
.object_fit(ObjectFit::Contain)
.with_fallback({
let theme = theme;
move || {
gpui::svg()
.path("icons/gitgpui_mark.svg")
.w(px(16.0))
.h(px(16.0))
.text_color(theme.colors.text)
.into_any_element()
}
}),
),
)
.into_any_element()
}
fn titlebar_control_button(
theme: AppTheme,
id: &'static str,
@ -138,17 +171,36 @@ impl GitGpuiView {
with_alpha(theme.colors.border, 0.7)
};
let app_icon = div()
.id("app_icon")
.h_full()
.pl_2()
.pr_1()
.flex()
.items_center()
.child(titlebar_app_icon(theme));
let hamburger = div()
.id("app_menu")
.debug_selector(|| "app_menu".to_string())
.h_full()
.px_2()
.w(px(44.0))
.flex()
.items_center()
.justify_center()
.cursor(CursorStyle::PointingHand)
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
.child("")
.child(
div()
.id("app_menu_btn")
.size(px(26.0))
.flex()
.items_center()
.justify_center()
.rounded(px(theme.radii.pill))
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
.child(titlebar_control_icon(theme, "icons/menu.svg")),
)
.on_click(cx.listener(|this, e: &ClickEvent, _w, cx| {
this.popover = Some(PopoverKind::AppMenu);
this.popover_anchor = Some(e.position());
@ -166,6 +218,10 @@ impl GitGpuiView {
.id("title_drag")
.flex_1()
.h_full()
.flex()
.items_center()
.min_w(px(0.0))
.px_2()
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(
MouseButton::Left,
@ -196,8 +252,12 @@ impl GitGpuiView {
}))
.child(
div()
.h_full()
.flex()
.items_center()
.text_sm()
.text_color(theme.colors.text_muted)
.whitespace_nowrap()
.child("GitGpui"),
);
@ -263,7 +323,15 @@ impl GitGpuiView {
.bg(bar_bg)
.border_b_1()
.border_color(bar_border)
.child(hamburger)
.child(
div()
.flex()
.items_center()
.h_full()
.gap_1()
.child(app_icon)
.child(hamburger),
)
.child(drag_region)
.child(
div()

View file

@ -188,16 +188,16 @@ impl Element for DiffTextSelectionOverlay {
underline: None,
strikethrough: None,
};
let layout = window
.text_system()
.shape_line(self.text.clone(), font_size, &[run], None);
let layout =
window
.text_system()
.shape_line(self.text.clone(), font_size, &[run], None);
let x0 = selection
.as_ref()
.map(|r| layout.x_for_index(r.start.min(self.text.len())));
let x1 =
selection
.as_ref()
.map(|r| layout.x_for_index(r.end.min(self.text.len())));
let x1 = selection
.as_ref()
.map(|r| layout.x_for_index(r.end.min(self.text.len())));
(x0, x1, Some(layout))
}
};

View file

@ -205,6 +205,10 @@ enum PopoverKind {
BranchPicker,
CreateBranch,
StashPrompt,
PushSetUpstreamPrompt {
repo_id: RepoId,
remote: String,
},
PullPicker,
AppMenu,
DiffHunks,
@ -414,6 +418,7 @@ pub struct GitGpuiView {
commit_message_input: Entity<zed::TextInput>,
create_branch_input: Entity<zed::TextInput>,
stash_message_input: Entity<zed::TextInput>,
push_upstream_branch_input: Entity<zed::TextInput>,
popover: Option<PopoverKind>,
popover_anchor: Option<Point<Pixels>>,
@ -463,7 +468,8 @@ struct DiffTextLayoutCacheEntry {
impl GitGpuiView {
fn is_file_preview_active(&self) -> bool {
self.untracked_worktree_preview_path().is_some() || self.added_file_preview_abs_path().is_some()
self.untracked_worktree_preview_path().is_some()
|| self.added_file_preview_abs_path().is_some()
}
fn worktree_preview_line_count(&self) -> Option<usize> {
@ -658,7 +664,7 @@ impl GitGpuiView {
let commit_message_input = cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Enter commit message".into(),
placeholder: "Enter commit message".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -686,7 +692,21 @@ impl GitGpuiView {
let stash_message_input = cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Stash message…".into(),
placeholder: "Stash message".into(),
multiline: false,
read_only: false,
chromeless: false,
soft_wrap: false,
},
window,
cx,
)
});
let push_upstream_branch_input = cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Remote branch name".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -700,7 +720,7 @@ impl GitGpuiView {
let history_search_input = cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Search commits".into(),
placeholder: "Search commits".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -714,7 +734,7 @@ impl GitGpuiView {
let diff_search_input = cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Search diff".into(),
placeholder: "Search diff".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -853,6 +873,7 @@ impl GitGpuiView {
commit_message_input,
create_branch_input,
stash_message_input,
push_upstream_branch_input,
popover: None,
popover_anchor: None,
context_menu_focus_handle,
@ -894,6 +915,10 @@ impl GitGpuiView {
view.set_theme(initial_theme, cx);
view.rebuild_diff_cache();
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
view.maybe_auto_install_linux_desktop_integration(cx);
view
}
@ -911,6 +936,8 @@ impl GitGpuiView {
.update(cx, |input, cx| input.set_theme(theme, cx));
self.stash_message_input
.update(cx, |input, cx| input.set_theme(theme, cx));
self.push_upstream_branch_input
.update(cx, |input, cx| input.set_theme(theme, cx));
self.history_search_input
.update(cx, |input, cx| input.set_theme(theme, cx));
self.diff_search_input
@ -1292,7 +1319,10 @@ impl GitGpuiView {
let Loadable::Ready(lines) = &self.worktree_preview else {
return fallback;
};
return lines.get(visible_ix).map(|l| expand_tabs(l)).unwrap_or(fallback);
return lines
.get(visible_ix)
.map(|l| expand_tabs(l))
.unwrap_or(fallback);
}
let Some(&mapped_ix) = self.diff_visible_indices.get(visible_ix) else {
@ -1731,21 +1761,19 @@ impl GitGpuiView {
return (false, false);
}
let handle_w = px(HISTORY_COL_HANDLE_PX);
let min_message = px(220.0);
// Always show Branch + Graph; Message is flex.
let fixed_base = self.history_col_branch + handle_w + self.history_col_graph + handle_w;
let fixed_base = self.history_col_branch + self.history_col_graph;
// Show both by default.
let mut show_date = true;
let mut show_sha = true;
let mut fixed =
fixed_base + handle_w + self.history_col_date + handle_w + self.history_col_sha;
let mut fixed = fixed_base + self.history_col_date + self.history_col_sha;
if available - fixed < min_message {
show_sha = false;
fixed -= handle_w + self.history_col_sha;
fixed -= self.history_col_sha;
}
if available - fixed < min_message {
show_date = false;
@ -1765,7 +1793,7 @@ impl GitGpuiView {
cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Filter repositories".into(),
placeholder: "Filter repositories".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -1795,7 +1823,7 @@ impl GitGpuiView {
cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Filter branches".into(),
placeholder: "Filter branches".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -1825,7 +1853,7 @@ impl GitGpuiView {
cx.new(|cx| {
zed::TextInput::new(
zed::TextInputOptions {
placeholder: "Filter hunks".into(),
placeholder: "Filter hunks".into(),
multiline: false,
read_only: false,
chromeless: false,
@ -1938,7 +1966,7 @@ impl GitGpuiView {
}
Loadable::Loading => rows.push(BranchSidebarRow::Placeholder {
section: BranchSection::Local,
message: "Loading".into(),
message: "Loading".into(),
}),
Loadable::NotLoaded => rows.push(BranchSidebarRow::Placeholder {
section: BranchSection::Local,
@ -1969,7 +1997,7 @@ impl GitGpuiView {
Loadable::Loading => {
rows.push(BranchSidebarRow::Placeholder {
section: BranchSection::Remote,
message: "Loading".into(),
message: "Loading".into(),
});
return rows;
}
@ -2042,7 +2070,7 @@ impl GitGpuiView {
}
}
Loadable::Loading => rows.push(BranchSidebarRow::StashPlaceholder {
message: "Loading".into(),
message: "Loading".into(),
}),
Loadable::NotLoaded => rows.push(BranchSidebarRow::StashPlaceholder {
message: "Not loaded".into(),
@ -2906,7 +2934,7 @@ impl GitGpuiView {
multiline: true,
read_only: true,
chromeless: true,
soft_wrap: false,
soft_wrap: true,
},
cx,
)
@ -2919,9 +2947,14 @@ impl GitGpuiView {
self.toasts.push(ToastState { id, kind, input });
let ttl = match kind {
zed::ToastKind::Error => Duration::from_secs(15),
zed::ToastKind::Success => Duration::from_secs(6),
zed::ToastKind::Info => Duration::from_secs(6),
};
cx.spawn(
async move |view: WeakEntity<GitGpuiView>, cx: &mut gpui::AsyncApp| {
Timer::after(Duration::from_secs(5)).await;
Timer::after(ttl).await;
let _ = view.update(cx, |this, cx| {
this.toasts.retain(|t| t.id != id);
cx.notify();
@ -2931,6 +2964,130 @@ impl GitGpuiView {
.detach();
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn maybe_auto_install_linux_desktop_integration(&mut self, cx: &mut gpui::Context<Self>) {
use std::path::PathBuf;
if std::env::var_os("GITGPUI_NO_DESKTOP_INSTALL").is_some() {
return;
}
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
if !desktop.to_ascii_lowercase().contains("gnome") {
return;
}
let home = std::env::var_os("HOME").map(PathBuf::from);
let data_home = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let Some(data_home) = data_home else {
return;
};
let desktop_path = data_home.join("applications/gitgpui.desktop");
let icon_path = data_home.join("icons/hicolor/scalable/apps/gitgpui.svg");
if desktop_path.exists() && icon_path.exists() {
return;
}
self.install_linux_desktop_integration(cx);
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn install_linux_desktop_integration(&mut self, cx: &mut gpui::Context<Self>) {
use std::fs;
use std::path::PathBuf;
use std::process::Command;
const DESKTOP_TEMPLATE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../assets/linux/gitgpui.desktop"
));
const ICON_SVG: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../assets/gitgpui_logo.svg"
));
let Ok(exe) = std::env::current_exe() else {
self.push_toast(
zed::ToastKind::Error,
"Desktop install failed: could not resolve executable path".to_string(),
cx,
);
return;
};
let home = std::env::var_os("HOME").map(PathBuf::from);
let data_home = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let Some(data_home) = data_home else {
self.push_toast(
zed::ToastKind::Error,
"Desktop install failed: HOME/XDG_DATA_HOME not set".to_string(),
cx,
);
return;
};
let applications_dir = data_home.join("applications");
let icons_dir = data_home.join("icons/hicolor/scalable/apps");
let desktop_path = applications_dir.join("gitgpui.desktop");
let icon_path = icons_dir.join("gitgpui.svg");
if let Err(e) =
fs::create_dir_all(&applications_dir).and_then(|_| fs::create_dir_all(&icons_dir))
{
self.push_toast(
zed::ToastKind::Error,
format!("Desktop install failed: {e}"),
cx,
);
return;
}
let mut desktop_out = String::new();
for line in DESKTOP_TEMPLATE.lines() {
if line.starts_with("Exec=") {
desktop_out.push_str("Exec=");
desktop_out.push_str(&exe.display().to_string());
desktop_out.push('\n');
} else {
desktop_out.push_str(line);
desktop_out.push('\n');
}
}
if let Err(e) = fs::write(&desktop_path, desktop_out.as_bytes())
.and_then(|_| fs::write(&icon_path, ICON_SVG))
{
self.push_toast(
zed::ToastKind::Error,
format!("Desktop install failed: {e}"),
cx,
);
return;
}
let _ = Command::new("update-desktop-database")
.arg(&applications_dir)
.output();
let _ = Command::new("gtk-update-icon-cache")
.arg(data_home.join("icons/hicolor"))
.output();
self.push_toast(
zed::ToastKind::Success,
format!(
"Installed desktop entry + icon to:\n{}\n{}\n\nIf GNOME still shows a generic icon, log out/in (or restart GNOME Shell).",
desktop_path.display(),
icon_path.display()
),
cx,
);
}
fn rebuild_diff_word_highlights(&mut self) {
self.diff_word_highlights.clear();
self.diff_word_highlights

View file

@ -216,7 +216,7 @@ impl GitGpuiView {
.active_repo()
.map(|r| match &r.head_branch {
Loadable::Ready(name) => name.clone().into(),
Loadable::Loading => "".into(),
Loadable::Loading => "".into(),
Loadable::Error(_) => "error".into(),
Loadable::NotLoaded => "".into(),
})
@ -373,10 +373,67 @@ impl GitGpuiView {
push = push.end_slot(count_badge(push_count, push_color));
}
let push = push
.on_click(theme, cx, |this, _e, _w, cx| {
if let Some(repo_id) = this.active_repo_id() {
this.store.dispatch(Msg::Push { repo_id });
.on_click(theme, cx, |this, e, window, cx| {
let Some(repo) = this.active_repo() else {
return;
};
let repo_id = repo.id;
let head = match &repo.head_branch {
Loadable::Ready(head) => head.clone(),
_ => {
this.store.dispatch(Msg::Push { repo_id });
cx.notify();
return;
}
};
let upstream_missing = match &repo.branches {
Loadable::Ready(branches) => branches
.iter()
.find(|b| b.name == head)
.is_some_and(|b| b.upstream.is_none()),
_ => false,
};
if upstream_missing {
let remote = match &repo.remotes {
Loadable::Ready(remotes) => {
if remotes.is_empty() {
None
} else if remotes.iter().any(|r| r.name == "origin") {
Some("origin".to_string())
} else {
Some(remotes[0].name.clone())
}
}
_ => Some("origin".to_string()),
};
if let Some(remote) = remote {
this.push_upstream_branch_input
.update(cx, |i, cx| i.set_text(head, cx));
let focus = this
.push_upstream_branch_input
.read_with(cx, |i, _| i.focus_handle());
window.focus(&focus);
this.open_popover_at(
PopoverKind::PushSetUpstreamPrompt { repo_id, remote },
e.position(),
window,
cx,
);
return;
}
this.push_toast(
zed::ToastKind::Error,
"Cannot push: no remotes configured".to_string(),
cx,
);
return;
}
this.store.dispatch(Msg::Push { repo_id });
cx.notify();
})
.on_hover(cx.listener(move |this, hovering: &bool, _w, cx| {
@ -404,7 +461,7 @@ impl GitGpuiView {
})
.on_hover(cx.listener(move |this, hovering: &bool, _w, cx| {
let text: SharedString = if can_stash {
"Create stash".into()
"Create stash".into()
} else {
"No changes to stash".into()
};
@ -429,7 +486,7 @@ impl GitGpuiView {
this.open_popover_at(PopoverKind::CreateBranch, e.position(), window, cx);
})
.on_hover(cx.listener(|this, hovering: &bool, _w, cx| {
let text: SharedString = "Create branch".into();
let text: SharedString = "Create branch".into();
if *hovering {
this.tooltip_text = Some(text);
} else if this.tooltip_text.as_ref() == Some(&text) {

View file

@ -26,6 +26,14 @@ impl GitGpuiView {
.min_h(px(0.0))
.track_scroll(self.branches_scroll.clone());
let scroll_handle = self.branches_scroll.0.borrow().base_handle.clone();
let list = div()
.flex()
.flex_col()
.flex_1()
.min_h(px(0.0))
.px(px(2.0))
.child(list)
.into_any_element();
let panel_body: AnyElement = div()
.id("branch_sidebar_scroll_container")
.relative()
@ -59,8 +67,7 @@ impl GitGpuiView {
.and_then(|r| r.selected_commit.clone())
});
if let (Some(repo_id), Some(selected_id)) = (active_repo_id, selected_id)
{
if let (Some(repo_id), Some(selected_id)) = (active_repo_id, selected_id) {
let show_delayed_loading = self.commit_details_delay.as_ref().is_some_and(|s| {
s.repo_id == repo_id && s.commit_id == selected_id && s.show_loading
});
@ -116,7 +123,7 @@ impl GitGpuiView {
None => zed::empty_state(theme, "Commit", "No repository.").into_any_element(),
Some(Loadable::Loading) => {
if show_delayed_loading {
zed::empty_state(theme, "Commit", "Loading").into_any_element()
zed::empty_state(theme, "Commit", "Loading").into_any_element()
} else {
div().into_any_element()
}
@ -126,7 +133,7 @@ impl GitGpuiView {
}
Some(Loadable::NotLoaded) => {
if show_delayed_loading {
zed::empty_state(theme, "Commit", "Loading").into_any_element()
zed::empty_state(theme, "Commit", "Loading").into_any_element()
} else {
div().into_any_element()
}
@ -134,7 +141,7 @@ impl GitGpuiView {
Some(Loadable::Ready(details)) => {
if &details.id != &selected_id {
if show_delayed_loading {
zed::empty_state(theme, "Commit", "Loading").into_any_element()
zed::empty_state(theme, "Commit", "Loading").into_any_element()
} else {
let parent = details
.parent_ids
@ -150,13 +157,16 @@ impl GitGpuiView {
.into_any_element()
} else {
let total_files = details.files.len();
let list_h = px((total_files as f32 * 24.0)
.min(COMMIT_DETAILS_FILES_MAX_HEIGHT_PX)
.max(24.0));
let list = uniform_list(
("commit_details_files_list", repo_id.0),
total_files,
cx.processor(Self::render_commit_file_rows),
)
.w_full()
.max_h(px(COMMIT_DETAILS_FILES_MAX_HEIGHT_PX))
.h(list_h)
.track_scroll(self.commit_files_scroll.clone());
let scroll_handle =
self.commit_files_scroll.0.borrow().base_handle.clone();
@ -165,7 +175,7 @@ impl GitGpuiView {
.id(("commit_details_files_container", repo_id.0))
.relative()
.w_full()
.max_h(px(COMMIT_DETAILS_FILES_MAX_HEIGHT_PX))
.h(list_h)
.child(list)
.child(
zed::Scrollbar::new(
@ -254,21 +264,25 @@ impl GitGpuiView {
.into_any_element()
} else {
let total_files = details.files.len();
let list_h = px((total_files as f32 * 24.0)
.min(COMMIT_DETAILS_FILES_MAX_HEIGHT_PX)
.max(24.0));
let list = uniform_list(
("commit_details_files_list", repo_id.0),
total_files,
cx.processor(Self::render_commit_file_rows),
)
.w_full()
.max_h(px(COMMIT_DETAILS_FILES_MAX_HEIGHT_PX))
.h(list_h)
.track_scroll(self.commit_files_scroll.clone());
let scroll_handle = self.commit_files_scroll.0.borrow().base_handle.clone();
let scroll_handle =
self.commit_files_scroll.0.borrow().base_handle.clone();
div()
.id(("commit_details_files_container", repo_id.0))
.relative()
.w_full()
.max_h(px(COMMIT_DETAILS_FILES_MAX_HEIGHT_PX))
.h(list_h)
.child(list)
.child(
zed::Scrollbar::new(
@ -531,7 +545,7 @@ impl GitGpuiView {
.bg(theme.colors.surface_bg)
.px_2()
.py_2()
.child(self.commit_box(cx)),
.child(self.commit_box(staged_count > 0, cx)),
)
.into_any_element()
} else {
@ -591,7 +605,11 @@ impl GitGpuiView {
}
}
pub(in super::super) fn commit_box(&mut self, cx: &mut gpui::Context<Self>) -> gpui::Div {
pub(in super::super) fn commit_box(
&mut self,
can_commit: bool,
cx: &mut gpui::Context<Self>,
) -> gpui::Div {
let theme = self.theme;
div()
.flex()
@ -612,6 +630,7 @@ impl GitGpuiView {
.child(
zed::Button::new("commit", "Commit")
.style(zed::ButtonStyle::Filled)
.disabled(!can_commit)
.on_click(theme, cx, |this, _e, _w, cx| {
let Some(repo_id) = this.active_repo_id() else {
return;

View file

@ -24,7 +24,7 @@ impl GitGpuiView {
match repo.map(|r| &r.log) {
None => zed::empty_state(theme, "History", "No repository.").into_any_element(),
Some(Loadable::Loading) => {
zed::empty_state(theme, "History", "Loading").into_any_element()
zed::empty_state(theme, "History", "Loading").into_any_element()
}
Some(Loadable::Error(e)) => {
zed::empty_state(theme, "History", e.clone()).into_any_element()
@ -50,17 +50,13 @@ impl GitGpuiView {
} else {
self.history_search_debounced.is_empty()
};
let should_load_more = state.last_item_size.is_some()
&& repo.is_some_and(|repo| {
!repo.log_loading_more
&& matches!(&repo.log, Loadable::Ready(page) if page.next_cursor.is_some())
})
&& should_load_by_scroll;
let should_load_more = state.last_item_size.is_some() && repo.is_some_and(|repo| {
!repo.log_loading_more
&& matches!(&repo.log, Loadable::Ready(page) if page.next_cursor.is_some())
}) && should_load_by_scroll;
(scroll_handle, should_load_more)
};
if should_load_more
&& let Some(repo_id) = self.active_repo_id()
{
if should_load_more && let Some(repo_id) = self.active_repo_id() {
self.store.dispatch(Msg::LoadMoreHistory { repo_id });
}
div()
@ -209,6 +205,14 @@ impl GitGpuiView {
let repo = self.active_repo();
let diff_nav_hotkey_hint = |label: &'static str| {
div()
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child(label)
};
let mut controls = div().flex().items_center().gap_1();
if !is_file_preview {
let nav_entries = self.diff_nav_entries();
@ -265,6 +269,7 @@ impl GitGpuiView {
)
.child(
zed::Button::new("diff_prev_hunk", "Prev")
.end_slot(diff_nav_hotkey_hint("F2"))
.style(zed::ButtonStyle::Outlined)
.disabled(!can_nav_prev)
.on_click(theme, cx, |this, _e, _w, cx| {
@ -272,7 +277,8 @@ impl GitGpuiView {
cx.notify();
})
.on_hover(cx.listener(|this, hovering: &bool, _w, cx| {
let text: SharedString = "Previous change (Shift+F7 / Alt+Up)".into();
let text: SharedString =
"Previous change (F2 / Shift+F7 / Alt+Up)".into();
if *hovering {
this.tooltip_text = Some(text);
} else if this.tooltip_text.as_ref() == Some(&text) {
@ -283,6 +289,7 @@ impl GitGpuiView {
)
.child(
zed::Button::new("diff_next_hunk", "Next")
.end_slot(diff_nav_hotkey_hint("F3"))
.style(zed::ButtonStyle::Outlined)
.disabled(!can_nav_next)
.on_click(theme, cx, |this, _e, _w, cx| {
@ -290,7 +297,7 @@ impl GitGpuiView {
cx.notify();
})
.on_hover(cx.listener(|this, hovering: &bool, _w, cx| {
let text: SharedString = "Next change (F7 / Alt+Down)".into();
let text: SharedString = "Next change (F3 / F7 / Alt+Down)".into();
if *hovering {
this.tooltip_text = Some(text);
} else if this.tooltip_text.as_ref() == Some(&text) {
@ -301,7 +308,7 @@ impl GitGpuiView {
)
.when(!wants_file_diff, |controls| {
controls.child(
zed::Button::new("diff_hunks", "Hunks")
zed::Button::new("diff_hunks", "Hunks")
.style(zed::ButtonStyle::Outlined)
.on_click(theme, cx, |this, e, window, cx| {
let _ = this.ensure_diff_hunk_picker_search_input(window, cx);
@ -310,7 +317,7 @@ impl GitGpuiView {
cx.notify();
})
.on_hover(cx.listener(|this, hovering: &bool, _w, cx| {
let text: SharedString = "Jump to hunk (Alt+H)".into();
let text: SharedString = "Jump to hunk (Alt+H)".into();
if *hovering {
this.tooltip_text = Some(text);
} else if this.tooltip_text.as_ref() == Some(&text) {
@ -363,7 +370,7 @@ impl GitGpuiView {
}
match &self.worktree_preview {
Loadable::NotLoaded | Loadable::Loading => {
zed::empty_state(theme, "File", "Loading").into_any_element()
zed::empty_state(theme, "File", "Loading").into_any_element()
}
Loadable::Error(e) => {
self.diff_raw_input.update(cx, |input, cx| {
@ -395,7 +402,8 @@ impl GitGpuiView {
.min_h(px(0.0))
.track_scroll(self.worktree_preview_scroll.clone());
let scroll_handle = self.worktree_preview_scroll.0.borrow().base_handle.clone();
let scroll_handle =
self.worktree_preview_scroll.0.borrow().base_handle.clone();
div()
.id("worktree_preview_scroll_container")
.debug_selector(|| "worktree_preview_scroll_container".to_string())
@ -419,7 +427,7 @@ impl GitGpuiView {
zed::empty_state(theme, "Diff", "Select a file.").into_any_element()
}
Loadable::Loading => {
zed::empty_state(theme, "Diff", "Loading").into_any_element()
zed::empty_state(theme, "Diff", "Loading").into_any_element()
}
Loadable::Error(e) => {
self.diff_raw_input.update(cx, |input, cx| {
@ -463,7 +471,7 @@ impl GitGpuiView {
.into_any_element()
}
DiffFileState::Loading => {
zed::empty_state(theme, "Diff", "Loading").into_any_element()
zed::empty_state(theme, "Diff", "Loading").into_any_element()
}
DiffFileState::Error(e) => {
self.diff_raw_input.update(cx, |input, cx| {
@ -760,7 +768,7 @@ impl GitGpuiView {
.w_full()
.h_full()
.min_h(px(0.0))
.bg(theme.colors.surface_bg)
.bg(theme.colors.surface_bg_elevated)
.track_focus(&self.diff_panel_focus_handle)
.on_mouse_down(
MouseButton::Left,

View file

@ -24,6 +24,11 @@ enum ContextMenuAction {
repo_id: RepoId,
name: String,
},
CheckoutRemoteBranch {
repo_id: RepoId,
remote: String,
name: String,
},
SetHistoryScope {
repo_id: RepoId,
scope: gitgpui_core::domain::LogScope,

View file

@ -247,9 +247,25 @@ impl GitGpuiView {
icon: Some("".into()),
shortcut: Some("Enter".into()),
disabled: false,
action: ContextMenuAction::CheckoutBranch {
repo_id: *repo_id,
name: name.clone(),
action: match section {
BranchSection::Local => ContextMenuAction::CheckoutBranch {
repo_id: *repo_id,
name: name.clone(),
},
BranchSection::Remote => {
if let Some((remote, branch)) = name.split_once('/') {
ContextMenuAction::CheckoutRemoteBranch {
repo_id: *repo_id,
remote: remote.to_string(),
name: branch.to_string(),
}
} else {
ContextMenuAction::CheckoutBranch {
repo_id: *repo_id,
name: name.clone(),
}
}
}
},
});
items.push(ContextMenuItem::Entry {
@ -305,7 +321,7 @@ impl GitGpuiView {
let mut items = vec![ContextMenuItem::Header(header)];
items.push(ContextMenuItem::Separator);
items.push(ContextMenuItem::Entry {
label: "Switch branch".into(),
label: "Switch branch".into(),
icon: Some("".into()),
shortcut: Some("Enter".into()),
disabled: false,
@ -418,6 +434,18 @@ impl GitGpuiView {
self.store.dispatch(Msg::CheckoutBranch { repo_id, name });
self.rebuild_diff_cache();
}
ContextMenuAction::CheckoutRemoteBranch {
repo_id,
remote,
name,
} => {
self.store.dispatch(Msg::CheckoutRemoteBranch {
repo_id,
remote,
name,
});
self.rebuild_diff_cache();
}
ContextMenuAction::SetHistoryScope { repo_id, scope } => {
self.store.dispatch(Msg::SetHistoryScope { repo_id, scope });
}
@ -636,6 +664,8 @@ impl GitGpuiView {
let (show_date, show_sha) = self.history_visible_columns();
let col_date = self.history_col_date;
let col_sha = self.history_col_sha;
let handle_w = px(HISTORY_COL_HANDLE_PX);
let handle_half = px(HISTORY_COL_HANDLE_PX / 2.0);
let scope_label: SharedString = self
.active_repo()
.map(|r| match r.history_scope {
@ -649,8 +679,10 @@ impl GitGpuiView {
let resize_handle = |id: &'static str, handle: HistoryColResizeHandle| {
div()
.id(id)
.w(px(HISTORY_COL_HANDLE_PX))
.h_full()
.absolute()
.w(handle_w)
.top_0()
.bottom_0()
.flex()
.items_center()
.justify_center()
@ -732,6 +764,7 @@ impl GitGpuiView {
};
let mut header = div()
.relative()
.flex()
.w_full()
.items_center()
@ -799,10 +832,6 @@ impl GitGpuiView {
})),
),
)
.child(resize_handle(
"history_col_resize_branch",
HistoryColResizeHandle::Branch,
))
.child(
div()
.w(self.history_col_graph)
@ -811,10 +840,6 @@ impl GitGpuiView {
.whitespace_nowrap()
.child("GRAPH"),
)
.child(resize_handle(
"history_col_resize_graph",
HistoryColResizeHandle::Graph,
))
.child(
div()
.flex_1()
@ -824,10 +849,6 @@ impl GitGpuiView {
);
if show_date {
header = header.child(resize_handle(
"history_col_resize_message",
HistoryColResizeHandle::Message,
));
header = header.child(
div()
.w(col_date)
@ -839,10 +860,6 @@ impl GitGpuiView {
}
if show_sha {
header = header.child(resize_handle(
"history_col_resize_date",
HistoryColResizeHandle::Date,
));
header = header.child(
div()
.w(col_sha)
@ -853,7 +870,36 @@ impl GitGpuiView {
);
}
header
let mut header_with_handles = header
.child(
resize_handle("history_col_resize_branch", HistoryColResizeHandle::Branch)
.left((self.history_col_branch - handle_half).max(px(0.0))),
)
.child(
resize_handle("history_col_resize_graph", HistoryColResizeHandle::Graph).left(
(self.history_col_branch + self.history_col_graph - handle_half).max(px(0.0)),
),
);
if show_date {
let right_fixed = col_date + if show_sha { col_sha } else { px(0.0) };
header_with_handles = header_with_handles.child(
resize_handle(
"history_col_resize_message",
HistoryColResizeHandle::Message,
)
.right((right_fixed - handle_half).max(px(0.0))),
);
}
if show_sha {
header_with_handles = header_with_handles.child(
resize_handle("history_col_resize_date", HistoryColResizeHandle::Date)
.right((col_sha - handle_half).max(px(0.0))),
);
}
header_with_handles
}
pub(in super::super) fn popover_view(
@ -867,10 +913,17 @@ impl GitGpuiView {
.unwrap_or_else(|| point(px(64.0), px(64.0)));
let is_app_menu = matches!(&kind, PopoverKind::AppMenu);
let popover_use_surface_bg = matches!(
&kind,
PopoverKind::CreateBranch
| PopoverKind::StashPrompt
| PopoverKind::PushSetUpstreamPrompt { .. }
);
let anchor_corner = match &kind {
PopoverKind::PullPicker
| PopoverKind::CreateBranch
| PopoverKind::StashPrompt
| PopoverKind::PushSetUpstreamPrompt { .. }
| PopoverKind::HistoryBranchFilter { .. } => Corner::TopRight,
_ => Corner::TopLeft,
};
@ -1000,7 +1053,7 @@ impl GitGpuiView {
}
}
Loadable::Loading => {
menu = menu.child(zed::context_menu_label(theme, "Loading"));
menu = menu.child(zed::context_menu_label(theme, "Loading"));
}
Loadable::Error(e) => {
menu = menu.child(zed::context_menu_label(theme, e.clone()));
@ -1011,40 +1064,9 @@ impl GitGpuiView {
}
}
zed::context_menu(
theme,
menu.child(zed::context_menu_separator(theme))
.child(zed::context_menu_header(theme, "Create branch"))
.child(
div()
.px_2()
.w_full()
.min_w(px(0.0))
.child(self.create_branch_input.clone()),
)
.child(
div().px_2().child(
zed::Button::new("create_branch_go", "Create")
.style(zed::ButtonStyle::Filled)
.on_click(theme, cx, |this, _e, _w, cx| {
let name = this
.create_branch_input
.read_with(cx, |i, _| i.text().trim().to_string());
if let Some(repo_id) = this.active_repo_id()
&& !name.is_empty()
{
this.store
.dispatch(Msg::CreateBranch { repo_id, name });
}
this.popover = None;
this.popover_anchor = None;
cx.notify();
}),
),
),
)
.min_w(px(240.0))
.max_w(px(420.0))
zed::context_menu(theme, menu)
.min_w(px(240.0))
.max_w(px(420.0))
}
PopoverKind::CreateBranch => div()
.flex()
@ -1163,6 +1185,75 @@ impl GitGpuiView {
}),
),
),
PopoverKind::PushSetUpstreamPrompt { repo_id, remote } => {
let remote = remote.clone();
div()
.flex()
.flex_col()
.min_w(px(320.0))
.child(
div()
.px_2()
.py_1()
.text_sm()
.font_weight(FontWeight::BOLD)
.child("Set upstream and push"),
)
.child(div().border_t_1().border_color(theme.colors.border))
.child(
div()
.px_2()
.py_1()
.text_sm()
.text_color(theme.colors.text_muted)
.child(format!("Remote: {remote}")),
)
.child(
div()
.px_2()
.py_1()
.w_full()
.min_w(px(0.0))
.child(self.push_upstream_branch_input.clone()),
)
.child(
div()
.px_2()
.py_1()
.flex()
.items_center()
.justify_between()
.child(
zed::Button::new("push_upstream_cancel", "Cancel")
.style(zed::ButtonStyle::Outlined)
.on_click(theme, cx, |this, _e, _w, cx| {
this.popover = None;
this.popover_anchor = None;
cx.notify();
}),
)
.child(
zed::Button::new("push_upstream_go", "Push")
.style(zed::ButtonStyle::Filled)
.on_click(theme, cx, move |this, _e, _w, cx| {
let branch = this
.push_upstream_branch_input
.read_with(cx, |i, _| i.text().trim().to_string());
if branch.is_empty() {
return;
}
this.store.dispatch(Msg::PushSetUpstream {
repo_id,
remote: remote.clone(),
branch,
});
this.popover = None;
this.popover_anchor = None;
cx.notify();
}),
),
)
}
PopoverKind::HistoryBranchFilter { repo_id } => self
.context_menu_view(PopoverKind::HistoryBranchFilter { repo_id }, cx)
.min_w(px(160.0))
@ -1336,34 +1427,62 @@ impl GitGpuiView {
},
cx,
),
PopoverKind::AppMenu => div()
.flex()
.flex_col()
.min_w(px(200.0))
.child(
div()
.id("app_menu_quit")
.debug_selector(|| "app_menu_quit".to_string())
.px_2()
.py_1()
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
.child("Quit")
.on_click(cx.listener(|_this, _e: &ClickEvent, _w, cx| {
cx.quit();
})),
)
.child(
div()
.id("app_menu_close")
.debug_selector(|| "app_menu_close".to_string())
.px_2()
.py_1()
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
.child("Close")
.on_click(close),
),
PopoverKind::AppMenu => {
let mut install_desktop = div()
.id("app_menu_install_desktop")
.debug_selector(|| "app_menu_install_desktop".to_string())
.px_2()
.py_1()
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
.child("Install desktop integration");
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
install_desktop =
install_desktop.on_click(cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.install_linux_desktop_integration(cx);
this.popover = None;
this.popover_anchor = None;
cx.notify();
}));
}
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
{
install_desktop = install_desktop.text_color(theme.colors.text_muted);
}
div()
.flex()
.flex_col()
.min_w(px(200.0))
.child(install_desktop)
.child(
div()
.id("app_menu_quit")
.debug_selector(|| "app_menu_quit".to_string())
.px_2()
.py_1()
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
.child("Quit")
.on_click(cx.listener(|_this, _e: &ClickEvent, _w, cx| {
cx.quit();
})),
)
.child(
div()
.id("app_menu_close")
.debug_selector(|| "app_menu_close".to_string())
.px_2()
.py_1()
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
.child("Close")
.on_click(close),
)
}
};
let offset_y = if is_app_menu {
@ -1384,7 +1503,11 @@ impl GitGpuiView {
.debug_selector(|| "app_popover".to_string())
.on_any_mouse_down(|_e, _w, cx| cx.stop_propagation())
.occlude()
.bg(theme.colors.surface_bg_elevated)
.bg(if popover_use_surface_bg {
theme.colors.surface_bg
} else {
theme.colors.surface_bg_elevated
})
.border_1()
.border_color(theme.colors.border)
.rounded(px(theme.radii.panel))

View file

@ -30,10 +30,7 @@ fn file_preview_renders_scrollable_syntax_highlighted_rows(cx: &mut gpui::TestAp
});
let repo_id = gitgpui_state::model::RepoId(1);
let workdir = std::env::temp_dir().join(format!(
"gitgpui_ui_test_{}",
std::process::id()
));
let workdir = std::env::temp_dir().join(format!("gitgpui_ui_test_{}", std::process::id()));
let file_rel = std::path::PathBuf::from("preview.rs");
let lines: Arc<Vec<String>> = Arc::new(
(0..300)
@ -45,7 +42,9 @@ fn file_preview_renders_scrollable_syntax_highlighted_rows(cx: &mut gpui::TestAp
view.update(app, |this, cx| {
let mut repo = gitgpui_state::model::RepoState::new_opening(
repo_id,
gitgpui_core::domain::RepoSpec { workdir: workdir.clone() },
gitgpui_core::domain::RepoSpec {
workdir: workdir.clone(),
},
);
repo.status = gitgpui_state::model::Loadable::Ready(gitgpui_core::domain::RepoStatus {
staged: vec![],
@ -108,10 +107,8 @@ fn patch_view_applies_syntax_highlighting_to_context_lines(cx: &mut gpui::TestAp
});
let repo_id = gitgpui_state::model::RepoId(2);
let workdir = std::env::temp_dir().join(format!(
"gitgpui_ui_test_{}_patch",
std::process::id()
));
let workdir =
std::env::temp_dir().join(format!("gitgpui_ui_test_{}_patch", std::process::id()));
cx.update(|_window, app| {
view.update(app, |this, cx| {
@ -140,7 +137,9 @@ fn patch_view_applies_syntax_highlighting_to_context_lines(cx: &mut gpui::TestAp
let mut repo = gitgpui_state::model::RepoState::new_opening(
repo_id,
gitgpui_core::domain::RepoSpec { workdir: workdir.clone() },
gitgpui_core::domain::RepoSpec {
workdir: workdir.clone(),
},
);
repo.status = gitgpui_state::model::Loadable::Ready(Default::default());
repo.diff_target = Some(target);

View file

@ -3,8 +3,8 @@ use super::*;
use crate::theme::AppTheme;
use crate::view::history_graph;
use gitgpui_core::domain::{
Branch, Commit, CommitDetails, CommitFileChange, CommitId, FileStatusKind, Remote, RemoteBranch,
RepoSpec, Upstream, UpstreamDivergence,
Branch, Commit, CommitDetails, CommitFileChange, CommitId, FileStatusKind, Remote,
RemoteBranch, RepoSpec, Upstream, UpstreamDivergence,
};
use gitgpui_state::model::{Loadable, RepoId, RepoState};
use std::collections::hash_map::DefaultHasher;
@ -18,10 +18,16 @@ pub struct OpenRepoFixture {
}
impl OpenRepoFixture {
pub fn new(commits: usize, local_branches: usize, remote_branches: usize, remotes: usize) -> Self {
pub fn new(
commits: usize,
local_branches: usize,
remote_branches: usize,
remotes: usize,
) -> Self {
let theme = AppTheme::zed_ayu_dark();
let commits_vec = build_synthetic_commits(commits);
let repo = build_synthetic_repo_state(local_branches, remote_branches, remotes, &commits_vec);
let repo =
build_synthetic_repo_state(local_branches, remote_branches, remotes, &commits_vec);
Self {
repo,
commits: commits_vec,
@ -166,7 +172,10 @@ fn build_synthetic_repo_state(
remote: "origin".to_string(),
branch: head.clone(),
}),
divergence: Some(UpstreamDivergence { ahead: 1, behind: 2 }),
divergence: Some(UpstreamDivergence {
ahead: 1,
behind: 2,
}),
});
for ix in 0..local_branches.saturating_sub(1) {
branches.push(Branch {
@ -286,10 +295,15 @@ fn build_synthetic_source_lines(count: usize) -> Vec<String> {
1 => format!("{indent}let value_{ix} = \"string {ix}\";"),
2 => format!("{indent}// comment {ix} with some extra words and tokens"),
3 => format!("{indent}if value_{ix} > 10 {{ return value_{ix}; }}"),
4 => format!("{indent}for i in 0..{r} {{ sum += i; }}", r = (ix % 100) + 1),
4 => format!(
"{indent}for i in 0..{r} {{ sum += i; }}",
r = (ix % 100) + 1
),
5 => format!("{indent}match tag_{ix} {{ Some(v) => v, None => 0 }}"),
6 => format!("{indent}struct S{ix} {{ a: i32, b: String }}"),
7 => format!("{indent}impl S{ix} {{ fn new() -> Self {{ Self {{ a: 0, b: String::new() }} }} }}"),
7 => format!(
"{indent}impl S{ix} {{ fn new() -> Self {{ Self {{ a: 0, b: String::new() }} }} }}"
),
8 => format!("{indent}const CONST_{ix}: u64 = {v};", v = ix as u64 * 31),
_ => format!("{indent}println!(\"{ix} {{}}\", value_{ix});"),
};

View file

@ -16,13 +16,16 @@ impl GitGpuiView {
}
let query = this.diff_visible_query.clone();
let empty_ranges: &[Range<usize>] = &[];
let language = (this.file_diff_inline_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING)
.then(|| {
this.file_diff_cache_path
.as_ref()
.and_then(|p| diff_syntax_language_for_path(p.to_string_lossy().as_ref()))
})
.flatten();
let syntax_mode =
if this.file_diff_inline_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
DiffSyntaxMode::Auto
} else {
DiffSyntaxMode::HeuristicOnly
};
let language = this
.file_diff_cache_path
.as_ref()
.and_then(|p| diff_syntax_language_for_path(p.to_string_lossy().as_ref()));
return range
.map(|visible_ix| {
@ -38,7 +41,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -57,7 +60,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -82,7 +85,7 @@ impl GitGpuiView {
word_ranges,
query.as_str(),
language,
DiffSyntaxMode::Auto,
syntax_mode,
word_color,
);
this.diff_text_segments_cache_set(inline_ix, computed);
@ -96,7 +99,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let styled = this
@ -124,7 +127,11 @@ impl GitGpuiView {
this.diff_text_segments_cache.clear();
}
let query = this.diff_visible_query.clone();
let syntax_enabled = this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING;
let syntax_mode = if this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
DiffSyntaxMode::Auto
} else {
DiffSyntaxMode::HeuristicOnly
};
range
.map(|visible_ix| {
let selected = this
@ -139,7 +146,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let click_kind = {
@ -151,7 +158,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -174,14 +181,11 @@ impl GitGpuiView {
let file_stat = this.diff_file_stats.get(src_ix).and_then(|s| *s);
let language = if syntax_enabled {
this.diff_file_for_src_ix
.get(src_ix)
.and_then(|p| p.as_deref())
.and_then(diff_syntax_language_for_path)
} else {
None
};
let language = this
.diff_file_for_src_ix
.get(src_ix)
.and_then(|p| p.as_deref())
.and_then(diff_syntax_language_for_path);
if matches!(click_kind, DiffClickKind::Line)
&& this.diff_text_segments_cache_get(src_ix).is_none()
@ -194,7 +198,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -219,7 +223,7 @@ impl GitGpuiView {
word_ranges,
query.as_str(),
language,
DiffSyntaxMode::Auto,
syntax_mode,
word_color,
);
this.diff_text_segments_cache_set(src_ix, computed);
@ -238,7 +242,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -271,13 +275,16 @@ impl GitGpuiView {
}
let query = this.diff_visible_query.clone();
let empty_ranges: &[Range<usize>] = &[];
let language = (this.file_diff_cache_rows.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING)
.then(|| {
this.file_diff_cache_path
.as_ref()
.and_then(|p| diff_syntax_language_for_path(p.to_string_lossy().as_ref()))
})
.flatten();
let syntax_mode =
if this.file_diff_cache_rows.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
DiffSyntaxMode::Auto
} else {
DiffSyntaxMode::HeuristicOnly
};
let language = this
.file_diff_cache_path
.as_ref()
.and_then(|p| diff_syntax_language_for_path(p.to_string_lossy().as_ref()));
return range
.map(|visible_ix| {
@ -293,7 +300,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let key = row_ix * 2;
@ -306,7 +313,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -330,7 +337,7 @@ impl GitGpuiView {
word_ranges,
query.as_str(),
language,
DiffSyntaxMode::Auto,
syntax_mode,
word_color,
);
this.diff_text_segments_cache_set(key, computed);
@ -345,7 +352,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let styled: Option<&CachedDiffStyledText> = row
@ -373,7 +380,11 @@ impl GitGpuiView {
this.diff_text_segments_cache.clear();
}
let query = this.diff_visible_query.clone();
let syntax_enabled = this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING;
let syntax_mode = if this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
DiffSyntaxMode::Auto
} else {
DiffSyntaxMode::HeuristicOnly
};
let empty_ranges: &[Range<usize>] = &[];
range
.map(|visible_ix| {
@ -389,7 +400,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let Some(row) = this.diff_split_cache.get(row_ix) else {
@ -400,7 +411,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -419,19 +430,16 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let text = row.old.as_deref().unwrap_or("");
let language = if syntax_enabled {
this.diff_file_for_src_ix
.get(src_ix)
.and_then(|p| p.as_deref())
.and_then(diff_syntax_language_for_path)
} else {
None
};
let language = this
.diff_file_for_src_ix
.get(src_ix)
.and_then(|p| p.as_deref())
.and_then(diff_syntax_language_for_path);
let language = this
.diff_cache
.get(src_ix)
@ -469,7 +477,7 @@ impl GitGpuiView {
word_ranges,
query.as_str(),
language,
DiffSyntaxMode::Auto,
syntax_mode,
word_color,
);
this.diff_text_segments_cache_set(src_ix, computed);
@ -486,7 +494,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -512,7 +520,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let file_stat = this.diff_file_stats.get(*src_ix).and_then(|s| *s);
@ -546,13 +554,16 @@ impl GitGpuiView {
}
let query = this.diff_visible_query.clone();
let empty_ranges: &[Range<usize>] = &[];
let language = (this.file_diff_cache_rows.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING)
.then(|| {
this.file_diff_cache_path
.as_ref()
.and_then(|p| diff_syntax_language_for_path(p.to_string_lossy().as_ref()))
})
.flatten();
let syntax_mode =
if this.file_diff_cache_rows.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
DiffSyntaxMode::Auto
} else {
DiffSyntaxMode::HeuristicOnly
};
let language = this
.file_diff_cache_path
.as_ref()
.and_then(|p| diff_syntax_language_for_path(p.to_string_lossy().as_ref()));
return range
.map(|visible_ix| {
@ -568,7 +579,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let key = row_ix * 2 + 1;
@ -581,7 +592,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -605,7 +616,7 @@ impl GitGpuiView {
word_ranges,
query.as_str(),
language,
DiffSyntaxMode::Auto,
syntax_mode,
word_color,
);
this.diff_text_segments_cache_set(key, computed);
@ -620,7 +631,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let styled: Option<&CachedDiffStyledText> = row
@ -648,7 +659,11 @@ impl GitGpuiView {
this.diff_text_segments_cache.clear();
}
let query = this.diff_visible_query.clone();
let syntax_enabled = this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING;
let syntax_mode = if this.diff_cache.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
DiffSyntaxMode::Auto
} else {
DiffSyntaxMode::HeuristicOnly
};
let empty_ranges: &[Range<usize>] = &[];
range
.map(|visible_ix| {
@ -664,7 +679,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let Some(row) = this.diff_split_cache.get(row_ix) else {
@ -675,7 +690,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -694,19 +709,16 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let text = row.new.as_deref().unwrap_or("");
let language = if syntax_enabled {
this.diff_file_for_src_ix
.get(src_ix)
.and_then(|p| p.as_deref())
.and_then(diff_syntax_language_for_path)
} else {
None
};
let language = this
.diff_file_for_src_ix
.get(src_ix)
.and_then(|p| p.as_deref())
.and_then(diff_syntax_language_for_path);
let language = this
.diff_cache
.get(src_ix)
@ -744,7 +756,7 @@ impl GitGpuiView {
word_ranges,
query.as_str(),
language,
DiffSyntaxMode::Auto,
syntax_mode,
word_color,
);
this.diff_text_segments_cache_set(src_ix, computed);
@ -761,7 +773,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
@ -787,7 +799,7 @@ impl GitGpuiView {
.font_family("monospace")
.text_xs()
.text_color(theme.colors.text_muted)
.child("")
.child("")
.into_any_element();
};
let file_stat = this.diff_file_stats.get(*src_ix).and_then(|s| *s);

View file

@ -37,6 +37,7 @@ pub(in super::super) enum DiffSyntaxLanguage {
Json,
Toml,
Yaml,
Sql,
Bash,
}
@ -94,6 +95,7 @@ pub(in super::super) fn diff_syntax_language_for_path(path: &str) -> Option<Diff
"json" => DiffSyntaxLanguage::Json,
"toml" => DiffSyntaxLanguage::Toml,
"yaml" | "yml" => DiffSyntaxLanguage::Yaml,
"sql" => DiffSyntaxLanguage::Sql,
"sh" | "bash" | "zsh" => DiffSyntaxLanguage::Bash,
_ => {
if file_name == "makefile" || file_name == "gnumakefile" {
@ -244,11 +246,12 @@ fn tree_sitter_language(language: DiffSyntaxLanguage) -> Option<tree_sitter::Lan
DiffSyntaxLanguage::Php => return None,
DiffSyntaxLanguage::Ruby => return None,
DiffSyntaxLanguage::Json => tree_sitter_json::LANGUAGE.into(),
DiffSyntaxLanguage::Yaml => tree_sitter_yaml::language(),
DiffSyntaxLanguage::Yaml => tree_sitter_yaml::LANGUAGE.into(),
DiffSyntaxLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
DiffSyntaxLanguage::Tsx | DiffSyntaxLanguage::JavaScript => {
tree_sitter_typescript::LANGUAGE_TSX.into()
}
DiffSyntaxLanguage::Sql => return None,
DiffSyntaxLanguage::Bash => tree_sitter_bash::LANGUAGE.into(),
DiffSyntaxLanguage::Toml => return None,
})
@ -337,7 +340,7 @@ fn tree_sitter_highlight_spec(
}),
DiffSyntaxLanguage::Yaml => YAML.get_or_init(|| {
init(
tree_sitter_yaml::language(),
tree_sitter_yaml::LANGUAGE.into(),
include_str!("../../../../../../zed/crates/languages/src/yaml/highlights.scm"),
)
}),
@ -369,6 +372,7 @@ fn tree_sitter_highlight_spec(
include_str!("../../../../../../zed/crates/languages/src/bash/highlights.scm"),
)
}),
DiffSyntaxLanguage::Sql => return None,
DiffSyntaxLanguage::Toml => return None,
})
}
@ -450,6 +454,7 @@ fn syntax_tokens_for_line_heuristic(text: &str, language: DiffSyntaxLanguage) ->
}
DiffSyntaxLanguage::Bash => (None, Some('#'), false),
DiffSyntaxLanguage::Makefile => (None, Some('#'), false),
DiffSyntaxLanguage::Sql => (Some("--"), None, true),
DiffSyntaxLanguage::Rust
| DiffSyntaxLanguage::JavaScript
| DiffSyntaxLanguage::TypeScript
@ -536,6 +541,7 @@ fn syntax_tokens_for_line_heuristic(text: &str, language: DiffSyntaxLanguage) ->
| DiffSyntaxLanguage::Tsx
| DiffSyntaxLanguage::Go
| DiffSyntaxLanguage::Bash
| DiffSyntaxLanguage::Sql
| DiffSyntaxLanguage::Plain
))
{
@ -1087,6 +1093,80 @@ fn is_keyword(language: DiffSyntaxLanguage, ident: &str) -> bool {
DiffSyntaxLanguage::Json => matches!(ident, "true" | "false" | "null"),
DiffSyntaxLanguage::Toml => matches!(ident, "true" | "false"),
DiffSyntaxLanguage::Yaml => matches!(ident, "true" | "false" | "null"),
DiffSyntaxLanguage::Sql => matches!(
ident.to_ascii_lowercase().as_str(),
"add"
| "all"
| "alter"
| "and"
| "as"
| "asc"
| "begin"
| "between"
| "by"
| "case"
| "check"
| "column"
| "commit"
| "constraint"
| "create"
| "cross"
| "database"
| "default"
| "delete"
| "desc"
| "distinct"
| "drop"
| "else"
| "end"
| "exists"
| "false"
| "foreign"
| "from"
| "full"
| "group"
| "having"
| "if"
| "in"
| "index"
| "inner"
| "insert"
| "intersect"
| "into"
| "is"
| "join"
| "key"
| "left"
| "like"
| "limit"
| "materialized"
| "not"
| "null"
| "offset"
| "on"
| "or"
| "order"
| "outer"
| "primary"
| "references"
| "returning"
| "right"
| "rollback"
| "select"
| "set"
| "table"
| "then"
| "transaction"
| "true"
| "union"
| "unique"
| "update"
| "values"
| "view"
| "when"
| "where"
| "with"
),
DiffSyntaxLanguage::Bash => matches!(
ident,
"if" | "then"
@ -1129,6 +1209,14 @@ mod tests {
);
}
#[test]
fn sql_extension_is_supported() {
assert_eq!(
diff_syntax_language_for_path("query.sql"),
Some(DiffSyntaxLanguage::Sql)
);
}
#[test]
fn treesitter_variable_capture_is_not_colored() {
assert_eq!(super::syntax_kind_from_capture_name("variable"), None);

View file

@ -25,9 +25,12 @@ impl GitGpuiView {
this.worktree_preview_segments_cache.clear();
}
let language = (lines.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING)
.then(|| diff_syntax_language_for_path(path.to_string_lossy().as_ref()))
.flatten();
let syntax_mode = if lines.len() <= MAX_LINES_FOR_SYNTAX_HIGHLIGHTING {
DiffSyntaxMode::Auto
} else {
DiffSyntaxMode::HeuristicOnly
};
let language = diff_syntax_language_for_path(path.to_string_lossy().as_ref());
let highlight_new_file = this.untracked_worktree_preview_path().is_some()
|| this.added_file_preview_abs_path().is_some()
@ -47,7 +50,7 @@ impl GitGpuiView {
&[],
"",
language,
DiffSyntaxMode::Auto,
syntax_mode,
None,
)
});
@ -114,7 +117,7 @@ impl GitGpuiView {
let col_sha = this.history_col_sha;
let (show_date, show_sha) = this.history_visible_columns();
let (show_working_tree_summary_row, unstaged_counts, staged_counts) = match &repo.status {
let (show_working_tree_summary_row, worktree_counts) = match &repo.status {
Loadable::Ready(status) => {
let count_for = |entries: &[FileStatus]| {
let mut added = 0usize;
@ -131,13 +134,18 @@ impl GitGpuiView {
}
(added, modified, deleted)
};
let unstaged_counts = count_for(&status.unstaged);
let staged_counts = count_for(&status.staged);
(
!status.unstaged.is_empty(),
count_for(&status.unstaged),
count_for(&status.staged),
!status.unstaged.is_empty() || !status.staged.is_empty(),
(
unstaged_counts.0 + staged_counts.0,
unstaged_counts.1 + staged_counts.1,
unstaged_counts.2 + staged_counts.2,
),
)
}
_ => (false, (0, 0, 0), (0, 0, 0)),
_ => (false, (0, 0, 0)),
};
let page = match &repo.log {
@ -165,8 +173,7 @@ impl GitGpuiView {
worktree_node_color,
repo.id,
selected,
unstaged_counts,
staged_counts,
worktree_counts,
cx,
));
}
@ -180,6 +187,21 @@ impl GitGpuiView {
let commit_ix = cache.visible_indices.get(visible_ix).copied()?;
let commit = page.commits.get(commit_ix)?;
let graph_row = cache.graph_rows.get(visible_ix)?;
let mut graph_row_with_incoming;
let graph_row = if show_working_tree_summary_row && visible_ix == 0 {
graph_row_with_incoming = graph_row.clone();
if !graph_row_with_incoming
.incoming_ids
.contains(&graph_row_with_incoming.node_id)
{
graph_row_with_incoming
.incoming_ids
.push(graph_row_with_incoming.node_id);
}
&graph_row_with_incoming
} else {
graph_row
};
let refs = commit_refs(repo, commit);
let when = format_relative_time(commit.time);
let selected = repo.selected_commit.as_ref() == Some(&commit.id);
@ -600,11 +622,9 @@ fn working_tree_summary_history_row(
node_color: gpui::Rgba,
repo_id: RepoId,
selected: bool,
unstaged: (usize, usize, usize),
staged: (usize, usize, usize),
counts: (usize, usize, usize),
cx: &mut gpui::Context<GitGpuiView>,
) -> AnyElement {
let staged_total = staged.0 + staged.1 + staged.2;
let icon_count = |icon: &'static str, color: gpui::Rgba, count: usize| {
div()
.flex()
@ -626,36 +646,23 @@ fn working_tree_summary_history_row(
.into_any_element()
};
let group = |label: &'static str, (added, modified, deleted): (usize, usize, usize)| {
let mut parts: Vec<AnyElement> = Vec::new();
if modified > 0 {
parts.push(icon_count("", theme.colors.warning, modified));
}
if added > 0 {
parts.push(icon_count("+", theme.colors.success, added));
}
if deleted > 0 {
parts.push(icon_count("", theme.colors.danger, deleted));
}
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.text_xs()
.text_color(theme.colors.text_muted)
.whitespace_nowrap()
.child(label),
)
.children(parts)
.into_any_element()
};
let (added, modified, deleted) = counts;
let mut parts: Vec<AnyElement> = Vec::new();
if modified > 0 {
parts.push(icon_count("", theme.colors.warning, modified));
}
if added > 0 {
parts.push(icon_count("+", theme.colors.success, added));
}
if deleted > 0 {
parts.push(icon_count("", theme.colors.danger, deleted));
}
let black = gpui::rgba(0x000000ff);
let circle = gpui::canvas(
|_, _, _| (),
move |bounds, _, window, _cx| {
use gpui::{PathBuilder, fill, point, px, size};
let r = px(3.0);
let border = px(1.0);
let outer = r + border;
@ -666,6 +673,16 @@ fn working_tree_summary_history_row(
bounds.left() + node_x,
bounds.top() + bounds.size.height / 2.0,
);
// Connect the working tree node into the history graph below.
let stroke_width = px(1.6);
let mut path = PathBuilder::stroke(stroke_width);
path.move_to(point(center.x, center.y));
path.line_to(point(center.x, bounds.bottom()));
if let Ok(p) = path.build() {
window.paint_path(p, node_color);
}
window.paint_quad(
fill(
gpui::Bounds::new(
@ -690,11 +707,10 @@ fn working_tree_summary_history_row(
let mut row = div()
.id(("history_worktree_summary", repo_id.0))
.h(px(28.0))
.h(px(24.0))
.flex()
.w_full()
.items_center()
.gap_2()
.px_2()
.hover(move |s| s.bg(theme.colors.hover))
.active(move |s| s.bg(theme.colors.active))
@ -703,34 +719,35 @@ fn working_tree_summary_history_row(
.w(col_branch)
.text_xs()
.text_color(theme.colors.text_muted)
.line_clamp(1)
.whitespace_nowrap()
.child("Working tree"),
.child(div()),
)
.child(div().w(col_graph).h_full().child(circle))
.child(
div()
.flex_1()
.min_w(px(0.0))
.w(col_graph)
.h_full()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_2()
.min_w(px(0.0))
.child(
div()
.text_sm()
.line_clamp(1)
.whitespace_nowrap()
.child("Uncommitted changes"),
)
.child(group("Unstaged", unstaged))
.when(staged_total > 0, |this| this.child(group("Staged", staged))),
),
.justify_center()
.overflow_hidden()
.child(circle),
)
.child(div().flex_1().min_w(px(0.0)).flex().items_center().child({
let mut summary = div().flex_1().min_w(px(0.0)).flex().items_center().gap_2();
summary = summary.child(
div()
.flex_1()
.min_w(px(0.0))
.text_sm()
.line_clamp(1)
.whitespace_nowrap()
.child("Uncommitted changes"),
);
if !parts.is_empty() {
summary = summary.child(div().flex().items_center().gap_2().children(parts));
}
summary
}))
.when(show_date, |row| {
row.child(
div()
@ -748,8 +765,7 @@ fn working_tree_summary_history_row(
this.store.dispatch(Msg::ClearCommitSelection { repo_id });
this.store.dispatch(Msg::ClearDiffSelection { repo_id });
cx.notify();
}))
;
}));
if selected {
row = row.bg(with_alpha(theme.colors.accent, 0.15));

View file

@ -33,5 +33,4 @@ mod history;
mod sidebar;
mod status;
#[cfg(feature = "bench")]
pub(crate) mod benchmarks;

View file

@ -294,6 +294,7 @@ impl GitGpuiView {
is_upstream,
} => {
let name_for_tooltip: SharedString = name.clone();
let full_name_for_checkout = name.to_string();
let branch_icon_color = if muted {
theme.colors.text_muted
} else {
@ -342,8 +343,8 @@ impl GitGpuiView {
has_right = true;
right = right.child(
div()
.px_1()
.py(px(1.0))
.px(px(3.0))
.py(px(0.0))
.rounded(px(999.0))
.text_xs()
.text_color(theme.colors.text_muted)
@ -399,6 +400,34 @@ impl GitGpuiView {
}
row = row
.on_click(cx.listener(move |this, e: &ClickEvent, _w, cx| {
if !e.standard_click() || e.click_count() < 2 {
return;
}
match section {
BranchSection::Local => {
this.store.dispatch(Msg::CheckoutBranch {
repo_id,
name: full_name_for_checkout.clone(),
});
this.rebuild_diff_cache();
cx.notify();
}
BranchSection::Remote => {
if let Some((remote, branch)) =
full_name_for_checkout.split_once('/')
{
this.store.dispatch(Msg::CheckoutRemoteBranch {
repo_id,
remote: remote.to_string(),
name: branch.to_string(),
});
this.rebuild_diff_cache();
cx.notify();
}
}
}
}))
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, e: &MouseDownEvent, window, cx| {
@ -460,7 +489,7 @@ impl GitGpuiView {
let (icon, color) = match f.kind {
FileStatusKind::Added => (Some("+"), theme.colors.success),
FileStatusKind::Modified => (Some(""), theme.colors.warning),
FileStatusKind::Deleted => (None, theme.colors.text_muted),
FileStatusKind::Deleted => (Some(""), theme.colors.danger),
FileStatusKind::Renamed => (Some(""), theme.colors.accent),
FileStatusKind::Untracked => (Some("?"), theme.colors.warning),
FileStatusKind::Conflicted => (Some("!"), theme.colors.danger),

View file

@ -83,13 +83,13 @@ impl Button {
let (bg, hover_bg, active_bg, border, hover_border, active_border, text) = match self.style
{
ButtonStyle::Filled => (
transparent,
hover_overlay,
active_overlay,
with_alpha(theme.colors.accent, 0.90),
with_alpha(theme.colors.accent, 1.00),
with_alpha(theme.colors.accent, 1.00),
theme.colors.accent,
with_alpha(theme.colors.accent, 0.85),
with_alpha(theme.colors.accent, 0.78),
with_alpha(theme.colors.accent, 0.9),
with_alpha(theme.colors.accent, 0.9),
with_alpha(theme.colors.accent, 0.9),
theme.colors.window_bg,
),
ButtonStyle::Outlined => (
transparent,
@ -200,7 +200,7 @@ impl Button {
}
fn looks_like_icon_button(label: &str) -> bool {
matches!(label.trim(), "" | "" | "" | "" | "" | "" | "" | "")
matches!(label.trim(), "" | "" | "" | "" | "" | "" | "" | "")
|| (label.chars().count() <= 2 && !label.chars().any(|c| c.is_alphanumeric()))
}

View file

@ -40,10 +40,10 @@ pub fn toast(theme: AppTheme, kind: ToastKind, message: impl IntoElement) -> Div
let accent = with_alpha(accent, if theme.is_dark { 0.85 } else { 0.75 });
div()
.min_w(px(260.0))
.max_w(px(520.0))
.min_w(px(300.0))
.max_w(px(760.0))
.flex()
.items_center()
.items_start()
.gap_2()
.bg(bg)
.border_1()
@ -52,7 +52,14 @@ pub fn toast(theme: AppTheme, kind: ToastKind, message: impl IntoElement) -> Div
.shadow_sm()
.text_sm()
.text_color(theme.colors.text)
.child(div().w(px(3.0)).h(px(18.0)).bg(accent).rounded(px(2.0)))
.child(
div()
.w(px(3.0))
.h_full()
.min_h(px(18.0))
.bg(accent)
.rounded(px(2.0)),
)
.child(div().flex_1().px_2().py_1().child(message))
}

View file

@ -229,7 +229,7 @@ name = "action_macros"
path = "tests/action_macros.rs"
[dependencies.anyhow]
version = "1.0.86"
version = "1.0.100"
[dependencies.async-task]
version = "4.7"
@ -259,10 +259,11 @@ version = "0.2.2"
package = "gpui_collections"
[dependencies.ctor]
version = "0.4.0"
version = "0.6.3"
[dependencies.derive_more]
version = "0.99.17"
version = "2.1.1"
features = ["full"]
[dependencies.etagere]
version = "0.2"
@ -279,10 +280,10 @@ version = "0.2.2"
package = "gpui_http_client"
[dependencies.image]
version = "0.25.1"
version = "0.25.9"
[dependencies.inventory]
version = "0.3.19"
version = "0.3.21"
[dependencies.itertools]
version = "0.14.0"
@ -291,7 +292,7 @@ version = "0.14.0"
version = "0.2"
[dependencies.log]
version = "0.4.16"
version = "0.4.29"
features = [
"kv_unstable_serde",
"serde",
@ -301,13 +302,13 @@ features = [
version = "1.0"
[dependencies.num_cpus]
version = "1.13"
version = "1.17"
[dependencies.parking]
version = "2.0.0"
version = "2.2.1"
[dependencies.parking_lot]
version = "0.12.1"
version = "0.12.5"
[dependencies.pin-project]
version = "1.1.10"
@ -331,7 +332,7 @@ version = "0.2.2"
package = "gpui_refineable"
[dependencies.resvg]
version = "0.45.0"
version = "0.46.0"
features = [
"text",
"system-fonts",
@ -340,7 +341,7 @@ features = [
default-features = false
[dependencies.schemars]
version = "1.0"
version = "1.2"
features = ["indexmap2"]
[dependencies.seahash]
@ -351,31 +352,31 @@ version = "0.2.2"
package = "gpui_semantic_version"
[dependencies.serde]
version = "1.0.221"
version = "1.0.228"
features = [
"derive",
"rc",
]
[dependencies.serde_json]
version = "1.0.144"
version = "1.0.149"
features = [
"preserve_order",
"raw_value",
]
[dependencies.slotmap]
version = "1.0.6"
version = "1.1.1"
[dependencies.smallvec]
version = "1.6"
version = "1.15"
features = ["union"]
[dependencies.smol]
version = "2.0"
[dependencies.stacksafe]
version = "0.1"
version = "1.0"
[dependencies.strum]
version = "0.27.2"
@ -389,10 +390,10 @@ package = "gpui_sum_tree"
version = "=0.9.0"
[dependencies.thiserror]
version = "2.0.12"
version = "2.0.18"
[dependencies.usvg]
version = "0.45.0"
version = "0.46.0"
default-features = false
[dependencies.util]
@ -404,7 +405,7 @@ version = "0.2.2"
package = "gpui_util_macros"
[dependencies.uuid]
version = "1.1.2"
version = "1.19.0"
features = [
"v4",
"v5",
@ -436,14 +437,14 @@ version = "1.0"
features = ["extra"]
[dev-dependencies.pretty_assertions]
version = "1.3.0"
version = "1.4.1"
features = ["unstable"]
[dev-dependencies.rand]
version = "0.9"
[dev-dependencies.unicode-segmentation]
version = "1.10"
version = "1.12"
[dev-dependencies.util]
version = "0.2.2"
@ -477,22 +478,22 @@ version = "1"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.calloop]
version = "0.13.0"
version = "0.14.3"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.calloop-wayland-source]
version = "0.3.0"
version = "0.4.1"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.cosmic-text]
version = "0.14.0"
version = "0.16.0"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.filedescriptor]
version = "0.8.2"
version = "0.8.3"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.flume]
version = "0.11"
version = "0.12"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.font-kit]
version = "0.14.1-zed"
@ -522,11 +523,11 @@ features = [
default-features = false
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.open]
version = "5.2.0"
version = "5.3.3"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-backend]
version = "0.3.3"
version = "0.3.12"
features = [
"client_system",
"dlopen",
@ -534,15 +535,15 @@ features = [
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-client]
version = "0.31.2"
version = "0.31.12"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-cursor]
version = "0.31.1"
version = "0.31.12"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-protocols]
version = "0.31.2"
version = "0.32.10"
features = [
"client",
"staging",
@ -551,7 +552,7 @@ features = [
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.wayland-protocols-plasma]
version = "0.2.0"
version = "0.3.10"
features = ["client"]
optional = true
@ -560,7 +561,7 @@ version = "0.9.3"
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.x11rb]
version = "0.13.1"
version = "0.13.2"
features = [
"allow-unsafe-code",
"xkb",
@ -582,7 +583,7 @@ optional = true
package = "zed-xim"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies.xkbcommon]
version = "0.8.0"
version = "0.9.0"
features = [
"wayland",
"x11",
@ -590,7 +591,7 @@ features = [
optional = true
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.build-dependencies.naga]
version = "25.0"
version = "28.0"
features = ["wgsl-in"]
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies.pathfinder_geometry]
@ -615,10 +616,10 @@ version = "=0.2.0"
version = "=0.10.0"
[target.'cfg(target_os = "macos")'.dependencies.core-foundation-sys]
version = "0.8.6"
version = "0.8.7"
[target.'cfg(target_os = "macos")'.dependencies.core-graphics]
version = "0.24"
version = "0.25"
[target.'cfg(target_os = "macos")'.dependencies.core-text]
version = "21"
@ -636,7 +637,7 @@ package = "zed-font-kit"
version = "0.5"
[target.'cfg(target_os = "macos")'.dependencies.log]
version = "0.4.16"
version = "0.4.29"
features = [
"kv_unstable_serde",
"serde",
@ -647,7 +648,7 @@ version = "0.2.2"
package = "gpui_media"
[target.'cfg(target_os = "macos")'.dependencies.metal]
version = "0.29"
version = "0.33"
[target.'cfg(target_os = "macos")'.dependencies.objc]
version = "0.2"
@ -661,24 +662,24 @@ version = "0.3"
optional = true
[target.'cfg(target_os = "macos")'.build-dependencies.bindgen]
version = "0.71"
version = "0.72"
[target.'cfg(target_os = "macos")'.build-dependencies.cbindgen]
version = "0.28.0"
version = "0.29.2"
default-features = false
[target.'cfg(target_os = "macos")'.build-dependencies.naga]
version = "25.0"
version = "28.0"
features = ["wgsl-in"]
[target.'cfg(target_os = "windows")'.dependencies.flume]
version = "0.11"
version = "0.12"
[target.'cfg(target_os = "windows")'.dependencies.rand]
version = "0.9"
[target.'cfg(target_os = "windows")'.dependencies.windows]
version = "0.61"
version = "0.62"
features = [
"Foundation_Numerics",
"Storage_Search",
@ -729,13 +730,13 @@ features = [
]
[target.'cfg(target_os = "windows")'.dependencies.windows-core]
version = "0.61"
version = "0.62"
[target.'cfg(target_os = "windows")'.dependencies.windows-numerics]
version = "0.2"
version = "0.3"
[target.'cfg(target_os = "windows")'.dependencies.windows-registry]
version = "0.5"
version = "0.6"
[target.'cfg(target_os = "windows")'.build-dependencies.embed-resource]
version = "3.0"

View file

@ -225,9 +225,15 @@ impl CosmicTextSystemState {
let mut loaded_font_ids = SmallVec::new();
for (font_id, postscript_name) in families {
let weight = self
.font_system
.db()
.face(font_id)
.map(|face| face.weight)
.unwrap_or_default();
let font = self
.font_system
.get_font(font_id)
.get_font(font_id, weight)
.context("Could not load font")?;
// HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback.
@ -274,6 +280,12 @@ impl CosmicTextSystemState {
fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result<Bounds<DevicePixels>> {
let font = &self.loaded_fonts[params.font_id.0].font;
let weight = self
.font_system
.db()
.face(font.id())
.map(|face| face.weight)
.unwrap_or_default();
let subpixel_shift = point(
params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor,
params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor,
@ -287,6 +299,7 @@ impl CosmicTextSystemState {
params.glyph_id.0 as u16,
(params.font_size * params.scale_factor).into(),
(subpixel_shift.x, subpixel_shift.y.trunc()),
weight,
cosmic_text::CacheKeyFlags::empty(),
)
.0,
@ -310,6 +323,12 @@ impl CosmicTextSystemState {
} else {
let bitmap_size = glyph_bounds.size;
let font = &self.loaded_fonts[params.font_id.0].font;
let weight = self
.font_system
.db()
.face(font.id())
.map(|face| face.weight)
.unwrap_or_default();
let subpixel_shift = point(
params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor,
params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor,
@ -323,6 +342,7 @@ impl CosmicTextSystemState {
params.glyph_id.0 as u16,
(params.font_size * params.scale_factor).into(),
(subpixel_shift.x, subpixel_shift.y.trunc()),
weight,
cosmic_text::CacheKeyFlags::empty(),
)
.0,
@ -357,14 +377,17 @@ impl CosmicTextSystemState {
{
FontId(ix)
} else {
let font = self.font_system.get_font(id).unwrap();
let face = self.font_system.db().face(id).unwrap();
let (weight, is_known_emoji_font) = {
let face = self.font_system.db().face(id).unwrap();
(face.weight, check_is_known_emoji_font(&face.post_script_name))
};
let font = self.font_system.get_font(id, weight).unwrap();
let font_id = FontId(self.loaded_fonts.len());
self.loaded_fonts.push(LoadedFont {
font,
features: CosmicFontFeatures::new(),
is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name),
is_known_emoji_font,
});
font_id
@ -408,6 +431,7 @@ impl CosmicTextSystemState {
None,
&mut layout_lines,
None,
cosmic_text::Hinting::Disabled,
);
let layout = layout_lines.first().unwrap();

View file

@ -10,7 +10,7 @@ pub use gpui_macros::{
visibility_style_methods,
};
const ELLIPSIS: SharedString = SharedString::new_static("");
const ELLIPSIS: SharedString = SharedString::new_static("");
/// A trait for elements that can be styled.
/// Use this to opt-in to a utility CSS-like styling API.
@ -78,7 +78,7 @@ pub trait Styled: Sized {
self
}
/// Sets the truncate overflowing text with an ellipsis () if needed.
/// Sets the truncate overflowing text with an ellipsis () if needed.
/// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis)
fn text_ellipsis(mut self) -> Self {
self.text_style()
@ -118,7 +118,7 @@ pub trait Styled: Sized {
self.text_align(TextAlign::Right)
}
/// Sets the truncate to prevent text from wrapping and truncate overflowing text with an ellipsis () if needed.
/// Sets the truncate to prevent text from wrapping and truncate overflowing text with an ellipsis () if needed.
/// [Docs](https://tailwindcss.com/docs/text-overflow#truncate)
fn truncate(mut self) -> Self {
self.overflow_hidden().whitespace_nowrap().text_ellipsis()

View file

@ -514,8 +514,8 @@ mod tests {
perform_test(
&mut wrapper,
"aa bbb cccc ddddd eeee ffff gggg",
"aa bbb cccc ddddd eee",
"",
"aa bbb cccc ddddd eee",
"",
);
perform_test(
&mut wrapper,
@ -539,7 +539,7 @@ mod tests {
) {
let mut dummy_runs = generate_test_runs(run_lens);
assert_eq!(
wrapper.truncate_line(text.into(), line_width, "", &mut dummy_runs),
wrapper.truncate_line(text.into(), line_width, "", &mut dummy_runs),
result
);
for (run, result_len) in dummy_runs.iter().zip(result_run_len) {
@ -550,20 +550,20 @@ mod tests {
// Text: abcdefghijkl
// Runs: Run0 { len: 12, ... }
//
// Truncate res: abcd (truncate_at = 4)
// Run res: Run0 { string: abcd, len: 7, ... }
perform_test(&mut wrapper, "abcdefghijkl", "abcd", &[12], &[7], px(50.));
// Truncate res: abcd (truncate_at = 4)
// Run res: Run0 { string: abcd, len: 7, ... }
perform_test(&mut wrapper, "abcdefghijkl", "abcd", &[12], &[7], px(50.));
// Case 1: Drop some runs
// Text: abcdefghijkl
// Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
//
// Truncate res: abcdef (truncate_at = 6)
// Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef, len:
// Truncate res: abcdef (truncate_at = 6)
// Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef, len:
// 5, ... }
perform_test(
&mut wrapper,
"abcdefghijkl",
"abcdef",
"abcdef",
&[4, 4, 4],
&[4, 5],
px(70.),
@ -572,13 +572,13 @@ mod tests {
// Text: abcdefghijkl
// Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
//
// Truncate res: abcdefgh (truncate_at = 8)
// Truncate res: abcdefgh (truncate_at = 8)
// Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
// 4, ... }, Run2 { string: , len: 3, ... }
// 4, ... }, Run2 { string: , len: 3, ... }
perform_test(
&mut wrapper,
"abcdefghijkl",
"abcdefgh",
"abcdefgh",
&[4, 4, 4],
&[4, 4, 3],
px(90.),
@ -589,7 +589,7 @@ mod tests {
fn test_update_run_after_truncation() {
fn perform_test(result: &str, run_lens: &[usize], result_run_lens: &[usize]) {
let mut dummy_runs = generate_test_runs(run_lens);
update_runs_after_truncation(result, "", &mut dummy_runs);
update_runs_after_truncation(result, "", &mut dummy_runs);
for (run, result_len) in dummy_runs.iter().zip(result_run_lens) {
assert_eq!(run.len, *result_len);
}
@ -598,25 +598,25 @@ mod tests {
// Text: abcdefghijkl
// Runs: Run0 { len: 12, ... }
//
// Truncate res: abcd (truncate_at = 4)
// Run res: Run0 { string: abcd, len: 7, ... }
perform_test("abcd", &[12], &[7]);
// Truncate res: abcd (truncate_at = 4)
// Run res: Run0 { string: abcd, len: 7, ... }
perform_test("abcd", &[12], &[7]);
// Case 1: Drop some runs
// Text: abcdefghijkl
// Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
//
// Truncate res: abcdef (truncate_at = 6)
// Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef, len:
// Truncate res: abcdef (truncate_at = 6)
// Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: ef, len:
// 5, ... }
perform_test("abcdef", &[4, 4, 4], &[4, 5]);
perform_test("abcdef", &[4, 4, 4], &[4, 5]);
// Case 2: Truncate at start of some run
// Text: abcdefghijkl
// Runs: Run0 { len: 4, ... }, Run1 { len: 4, ... }, Run2 { len: 4, ... }
//
// Truncate res: abcdefgh (truncate_at = 8)
// Truncate res: abcdefgh (truncate_at = 8)
// Runs res: Run0 { string: abcd, len: 4, ... }, Run1 { string: efgh, len:
// 4, ... }, Run2 { string: , len: 3, ... }
perform_test("abcdefgh", &[4, 4, 4], &[4, 4, 3]);
// 4, ... }, Run2 { string: , len: 3, ... }
perform_test("abcdefgh", &[4, 4, 4], &[4, 4, 3]);
}
#[test]

24
docs/LINUX_DESKTOP.md Normal file
View file

@ -0,0 +1,24 @@
# Linux desktop integration (GNOME/Wayland)
GitGpui sets `app_id` to `gitgpui` so GNOME can associate the running window with a desktop entry.
To make GNOME show the correct app name/icon, run:
```sh
./scripts/install-linux.sh
```
Manual install (what the script does):
```sh
install -Dm644 assets/linux/gitgpui.desktop \
~/.local/share/applications/gitgpui.desktop
install -Dm644 assets/gitgpui_logo.svg \
~/.local/share/icons/hicolor/scalable/apps/gitgpui.svg
update-desktop-database ~/.local/share/applications >/dev/null 2>&1 || true
gtk-update-icon-cache ~/.local/share/icons/hicolor >/dev/null 2>&1 || true
```
Then restart GNOME Shell (or log out/in).

69
scripts/install-linux.sh Executable file
View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/install-linux.sh [--release|--debug] [--prefix PATH] [--no-build]
Installs:
- binary to <prefix>/bin/gitgpui-app
- desktop entry to ~/.local/share/applications/gitgpui.desktop
- icon to ~/.local/share/icons/hicolor/scalable/apps/gitgpui.svg
Defaults:
--release, --prefix ~/.local, build if needed
EOF
}
mode="release"
prefix="${HOME}/.local"
build=1
while [[ $# -gt 0 ]]; do
case "$1" in
--release) mode="release"; shift ;;
--debug) mode="debug"; shift ;;
--prefix) prefix="$2"; shift 2 ;;
--no-build) build=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
bin_src="${repo_root}/target/${mode}/gitgpui-app"
if [[ $build -eq 1 && ! -x "$bin_src" ]]; then
(cd "$repo_root" && cargo build -p gitgpui-app --${mode})
fi
if [[ ! -x "$bin_src" ]]; then
echo "Binary not found or not executable: $bin_src" >&2
echo "Build first or omit --no-build." >&2
exit 1
fi
bindir="${prefix}/bin"
appdir="${XDG_DATA_HOME:-${HOME}/.local/share}/applications"
icondir="${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor/scalable/apps"
install -Dm755 "$bin_src" "${bindir}/gitgpui-app"
# Install desktop file with absolute Exec path so it works even if ~/.local/bin isn't on PATH.
tmp_desktop="$(mktemp)"
trap 'rm -f "$tmp_desktop"' EXIT
sed "s|^Exec=.*$|Exec=${bindir}/gitgpui-app|g" \
"${repo_root}/assets/linux/gitgpui.desktop" >"$tmp_desktop"
install -Dm644 "$tmp_desktop" "${appdir}/gitgpui.desktop"
install -Dm644 "${repo_root}/assets/gitgpui_logo.svg" \
"${icondir}/gitgpui.svg"
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$appdir" >/dev/null 2>&1 || true
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache "${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor" >/dev/null 2>&1 || true
echo "Installed GitGpui:"
echo " ${bindir}/gitgpui-app"
echo " ${appdir}/gitgpui.desktop"
echo " ${icondir}/gitgpui.svg"
echo "If GNOME still shows a generic icon, log out/in (or restart GNOME Shell)."

View file

@ -0,0 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
scripts/profile-callgrind.sh [options] [-- <app-args...>]
Runs GitGpui under Valgrind Callgrind with instrumentation OFF at startup,
then lets you toggle collection ON/OFF interactively.
Options:
--bin NAME Binary name to run (default: gitgpui-app)
--package NAME Cargo package to build (default: gitgpui-app)
--release Shortcut for --profile release
--debug Shortcut for --profile dev
--profile NAME Cargo profile to build/run (default: release)
--features LIST Cargo features to enable (passed to cargo build)
--no-default-features Disable default Cargo features
--no-build Do not run cargo build (fail if binary missing)
--out FILE Callgrind output file pattern (default: callgrind.out.%p)
(%p expands to PID; recommended to avoid collisions)
--open Open output with kcachegrind after exit (if installed)
-h, --help Show help
Examples:
scripts/profile-callgrind.sh
scripts/profile-callgrind.sh --profile dev -- --help
scripts/profile-callgrind.sh --features ui-gpui,gix -- /path/to/repo
scripts/profile-callgrind.sh --out callgrind.out.%p --open
EOF
}
bin_name="gitgpui-app"
package_name="gitgpui-app"
cargo_profile="release"
features=""
no_default_features=0
build=1
out_pattern="callgrind.out.%p"
open_after=0
app_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--bin) bin_name="$2"; shift 2 ;;
--package) package_name="$2"; shift 2 ;;
--release) cargo_profile="release"; shift ;;
--debug) cargo_profile="dev"; shift ;;
--profile) cargo_profile="$2"; shift 2 ;;
--features) features="$2"; shift 2 ;;
--no-default-features) no_default_features=1; shift ;;
--no-build) build=0; shift ;;
--out) out_pattern="$2"; shift 2 ;;
--open) open_after=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; app_args+=("$@"); break ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
target_dir="${CARGO_TARGET_DIR:-${repo_root}/target}"
profile_dir="$cargo_profile"
case "$cargo_profile" in
dev) profile_dir="debug" ;;
release) profile_dir="release" ;;
esac
bin_path="${target_dir}/${profile_dir}/${bin_name}"
if [[ $build -eq 1 && ! -x "$bin_path" ]]; then
(
cd "$repo_root"
# Ensure line info for attribution in callgrind output (especially for release).
profile_env="${cargo_profile^^}"
profile_env="${profile_env//-/_}"
cargo_args=(build -p "$package_name" --profile "$cargo_profile")
[[ -n "$features" ]] && cargo_args+=(--features "$features")
[[ $no_default_features -eq 1 ]] && cargo_args+=(--no-default-features)
env \
"CARGO_PROFILE_${profile_env}_DEBUG=true" \
"CARGO_PROFILE_${profile_env}_STRIP=none" \
cargo "${cargo_args[@]}"
)
fi
if [[ ! -x "$bin_path" ]]; then
echo "Binary not found or not executable: $bin_path" >&2
echo "Build first or omit --no-build." >&2
exit 1
fi
if ! command -v valgrind >/dev/null 2>&1; then
echo "valgrind not found on PATH." >&2
exit 1
fi
if ! command -v callgrind_control >/dev/null 2>&1; then
echo "callgrind_control not found on PATH." >&2
exit 1
fi
vgdb_dir="$(mktemp -d -t gitgpui-callgrind.XXXXXX)"
vgdb_prefix="${vgdb_dir}/vgdb-pipe"
cleanup() {
rm -rf "$vgdb_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT
cd "$repo_root"
echo "Starting under Callgrind:"
echo " $bin_path ${app_args[*]:-}"
echo
echo "When the app is ready, press Enter to start collecting."
echo "When you want to stop collecting, press Enter again."
echo
valgrind \
--tool=callgrind \
--callgrind-out-file="$out_pattern" \
--dump-instr=yes \
--instr-atstart=no \
--collect-jumps=yes \
--sigill-diagnostics=no \
--error-limit=no \
--vgdb=yes \
--vgdb-error=0 \
--vgdb-prefix="$vgdb_prefix" \
"$bin_path" "${app_args[@]}" &
valgrind_pid=$!
out_file="${out_pattern//%p/${valgrind_pid}}"
out_path="$out_file"
[[ "$out_path" != /* ]] && out_path="${repo_root}/${out_path}"
echo "Valgrind pid: $valgrind_pid"
echo "Manual toggles (from another shell):"
echo " callgrind_control --vgdb-prefix=\"$vgdb_prefix\" -i on $valgrind_pid"
echo " callgrind_control --vgdb-prefix=\"$vgdb_prefix\" -i off $valgrind_pid"
echo
read -r _ || true
enabled=0
for _try in {1..50}; do
if callgrind_control --vgdb-prefix="$vgdb_prefix" -i on "$valgrind_pid" >/dev/null 2>&1; then
enabled=1
break
fi
sleep 0.1
done
if [[ $enabled -ne 1 ]]; then
callgrind_control --vgdb-prefix="$vgdb_prefix" -i on "$valgrind_pid"
fi
echo "Collecting (pid $valgrind_pid). Press Enter to stop collecting."
read -r _ || true
callgrind_control --vgdb-prefix="$vgdb_prefix" -i off "$valgrind_pid" >/dev/null
echo "Instrumentation off. Quit the app to flush final output to:"
echo " $out_path"
wait "$valgrind_pid"
if [[ $open_after -eq 1 ]]; then
if command -v kcachegrind >/dev/null 2>&1; then
kcachegrind "$out_path" >/dev/null 2>&1 &
else
echo "kcachegrind not found on PATH; output is at: $out_path" >&2
fi
fi

28
scripts/uninstall-linux.sh Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
prefix="${HOME}/.local"
while [[ $# -gt 0 ]]; do
case "$1" in
--prefix) prefix="$2"; shift 2 ;;
-h|--help)
echo "Usage: scripts/uninstall-linux.sh [--prefix PATH]"
exit 0
;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
bindir="${prefix}/bin"
appdir="${XDG_DATA_HOME:-${HOME}/.local/share}/applications"
icondir="${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor/scalable/apps"
rm -f "${bindir}/gitgpui-app"
rm -f "${appdir}/gitgpui.desktop"
rm -f "${icondir}/gitgpui.svg"
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$appdir" >/dev/null 2>&1 || true
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache "${XDG_DATA_HOME:-${HOME}/.local/share}/icons/hicolor" >/dev/null 2>&1 || true
echo "Uninstalled GitGpui desktop integration from ${prefix} and ~/.local/share."