mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
Generated
+1
@@ -9846,6 +9846,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"system-configuration",
|
||||
"system-configuration-sys",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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-")),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+2293
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")), "");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user