Merge pull request #424 from l0ng-ai/feat/scm-foundation

feat(scm): a full Source Control panel, decorations and commit history
This commit is contained in:
l0ng-ai
2026-08-10 14:12:21 +08:00
committed by GitHub
50 changed files with 21660 additions and 1551 deletions
Generated
+1
View File
@@ -9846,6 +9846,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"smallvec",
"smol",
"system-configuration",
"system-configuration-sys",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M2.9 12h5.5"/><path d="M15.6 12h5.5"/><circle cx="12" cy="12" r="3.6"/></svg>

After

Width:  |  Height:  |  Size: 270 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 0 1 8.5-8.5 9.2 9.2 0 0 1 6.37 2.59L20.5 8.2"/><path d="M20.5 3.5v4.7h-4.7"/><path d="M20.5 12a8.5 8.5 0 0 1-8.5 8.5 9.2 9.2 0 0 1-6.37-2.59L3.5 15.8"/><path d="M3.5 20.5v-4.7h4.7"/></svg>

After

Width:  |  Height:  |  Size: 399 B

+6
View File
@@ -48,6 +48,12 @@ sha2 = "0.11"
# implementation.
ignore = "0.4"
# Lane assignment for the commit graph (`core::git::log`) keeps a couple of
# parents and a handful of edges per row; a SmallVec keeps those off the heap
# for the shapes that make up almost all of a real history. Already in the tree
# via the GUI, so this pins no new code.
smallvec.workspace = true
# Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows,
# behind one blocking `Read`/`Write`/`resize` API. This is what lets
# `daemon::pane` share a single code path across platforms instead of
+30 -1
View File
@@ -168,6 +168,15 @@ pub struct Config {
pub right_panel_width: f32,
#[serde(default, deserialize_with = "de_lenient")]
pub right_panel_tab: RightPanelTab,
/// Global, not per-overlay — the same call VS Code's
/// `diffEditor.renderSideBySide` makes.
#[serde(default, deserialize_with = "de_lenient")]
pub diff_view: DiffViewMode,
/// The source control panel's history section starts collapsed: a graph
/// unfurling the first time someone opens the panel is a worse first
/// impression than one they asked for.
#[serde(default)]
pub scm_graph_expanded: bool,
#[serde(default, deserialize_with = "de_lenient")]
pub sidebar_grouping: SidebarGrouping,
#[serde(default = "default_true")]
@@ -508,6 +517,8 @@ impl Default for Config {
right_panel_visible: false,
right_panel_width: default_right_panel_width(),
right_panel_tab: RightPanelTab::Info,
diff_view: DiffViewMode::Split,
scm_graph_expanded: false,
sidebar_grouping: SidebarGrouping::Repo,
sidebar_diff_preview: true,
notify_on_command_finish: NotifyMode::Unfocused,
@@ -828,10 +839,28 @@ fn default_prefix() -> String {
pub enum RightPanelTab {
#[default]
Info,
Changes,
/// The source control panel. Renamed from `Changes` in place rather than
/// added alongside it: `rename` works in both directions, so a config
/// written by this version still says `"changes"` and an older build reads
/// it back unchanged. A fourth variant could not do that — the old build
/// would fall through `de_lenient` to `Info` and kick anyone who rolled
/// back off the panel they were sitting on. 260px has no room for a fourth
/// tab tile either.
#[serde(rename = "changes", alias = "scm", alias = "git")]
Scm,
Files,
}
/// How the diff overlay lays a file out. Side-by-side is the default because
/// that is what everyone already sees.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DiffViewMode {
#[default]
Split,
Unified,
}
fn default_right_panel_width() -> f32 {
260.
}
-386
View File
@@ -1,386 +0,0 @@
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::host::{Host, Output};
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct GitStatus {
pub branch: String,
pub added: u32,
pub removed: u32,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct RepoSnapshot {
pub root: PathBuf,
pub home: PathBuf,
pub branch: String,
pub counts: Option<(u32, u32)>,
}
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
let paths = git(
host,
cwd,
&[
"rev-parse",
"--path-format=absolute",
"--show-toplevel",
"--git-dir",
"--git-common-dir",
],
)?;
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
let root = PathBuf::from(lines.next()?);
let home = repo_home(&root, lines.next(), lines.next());
let branch = branch_name(host, cwd)?;
Some(RepoSnapshot {
home,
root,
branch,
counts: diff_numstat(host, cwd),
})
}
fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
return root.to_path_buf();
};
if git_dir == common {
return root.to_path_buf();
}
let common = Path::new(common);
match (common.file_name(), common.parent()) {
(Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(),
_ => common.to_path_buf(),
}
}
pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option<String> {
if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
let name = out.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?;
let sha = sha.trim();
(!sha.is_empty()).then(|| sha.to_string())
}
fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> {
let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?;
let mut added = 0u32;
let mut removed = 0u32;
for line in out.lines() {
let mut fields = line.split('\t');
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
added += n;
}
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
removed += n;
}
}
Some((added, removed))
}
pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option<String> {
let out = host.git(cwd, args).ok()?;
if !out.success() {
return None;
}
String::from_utf8(out.stdout).ok()
}
pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result<Output> {
if !cwd.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("git cwd does not exist: {}", cwd.display()),
));
}
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let out = crate::core::proc::hide_console(&mut cmd).output()?;
Ok(Output {
status: out.status.code(),
stdout: out.stdout,
stderr: out.stderr,
})
}
pub fn git_stream(
cwd: &Path,
args: &[&str],
mut on_chunk: impl FnMut(&[u8]) -> bool,
) -> io::Result<Option<i32>> {
if !cwd.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("git cwd does not exist: {}", cwd.display()),
));
}
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = crate::core::proc::hide_console(&mut cmd).spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("git stdout was not piped"))?;
let mut buf = vec![0u8; 64 * 1024];
let mut read_err = None;
loop {
match stdout.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if !on_chunk(&buf[..n]) {
break;
}
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => {
read_err = Some(e);
break;
}
}
}
drop(stdout);
let status = child.wait()?;
match read_err {
Some(e) => Err(e),
None => Ok(status.code()),
}
}
#[derive(Default)]
pub struct LineSplitter {
tail: Vec<u8>,
dropped: usize,
}
pub const MAX_LINE: usize = 1024 * 1024;
impl LineSplitter {
pub fn push(&mut self, chunk: &[u8], mut on_line: impl FnMut(&str)) {
let mut rest = chunk;
while let Some(nl) = rest.iter().position(|b| *b == b'\n') {
let (line, after) = rest.split_at(nl);
if self.tail.is_empty() && self.dropped == 0 && line.len() <= MAX_LINE {
on_line(&trim_cr(line));
} else {
self.keep(line);
let joined = std::mem::take(&mut self.tail);
let dropped = std::mem::take(&mut self.dropped);
emit(&joined, dropped, &mut on_line);
}
rest = &after[1..];
}
self.keep(rest);
}
pub fn finish(self, mut on_line: impl FnMut(&str)) {
if !self.tail.is_empty() || self.dropped > 0 {
emit(&self.tail, self.dropped, &mut on_line);
}
}
fn keep(&mut self, bytes: &[u8]) {
let room = MAX_LINE.saturating_sub(self.tail.len());
let take = room.min(bytes.len());
self.tail.extend_from_slice(&bytes[..take]);
self.dropped += bytes.len() - take;
}
}
fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) {
if dropped == 0 {
on_line(&trim_cr(line));
return;
}
let mut text = String::from_utf8_lossy(line).into_owned();
text.push_str(&format!(
" …[{dropped} more bytes on this line dropped: past tty7's {MAX_LINE}-byte line cap]"
));
on_line(&text);
}
fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> {
let line = match line.last() {
Some(b'\r') => &line[..line.len() - 1],
_ => line,
};
String::from_utf8_lossy(line)
}
#[cfg(test)]
mod tests {
use super::*;
fn h() -> crate::host::SharedHost {
crate::host::local::LocalHost::new()
}
#[test]
fn line_splitter_rejoins_across_chunks() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(b"alpha\nbe", |l| got.push(l.to_string()));
assert_eq!(got, ["alpha"], "only the complete line so far");
split.push(b"ta\ngamma\n", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got, ["alpha", "beta", "gamma"]);
}
#[test]
fn line_splitter_handles_crlf_and_a_missing_final_newline() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(b"one\r\ntwo\r\nthree", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got, ["one", "two", "three"]);
}
#[test]
fn line_splitter_replaces_invalid_utf8() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(b"caf\xe9\n", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got.len(), 1);
assert!(got[0].starts_with("caf"), "{:?}", got[0]);
}
#[test]
fn line_splitter_caps_one_absurd_line() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
let huge = vec![b'x'; MAX_LINE + 5_000];
split.push(b"before\n", |l| got.push(l.to_string()));
for piece in huge.chunks(64 * 1024) {
split.push(piece, |l| got.push(l.to_string()));
}
split.push(b"\nafter\n", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(
got.len(),
3,
"{:?}",
got.iter().map(|l| l.len()).collect::<Vec<_>>()
);
assert_eq!(got[0], "before");
assert_eq!(got[2], "after", "the next line is unaffected");
assert!(
got[1].starts_with(&"x".repeat(1000)),
"the kept prefix is the real content"
);
assert!(
got[1].contains("5000 more bytes"),
"the cut says how much went: {}",
&got[1][got[1].len() - 80..]
);
assert!(
got[1].len() < MAX_LINE + 200,
"nothing past the cap was retained"
);
}
#[test]
fn line_splitter_caps_a_final_unterminated_line() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(&vec![b'y'; MAX_LINE + 7], |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got.len(), 1);
assert!(got[0].contains("7 more bytes"), "{}", got[0]);
}
#[test]
fn streaming_and_buffered_reads_agree() {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
let args = ["log", "--oneline", "-n", "40"];
let Ok(code) = git_stream(here, &args, |_| true) else {
return;
};
if code != Some(0) {
return;
}
let mut streamed = Vec::new();
let mut split = LineSplitter::default();
git_stream(here, &args, |chunk| {
split.push(chunk, |l| streamed.push(l.to_string()));
true
})
.unwrap();
split.finish(|l| streamed.push(l.to_string()));
let buffered = git_output(here, &args).unwrap();
let expected: Vec<String> = String::from_utf8_lossy(&buffered.stdout)
.lines()
.map(str::to_string)
.collect();
assert_eq!(streamed, expected);
assert!(!streamed.is_empty(), "this repo has commits");
}
#[test]
fn non_repo_is_none() {
let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
let _ = std::fs::create_dir_all(&dir);
assert_eq!(probe(&*h(), &dir), None);
}
#[test]
fn missing_path_is_none() {
assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None);
}
#[test]
fn own_repo_has_a_branch_and_root() {
let here = env!("CARGO_MANIFEST_DIR");
if let Some(snap) = probe(&*h(), Path::new(here)) {
assert!(!snap.branch.is_empty());
assert!(Path::new(here).starts_with(&snap.root));
}
}
#[test]
fn repo_home_resolves_worktree_layouts() {
let root = Path::new("/repo/.wt/feat");
assert_eq!(
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
PathBuf::from("/repo")
);
assert_eq!(
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
PathBuf::from("/repo")
);
assert_eq!(
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
PathBuf::from("/bare.git")
);
assert_eq!(
repo_home(root, Some("/repo/.git"), None),
root.to_path_buf()
);
assert_eq!(repo_home(root, None, None), root.to_path_buf());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+682
View File
@@ -0,0 +1,682 @@
//! Everything tty7 knows about git, split by question:
//!
//! - this file — how a `git` process is *run* (and its output re-assembled)
//! - [`status`] — what the working tree looks like right now
//! - [`diff`] — what a particular patch looks like
//! - [`log`] — what the history looks like, and how to lay it out in lanes
//! - [`ops`] — how to *change* the repository
//!
//! Nothing here depends on gpui: the headless `tty7-server` answers the same
//! questions for a remote workspace that `LocalHost` answers for this machine,
//! and the conformance suite holds the two to the same behaviour.
pub mod diff;
pub mod log;
pub mod ops;
pub mod status;
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::host::{Host, Output};
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct GitStatus {
pub branch: String,
pub added: u32,
pub removed: u32,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct RepoSnapshot {
pub root: PathBuf,
pub home: PathBuf,
pub branch: String,
pub counts: Option<(u32, u32)>,
}
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<RepoSnapshot> {
let paths = git(
host,
cwd,
&[
"rev-parse",
"--path-format=absolute",
"--show-toplevel",
"--git-dir",
"--git-common-dir",
],
)?;
let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r']));
let root = PathBuf::from(lines.next()?);
let home = repo_home(&root, lines.next(), lines.next());
let branch = branch_name(host, cwd)?;
Some(RepoSnapshot {
home,
root,
branch,
counts: diff_numstat(host, cwd),
})
}
pub(crate) fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf {
let (Some(git_dir), Some(common)) = (git_dir, common_dir) else {
return root.to_path_buf();
};
if git_dir == common {
return root.to_path_buf();
}
let common = Path::new(common);
match (common.file_name(), common.parent()) {
(Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(),
_ => common.to_path_buf(),
}
}
pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option<String> {
if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
let name = out.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?;
let sha = sha.trim();
(!sha.is_empty()).then(|| sha.to_string())
}
fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> {
let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?;
let mut added = 0u32;
let mut removed = 0u32;
for line in out.lines() {
let mut fields = line.split('\t');
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
added += n;
}
if let Some(n) = fields.next().and_then(|s| s.parse::<u32>().ok()) {
removed += n;
}
}
Some((added, removed))
}
pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option<String> {
let out = host.git(cwd, args).ok()?;
if !out.success() {
return None;
}
String::from_utf8(out.stdout).ok()
}
pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result<Output> {
git_output_with_env(cwd, args, &[])
}
/// `git_output` plus extra environment.
///
/// What `LocalHost` passes is the no-prompt set: git and ssh have to fail
/// rather than block on a credential prompt nobody is watching. It goes on
/// every call, not just `fetch`/`pull`/`push` — a read path never prompts, so
/// carrying it there costs nothing, and a remote workspace only inherits it
/// because the far side's `LocalHost` applies the same rule to a request that
/// arrived over the wire with no "this one is a network op" bit on it.
///
/// A `None` value removes the variable instead of setting it.
pub fn git_output_with_env(
cwd: &Path,
args: &[&str],
env: &[(&str, Option<&str>)],
) -> io::Result<Output> {
if !cwd.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("git cwd does not exist: {}", cwd.display()),
));
}
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args(args)
// Keeps `git status` from refreshing and writing back `.git/index`.
// Beyond the obvious (never dirty a repo just by looking at it), this is
// what stops the SCM panel's own probes from waking the `.git` watcher
// that schedules them — the read path is provably write-free. Do not
// drop it.
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in env {
match value {
Some(v) => cmd.env(key, v),
None => cmd.env_remove(key),
};
}
let out = crate::core::proc::hide_console(&mut cmd).output()?;
Ok(Output {
status: out.status.code(),
stdout: out.stdout,
stderr: out.stderr,
})
}
pub fn git_stream(
cwd: &Path,
args: &[&str],
mut on_chunk: impl FnMut(&[u8]) -> bool,
) -> io::Result<Option<i32>> {
if !cwd.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("git cwd does not exist: {}", cwd.display()),
));
}
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(cwd)
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = crate::core::proc::hide_console(&mut cmd).spawn()?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| io::Error::other("git stdout was not piped"))?;
let mut buf = vec![0u8; 64 * 1024];
let mut read_err = None;
loop {
match stdout.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if !on_chunk(&buf[..n]) {
break;
}
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => {
read_err = Some(e);
break;
}
}
}
drop(stdout);
let status = child.wait()?;
match read_err {
Some(e) => Err(e),
None => Ok(status.code()),
}
}
#[derive(Default)]
pub struct LineSplitter {
tail: Vec<u8>,
dropped: usize,
}
pub const MAX_LINE: usize = 1024 * 1024;
impl LineSplitter {
pub fn push(&mut self, chunk: &[u8], mut on_line: impl FnMut(&str)) {
let mut rest = chunk;
while let Some(nl) = rest.iter().position(|b| *b == b'\n') {
let (line, after) = rest.split_at(nl);
if self.tail.is_empty() && self.dropped == 0 && line.len() <= MAX_LINE {
on_line(&trim_cr(line));
} else {
self.keep(line);
let joined = std::mem::take(&mut self.tail);
let dropped = std::mem::take(&mut self.dropped);
emit(&joined, dropped, &mut on_line);
}
rest = &after[1..];
}
self.keep(rest);
}
pub fn finish(self, mut on_line: impl FnMut(&str)) {
if !self.tail.is_empty() || self.dropped > 0 {
emit(&self.tail, self.dropped, &mut on_line);
}
}
fn keep(&mut self, bytes: &[u8]) {
let room = MAX_LINE.saturating_sub(self.tail.len());
let take = room.min(bytes.len());
self.tail.extend_from_slice(&bytes[..take]);
self.dropped += bytes.len() - take;
}
}
/// Splits a byte stream on an arbitrary separator, handing out whole records.
///
/// [`LineSplitter`]'s sibling, for the two git formats that are *not* newline
/// delimited: `--porcelain=v2 -z` (NUL) and `log --pretty` with an ASCII record
/// separator. Records come out as `&[u8]` rather than `&str` because a path in
/// a `-z` status is raw bytes and need not be UTF-8 at all — deciding what to
/// do about that belongs to the parser, not to the splitter.
pub struct RecordSplitter {
sep: u8,
tail: Vec<u8>,
/// The record being assembled overran [`MAX_RECORD`] and is now being
/// discarded up to its separator.
discarding: bool,
dropped: usize,
}
pub const MAX_RECORD: usize = 1024 * 1024;
impl RecordSplitter {
pub fn new(sep: u8) -> RecordSplitter {
RecordSplitter {
sep,
tail: Vec::new(),
discarding: false,
dropped: 0,
}
}
pub fn push(&mut self, chunk: &[u8], mut on_record: impl FnMut(&[u8])) {
let mut rest = chunk;
while let Some(at) = rest.iter().position(|b| *b == self.sep) {
let (record, after) = rest.split_at(at);
// An overlong record is dropped whole, never delivered cut short:
// a truncated record still parses — a commit body cut mid-way
// reads as the real message — and a wrong record is worse than a
// missing one the caller is told about.
if self.discarding {
self.discarding = false;
self.dropped += 1;
} else if self.tail.is_empty() {
// The common case — a whole record inside one chunk — is
// borrowed straight from the input, no copy.
if record.len() <= MAX_RECORD {
on_record(record);
} else {
self.dropped += 1;
}
} else {
self.keep(record);
if self.discarding {
self.discarding = false;
self.dropped += 1;
} else {
let joined = std::mem::take(&mut self.tail);
on_record(&joined);
}
}
rest = &after[1..];
}
self.keep(rest);
}
/// Emits a trailing record only if one was actually started — unlike
/// lines, well-formed `-z` output ends *with* a separator, so the common
/// case here is emitting nothing. Returns how many records were dropped
/// whole for overrunning [`MAX_RECORD`]; non-zero means the parse is
/// incomplete and the caller must not present it as the full answer.
#[must_use]
pub fn finish(mut self, mut on_record: impl FnMut(&[u8])) -> usize {
if self.discarding {
self.dropped += 1;
} else if !self.tail.is_empty() {
let joined = std::mem::take(&mut self.tail);
on_record(&joined);
}
self.dropped
}
fn keep(&mut self, bytes: &[u8]) {
if self.discarding {
return;
}
if self.tail.len() + bytes.len() > MAX_RECORD {
// Free what was buffered too — nobody will ever see this record.
self.tail.clear();
self.discarding = true;
return;
}
self.tail.extend_from_slice(bytes);
}
}
fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) {
if dropped == 0 {
on_line(&trim_cr(line));
return;
}
let mut text = String::from_utf8_lossy(line).into_owned();
text.push_str(&format!(
" …[{dropped} more bytes on this line dropped: past tty7's {MAX_LINE}-byte line cap]"
));
on_line(&text);
}
fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> {
let line = match line.last() {
Some(b'\r') => &line[..line.len() - 1],
_ => line,
};
String::from_utf8_lossy(line)
}
/// What [`status`], [`diff`], [`log`] and [`ops`] all need to drive a scratch
/// repository the same way. One copy here rather than four that drift.
#[cfg(test)]
pub(crate) mod test_support {
use std::path::Path;
/// The `-c` overrides a test's own git invocations carry.
///
/// `user.name`/`user.email` because a CI runner has neither and git refuses
/// to commit without them, and `commit.gpgsign` because a developer may
/// have signing on globally. The two line-ending settings are here for the
/// same reason: Git for Windows ships `core.autocrlf=true` in its *system*
/// config, so a fixture built with LF and read back is a fixture whose
/// bytes depend on which machine ran the test.
pub(crate) const PINS: [&str; 10] = [
"-c",
"user.name=tty7 test",
"-c",
"user.email=test@tty7.invalid",
"-c",
"commit.gpgsign=false",
"-c",
"core.autocrlf=false",
"-c",
"core.eol=lf",
];
/// The same settings, written into `<repo>/.git/config`.
///
/// [`PINS`] only reaches the commands the *test* runs. The code under test
/// runs its own git — `run_op`'s `checkout --`, `probe_status`,
/// `probe_diff` — with no overrides at all, and rightly so: it must obey
/// the repository the user actually has. So the line-ending rules have to
/// live in the repository, where every git that opens it will read them,
/// and repository config outranks the system config that put
/// `core.autocrlf=true` there.
///
/// Call this straight after `git init`, before anything is written or
/// checked out, so no blob is ever created under the other rules.
pub(crate) fn pin_repo_config(repo: &Path) -> bool {
PINS.chunks(2).all(|pair| {
let Some((key, value)) = pair[1].split_once('=') else {
return false;
};
super::git_output(repo, &["config", key, value]).is_ok_and(|out| out.success())
})
}
/// A path in the one spelling two halves of an assertion can agree on.
///
/// They do not naturally agree. Anything that came out of `git rev-parse`
/// is in git's dialect — forward slashes, no extended-length prefix, even
/// on Windows — while the expected side is usually built from
/// `fs::canonicalize`, which on Windows answers `\\?\C:\…`. Both name the
/// same directory and the Win32 file APIs take either, so the code under
/// test is right to pass git's answer straight through; it is only the
/// comparison that has to pick a spelling. On unix this is the identity.
///
/// Note this normalises the *spelling*, not the path: equality stays exact.
pub(crate) fn one_spelling(p: &Path) -> String {
let slashed = p.to_string_lossy().replace('\\', "/");
match slashed.strip_prefix("//?/") {
Some(bare) => bare.to_string(),
None => slashed,
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::one_spelling;
use super::*;
fn h() -> crate::host::SharedHost {
crate::host::local::LocalHost::new()
}
/// The mapping [`test_support::one_spelling`] promises, proven on literals
/// rather than on whatever this machine's temp directory happens to be —
/// a developer on unix never sees either of the Windows shapes, which is
/// exactly how a comparison against `fs::canonicalize` reached Windows CI
/// unnoticed in the first place.
#[test]
fn one_spelling_folds_the_two_ways_windows_writes_a_path() {
assert_eq!(
one_spelling(Path::new(r"\\?\C:\Users\x\repo\.git")),
"C:/Users/x/repo/.git",
"the extended-length prefix goes, and the separators match git's"
);
assert_eq!(
one_spelling(Path::new(r"C:\Users\x\repo\.git")),
"C:/Users/x/repo/.git",
"a plain Windows path lands on the same spelling"
);
assert_eq!(
one_spelling(Path::new("C:/Users/x/repo/.git")),
"C:/Users/x/repo/.git",
"what git itself answers is already in that spelling"
);
assert_eq!(
one_spelling(Path::new("/private/var/t/repo/.git")),
"/private/var/t/repo/.git",
"on unix it is the identity, which is why this never fired locally"
);
}
#[test]
fn line_splitter_rejoins_across_chunks() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(b"alpha\nbe", |l| got.push(l.to_string()));
assert_eq!(got, ["alpha"], "only the complete line so far");
split.push(b"ta\ngamma\n", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got, ["alpha", "beta", "gamma"]);
}
#[test]
fn line_splitter_handles_crlf_and_a_missing_final_newline() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(b"one\r\ntwo\r\nthree", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got, ["one", "two", "three"]);
}
#[test]
fn line_splitter_replaces_invalid_utf8() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(b"caf\xe9\n", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got.len(), 1);
assert!(got[0].starts_with("caf"), "{:?}", got[0]);
}
#[test]
fn record_splitter_rejoins_across_chunks_and_emits_a_trailing_record() {
let mut split = RecordSplitter::new(0);
let mut got = Vec::new();
split.push(b"alpha\0be", |r| got.push(r.to_vec()));
assert_eq!(got, [b"alpha".to_vec()], "only the complete record so far");
split.push(b"ta\0gamma", |r| got.push(r.to_vec()));
// Well-formed `-z` output ends with a separator; a trailing record
// without one still comes out at `finish`.
assert_eq!(split.finish(|r| got.push(r.to_vec())), 0);
assert_eq!(
got,
[b"alpha".to_vec(), b"beta".to_vec(), b"gamma".to_vec()]
);
}
/// An overlong record is dropped whole and *counted* — delivered cut
/// short it would still parse, and a commit body cut mid-way reads as the
/// real message.
#[test]
fn record_splitter_drops_an_absurd_record_whole_and_says_so() {
let mut split = RecordSplitter::new(0);
let mut got = Vec::new();
let huge = vec![b'x'; MAX_RECORD + 5_000];
split.push(b"before\0", |r| got.push(r.to_vec()));
for piece in huge.chunks(64 * 1024) {
split.push(piece, |r| got.push(r.to_vec()));
}
split.push(b"\0after\0", |r| got.push(r.to_vec()));
let dropped = split.finish(|r| got.push(r.to_vec()));
assert_eq!(dropped, 1);
assert_eq!(
got,
[b"before".to_vec(), b"after".to_vec()],
"no truncated ghost between the two, and the next record survives"
);
// A single-chunk oversized record takes the borrow fast path and must
// be counted the same way.
let mut split = RecordSplitter::new(0);
let mut got: Vec<Vec<u8>> = Vec::new();
let mut one = vec![b'y'; MAX_RECORD + 1];
one.push(0);
one.extend_from_slice(b"tail\0");
split.push(&one, |r| got.push(r.to_vec()));
assert_eq!(split.finish(|r| got.push(r.to_vec())), 1);
assert_eq!(got, [b"tail".to_vec()]);
}
/// A stream that *ends* mid-way through an oversized record still reports
/// the drop.
#[test]
fn record_splitter_counts_a_truncated_trailing_record() {
let mut split = RecordSplitter::new(0);
let mut got: Vec<Vec<u8>> = Vec::new();
split.push(&vec![b'z'; MAX_RECORD + 1], |r| got.push(r.to_vec()));
assert_eq!(split.finish(|r| got.push(r.to_vec())), 1);
assert!(got.is_empty());
}
#[test]
fn line_splitter_caps_one_absurd_line() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
let huge = vec![b'x'; MAX_LINE + 5_000];
split.push(b"before\n", |l| got.push(l.to_string()));
for piece in huge.chunks(64 * 1024) {
split.push(piece, |l| got.push(l.to_string()));
}
split.push(b"\nafter\n", |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(
got.len(),
3,
"{:?}",
got.iter().map(|l| l.len()).collect::<Vec<_>>()
);
assert_eq!(got[0], "before");
assert_eq!(got[2], "after", "the next line is unaffected");
assert!(
got[1].starts_with(&"x".repeat(1000)),
"the kept prefix is the real content"
);
assert!(
got[1].contains("5000 more bytes"),
"the cut says how much went: {}",
&got[1][got[1].len() - 80..]
);
assert!(
got[1].len() < MAX_LINE + 200,
"nothing past the cap was retained"
);
}
#[test]
fn line_splitter_caps_a_final_unterminated_line() {
let mut split = LineSplitter::default();
let mut got = Vec::new();
split.push(&vec![b'y'; MAX_LINE + 7], |l| got.push(l.to_string()));
split.finish(|l| got.push(l.to_string()));
assert_eq!(got.len(), 1);
assert!(got[0].contains("7 more bytes"), "{}", got[0]);
}
#[test]
fn streaming_and_buffered_reads_agree() {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
let args = ["log", "--oneline", "-n", "40"];
let Ok(code) = git_stream(here, &args, |_| true) else {
return;
};
if code != Some(0) {
return;
}
let mut streamed = Vec::new();
let mut split = LineSplitter::default();
git_stream(here, &args, |chunk| {
split.push(chunk, |l| streamed.push(l.to_string()));
true
})
.unwrap();
split.finish(|l| streamed.push(l.to_string()));
let buffered = git_output(here, &args).unwrap();
let expected: Vec<String> = String::from_utf8_lossy(&buffered.stdout)
.lines()
.map(str::to_string)
.collect();
assert_eq!(streamed, expected);
assert!(!streamed.is_empty(), "this repo has commits");
}
#[test]
fn non_repo_is_none() {
let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
let _ = std::fs::create_dir_all(&dir);
assert_eq!(probe(&*h(), &dir), None);
}
#[test]
fn missing_path_is_none() {
assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None);
}
#[test]
fn own_repo_has_a_branch_and_root() {
let here = env!("CARGO_MANIFEST_DIR");
if let Some(snap) = probe(&*h(), Path::new(here)) {
assert!(!snap.branch.is_empty());
assert!(Path::new(here).starts_with(&snap.root));
}
}
#[test]
fn repo_home_resolves_worktree_layouts() {
let root = Path::new("/repo/.wt/feat");
assert_eq!(
repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")),
PathBuf::from("/repo")
);
assert_eq!(
repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")),
PathBuf::from("/repo")
);
assert_eq!(
repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")),
PathBuf::from("/bare.git")
);
assert_eq!(
repo_home(root, Some("/repo/.git"), None),
root.to_path_buf()
);
assert_eq!(repo_home(root, None, None), root.to_path_buf());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+118
View File
@@ -54,7 +54,10 @@ macro_rules! for_each_host_case {
git_nonzero_exit_is_ok_not_err,
git_that_cannot_run_is_err,
git_optional_locks_env_is_set,
git_terminal_prompt_is_disabled,
git_stdin_is_null,
git_output_preserves_nul_bytes,
git_args_survive_pathspec_magic,
join_uses_host_separator,
is_absolute_matches_host_semantics,
search_is_breadth_first,
@@ -660,6 +663,53 @@ pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) {
);
}
pub fn git_terminal_prompt_is_disabled(h: &dyn Host, sb: &dyn Sandbox) {
let sandbox = sb.path();
let repo = h.join(sandbox, "repo");
mkdir(h, &repo);
let Some(()) = git_repo(h, &repo) else { return };
// Failures past this point assert rather than return: a broken alias or a
// failing run is exactly a git that misbehaved, and returning would make
// this case pass vacuously in precisely that situation.
let out = h
.git(
&repo,
&[
"config",
"alias.tty7prompt",
"!echo PROMPT=[$GIT_TERMINAL_PROMPT] REQUIRE=[$SSH_ASKPASS_REQUIRE]",
],
)
.unwrap();
assert!(
out.success(),
"config exited {:?}: {:?}",
out.status,
out.stderr_trimmed()
);
let out = h.git(&repo, &["tty7prompt"]).unwrap();
assert!(
out.success(),
"alias run exited {:?}: {:?}",
out.status,
out.stderr_trimmed()
);
// A `push` that stops to ask for a username never comes back — and on the
// far side of a control link there is no terminal to answer at anyway. The
// remote host inherits this from the server's own local host, so both ends
// have to agree.
let text = out.stdout_trimmed();
assert!(
text.contains("PROMPT=[0]"),
"GIT_TERMINAL_PROMPT must reach git: {text:?}"
);
assert!(
text.contains("REQUIRE=[never]"),
"SSH_ASKPASS_REQUIRE must reach git: {text:?}"
);
}
pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) {
let sandbox = sb.path();
let repo = h.join(sandbox, "repo");
@@ -678,6 +728,74 @@ pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) {
});
}
pub fn git_output_preserves_nul_bytes(h: &dyn Host, sb: &dyn Sandbox) {
let sandbox = sb.path();
let repo = h.join(sandbox, "repo");
mkdir(h, &repo);
let Some(()) = git_repo(h, &repo) else { return };
write(h, &h.join(&repo, "one two.txt"), "x");
write(h, &h.join(&repo, "three.txt"), "y");
let out = h.git(&repo, &["status", "--porcelain=v2", "-z"]).unwrap();
assert!(
out.success(),
"status exited {:?}: {:?}",
out.status,
out.stderr_trimmed()
);
// `git` hands back bytes, not lines. The `-z` formats are the only ones
// whose paths are unambiguous, and the SCM panel reads them through this
// method precisely because `git_lines` cannot: it splits on newlines and
// rejoins with them, which turns a NUL stream into mush.
assert!(
out.stdout.contains(&0),
"`-z` came back with no NUL at all: {:?}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
!out.stdout.contains(&b'\n'),
"`-z` records were re-terminated with newlines: {:?}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
contains_bytes(&out.stdout, b"one two.txt"),
"the raw, unquoted path did not survive: {:?}",
String::from_utf8_lossy(&out.stdout)
);
}
pub fn git_args_survive_pathspec_magic(h: &dyn Host, sb: &dyn Sandbox) {
let sandbox = sb.path();
let repo = h.join(sandbox, "repo");
mkdir(h, &repo);
let Some(()) = git_repo(h, &repo) else { return };
// Staging a single file means naming it as a pathspec, and a name with
// glob characters only stages itself behind `:(literal)`. Nothing between
// here and git may re-split, re-quote or shell-expand that argument.
let name = "a[b].txt";
write(h, &h.join(&repo, name), "x");
let added = h.git(&repo, &["add", "--", ":(literal)a[b].txt"]).unwrap();
assert!(
added.success(),
"add exited {:?}: {:?}",
added.status,
added.stderr_trimmed()
);
let out = h.git(&repo, &["status", "--porcelain"]).unwrap();
let text = out.stdout_trimmed();
assert!(
text.lines().any(|l| l.starts_with('A') && l.contains(name)),
"`:(literal)` did not reach git intact: {text:?}"
);
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) {
let sandbox = sb.path();
let sep = h.separator();
+44 -1
View File
@@ -16,6 +16,49 @@ use crate::host::{
const COALESCE_WINDOW: Duration = Duration::from_millis(100);
/// What every git we spawn runs with, on top of what `git_output_with_env`
/// already sets: nothing may stop and ask a human anything.
///
/// It only bites when git reaches for a credential, which in practice is
/// `fetch`/`pull`/`push` — `status`, `diff` and `log` have nothing to prompt
/// about, so carrying it on the read path too costs nothing and buys the one
/// thing we cannot get any other way: the wire carries a single `Git` request
/// with no "this one talks to a network" bit, so the remote `tty7-server`
/// arrives here with the same args and is protected by the same rule, without a
/// protocol change.
///
/// `git_output_with_env` already nulls stdin, and that is not enough — with no
/// `GIT_TERMINAL_PROMPT` git opens `/dev/tty` directly and blocks on it, which
/// is exactly the hang this prevents.
const NO_PROMPT_ENV: &[(&str, Option<&str>)] = &[
// Fail instead of blocking on `Username for 'https://…'`.
("GIT_TERMINAL_PROMPT", Some("0")),
// Nobody is watching for a password dialog a background probe popped up.
("GIT_ASKPASS", None),
("SSH_ASKPASS", None),
// OpenSSH 8.4+; without it ssh may fall back to askpass on its own.
("SSH_ASKPASS_REQUIRE", Some("never")),
];
/// `NO_PROMPT_ENV`, plus a batch-mode ssh unless the user picked their own
/// `GIT_SSH_COMMAND` — replacing theirs would drop the identity file or jump
/// host they configured. `BatchMode=yes` bans only interactive password and
/// passphrase prompts; a key held by ssh-agent still authenticates.
///
/// A repository's `core.sshCommand` does lose to this, because that is git's
/// own precedence. Honouring it would mean a `git config` probe before every
/// call, and this is the read path too.
fn no_prompt_env() -> Vec<(&'static str, Option<&'static str>)> {
let mut env = NO_PROMPT_ENV.to_vec();
// `GIT_SSH` too: it is the older spelling of the same choice (plink on
// Windows, most commonly), and `GIT_SSH_COMMAND` outranks it — forcing
// ours would silently swap their transport out.
if std::env::var_os("GIT_SSH_COMMAND").is_none() && std::env::var_os("GIT_SSH").is_none() {
env.push(("GIT_SSH_COMMAND", Some("ssh -o BatchMode=yes")));
}
env
}
pub struct LocalHost {
gitignore: Arc<Mutex<GitignoreChain>>,
}
@@ -239,7 +282,7 @@ impl Host for LocalHost {
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output> {
guard_off_ui();
git::git_output(cwd, args)
git::git_output_with_env(cwd, args, &no_prompt_env())
}
fn git_lines(
+24
View File
@@ -216,6 +216,30 @@ pub trait Host: Send + Sync + 'static {
fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>;
/// `git`, but with an explicit ceiling on how long to wait for the far side.
///
/// Network verbs (`fetch`/`pull`/`push`) run for as long as the network
/// takes, which is minutes, not the seconds an interactive query is allowed.
///
/// `LocalHost` deliberately does not override this: `Command::output()`
/// blocks until the child exits and has no timeout of its own, so waiting
/// "until the deadline" and waiting "until git is done" are the same wait —
/// forwarding to `git` is the honest implementation, not a stub. `RemoteHost`
/// does override it, because its per-request deadline is sized for
/// interactive queries and a push walks straight into it.
///
/// The reply is still the plain `Git` request on the wire, so a server that
/// predates this method serves it unchanged.
fn git_with_deadline(
&self,
cwd: &Path,
args: &[&str],
deadline: std::time::Duration,
) -> io::Result<Output> {
let _ = deadline;
self.git(cwd, args)
}
fn git_lines(
&self,
cwd: &Path,
+19
View File
@@ -277,6 +277,25 @@ impl Host for RemoteHost {
}
}
fn git_with_deadline(
&self,
cwd: &Path,
args: &[&str],
deadline: Duration,
) -> io::Result<Output> {
// Byte-for-byte the same request `git` sends; only the client's own
// patience changes. The server never times a job out, so a v5 peer that
// knows nothing about long git verbs serves this unchanged.
let req = ControlRequest::Git {
cwd: wire_path(cwd),
args: args.iter().map(|a| a.to_string()).collect(),
};
match self.client.call_with_deadline(req, &[], deadline)?.reply {
ReplyOk::Output(o) => Ok(o),
other => Err(wrong_shape("a process result", &other)),
}
}
fn git_lines(
&self,
cwd: &Path,
+40
View File
@@ -2210,6 +2210,46 @@ mod tests {
}
}
#[test]
fn a_short_deadline_gives_up_on_a_slow_git() {
let p = pair_with(Arc::new(SlowGit {
inner: LocalHost::new(),
delay: Duration::from_millis(300),
running: Arc::new(AtomicBool::new(false)),
}));
let tmp = tempfile::TempDir::new().unwrap();
let err = p
.host
.git_with_deadline(tmp.path(), &["status"], Duration::from_millis(100))
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::TimedOut, "{err}");
}
#[test]
fn a_long_deadline_outlasts_the_one_a_git_request_would_get() {
let p = pair_with(Arc::new(SlowGit {
inner: LocalHost::new(),
delay: Duration::from_millis(300),
running: Arc::new(AtomicBool::new(false)),
}));
let tmp = tempfile::TempDir::new().unwrap();
// The first call abandons its request mid-flight. The server has no
// timer of its own — it runs the job to completion and answers into a
// slot nobody is waiting on — so the point of doing it twice is that
// the link is still usable afterwards. A `push` behind a cancelled
// probe depends on exactly that.
let _ = p
.host
.git_with_deadline(tmp.path(), &["status"], Duration::from_millis(100));
let out = p
.host
.git_with_deadline(tmp.path(), &["status"], Duration::from_secs(5))
.unwrap();
assert_eq!(out.stdout, b"slow");
}
#[test]
fn replies_come_back_out_of_order() {
let running = Arc::new(AtomicBool::new(false));
+17
View File
@@ -67,8 +67,25 @@ actions!(
ToggleLeftPanel,
ToggleRightPanel,
ShowRightPanelInfo,
// Keeps its old name even though the panel is now "Source Control":
// `Config::keybindings` is keyed by action name, so renaming it would
// orphan every custom binding already on disk.
ShowRightPanelChanges,
ShowRightPanelFiles,
ScmCommit,
ScmCommitAmend,
ScmStageAll,
ScmUnstageAll,
ScmDiscardAll,
ScmRefresh,
ScmSync,
ScmPush,
ScmPull,
ScmFetch,
ScmCheckoutBranch,
ScmCreateBranch,
ScmToggleGraph,
ToggleDiffViewMode,
OpenSettings,
ShowKeyboardShortcuts,
About,
File diff suppressed because it is too large Load Diff
+6 -732
View File
@@ -1,732 +1,6 @@
use std::path::{Path, PathBuf};
use crate::terminal::git_status;
use crate::ui::host_ops::Host;
pub const MAX_LINES_PER_FILE: usize = 2000;
pub const MAX_TOTAL_LINES: usize = 20_000;
pub const MAX_FILES_WITH_HUNKS: usize = 500;
pub const AUTO_COLLAPSE_LINES: u32 = 400;
pub const AUTO_COLLAPSE_TOTAL_LINES: usize = 8_000;
pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100;
pub const MAX_RENDERED_FILES: usize = 300;
pub const MAX_UNTRACKED: usize = 500;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Truncation {
PerFile,
Budget,
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct DiffSnapshot {
pub root: PathBuf,
pub branch: String,
pub files: Vec<FileDiff>,
pub untracked: Vec<String>,
pub untracked_total: usize,
pub read_failed: bool,
}
impl DiffSnapshot {
pub fn totals(&self) -> (u32, u32) {
self.files
.iter()
.fold((0, 0), |(a, r), f| (a + f.added, r + f.removed))
}
pub fn untracked_count(&self) -> usize {
self.untracked_total.max(self.untracked.len())
}
pub fn stats(&self) -> DiffStats {
let mut added = 0u32;
let mut removed = 0u32;
let mut retained_lines = 0usize;
let mut budget_exhausted = false;
let mut per_file_truncated = false;
for file in &self.files {
added += file.added;
removed += file.removed;
retained_lines += file.hunks.iter().map(|h| h.lines.len()).sum::<usize>();
match file.truncated {
Some(Truncation::Budget) => budget_exhausted = true,
Some(Truncation::PerFile) => per_file_truncated = true,
None => {}
}
}
let untracked_count = self.untracked_count();
DiffStats {
totals: (added, removed),
retained_lines,
untracked_count,
oversized: self.files.len() > AUTO_COLLAPSE_TOTAL_FILES
|| retained_lines > AUTO_COLLAPSE_TOTAL_LINES,
budget_exhausted,
per_file_truncated,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct DiffStats {
pub totals: (u32, u32),
pub retained_lines: usize,
pub untracked_count: usize,
pub oversized: bool,
pub budget_exhausted: bool,
pub per_file_truncated: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FileStatus {
Added,
Modified,
Deleted,
Renamed,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct FileDiff {
pub path: String,
pub old_path: Option<String>,
pub status: FileStatus,
pub added: u32,
pub removed: u32,
pub binary: bool,
pub truncated: Option<Truncation>,
pub hunks: Vec<Hunk>,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Hunk {
pub header: String,
pub lines: Vec<DiffLine>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum LineKind {
Context,
Added,
Removed,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct DiffLine {
pub kind: LineKind,
pub old_no: Option<u32>,
pub new_no: Option<u32>,
pub text: String,
}
pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> {
let root = git_status::git(host, cwd, &["rev-parse", "--show-toplevel"])?;
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
let branch = git_status::branch_name(host, cwd)?;
let mut parser = DiffParser::default();
let diffed = host.git_lines(
cwd,
&["diff", "--no-color", "--no-ext-diff", "-M", "HEAD"],
&mut |line| parser.push_line(line),
);
let files = match diffed {
Ok(Some(0)) => parser.finish(),
_ => Vec::new(),
};
let mut untracked: Vec<String> = Vec::new();
let mut untracked_total = 0usize;
let listed = host.git_lines(
cwd,
&["ls-files", "--others", "--exclude-standard", "--full-name"],
&mut |line| {
untracked_total += 1;
if untracked.len() < MAX_UNTRACKED {
untracked.push(line.to_string());
}
},
);
if !matches!(listed, Ok(Some(0))) {
untracked.clear();
untracked_total = 0;
}
Some(DiffSnapshot {
root,
branch,
files,
untracked,
untracked_total,
read_failed: !matches!(diffed, Ok(Some(0))) || !matches!(listed, Ok(Some(0))),
})
}
#[cfg(test)]
pub fn parse_unified(out: &str) -> Vec<FileDiff> {
let mut parser = DiffParser::default();
for line in out.lines() {
parser.push_line(line);
}
parser.finish()
}
#[derive(Default)]
pub struct DiffParser {
files: Vec<FileDiff>,
old_no: u32,
new_no: u32,
file_lines: usize,
total_lines: usize,
files_with_hunks: usize,
in_hunk: bool,
}
impl DiffParser {
pub fn push_line(&mut self, line: &str) {
if let Some(rest) = line.strip_prefix("diff --git ") {
let (old_p, new_p) = parse_git_header_paths(rest);
self.files.push(FileDiff {
path: new_p.clone(),
old_path: (old_p != new_p).then_some(old_p),
status: FileStatus::Modified,
added: 0,
removed: 0,
binary: false,
truncated: None,
hunks: Vec::new(),
});
self.file_lines = 0;
self.in_hunk = false;
return;
}
let Some(file) = self.files.last_mut() else {
return;
};
if line.starts_with("new file mode") {
file.status = FileStatus::Added;
return;
}
if line.starts_with("deleted file mode") {
file.status = FileStatus::Deleted;
return;
}
if line.starts_with("rename from ") {
file.status = FileStatus::Renamed;
return;
}
if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
file.binary = true;
return;
}
if !self.in_hunk
&& (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line))
&& !line.starts_with("@@")
{
return;
}
if line.starts_with("@@") {
self.in_hunk = true;
if file.truncated.is_some() {
return;
}
let first_hunk = file.hunks.is_empty();
if (first_hunk && self.files_with_hunks >= MAX_FILES_WITH_HUNKS)
|| self.total_lines >= MAX_TOTAL_LINES
{
file.truncated = Some(Truncation::Budget);
return;
}
if first_hunk {
self.files_with_hunks += 1;
}
let (o, n) = parse_hunk_starts(line).unwrap_or((0, 0));
self.old_no = o;
self.new_no = n;
file.hunks.push(Hunk {
header: line.to_string(),
lines: Vec::new(),
});
return;
}
if !self.in_hunk {
return;
}
let (kind, text) = match line.as_bytes().first() {
Some(b'+') => (LineKind::Added, &line[1..]),
Some(b'-') => (LineKind::Removed, &line[1..]),
Some(b' ') => (LineKind::Context, &line[1..]),
_ => return,
};
match kind {
LineKind::Added => file.added += 1,
LineKind::Removed => file.removed += 1,
LineKind::Context => {}
}
if file.truncated.is_some() {
return;
}
self.file_lines += 1;
if self.file_lines > MAX_LINES_PER_FILE {
file.truncated = Some(Truncation::PerFile);
return;
}
if self.total_lines >= MAX_TOTAL_LINES {
file.truncated = Some(Truncation::Budget);
return;
}
let Some(hunk) = file.hunks.last_mut() else {
return;
};
let (o, n) = match kind {
LineKind::Added => {
let n = self.new_no;
self.new_no += 1;
(None, Some(n))
}
LineKind::Removed => {
let o = self.old_no;
self.old_no += 1;
(Some(o), None)
}
LineKind::Context => {
let (o, n) = (self.old_no, self.new_no);
self.old_no += 1;
self.new_no += 1;
(Some(o), Some(n))
}
};
hunk.lines.push(DiffLine {
kind,
old_no: o,
new_no: n,
text: text.to_string(),
});
self.total_lines += 1;
}
pub fn finish(self) -> Vec<FileDiff> {
self.files
}
}
fn is_hunk_line(line: &str) -> bool {
matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty()
}
fn parse_git_header_paths(rest: &str) -> (String, String) {
if rest.starts_with('"') {
let parts: Vec<String> = parse_quoted_pair(rest);
if parts.len() == 2 {
return (strip_prefix_ab(&parts[0]), strip_prefix_ab(&parts[1]));
}
}
if let Some(idx) = rest.rfind(" b/") {
let old = &rest[..idx];
let new = &rest[idx + 1..];
return (strip_prefix_ab(old), strip_prefix_ab(new));
}
(rest.to_string(), rest.to_string())
}
fn parse_quoted_pair(s: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut cur = String::new();
let mut in_quote = false;
let mut escaped = false;
for ch in s.chars() {
if escaped {
cur.push(ch);
escaped = false;
continue;
}
match ch {
'\\' if in_quote => escaped = true,
'"' => {
if in_quote {
parts.push(std::mem::take(&mut cur));
}
in_quote = !in_quote;
}
_ if in_quote => cur.push(ch),
_ => {}
}
}
parts
}
fn strip_prefix_ab(p: &str) -> String {
p.strip_prefix("a/")
.or_else(|| p.strip_prefix("b/"))
.unwrap_or(p)
.to_string()
}
fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> {
let rest = line.strip_prefix("@@ -")?;
let (old_part, rest) = rest.split_once(" +")?;
let (new_part, _) = rest.split_once(" @@")?;
let old = old_part.split(',').next()?.parse().ok()?;
let new = new_part.split(',').next()?.parse().ok()?;
Some((old, new))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
diff --git a/src/main.rs b/src/main.rs
index 1111111..2222222 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,4 +10,5 @@ fn main() {
let a = 1;
-let b = old();
+let b = new();
+let c = 3;
done();
diff --git a/docs/new.md b/docs/new.md
new file mode 100644
index 0000000..3333333
--- /dev/null
+++ b/docs/new.md
@@ -0,0 +1,2 @@
+hello
+world
diff --git a/gone.txt b/gone.txt
deleted file mode 100644
index 4444444..0000000
--- a/gone.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-bye
diff --git a/img.png b/img.png
index 5555555..6666666 100644
Binary files a/img.png and b/img.png differ
";
#[test]
fn parses_the_four_file_shapes() {
let files = parse_unified(SAMPLE);
assert_eq!(files.len(), 4);
let m = &files[0];
assert_eq!(m.path, "src/main.rs");
assert_eq!(m.status, FileStatus::Modified);
assert_eq!((m.added, m.removed), (2, 1));
assert_eq!(m.hunks.len(), 1);
assert_eq!(m.hunks[0].header, "@@ -10,4 +10,5 @@ fn main() {");
let lines = &m.hunks[0].lines;
assert_eq!(lines.len(), 5);
assert_eq!((lines[0].old_no, lines[0].new_no), (Some(10), Some(10)));
assert_eq!(lines[1].kind, LineKind::Removed);
assert_eq!(lines[1].old_no, Some(11));
assert_eq!(lines[1].new_no, None);
assert_eq!(lines[2].kind, LineKind::Added);
assert_eq!(lines[2].new_no, Some(11));
assert_eq!(lines[3].new_no, Some(12));
assert_eq!(lines[3].text, "let c = 3;");
assert_eq!((lines[4].old_no, lines[4].new_no), (Some(12), Some(13)));
let a = &files[1];
assert_eq!(a.status, FileStatus::Added);
assert_eq!((a.added, a.removed), (2, 0));
let d = &files[2];
assert_eq!(d.status, FileStatus::Deleted);
assert_eq!((d.added, d.removed), (0, 1));
let b = &files[3];
assert!(b.binary);
assert!(b.hunks.is_empty());
}
#[test]
fn parses_renames() {
let out = "\
diff --git a/old/name.rs b/new/name.rs
similarity index 100%
rename from old/name.rs
rename to new/name.rs
";
let files = parse_unified(out);
assert_eq!(files.len(), 1);
assert_eq!(files[0].status, FileStatus::Renamed);
assert_eq!(files[0].path, "new/name.rs");
assert_eq!(files[0].old_path.as_deref(), Some("old/name.rs"));
assert_eq!((files[0].added, files[0].removed), (0, 0));
}
#[test]
fn parses_quoted_paths() {
let out = "diff --git \"a/has space.txt\" \"b/has space.txt\"\n";
let files = parse_unified(out);
assert_eq!(files[0].path, "has space.txt");
assert_eq!(files[0].old_path, None);
}
#[test]
fn triple_dash_content_line_is_kept() {
let out = "\
diff --git a/x.md b/x.md
index 1111111..2222222 100644
--- a/x.md
+++ b/x.md
@@ -1,2 +1,1 @@
keep
---- a heading rule
";
let files = parse_unified(out);
let lines = &files[0].hunks[0].lines;
assert_eq!(lines.len(), 2);
assert_eq!(lines[1].kind, LineKind::Removed);
assert_eq!(lines[1].text, "--- a heading rule");
}
#[test]
fn caps_lines_per_file_but_keeps_counting() {
let mut out = String::from(
"diff --git a/big.txt b/big.txt\nindex 1..2 100644\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,3000 @@\n",
);
for i in 0..3000 {
out.push_str(&format!("+line {i}\n"));
}
let files = parse_unified(&out);
assert_eq!(files[0].truncated, Some(Truncation::PerFile));
assert_eq!(files[0].added, 3000);
let kept: usize = files[0].hunks.iter().map(|h| h.lines.len()).sum();
assert_eq!(kept, MAX_LINES_PER_FILE);
}
#[test]
fn skips_no_newline_marker() {
let out = "\
diff --git a/x b/x
index 1..2 100644
--- a/x
+++ b/x
@@ -1,1 +1,1 @@
-old
\\ No newline at end of file
+new
\\ No newline at end of file
";
let files = parse_unified(out);
assert_eq!(files[0].hunks[0].lines.len(), 2);
assert_eq!((files[0].added, files[0].removed), (1, 1));
}
#[test]
fn snapshot_totals() {
let snap = DiffSnapshot {
files: parse_unified(SAMPLE),
..Default::default()
};
assert_eq!(snap.totals(), (4, 2));
}
fn many_files(files: usize, lines_each: usize) -> String {
let mut out = String::new();
for f in 0..files {
out.push_str(&format!(
"diff --git a/f{f}.rs b/f{f}.rs\nindex 1..2 100644\n--- a/f{f}.rs\n+++ b/f{f}.rs\n@@ -0,0 +1,{lines_each} @@\n"
));
for i in 0..lines_each {
out.push_str(&format!("+file {f} line {i}\n"));
}
}
out
}
#[test]
fn repo_wide_budget_caps_retained_lines() {
let files = parse_unified(&many_files(300, 300));
assert_eq!(files.len(), 300, "every file keeps its header row");
let retained: usize = files
.iter()
.flat_map(|f| &f.hunks)
.map(|h| h.lines.len())
.sum();
assert!(
retained <= MAX_TOTAL_LINES,
"retained {retained} lines, budget is {MAX_TOTAL_LINES}"
);
assert!(
files
.iter()
.any(|f| f.truncated == Some(Truncation::Budget))
);
}
#[test]
fn repo_wide_budget_keeps_totals_exact() {
let snap = DiffSnapshot {
files: parse_unified(&many_files(300, 300)),
..Default::default()
};
assert_eq!(snap.totals(), (90_000, 0));
assert!(snap.stats().budget_exhausted);
}
#[test]
fn repo_wide_budget_caps_files_with_hunks() {
let files = parse_unified(&many_files(MAX_FILES_WITH_HUNKS + 50, 1));
assert_eq!(files.len(), MAX_FILES_WITH_HUNKS + 50);
let with_hunks = files.iter().filter(|f| !f.hunks.is_empty()).count();
assert_eq!(with_hunks, MAX_FILES_WITH_HUNKS);
assert_eq!(
files.iter().map(|f| f.added).sum::<u32>(),
(MAX_FILES_WITH_HUNKS + 50) as u32
);
assert_eq!(files.last().unwrap().truncated, Some(Truncation::Budget));
}
#[test]
fn small_diff_is_not_truncated() {
let snap = DiffSnapshot {
files: parse_unified(SAMPLE),
..Default::default()
};
assert!(snap.files.iter().all(|f| f.truncated.is_none()));
assert!(!snap.stats().oversized);
assert!(!snap.stats().budget_exhausted);
}
#[test]
fn oversized_trips_on_files_or_lines() {
let by_files = DiffSnapshot {
files: parse_unified(&many_files(AUTO_COLLAPSE_TOTAL_FILES + 1, 1)),
..Default::default()
};
assert!(by_files.stats().oversized);
let per_file = MAX_LINES_PER_FILE / 2;
let by_lines = DiffSnapshot {
files: parse_unified(&many_files(
AUTO_COLLAPSE_TOTAL_LINES / per_file + 1,
per_file,
)),
..Default::default()
};
assert!(by_lines.files.len() <= AUTO_COLLAPSE_TOTAL_FILES);
assert!(by_lines.stats().retained_lines > AUTO_COLLAPSE_TOTAL_LINES);
assert!(by_lines.stats().oversized);
}
#[test]
fn truncated_file_counts_dash_prefixed_content() {
let mut out = many_files(MAX_FILES_WITH_HUNKS, 1);
out.push_str(
"diff --git a/late.md b/late.md\nindex 1..2 100644\n--- a/late.md\n+++ b/late.md\n@@ -1,2 +1,1 @@\n keep\n--- a heading rule\n",
);
let files = parse_unified(&out);
let late = files.last().unwrap();
assert_eq!(late.path, "late.md");
assert_eq!(late.truncated, Some(Truncation::Budget));
assert!(late.hunks.is_empty(), "no body kept past the file cap");
assert_eq!((late.added, late.removed), (0, 1), "but the line counts");
}
#[test]
fn untracked_is_capped_but_counted() {
let mut untracked: Vec<String> = Vec::new();
let mut untracked_total = 0usize;
for i in 0..(MAX_UNTRACKED * 3) {
untracked_total += 1;
if untracked.len() < MAX_UNTRACKED {
untracked.push(format!("node_modules/p{i}/index.js"));
}
}
let snap = DiffSnapshot {
untracked,
untracked_total,
..Default::default()
};
assert_eq!(snap.untracked.len(), MAX_UNTRACKED, "retention is bounded");
assert_eq!(
snap.untracked_count(),
MAX_UNTRACKED * 3,
"the count is not"
);
}
#[test]
#[ignore = "measurement, not an assertion"]
fn bench_stream_vs_buffer() {
use std::time::Instant;
use tty7_core::core::git::{LineSplitter, git_output, git_stream};
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let args = ["log", "-p", "-n", "400", "--no-color"];
let t = Instant::now();
let Ok(out) = git_output(here, &args) else {
println!("no git here; skipping");
return;
};
let read = t.elapsed();
let resident = out.stdout.len();
let t = Instant::now();
let buffered_files = parse_unified(&String::from_utf8_lossy(&out.stdout));
let buffered_parse = t.elapsed();
println!(
"buffered: {resident} bytes resident, read {read:?}, parse {buffered_parse:?}, \
{} files",
buffered_files.len()
);
drop(out);
let t = Instant::now();
let mut parser = DiffParser::default();
let mut split = LineSplitter::default();
let mut peak = 0usize;
git_stream(here, &args, |chunk| {
peak = peak.max(chunk.len());
split.push(chunk, |line| parser.push_line(line));
true
})
.unwrap();
split.finish(|line| parser.push_line(line));
let streamed = t.elapsed();
let streamed_files = parser.finish();
println!(
"streamed: peak transient chunk {peak} bytes (vs {resident} resident), \
read+parse {streamed:?}, {} files",
streamed_files.len()
);
assert_eq!(buffered_files.len(), streamed_files.len());
}
#[test]
#[ignore = "measurement, not an assertion"]
fn bench_parse_budget() {
use std::time::Instant;
let out = many_files(300, 300);
println!("input: {} bytes, 300 files × 300 lines", out.len());
let t = Instant::now();
let files = parse_unified(&out);
let elapsed = t.elapsed();
let retained: usize = files
.iter()
.flat_map(|f| &f.hunks)
.map(|h| h.lines.len())
.sum();
let bytes: usize = files
.iter()
.flat_map(|f| &f.hunks)
.flat_map(|h| &h.lines)
.map(|l| l.text.capacity() + std::mem::size_of::<DiffLine>())
.sum();
println!(
"parse {elapsed:?} → {retained} retained lines, ~{} KiB of DiffLine text \
(unbudgeted would be 90000 lines / ~{} KiB)",
bytes / 1024,
90_000 * (24 + std::mem::size_of::<DiffLine>()) / 1024,
);
}
}
//! The diff model moved into `tty7-core` (`core::git::diff`) so the headless
//! server and the GUI parse a patch with the same code. Nothing here has a gpui
//! dependency, and the SCM panel needs the same types the daemon does.
//!
//! This shim keeps every `crate::terminal::git_diff::…` path in the GUI working.
pub use tty7_core::core::git::diff::*;
+63 -1
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub use crate::core::git::{GitStatus, RepoSnapshot, branch_name, git, probe};
pub use crate::core::git::{GitStatus, RepoSnapshot, probe};
use crate::ui::host_ops::{ByHost, HostId, InFlight};
#[derive(Default)]
@@ -31,6 +31,25 @@ impl GitStatusCache {
}))
}
/// The working tree `cwd` is in, if this cache has already found out.
///
/// Distinct from [`GitStatusCache::known_repo_for`], which answers with the
/// *home* — the main working tree a linked one belongs to, which is what
/// a "which project is this" question wants. This answers with the root,
/// which is the key everything git-shaped is stored under.
pub fn repo_root_for(&self, host: HostId, cwd: &Path) -> Option<&Path> {
self.roots.get(host, cwd)?.as_deref()
}
/// Forget a machine we have stopped talking to, so a reconnect starts from
/// nothing rather than from whatever it looked like on the way down.
pub fn clear_host(&mut self, host: HostId) {
self.roots.clear_host(host);
self.homes.clear_host(host);
self.status.clear_host(host);
self.last_probe.clear_host(host);
}
pub fn begin_probe(&mut self, host: HostId, cwd: &Path) -> bool {
let key = (host, cwd.to_path_buf());
if self.probes.begin(key.clone()) {
@@ -319,6 +338,49 @@ mod tests {
assert_eq!(cache.status_for(L, main).unwrap().branch, "main");
assert_eq!(cache.status_for(L, wt).unwrap().branch, "feat/x");
}
#[test]
fn a_linked_worktrees_root_is_not_its_home() {
// `known_repo_for` groups a worktree with the repository it belongs
// to; `repo_root_for` answers with the working tree itself, which is
// the key every git-shaped cache is stored under.
let mut cache = GitStatusCache::default();
let wt = Path::new("/repo/.wt/feat");
cache.finish_probe(L, wt, Some(wt_snap("/repo/.wt/feat", "/repo", "feat/x")));
assert_eq!(
cache.repo_root_for(L, wt),
Some(Path::new("/repo/.wt/feat"))
);
assert_eq!(
cache.known_repo_for(L, wt),
Some(Some(PathBuf::from("/repo")))
);
let plain = Path::new("/tmp/notes");
cache.finish_probe(L, plain, None);
assert_eq!(cache.repo_root_for(L, plain), None, "not a repository");
assert_eq!(cache.repo_root_for(L, Path::new("/never")), None);
}
#[test]
fn clearing_a_host_leaves_the_others_alone() {
let mut cache = GitStatusCache::default();
let gone = HostId::from_connection_key("ssh-direct:me@box:22");
let cwd = Path::new("/src/app");
cache.finish_probe(L, cwd, Some(snap("/src/app", "main", Some((1, 2)))));
cache.finish_probe(gone, cwd, Some(snap("/src/app", "feat/x", Some((3, 4)))));
cache.clear_host(gone);
assert_eq!(cache.status_for(gone, cwd), None);
assert_eq!(cache.known_repo_for(gone, cwd), None);
assert!(
cache.begin_probe_throttled(gone, cwd, Duration::from_secs(60)),
"a reconnect must be free to ask again straight away"
);
assert_eq!(cache.status_for(L, cwd).unwrap().branch, "main");
}
#[test]
fn throttled_probes_decline_instead_of_queueing() {
let mut cache = GitStatusCache::default();
+1
View File
@@ -5,6 +5,7 @@ pub mod element;
pub mod fps;
mod fuzzy;
mod generator;
pub(crate) mod git_data;
pub(crate) mod git_diff;
pub(crate) mod git_status;
mod highlight;
+30
View File
@@ -2830,6 +2830,9 @@ impl TerminalView {
})
.flatten();
if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
if cmd_finished || turn_finished {
self.mark_repo_changed(cwd_now.as_deref(), cx);
}
self.refresh_git_status(cwd_now, GitRefresh::Edge, cx);
} else if tool_activity {
self.refresh_git_status(cwd_now, GitRefresh::Opportunistic, cx);
@@ -2838,6 +2841,33 @@ impl TerminalView {
self.follow_history_scope(cx);
}
/// Tell the source control cache that a command just ran here.
///
/// The `.git` watch catches anything that writes the repository, and the
/// file tree catches edits in the directories it is showing. What is left
/// is the common case neither sees: a command that edits a file somewhere
/// the tree is not looking. A command boundary is the cheapest honest
/// signal that that may have happened.
///
/// Only the epoch moves. Scheduling the debounced re-read needs the app
/// entity, which a pane does not hold — but `refresh_git_status` below
/// writes `GitStatusCache`, the app observes that global, and the panel's
/// next render finds the repository stale and asks. One notify, not two.
fn mark_repo_changed(&self, cwd: Option<&std::path::Path>, cx: &mut Context<Self>) {
use crate::terminal::git_data::ScmData;
use crate::terminal::git_status::GitStatusCache;
let Some(cwd) = cwd else { return };
let Some(root) = cx
.try_global::<GitStatusCache>()
.and_then(|cache| cache.repo_root_for(self.host_id, cwd))
.map(std::path::Path::to_path_buf)
else {
return;
};
cx.default_global::<ScmData>().bump(self.host_id, &root);
}
fn desired_history_scope(&self) -> super::history::Scope {
if let Some(ctx) = self.remote_context() {
return super::history::Scope::remote(&ctx.target);
+80 -2
View File
@@ -34,7 +34,10 @@ use crate::ui::palette::{
};
use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot};
use crate::ui::presets::Fill;
use crate::ui::settings::{Recording, SettingsSection, SettingsState, ThemeEditor};
use crate::ui::scm::ScmIntent;
use crate::ui::settings::{
Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action,
};
use crate::ui::theme::{apply_theme, set_menus};
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -115,6 +118,15 @@ pub(crate) const TILE_GLYPH: f32 = 13.;
pub(crate) const TILE_SIZE_SM: f32 = 24.;
pub(crate) const TILE_GLYPH_SM: f32 = TILE_GLYPH;
/// The tile that lives *inside* a list row rather than beside one, for the
/// buttons a row reveals on hover.
///
/// A box below [`TILE_SIZE_SM`] because of width: three `TILE_SIZE_SM` squares
/// would eat 72 of the 236px a file name has to live in, where three of these
/// eat 54.
pub(crate) const TILE_SIZE_XS: f32 = 18.;
pub(crate) const TILE_GLYPH_XS: f32 = 11.;
pub(crate) const TILE_GLYPH_LINE: f32 = 16.;
pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.;
@@ -430,6 +442,7 @@ pub struct Tty7App {
pub(crate) loopback_panel: LoopbackForwardPanelState,
pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState,
pub(crate) right_panel: crate::ui::right_panel::RightPanelState,
pub(crate) scm: crate::ui::scm::ScmPanelState,
pub(crate) diff_probes_inflight:
std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>,
pub(crate) diff_probes_restale:
@@ -827,6 +840,7 @@ impl Tty7App {
let right_panel_width = cx.global::<Config>().right_panel_width;
let right_panel_visible = cx.global::<Config>().right_panel_visible;
let right_panel_tab = cx.global::<Config>().right_panel_tab;
let scm_graph_expanded = cx.global::<Config>().scm_graph_expanded;
let sidebar_collapsed = cx.global::<Config>().sidebar_collapsed;
let config_watch = cx.observe_global_in::<Config>(window, |this, window, cx| {
this.reload_from_config(window, cx)
@@ -974,6 +988,13 @@ impl Tty7App {
},
sftp_panel,
right_panel: Default::default(),
scm: crate::ui::scm::ScmPanelState {
graph: crate::ui::scm::GraphState {
expanded: scm_graph_expanded,
..Default::default()
},
..Default::default()
},
diff_probes_inflight: Default::default(),
diff_probes_restale: Default::default(),
file_tree,
@@ -4078,6 +4099,20 @@ impl Tty7App {
OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx),
SendSelectionToAgent => self.send_selection_to_agent(window, cx),
SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx),
ScmCommit => self.run_scm_action(ScmIntent::Commit, window, cx),
ScmStageAll => self.run_scm_action(ScmIntent::StageAll, window, cx),
ScmUnstageAll => self.run_scm_action(ScmIntent::UnstageAll, window, cx),
ScmDiscardAll => self.run_scm_action(ScmIntent::DiscardAll, window, cx),
ScmPush => self.run_scm_action(ScmIntent::Push, window, cx),
ScmPull => self.run_scm_action(ScmIntent::Pull, window, cx),
ScmFetch => self.run_scm_action(ScmIntent::Fetch, window, cx),
ScmSync => self.run_scm_action(ScmIntent::Sync, window, cx),
ScmCreateBranch => self.run_scm_action(ScmIntent::CreateBranch, window, cx),
OpenBranchPicker => self.run_scm_action(ScmIntent::CheckoutBranch, window, cx),
// The branch picker fills this in once it can list refs; until
// then the palette never emits it.
CheckoutBranch(_) => {}
ToggleDiffViewMode => self.toggle_diff_view_mode(cx),
OpenThemePicker | OpenSshConnectInput => {}
ActivateTab(i) => self.activate(i, window, cx),
}
@@ -5949,6 +5984,7 @@ impl Render for Tty7App {
window.set_rem_size(px(cx.global::<Config>().ui_font_size));
self.claim_pending_tab(window, cx);
self.touch_active_tab();
self.scm_sync_watchers(window, cx);
if cx.has_active_drag() {
crate::ui::reorder::clear_pending(&self.reorder);
crate::ui::pane_drag::clear_landing(&self.pane_drag);
@@ -6333,11 +6369,53 @@ impl Render for Tty7App {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Info, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelChanges, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Changes, cx)
this.set_right_panel_tab(crate::core::config::RightPanelTab::Scm, cx)
}))
.on_action(cx.listener(|this, _: &ShowRightPanelFiles, _window, cx| {
this.set_right_panel_tab(crate::core::config::RightPanelTab::Files, cx)
}))
.on_action(
cx.listener(|this, _: &ScmToggleGraph, _window, cx| this.scm_toggle_graph(cx)),
)
.on_action(cx.listener(|this, _: &ToggleDiffViewMode, _window, cx| {
this.toggle_diff_view_mode(cx)
}))
.on_action(cx.listener(|this, _: &ScmCommit, window, cx| {
this.run_scm_action(ScmIntent::Commit, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmCommitAmend, window, cx| {
this.run_scm_action(ScmIntent::CommitAmend, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmStageAll, window, cx| {
this.run_scm_action(ScmIntent::StageAll, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmUnstageAll, window, cx| {
this.run_scm_action(ScmIntent::UnstageAll, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmDiscardAll, window, cx| {
this.run_scm_action(ScmIntent::DiscardAll, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmRefresh, window, cx| {
this.run_scm_action(ScmIntent::Refresh, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmSync, window, cx| {
this.run_scm_action(ScmIntent::Sync, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmPush, window, cx| {
this.run_scm_action(ScmIntent::Push, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmPull, window, cx| {
this.run_scm_action(ScmIntent::Pull, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmFetch, window, cx| {
this.run_scm_action(ScmIntent::Fetch, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmCheckoutBranch, window, cx| {
this.run_scm_action(ScmIntent::CheckoutBranch, window, cx)
}))
.on_action(cx.listener(|this, _: &ScmCreateBranch, window, cx| {
this.run_scm_action(ScmIntent::CreateBranch, window, cx)
}))
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
this.toggle_settings(window, cx)
}))
+21
View File
@@ -26,6 +26,11 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
let bytes: &'static [u8] = match path {
"icons/terminal.svg" => include_bytes!("../../assets/icons/terminal.svg"),
"icons/git-branch.svg" => include_bytes!("../../assets/icons/git-branch.svg"),
// Deliberately not `refresh.svg`: the panel header already carries a
// refresh tile, and the same glyph meaning two different things one row
// apart reads as a bug.
"icons/git-sync.svg" => include_bytes!("../../assets/icons/git-sync.svg"),
"icons/git-commit.svg" => include_bytes!("../../assets/icons/git-commit.svg"),
"icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"),
"icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"),
"icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"),
@@ -93,6 +98,22 @@ mod tests {
}
}
#[test]
fn every_git_icon_resolves() {
// An SVG on disk that nobody added to the match above silently renders
// as nothing, which is exactly the kind of miss no one notices.
for path in [
"icons/git-branch.svg",
"icons/git-sync.svg",
"icons/git-commit.svg",
] {
assert!(
Assets.load(path).unwrap().is_some(),
"{path} is not registered in `agent_icon`"
);
}
}
#[test]
fn stock_prefix_works_for_unoverridden_glyphs() {
assert_eq!(
+9
View File
@@ -617,6 +617,8 @@ impl Tty7App {
f.saving = Some(seq);
let text = f.input.read(cx).text().to_string();
let target = f.path.clone();
let host_id = host.id();
let saved_in = target.parent().map(std::path::Path::to_path_buf);
HostOps::run_in(
host,
window,
@@ -633,6 +635,7 @@ impl Tty7App {
f.edit_seq,
std::mem::take(&mut f.save_pending),
);
let wrote = result.is_ok();
match result {
Ok(mtime) => {
f.disk_mtime = mtime;
@@ -654,6 +657,12 @@ impl Tty7App {
f.dirty = false;
f.conflict = false;
}
// A save is a working-tree edit the `.git` watch cannot see,
// and the file tree only sees it while it happens to be showing
// that directory.
if wrote && let Some(dir) = &saved_in {
app.scm_invalidate_cwd(host_id, dir, cx);
}
if landing.requeue {
app.editor_save_file(id, false, window, cx);
cx.notify();
+1031 -164
View File
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
//! Turning a parsed hunk into rows a diff view can lay out.
//!
//! Side-by-side and unified are two renderings of the same `Vec<DiffLine>`, so
//! the pairing logic lives here — outside either renderer — and is unit tested
//! without a window.
use crate::terminal::git_diff::{DiffLine, LineKind};
/// A tab is worth this many columns. Not configurable: a diff is read next to
/// the file's other lines, not on its own, and the grid has to line up.
const TAB_WIDTH: usize = 4;
/// Diff text is laid out as a single run, so a literal tab would advance to the
/// renderer's idea of a tab stop rather than the file's. Both views expand
/// them the same way, or the two halves of a split row would drift apart.
pub(crate) fn expand_tabs(text: &str) -> String {
text.replace('\t', &" ".repeat(TAB_WIDTH))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Side {
Old,
New,
}
pub(crate) struct SplitCell {
pub(crate) no: Option<u32>,
pub(crate) text: String,
pub(crate) changed: bool,
}
pub(crate) struct SplitRow {
pub(crate) left: Option<SplitCell>,
pub(crate) right: Option<SplitCell>,
}
/// Pairs each run of removals with the run of additions that follows it, so a
/// rewritten line sits opposite the line it replaced. Whichever run is shorter
/// leaves empty cells at the bottom of the pair.
pub(crate) fn split_hunk(lines: &[DiffLine]) -> Vec<SplitRow> {
fn flush(rows: &mut Vec<SplitRow>, rem: &mut Vec<&DiffLine>, add: &mut Vec<&DiffLine>) {
for i in 0..rem.len().max(add.len()) {
rows.push(SplitRow {
left: rem.get(i).map(|l| SplitCell {
no: l.old_no,
text: expand_tabs(&l.text),
changed: true,
}),
right: add.get(i).map(|l| SplitCell {
no: l.new_no,
text: expand_tabs(&l.text),
changed: true,
}),
});
}
rem.clear();
add.clear();
}
let mut rows = Vec::new();
let mut rem: Vec<&DiffLine> = Vec::new();
let mut add: Vec<&DiffLine> = Vec::new();
for line in lines {
match line.kind {
LineKind::Removed => rem.push(line),
LineKind::Added => add.push(line),
LineKind::Context => {
flush(&mut rows, &mut rem, &mut add);
rows.push(SplitRow {
left: Some(SplitCell {
no: line.old_no,
text: expand_tabs(&line.text),
changed: false,
}),
right: Some(SplitCell {
no: line.new_no,
text: expand_tabs(&line.text),
changed: false,
}),
});
}
}
}
flush(&mut rows, &mut rem, &mut add);
rows
}
pub(crate) struct UnifiedRow {
pub(crate) old: Option<u32>,
pub(crate) new: Option<u32>,
pub(crate) kind: LineKind,
pub(crate) text: String,
}
/// One row per line, in git's own order — every removal in a run first, then
/// every addition. That is the opposite of [`split_hunk`], and it is the whole
/// difference between the two views: unified shows the patch as it was written,
/// side-by-side re-pairs it into before and after.
pub(crate) fn unified_rows(lines: &[DiffLine]) -> Vec<UnifiedRow> {
lines
.iter()
.map(|line| UnifiedRow {
old: line.old_no,
new: line.new_no,
kind: line.kind,
text: expand_tabs(&line.text),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn line(kind: LineKind, old: Option<u32>, new: Option<u32>, text: &str) -> DiffLine {
DiffLine {
kind,
old_no: old,
new_no: new,
text: text.to_string(),
}
}
/// A context line, two removals, one addition, a context line — the shape
/// where the two views visibly disagree.
fn hunk() -> Vec<DiffLine> {
vec![
line(LineKind::Context, Some(1), Some(1), "a"),
line(LineKind::Removed, Some(2), None, "b"),
line(LineKind::Removed, Some(3), None, "c"),
line(LineKind::Added, None, Some(2), "B"),
line(LineKind::Context, Some(4), Some(3), "d"),
]
}
#[test]
fn pairs_removed_and_added_side_by_side() {
let rows = split_hunk(&hunk());
assert_eq!(rows.len(), 4);
let l = rows[0].left.as_ref().unwrap();
let r = rows[0].right.as_ref().unwrap();
assert_eq!((l.no, l.text.as_str(), l.changed), (Some(1), "a", false));
assert_eq!((r.no, r.text.as_str(), r.changed), (Some(1), "a", false));
let l = rows[1].left.as_ref().unwrap();
let r = rows[1].right.as_ref().unwrap();
assert_eq!((l.no, l.text.as_str(), l.changed), (Some(2), "b", true));
assert_eq!((r.no, r.text.as_str(), r.changed), (Some(2), "B", true));
assert_eq!(rows[2].left.as_ref().unwrap().text, "c");
assert!(rows[2].right.is_none());
assert_eq!(rows[3].left.as_ref().unwrap().no, Some(4));
assert_eq!(rows[3].right.as_ref().unwrap().no, Some(3));
}
#[test]
fn expands_tabs_in_cell_text() {
let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")];
let rows = split_hunk(&lines);
assert_eq!(rows[0].right.as_ref().unwrap().text, " indented");
assert!(rows[0].left.is_none());
}
#[test]
fn expand_tabs_is_a_fixed_width_substitution() {
assert_eq!(expand_tabs("plain"), "plain");
assert_eq!(expand_tabs("\tone"), " one");
assert_eq!(expand_tabs("\t\ttwo"), " two");
assert_eq!(
expand_tabs("a\tb"),
"a b",
"a fixed width, not the next tab stop — the diff has no column grid"
);
assert_eq!(expand_tabs(""), "");
}
#[test]
fn unified_keeps_gits_own_order() {
let rows = unified_rows(&hunk());
let shape: Vec<(Option<u32>, Option<u32>, LineKind, &str)> = rows
.iter()
.map(|r| (r.old, r.new, r.kind, r.text.as_str()))
.collect();
assert_eq!(
shape,
[
(Some(1), Some(1), LineKind::Context, "a"),
(Some(2), None, LineKind::Removed, "b"),
(Some(3), None, LineKind::Removed, "c"),
(None, Some(2), LineKind::Added, "B"),
(Some(4), Some(3), LineKind::Context, "d"),
],
"both removals come before the addition, unlike the split view"
);
}
#[test]
fn unified_numbers_each_column_from_the_side_it_belongs_to() {
let rows = unified_rows(&hunk());
assert!(
rows.iter()
.all(|r| (r.old.is_some() && r.new.is_some()) == (r.kind == LineKind::Context)),
"a context line is the only kind that exists on both sides"
);
assert!(
rows.iter()
.filter(|r| r.kind == LineKind::Added)
.all(|r| r.old.is_none())
);
assert!(
rows.iter()
.filter(|r| r.kind == LineKind::Removed)
.all(|r| r.new.is_none())
);
}
#[test]
fn unified_expands_tabs_the_same_way_split_does() {
let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")];
assert_eq!(unified_rows(&lines)[0].text, " indented");
}
#[test]
fn both_views_render_every_line_exactly_once() {
let lines = hunk();
let unified = unified_rows(&lines);
assert_eq!(unified.len(), lines.len());
let cells: usize = split_hunk(&lines)
.iter()
.map(|r| r.left.is_some() as usize + r.right.is_some() as usize)
.sum();
let context = lines.iter().filter(|l| l.kind == LineKind::Context).count();
assert_eq!(
cells,
lines.len() + context,
"a context line fills two cells, a change fills one"
);
}
}
+477 -4
View File
@@ -1,13 +1,18 @@
use std::borrow::Cow;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::core::config::RightPanelTab;
use crate::core::git::status::{DecoStatus, DirRollup, StatusIndex};
use crate::terminal::git_data::index_of;
use crate::ui::app::Tty7App;
use crate::ui::file_copy;
use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub};
use crate::ui::host_registry::HostRegistry;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::right_panel::git_badge;
use crate::ui::scm::status::{status_color, status_glyph};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, ExternalPaths, FocusHandle, KeyDownEvent, MouseButton,
@@ -815,6 +820,21 @@ impl Tty7App {
}) {
roots_moved = self.file_tree.invalidate_repo_roots();
}
// Working-tree edits the source control cache has no other way to hear
// about. Anything under `.git` is skipped: the repository has its own
// watch, and routing it through here would only double the events that
// land in one debounce window.
let mut announced: HashSet<&Path> = HashSet::new();
for path in paths {
if path.components().any(|c| c.as_os_str() == ".git") {
continue;
}
let Some(dir) = path.parent() else { continue };
if announced.insert(dir) {
self.scm_invalidate_cwd(host, dir, cx);
}
}
let gitignore_touched = paths
.iter()
.any(|p| p.file_name().is_some_and(|n| n == ".gitignore"));
@@ -1369,6 +1389,7 @@ impl Tty7App {
if let Some(host) = host.clone() {
self.file_tree_sync_watch(host, cx);
}
let decor = self.file_tree_decorations(host.as_ref(), host_id, &roots, cx);
self.file_tree.sync_search(&query, &roots, cx);
let rows = if self.file_tree_searching(cx) {
self.file_tree.search_rows()
@@ -1406,10 +1427,10 @@ impl Tty7App {
this.file_tree_key_down(ev, window, cx);
}))
.children(blank)
.children(
rows.iter()
.flat_map(|row| self.render_tree_row(row, window, cx)),
)
.children(rows.iter().flat_map(|row| {
let deco = row_decoration(&decor, &row.entry);
self.render_tree_row(row, deco, window, cx)
}))
// Everything the rows do not cover — the gap below the last one,
// and the whole column while the tree is still empty — belongs to
// the top of the tree. A row under the cursor wins: gpui hands a
@@ -1431,9 +1452,36 @@ impl Tty7App {
)
}
/// Ask each root for a fresh status and take the index it already holds.
///
/// The `Arc` is cloned here, outside the row loop: a tree can be thousands
/// of rows and every one of them wants the same index. `scm_refresh` is
/// idempotent and drops a probe that is already running or already current,
/// which is what makes it safe from a render.
fn file_tree_decorations(
&mut self,
host: Option<&SharedHost>,
host_id: HostId,
roots: &[PathBuf],
cx: &mut Context<Self>,
) -> Decorations {
let mut decor: Decorations = Vec::new();
for root in roots {
if let Some(host) = host {
self.scm_refresh(host.clone(), root.clone(), cx);
}
if let Some(index) = index_of(cx, host_id, root) {
decor.push((root.clone(), index));
}
}
order_innermost_first(&mut decor);
decor
}
fn render_tree_row(
&self,
row: &TreeRow,
deco: RowDeco,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
@@ -1527,6 +1575,13 @@ impl Tty7App {
.when(row.entry.ignored, |d| {
d.italic().text_color(muted.opacity(0.7))
})
// The name carrying the colour is the signal people actually
// read; the letter at the end of the row is the confirmation.
.when_some(deco.tint, |d, status| {
d.text_color(status_color(status, cx))
})
.when(deco.strike, |d| d.line_through())
.when(deco.bold, |d| d.font_weight(gpui::FontWeight::SEMIBOLD))
.when(row.is_root, |d| d.font_weight(gpui::FontWeight::MEDIUM))
.child(SharedString::from(row.entry.name.clone()))
.into_any_element()
@@ -1549,6 +1604,10 @@ impl Tty7App {
muted
}))
.child(label)
// Two indicators, two columns, two shapes. The dot is an unsaved
// editor buffer and has nothing to do with git; keeping it round and
// `warning` while the git letter sits in its own trailing cell is
// what stops the two from ever being read as one.
.when(dirty, |d| {
d.child(
div()
@@ -1558,6 +1617,13 @@ impl Tty7App {
.bg(cx.theme().warning),
)
})
.when_some(deco.badge(), |d, (letter, status)| {
d.child(git_badge(
letter,
status_color(status, cx),
&cx.theme().mono_font_family,
))
})
.on_mouse_down(
MouseButton::Left,
cx.listener({
@@ -1843,6 +1909,117 @@ fn dirs_to_relist(paths: &HashSet<PathBuf>, show_hidden: bool) -> HashSet<&Path>
.collect()
}
/// Every repository behind the tree, paired with the root its index keys are
/// relative to. Built once per render and ordered innermost-first.
type Decorations = Vec<(PathBuf, Arc<StatusIndex>)>;
/// What git says about one row: a letter for the trailing badge and the shape
/// of the name beside it.
///
/// The colour is carried as a `DecoStatus` rather than an `Hsla` so that it
/// resolves through the one table in `scm::status` — the panel, the diff cards
/// and the tree cannot grow three opinions about what "modified" looks like —
/// and so that everything below stays a pure function.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
struct RowDeco {
/// Empty wherever no badge is drawn: directories, because a folder is not
/// "M", and ignored rows, because a tree full of `!` is noise.
letter: &'static str,
tint: Option<DecoStatus>,
strike: bool,
bold: bool,
}
impl RowDeco {
fn file(status: DecoStatus) -> RowDeco {
RowDeco {
letter: status_glyph(status),
tint: Some(status),
// The name says "gone" twice — struck through and greyed — because
// the row still occupies a slot in a listing that no longer has the
// file in it.
strike: status == DecoStatus::Deleted,
bold: status == DecoStatus::Conflict,
}
}
/// A directory is two states, never seven: work happened under it, or a
/// conflict is waiting under it. `Modified` and `Conflict` appear here only
/// as the way to reach `warning` and `danger` through the shared table.
fn dir(rollup: DirRollup) -> RowDeco {
let tint = if rollup.conflict {
Some(DecoStatus::Conflict)
} else if rollup.changed {
Some(DecoStatus::Modified)
} else {
None
};
RowDeco {
tint,
..RowDeco::default()
}
}
/// The badge is laid out only where there is a letter for it, so a tree with
/// no repository behind it gives up no width.
fn badge(&self) -> Option<(&'static str, DecoStatus)> {
let status = self.tint?;
(!self.letter.is_empty()).then_some((self.letter, status))
}
}
/// `StatusIndex` is keyed by a repo-root-relative, `/`-separated path. Borrowed
/// rather than built, which on Unix is every row.
fn repo_relative<'a>(root: &Path, path: &'a Path) -> Option<Cow<'a, str>> {
let rel = path.strip_prefix(root).ok()?.to_str()?;
if rel.is_empty() {
// The root row itself, which has no key and nothing worth saying:
// "this repository contains changes" is not news.
return None;
}
Some(with_forward_slashes(rel, std::path::MAIN_SEPARATOR))
}
/// Split out of `repo_relative` so the Windows separator is reachable from a
/// test on any platform — `strip_prefix` only ever splits on the host's own
/// separator, which leaves a backslash path untestable through the caller.
fn with_forward_slashes(text: &str, sep: char) -> Cow<'_, str> {
if sep == '/' || !text.contains(sep) {
return Cow::Borrowed(text);
}
Cow::Owned(text.replace(sep, "/"))
}
/// Innermost first, so a submodule nested inside another root answers for its
/// own files instead of the repository that contains it.
fn order_innermost_first(decor: &mut Decorations) {
decor.sort_by_key(|(root, _)| std::cmp::Reverse(root.as_os_str().len()));
}
/// One hash probe per row and no allocation on the path that matters.
fn row_decoration(decor: &Decorations, entry: &TreeEntry) -> RowDeco {
// A gitignored row keeps the italic-and-dim it has always worn and takes
// nothing else: a letter and a colour would be describing a file the
// repository is not tracking.
if entry.ignored {
return RowDeco::default();
}
for (root, index) in decor {
let Some(rel) = repo_relative(root, &entry.path) else {
continue;
};
return if entry.is_dir {
index.dir(&rel).map(RowDeco::dir).unwrap_or_default()
} else {
// `file` comes back empty once the change count blew past
// `MAX_DECORATED_FILES`. The rollups survive that, so the folders
// keep saying where the work is.
index.file(&rel).map(RowDeco::file).unwrap_or_default()
};
}
RowDeco::default()
}
fn event_can_change_a_row(path: &Path, show_hidden: bool) -> bool {
show_hidden
|| !path
@@ -2024,6 +2201,210 @@ mod tests {
assert_eq!(names, vec!["Alpha", "beta", "Apple.rs", "zeta.rs"]);
}
fn tree_entry(path: &str, is_dir: bool, ignored: bool) -> TreeEntry {
let path = PathBuf::from(path);
TreeEntry {
name: path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
path,
is_dir,
ignored,
}
}
fn one_repo(paths: &[(&str, DecoStatus)]) -> Decorations {
let mut index = StatusIndex::default();
for (path, status) in paths {
index.insert(path, *status);
}
vec![(PathBuf::from("/repo"), Arc::new(index))]
}
#[test]
fn a_row_is_keyed_by_where_it_sits_below_the_repository_root() {
let root = Path::new("/repo");
assert!(
repo_relative(root, Path::new("/repo")).is_none(),
"the root row has no key of its own"
);
assert_eq!(
repo_relative(root, Path::new("/repo/README.md")).as_deref(),
Some("README.md")
);
assert_eq!(
repo_relative(root, Path::new("/repo/src/ui/file_tree.rs")).as_deref(),
Some("src/ui/file_tree.rs")
);
assert!(
repo_relative(root, Path::new("/elsewhere/a.rs")).is_none(),
"a row outside the repository is not decorated"
);
assert!(
repo_relative(root, Path::new("/repository/a.rs")).is_none(),
"a shared text prefix is not a shared root"
);
}
#[test]
fn a_windows_path_is_keyed_with_forward_slashes() {
assert_eq!(
with_forward_slashes(r"src\ui\file_tree.rs", '\\'),
"src/ui/file_tree.rs"
);
assert_eq!(with_forward_slashes("README.md", '\\'), "README.md");
assert_eq!(
with_forward_slashes("src/ui/file_tree.rs", '/'),
"src/ui/file_tree.rs"
);
// The rows that exist in their thousands must not allocate a key.
assert!(matches!(
with_forward_slashes("src/ui/file_tree.rs", '/'),
Cow::Borrowed(_)
));
assert!(matches!(
with_forward_slashes("README.md", '\\'),
Cow::Borrowed(_)
));
}
#[test]
fn every_status_gets_its_letter_and_its_own_name_shape() {
// (status, letter, bold, struck through)
let cases = [
(DecoStatus::Conflict, "U", true, false),
(DecoStatus::Deleted, "D", false, true),
(DecoStatus::Added, "A", false, false),
(DecoStatus::Untracked, "?", false, false),
(DecoStatus::Modified, "M", false, false),
(DecoStatus::Renamed, "R", false, false),
];
for (status, letter, bold, strike) in cases {
let deco = RowDeco::file(status);
assert_eq!(deco.letter, letter, "{status:?}");
assert_eq!(
deco.tint,
Some(status),
"{status:?} colours the name through the shared table"
);
assert_eq!(deco.bold, bold, "{status:?}");
assert_eq!(deco.strike, strike, "{status:?}");
assert_eq!(deco.badge(), Some((letter, status)), "{status:?}");
}
let ignored = RowDeco::file(DecoStatus::Ignored);
assert_eq!(ignored.letter, "");
assert!(
ignored.badge().is_none(),
"a tree full of `!` is noise, not information"
);
}
#[test]
fn a_folder_is_only_ever_changed_or_conflicted_and_never_lettered() {
assert_eq!(RowDeco::dir(DirRollup::default()), RowDeco::default());
let changed = RowDeco::dir(DirRollup {
changed: true,
conflict: false,
});
assert_eq!(
changed.tint,
Some(DecoStatus::Modified),
"the same warning a modified file wears"
);
assert_eq!(changed.letter, "");
assert!(changed.badge().is_none(), "a folder is not `M`");
let conflict = RowDeco::dir(DirRollup {
changed: true,
conflict: true,
});
assert_eq!(
conflict.tint,
Some(DecoStatus::Conflict),
"a conflict below outranks a mere change below"
);
assert_eq!(conflict.letter, "");
assert!(
!conflict.bold,
"the folder points; the file inside it shouts"
);
}
#[test]
fn dropping_the_file_map_leaves_the_folders_decorated() {
let mut index = StatusIndex::default();
index.insert("src/ui/a.rs", DecoStatus::Modified);
index.drop_files();
let decor: Decorations = vec![(PathBuf::from("/repo"), Arc::new(index))];
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/src/ui/a.rs", false, false)),
RowDeco::default(),
"no letter survives the circuit breaker"
);
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/src", true, false)).tint,
Some(DecoStatus::Modified),
"but the folders still say where the work is"
);
}
#[test]
fn a_gitignored_row_is_left_with_the_styling_it_already_had() {
let decor = one_repo(&[("target/debug/app", DecoStatus::Untracked)]);
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/target/debug/app", false, false)).letter,
"?",
"the same row without the ignore flag is decorated"
);
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/target/debug/app", false, true)),
RowDeco::default(),
"italic and dim is the whole of what an ignored row says"
);
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/target", true, true)),
RowDeco::default(),
"and an ignored folder does not get a rollup colour either"
);
}
#[test]
fn the_innermost_repository_answers_for_its_own_rows() {
let mut outer = StatusIndex::default();
outer.insert("vendor/lib/a.rs", DecoStatus::Modified);
let mut inner = StatusIndex::default();
inner.insert("a.rs", DecoStatus::Conflict);
let mut decor: Decorations = vec![
(PathBuf::from("/repo"), Arc::new(outer)),
(PathBuf::from("/repo/vendor/lib"), Arc::new(inner)),
];
order_innermost_first(&mut decor);
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/vendor/lib/a.rs", false, false)).letter,
"U",
"the submodule, not the repository holding it"
);
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/vendor", true, false)).tint,
Some(DecoStatus::Modified),
"the outer repository still rolls its own directories up"
);
assert_eq!(
row_decoration(&decor, &tree_entry("/elsewhere/a.rs", false, false)),
RowDeco::default()
);
assert_eq!(
row_decoration(&decor, &tree_entry("/repo/README.md", false, false)),
RowDeco::default(),
"a clean tracked file is left alone"
);
}
#[test]
fn shell_quote_leaves_safe_paths_and_quotes_the_rest() {
assert_eq!(shell_quote(Path::new("/a/b.txt")), "/a/b.txt");
@@ -2356,6 +2737,50 @@ mod render_idle_gpui_tests {
panic!("the tree never went quiet");
}
/// Runs git with the identity and signing pinned, so the test does not
/// depend on whatever is in the developer's `~/.gitconfig`.
fn git(root: &Path, args: &[&str]) -> bool {
let mut full = vec![
"-c",
"user.name=tty7 test",
"-c",
"user.email=test@tty7.invalid",
"-c",
"commit.gpgsign=false",
];
full.extend_from_slice(args);
std::process::Command::new("git")
.args(&full)
.current_dir(root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// The status probe only goes out from `render`, so this drives frames
/// until the index it produces is on the global.
fn scm_index(
app: &Entity<Tty7App>,
vcx: &mut VisualTestContext,
root: &Path,
) -> Arc<StatusIndex> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
app.update_in(vcx, |_, _, cx| cx.notify());
vcx.background_executor.run_until_parked();
if let Some(index) = app.update_in(vcx, |_, _, cx| index_of(cx, HostId::LOCAL, root)) {
return index;
}
assert!(
std::time::Instant::now() < deadline,
"the repository status never landed"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 {
render_probe::arm(BUDGET);
vcx.background_executor.run_until_parked();
@@ -2383,6 +2808,54 @@ mod render_idle_gpui_tests {
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_decorated_tree_settles_and_then_reaches_render_idle(cx: &mut TestAppContext) {
let _serial = serial();
crate::core::config::pin_test_config_dir();
let root = scratch("decorated");
if !git(&root, &["init", "--quiet"]) {
return; // no git on this machine
}
std::fs::write(root.join("tracked.rs"), "one\n").unwrap();
assert!(git(&root, &["add", "-A"]));
assert!(git(&root, &["commit", "--quiet", "-m", "base"]));
std::fs::write(root.join("tracked.rs"), "one\ntwo\n").unwrap();
std::fs::write(root.join("loose.rs"), "new\n").unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/deep.rs"), "new\n").unwrap();
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
let decor: Decorations = vec![(root.clone(), scm_index(&app, &mut vcx, &root))];
let deco = |name: &str, is_dir: bool| {
row_decoration(
&decor,
&TreeEntry {
name: name.to_string(),
path: root.join(name),
is_dir,
ignored: false,
},
)
};
assert_eq!(deco("tracked.rs", false).letter, "M");
assert_eq!(deco("loose.rs", false).letter, "?");
assert_eq!(
deco("src", true).tint,
Some(DecoStatus::Modified),
"the collapsed folder says there is work under it"
);
assert_eq!(
deco("src", true).letter,
"",
"without pretending to be a file"
);
settle(&app, &mut vcx, &root);
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_settled_files_panel_on_an_empty_directory_reaches_render_idle(cx: &mut TestAppContext) {
let _serial = serial();
+16 -6
View File
@@ -5,7 +5,7 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
use crate::terminal::view::TerminalView;
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::right_panel::{META, TEXT, TEXT_MONO};
@@ -61,6 +61,10 @@ impl Tty7App {
.border_1()
.border_color(theme.danger.opacity(0.4))
.shadow_md()
// Off the right panel's ramp on purpose: this bar floats over the
// terminal, not inside the panel, and it is sized against the
// terminal's own text. `app.rs` draws it, `render_panel_info` does
// not.
.text_xs()
.text_color(theme.muted_foreground)
.child(
@@ -136,15 +140,21 @@ impl Tty7App {
) -> Option<AnyElement> {
let pane_id = pane_id?;
let open = self.loopback_panel.form_pane_id == Some(pane_id);
let add = crate::ui::tab_strip::chrome_tile(
// The section's own affordance, and the same 24px chrome tile the Info
// tab's cwd actions use. It used to be built by hand — a 32px tile
// forced down to 24 and then set `.xsmall()`, which quietly overrode
// the 13px the icon asked for with the button size's own 12, so the
// glyph never was the size the code claimed. `chrome_tile_sized`
// derives it instead: `TILE_GLYPH_SM / BUTTON_ICON_SCALE` of the button
// size, the same pair every other 24px tile in the panel is on.
let add = crate::ui::tab_strip::chrome_tile_sized(
Button::new(("ssh-forward-add-toggle", pane_id))
.icon(Icon::empty().path("icons/plus.svg").size(px(13.))),
.icon(Icon::empty().path("icons/plus.svg")),
TILE_SIZE_SM,
TILE_GLYPH_SM,
open,
cx,
)
.xsmall()
.w(px(24.))
.h(px(24.))
.rounded_md()
.tooltip(if open {
t(L10nKey::Cancel)
+8
View File
@@ -259,6 +259,14 @@ impl<K: Eq + Hash + Clone> InFlight<K> {
self.stale.extend(self.in_flight.iter().cloned());
}
/// Drop every key the predicate rejects — bookkeeping for work that will
/// never land (a host cleared away under an in-flight job). If the job
/// does land after all, its `finish` is a no-op rather than a poison.
pub fn retain(&mut self, keep: impl Fn(&K) -> bool) {
self.in_flight.retain(|k| keep(k));
self.stale.retain(|k| keep(k));
}
pub fn finish(&mut self, key: &K) -> bool {
self.in_flight.remove(key);
!self.stale.remove(key)
+125 -5
View File
@@ -885,7 +885,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::EditorFileTooLarge => "\"{path}\" is too large for the editor ({size} MB)",
L10nKey::EditorBinaryFile => "\"{path}\" looks like a binary file",
L10nKey::PanelInfoTitle => "Info",
L10nKey::PanelChangesTitle => "Changes",
L10nKey::PanelChangesTitle => "Source Control",
L10nKey::PanelScmTitle => "Source Control",
L10nKey::PanelFilesTitle => "Files",
L10nKey::PanelNoSession => "No active session.",
L10nKey::PanelNoSessionHint => {
@@ -913,6 +914,95 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::PanelAgentDone => "done",
L10nKey::PanelRevealInFinder => "Reveal in Finder",
L10nKey::PanelOpenFolder => "Open Folder",
L10nKey::ScmGroupMerge => "Merge Changes",
L10nKey::ScmGroupStaged => "Staged Changes",
L10nKey::ScmGroupChanges => "Changes",
L10nKey::ScmGroupUntracked => "Untracked",
L10nKey::ScmCommitPlaceholder => "Say what changed…",
L10nKey::ScmCommitButton => "Commit",
L10nKey::ScmCommitAllButton => "Commit All",
L10nKey::ScmCommitAmendButton => "Commit (Amend)",
L10nKey::ScmCommitAndPush => "Commit & Push",
L10nKey::ScmCommitAndSync => "Commit & Sync",
L10nKey::ScmAmendLastCommit => "Amend Last Commit",
L10nKey::ScmCommitStaged => "Commit Staged",
L10nKey::ScmStashAll => "Stash All",
L10nKey::ScmNothingToCommit => "Nothing to commit",
L10nKey::ScmNetworkBusy => "Another network operation is still running for this repository",
L10nKey::ScmCommitNeedsMessage => "Write a commit message first",
L10nKey::ScmDetailFilesFailed => "The file list could not be read",
L10nKey::ScmTimeNow => "now",
L10nKey::ScmTimeMinutes => "{n}m",
L10nKey::ScmTimeHours => "{n}h",
L10nKey::ScmTimeDays => "{n}d",
L10nKey::ScmTimeMonths => "{n}mo",
L10nKey::ScmTimeYears => "{n}y",
L10nKey::ScmResetHardConfirm => {
"Reset the branch to this commit? Commits after it fall off the branch, \
and uncommitted changes are discarded."
}
L10nKey::ScmReset => "Reset",
L10nKey::ScmChipStaged => "STAGED",
L10nKey::ScmStage => "Stage Changes",
L10nKey::ScmStageAll => "Stage All Changes",
L10nKey::ScmUnstage => "Unstage Changes",
L10nKey::ScmUnstageAll => "Unstage All Changes",
L10nKey::ScmDiscard => "Discard Changes",
L10nKey::ScmDiscardAll => "Discard All Changes",
L10nKey::ScmDiscardConfirm => "Discard changes to {path}? This cannot be undone.",
L10nKey::ScmOpenConflict => "Resolve Conflict",
L10nKey::ScmMarkResolved => "Mark as Resolved",
L10nKey::ScmUnrepresentablePath => {
"This path is not valid UTF-8, so git cannot be asked about it — read only."
}
L10nKey::ScmPublishBranch => "Publish Branch",
L10nKey::ScmDetached => "detached",
L10nKey::ScmAmendBadge => "amend",
L10nKey::ScmSync => "Sync Changes",
L10nKey::ScmPush => "Push",
L10nKey::ScmPull => "Pull",
L10nKey::ScmFetch => "Fetch",
L10nKey::ScmCheckoutBranch => "Checkout to…",
L10nKey::ScmCreateBranch => "Create Branch…",
L10nKey::ScmSearchBranches => "Search Branches…",
L10nKey::ScmStashAndSwitch => "Stash & Switch",
L10nKey::ScmGraphTitle => "History",
L10nKey::ScmGraphLoadMore => "Load more",
L10nKey::ScmGraphFilterPlaceholder => "Filter commits…",
L10nKey::ScmGraphAllBranches => "All Branches",
L10nKey::ScmGraphEmpty => "No commits yet",
L10nKey::ScmGraphCurrentBranch => "Current Branch",
L10nKey::ScmCheckoutCommit => "Checkout Commit",
L10nKey::ScmCreateBranchHere => "Create Branch Here…",
L10nKey::ScmResetSoft => "Reset (Soft)",
L10nKey::ScmResetMixed => "Reset (Mixed)",
L10nKey::ScmResetHard => "Reset (Hard)",
L10nKey::ScmCommitDetailTitle => "Commit",
L10nKey::ScmCopyCommitSha => "Copy Commit SHA",
L10nKey::ScmCherryPick => "Cherry Pick",
L10nKey::ScmRevertCommit => "Revert Commit",
L10nKey::ScmResetToCommit => "Reset to Commit",
L10nKey::ScmRefresh => "Refresh",
L10nKey::ScmBackToChanges => "Back",
L10nKey::ScmCommitParents => "Parents",
L10nKey::ScmShowMore => "Show more",
L10nKey::ScmShowLess => "Show less",
L10nKey::ScmCommitNotFound => "This commit is not in this repository.",
L10nKey::ScmTooManyChanges => "Showing the first {shown} of {total} changes.",
L10nKey::ScmOpenChanges => "Open Changes",
L10nKey::ScmDiscardAllConfirm => {
"Discard every change in this repository? This cannot be undone."
}
L10nKey::ScmAmendConfirm => {
"Amend the last commit? It will be replaced by a new one, so anyone who already has it has to reconcile."
}
L10nKey::ScmOpMerge => "merging",
L10nKey::ScmOpRebase => "rebasing",
L10nKey::ScmOpCherryPick => "cherry-picking",
L10nKey::ScmOpRevert => "reverting",
L10nKey::ScmOpBisect => "bisecting",
L10nKey::ScmOpAm => "applying",
L10nKey::ScmSwitchRepository => "Switch Repository",
L10nKey::WindowStop => "Stop",
L10nKey::WindowDelete => "Delete",
L10nKey::WindowThisWorkspace => "this workspace",
@@ -960,6 +1050,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::DiffBudget => "tty7's budget",
L10nKey::DiffPerFileCap => "the per-file cap",
L10nKey::DiffUntrackedSummary => "{count} untracked",
L10nKey::DiffViewSplit => "Side by Side",
L10nKey::DiffViewUnified => "Unified",
L10nKey::PendingConnecting => "Connecting to {machine}…",
L10nKey::PendingUnreachable => "Could not reach {machine}",
L10nKey::WorktreePromptNeedsName => "The worktree needs a name",
@@ -1130,6 +1222,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdGroupTabsPanes => "Tabs & Panes",
L10nKey::CmdGroupWorkspaces => "Workspaces",
L10nKey::CmdGroupView => "View",
L10nKey::CmdGroupGit => "Git",
L10nKey::CmdGroupTerminal => "Terminal",
L10nKey::CmdGroupSsh => "SSH",
L10nKey::CmdGroupAgents => "Agents",
@@ -1185,6 +1278,22 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdChangeTheme => "Change Theme…",
L10nKey::CmdResetFontSize => "Reset Font Size",
L10nKey::CmdEnterFullScreen => "Enter Full Screen",
L10nKey::CmdToggleDiffViewMode => "Toggle Unified / Side-by-Side Diff",
L10nKey::CmdGitCommit => "Git: Commit",
L10nKey::CmdGitStageAll => "Git: Stage All Changes",
L10nKey::CmdGitUnstageAll => "Git: Unstage All Changes",
L10nKey::CmdGitDiscardAll => "Git: Discard All Changes",
L10nKey::CmdGitDiscardAllSubtitle => {
"Throws away every uncommitted change in the working tree."
}
L10nKey::CmdGitCheckoutTo => "Git: Checkout to…",
L10nKey::CmdGitCreateBranch => "Git: Create Branch…",
L10nKey::CmdGitSync => "Git: Sync",
L10nKey::CmdGitSyncSubtitle => "Pull, then push.",
L10nKey::CmdGitPush => "Git: Push",
L10nKey::CmdGitPull => "Git: Pull",
L10nKey::CmdGitFetch => "Git: Fetch",
L10nKey::CmdGitToggleGraph => "Git: Toggle Commit History",
L10nKey::CmdClearScrollback => "Clear Scrollback",
L10nKey::CmdFindInTerminal => "Find in Terminal…",
L10nKey::CmdFindNext => "Find Next",
@@ -1385,7 +1494,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::PanelMoreChangedFiles => {
"… and {count} more changed files — run git diff to see them."
}
L10nKey::PanelUntracked => "{count} untracked",
L10nKey::ScmFilesChanged => "{count} files changed",
L10nKey::ScmStagedFileCount => "{count} files staged",
L10nKey::AppMenuAbout => "About tty7",
L10nKey::AppMenuCheckForUpdates => "Check for Updates…",
L10nKey::AppMenuSettings => "Settings…",
@@ -1465,6 +1575,13 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SidebarScratchGroup => "Scratch",
L10nKey::TabContextCloseTab => "Close Tab",
L10nKey::TabContextCloseTabsBelow => "Close Tabs Below",
L10nKey::AppAgentHooksOpFailed => "Failed: {error}",
L10nKey::AppMenuEnterFullscreen => "Enter Full Screen",
L10nKey::HomeTimeOverWeekAgo => "over a week ago",
L10nKey::Search => "Search",
L10nKey::SettingsDaemonStaleRestart => "Restart Service",
L10nKey::SettingsNoneLower => "none",
L10nKey::SettingsSearchCommandLineToolTitle => "Command line tool",
L10nKey::TabContextMarkUnread => "Mark as Unread",
}
}
@@ -1500,9 +1617,12 @@ pub fn translate_variant_en(key: L10nKey, branch: &'static str) -> Option<&'stat
(L10nKey::AppTabsNotRestored, "other") => {
"{count} tabs from last time could not be reopened"
}
(L10nKey::PanelUntracked, "zero") => "0 untracked",
(L10nKey::PanelUntracked, "one") => "1 untracked",
(L10nKey::PanelUntracked, "other") => "{count} untracked",
(L10nKey::ScmFilesChanged, "zero") => "No files changed",
(L10nKey::ScmFilesChanged, "one") => "1 file changed",
(L10nKey::ScmFilesChanged, "other") => "{count} files changed",
(L10nKey::ScmStagedFileCount, "zero") => "No staged changes",
(L10nKey::ScmStagedFileCount, "one") => "1 file staged",
(L10nKey::ScmStagedFileCount, "other") => "{count} files staged",
(L10nKey::PanelMoreChangedFiles, "zero") => {
"… and 0 more changed files — run git diff to see them."
}
+126 -5
View File
@@ -927,7 +927,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorFileTooLarge => "「{path}」はエディタで開くには大きすぎます({size} MB)",
L10nKey::EditorBinaryFile => "「{path}」はバイナリファイルのようです",
L10nKey::PanelInfoTitle => "情報",
L10nKey::PanelChangesTitle => "変更",
L10nKey::PanelChangesTitle => "ソース管理",
L10nKey::PanelScmTitle => "ソース管理",
L10nKey::PanelFilesTitle => "ファイル",
L10nKey::PanelNoSession => "アクティブなセッションがありません",
L10nKey::PanelNoSessionHint => {
@@ -959,6 +960,98 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelAgentDone => "完了",
L10nKey::PanelRevealInFinder => "Finder で表示",
L10nKey::PanelOpenFolder => "フォルダを開く",
L10nKey::ScmGroupMerge => "マージの競合",
L10nKey::ScmGroupStaged => "ステージされた変更",
L10nKey::ScmGroupChanges => "変更",
L10nKey::ScmGroupUntracked => "未追跡",
L10nKey::ScmCommitPlaceholder => "何を変えたか書いてみましょう…",
L10nKey::ScmCommitButton => "コミット",
L10nKey::ScmCommitAllButton => "すべてコミット",
L10nKey::ScmCommitAmendButton => "コミット(修正)",
L10nKey::ScmCommitAndPush => "コミットしてプッシュ",
L10nKey::ScmCommitAndSync => "コミットして同期",
L10nKey::ScmAmendLastCommit => "直前のコミットを修正",
L10nKey::ScmCommitStaged => "ステージ済みをコミット",
L10nKey::ScmStashAll => "すべてスタッシュ",
L10nKey::ScmNothingToCommit => "コミットするものがありません",
L10nKey::ScmNetworkBusy => "このリポジトリでは別のネットワーク操作が実行中です",
L10nKey::ScmCommitNeedsMessage => "先にコミットメッセージを入力してください",
L10nKey::ScmDetailFilesFailed => "ファイル一覧を読み込めませんでした",
L10nKey::ScmTimeNow => "",
L10nKey::ScmTimeMinutes => "{n}分",
// 「{n}時」は時刻に読めるので「時間」のまま。
L10nKey::ScmTimeHours => "{n}時間",
L10nKey::ScmTimeDays => "{n}日",
L10nKey::ScmTimeMonths => "{n}か月",
L10nKey::ScmTimeYears => "{n}年",
L10nKey::ScmResetHardConfirm => {
"ブランチをこのコミットへリセットしますか?それ以降のコミットはブランチから外れ、\
"
}
L10nKey::ScmReset => "リセット",
L10nKey::ScmChipStaged => "ステージ済み",
L10nKey::ScmStage => "変更をステージ",
L10nKey::ScmStageAll => "すべての変更をステージ",
L10nKey::ScmUnstage => "ステージを取り消す",
L10nKey::ScmUnstageAll => "すべてのステージを取り消す",
L10nKey::ScmDiscard => "変更を破棄",
L10nKey::ScmDiscardAll => "すべての変更を破棄",
L10nKey::ScmDiscardConfirm => "{path} の変更を破棄しますか?元に戻せません。",
L10nKey::ScmOpenConflict => "競合を解決",
L10nKey::ScmMarkResolved => "解決済みにする",
L10nKey::ScmUnrepresentablePath => {
"このパスは正しい UTF-8 ではないため git に渡せません — 閲覧のみです。"
}
L10nKey::ScmPublishBranch => "ブランチを公開",
L10nKey::ScmDetached => "デタッチ",
L10nKey::ScmAmendBadge => "修正",
L10nKey::ScmSync => "変更を同期",
L10nKey::ScmPush => "プッシュ",
L10nKey::ScmPull => "プル",
L10nKey::ScmFetch => "フェッチ",
L10nKey::ScmCheckoutBranch => "チェックアウト…",
L10nKey::ScmCreateBranch => "ブランチを作成…",
L10nKey::ScmSearchBranches => "ブランチを検索…",
L10nKey::ScmStashAndSwitch => "スタッシュして切り替え",
L10nKey::ScmGraphTitle => "履歴",
L10nKey::ScmGraphLoadMore => "さらに読み込む",
L10nKey::ScmGraphFilterPlaceholder => "コミットを絞り込む…",
L10nKey::ScmGraphAllBranches => "すべてのブランチ",
L10nKey::ScmGraphEmpty => "まだコミットがありません",
L10nKey::ScmGraphCurrentBranch => "現在のブランチ",
L10nKey::ScmCheckoutCommit => "このコミットをチェックアウト",
L10nKey::ScmCreateBranchHere => "ここにブランチを作成…",
L10nKey::ScmResetSoft => "リセット(ソフト)",
L10nKey::ScmResetMixed => "リセット(ミックス)",
L10nKey::ScmResetHard => "リセット(ハード)",
L10nKey::ScmCommitDetailTitle => "コミット",
L10nKey::ScmCopyCommitSha => "コミット SHA をコピー",
L10nKey::ScmCherryPick => "チェリーピック",
L10nKey::ScmRevertCommit => "コミットを取り消す",
L10nKey::ScmResetToCommit => "このコミットにリセット",
L10nKey::ScmRefresh => "更新",
L10nKey::ScmBackToChanges => "戻る",
L10nKey::ScmCommitParents => "親コミット",
L10nKey::ScmShowMore => "続きを表示",
L10nKey::ScmShowLess => "折りたたむ",
L10nKey::ScmCommitNotFound => "このリポジトリにそのコミットはありません。",
L10nKey::ScmTooManyChanges => {
"変更が多いため、{total} 件のうち先頭 {shown} 件のみ表示しています。"
}
L10nKey::ScmOpenChanges => "変更を開く",
L10nKey::ScmDiscardAllConfirm => {
"このリポジトリのすべての変更を破棄しますか?元に戻せません。"
}
L10nKey::ScmAmendConfirm => {
"直前のコミットを修正しますか?新しいコミットに置き換わるため、すでに取得した人は対応が必要になります。"
}
L10nKey::ScmOpMerge => "マージ中",
L10nKey::ScmOpRebase => "リベース中",
L10nKey::ScmOpCherryPick => "チェリーピック中",
L10nKey::ScmOpRevert => "リバート中",
L10nKey::ScmOpBisect => "二分探索中",
L10nKey::ScmOpAm => "パッチ適用中",
L10nKey::ScmSwitchRepository => "リポジトリを切り替え",
L10nKey::WindowStop => "停止",
L10nKey::WindowDelete => "削除",
L10nKey::WindowThisWorkspace => "このワークスペース",
@@ -1004,6 +1097,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::DiffBudget => "tty7 の予算",
L10nKey::DiffPerFileCap => "ファイルごとの上限",
L10nKey::DiffUntrackedSummary => "未追跡 {count}",
L10nKey::DiffViewSplit => "左右分割",
L10nKey::DiffViewUnified => "統合",
L10nKey::PendingConnecting => "{machine} に接続中…",
L10nKey::PendingUnreachable => "{machine} に到達できませんでした",
L10nKey::WorktreePromptNeedsName => "ワークツリーには名前が必要です",
@@ -1161,6 +1256,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdGroupTabsPanes => "タブとペイン",
L10nKey::CmdGroupWorkspaces => "ワークスペース",
L10nKey::CmdGroupView => "表示",
L10nKey::CmdGroupGit => "Git",
L10nKey::CmdGroupTerminal => "ターミナル",
L10nKey::CmdGroupSsh => "SSH",
L10nKey::CmdGroupAgents => "エージェント",
@@ -1216,6 +1312,20 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdChangeTheme => "テーマを変更…",
L10nKey::CmdResetFontSize => "フォントサイズをリセット",
L10nKey::CmdEnterFullScreen => "全画面表示",
L10nKey::CmdToggleDiffViewMode => "統合 / 左右分割の差分表示を切り替え",
L10nKey::CmdGitCommit => "Git: コミット",
L10nKey::CmdGitStageAll => "Git: すべての変更をステージ",
L10nKey::CmdGitUnstageAll => "Git: すべてのステージを取り消す",
L10nKey::CmdGitDiscardAll => "Git: すべての変更を破棄",
L10nKey::CmdGitDiscardAllSubtitle => "ワークツリーの未コミットの変更をすべて捨てます。",
L10nKey::CmdGitCheckoutTo => "Git: チェックアウト…",
L10nKey::CmdGitCreateBranch => "Git: ブランチを作成…",
L10nKey::CmdGitSync => "Git: 同期",
L10nKey::CmdGitSyncSubtitle => "プルしてからプッシュします。",
L10nKey::CmdGitPush => "Git: プッシュ",
L10nKey::CmdGitPull => "Git: プル",
L10nKey::CmdGitFetch => "Git: フェッチ",
L10nKey::CmdGitToggleGraph => "Git: コミット履歴の表示切替",
L10nKey::CmdClearScrollback => "スクロールバックをクリア",
L10nKey::CmdFindInTerminal => "ターミナル内を検索…",
L10nKey::CmdFindNext => "次を検索",
@@ -1431,7 +1541,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelMoreChangedFiles => {
"… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください"
}
L10nKey::PanelUntracked => "未追跡 {count}",
L10nKey::ScmFilesChanged => "{count} 個のファイルが変更されました",
L10nKey::ScmStagedFileCount => "{count} 個のファイルがステージされました",
L10nKey::AppMenuAbout => "tty7 について",
L10nKey::AppMenuCheckForUpdates => "アップデートを確認…",
L10nKey::AppMenuSettings => "設定…",
@@ -1511,6 +1622,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SidebarScratchGroup => "スクラッチ",
L10nKey::TabContextCloseTab => "タブを閉じる",
L10nKey::TabContextCloseTabsBelow => "下のタブを閉じる",
L10nKey::AppAgentHooksOpFailed => "失敗: {error}",
L10nKey::AppMenuEnterFullscreen => "全画面表示",
L10nKey::HomeTimeOverWeekAgo => "1 週間以上前",
L10nKey::Search => "検索",
L10nKey::SettingsDaemonStaleRestart => "サービスを再起動",
L10nKey::SettingsNoneLower => "なし",
L10nKey::SettingsSearchCommandLineToolTitle => "コマンドラインツール",
L10nKey::TabContextMarkUnread => "未読としてマーク",
})
}
@@ -1542,9 +1660,12 @@ pub fn translate_variant_ja(key: L10nKey, branch: &'static str) -> Option<&'stat
}
(L10nKey::AppTabsNotRestored, "one") => "前回のタブ 1 個を開き直せませんでした",
(L10nKey::AppTabsNotRestored, "other") => "前回のタブ {count} 個を開き直せませんでした",
(L10nKey::PanelUntracked, "zero") => "未追跡 0",
(L10nKey::PanelUntracked, "one") => "未追跡 1",
(L10nKey::PanelUntracked, "other") => "未追跡 {count}",
(L10nKey::ScmFilesChanged, "zero") => "変更されたファイルはありません",
(L10nKey::ScmFilesChanged, "one") => "1 個のファイルが変更されました",
(L10nKey::ScmFilesChanged, "other") => "{count} 個のファイルが変更されました",
(L10nKey::ScmStagedFileCount, "zero") => "ステージされた変更はありません",
(L10nKey::ScmStagedFileCount, "one") => "1 個のファイルがステージされました",
(L10nKey::ScmStagedFileCount, "other") => "{count} 個のファイルがステージされました",
(L10nKey::PanelMoreChangedFiles, "zero") => {
"… さらに変更されたファイル 0 個 — 表示するには `git diff` を実行してください"
}
+658 -2
View File
@@ -76,6 +76,7 @@ l10n_keys! {
FilterHosts,
SearchCommandsOrHost,
SearchTheme,
Search,
SearchWorkspacesAndMachines,
SearchFonts,
SearchFind,
@@ -255,6 +256,7 @@ l10n_keys! {
SettingsJumpHost,
SettingsJumpHostDesc,
SettingsNoneSummary,
SettingsNoneLower,
SettingsPortForwarding,
SettingsRulesOpenedWithConnection,
SettingsAddRule,
@@ -482,6 +484,7 @@ l10n_keys! {
SettingsUpdateChannelNightly,
SettingsDaemonStale,
SettingsDaemonStaleDesc,
SettingsDaemonStaleRestart,
UpdateDialogTitle,
UpdateDialogDetail,
UpdateDialogDetailWindows,
@@ -534,6 +537,7 @@ l10n_keys! {
SettingsSearchClaudeCodeKeywords,
SettingsSearchCodexKeywords,
SettingsSearchCommandLineToolKeywords,
SettingsSearchCommandLineToolTitle,
SettingsSearchCopilotCliKeywords,
SettingsSearchCopyOnSelectKeywords,
SettingsSearchCursorBlinkKeywords,
@@ -682,6 +686,7 @@ l10n_keys! {
EditorBinaryFile,
PanelInfoTitle,
PanelChangesTitle,
PanelScmTitle,
PanelFilesTitle,
PanelNoSession,
PanelNoSessionHint,
@@ -693,7 +698,6 @@ l10n_keys! {
PanelNoChanges,
PanelNoChangesHint,
PanelMoreChangedFiles,
PanelUntracked,
PanelSessionSubtitle,
PanelProcessesSubtitle,
PanelPortsSubtitle,
@@ -709,6 +713,88 @@ l10n_keys! {
PanelAgentDone,
PanelRevealInFinder,
PanelOpenFolder,
ScmGroupMerge,
ScmGroupStaged,
ScmGroupChanges,
ScmGroupUntracked,
ScmCommitPlaceholder,
ScmCommitButton,
ScmCommitAllButton,
ScmCommitAmendButton,
ScmCommitAndPush,
ScmCommitAndSync,
ScmAmendLastCommit,
ScmCommitStaged,
ScmStashAll,
ScmNothingToCommit,
ScmNetworkBusy,
ScmCommitNeedsMessage,
ScmDetailFilesFailed,
ScmTimeNow,
ScmTimeMinutes,
ScmTimeHours,
ScmTimeDays,
ScmTimeMonths,
ScmTimeYears,
ScmResetHardConfirm,
ScmReset,
ScmChipStaged,
ScmStage,
ScmStageAll,
ScmUnstage,
ScmUnstageAll,
ScmDiscard,
ScmDiscardAll,
ScmDiscardConfirm,
ScmOpenConflict,
ScmMarkResolved,
ScmUnrepresentablePath,
ScmPublishBranch,
ScmDetached,
ScmAmendBadge,
ScmSync,
ScmPush,
ScmPull,
ScmFetch,
ScmCheckoutBranch,
ScmCreateBranch,
ScmSearchBranches,
ScmStashAndSwitch,
ScmGraphTitle,
ScmGraphLoadMore,
ScmGraphFilterPlaceholder,
ScmGraphAllBranches,
ScmGraphEmpty,
ScmGraphCurrentBranch,
ScmCheckoutCommit,
ScmCreateBranchHere,
ScmResetSoft,
ScmResetMixed,
ScmResetHard,
ScmCommitDetailTitle,
ScmCopyCommitSha,
ScmCherryPick,
ScmRevertCommit,
ScmResetToCommit,
ScmRefresh,
ScmBackToChanges,
ScmCommitParents,
ScmShowMore,
ScmShowLess,
ScmCommitNotFound,
ScmTooManyChanges,
ScmOpenChanges,
ScmDiscardAllConfirm,
ScmAmendConfirm,
ScmOpMerge,
ScmOpRebase,
ScmOpCherryPick,
ScmOpRevert,
ScmOpBisect,
ScmOpAm,
ScmSwitchRepository,
ScmFilesChanged,
ScmStagedFileCount,
WindowStop,
WindowDelete,
WindowThisWorkspace,
@@ -736,6 +822,8 @@ l10n_keys! {
DiffBudget,
DiffPerFileCap,
DiffUntrackedSummary,
DiffViewSplit,
DiffViewUnified,
PendingConnecting,
PendingUnreachable,
WorktreePromptNeedsName,
@@ -752,6 +840,7 @@ l10n_keys! {
HomeTimeHoursAgo,
HomeTimeYesterday,
HomeTimeDaysAgo,
HomeTimeOverWeekAgo,
HomeTimeWeeksAgo,
HomeTimeMonthsAgo,
HomeTimeOverYearAgo,
@@ -808,6 +897,7 @@ l10n_keys! {
AppMenuFocusPreviousPane,
AppMenuZoomPane,
AppMenuClearScrollback,
AppMenuEnterFullscreen,
AppMenuDocumentation,
AppMenuKeyboardShortcuts,
AppMenuJoinDiscord,
@@ -927,6 +1017,7 @@ l10n_keys! {
CmdGroupTabsPanes,
CmdGroupWorkspaces,
CmdGroupView,
CmdGroupGit,
CmdGroupTerminal,
CmdGroupSsh,
CmdGroupAgents,
@@ -982,6 +1073,20 @@ l10n_keys! {
CmdChangeTheme,
CmdResetFontSize,
CmdEnterFullScreen,
CmdToggleDiffViewMode,
CmdGitCommit,
CmdGitStageAll,
CmdGitUnstageAll,
CmdGitDiscardAll,
CmdGitDiscardAllSubtitle,
CmdGitCheckoutTo,
CmdGitCreateBranch,
CmdGitSync,
CmdGitSyncSubtitle,
CmdGitPush,
CmdGitPull,
CmdGitFetch,
CmdGitToggleGraph,
CmdClearScrollback,
CmdFindInTerminal,
CmdFindNext,
@@ -1082,6 +1187,7 @@ l10n_keys! {
AppAgentHooksNoHomeDir,
AppAgentHooksOffline,
AppAgentHooksHomeDirUnresolved,
AppAgentHooksOpFailed,
AppAgentHooksInstalled,
AppAgentHooksInstalledEnableCodexThere,
AppAgentHooksInstalledCodexEnableFailed,
@@ -1126,6 +1232,25 @@ l10n_keys! {
SettingsPerPaneHistoryDescription,
}
/// The source control strings that are translated but not yet displayed.
///
/// They all ship in one go so the three language files are edited once for the
/// whole feature instead of once per step, and so the wording can be reviewed
/// as a set rather than a string at a time. Naming them here is what keeps
/// `dead_code` reporting on the rest of the enum: without it every unused key
/// is folded into one warning and a genuinely stale key hides in the crowd.
///
/// **Delete a key from this list as soon as something renders it.**
#[allow(dead_code)]
const SCM_KEYS_AWAITING_A_CALLER: &[L10nKey] = &[
L10nKey::ScmCheckoutBranch,
L10nKey::ScmCommitDetailTitle,
L10nKey::ScmCommitStaged,
L10nKey::ScmResetToCommit,
L10nKey::ScmSearchBranches,
L10nKey::ScmStashAndSwitch,
];
pub fn set_locale(gui_language: &str) {
let index = SUPPORTED_LANGUAGES
.iter()
@@ -1258,6 +1383,7 @@ mod tests {
const KEPT_IN_ENGLISH: &[L10nKey] = &[
// Protocol, format and command names no locale translates.
L10nKey::CmdGroupSsh,
L10nKey::CmdGroupGit,
L10nKey::SettingsNavSsh,
L10nKey::ForwardSocksLabel,
L10nKey::RemoteInstallShaLabel,
@@ -1286,6 +1412,535 @@ mod tests {
L10nKey::SettingsBackdropMicaAlt,
L10nKey::SettingsBackdropAcrylic,
// A language is named in its own language on every locale's list.
L10nKey::SettingsSearchAboutKeywords,
L10nKey::SettingsSearchAppHttpProxyKeywords,
L10nKey::SettingsSearchAnsiColorsKeywords,
L10nKey::SettingsSearchArgumentsKeywords,
L10nKey::SettingsSearchBlurKeywords,
L10nKey::SettingsSearchBoldFontKeywords,
L10nKey::SettingsSearchClaudeCodeKeywords,
L10nKey::SettingsSearchCodexKeywords,
L10nKey::SettingsSearchCommandLineToolKeywords,
L10nKey::SettingsSearchCommandLineToolTitle,
L10nKey::SettingsSearchCopilotCliKeywords,
L10nKey::SettingsSearchCopyOnSelectKeywords,
L10nKey::SettingsSearchCursorBlinkKeywords,
L10nKey::SettingsSearchCursorShapeKeywords,
L10nKey::SettingsSearchCustomThemesKeywords,
L10nKey::SettingsSearchDetectUrlsKeywords,
L10nKey::SettingsSearchDiffPreviewFromCountsKeywords,
L10nKey::SettingsSearchDimInactivePanesKeywords,
L10nKey::SettingsSearchFocusFollowsMouseKeywords,
L10nKey::SettingsSearchFontFamilyKeywords,
L10nKey::SettingsSearchFontLigaturesKeywords,
L10nKey::SettingsSearchFontSizeKeywords,
L10nKey::SettingsSearchForwardSshLoopbackLinksKeywords,
L10nKey::SettingsSearchGrokBuildKeywords,
L10nKey::SettingsSearchHideMouseWhileTypingKeywords,
L10nKey::SettingsSearchHistorySearchKeywords,
L10nKey::SettingsSearchHostsKeywords,
L10nKey::SettingsSearchHowShellsWorkKeywords,
L10nKey::SettingsSearchHowShellsWorkTitle,
L10nKey::SettingsSearchItalicFontKeywords,
L10nKey::SettingsSearchKeybindingsKeywords,
L10nKey::SettingsSearchKeybindingsTitle,
L10nKey::SettingsSearchLineHeightKeywords,
L10nKey::SettingsSearchNewTabPositionKeywords,
L10nKey::SettingsSearchNotifyOnCommandFinishKeywords,
L10nKey::SettingsSearchNotifyThresholdKeywords,
L10nKey::SettingsSearchOhMyPiKeywords,
L10nKey::SettingsSearchOpacityKeywords,
L10nKey::SettingsSearchOpenFilesWithKeywords,
L10nKey::SettingsSearchOpencodeKeywords,
L10nKey::SettingsSearchOptionAsMetaKeywords,
L10nKey::SettingsSearchPiKeywords,
L10nKey::SettingsSearchPortForwardingKeywords,
L10nKey::SettingsSearchProgramKeywords,
L10nKey::SettingsSearchRememberWindowSizeKeywords,
L10nKey::SettingsSearchReportMouseToAppsKeywords,
L10nKey::SettingsSearchRestoreLastLayoutKeywords,
L10nKey::SettingsSearchScrollSpeedKeywords,
L10nKey::SettingsSearchScrollbackKeywords,
L10nKey::SettingsSearchShowTrayIconKeywords,
L10nKey::SettingsSearchSidebarGroupingKeywords,
L10nKey::SettingsSearchSmartSelectionKeywords,
L10nKey::SettingsSearchStartInKeywords,
L10nKey::SettingsSearchSyncWithSystemKeywords,
L10nKey::SettingsSearchTabBarPositionKeywords,
L10nKey::SettingsSearchTabCompletionKeywords,
L10nKey::SettingsSearchTerminalBellKeywords,
L10nKey::SettingsSearchThemeKeywords,
L10nKey::SettingsSearchTrimTrailingSpacesKeywords,
L10nKey::SettingsSearchVerifyHostKeysKeywords,
L10nKey::SettingsSearchWarnBeforeClosingKeywords,
L10nKey::SettingsSearchStartupWindowKeywords,
L10nKey::SwitcherNoMatch,
L10nKey::AddSshHost,
L10nKey::ClickForNewWindow,
L10nKey::RestartServer,
L10nKey::OtherMachines,
L10nKey::Ok,
L10nKey::SftpNoTransfers,
L10nKey::SftpPanelTitleFiles,
L10nKey::SftpTooltipRefresh,
L10nKey::SftpTooltipMore,
L10nKey::SftpMenuNewFolder,
L10nKey::SftpMenuNewFile,
L10nKey::SftpMenuUpload,
L10nKey::SftpMenuGotoShellCwd,
L10nKey::SftpMenuHideTransferHistory,
L10nKey::SftpMenuTransferHistory,
L10nKey::SftpEditNewFolder,
L10nKey::SftpEditNewFile,
L10nKey::SftpEditRename,
L10nKey::SftpEditPermissions,
L10nKey::SftpLoading,
L10nKey::SftpEmptyDirectory,
L10nKey::SftpContextOpen,
L10nKey::SftpContextFollowSymlink,
L10nKey::SftpContextRename,
L10nKey::SftpContextChmod,
L10nKey::SftpTransferSummaryRunning,
L10nKey::SftpTransferSummaryFailed,
L10nKey::SftpTransferSummaryIdle,
L10nKey::SftpTransferProgress,
L10nKey::SftpTransferDone,
L10nKey::SftpTransferCancelled,
L10nKey::SftpTransferError,
L10nKey::SftpImagePasteUploadFailed,
L10nKey::ForwardPanelTitle,
L10nKey::ForwardDisconnected,
L10nKey::ForwardDisconnectedFrom,
L10nKey::ForwardTooltipAdd,
L10nKey::ForwardTooltipRemove,
L10nKey::ForwardLocal,
L10nKey::ForwardRemote,
L10nKey::ForwardDynamic,
L10nKey::ForwardBindLabel,
L10nKey::ForwardToLabel,
L10nKey::ForwardSocksLabel,
L10nKey::ForwardAdd,
L10nKey::FileTreePlaceholderFileName,
L10nKey::FileTreePlaceholderFolderName,
L10nKey::FileTreePlaceholderNewName,
L10nKey::FileTreeDeleteTitle,
L10nKey::FileTreeDeleteFolderBody,
L10nKey::FileTreeDeleteFileBody,
L10nKey::FileTreeDeleteFailed,
L10nKey::FileTreeContextOpen,
L10nKey::FileTreeContextCdHere,
L10nKey::FileTreeContextInsertPath,
L10nKey::FileTreeContextAttachAgent,
L10nKey::FileTreeContextNewFile,
L10nKey::FileTreeContextNewFolder,
L10nKey::FileTreeContextRename,
L10nKey::FileTreeContextCopyPath,
L10nKey::FileTreeContextHideDotfiles,
L10nKey::FileTreeContextShowDotfiles,
L10nKey::SshPromptNewKey,
L10nKey::SshPromptOldKey,
L10nKey::EditorCantOpen,
L10nKey::EditorCantRead,
L10nKey::EditorNotUtf8,
L10nKey::EditorSaveFailed,
L10nKey::EditorUnsavedChanges,
L10nKey::EditorDiscard,
L10nKey::EditorNoFileOpen,
L10nKey::EditorBackToTerminal,
L10nKey::EditorLnCol,
L10nKey::EditorEdit,
L10nKey::EditorPreview,
L10nKey::EditorWrapOn,
L10nKey::EditorWrapOff,
L10nKey::EditorFileTooLarge,
L10nKey::EditorBinaryFile,
L10nKey::PanelInfoTitle,
L10nKey::PanelChangesTitle,
L10nKey::PanelFilesTitle,
L10nKey::PanelNoSession,
L10nKey::PanelNoSessionHint,
L10nKey::PanelNoWorkingDirectory,
L10nKey::PanelNoWorkingDirectoryHint,
L10nKey::PanelLoading,
L10nKey::PanelNotAGitRepo,
L10nKey::PanelNotAGitRepoHint,
L10nKey::PanelNoChanges,
L10nKey::PanelNoChangesHint,
L10nKey::PanelMoreChangedFiles,
L10nKey::PanelSessionSubtitle,
L10nKey::PanelProcessesSubtitle,
L10nKey::PanelPortsSubtitle,
L10nKey::PanelCwd,
L10nKey::PanelShell,
L10nKey::PanelSsh,
L10nKey::PanelBranch,
L10nKey::PanelChangesRow,
L10nKey::PanelAgent,
L10nKey::PanelAgentIdle,
L10nKey::PanelAgentWorking,
L10nKey::PanelAgentWaiting,
L10nKey::PanelAgentDone,
L10nKey::PanelRevealInFinder,
L10nKey::PanelOpenFolder,
L10nKey::WindowStop,
L10nKey::WindowDelete,
L10nKey::WindowThisWorkspace,
L10nKey::WindowConfirmTitle,
L10nKey::WindowStopUnreachable,
L10nKey::WindowDeleteUnreachable,
L10nKey::WindowStopShells,
L10nKey::WindowDeleteShells,
L10nKey::DiffReading,
L10nKey::DiffNotARepo,
L10nKey::DiffReadFailed,
L10nKey::DiffWorkingTreeClean,
L10nKey::DiffCloseTooltip,
L10nKey::DiffChangedFiles,
L10nKey::DiffUntrackedCount,
L10nKey::DiffMoreFiles,
L10nKey::DiffOversizedNotice,
L10nKey::DiffTruncatedPerFile,
L10nKey::DiffTruncatedBudget,
L10nKey::DiffUntrackedHeader,
L10nKey::DiffMoreUntracked,
L10nKey::DiffLines,
L10nKey::DiffChangedLines,
L10nKey::DiffBudgetAndCap,
L10nKey::DiffBudget,
L10nKey::DiffPerFileCap,
L10nKey::DiffUntrackedSummary,
L10nKey::PendingConnecting,
L10nKey::PendingUnreachable,
L10nKey::WorktreePromptNeedsName,
L10nKey::WorktreePromptTitle,
L10nKey::WorktreePromptName,
L10nKey::WorktreePromptBranch,
L10nKey::WorktreePromptBase,
L10nKey::WorktreePromptCreating,
L10nKey::WorktreePromptCreate,
L10nKey::AppNewWorktreeFailed,
L10nKey::HomeTimeJustNow,
L10nKey::HomeTimeMinutesAgo,
L10nKey::HomeTimeHourAgo,
L10nKey::HomeTimeHoursAgo,
L10nKey::HomeTimeYesterday,
L10nKey::HomeTimeDaysAgo,
L10nKey::HomeTimeOverWeekAgo,
L10nKey::HomeReopenNamed,
L10nKey::AppMenuAbout,
L10nKey::AppMenuCheckForUpdates,
L10nKey::AppMenuSettings,
L10nKey::AppMenuServices,
L10nKey::AppMenuHideApp,
L10nKey::AppMenuHideOthers,
L10nKey::AppMenuShowAll,
L10nKey::AppMenuQuit,
L10nKey::AppMenuFile,
L10nKey::AppMenuEdit,
L10nKey::AppMenuView,
L10nKey::AppMenuWindow,
L10nKey::AppMenuHelp,
L10nKey::AppMenuNewTab,
L10nKey::AppMenuNewWorkspace,
L10nKey::AppMenuNewWorktreeTab,
L10nKey::AppMenuSplitRight,
L10nKey::AppMenuSplitLeft,
L10nKey::AppMenuSplitDown,
L10nKey::AppMenuSplitUp,
L10nKey::AppMenuRenameTab,
L10nKey::AppMenuCopyWorkingDirectory,
L10nKey::AppMenuCopySessionId,
L10nKey::AppMenuForkSession,
L10nKey::AppMenuClosePaneTab,
L10nKey::AppMenuCloseOtherTabs,
L10nKey::AppMenuCloseTabsRight,
L10nKey::AppMenuReopenClosedTab,
L10nKey::AppMenuRenameWorkspace,
L10nKey::AppMenuStopWorkspace,
L10nKey::AppMenuDeleteWorkspace,
L10nKey::AppMenuUndo,
L10nKey::AppMenuRedo,
L10nKey::AppMenuCut,
L10nKey::AppMenuCopy,
L10nKey::AppMenuPaste,
L10nKey::AppMenuSelectAll,
L10nKey::AppMenuFind,
L10nKey::AppMenuFindNext,
L10nKey::AppMenuFindPrevious,
L10nKey::AppMenuCommandPalette,
L10nKey::AppMenuIncreaseFontSize,
L10nKey::AppMenuDecreaseFontSize,
L10nKey::AppMenuResetFontSize,
L10nKey::AppMenuLeftSidebar,
L10nKey::AppMenuRightPanel,
L10nKey::AppMenuCodePanel,
L10nKey::AppMenuTabBarPosition,
L10nKey::AppMenuFocusNextPane,
L10nKey::AppMenuFocusPreviousPane,
L10nKey::AppMenuZoomPane,
L10nKey::AppMenuClearScrollback,
L10nKey::AppMenuEnterFullscreen,
L10nKey::AppMenuDocumentation,
L10nKey::AppMenuKeyboardShortcuts,
L10nKey::AppMenuJoinDiscord,
L10nKey::AppMenuReportIssue,
L10nKey::AppMenuRestartServer,
L10nKey::WindowUntitled,
L10nKey::TrayShowTty7,
L10nKey::TrayNotifications,
L10nKey::TrayAgentNeedsInput,
L10nKey::NotifyCommandFinished,
L10nKey::NotifyCommandFinishedWithCommand,
L10nKey::NotifyAgentFinished,
L10nKey::NotifyAgentWaiting,
L10nKey::NotifyTurnFinished,
L10nKey::TabTooltipMore,
L10nKey::TabTooltipShowSidebar,
L10nKey::TabTooltipHideSidebar,
L10nKey::TabTooltipHideDetailPanel,
L10nKey::TabTooltipShowDetailPanel,
L10nKey::TabUnnamedShell,
L10nKey::ShellDefault,
L10nKey::SidebarScratchGroup,
L10nKey::TabContextCloseTab,
L10nKey::TabContextCloseTabsBelow,
L10nKey::TabContextMarkUnread,
L10nKey::RemoteStripDisconnected,
L10nKey::RemoteStripConnecting,
L10nKey::RemoteStripReconnecting,
L10nKey::RemoteStripReconnectingAttempt,
L10nKey::RemoteStripPreempted,
L10nKey::RemoteStripFailed,
L10nKey::RemoteNoticePreempted,
L10nKey::RemoteNoticeDisconnected,
L10nKey::RemoteActionRetryNow,
L10nKey::RemoteActionTakeBack,
L10nKey::RemoteActionConnect,
L10nKey::RemoteActionRetry,
L10nKey::RemoteNoConnectionDetails,
L10nKey::RemoteThisComputer,
L10nKey::RemoteRestartTitle,
L10nKey::RemoteRestartBody,
L10nKey::RemoteReplaceBody,
L10nKey::RemoteRestartFailedTitle,
L10nKey::RemoteRestartFailedBody,
L10nKey::RemoteHostUnreachable,
L10nKey::RemoteInstallTitle,
L10nKey::RemoteInstallDetail,
L10nKey::RemoteInstallPathLabel,
L10nKey::RemoteInstallVersionLabel,
L10nKey::RemoteInstallSizeLabel,
L10nKey::RemoteInstallFromLabel,
L10nKey::RemoteInstallShaLabel,
L10nKey::RemoteInstallSilentUpgrades,
L10nKey::RemoteInstallBytes,
L10nKey::RemoteMismatchTitle,
L10nKey::RemoteMismatchDetail,
L10nKey::RemoteMismatchUnknownBuild,
L10nKey::RemoteMismatchUnknownBuildFromExe,
L10nKey::RemoteMismatchReplaceServer,
L10nKey::RemoteServerOutdated,
L10nKey::RemoteServerTooNew,
L10nKey::RemoteDaemonStartFailed,
L10nKey::RemoteDaemonUnreachable,
L10nKey::RemoteDaemonTooOld,
L10nKey::RemoteProfileMissing,
L10nKey::RemoteAliasMissing,
L10nKey::RemoteWslNoSsh,
L10nKey::RemoteLocalStdioNoSsh,
L10nKey::RemoteHostNotTty7,
L10nKey::RemoteWorkspaceListFailed,
L10nKey::RemoteServerRestartFailed,
L10nKey::RemoteNoRouteToHost,
L10nKey::RemoteMachineTreeUnexpectedReply,
L10nKey::RemoteMismatchVersionFromExe,
L10nKey::AppNoRunningCodingAgent,
L10nKey::SwitcherThisComputer,
L10nKey::SwitcherRestartingServer,
L10nKey::SwitcherDownloadingServerWithTotal,
L10nKey::SwitcherDownloadingServerNoTotal,
L10nKey::SwitcherCopyingServer,
L10nKey::SwitcherThisWindow,
L10nKey::SwitcherOpen,
L10nKey::SwitcherDisconnect,
L10nKey::SwitcherOpenInNewWindow,
L10nKey::SwitcherRename,
L10nKey::SwitcherPickAWorkspace,
L10nKey::SwitcherNoTabs,
L10nKey::SwitcherTabsAfterOpening,
L10nKey::SwitcherTabCount,
L10nKey::SwitcherTabCountOne,
L10nKey::SwitcherActiveTab,
L10nKey::SwitcherHoldToSwitch,
L10nKey::SshPromptPasswordFor,
L10nKey::SshPromptPassphraseFor,
L10nKey::SshPromptTwoFactor,
L10nKey::SshPromptUnknownHost,
L10nKey::SshPromptHostKeyChanged,
L10nKey::SshPromptHostKeyChangedBody,
L10nKey::SshPromptConnect,
L10nKey::SshPromptUnlock,
L10nKey::SshPromptSubmit,
L10nKey::HostOpsError,
L10nKey::TreeWindowOpenedEmpty,
L10nKey::CmdGroupTabsPanes,
L10nKey::CmdGroupWorkspaces,
L10nKey::CmdGroupView,
L10nKey::CmdGroupTerminal,
L10nKey::CmdGroupSsh,
L10nKey::CmdGroupAgents,
L10nKey::CmdGroupApplication,
L10nKey::CmdNewTab,
L10nKey::CmdNewWorktreeTab,
L10nKey::CmdNewWorktreeTabSubtitle,
L10nKey::CmdRenameTab,
L10nKey::CmdSplitRight,
L10nKey::CmdSplitDown,
L10nKey::CmdZoomPane,
L10nKey::CmdNextPane,
L10nKey::CmdPreviousPane,
L10nKey::CmdFocusPaneLeft,
L10nKey::CmdFocusPaneRight,
L10nKey::CmdFocusPaneUp,
L10nKey::CmdFocusPaneDown,
L10nKey::CmdResizePaneLeft,
L10nKey::CmdResizePaneRight,
L10nKey::CmdResizePaneUp,
L10nKey::CmdResizePaneDown,
L10nKey::CmdSwapPaneNext,
L10nKey::CmdSwapPanePrevious,
L10nKey::CmdNextTab,
L10nKey::CmdPreviousTab,
L10nKey::CmdCopyWorkingDirectory,
L10nKey::CmdCopySessionId,
L10nKey::CmdCopySessionIdSubtitle,
L10nKey::CmdForkSession,
L10nKey::CmdForkSessionSubtitle,
L10nKey::CmdMarkTabAsUnread,
L10nKey::CmdClosePaneTab,
L10nKey::CmdCloseOtherTabs,
L10nKey::CmdCloseTabsToTheRight,
L10nKey::CmdReopenClosedTab,
L10nKey::CmdNewWorkspace,
L10nKey::CmdSwitchWorkspace,
L10nKey::CmdRenameWorkspace,
L10nKey::CmdStopWorkspace,
L10nKey::CmdStopWorkspaceSubtitle,
L10nKey::CmdDeleteWorkspace,
L10nKey::CmdDeleteWorkspaceSubtitle,
L10nKey::CmdShowLeftSidebar,
L10nKey::CmdHideLeftSidebar,
L10nKey::CmdHideRightPanel,
L10nKey::CmdShowRightPanel,
L10nKey::CmdShowCodePanel,
L10nKey::CmdTabBarMoveToTop,
L10nKey::CmdTabBarMoveToLeftSidebar,
L10nKey::CmdRightPanelInfo,
L10nKey::CmdRightPanelChanges,
L10nKey::CmdRightPanelFiles,
L10nKey::CmdChangeTheme,
L10nKey::CmdResetFontSize,
L10nKey::CmdEnterFullScreen,
L10nKey::CmdClearScrollback,
L10nKey::CmdFindInTerminal,
L10nKey::CmdFindNext,
L10nKey::CmdFindPrevious,
L10nKey::CmdCopy,
L10nKey::CmdCut,
L10nKey::CmdPaste,
L10nKey::CmdSelectAll,
L10nKey::CmdSshAddConnection,
L10nKey::CmdSshManageProfiles,
L10nKey::CmdSshReconnect,
L10nKey::CmdSshRemoteFiles,
L10nKey::CmdSshPortForwarding,
L10nKey::CmdSshConnectWithInput,
L10nKey::CmdAgentSendSelection,
L10nKey::CmdAgentSendSelectionSubtitle,
L10nKey::CmdAgentSendGitDiffForReview,
L10nKey::CmdAgentSendGitDiffSubtitle,
L10nKey::CmdSettings,
L10nKey::CmdKeyboardShortcuts,
L10nKey::CmdAboutTty7,
L10nKey::CmdCheckForUpdates,
L10nKey::CmdDocumentation,
L10nKey::CmdJoinDiscord,
L10nKey::CmdReportIssue,
L10nKey::CmdRestartServer,
L10nKey::CmdRestartServerSubtitle,
L10nKey::CmdQuitTty7,
L10nKey::CmdQuitTty7Subtitle,
L10nKey::CmdQuickConnect,
L10nKey::CmdQuickConnectSaveProfile,
L10nKey::CmdRecent,
L10nKey::AppRestartServerTitle,
L10nKey::AppRestartServerMismatchDetail,
L10nKey::AppRestartServerDialectDetail,
L10nKey::AppRestartServerDialectNewerDetail,
L10nKey::AppRestartServerOldDetail,
L10nKey::AppRestart,
L10nKey::AppRestartServerNoServer,
L10nKey::AppRestartServerBody,
L10nKey::AppWorktreeRemoveDetailDirty,
L10nKey::AppWorktreeRemoveDetailClean,
L10nKey::AppWorktreeRemoveTitle,
L10nKey::AppWorktreeDiscardAndRemove,
L10nKey::AppWorktreeRemove,
L10nKey::AppWorktreeKeep,
L10nKey::AppReopenTabFailed,
L10nKey::AppOpenTerminalFailed,
L10nKey::AppSshConnectionFailed,
L10nKey::AppSshReconnectFailed,
L10nKey::AppSplitPaneFailed,
L10nKey::AppWorktreeRemoved,
L10nKey::AppWorktreeRemoveFailed,
L10nKey::AppForkStillConnecting,
L10nKey::AppPaneNoCodingAgent,
L10nKey::AppForkNoCommand,
L10nKey::AppForkLocalOnly,
L10nKey::AppForkNoSessionId,
L10nKey::AppForkSessionIdNotToken,
L10nKey::AppForkMidTurn,
L10nKey::AppTabNoWorkingDirectory,
L10nKey::AppNothingSelected,
L10nKey::AppPaneNoKnownDirectory,
L10nKey::AppNoUncommittedChanges,
L10nKey::AppCmdSshProfileTitle,
L10nKey::AppCmdSwitchToTab,
L10nKey::AppPlaceholderDescription,
L10nKey::AppPlaceholderSshQuickConnect,
L10nKey::AppPlaceholderLoginShell,
L10nKey::AppPlaceholderNone,
L10nKey::AppPlaceholderOpenInDefaultApp,
L10nKey::AppThemeColorBackground,
L10nKey::AppThemeColorForeground,
L10nKey::AppThemeColorAccent,
L10nKey::AppThemeColorCursor,
L10nKey::AppThemeColorSelection,
L10nKey::AppAgentHooksThisComputer,
L10nKey::AppAgentHooksRemoteMachine,
L10nKey::AppAgentHooksNoHomeDir,
L10nKey::AppAgentHooksOffline,
L10nKey::AppAgentHooksHomeDirUnresolved,
L10nKey::AppAgentHooksOpFailed,
L10nKey::AppKeybindingDisplacedNote,
L10nKey::AppLocalServerName,
L10nKey::AppSshParseUnbalancedQuotes,
L10nKey::AppSshParseNoRemoteCommands,
L10nKey::AppSshParseFlagNeedsValue,
L10nKey::AppSshParseInvalidPort,
L10nKey::AppSshParseUnsupportedOption,
L10nKey::AppSshParseEnterHost,
L10nKey::AppSshParseBadHost,
L10nKey::AppMenuMinimize,
L10nKey::AppMenuZoom,
L10nKey::SwitcherStatusRestarting,
L10nKey::SwitcherStatusInstalling,
L10nKey::SwitcherStatusConnecting,
L10nKey::SwitcherStatusConnectFailed,
L10nKey::SwitcherStatusNotConnected,
L10nKey::SettingsLanguage,
L10nKey::SettingsLanguageDesc,
L10nKey::SettingsLanguageEnglish,
L10nKey::SettingsLanguageChinese,
L10nKey::SettingsLanguageJapanese,
@@ -1341,8 +1996,9 @@ mod tests {
L10nKey::SettingsAliasesLinked,
L10nKey::SettingsRulesOpenedWithConnection,
L10nKey::SettingsOfflineMachines,
L10nKey::PanelUntracked,
L10nKey::PanelMoreChangedFiles,
L10nKey::ScmFilesChanged,
L10nKey::ScmStagedFileCount,
L10nKey::WindowStopShells,
L10nKey::WindowDeleteShells,
L10nKey::DiffChangedFiles,
+123 -5
View File
@@ -848,7 +848,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::EditorFileTooLarge => "“{path}”太大,无法在编辑器中打开({size} MB)",
L10nKey::EditorBinaryFile => "“{path}”看起来是二进制文件",
L10nKey::PanelInfoTitle => "信息",
L10nKey::PanelChangesTitle => "变更",
L10nKey::PanelChangesTitle => "源代码管理",
L10nKey::PanelScmTitle => "源代码管理",
L10nKey::PanelFilesTitle => "文件",
L10nKey::PanelNoSession => "没有活动会话。",
L10nKey::PanelNoSessionHint => "打开一个标签页以在此处查看其 shell、目录和进程。",
@@ -874,6 +875,91 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::PanelAgentDone => "已完成",
L10nKey::PanelRevealInFinder => "在 Finder 中显示",
L10nKey::PanelOpenFolder => "打开文件夹",
L10nKey::ScmGroupMerge => "合并冲突",
L10nKey::ScmGroupStaged => "暂存的更改",
L10nKey::ScmGroupChanges => "更改",
L10nKey::ScmGroupUntracked => "未跟踪",
L10nKey::ScmCommitPlaceholder => "说说改了什么…",
L10nKey::ScmCommitButton => "提交",
L10nKey::ScmCommitAllButton => "提交全部",
L10nKey::ScmCommitAmendButton => "提交(修订)",
L10nKey::ScmCommitAndPush => "提交并推送",
L10nKey::ScmCommitAndSync => "提交并同步",
L10nKey::ScmAmendLastCommit => "修订上一次提交",
L10nKey::ScmCommitStaged => "提交已暂存的更改",
L10nKey::ScmStashAll => "全部贮藏",
L10nKey::ScmNothingToCommit => "没有可提交的内容",
L10nKey::ScmNetworkBusy => "这个仓库还有一个网络操作在进行中",
L10nKey::ScmCommitNeedsMessage => "先写一条提交信息",
L10nKey::ScmDetailFilesFailed => "无法读取文件列表",
L10nKey::ScmTimeNow => "刚刚",
L10nKey::ScmTimeMinutes => "{n}分",
L10nKey::ScmTimeHours => "{n}时",
L10nKey::ScmTimeDays => "{n}天",
// "个月" 而不是 "月":"3月" 会被读成月份名。
L10nKey::ScmTimeMonths => "{n}个月",
L10nKey::ScmTimeYears => "{n}年",
L10nKey::ScmResetHardConfirm => {
"把分支重置到这个提交?之后的提交会从分支上消失,未提交的更改会被丢弃。"
}
L10nKey::ScmReset => "重置",
L10nKey::ScmChipStaged => "已暂存",
L10nKey::ScmStage => "暂存更改",
L10nKey::ScmStageAll => "暂存全部更改",
L10nKey::ScmUnstage => "取消暂存",
L10nKey::ScmUnstageAll => "取消暂存全部更改",
L10nKey::ScmDiscard => "放弃更改",
L10nKey::ScmDiscardAll => "放弃全部更改",
L10nKey::ScmDiscardConfirm => "放弃对 {path} 的更改?此操作无法撤销。",
L10nKey::ScmOpenConflict => "解决冲突",
L10nKey::ScmMarkResolved => "标记为已解决",
L10nKey::ScmUnrepresentablePath => "该路径不是合法的 UTF-8,无法传给 git —— 仅可查看。",
L10nKey::ScmPublishBranch => "发布分支",
L10nKey::ScmDetached => "游离头指针",
L10nKey::ScmAmendBadge => "修订",
L10nKey::ScmSync => "同步更改",
L10nKey::ScmPush => "推送",
L10nKey::ScmPull => "拉取",
L10nKey::ScmFetch => "获取",
L10nKey::ScmCheckoutBranch => "切换到…",
L10nKey::ScmCreateBranch => "新建分支…",
L10nKey::ScmSearchBranches => "搜索分支…",
L10nKey::ScmStashAndSwitch => "贮藏并切换",
L10nKey::ScmGraphTitle => "提交历史",
L10nKey::ScmGraphLoadMore => "加载更多",
L10nKey::ScmGraphFilterPlaceholder => "筛选提交…",
L10nKey::ScmGraphAllBranches => "全部分支",
L10nKey::ScmGraphEmpty => "还没有提交",
L10nKey::ScmGraphCurrentBranch => "当前分支",
L10nKey::ScmCheckoutCommit => "检出此提交",
L10nKey::ScmCreateBranchHere => "在此创建分支…",
L10nKey::ScmResetSoft => "重置(保留暂存)",
L10nKey::ScmResetMixed => "重置(保留工作区)",
L10nKey::ScmResetHard => "重置(丢弃更改)",
L10nKey::ScmCommitDetailTitle => "提交",
L10nKey::ScmCopyCommitSha => "复制提交 SHA",
L10nKey::ScmCherryPick => "拣选提交",
L10nKey::ScmRevertCommit => "还原提交",
L10nKey::ScmResetToCommit => "重置到该提交",
L10nKey::ScmRefresh => "刷新",
L10nKey::ScmBackToChanges => "返回",
L10nKey::ScmCommitParents => "父提交",
L10nKey::ScmShowMore => "展开",
L10nKey::ScmShowLess => "收起",
L10nKey::ScmCommitNotFound => "本仓库中没有这个提交。",
L10nKey::ScmTooManyChanges => "改动过多,仅显示前 {shown} 项(共 {total} 项)。",
L10nKey::ScmOpenChanges => "查看改动",
L10nKey::ScmDiscardAllConfirm => "放弃本仓库的全部改动?此操作无法撤销。",
L10nKey::ScmAmendConfirm => {
"修补上一次提交?它会被一个新提交取代,已经拿到旧提交的人需要自行处理。"
}
L10nKey::ScmOpMerge => "合并中",
L10nKey::ScmOpRebase => "变基中",
L10nKey::ScmOpCherryPick => "拣选中",
L10nKey::ScmOpRevert => "还原中",
L10nKey::ScmOpBisect => "二分查找中",
L10nKey::ScmOpAm => "应用补丁中",
L10nKey::ScmSwitchRepository => "切换仓库",
L10nKey::WindowStop => "停止",
L10nKey::WindowDelete => "删除",
L10nKey::WindowThisWorkspace => "此工作区",
@@ -909,6 +995,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::DiffBudget => "tty7 的预算",
L10nKey::DiffPerFileCap => "单文件上限",
L10nKey::DiffUntrackedSummary => "{count} 个未跟踪",
L10nKey::DiffViewSplit => "并排",
L10nKey::DiffViewUnified => "统一",
L10nKey::PendingConnecting => "正在连接 {machine}…",
L10nKey::PendingUnreachable => "无法连接到 {machine}",
L10nKey::WorktreePromptNeedsName => "worktree 需要一个名称",
@@ -1065,6 +1153,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdGroupTabsPanes => "标签页与窗格",
L10nKey::CmdGroupWorkspaces => "工作区",
L10nKey::CmdGroupView => "视图",
L10nKey::CmdGroupGit => "Git",
L10nKey::CmdGroupTerminal => "终端",
L10nKey::CmdGroupSsh => "SSH",
L10nKey::CmdGroupAgents => "Agents",
@@ -1121,6 +1210,21 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdResetFontSize => "重置字号",
L10nKey::CmdEnterFullScreen => "进入全屏",
L10nKey::CmdClearScrollback => "清除回滚内容",
L10nKey::CmdToggleDiffViewMode => "切换统一 / 并排差异视图",
L10nKey::CmdGitCommit => "Git:提交",
L10nKey::CmdGitStageAll => "Git:暂存全部更改",
L10nKey::CmdGitUnstageAll => "Git:取消暂存全部更改",
L10nKey::CmdGitDiscardAll => "Git:放弃全部更改",
L10nKey::CmdGitDiscardAllSubtitle => "丢弃工作区里所有未提交的更改。",
L10nKey::CmdGitCheckoutTo => "Git:切换到…",
L10nKey::CmdGitCreateBranch => "Git:新建分支…",
L10nKey::CmdGitSync => "Git:同步",
L10nKey::CmdGitSyncSubtitle => "先拉取,再推送。",
L10nKey::CmdGitPush => "Git:推送",
L10nKey::CmdGitPull => "Git:拉取",
L10nKey::CmdGitFetch => "Git:获取",
L10nKey::CmdGitToggleGraph => "Git:显示 / 隐藏提交历史",
L10nKey::CmdClearScrollback => "清除 scrollback",
L10nKey::CmdFindInTerminal => "在终端中查找…",
L10nKey::CmdFindNext => "查找下一个",
L10nKey::CmdFindPrevious => "查找上一个",
@@ -1309,7 +1413,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
token`env` agent "
}
L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 git diff 查看。",
L10nKey::PanelUntracked => "{count} 个未跟踪文件",
L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。",
L10nKey::ScmFilesChanged => "{count} 个文件改动",
L10nKey::ScmStagedFileCount => "已暂存 {count} 个文件",
L10nKey::AppMenuAbout => "关于 tty7",
L10nKey::AppMenuCheckForUpdates => "检查更新…",
L10nKey::AppMenuSettings => "设置…",
@@ -1389,6 +1495,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SidebarScratchGroup => "草稿",
L10nKey::TabContextCloseTab => "关闭标签页",
L10nKey::TabContextCloseTabsBelow => "关闭下方标签页",
L10nKey::AppAgentHooksOpFailed => "失败:{error}",
L10nKey::AppMenuEnterFullscreen => "进入全屏",
L10nKey::HomeTimeOverWeekAgo => "一周多前",
L10nKey::Search => "搜索",
L10nKey::SettingsDaemonStaleRestart => "重启 server",
L10nKey::SettingsNoneLower => "",
L10nKey::SettingsSearchCommandLineToolTitle => "命令行工具",
L10nKey::TabContextMarkUnread => "标记为未读",
})
}
@@ -1414,11 +1527,16 @@ pub fn translate_variant_zh(key: L10nKey, branch: &'static str) -> Option<&'stat
(L10nKey::SftpReplaceBody, "other") => "{names} 在这个文件夹里已经存在,上传会覆盖它们。",
(L10nKey::AppTabsNotRestored, "one") => "上次的 1 个标签页没能重新打开",
(L10nKey::AppTabsNotRestored, "other") => "上次的 {count} 个标签页没能重新打开",
(L10nKey::PanelUntracked, "zero") => "0 个未跟踪文件",
(L10nKey::PanelUntracked, "one") => "1 个未跟踪文件",
(L10nKey::PanelUntracked, "other") => "{count} 个未跟踪文件",
(L10nKey::PanelMoreChangedFiles, "zero") => "…还有 0 个变更文件——运行 git diff 查看。",
(L10nKey::PanelMoreChangedFiles, "one") => "…还有 1 个变更文件——运行 git diff 查看。",
(L10nKey::ScmFilesChanged, "zero") => "没有文件改动",
(L10nKey::ScmFilesChanged, "one") => "1 个文件改动",
(L10nKey::ScmFilesChanged, "other") => "{count} 个文件改动",
(L10nKey::ScmStagedFileCount, "zero") => "没有暂存的更改",
(L10nKey::ScmStagedFileCount, "one") => "已暂存 1 个文件",
(L10nKey::ScmStagedFileCount, "other") => "已暂存 {count} 个文件",
(L10nKey::PanelMoreChangedFiles, "zero") => "…还有 0 个变更文件——运行 `git diff` 查看。",
(L10nKey::PanelMoreChangedFiles, "one") => "…还有 1 个变更文件——运行 `git diff` 查看。",
(L10nKey::PanelMoreChangedFiles, "other") => {
"…还有 {count} 个变更文件——运行 git diff 查看。"
}
+95 -4
View File
@@ -329,6 +329,23 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
},
),
("ZoomWindow", ""),
// `secondary-enter` is `ToggleFullscreen` on macOS. That is not a
// clash: `ScmCommit` binds inside the `ScmCommit` key context, so it
// only wins while the caret sits in the commit box.
("ScmCommit", "secondary-enter"),
("ScmCommitAmend", ""),
("ScmStageAll", ""),
("ScmUnstageAll", ""),
("ScmDiscardAll", ""),
("ScmRefresh", ""),
("ScmSync", ""),
("ScmPush", ""),
("ScmPull", ""),
("ScmFetch", ""),
("ScmCheckoutBranch", ""),
("ScmCreateBranch", ""),
("ScmToggleGraph", ""),
("ToggleDiffViewMode", ""),
("ToggleSftp", ""),
("ShowSshForwards", ""),
("ToggleCodePanel", "secondary-shift-e"),
@@ -636,6 +653,31 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
CommandGroup::Application,
t(L10nKey::CmdQuitTty7).to_string(),
),
// The source control verbs wear their palette names, so the
// Keybindings page and the palette agree on what a chord does.
"ScmCommit" => (CommandGroup::Git, t(L10nKey::CmdGitCommit).to_string()),
"ScmCommitAmend" => (
CommandGroup::Git,
t(L10nKey::ScmAmendLastCommit).to_string(),
),
"ScmStageAll" => (CommandGroup::Git, t(L10nKey::CmdGitStageAll).to_string()),
"ScmUnstageAll" => (CommandGroup::Git, t(L10nKey::CmdGitUnstageAll).to_string()),
"ScmDiscardAll" => (CommandGroup::Git, t(L10nKey::CmdGitDiscardAll).to_string()),
"ScmRefresh" => (CommandGroup::Git, t(L10nKey::ScmRefresh).to_string()),
"ScmSync" => (CommandGroup::Git, t(L10nKey::CmdGitSync).to_string()),
"ScmPush" => (CommandGroup::Git, t(L10nKey::CmdGitPush).to_string()),
"ScmPull" => (CommandGroup::Git, t(L10nKey::CmdGitPull).to_string()),
"ScmFetch" => (CommandGroup::Git, t(L10nKey::CmdGitFetch).to_string()),
"ScmCheckoutBranch" => (CommandGroup::Git, t(L10nKey::CmdGitCheckoutTo).to_string()),
"ScmCreateBranch" => (
CommandGroup::Git,
t(L10nKey::CmdGitCreateBranch).to_string(),
),
"ScmToggleGraph" => (CommandGroup::Git, t(L10nKey::CmdGitToggleGraph).to_string()),
"ToggleDiffViewMode" => (
CommandGroup::View,
t(L10nKey::CmdToggleDiffViewMode).to_string(),
),
_ => return None,
})
}
@@ -845,6 +887,7 @@ fn action_context(action: &str) -> Option<&'static str> {
match action {
"FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline"
| "CopyText" | "PasteText" => Some("Terminal"),
"ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"),
_ => None,
}
}
@@ -917,6 +960,20 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> {
"ShowRightPanelInfo" => KeyBinding::new(keystroke, ShowRightPanelInfo, None),
"ShowRightPanelChanges" => KeyBinding::new(keystroke, ShowRightPanelChanges, None),
"ShowRightPanelFiles" => KeyBinding::new(keystroke, ShowRightPanelFiles, None),
"ScmCommit" => KeyBinding::new(keystroke, ScmCommit, action_context(action)),
"ScmCommitAmend" => KeyBinding::new(keystroke, ScmCommitAmend, action_context(action)),
"ScmStageAll" => KeyBinding::new(keystroke, ScmStageAll, None),
"ScmUnstageAll" => KeyBinding::new(keystroke, ScmUnstageAll, None),
"ScmDiscardAll" => KeyBinding::new(keystroke, ScmDiscardAll, None),
"ScmRefresh" => KeyBinding::new(keystroke, ScmRefresh, None),
"ScmSync" => KeyBinding::new(keystroke, ScmSync, None),
"ScmPush" => KeyBinding::new(keystroke, ScmPush, None),
"ScmPull" => KeyBinding::new(keystroke, ScmPull, None),
"ScmFetch" => KeyBinding::new(keystroke, ScmFetch, None),
"ScmCheckoutBranch" => KeyBinding::new(keystroke, ScmCheckoutBranch, None),
"ScmCreateBranch" => KeyBinding::new(keystroke, ScmCreateBranch, None),
"ScmToggleGraph" => KeyBinding::new(keystroke, ScmToggleGraph, None),
"ToggleDiffViewMode" => KeyBinding::new(keystroke, ToggleDiffViewMode, None),
"FindInTerminal" => KeyBinding::new(keystroke, FindInTerminal, action_context(action)),
"FindNext" => KeyBinding::new(keystroke, FindNext, action_context(action)),
"FindPrevious" => KeyBinding::new(keystroke, FindPrevious, action_context(action)),
@@ -1063,6 +1120,32 @@ mod tests {
}
}
#[test]
fn every_action_has_a_binding_arm() {
// The sibling test above only reaches actions that ship with a default
// keystroke, which leaves the unbound ones — the majority — free to be
// listed in `default_bindings` with no `make_binding` arm behind them.
// Nothing surfaces that: the action shows up in Settings, the user
// assigns a key, and the key silently does nothing.
for (action, _) in default_bindings() {
assert!(
make_binding(action, "ctrl-f12").is_some(),
"no make_binding arm for action {action}; \
anyone who binds a key to it in Settings gets nothing"
);
}
}
#[test]
fn the_commit_key_only_fires_inside_the_commit_box() {
// `secondary-enter` is `ToggleFullscreen` on macOS. The two coexist
// only because the commit binding is scoped; drop the context and
// committing steals full screen everywhere.
assert_eq!(action_context("ScmCommit"), Some("ScmCommit"));
assert_eq!(action_context("ScmCommitAmend"), Some("ScmCommit"));
assert_eq!(action_context("ToggleFullscreen"), None);
}
#[test]
fn tmux_preset_keystrokes_all_parse_and_map_to_actions() {
for (action, key) in tmux_preset("ctrl-b") {
@@ -1310,15 +1393,23 @@ mod tests {
#[test]
fn every_default_chord_is_claimed_by_exactly_one_action() {
let mut seen: Vec<(&str, &str)> = Vec::new();
// Per context, not globally: gpui resolves a keystroke by walking the
// focus chain outwards, so a chord bound inside a narrow context and
// again with no context is not a clash — the narrow one wins while
// that element has focus and the global one applies everywhere else.
// `ScmCommit` and `ToggleFullscreen` both take secondary-enter on that
// basis. Two bindings sharing a chord *and* a context is still a bug,
// because then which one fires is arbitrary.
let mut seen: Vec<(&str, &str, Option<&'static str>)> = Vec::new();
for (action, spec) in default_bindings() {
if spec.is_empty() {
continue;
}
if let Some((other, _)) = seen.iter().find(|(_, s)| *s == spec) {
panic!("{action} and {other} both claim {spec}");
let context = action_context(action);
if let Some((other, _, _)) = seen.iter().find(|(_, s, c)| *s == spec && *c == context) {
panic!("{action} and {other} both claim {spec} in context {context:?}");
}
seen.push((action, spec));
seen.push((action, spec, context));
}
}
+2
View File
@@ -2,6 +2,7 @@ pub mod app;
pub mod assets;
pub mod code_editor;
pub mod diff_overlay;
pub mod diff_rows;
pub mod file_copy;
pub mod file_tree;
pub mod forwards;
@@ -27,6 +28,7 @@ pub mod remote_workspace;
pub mod reorder;
pub mod right_panel;
pub mod rounding;
pub mod scm;
pub mod scrollbar;
pub mod settings;
pub mod sftp;
+119 -4
View File
@@ -76,6 +76,22 @@ pub enum CommandKind {
ShowSshForwards,
ToggleCodePanel,
RestartSshSession,
ScmCommit,
ScmStageAll,
ScmUnstageAll,
ScmDiscardAll,
ScmPush,
ScmPull,
ScmFetch,
ScmSync,
ScmCreateBranch,
OpenBranchPicker,
/// One branch, filled in by the picker. Dynamic like `OpenSshConnect`, so
/// it gets no stable id and no key spec. Nothing emits it until the picker
/// can list refs.
#[allow(dead_code)]
CheckoutBranch(String),
ToggleDiffViewMode,
SendSelectionToAgent,
SendGitDiffToAgent,
OpenThemePicker,
@@ -140,7 +156,9 @@ impl CommandKind {
ToggleLeftPanel => "left-sidebar",
ToggleRightPanel => "right-panel",
ShowRightPanel(RightPanelTab::Info) => "right-panel-info",
ShowRightPanel(RightPanelTab::Changes) => "right-panel-changes",
// Frecency is keyed by this string, so it stays `right-panel-changes`
// even though the panel is now called Source Control.
ShowRightPanel(RightPanelTab::Scm) => "right-panel-changes",
ShowRightPanel(RightPanelTab::Files) => "right-panel-files",
ClearTerminal => "clear-scrollback",
FindInTerminal => "find",
@@ -164,12 +182,24 @@ impl CommandKind {
ShowSshForwards => "ssh-port-forwarding",
ToggleCodePanel => "code-panel",
RestartSshSession => "ssh-reconnect",
ScmCommit => "git-commit",
ScmStageAll => "git-stage-all",
ScmUnstageAll => "git-unstage-all",
ScmDiscardAll => "git-discard-all",
ScmPush => "git-push",
ScmPull => "git-pull",
ScmFetch => "git-fetch",
ScmSync => "git-sync",
ScmCreateBranch => "git-create-branch",
OpenBranchPicker => "git-checkout",
ToggleDiffViewMode => "diff-view-mode",
SendSelectionToAgent => "agent-send-selection",
SendGitDiffToAgent => "agent-send-diff",
OpenThemePicker => "change-theme",
OpenSshConnectInput => "ssh-add-connection",
OpenSshProfiles => "ssh-manage-profiles",
OpenSshConnect(_)
| CheckoutBranch(_)
| SetTheme(_)
| ActivateTab(_)
| ConnectSavedProfile(_)
@@ -229,7 +259,7 @@ impl CommandKind {
ToggleRightPanel => "ToggleRightPanel",
ShowRightPanel(tab) => match tab {
RightPanelTab::Info => "ShowRightPanelInfo",
RightPanelTab::Changes => "ShowRightPanelChanges",
RightPanelTab::Scm => "ShowRightPanelChanges",
RightPanelTab::Files => "ShowRightPanelFiles",
},
ClearTerminal => "ClearScrollback",
@@ -251,6 +281,17 @@ impl CommandKind {
ToggleCodePanel => "ToggleCodePanel",
RestartSshSession => "RestartSshSession",
OpenSshProfiles => "OpenSshProfiles",
ScmCommit => "ScmCommit",
ScmStageAll => "ScmStageAll",
ScmUnstageAll => "ScmUnstageAll",
ScmDiscardAll => "ScmDiscardAll",
ScmPush => "ScmPush",
ScmPull => "ScmPull",
ScmFetch => "ScmFetch",
ScmSync => "ScmSync",
ScmCreateBranch => "ScmCreateBranch",
OpenBranchPicker => "ScmCheckoutBranch",
ToggleDiffViewMode => "ToggleDiffViewMode",
CopyText
| CutText
| PasteText
@@ -261,6 +302,7 @@ impl CommandKind {
| OpenThemePicker
| OpenSshConnectInput
| OpenSshConnect(_)
| CheckoutBranch(_)
| SetTheme(_)
| ActivateTab(_)
| ConnectSavedProfile(_)
@@ -277,6 +319,7 @@ pub enum CommandGroup {
TabsPanes,
Workspaces,
View,
Git,
Terminal,
Ssh,
Agents,
@@ -284,10 +327,11 @@ pub enum CommandGroup {
}
impl CommandGroup {
pub(crate) const ORDER: [CommandGroup; 7] = [
pub(crate) const ORDER: [CommandGroup; 8] = [
CommandGroup::TabsPanes,
CommandGroup::Workspaces,
CommandGroup::View,
CommandGroup::Git,
CommandGroup::Terminal,
CommandGroup::Ssh,
CommandGroup::Agents,
@@ -299,6 +343,7 @@ impl CommandGroup {
CommandGroup::TabsPanes => t(L10nKey::CmdGroupTabsPanes),
CommandGroup::Workspaces => t(L10nKey::CmdGroupWorkspaces),
CommandGroup::View => t(L10nKey::CmdGroupView),
CommandGroup::Git => t(L10nKey::CmdGroupGit),
CommandGroup::Terminal => t(L10nKey::CmdGroupTerminal),
CommandGroup::Ssh => t(L10nKey::CmdGroupSsh),
CommandGroup::Agents => t(L10nKey::CmdGroupAgents),
@@ -439,7 +484,7 @@ impl Command {
),
Command::localized(
L10nKey::CmdRightPanelChanges,
ShowRightPanel(RightPanelTab::Changes),
ShowRightPanel(RightPanelTab::Scm),
),
Command::localized(
L10nKey::CmdRightPanelFiles,
@@ -448,6 +493,24 @@ impl Command {
Command::localized(L10nKey::CmdChangeTheme, OpenThemePicker),
Command::localized(L10nKey::CmdResetFontSize, ResetFontSize),
Command::localized(L10nKey::CmdEnterFullScreen, ToggleFullscreen),
Command::localized(L10nKey::CmdToggleDiffViewMode, ToggleDiffViewMode),
];
// Their own group rather than more entries under View: View is a list
// of things to show and hide, and ten git verbs in it would drown that.
let git = [
Command::localized(L10nKey::CmdGitCommit, ScmCommit),
Command::localized(L10nKey::CmdGitStageAll, ScmStageAll),
Command::localized(L10nKey::CmdGitUnstageAll, ScmUnstageAll),
Command::localized(L10nKey::CmdGitDiscardAll, ScmDiscardAll)
.with_subtitle(t(L10nKey::CmdGitDiscardAllSubtitle)),
Command::localized(L10nKey::CmdGitCheckoutTo, OpenBranchPicker),
Command::localized(L10nKey::CmdGitCreateBranch, ScmCreateBranch),
Command::localized(L10nKey::CmdGitSync, ScmSync)
.with_subtitle(t(L10nKey::CmdGitSyncSubtitle)),
Command::localized(L10nKey::CmdGitPush, ScmPush),
Command::localized(L10nKey::CmdGitPull, ScmPull),
Command::localized(L10nKey::CmdGitFetch, ScmFetch),
];
let terminal = [
@@ -497,6 +560,7 @@ impl Command {
push(tabs.into(), CommandGroup::TabsPanes);
push(workspaces.into(), CommandGroup::Workspaces);
push(view.into(), CommandGroup::View);
push(git.into(), CommandGroup::Git);
push(terminal.into(), CommandGroup::Terminal);
push(ssh.into(), CommandGroup::Ssh);
push(agents.into(), CommandGroup::Agents);
@@ -1431,3 +1495,54 @@ mod tests {
assert!(CommandKind::QuickConnect("a@b".into()).id().is_none());
}
}
#[cfg(test)]
mod gpui_tests {
use super::*;
use gpui::TestAppContext;
#[gpui::test]
fn every_palette_command_has_a_stable_id(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
cx.update(|cx| {
cx.set_global(Config::default());
crate::ui::i18n::set_locale("en");
let chrome = ChromeState {
rail_collapsed: false,
right_panel_visible: false,
};
let mut seen = std::collections::HashSet::new();
for cmd in Command::base_commands(cx, chrome) {
// Frecency is keyed by this string. A command without one is
// never learned, so it never rises in the list no matter how
// often it is run.
let id = cmd
.kind
.id()
.unwrap_or_else(|| panic!("`{}` has no stable id", cmd.title));
assert!(seen.insert(id), "two commands claim the id {id:?}");
}
});
}
#[gpui::test]
fn the_git_group_is_its_own_section(cx: &mut TestAppContext) {
crate::core::config::pin_test_config_dir();
cx.update(|cx| {
cx.set_global(Config::default());
crate::ui::i18n::set_locale("en");
let chrome = ChromeState {
rail_collapsed: false,
right_panel_visible: false,
};
let cmds = Command::base_commands(cx, chrome);
let git = cmds.iter().filter(|c| c.group == CommandGroup::Git).count();
assert_eq!(git, 10, "the git section should hold ten verbs");
// View stays a list of things to show and hide.
assert!(
!cmds.iter().any(|c| c.group == CommandGroup::View
&& c.kind.id().unwrap_or("").starts_with("git-")),
);
});
}
}
+168
View File
@@ -123,6 +123,26 @@ pub struct ActiveAccent(pub u32);
impl Global for ActiveAccent {}
/// How many lanes of the commit graph get a colour of their own.
///
/// Six because that is how many hues of the ANSI set survive being pulled to a
/// contrast floor while staying apart from each other — and because the graph
/// caps its visible lanes at the same number, which is what guarantees no two
/// columns on screen are ever the same colour.
pub const LANE_SLOTS: usize = 6;
#[derive(Debug, Clone, Copy)]
pub struct Lanes {
pub ink: [u32; LANE_SLOTS],
/// Everything past the last slot shares one column, so it gets a neutral:
/// a hue there would claim a branch identity the column does not have.
pub overflow: u32,
}
pub struct ActiveLanes(pub Lanes);
impl Global for ActiveLanes {}
impl Theme {
pub fn background_color(&self) -> u32 {
self.background.color()
@@ -226,6 +246,38 @@ impl Theme {
}
}
/// Lane colours for the commit graph, derived the same way every other
/// colour in this file is: seeded from the theme's own palette, then walked
/// to a contrast floor on each surface it can be painted on.
///
/// Not a fixed table of hexes. A hard-coded palette would be the one thing
/// here that does not follow the theme, and — worse — the contrast tests
/// below cannot see it, so the four light builtins would ship a graph whose
/// lanes sit at 2:1 against their own background.
///
/// The seed order is blue, yellow, magenta, green, cyan, red. Three
/// constraints picked it: no two adjacent slots share a hue family; red and
/// green are never neighbours, for the readers who cannot tell them apart;
/// and red is last because a panel three or four lanes wide never reaches
/// it, so the one colour that also means "danger" everywhere else in the UI
/// stays out of the common case.
pub fn lanes(&self) -> Lanes {
const SEEDS: [usize; LANE_SLOTS] = [4, 3, 5, 2, 6, 1];
let bg = self.background_color();
let fg = legible_foreground(bg, self.foreground);
// Through `clear_ink`, the same three surfaces `semantics` clears on:
// the graph draws on the window in a floating panel, on the sidebar
// when the panel is docked, and on a popover in the detail view.
let mut ink = [0u32; LANE_SLOTS];
for (slot, seed) in SEEDS.iter().enumerate() {
ink[slot] = self.clear_ink(self.ansi_seed(*seed), ACCENT_FLOOR);
}
Lanes {
ink,
overflow: dim(fg, bg, state::TEXT_RESTING),
}
}
pub fn surfaces(&self) -> Surfaces {
let m = self.neutrals();
let mut sidebar = self.surface(m.sidebar);
@@ -1421,6 +1473,122 @@ mod tests {
}
}
/// CIE L*a*b* for a packed sRGB colour, D65.
///
/// Contrast is a luminance ratio and says nothing about hue: two lanes can
/// both clear 3:1 against the background and still be the same colour to
/// look at. ΔE is the measure that catches that, and it needs Lab.
fn lab(c: u32) -> (f32, f32, f32) {
fn linear(v: u32) -> f32 {
let s = v as f32 / 255.0;
if s <= 0.04045 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
}
let (r, g, b) = (
linear(c >> 16 & 0xff),
linear(c >> 8 & 0xff),
linear(c & 0xff),
);
// sRGB → XYZ, then normalised by the D65 white point.
let x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047;
let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
let z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883;
let f = |t: f32| {
if t > 0.008856 {
t.cbrt()
} else {
7.787 * t + 16.0 / 116.0
}
};
let (fx, fy, fz) = (f(x), f(y), f(z));
(116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz))
}
fn delta_e76(a: u32, b: u32) -> f32 {
let (l1, a1, b1) = lab(a);
let (l2, a2, b2) = lab(b);
((l1 - l2).powi(2) + (a1 - a2).powi(2) + (b1 - b2).powi(2)).sqrt()
}
#[test]
fn lane_colours_clear_the_floor_on_every_surface() {
for t in builtins() {
let bg = t.background_color();
let fg = legible_foreground(bg, t.foreground);
let lanes = t.lanes();
for (name, surface) in [
("background", bg),
("sidebar", mix(bg, fg, 0.03)),
("popover", mix(bg, fg, 0.05)),
] {
for (slot, ink) in lanes.ink.iter().enumerate() {
let ratio = contrast(*ink, surface);
assert!(
ratio >= ACCENT_FLOOR,
"{}/{name}: lane {slot} is only {ratio:.2}:1",
t.id
);
}
let ratio = contrast(lanes.overflow, surface);
assert!(
ratio >= ACCENT_FLOOR,
"{}/{name}: the overflow lane is only {ratio:.2}:1",
t.id
);
}
}
}
#[test]
fn adjacent_lanes_are_never_the_same_colour() {
// A just-noticeable difference is around 2.3. The floor is set far
// above it because these are 1.5px lines a few pixels apart, not
// patches side by side, and the eye is much worse at hairlines.
const FLOOR: f32 = 12.0;
for t in builtins() {
let lanes = t.lanes();
for slot in 0..LANE_SLOTS - 1 {
let d = delta_e76(lanes.ink[slot], lanes.ink[slot + 1]);
assert!(
d >= FLOOR,
"{}: lanes {slot} and {} are ΔE {d:.1} apart",
t.id,
slot + 1
);
}
}
}
#[test]
fn lane_colours_are_deterministic() {
for t in builtins() {
assert_eq!(
t.lanes().ink,
t.lanes().ink,
"{}: lane derivation is not a pure function",
t.id
);
}
}
/// The seeds were chosen so that no two neighbours share a hue family and
/// red never sits beside green. Both are properties of the *order*, so a
/// reshuffle has to fail here rather than only looking slightly worse.
#[test]
fn the_lane_seed_order_keeps_red_and_green_apart() {
let seeds = [4usize, 3, 5, 2, 6, 1];
let red = seeds.iter().position(|s| *s == 1).expect("red is a seed");
let green = seeds.iter().position(|s| *s == 2).expect("green is a seed");
assert!(
red.abs_diff(green) > 1,
"red and green ended up adjacent at slots {red} and {green}"
);
assert_eq!(red, LANE_SLOTS - 1, "red should be the last slot reached");
}
#[test]
fn resting_labels_stay_readable() {
for t in builtins() {
+69 -212
View File
@@ -5,16 +5,14 @@ use gpui_component::{
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex,
};
use std::path::PathBuf;
use std::sync::Arc;
use crate::core::config::{Config, RightPanelTab};
use crate::daemon::protocol::PaneProcs;
use crate::terminal::git_diff::{DiffSnapshot, MAX_RENDERED_FILES};
use crate::ui::app::{
CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset,
tile_trailing_inset_sm,
};
use crate::ui::i18n::{L10nKey, t, t_plural};
use crate::ui::i18n::{L10nKey, t};
use crate::ui::scrollbar::with_vertical_scrollbar;
pub(crate) const MIN_WIDTH: f32 = 216.;
@@ -48,11 +46,33 @@ pub(crate) const META_MONO: f32 = META - STEP;
/// sidebar's group headings, which are the same thing one panel over.
const HEADING: f32 = 11. * STEP;
// The right panel's type ramp: four steps, half a point apart, that the Info
// and Source Control tabs draw from so switching between them does not change
// the apparent size of the panel. (The Files tab, in `file_tree.rs`, is the
// one holdout — it renders its rows with `text_sm()` = 14px.) The steps are
// close together on purpose: the panel is a dense aside next to the terminal,
// and the differences between them are meant to be felt as hierarchy rather
// than seen as different type sizes.
//
// (The px constants that used to live here — PANEL_TEXT and its steps — were
// superseded by the interface font scale's rems tokens; the Source Control
// panel still names its own px steps locally until it moves onto that scale.)
/// Height of the search strip.
///
/// gpui-component sizes an `Input` border-box, and `.xsmall()` is
/// `input_h(Size::XSmall)` = `h_5()` = 20px: one `LINE_HEIGHT` of `Rems(1.25)`
/// = 20px with `input_py(Size::XSmall)` = 0 above and below. (`.appearance(false)`
/// only drops the background, border and radius; the padding and the height
/// stay.) Thirty leaves that field 5px of slack top and bottom.
///
/// Load-bearing beyond this file: `scm/panel.rs` pins its commit box to the
/// same height with a `const _: () = assert!(…)`, so the two tabs' top strips
/// line up.
pub(crate) const SEARCH_H: f32 = 30.;
#[derive(Default)]
pub(crate) struct RightPanelState {
pub(crate) diff_cwd: Option<(crate::ui::host_ops::HostId, PathBuf)>,
pub(crate) diff: Option<Option<Arc<DiffSnapshot>>>,
pub(crate) diff_pending: Option<(crate::ui::host_ops::HostId, PathBuf)>,
pub(crate) procs_pane: Option<u64>,
pub(crate) procs: Option<PaneProcs>,
pub(crate) procs_loading: bool,
@@ -164,7 +184,7 @@ impl Tty7App {
let body = match tab {
RightPanelTab::Info => self.render_panel_info(window, cx),
RightPanelTab::Changes => self.render_panel_changes(window, cx),
RightPanelTab::Scm => self.render_panel_scm(window, cx),
RightPanelTab::Files => self.render_panel_files(window, cx),
};
let (backing, handle) = self.right_panel_resize(cx);
@@ -340,6 +360,9 @@ impl Tty7App {
.items_baseline()
.gap(px(7.))
.child(
// The title step of the panel ramp, SEMIBOLD and
// uppercased. It reads as a label rather than as
// content because of the weight and the caps.
div()
.text_size(rems(META))
.font_weight(gpui::FontWeight::SEMIBOLD)
@@ -348,6 +371,8 @@ impl Tty7App {
)
.when_some(count, |this, c| {
this.child(
// A count is a token hanging off the heading, not
// part of it: one step down, mono, regular weight.
div()
.text_size(rems(META_MONO))
.font_family(cx.theme().mono_font_family.clone())
@@ -379,8 +404,10 @@ impl Tty7App {
h_flex()
.flex_none()
.items_center()
// 8 here plus the `.xsmall()` field's own 4px of leading padding
// is 12px of daylight between the glyph and the first character.
.gap(px(8.))
.h(px(30.))
.h(px(SEARCH_H))
.px(px(CONTENT_INSET))
.child(
Icon::new(IconName::Search)
@@ -396,7 +423,7 @@ impl Tty7App {
.into_any_element()
}
fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement {
pub(crate) fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement {
let body = div()
.id("right-panel-body")
.flex_1()
@@ -416,7 +443,12 @@ impl Tty7App {
.into_any_element()
}
fn panel_empty(&self, text: &str, hint: Option<&str>, cx: &mut Context<Self>) -> AnyElement {
pub(crate) fn panel_empty(
&self,
text: &str,
hint: Option<&str>,
cx: &mut Context<Self>,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
v_flex()
.px(px(CONTENT_INSET))
@@ -637,6 +669,9 @@ impl Tty7App {
}))
.pb(px(if trailing.is_some() { 0. } else { 4. }))
.child(
// A group header sits below the panel's own title in the
// hierarchy, so it sits below it in the ramp too: the smallest
// step, carried by weight and caps rather than by size.
div()
.text_size(rems(HEADING))
.font_weight(gpui::FontWeight::SEMIBOLD)
@@ -810,207 +845,6 @@ impl Tty7App {
.detach();
}
fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
let target = self
.tabs
.get(self.active)
.and_then(|t| t.detail_pane(window, cx))
.and_then(|leaf| {
let v = leaf.read(cx);
let cwd = v
.git_status_cwd()
.map(|p| p.to_path_buf())
.or_else(|| v.host_cwd())?;
Some((v.host(cx)?, cwd))
});
let Some((host, cwd)) = target else {
let title = self.panel_title(t(L10nKey::PanelChangesTitle), None, None, window, cx);
return self.panel_scroll(
self.panel_empty(
t(L10nKey::PanelNoWorkingDirectory),
Some(t(L10nKey::PanelNoWorkingDirectoryHint)),
cx,
),
title,
);
};
let key = (host.id(), cwd.clone());
if self.right_panel.diff_cwd.as_ref() != Some(&key) {
self.right_panel.diff_cwd = Some(key);
self.right_panel.diff = None;
self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx);
} else if self.right_panel.diff.is_none() && self.right_panel.diff_pending.is_none() {
self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx);
}
let count = match &self.right_panel.diff {
Some(Some(snap)) => {
let n = snap.files.len() + snap.untracked_count();
(n > 0).then(|| n.to_string())
}
_ => None,
};
let title = self.panel_title(t(L10nKey::PanelChangesTitle), count, None, window, cx);
let mono = cx.theme().mono_font_family.clone();
let inner = match &self.right_panel.diff {
None => self.panel_empty(t(L10nKey::PanelLoading), None, cx),
Some(None) => self.panel_empty(
t(L10nKey::PanelNotAGitRepo),
Some(t(L10nKey::PanelNotAGitRepoHint)),
cx,
),
Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => self
.panel_empty(
t(L10nKey::PanelNoChanges),
Some(t(L10nKey::PanelNoChangesHint)),
cx,
),
Some(Some(snap)) => {
let snap = Arc::clone(snap);
let untracked = snap.untracked_count();
let focused = self.diff_overlay_focus(host.id(), &cwd).map(str::to_string);
let shown = snap.files.len().min(MAX_RENDERED_FILES);
let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.));
for file in snap.files.iter().take(shown) {
let path = file.path.clone();
let (added, removed) = (file.added, file.removed);
let selected = focused.as_deref() == Some(path.as_str());
list = list.child(
h_flex()
.id(gpui::SharedString::from(format!("panel-change-{path}")))
.items_center()
.gap(px(8.))
.px(px(4.))
.py(px(3.))
.rounded(px(5.))
.cursor_pointer()
.hover(|s| s.bg(gpui::rgb(sf.hover)))
.when(selected, |s| s.bg(gpui::rgb(sf.selected)))
.on_click({
let host_id = host.id();
let cwd = cwd.clone();
let path = path.clone();
cx.listener(move |this, _, window, cx| {
this.toggle_diff_overlay_at(
host_id,
cwd.clone(),
Some(path.clone()),
window,
cx,
);
})
})
.child(git_badge("M", cx.theme().muted_foreground, &mono))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(rems(TEXT_MONO))
.font_family(mono.clone())
.text_color(cx.theme().foreground)
.child(path),
)
.when(added > 0, |this| {
this.child(
div()
.flex_none()
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(cx.theme().success)
.child(format!("+{added}")),
)
})
.when(removed > 0, |this| {
this.child(
div()
.flex_none()
.text_size(rems(META_MONO))
.font_family(mono.clone())
.text_color(cx.theme().danger)
.child(format!("{removed}")),
)
}),
);
}
if snap.files.len() > shown {
let rest = snap.files.len() - shown;
list = list.child(
div()
.px(px(4.))
.py(px(3.))
.text_size(rems(META))
.text_color(cx.theme().muted_foreground)
.child(t_plural(L10nKey::PanelMoreChangedFiles, rest, &[])),
);
}
if untracked > 0 {
list = list.child(
h_flex()
.items_center()
.gap(px(8.))
.px(px(4.))
.py(px(3.))
.child(git_badge(
"U",
cx.theme().muted_foreground.opacity(0.75),
&mono,
))
.child(
div()
.text_size(rems(META))
.text_color(cx.theme().muted_foreground)
.child(t_plural(L10nKey::PanelUntracked, untracked, &[])),
),
);
}
list.into_any_element()
}
};
self.panel_scroll(inner, title)
}
fn spawn_right_panel_diff(
&mut self,
host: crate::ui::host_ops::SharedHost,
cwd: PathBuf,
cx: &mut Context<Self>,
) {
if self.right_panel.diff_pending.is_some() {
return;
}
self.right_panel.diff_pending = Some((host.id(), cwd.clone()));
self.spawn_shared_diff_probe(host, cwd, cx);
}
pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context<Self>) {
if self.right_panel.diff_pending.is_some() {
return;
}
let Some((id, cwd)) = self.right_panel.diff_cwd.clone() else {
return;
};
let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, id) else {
return;
};
let Some(Some(snap)) = &self.right_panel.diff else {
return;
};
let Some(status) = cx
.try_global::<crate::terminal::git_status::GitStatusCache>()
.and_then(|cache| cache.status_for(id, &cwd))
else {
return;
};
let stale = status.branch != snap.branch || (status.added, status.removed) != snap.totals();
if stale {
self.spawn_right_panel_diff(host, cwd, cx);
}
}
fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let remote = self.remote_files_pane(window, cx);
let host = remote.as_ref().map(|(_, host)| host.clone());
@@ -1058,10 +892,25 @@ impl Tty7App {
}
}
/// Width of the fixed cell a git status letter is centred in.
///
/// Load-bearing beyond this function: `scm/panel.rs` gives its group-header
/// chevron box exactly this width so the group arrows and the status letters
/// stack into one vertical line down the right edge of the panel, and it keeps
/// its own `BADGE_W` in step. Changing it here without changing it there
/// breaks that column.
pub(crate) const BADGE_W: f32 = 14.;
/// A single-letter git status marker in a fixed-width cell.
///
/// Mono and SEMIBOLD so `M`, `A`, `D` and `U` all read as the same kind of
/// mark at a glance, and centred in a cell wide enough for the widest of them
/// at [`PANEL_TEXT_META`] — that is what makes a column of them line up
/// instead of drifting with the glyph widths.
pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement {
div()
.flex_none()
.w(px(14.))
.w(px(BADGE_W))
.text_center()
.text_size(rems(META_MONO))
.font_family(mono.clone())
@@ -1071,6 +920,14 @@ pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedStri
.into_any_element()
}
/// A small filled pill around a mono token — a pid, a port number.
///
/// The padding and the radius are derived from the text size: at
/// [`PANEL_TEXT_META`] the line box is `round(10.5 × 1.618) = 17px`, so 1.5px
/// of vertical padding makes the pill 20px tall — one pixel more than the 19px
/// line of [`PANEL_TEXT`] beside it, which is what sets the height of a ports
/// row. Horizontal padding of 5px is about half an em of breathing room on
/// each side, and radius 4 is a fifth of the pill's height.
pub(crate) fn info_chip(
text: &str,
bg: gpui::Hsla,
+609
View File
@@ -0,0 +1,609 @@
//! Where the source control actions and palette commands land.
//!
//! One `ScmIntent` match, so the four ways of asking for a verb — the action,
//! the key binding, the palette entry and the button on the row — cannot drift
//! into meaning different things.
use gpui::{Context, PromptLevel, Window};
use tty7_core::core::git::ops::{Destructive, GitOp, PullMode};
use tty7_core::core::git::status::HeadState;
use crate::core::config::DiffViewMode;
use crate::ui::app::Tty7App;
use crate::ui::host_registry::HostRegistry;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::scm::state::RepoKey;
/// One entry point for every source control verb.
///
/// A single enum rather than fourteen methods: the actions, the palette and
/// the row buttons all want the same behaviour, and routing them through one
/// match is what keeps the three from drifting apart.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ScmIntent {
Commit,
CommitAmend,
/// Commit, then send it on. Two operations rather than one, so the commit
/// still stands if the network half fails — and strictly in that order:
/// the send rides in the commit's [`ScmFollowUp`], because a push
/// dispatched alongside the commit resolves the branch tip whenever the
/// pool gets to it, and pushing the *old* tip reports success while
/// sending nothing.
CommitAndPush,
CommitAndSync,
StageAll,
UnstageAll,
DiscardAll,
Refresh,
Sync,
Push,
Pull,
Fetch,
CheckoutBranch,
CreateBranch,
}
/// What to run once an operation has landed *successfully*.
///
/// Compound verbs — commit-and-push, pull-then-push, discard-all's two halves
/// — are sequences, not bundles: the second operation only makes sense against
/// the repository the first one produced. Dispatching both into the worker
/// pool at once lets them race, so the second rides here and is started from
/// the first one's landing closure instead. A failed or cancelled first half
/// drops the follow-up.
#[derive(Clone, Debug)]
pub(crate) enum ScmFollowUp {
/// Push the current branch, re-reading the (by then updated) status.
Push,
/// Pull, then push — the whole sync sequence.
Sync,
/// One more operation, run without a second confirmation: the prompt that
/// approved the first half covered this one too.
Op(GitOp),
}
impl Tty7App {
/// Fold the history section open or shut and remember it.
pub(crate) fn scm_toggle_graph(&mut self, cx: &mut Context<Self>) {
let next = !self.scm.graph.expanded;
self.scm.graph.expanded = next;
self.update_config(cx, |cfg| cfg.scm_graph_expanded = next);
cx.notify();
}
/// Flip the diff overlay between side-by-side and unified.
///
/// Global rather than per-overlay, matching `diffEditor.renderSideBySide`:
/// someone who prefers unified prefers it for every file.
pub(crate) fn toggle_diff_view_mode(&mut self, cx: &mut Context<Self>) {
let next = match cx.global::<crate::core::config::Config>().diff_view {
DiffViewMode::Split => DiffViewMode::Unified,
DiffViewMode::Unified => DiffViewMode::Split,
};
self.update_config(cx, |cfg| cfg.diff_view = next);
cx.notify();
}
/// Run one operation against the panel's repository, asking first when it
/// can lose work.
///
/// The gate lives here rather than in `run_git_op` because
/// [`GitOp::destructive`] is advice about what the user stands to lose,
/// and only a window can ask them. `window.prompt` is the project's one
/// confirmation mechanism — deleting a file in the tree already uses it —
/// so no modal component is introduced for this.
pub(crate) fn scm_op(
&mut self,
repo: RepoKey,
op: GitOp,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.scm_op_then(repo, op, None, window, cx);
}
/// [`scm_op`], with something to run once this operation has succeeded.
/// Cancelling the confirmation drops the follow-up along with the op.
pub(crate) fn scm_op_then(
&mut self,
repo: RepoKey,
op: GitOp,
then: Option<ScmFollowUp>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(host) = HostRegistry::get(cx, repo.host) else {
return;
};
let Some(loss) = op.destructive() else {
self.run_git_op(host, repo.root, op, then, window, cx);
return;
};
let answer = window.prompt(
PromptLevel::Warning,
&confirm_question(&op, loss),
None,
&[t(L10nKey::Cancel), confirm_verb(&op, loss)],
cx,
);
cx.spawn_in(window, async move |app, cx| {
let Ok(1) = answer.await else { return };
let _ = app.update_in(cx, |app, window, cx| {
app.run_git_op(host, repo.root, op, then, window, cx)
});
})
.detach();
}
pub(crate) fn run_scm_action(
&mut self,
intent: ScmIntent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(repo) = self.scm.active_repo().cloned() else {
return;
};
match intent {
ScmIntent::Refresh => {
self.scm_invalidate(repo.host, &repo.root, cx);
cx.notify();
}
ScmIntent::StageAll => self.scm_op(repo, GitOp::StageAll, window, cx),
ScmIntent::UnstageAll => self.scm_op(repo, GitOp::UnstageAll, window, cx),
ScmIntent::DiscardAll => self.scm_discard_all(repo, window, cx),
ScmIntent::Commit => {
let amend = self.scm.amend;
self.scm_commit(repo, amend, None, window, cx);
}
ScmIntent::CommitAmend => self.scm_commit(repo, true, None, window, cx),
ScmIntent::CommitAndPush => {
let amend = self.scm.amend;
self.scm_commit(repo, amend, Some(ScmFollowUp::Push), window, cx);
}
ScmIntent::CommitAndSync => {
let amend = self.scm.amend;
self.scm_commit(repo, amend, Some(ScmFollowUp::Sync), window, cx);
}
ScmIntent::Sync => self.scm_sync(repo, window, cx),
ScmIntent::Push => self.scm_push(repo, false, window, cx),
ScmIntent::Pull => self.scm_op(
repo,
GitOp::Pull {
mode: PullMode::FfOnly,
},
window,
cx,
),
ScmIntent::Fetch => self.scm_op(
repo,
GitOp::Fetch {
remote: None,
prune: false,
},
window,
cx,
),
ScmIntent::CreateBranch => self.scm_begin_create_branch(window, cx),
// Checking out is a pick, not a verb: the switcher hangs off the
// branch name, which is where the list of branches already is.
ScmIntent::CheckoutBranch => {}
}
}
/// Commit whatever the message box holds.
///
/// The message comes from the box when it is the one on screen and from
/// the saved draft otherwise, so the key binding and the palette entry
/// commit the same text the user can see.
pub(crate) fn scm_commit(
&mut self,
repo: RepoKey,
amend: bool,
then: Option<ScmFollowUp>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(status) = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) else {
return;
};
let message = self.scm_message(&repo, cx);
let plan = crate::ui::scm::panel::commit_plan(&status, amend, &message);
if !plan.enabled {
gpui_component::WindowExt::push_notification(
window,
t(L10nKey::ScmNothingToCommit).to_string(),
cx,
);
// The follow-up dies with the commit: "commit and push" with
// nothing to commit must not push whatever the branch holds.
return;
}
let all = crate::ui::scm::panel::commit_stages_everything(&status, amend);
self.scm.amend = false;
// `run_git_op` arms `scm.committing` when the commit is actually
// dispatched — after the amend confirmation, not before it — so a
// cancelled prompt leaves nothing armed. See `scm_commit_landed`.
self.scm_op_then(
repo,
GitOp::Commit {
message,
amend,
signoff: false,
no_verify: false,
all,
},
then,
window,
cx,
);
}
/// What the commit box holds for a repository, whether or not it is the
/// one currently on screen.
fn scm_message(&self, repo: &RepoKey, cx: &gpui::App) -> String {
match (&self.scm.commit_input, &self.scm.commit_repo) {
(Some(input), Some(showing)) if showing == repo => input.read(cx).value().to_string(),
_ => self.scm.draft(repo).to_string(),
}
}
/// Park everything, including the files git does not track yet.
///
/// `-u`, because a stash that silently leaves new files behind is a stash
/// that did not do what "stash all" says.
pub(crate) fn scm_stash_all(
&mut self,
repo: RepoKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
let message = match self.scm_message(&repo, cx) {
m if m.trim().is_empty() => None,
m => Some(m),
};
self.scm_op(
repo,
GitOp::Stash {
message,
include_untracked: true,
},
window,
cx,
);
}
/// Throw away every change in the worktree: unstaged edits and untracked
/// files alike.
///
/// Two operations, because git has no single command for it —
/// `checkout --` cannot touch a file it has never heard of, and `clean`
/// cannot touch one it has. One confirmation and one sequence, though:
/// the second half rides in the first one's [`ScmFollowUp`], so the user
/// answers a single dialog and the two gits never race each other.
///
/// Only *unstaged* paths go to `checkout --`: it restores from the index,
/// so a staged edit would survive it anyway, and a staged deletion — a
/// path in neither index nor worktree — would make git reject the whole
/// batch as an unmatched pathspec. What is staged stays staged, which is
/// also what the button's own group implies.
fn scm_discard_all(&mut self, repo: RepoKey, window: &mut Window, cx: &mut Context<Self>) {
let Some(status) = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) else {
return;
};
let (first, second) = match &discard_all_ops(&status)[..] {
[] => return,
[one] => (one.clone(), None),
[a, b, ..] => (a.clone(), Some(b.clone())),
};
let Some(host) = HostRegistry::get(cx, repo.host) else {
return;
};
// Its own prompt rather than `scm_op_then`'s: that one names the file
// when an op carries a single path, and "Discard changes to a.rs?"
// would be the wrong question for a click that also sweeps the
// untracked files.
let answer = window.prompt(
PromptLevel::Warning,
&t(L10nKey::ScmDiscardAllConfirm).to_string(),
None,
&[t(L10nKey::Cancel), t(L10nKey::ScmDiscard)],
cx,
);
cx.spawn_in(window, async move |app, cx| {
let Ok(1) = answer.await else { return };
let _ = app.update_in(cx, |app, window, cx| {
app.run_git_op(
host,
repo.root,
first,
second.map(ScmFollowUp::Op),
window,
cx,
)
});
})
.detach();
}
/// Push the current branch to its upstream, or publish it if it has none.
pub(crate) fn scm_push(
&mut self,
repo: RepoKey,
force_with_lease: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(status) = crate::terminal::git_data::status_of(cx, repo.host, &repo.root) else {
return;
};
let HeadState::Branch { name, .. } = &status.head else {
// A detached HEAD has no branch to push, and pushing a bare sha
// needs a refspec the panel has no way to ask for.
return;
};
let (remote, branch) = match status.upstream.as_deref().and_then(split_upstream) {
Some((remote, branch)) => (remote.to_string(), branch.to_string()),
None => ("origin".to_string(), name.clone()),
};
let set_upstream = status.upstream.is_none();
self.scm_op(
repo,
GitOp::Push {
remote,
branch,
set_upstream,
force_with_lease,
},
window,
cx,
);
}
/// Pull then push, which is what "sync" means everywhere else — and
/// strictly in that order: the push rides in the pull's [`ScmFollowUp`],
/// because a push racing the pull it was waiting for reads the pre-pull
/// tip and earns a non-fast-forward rejection from the very sync that
/// was fixing it. A failed pull stops the sequence.
///
/// A branch with no upstream has nothing to pull, so sync is a publish.
pub(crate) fn scm_sync(&mut self, repo: RepoKey, window: &mut Window, cx: &mut Context<Self>) {
let has_upstream = crate::terminal::git_data::status_of(cx, repo.host, &repo.root)
.is_some_and(|s| s.upstream.is_some());
if has_upstream {
self.scm_op_then(
repo,
GitOp::Pull {
mode: PullMode::FfOnly,
},
Some(ScmFollowUp::Push),
window,
cx,
);
} else {
self.scm_push(repo, false, window, cx);
}
}
/// Run the second half of a compound verb, from the first half's landing.
pub(crate) fn scm_follow_up(
&mut self,
host: tty7_core::host::HostId,
root: std::path::PathBuf,
follow: ScmFollowUp,
window: &mut Window,
cx: &mut Context<Self>,
) {
let repo = RepoKey { host, root };
match follow {
ScmFollowUp::Push => self.scm_push(repo, false, window, cx),
ScmFollowUp::Sync => self.scm_sync(repo, window, cx),
ScmFollowUp::Op(op) => {
let Some(shared) = HostRegistry::get(cx, repo.host) else {
return;
};
self.run_git_op(shared, repo.root, op, None, window, cx);
}
}
}
/// A dispatched commit came back with an error: disarm the latch that
/// would otherwise clear the message box on the next unrelated HEAD move.
/// The message itself stays where the user can see it.
pub(crate) fn scm_commit_failed(
&mut self,
host: tty7_core::host::HostId,
root: &std::path::Path,
) {
if self
.scm
.committing
.as_ref()
.is_some_and(|(r, _, _)| r.host == host && r.root == root)
{
self.scm.committing = None;
}
}
}
/// What "discard all" actually runs, in order. Pure so a test can hold it up
/// against a status without a window.
fn discard_all_ops(status: &tty7_core::core::git::status::WorkingTreeStatus) -> Vec<GitOp> {
let unstaged: Vec<_> = status
.unstaged()
.filter(|e| e.path.pathspec().is_some())
.map(|e| e.path.clone())
.collect();
let untracked: Vec<_> = status
.untracked()
.filter(|e| e.path.pathspec().is_some())
.map(|e| e.path.clone())
.collect();
let mut ops = Vec::new();
if !unstaged.is_empty() {
ops.push(GitOp::DiscardWorktree { paths: unstaged });
}
if !untracked.is_empty() {
let directories = untracked.iter().any(|p| p.as_str().ends_with('/'));
ops.push(GitOp::DiscardUntracked {
paths: untracked,
directories,
});
}
ops
}
/// `origin/main` → `("origin", "main")`.
///
/// The first component is the remote: a branch name may contain slashes, a
/// remote name may not.
pub(crate) fn split_upstream(upstream: &str) -> Option<(&str, &str)> {
let (remote, branch) = upstream.split_once('/')?;
(!remote.is_empty() && !branch.is_empty()).then_some((remote, branch))
}
/// The question a destructive operation has to answer before it runs.
fn confirm_question(op: &GitOp, loss: Destructive) -> String {
// Its own question, not the discard one: a hard reset to an older commit
// drops commits off the branch, and a dialog that says "Discard every
// change in this repository?" never mentions the part that hurts.
if matches!(op, GitOp::Reset { .. }) {
return t(L10nKey::ScmResetHardConfirm).to_string();
}
match loss {
Destructive::RewritesHistory => t(L10nKey::ScmAmendConfirm).to_string(),
// One file gets named; a whole group does not, because a list of two
// hundred paths in a system dialog says less than the count does.
_ => match op.paths() {
[only] => t_fmt(L10nKey::ScmDiscardConfirm, &[("path", only.as_str())]),
_ => t(L10nKey::ScmDiscardAllConfirm).to_string(),
},
}
}
fn confirm_verb(op: &GitOp, loss: Destructive) -> &'static str {
if matches!(op, GitOp::Reset { .. }) {
return t(L10nKey::ScmReset);
}
match loss {
Destructive::RewritesHistory => t(L10nKey::ScmAmendLastCommit),
_ => t(L10nKey::ScmDiscard),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tty7_core::core::git::status::{
ChangeCode, EntryKind, HeadState, RepoPath, StatusEntry, WorkingTreeStatus,
};
fn entry(path: &str, index: ChangeCode, worktree: ChangeCode, kind: EntryKind) -> StatusEntry {
StatusEntry {
path: RepoPath::from_bytes(path.as_bytes()),
orig_path: None,
index,
worktree,
kind,
submodule: None,
rename_score: None,
conflict: None,
}
}
fn status_with(entries: Vec<StatusEntry>) -> WorkingTreeStatus {
WorkingTreeStatus {
root: std::path::PathBuf::from("/repo"),
home: std::path::PathBuf::from("/repo"),
head: HeadState::Branch {
name: "main".into(),
oid: "0".repeat(40),
},
upstream: None,
ahead_behind: None,
total_entries: entries.len(),
entries,
truncated: false,
stash_count: 0,
operation: None,
prefilled_message: None,
}
}
/// `checkout --` restores from the *index*: a staged-only path either
/// survives it (staged edit) or — a staged deletion, in neither index nor
/// worktree — makes git reject the whole batch as an unmatched pathspec,
/// taking every real discard down with it. Only unstaged paths go in.
#[test]
fn discard_all_sends_only_unstaged_paths_to_checkout() {
let status = status_with(vec![
// Staged edit, clean worktree: not `checkout --`'s business.
entry(
"staged.rs",
ChangeCode::Modified,
ChangeCode::None,
EntryKind::Tracked,
),
// Staged deletion: the pathspec that used to sink the batch.
entry(
"deleted.rs",
ChangeCode::Deleted,
ChangeCode::None,
EntryKind::Tracked,
),
// Staged and edited again: the worktree half is discardable.
entry(
"both.rs",
ChangeCode::Modified,
ChangeCode::Modified,
EntryKind::Tracked,
),
entry(
"edited.rs",
ChangeCode::None,
ChangeCode::Modified,
EntryKind::Tracked,
),
entry(
"new.rs",
ChangeCode::None,
ChangeCode::None,
EntryKind::Untracked,
),
]);
let ops = discard_all_ops(&status);
assert_eq!(ops.len(), 2, "one checkout batch, one clean batch");
match &ops[0] {
GitOp::DiscardWorktree { paths } => {
let names: Vec<_> = paths.iter().map(|p| p.as_str()).collect();
assert_eq!(names, vec!["both.rs", "edited.rs"]);
}
other => panic!("expected DiscardWorktree first, got {:?}", other.label()),
}
match &ops[1] {
GitOp::DiscardUntracked { paths, directories } => {
let names: Vec<_> = paths.iter().map(|p| p.as_str()).collect();
assert_eq!(names, vec!["new.rs"]);
assert!(!directories);
}
other => panic!("expected DiscardUntracked second, got {:?}", other.label()),
}
// Nothing to discard means nothing to run — and no prompt to answer.
assert!(discard_all_ops(&status_with(Vec::new())).is_empty());
}
#[test]
fn an_upstream_splits_on_its_first_slash_only() {
assert_eq!(split_upstream("origin/main"), Some(("origin", "main")));
// Branch names carry slashes; remote names cannot.
assert_eq!(
split_upstream("origin/feature/auth-retry"),
Some(("origin", "feature/auth-retry"))
);
assert_eq!(split_upstream("main"), None);
assert_eq!(split_upstream("/main"), None);
assert_eq!(split_upstream("origin/"), None);
}
}
+1273
View File
File diff suppressed because it is too large Load Diff
+2293
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
//! Source control: the panel, its file rows, the commit box, and the graph.
//!
//! Every file here hangs `impl Tty7App` blocks, the same shape `sftp.rs` and
//! `file_tree.rs` use. The directory only keeps the surface from piling into
//! `right_panel.rs`.
// The graph and the commit detail view both have callers now, so their allows
// are gone. What is left is `status_rank`, which is the file tree's to use.
pub(crate) mod actions;
pub(crate) mod detail;
pub(crate) mod graph;
pub(crate) mod panel;
pub(crate) mod path;
pub(crate) mod state;
#[allow(dead_code)]
pub(crate) mod status;
pub(crate) use actions::ScmIntent;
pub(crate) use state::{GraphState, ScmPanelState};
+2930
View File
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
//! Turning a repo-relative path and a timestamp into something that fits in a
//! 260px column. All pure, all cheap, all unit-tested — the panel calls these
//! once per visible row per frame.
use std::borrow::Cow;
/// The ellipsis every eliding function here uses. One `char`, so a budget in
/// characters is a budget the caller can reason about.
const ELLIPSIS: char = '…';
/// Split `src/ui/app.rs` into `("app.rs", "src/ui")`.
///
/// The panel renders these as two runs with different sizes and colours, so
/// they have to come back as separate slices rather than one pre-joined
/// string. A path with no directory gets an empty second half.
pub(crate) fn split_display_path(rel: &str) -> (&str, &str) {
// A trailing slash means the caller handed us a directory; the last
// component is still the name, so drop the slash before splitting.
let trimmed = rel.strip_suffix('/').unwrap_or(rel);
match trimmed.rsplit_once('/') {
Some((dir, name)) => (name, dir),
None => (trimmed, ""),
}
}
/// Keep the head and the tail, drop the middle. Paths and branch names both
/// carry their meaning at the ends — `feature/…/auth-retry` still says which
/// area and which change, where a plain truncate says neither.
///
/// `max_chars` counts characters including the ellipsis, so the result never
/// renders wider than the caller budgeted. Cuts land on character boundaries by
/// construction: everything here walks `chars()`, never bytes.
pub(crate) fn elide_middle(s: &str, max_chars: usize) -> Cow<'_, str> {
let total = s.chars().count();
if total <= max_chars {
return Cow::Borrowed(s);
}
// Below three there is no room for head + ellipsis + tail; fall back to a
// plain head cut rather than returning something wider than asked for.
if max_chars <= 2 {
return Cow::Owned(s.chars().take(max_chars).collect());
}
let keep = max_chars - 1;
// Bias the extra character to the head: the tail is usually a file name,
// and its last few characters (the extension) repeat across rows anyway.
let head = keep.div_ceil(2);
let tail = keep - head;
let mut out = String::with_capacity(s.len());
out.extend(s.chars().take(head));
out.push(ELLIPSIS);
out.extend(s.chars().skip(total - tail));
Cow::Owned(out)
}
const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
/// Calendar-average, so "12mo" and "1y" describe the same distance instead of
/// leaving a gap where 365 days is neither.
const MONTH: i64 = DAY * 30;
const YEAR: i64 = DAY * 365;
/// `"2h"` / `"3d"` / `"5mo"` — a graph row has about 26px for this.
///
/// Through the i18n table like `home::relative_time`, in the compact spelling
/// this column's width demands — "now" is still an English word, and the row,
/// the tooltip, the detail byline and the overlay header all read it.
///
/// `now` is a parameter rather than a clock read so the whole thing stays a
/// pure function, and so a test can sit exactly on a boundary.
pub(crate) fn relative_time(now_unix: i64, then_unix: i64) -> String {
use crate::ui::i18n::{L10nKey, t, t_fmt};
// A commit stamped in the future (clock skew across machines is routine in
// a shared repo) reads as "now" rather than as a negative age.
let delta = (now_unix - then_unix).max(0);
let unit = |key: L10nKey, n: i64| t_fmt(key, &[("n", &n.to_string())]);
match delta {
d if d < MINUTE => t(L10nKey::ScmTimeNow).to_string(),
d if d < HOUR => unit(L10nKey::ScmTimeMinutes, d / MINUTE),
d if d < DAY => unit(L10nKey::ScmTimeHours, d / HOUR),
d if d < MONTH => unit(L10nKey::ScmTimeDays, d / DAY),
d if d < YEAR => unit(L10nKey::ScmTimeMonths, d / MONTH),
d => unit(L10nKey::ScmTimeYears, d / YEAR),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_display_path_separates_the_name_from_its_directory() {
assert_eq!(split_display_path("src/ui/app.rs"), ("app.rs", "src/ui"));
assert_eq!(split_display_path("README.md"), ("README.md", ""));
assert_eq!(split_display_path("a/b"), ("b", "a"));
assert_eq!(split_display_path(""), ("", ""));
}
#[test]
fn split_display_path_ignores_a_trailing_slash() {
assert_eq!(split_display_path("src/ui/"), ("ui", "src"));
assert_eq!(split_display_path("src/"), ("src", ""));
// A leading slash leaves an empty directory half rather than dropping
// the root — the caller decides how to render that.
assert_eq!(split_display_path("/etc"), ("etc", ""));
}
#[test]
fn elide_middle_leaves_short_strings_borrowed() {
assert!(matches!(elide_middle("short", 10), Cow::Borrowed("short")));
assert!(matches!(elide_middle("exact", 5), Cow::Borrowed("exact")));
}
#[test]
fn elide_middle_keeps_both_ends_and_respects_the_budget() {
let out = elide_middle("crates/tty7-core/src/core/git/status.rs", 20);
assert_eq!(out.chars().count(), 20);
assert_eq!(out.matches(ELLIPSIS).count(), 1);
assert!(out.starts_with("crates/"), "{out}");
assert!(out.ends_with("status.rs"), "{out}");
}
#[test]
fn elide_middle_spends_exactly_one_char_on_the_ellipsis() {
// U+2026, not three ASCII dots: three dots would eat three columns of
// a budget measured in characters.
let out = elide_middle("abcdefghij", 5);
assert_eq!(out, "ab…ij");
assert_eq!(out.chars().count(), 5);
// An odd budget gives the head the spare character.
assert_eq!(elide_middle("abcdefghij", 6), "abc…ij");
}
#[test]
fn elide_middle_handles_degenerate_budgets() {
assert_eq!(elide_middle("abcdef", 3), "a…f");
assert_eq!(elide_middle("abcdef", 2), "ab");
assert_eq!(elide_middle("abcdef", 1), "a");
assert_eq!(elide_middle("abcdef", 0), "");
}
#[test]
fn elide_middle_never_cuts_a_multibyte_char_in_half() {
// Every one of these is 3 bytes; a byte-indexed implementation panics
// here rather than returning something wrong, which is why this test
// asserts on the value and not just on not panicking.
let path = "文档/设计/源代码管理方案.md";
for budget in 0..=path.chars().count() + 2 {
let out = elide_middle(path, budget);
assert!(
out.chars().count() <= budget,
"budget {budget} produced {out:?}"
);
}
let out = elide_middle(path, 8);
assert_eq!(out.chars().count(), 8);
assert!(out.contains(ELLIPSIS));
assert!(out.starts_with("文档/设"), "{out}");
assert!(out.ends_with(".md"), "{out}");
}
#[test]
fn relative_time_covers_every_bucket() {
crate::ui::i18n::set_locale("en");
let now = 1_800_000_000i64;
let ago = |secs: i64| relative_time(now, now - secs);
assert_eq!(ago(0), "now");
assert_eq!(ago(59), "now");
assert_eq!(ago(60), "1m");
assert_eq!(ago(90), "1m");
assert_eq!(ago(59 * MINUTE), "59m");
assert_eq!(ago(HOUR), "1h");
assert_eq!(ago(23 * HOUR), "23h");
assert_eq!(ago(DAY), "1d");
assert_eq!(ago(29 * DAY), "29d");
assert_eq!(ago(MONTH), "1mo");
assert_eq!(ago(YEAR - 1), "12mo");
assert_eq!(ago(YEAR), "1y");
assert_eq!(ago(5 * YEAR), "5y");
}
#[test]
fn relative_time_clamps_commits_from_the_future() {
crate::ui::i18n::set_locale("en");
let now = 1_800_000_000i64;
assert_eq!(relative_time(now, now + DAY), "now");
}
/// The row, the tooltip and the overlay byline all read this — it goes
/// through the i18n table like `home::relative_time`, in compact form.
#[test]
fn relative_time_speaks_the_ui_language() {
crate::ui::i18n::set_locale("zh-CN");
let now = 1_800_000_000i64;
assert_eq!(relative_time(now, now), "刚刚");
assert_eq!(relative_time(now, now - 2 * HOUR), "2时");
// “3月”会被读成月份名,所以是“个月”。
assert_eq!(relative_time(now, now - 3 * MONTH), "3个月");
crate::ui::i18n::set_locale("en");
}
}
+311
View File
@@ -0,0 +1,311 @@
//! Everything the source control panel remembers between frames.
//!
//! All of it is app-level, hanging off `Tty7App.scm` rather than off a tab.
//! The panel has exactly one instance per window — the same model
//! `RightPanelState.diff_cwd` already uses. `Tab.diff_overlay` and `Tab.code`
//! are per-tab because they are full-screen overlays that belong to a tab; a
//! side panel does not.
//!
//! The one thing that must survive everything is the commit draft, so it is
//! keyed by repository rather than by tab or pane: a working tree has one
//! pending message, no matter how many panes are looking at it.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use gpui::Entity;
use gpui_component::input::InputState;
use tty7_core::core::git::log::{Commit, CommitFile, CommitPage, GraphScope};
use tty7_core::core::git::status::HeadState;
use crate::ui::host_ops::HostId;
/// Which working tree a piece of state belongs to.
///
/// The host is part of the key because the same path can exist on this machine
/// and on three different remotes at once, and they are unrelated repositories.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub(crate) struct RepoKey {
pub(crate) host: HostId,
pub(crate) root: PathBuf,
}
/// The four sections of the file list, in the order they are rendered.
///
/// `Merge` only appears while a merge is unresolved, which is why the panel
/// asks for it by variant rather than always drawing a header.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum ScmGroup {
Merge,
Staged,
Changes,
Untracked,
}
impl ScmGroup {
pub(crate) const ORDER: [ScmGroup; 4] = [
ScmGroup::Merge,
ScmGroup::Staged,
ScmGroup::Changes,
ScmGroup::Untracked,
];
}
#[derive(Default)]
pub(crate) struct ScmPanelState {
/// Which repository the panel is showing. Follows the active pane unless
/// `repo_override` says otherwise.
pub(crate) repo: Option<RepoKey>,
/// Set when the user picks a repository from the multi-repo dropdown, and
/// cleared whenever the active tab changes — an explicit choice should
/// outlive a pane switch inside one tab, not a jump to somewhere else.
pub(crate) repo_override: Option<RepoKey>,
/// The tab the override was made on, so the jump away can be noticed.
pub(crate) override_tab: Option<usize>,
/// Local branch names per repository, with the epoch they were read at.
/// Anything that could have moved a ref bumps the epoch, so the list in
/// the switcher is never older than the last operation.
pub(crate) branches: HashMap<RepoKey, (u64, Vec<String>)>,
pub(crate) branches_loading: HashSet<RepoKey>,
/// The inline "name your branch" input, present only while it is open.
pub(crate) new_branch: Option<Entity<InputState>>,
/// Unsent commit messages, one per working tree.
pub(crate) drafts: HashMap<RepoKey, String>,
/// The commit box. `None` until the panel has been rendered once: an
/// `InputState` needs a real window to be created in, and this struct is
/// built by `Default` alongside the rest of `Tty7App`.
pub(crate) commit_input: Option<Entity<InputState>>,
/// Which repository's draft the box is currently holding. A change here
/// is what moves one draft out and the next one in.
pub(crate) commit_repo: Option<RepoKey>,
/// A commit that has been dispatched: the repository, what HEAD was
/// before it, and the message it carried. Held until HEAD moves, so a
/// commit a hook rejects leaves the message in the box.
pub(crate) committing: Option<(RepoKey, HeadState, String)>,
/// Whether the next commit rewrites HEAD. Armed from the commit dropdown
/// rather than a checkbox row — 260px does not have a row to spare.
pub(crate) amend: bool,
/// Groups the user folded shut, and the ones whose fold state they have
/// set at all. Both are needed: a group nobody has touched follows the
/// default for its size (a thousand untracked files start folded), and
/// opening one by hand has to outlast the next file landing in it.
pub(crate) collapsed: HashSet<ScmGroup>,
pub(crate) toggled: HashSet<ScmGroup>,
/// Working directory → the repository root containing it, or `None` when
/// there is none, with when the answer was given. The root is what every
/// write runs from and what every cache is keyed by, so it is resolved
/// once per directory and reused.
pub(crate) roots: HashMap<(HostId, PathBuf), (std::time::Instant, Option<PathBuf>)>,
pub(crate) root_lookups: HashSet<(HostId, PathBuf)>,
/// When the panel last asked for a status that it did not get back.
pub(crate) probe_attempt: HashMap<(HostId, PathBuf), std::time::Instant>,
/// The status the last frame drew, as (cache key, `Arc` identity). The
/// watcher compares against it so a global write that changed nothing does
/// not ask for another frame.
pub(crate) seen: Option<((HostId, PathBuf), usize)>,
pub(crate) watch: Option<gpui::Subscription>,
/// The cheap per-tab git status the panel last reacted to. A change in it
/// means a command touched the repository and the expensive status is due
/// another look.
pub(crate) last_tab_status: Option<crate::terminal::git_status::GitStatus>,
pub(crate) graph: GraphState,
/// When set, the panel body is replaced by a single commit's detail view
/// instead of the working tree.
pub(crate) detail: Option<CommitDetailView>,
pub(crate) scroll: gpui::ScrollHandle,
}
impl ScmPanelState {
/// The repository the panel should act on: an explicit pick wins over
/// whatever the active pane happens to be sitting in.
pub(crate) fn active_repo(&self) -> Option<&RepoKey> {
self.repo_override.as_ref().or(self.repo.as_ref())
}
/// Drop everything keyed to a host that left the registry. Without this,
/// `roots` and friends grow one entry per directory ever visited on a
/// link that no longer exists. Drafts survive on purpose: a reconnect
/// brings the same repositories back, and unsent messages with them.
pub(crate) fn forget_host(&mut self, host: HostId) {
self.roots.retain(|(h, _), _| *h != host);
self.root_lookups.retain(|(h, _)| *h != host);
self.probe_attempt.retain(|(h, _), _| *h != host);
self.branches.retain(|k, _| k.host != host);
self.branches_loading.retain(|k| k.host != host);
if self.repo_override.as_ref().is_some_and(|k| k.host == host) {
self.repo_override = None;
self.override_tab = None;
}
}
/// A probe just answered "no repository" for `root`: every directory that
/// resolved to it has to re-ask. Left cached, the panel keeps mapping the
/// cwd to a repository that is gone and draws its Loading state forever.
pub(crate) fn forget_root(&mut self, host: HostId, root: &std::path::Path) {
self.roots
.retain(|(h, _), (_, held)| !(*h == host && held.as_deref() == Some(root)));
}
pub(crate) fn draft(&self, repo: &RepoKey) -> &str {
self.drafts.get(repo).map(String::as_str).unwrap_or("")
}
/// Whether a group renders folded. `count` decides it only for a group the
/// user has never touched.
pub(crate) fn group_collapsed(&self, group: ScmGroup, count: usize) -> bool {
if self.toggled.contains(&group) {
self.collapsed.contains(&group)
} else {
crate::ui::scm::panel::starts_collapsed(group, count)
}
}
pub(crate) fn set_group_collapsed(&mut self, group: ScmGroup, collapsed: bool) {
self.toggled.insert(group);
if collapsed {
self.collapsed.insert(group);
} else {
self.collapsed.remove(&group);
}
}
}
#[derive(Default)]
pub(crate) struct GraphState {
/// Mirrors `Config::scm_graph_expanded`, which starts `false`: the history
/// section unfurling on first open would make the panel look like a mess
/// nobody asked for.
pub(crate) expanded: bool,
/// How many commits have been asked for so far. Paging grows this and
/// re-runs the query rather than using `--skip`, which is O(skip) to walk
/// and shifts under you when a ref moves between pages.
pub(crate) requested: usize,
pub(crate) loading: bool,
/// The page the graph is currently drawn from, and which repository and
/// query produced it. `Arc` because paint reads it while the next page is
/// being laid out on a worker, and the key so a repository switch shows an
/// empty graph rather than the previous repository's history.
pub(crate) page: Option<Arc<CommitPage>>,
pub(crate) page_key: Option<(RepoKey, u64, GraphScope)>,
/// The key of a load that came back empty-handed. Without it a failing
/// `git log` — a scope pinned to a since-deleted branch, a vanished
/// repository — would be retried from every frame's render, one git
/// process per notify, forever. The failure clears itself the moment the
/// key changes: an epoch bump (any refresh), a new scope, a new repo.
pub(crate) failed_key: Option<(RepoKey, u64, GraphScope)>,
/// Filter box. Like `commit_input`, created on first render — and with the
/// subscription that turns typing into a repaint. An `InputState` is its
/// own entity; without this the box would take text the list never sees.
pub(crate) search: Option<Entity<InputState>>,
pub(crate) search_sub: Option<gpui::Subscription>,
/// Which commit indices the list shows, cached per (page identity, query).
/// The filter case-folds every subject and author; re-running that over
/// 5000 commits on every frame while the box is open is real work, and
/// even the unfiltered identity list is 40KB of indices a frame.
pub(crate) filter_cache: Option<(Option<String>, usize, Arc<Vec<usize>>)>,
/// An open "name a branch at this commit" input, and the rev it starts
/// from. The panel's own naming row cannot serve this: it always creates
/// at HEAD, and the whole point here is the commit under the cursor.
pub(crate) naming: Option<(Entity<InputState>, String)>,
/// Which refs the graph walks from. Three states rather than an
/// `Option<branch>`: "this branch and its upstream", "one named branch",
/// and "everything" are all reachable from the header's dropdown, and only
/// the enum the data layer already takes can express all three.
pub(crate) scope: GraphScope,
/// The selected row, by full sha.
pub(crate) selected: Option<String>,
pub(crate) scroll: gpui::ScrollHandle,
/// Height of the history section, and whether its divider is being
/// dragged. Shaped like `right_panel_width` / `right_panel_dragging` so
/// the same drag code works on the other axis.
pub(crate) height: std::rc::Rc<std::cell::Cell<f32>>,
pub(crate) dragging: std::rc::Rc<std::cell::Cell<bool>>,
}
/// The panel's second-level view: one commit's metadata and the files it
/// touched. A file-level diff is not shown here — that opens the full-screen
/// overlay, because 260px cannot render a diff and pretending otherwise would
/// mean inventing a third kind of container.
///
/// The two loaded halves are behind `Arc` because the panel clones this whole
/// struct once per frame — `render_panel_scm` cannot hand `render_commit_
/// detail` a borrow of `self.scm` and a `&mut self` at once — and a commit
/// that touched a thousand files would otherwise deep-copy a thousand paths
/// every time anything on the panel redrew.
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) struct CommitDetailView {
pub(crate) repo: RepoKey,
pub(crate) oid: String,
/// A read is out. Set before it is dispatched, so the render that runs in
/// between does not ask for a second one.
pub(crate) loading: bool,
/// Whether a read has ever come back. With `loading` it is what stops the
/// view asking again forever after a commit git could not resolve: the
/// pair says "nothing is in flight and nothing is coming".
pub(crate) loaded: bool,
/// `None` until a read lands, and still `None` afterwards for a commit
/// that is not in this repository.
pub(crate) commit: Option<Arc<Commit>>,
pub(crate) files: Option<Arc<Vec<CommitFile>>>,
/// The file read came back with nothing — git errored, or the link
/// dropped mid-read. Distinct from "still loading", and from an empty
/// list: "0 files changed" for a commit whose files could not be read
/// would be a confident lie.
pub(crate) files_failed: bool,
/// A long body starts folded — a merge from a bot can run to fifty lines,
/// and the file list is what the reader came for.
pub(crate) body_expanded: bool,
}
impl CommitDetailView {
/// A commit the panel is about to show. `seed` is the row the graph
/// already has in hand, where the caller came from the graph: the page
/// carries every field a detail view needs, so handing it over is what
/// keeps the common path from running `git show` for a commit that is
/// literally on screen.
pub(crate) fn new(repo: RepoKey, oid: String, seed: Option<Commit>) -> CommitDetailView {
CommitDetailView {
repo,
oid,
loading: false,
loaded: false,
commit: seed.map(Arc::new),
files: None,
files_failed: false,
body_expanded: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(root: &str) -> RepoKey {
RepoKey {
host: HostId::LOCAL,
root: PathBuf::from(root),
}
}
#[test]
fn an_explicit_repo_pick_outranks_the_active_pane() {
let mut state = ScmPanelState::default();
assert!(state.active_repo().is_none());
state.repo = Some(key("/a"));
assert_eq!(state.active_repo(), Some(&key("/a")));
state.repo_override = Some(key("/b"));
assert_eq!(state.active_repo(), Some(&key("/b")));
state.repo_override = None;
assert_eq!(state.active_repo(), Some(&key("/a")));
}
#[test]
fn drafts_are_keyed_by_repository_not_by_path_alone() {
let mut state = ScmPanelState::default();
state.drafts.insert(key("/a"), "wip".into());
assert_eq!(state.draft(&key("/a")), "wip");
assert_eq!(state.draft(&key("/b")), "");
}
}
+128
View File
@@ -0,0 +1,128 @@
//! One definition of how a git status looks, shared by the source control
//! panel, the file tree and the diff overlay's file cards.
//!
//! Keeping it in one place is the point: three hand-written A/M/D/R tables
//! drift, and the drift is invisible until someone notices the same file wears
//! two different letters in two different places.
use gpui_component::ActiveTheme as _;
use tty7_core::core::git::status::DecoStatus;
/// The single letter shown in the 14px badge column. `Ignored` has none — a
/// tree full of `!` is noise, not information.
pub(crate) fn status_glyph(s: DecoStatus) -> &'static str {
match s {
DecoStatus::Ignored => "",
DecoStatus::Untracked => "?",
DecoStatus::Added => "A",
DecoStatus::Modified => "M",
DecoStatus::Renamed => "R",
DecoStatus::Deleted => "D",
DecoStatus::Conflict => "U",
}
}
/// Every colour here comes from `Semantics` (ansi 1/2/3/6 pushed over the
/// contrast floor), so it already tracks the theme and is already covered by
/// the contrast tests in `presets.rs`. No new token is introduced.
pub(crate) fn status_color(s: DecoStatus, cx: &gpui::App) -> gpui::Hsla {
let theme = cx.theme();
match s {
DecoStatus::Conflict => theme.danger,
// Muted rather than danger: a deleted file is gone, not broken, and
// the strikethrough on its name already carries the message.
DecoStatus::Deleted => theme.muted_foreground,
DecoStatus::Added | DecoStatus::Untracked => theme.success,
DecoStatus::Modified => theme.warning,
DecoStatus::Renamed => theme.info,
DecoStatus::Ignored => theme.muted_foreground.opacity(0.7),
}
}
/// Display precedence for rolling a directory up to one status: the worst of
/// everything beneath it wins.
///
/// This is `DecoStatus`'s own `Ord` spelled out rather than a second opinion —
/// `StatusIndex` already rolls directories up with `max()`, so a rank that
/// disagreed would make a folder and the file inside it contradict each other.
/// Written out longhand so reordering the enum trips the test below instead of
/// silently reshuffling the UI.
pub(crate) fn status_rank(s: DecoStatus) -> u8 {
match s {
DecoStatus::Ignored => 0,
DecoStatus::Untracked => 1,
DecoStatus::Added => 2,
DecoStatus::Modified => 3,
DecoStatus::Renamed => 4,
DecoStatus::Deleted => 5,
DecoStatus::Conflict => 6,
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [DecoStatus; 7] = [
DecoStatus::Ignored,
DecoStatus::Untracked,
DecoStatus::Added,
DecoStatus::Modified,
DecoStatus::Renamed,
DecoStatus::Deleted,
DecoStatus::Conflict,
];
#[test]
fn every_status_has_its_own_letter() {
let mut seen: Vec<&str> = Vec::new();
for s in ALL {
let glyph = status_glyph(s);
if s == DecoStatus::Ignored {
assert_eq!(glyph, "", "ignored files carry no letter");
continue;
}
assert_eq!(glyph.chars().count(), 1, "{s:?} should be one character");
assert!(!seen.contains(&glyph), "{glyph} is used twice");
seen.push(glyph);
}
assert_eq!(status_glyph(DecoStatus::Conflict), "U");
assert_eq!(status_glyph(DecoStatus::Untracked), "?");
}
#[test]
fn rank_orders_conflict_above_everything_and_ignored_below() {
let worst_first = [
DecoStatus::Conflict,
DecoStatus::Deleted,
DecoStatus::Renamed,
DecoStatus::Modified,
DecoStatus::Added,
DecoStatus::Untracked,
DecoStatus::Ignored,
];
for pair in worst_first.windows(2) {
assert!(
status_rank(pair[0]) > status_rank(pair[1]),
"{:?} should outrank {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn rank_agrees_with_the_enums_own_ordering() {
// The data layer sorts by `Ord`; the UI sorts by `status_rank`. If the
// two ever disagree a directory rollup and a file row can disagree too.
for a in ALL {
for b in ALL {
assert_eq!(
status_rank(a).cmp(&status_rank(b)),
a.cmp(&b),
"{a:?} vs {b:?}"
);
}
}
}
}
+17 -8
View File
@@ -20,7 +20,7 @@ use crate::daemon::protocol::{
};
use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent, safe_local_name};
use crate::terminal::RemoteTerminal;
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::right_panel::{META, TEXT};
@@ -1111,12 +1111,21 @@ impl Tty7App {
fn sftp_controls(&self, cx: &mut Context<Self>) -> AnyElement {
let history = self.sftp_panel.show_history;
// The title bar's 24px chrome tile, the same one the Info tab puts its
// cwd actions in. It used to be built by hand — a 32px tile forced to
// 24 and then set `.xsmall()`, which overrode the 13px each icon below
// asked for with the button size's own 12, so the glyph never was the
// size the code claimed. `chrome_tile_sized` derives it from the tile
// instead, which is what every other 24px tile in the panel does.
let tile = |button: Button, selected: bool, cx: &mut Context<Self>| {
crate::ui::tab_strip::chrome_tile(button, selected, cx)
.xsmall()
.w(px(24.))
.h(px(24.))
.rounded_md()
crate::ui::tab_strip::chrome_tile_sized(
button,
TILE_SIZE_SM,
TILE_GLYPH_SM,
selected,
cx,
)
.rounded_md()
};
h_flex()
@@ -1126,7 +1135,7 @@ impl Tty7App {
div().occlude().child(
tile(
Button::new("panel-sftp-refresh")
.icon(Icon::empty().path("icons/refresh.svg").size(px(13.))),
.icon(Icon::empty().path("icons/refresh.svg")),
false,
cx,
)
@@ -1138,7 +1147,7 @@ impl Tty7App {
div().occlude().child(
tile(
Button::new("panel-sftp-menu")
.icon(Icon::empty().path("icons/ellipsis.svg").size(px(13.))),
.icon(Icon::empty().path("icons/ellipsis.svg")),
false,
cx,
)
+13 -9
View File
@@ -748,13 +748,17 @@ impl Tty7App {
pub(crate) fn right_panel_tabs(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let active_tab = self.right_panel_tab;
let changed = match &self.right_panel.diff {
Some(Some(snap)) => {
let n = snap.files.len() + snap.untracked_count();
(n > 0).then_some(n)
}
_ => None,
};
// The count the source control tile carries. It reads the same status
// the panel draws, so the badge and the group headers can never
// disagree — and it counts entries, not files, because a path that is
// both staged and modified is two things to do, which is what the
// groups show.
let changed = self
.scm
.active_repo()
.and_then(|repo| crate::terminal::git_data::status_of(cx, repo.host, &repo.root))
.map(|status| status.entries.len())
.filter(|n| *n > 0);
[
(
RightPanelTab::Info,
@@ -762,7 +766,7 @@ impl Tty7App {
L10nKey::PanelInfoTitle,
),
(
RightPanelTab::Changes,
RightPanelTab::Scm,
Icon::empty().path("icons/git-branch.svg"),
L10nKey::PanelChangesTitle,
),
@@ -785,7 +789,7 @@ impl Tty7App {
)
.rounded_lg()
.tooltip(match (tab, changed) {
(RightPanelTab::Changes, Some(n)) => {
(RightPanelTab::Scm, Some(n)) => {
SharedString::from(format!("{} · {n}", t(label_key)))
}
_ => SharedString::from(t(label_key)),
+4
View File
@@ -598,6 +598,10 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
});
cx.set_global(surfaces.clone());
cx.set_global(presets::ActiveAccent(m.accent));
// Same treatment as `Surfaces`: derived once here rather than recomputed
// in `render`, because the graph reads it once per visible row per frame
// and each entry costs a contrast bisection on three surfaces.
cx.set_global(presets::ActiveLanes(theme.lanes()));
let t = Theme::global_mut(cx);
let mut base: Hsla = rgb(m.background).into();