T15: the Git panel: HEAD's branch, or its commit while detached; Ely's changes list of conflicted, staged, unstaged and untracked files, each with open, stage, unstage, and discard, which asks first; stage all and unstage all; a commit with a summary, a description and amend; the last 50 commits; a pressed file's diff in Ely's DiffViewer over the window, unified or split; outside a repository it says so, and a failed git command shows git's words; it reads again at each prompt, on a folder change and after each action, and Files' marks with it; a section lists 500 files at most and the Git body is a cached view, so a long list does not slow the terminal
This commit is contained in:
8 files changed
+976
-123
No files matched your search
@@ -21,7 +21,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, commit, log and diff.
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
@@ -63,4 +63,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A hidden terminal hears no mouse release, so hiding it ends its mouse gesture as releases would: the selection drag ends, and the program hears each held button come up. A row's title is the program's, or the app's name while it names none.
|
||||
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder. A control in a column header keeps its presses: Ely's header zooms the window on any double press.
|
||||
- 2026-10-14: git runs in English with `--literal-pathspecs` and without optional locks. Paths stay git's bytes until shown, so a name not in UTF-8 still names its own file; off Unix git writes UTF-8. Status reads porcelain v2. Paths to stage and unstage go in on stdin, NUL-ended (`--pathspec-from-file`), so any name and any count pass; an empty list is refused, since git would take it as the whole tree. Unstaging is `git reset`, which works before the first commit, and a staged rename unstages with its old path; a copy is a new file, its source no part of it. A diff is git's own, without external tools or text converters, a conflict against HEAD, our side, so a file we deleted reads as new; it is read into Ely's stretches; a binary file has none, and an untracked folder none either. Without an index git exits 1 for a difference and a failure alike; only a difference prints. What git warns of while it works goes to the log. The log needs a commit at HEAD: git refuses it before the first.
|
||||
- 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run one at a time off the main thread; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view.
|
||||
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
|
||||
+59
-108
@@ -7,10 +7,15 @@ use std::{
|
||||
|
||||
use anyhow::{Context as _, anyhow, bail, ensure};
|
||||
use ely_gpui_component::{
|
||||
git::{ChangeSection, Commit, DiffLine, LineKind, Stretch, mark_words},
|
||||
git::{ChangeSection, Commit},
|
||||
lists::GitStatus,
|
||||
};
|
||||
|
||||
mod diff;
|
||||
pub mod panel;
|
||||
|
||||
pub use diff::diff;
|
||||
|
||||
/// Git's arguments on one line, for messages.
|
||||
fn line<A: AsRef<OsStr>>(args: &[A]) -> String {
|
||||
let args: Vec<_> = args
|
||||
@@ -70,9 +75,9 @@ fn warnings<A: AsRef<OsStr>>(folder: &Path, args: &[A], output: &Output) {
|
||||
}
|
||||
|
||||
/// Runs git and gives what it printed; a failure is an error carrying what git said.
|
||||
fn run(folder: &Path, args: &[&str], input: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
fn run<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let output = git(folder, args, input)?;
|
||||
let (command, place) = (args.join(" "), folder.display());
|
||||
let (command, place) = (line(args), folder.display());
|
||||
ensure!(
|
||||
output.status.success(),
|
||||
"git: `git {command}` failed in {place}: {}",
|
||||
@@ -326,11 +331,16 @@ fn pathspecs(changes: &[Change]) -> anyhow::Result<Vec<u8>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The first few changes by name, for a log line, and how many more.
|
||||
fn names(changes: &[Change]) -> String {
|
||||
let names: Vec<String> = changes
|
||||
let mut names: Vec<String> = changes
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|change| change.path.display().to_string())
|
||||
.collect();
|
||||
if changes.len() > names.len() {
|
||||
names.push(format!("{} more", changes.len() - names.len()));
|
||||
}
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
@@ -356,9 +366,51 @@ pub fn unstage(top: &Path, changes: &[Change]) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Commits what is staged with `message`.
|
||||
pub fn commit(top: &Path, message: &str) -> anyhow::Result<()> {
|
||||
run(top, &["commit", "-q", "-F", "-"], message.as_bytes())?;
|
||||
log::info!("git: committed in {}", top.display());
|
||||
pub fn commit(top: &Path, message: &str, amend: bool) -> anyhow::Result<()> {
|
||||
let args: &[&str] = match amend {
|
||||
true => &["commit", "-q", "--amend", "-F", "-"],
|
||||
false => &["commit", "-q", "-F", "-"],
|
||||
};
|
||||
run(top, args, message.as_bytes())?;
|
||||
let amended = if amend { ", amending HEAD" } else { "" };
|
||||
log::info!("git: committed in {}{amended}", top.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discards `change` from the work tree: an edit goes back to the index; a new file, untracked or only intended to be added, goes.
|
||||
pub fn discard(top: &Path, change: &Change, section: ChangeSection) -> anyhow::Result<()> {
|
||||
let shown = change.path.display();
|
||||
let one = || pathspecs(std::slice::from_ref(change));
|
||||
match (section, change.status) {
|
||||
(ChangeSection::Untracked, _) => {
|
||||
let flags = ["clean", "-f", "-d", "--"].map(OsStr::new);
|
||||
let args: Vec<&OsStr> = flags.into_iter().chain([change.path.as_os_str()]).collect();
|
||||
// Git passes over a repository of its own without a word.
|
||||
let removed = run(top, &args, &[])?;
|
||||
ensure!(!removed.is_empty(), "git: clean removed nothing at {shown}");
|
||||
}
|
||||
(ChangeSection::Unstaged, GitStatus::Added | GitStatus::Renamed) => {
|
||||
let args = [
|
||||
"rm",
|
||||
"-f",
|
||||
"-q",
|
||||
"--pathspec-from-file=-",
|
||||
"--pathspec-file-nul",
|
||||
];
|
||||
run(top, &args, &one()?)?;
|
||||
}
|
||||
(ChangeSection::Unstaged, _) => {
|
||||
let args = [
|
||||
"restore",
|
||||
"--worktree",
|
||||
"--pathspec-from-file=-",
|
||||
"--pathspec-file-nul",
|
||||
];
|
||||
run(top, &args, &one()?)?;
|
||||
}
|
||||
_ => bail!("git: {shown} has nothing to discard in {section:?}"),
|
||||
}
|
||||
log::info!("git: discarded {shown} in {}", top.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -391,106 +443,5 @@ pub fn log(top: &Path, count: usize) -> anyhow::Result<Vec<Commit>> {
|
||||
Ok(commits.collect())
|
||||
}
|
||||
|
||||
/// `change`'s diff as `section` holds it, in Ely's stretches; none for a binary file.
|
||||
pub fn diff(
|
||||
top: &Path,
|
||||
change: &Change,
|
||||
section: ChangeSection,
|
||||
) -> anyhow::Result<Option<Vec<Stretch>>> {
|
||||
let (path, shown) = (change.path.as_os_str(), change.path.display());
|
||||
let flags: &[&str] = match section {
|
||||
ChangeSection::Staged => &["--cached", "-M", "--"],
|
||||
ChangeSection::Unstaged => &["--"],
|
||||
// HEAD is our side: a file we deleted reads as new.
|
||||
ChangeSection::Conflicted => &["HEAD", "--"],
|
||||
ChangeSection::Untracked if path.as_encoded_bytes().ends_with(b"/") => {
|
||||
bail!("git: {shown} is a folder")
|
||||
}
|
||||
ChangeSection::Untracked => &["--no-index", "--", "/dev/null"],
|
||||
ChangeSection::Ignored => bail!("git: {shown} is ignored"),
|
||||
};
|
||||
let mut args: Vec<&OsStr> = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"]
|
||||
.into_iter()
|
||||
.chain(flags.iter().copied())
|
||||
.map(OsStr::new)
|
||||
.collect();
|
||||
args.push(path);
|
||||
args.extend(change.from.as_deref().map(Path::as_os_str));
|
||||
let output = git(top, &args, &[])?;
|
||||
// Without an index git exits 1 for a difference and a failure alike; only a difference prints.
|
||||
let differ = section == ChangeSection::Untracked
|
||||
&& output.status.code() == Some(1)
|
||||
&& !output.stdout.is_empty();
|
||||
ensure!(
|
||||
output.status.success() || differ,
|
||||
"git: `git {}` failed in {}: {}",
|
||||
line(&args),
|
||||
top.display(),
|
||||
said(&output)
|
||||
);
|
||||
warnings(top, &args, &output);
|
||||
hunks(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// A hunk header's first old and new line numbers.
|
||||
fn starts(header: &str) -> Option<(usize, usize)> {
|
||||
let mut ranges = header.strip_prefix("@@ -")?.split(' ');
|
||||
let first = |range: &str| range.split(',').next()?.parse().ok();
|
||||
let old = first(ranges.next()?)?;
|
||||
let new = first(ranges.next()?.strip_prefix('+')?)?;
|
||||
Some((old, new))
|
||||
}
|
||||
|
||||
/// Git's unified diff as Ely's stretches, changed words marked; none for a binary file.
|
||||
fn hunks(text: &str) -> anyhow::Result<Option<Vec<Stretch>>> {
|
||||
let (mut stretches, mut at, mut inside) = (Vec::new(), (0, 0), false);
|
||||
for line in text.lines() {
|
||||
if line.starts_with("diff ") {
|
||||
inside = false;
|
||||
} else if line.starts_with("@@") {
|
||||
at = starts(line).with_context(|| format!("git: hunk header {line:?}"))?;
|
||||
let header = line.to_string().into();
|
||||
stretches.push(Stretch::Hunk {
|
||||
header,
|
||||
lines: Vec::new(),
|
||||
});
|
||||
inside = true;
|
||||
} else if !inside {
|
||||
if line.starts_with("Binary files ") {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if let Some(Stretch::Hunk { lines, .. }) = stretches.last_mut() {
|
||||
let (kind, text) = match line.as_bytes().first() {
|
||||
// `diff.suppressBlankEmpty` drops a blank line's space.
|
||||
None => (LineKind::Same, ""),
|
||||
Some(b' ') => (LineKind::Same, &line[1..]),
|
||||
Some(b'-') => (LineKind::Removed, &line[1..]),
|
||||
Some(b'+') => (LineKind::Added, &line[1..]),
|
||||
Some(b'\\') => continue,
|
||||
Some(_) => bail!("git: diff line {line:?} is malformed"),
|
||||
};
|
||||
let old = (kind != LineKind::Added).then_some(at.0);
|
||||
let new = (kind != LineKind::Removed).then_some(at.1);
|
||||
at = (
|
||||
at.0 + usize::from(old.is_some()),
|
||||
at.1 + usize::from(new.is_some()),
|
||||
);
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old,
|
||||
new,
|
||||
text: text.to_string().into(),
|
||||
words: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for stretch in &mut stretches {
|
||||
if let Stretch::Hunk { lines, .. } = stretch {
|
||||
mark_words(lines);
|
||||
}
|
||||
}
|
||||
Ok(Some(stretches))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
use std::{ffi::OsStr, path::Path};
|
||||
|
||||
use anyhow::{Context as _, bail, ensure};
|
||||
use ely_gpui_component::git::{ChangeSection, DiffLine, LineKind, Stretch, mark_words};
|
||||
|
||||
use super::{Change, git, line, said, warnings};
|
||||
|
||||
/// `change`'s diff as `section` holds it, in Ely's stretches; none for a binary file.
|
||||
pub fn diff(
|
||||
top: &Path,
|
||||
change: &Change,
|
||||
section: ChangeSection,
|
||||
) -> anyhow::Result<Option<Vec<Stretch>>> {
|
||||
let (path, shown) = (change.path.as_os_str(), change.path.display());
|
||||
let flags: &[&str] = match section {
|
||||
ChangeSection::Staged => &["--cached", "-M", "--"],
|
||||
ChangeSection::Unstaged => &["--"],
|
||||
// HEAD is our side: a file we deleted reads as new.
|
||||
ChangeSection::Conflicted => &["HEAD", "--"],
|
||||
ChangeSection::Untracked if path.as_encoded_bytes().ends_with(b"/") => {
|
||||
bail!("git: {shown} is a folder")
|
||||
}
|
||||
ChangeSection::Untracked => &["--no-index", "--", "/dev/null"],
|
||||
ChangeSection::Ignored => bail!("git: {shown} is ignored"),
|
||||
};
|
||||
let mut args: Vec<&OsStr> = ["diff", "--no-color", "--no-ext-diff", "--no-textconv"]
|
||||
.into_iter()
|
||||
.chain(flags.iter().copied())
|
||||
.map(OsStr::new)
|
||||
.collect();
|
||||
args.push(path);
|
||||
args.extend(change.from.as_deref().map(Path::as_os_str));
|
||||
let output = git(top, &args, &[])?;
|
||||
// Without an index git exits 1 for a difference and a failure alike; only a difference prints.
|
||||
let differ = section == ChangeSection::Untracked
|
||||
&& output.status.code() == Some(1)
|
||||
&& !output.stdout.is_empty();
|
||||
ensure!(
|
||||
output.status.success() || differ,
|
||||
"git: `git {}` failed in {}: {}",
|
||||
line(&args),
|
||||
top.display(),
|
||||
said(&output)
|
||||
);
|
||||
warnings(top, &args, &output);
|
||||
hunks(&String::from_utf8_lossy(&output.stdout))
|
||||
}
|
||||
|
||||
/// A hunk header's first old and new line numbers.
|
||||
fn starts(header: &str) -> Option<(usize, usize)> {
|
||||
let mut ranges = header.strip_prefix("@@ -")?.split(' ');
|
||||
let first = |range: &str| range.split(',').next()?.parse().ok();
|
||||
let old = first(ranges.next()?)?;
|
||||
let new = first(ranges.next()?.strip_prefix('+')?)?;
|
||||
Some((old, new))
|
||||
}
|
||||
|
||||
/// Git's unified diff as Ely's stretches, changed words marked; none for a binary file.
|
||||
pub(super) fn hunks(text: &str) -> anyhow::Result<Option<Vec<Stretch>>> {
|
||||
let (mut stretches, mut at, mut inside) = (Vec::new(), (0, 0), false);
|
||||
for line in text.lines() {
|
||||
if line.starts_with("diff ") {
|
||||
inside = false;
|
||||
} else if line.starts_with("@@") {
|
||||
at = starts(line).with_context(|| format!("git: hunk header {line:?}"))?;
|
||||
let header = line.to_string().into();
|
||||
stretches.push(Stretch::Hunk {
|
||||
header,
|
||||
lines: Vec::new(),
|
||||
});
|
||||
inside = true;
|
||||
} else if !inside {
|
||||
if line.starts_with("Binary files ") {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if let Some(Stretch::Hunk { lines, .. }) = stretches.last_mut() {
|
||||
let (kind, text) = match line.as_bytes().first() {
|
||||
// `diff.suppressBlankEmpty` drops a blank line's space.
|
||||
None => (LineKind::Same, ""),
|
||||
Some(b' ') => (LineKind::Same, &line[1..]),
|
||||
Some(b'-') => (LineKind::Removed, &line[1..]),
|
||||
Some(b'+') => (LineKind::Added, &line[1..]),
|
||||
Some(b'\\') => continue,
|
||||
Some(_) => bail!("git: diff line {line:?} is malformed"),
|
||||
};
|
||||
let old = (kind != LineKind::Added).then_some(at.0);
|
||||
let new = (kind != LineKind::Removed).then_some(at.1);
|
||||
at = (
|
||||
at.0 + usize::from(old.is_some()),
|
||||
at.1 + usize::from(new.is_some()),
|
||||
);
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old,
|
||||
new,
|
||||
text: text.to_string().into(),
|
||||
words: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for stretch in &mut stretches {
|
||||
if let Stretch::Hunk { lines, .. } = stretch {
|
||||
mark_words(lines);
|
||||
}
|
||||
}
|
||||
Ok(Some(stretches))
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
forms::TextInput,
|
||||
git::{ChangeAction, ChangeSection, Commit, DiffLayout, Stretch},
|
||||
};
|
||||
use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task, Window};
|
||||
|
||||
use self::view::{is_folder, shown};
|
||||
use super::{Change, Repo};
|
||||
|
||||
mod view;
|
||||
|
||||
/// The newest commits the panel lists.
|
||||
const RECENT: usize = 50;
|
||||
|
||||
/// What the shown folder reads as.
|
||||
#[derive(PartialEq)]
|
||||
enum State {
|
||||
Reading,
|
||||
Outside,
|
||||
Failed(SharedString),
|
||||
Read(Repo, Rc<Vec<Commit>>),
|
||||
}
|
||||
|
||||
/// An open diff: its file, the section it came from, and its lines or why there are none.
|
||||
struct Diff {
|
||||
change: Change,
|
||||
section: ChangeSection,
|
||||
lines: Result<Rc<Vec<Stretch>>, &'static str>,
|
||||
}
|
||||
|
||||
/// The panel changed the repository, so other views read it again.
|
||||
pub struct Acted;
|
||||
|
||||
/// The changes `section` lists.
|
||||
fn listed(repo: &Repo, section: ChangeSection) -> &[Change] {
|
||||
match section {
|
||||
ChangeSection::Conflicted => &repo.conflicted,
|
||||
ChangeSection::Staged => &repo.staged,
|
||||
ChangeSection::Unstaged => &repo.unstaged,
|
||||
ChangeSection::Untracked => &repo.untracked,
|
||||
ChangeSection::Ignored => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The Git panel: the shown folder's repository, its changes, a commit, recent commits and diffs.
|
||||
pub struct GitPanel {
|
||||
folder: PathBuf,
|
||||
state: State,
|
||||
/// The last action's failure: what failed, and what git said.
|
||||
error: Option<(&'static str, SharedString)>,
|
||||
subject: Entity<TextInput>,
|
||||
body: Entity<TextInput>,
|
||||
amend: bool,
|
||||
diff: Option<Diff>,
|
||||
layout: DiffLayout,
|
||||
discarding: Option<(Change, ChangeSection)>,
|
||||
/// One action at a time, in the order asked.
|
||||
turn: Arc<Mutex<()>>,
|
||||
reading: Task<()>,
|
||||
opening: Task<()>,
|
||||
}
|
||||
|
||||
impl EventEmitter<Acted> for GitPanel {}
|
||||
|
||||
impl GitPanel {
|
||||
pub fn new(folder: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let subject = cx.new(|cx| TextInput::new(window, cx).placeholder("Summary"));
|
||||
let body = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(3, 6)
|
||||
.placeholder("Description")
|
||||
});
|
||||
Self {
|
||||
folder,
|
||||
state: State::Reading,
|
||||
error: None,
|
||||
subject,
|
||||
body,
|
||||
amend: false,
|
||||
diff: None,
|
||||
layout: DiffLayout::Unified,
|
||||
discarding: None,
|
||||
turn: Arc::default(),
|
||||
reading: Task::ready(()),
|
||||
opening: Task::ready(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `folder`'s repository, read afresh; a new folder drops what the old one had open.
|
||||
pub fn show(&mut self, folder: &Path, cx: &mut Context<Self>) {
|
||||
if folder != self.folder {
|
||||
log::info!("git panel: shows {}", folder.display());
|
||||
self.folder = folder.to_path_buf();
|
||||
(self.error, self.diff, self.discarding) = (None, None, None);
|
||||
}
|
||||
self.read(cx);
|
||||
}
|
||||
|
||||
/// Reads the repository and its recent commits, off the main thread.
|
||||
fn read(&mut self, cx: &mut Context<Self>) {
|
||||
let folder = self.folder.clone();
|
||||
let read = cx.background_spawn(async move {
|
||||
let Some(repo) = super::repo(&folder)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Git refuses a log before the first commit.
|
||||
let commits = match repo.commit {
|
||||
Some(_) => super::log(&repo.top, RECENT)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
anyhow::Ok(Some((repo, commits)))
|
||||
});
|
||||
self.reading = cx.spawn(async move |panel, cx| {
|
||||
let read = read.await;
|
||||
panel.update(cx, |panel, cx| panel.store(read, cx)).ok();
|
||||
});
|
||||
}
|
||||
|
||||
fn store(&mut self, read: anyhow::Result<Option<(Repo, Vec<Commit>)>>, cx: &mut Context<Self>) {
|
||||
let state = match read {
|
||||
Ok(Some((repo, commits))) => State::Read(repo, Rc::new(commits)),
|
||||
Ok(None) => State::Outside,
|
||||
Err(error) => {
|
||||
let folder = self.folder.display();
|
||||
log::error!("git panel: {folder} did not read: {error:#}");
|
||||
State::Failed(format!("{error:#}").into())
|
||||
}
|
||||
};
|
||||
if state == self.state {
|
||||
return;
|
||||
}
|
||||
match &state {
|
||||
State::Read(repo, commits) => log::debug!(
|
||||
"git panel: {} on {:?}, {} conflicted, {} staged, {} unstaged, {} untracked, {} commits",
|
||||
repo.top.display(),
|
||||
repo.branch,
|
||||
repo.conflicted.len(),
|
||||
repo.staged.len(),
|
||||
repo.unstaged.len(),
|
||||
repo.untracked.len(),
|
||||
commits.len()
|
||||
),
|
||||
State::Outside => log::debug!("git panel: {} is no repository", self.folder.display()),
|
||||
State::Reading | State::Failed(_) => {}
|
||||
}
|
||||
self.state = state;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Runs `work` on the repository at `top` off the main thread, one action at a time; on success `then`; after it, a fresh read and word to other views.
|
||||
fn act(
|
||||
&mut self,
|
||||
what: &'static str,
|
||||
top: PathBuf,
|
||||
work: impl FnOnce(&Path) -> anyhow::Result<()> + Send + 'static,
|
||||
then: impl FnOnce(&mut Self, &mut Context<Self>) + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.error = None;
|
||||
let turn = self.turn.clone();
|
||||
let done = cx.background_spawn(async move {
|
||||
let _turn = turn.lock().expect("git panel: an action panicked");
|
||||
work(&top)
|
||||
});
|
||||
cx.spawn(async move |panel, cx| {
|
||||
let done = done.await;
|
||||
let acted = |panel: &mut Self, cx: &mut Context<Self>| {
|
||||
match done {
|
||||
Ok(()) => then(panel, cx),
|
||||
Err(error) => {
|
||||
log::error!("git panel: {what}: {error:#}");
|
||||
panel.error = Some((what, format!("{error:#}").into()));
|
||||
}
|
||||
}
|
||||
panel.read(cx);
|
||||
cx.emit(Acted);
|
||||
cx.notify();
|
||||
};
|
||||
panel.update(cx, acted).ok();
|
||||
})
|
||||
.detach();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// What a row asked: `row` names its file in `section`.
|
||||
fn row_action(
|
||||
&mut self,
|
||||
row: &SharedString,
|
||||
section: ChangeSection,
|
||||
action: ChangeAction,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let State::Read(repo, _) = &self.state else {
|
||||
return;
|
||||
};
|
||||
let found = listed(repo, section)
|
||||
.iter()
|
||||
.find(|change| shown(&change.path) == *row);
|
||||
let (Some(change), top) = (found.cloned(), repo.top.clone()) else {
|
||||
log::warn!("git panel: {row} is no longer in {section:?}");
|
||||
return;
|
||||
};
|
||||
log::debug!("git panel: {action:?} {row} in {section:?}");
|
||||
match action {
|
||||
ChangeAction::Stage => {
|
||||
let work = move |top: &Path| super::stage(top, &[change]);
|
||||
self.act("Stage failed", top, work, |_, _| {}, cx)
|
||||
}
|
||||
ChangeAction::Unstage => {
|
||||
let work = move |top: &Path| super::unstage(top, &[change]);
|
||||
self.act("Unstage failed", top, work, |_, _| {}, cx)
|
||||
}
|
||||
ChangeAction::Discard => {
|
||||
self.discarding = Some((change, section));
|
||||
cx.notify();
|
||||
}
|
||||
ChangeAction::Open => self.open(top, change, section, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stages every unstaged change, or unstages every staged one.
|
||||
fn all(&mut self, stage: bool, cx: &mut Context<Self>) {
|
||||
let State::Read(repo, _) = &self.state else {
|
||||
return;
|
||||
};
|
||||
let top = repo.top.clone();
|
||||
if stage {
|
||||
let changes = repo.unstaged.clone();
|
||||
let work = move |top: &Path| super::stage(top, &changes);
|
||||
self.act("Stage failed", top, work, |_, _| {}, cx);
|
||||
} else {
|
||||
let changes = repo.staged.clone();
|
||||
let work = move |top: &Path| super::unstage(top, &changes);
|
||||
self.act("Unstage failed", top, work, |_, _| {}, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn discard(&mut self, change: Change, section: ChangeSection, cx: &mut Context<Self>) {
|
||||
let State::Read(repo, _) = &self.state else {
|
||||
return;
|
||||
};
|
||||
let top = repo.top.clone();
|
||||
log::info!("git panel: discarding {} confirmed", change.path.display());
|
||||
let work = move |top: &Path| super::discard(top, &change, section);
|
||||
self.act("Discard failed", top, work, |_, _| {}, cx);
|
||||
}
|
||||
|
||||
/// Commits what is staged with the typed message; on success the fields empty.
|
||||
fn commit(&mut self, cx: &mut Context<Self>) {
|
||||
let State::Read(repo, _) = &self.state else {
|
||||
return;
|
||||
};
|
||||
let top = repo.top.clone();
|
||||
let subject = self.subject.read(cx).text().trim().to_string();
|
||||
let body = self.body.read(cx).text().trim().to_string();
|
||||
let message = match body.is_empty() {
|
||||
true => subject,
|
||||
false => format!("{subject}\n\n{body}"),
|
||||
};
|
||||
let amend = self.amend;
|
||||
let work = move |top: &Path| super::commit(top, &message, amend);
|
||||
let then = |panel: &mut Self, cx: &mut Context<Self>| {
|
||||
panel.amend = false;
|
||||
panel.subject.update(cx, |input, cx| input.set_text("", cx));
|
||||
panel.body.update(cx, |input, cx| input.set_text("", cx));
|
||||
};
|
||||
self.act("Commit failed", top, work, then, cx);
|
||||
}
|
||||
|
||||
/// Opens `change`'s diff as `section` holds it, once git gives it.
|
||||
fn open(
|
||||
&mut self,
|
||||
top: PathBuf,
|
||||
change: Change,
|
||||
section: ChangeSection,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.error = None;
|
||||
// Git keeps an untracked folder whole, so it has no lines.
|
||||
if section == ChangeSection::Untracked && is_folder(&change.path) {
|
||||
let lines = Err("An untracked folder has no diff.");
|
||||
return self.shows(
|
||||
Diff {
|
||||
change,
|
||||
section,
|
||||
lines,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
let asked = change.clone();
|
||||
let read = cx.background_spawn(async move { super::diff(&top, &asked, section) });
|
||||
self.opening = cx.spawn(async move |panel, cx| {
|
||||
let read = read.await;
|
||||
let opened = |panel: &mut Self, cx: &mut Context<Self>| {
|
||||
let lines = match read {
|
||||
Ok(Some(lines)) if lines.is_empty() => Err("No line changed."),
|
||||
Ok(Some(lines)) => Ok(Rc::new(lines)),
|
||||
Ok(None) => Err("A binary file."),
|
||||
Err(error) => {
|
||||
log::error!("git panel: Diff failed: {error:#}");
|
||||
panel.error = Some(("Diff failed", format!("{error:#}").into()));
|
||||
return cx.notify();
|
||||
}
|
||||
};
|
||||
panel.shows(
|
||||
Diff {
|
||||
change,
|
||||
section,
|
||||
lines,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
};
|
||||
panel.update(cx, opened).ok();
|
||||
});
|
||||
}
|
||||
|
||||
fn shows(&mut self, diff: Diff, cx: &mut Context<Self>) {
|
||||
let (path, section) = (diff.change.path.display(), diff.section);
|
||||
log::info!("git panel: shows the diff of {path} in {section:?}");
|
||||
self.diff = Some(diff);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ely_gpui_component::{
|
||||
feedback::Alert,
|
||||
git::{ChangeSection, Changed, ChangesList, CommitInput, CommitList, DiffViewer},
|
||||
lists::GitStatus,
|
||||
overlays::{ConfirmDialog, Dialog},
|
||||
primitives::{Icon, IconName, Severity},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::{Caption, Ellipsis},
|
||||
};
|
||||
use gpui::{
|
||||
App, Context, Div, FontWeight, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder,
|
||||
};
|
||||
|
||||
use super::{GitPanel, State};
|
||||
use crate::git::{Change, Repo};
|
||||
|
||||
/// The most files a section lists: Ely's list draws every row, and each row costs every frame.
|
||||
const LISTED: usize = 500;
|
||||
|
||||
/// `path` as the panel shows it, one string to a path, on one line: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8.
|
||||
pub(super) fn shown(path: &Path) -> SharedString {
|
||||
// Git ends a folder with a slash, and no file shares a folder's name.
|
||||
let bytes = path.as_os_str().as_encoded_bytes();
|
||||
let bytes = bytes.strip_suffix(b"/").unwrap_or(bytes);
|
||||
let plain = |c: char| !c.is_control() && c != '"' && c != '\\';
|
||||
if let Ok(text) = std::str::from_utf8(bytes)
|
||||
&& text.chars().all(plain)
|
||||
{
|
||||
return text.to_string().into();
|
||||
}
|
||||
let mut out = String::from("\"");
|
||||
let octal = |out: &mut String, byte: u8| out.push_str(&format!("\\{byte:03o}"));
|
||||
for chunk in bytes.utf8_chunks() {
|
||||
for c in chunk.valid().chars() {
|
||||
match c {
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'"' | '\\' => out.extend(['\\', c]),
|
||||
c if c.is_control() => {
|
||||
let mut bytes = [0; 4];
|
||||
for byte in c.encode_utf8(&mut bytes).bytes() {
|
||||
octal(&mut out, byte);
|
||||
}
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
for &byte in chunk.invalid() {
|
||||
octal(&mut out, byte);
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out.into()
|
||||
}
|
||||
|
||||
pub(super) fn is_folder(path: &Path) -> bool {
|
||||
path.as_os_str().as_encoded_bytes().ends_with(b"/")
|
||||
}
|
||||
|
||||
/// Changes as the rows of Ely's list.
|
||||
fn rows(changes: &[Change]) -> Vec<Changed> {
|
||||
let row = |change: &Change| Changed {
|
||||
path: shown(&change.path),
|
||||
status: change.status,
|
||||
lines: None,
|
||||
};
|
||||
changes.iter().take(LISTED).map(row).collect()
|
||||
}
|
||||
|
||||
/// How many changes the list holds, if it leaves some out.
|
||||
fn cut(repo: &Repo) -> Option<String> {
|
||||
let sections = [
|
||||
&repo.conflicted,
|
||||
&repo.staged,
|
||||
&repo.unstaged,
|
||||
&repo.untracked,
|
||||
];
|
||||
let total: usize = sections.iter().map(|changes| changes.len()).sum();
|
||||
let listed: usize = sections
|
||||
.iter()
|
||||
.map(|changes| changes.len().min(LISTED))
|
||||
.sum();
|
||||
(listed < total).then(|| format!("{listed} of {total} changes listed."))
|
||||
}
|
||||
|
||||
/// What discarding `change` costs, in words.
|
||||
fn loss(change: &Change, section: ChangeSection) -> &'static str {
|
||||
match (section, change.status) {
|
||||
(ChangeSection::Untracked, _) if is_folder(&change.path) => {
|
||||
"Its untracked files are deleted."
|
||||
}
|
||||
(ChangeSection::Untracked, _) | (_, GitStatus::Added | GitStatus::Renamed) => {
|
||||
"It is deleted."
|
||||
}
|
||||
_ => "Its unstaged changes are lost.",
|
||||
}
|
||||
}
|
||||
|
||||
/// HEAD's name: its branch, or its commit's short id while detached.
|
||||
fn head(repo: &Repo) -> (IconName, String) {
|
||||
match (&repo.branch, &repo.commit) {
|
||||
(Some(branch), _) => (IconName::GitBranch, branch.clone()),
|
||||
(None, Some(commit)) => (
|
||||
IconName::GitCommitHorizontal,
|
||||
commit.chars().take(7).collect(),
|
||||
),
|
||||
(None, None) => (IconName::GitCommitHorizontal, "HEAD".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a diff from `section` shows, as the changes list names it.
|
||||
fn compared(section: ChangeSection) -> &'static str {
|
||||
match section {
|
||||
ChangeSection::Conflicted => "Conflicts",
|
||||
ChangeSection::Staged => "Staged changes",
|
||||
ChangeSection::Unstaged => "Changes",
|
||||
ChangeSection::Untracked => "Untracked",
|
||||
ChangeSection::Ignored => "Ignored",
|
||||
}
|
||||
}
|
||||
|
||||
/// A small heading, as the changes list draws its own.
|
||||
fn heading(text: &'static str, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(text)
|
||||
}
|
||||
|
||||
impl GitPanel {
|
||||
/// The open diff over the window, below the title bar.
|
||||
fn diff_dialog(&self, cx: &Context<Self>) -> Option<Dialog> {
|
||||
let diff = self.diff.as_ref()?;
|
||||
let path = shown(&diff.change.path);
|
||||
let (close, pick) = (cx.entity(), cx.entity());
|
||||
let dialog = Dialog::new("git-diff", compared(diff.section), move |_, cx| {
|
||||
close.update(cx, |panel, cx| {
|
||||
log::debug!("git panel: the diff closes");
|
||||
panel.diff = None;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.fullscreen();
|
||||
Some(match &diff.lines {
|
||||
Ok(lines) => dialog.child(
|
||||
DiffViewer::from_stretches("git-diff-lines", path, lines.clone())
|
||||
.layout(self.layout)
|
||||
.on_layout(move |layout, _, cx| {
|
||||
pick.update(cx, |panel, cx| {
|
||||
log::debug!("git panel: the diff lays out {layout:?}");
|
||||
panel.layout = layout;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
Err(note) => dialog
|
||||
.child(div().font_weight(FontWeight::MEDIUM).child(path))
|
||||
.child(Caption::new(*note)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The question before a discard, which cannot be taken back.
|
||||
fn discard_dialog(&self, cx: &Context<Self>) -> Option<ConfirmDialog> {
|
||||
let (change, section) = self.discarding.clone()?;
|
||||
let (close, confirm) = (cx.entity(), cx.entity());
|
||||
let title = format!("Discard {}?", shown(&change.path));
|
||||
let dialog = ConfirmDialog::new("git-discard", title, loss(&change, section), {
|
||||
move |_, cx| {
|
||||
close.update(cx, |panel, cx| {
|
||||
panel.discarding = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
});
|
||||
let confirmed = move |_: &mut Window, cx: &mut App| {
|
||||
let change = change.clone();
|
||||
confirm.update(cx, |panel, cx| panel.discard(change, section, cx))
|
||||
};
|
||||
Some(
|
||||
dialog
|
||||
.confirm("Discard")
|
||||
.destructive()
|
||||
.on_confirm(confirmed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for GitPanel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let root = div().size_full().flex().flex_col().gap_2();
|
||||
let (repo, commits) = match &self.state {
|
||||
State::Reading => return root,
|
||||
State::Outside => return root.child(Caption::new("Not a Git repository.").px_2()),
|
||||
State::Failed(error) => {
|
||||
let failed = Alert::new("git-failed", Severity::Danger, "Status failed");
|
||||
return root.child(failed.body(error.clone()));
|
||||
}
|
||||
State::Read(repo, commits) => (repo, commits.clone()),
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let (icon, name) = head(repo);
|
||||
let branch = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(Icon::new(icon).color(theme.colors.fg_muted))
|
||||
.child(div().min_w_0().child(Ellipsis::new(name.clone())));
|
||||
let error = self.error.as_ref().map(|(what, said)| {
|
||||
let dismiss = cx.entity();
|
||||
Alert::new("git-error", Severity::Danger, *what)
|
||||
.body(said.clone())
|
||||
.on_dismiss(move |_, cx| {
|
||||
dismiss.update(cx, |panel, cx| {
|
||||
panel.error = None;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
});
|
||||
let (act, all, flip, commit) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
|
||||
let selected = (self.diff.as_ref()).map(|diff| (diff.section, shown(&diff.change.path)));
|
||||
let changes = ChangesList::new("git-changes", rows(&repo.staged), rows(&repo.unstaged))
|
||||
.conflicted(rows(&repo.conflicted))
|
||||
.untracked(rows(&repo.untracked))
|
||||
.when_some(selected, |list, (section, path)| {
|
||||
list.selected(section, path)
|
||||
})
|
||||
.on_action(move |row, section, action, _, cx| {
|
||||
act.update(cx, |panel, cx| panel.row_action(row, section, action, cx))
|
||||
})
|
||||
.on_all(move |stage, _, cx| all.update(cx, |panel, cx| panel.all(stage, cx)));
|
||||
let staged = repo.staged.len();
|
||||
let message = CommitInput::new("git-commit", &self.subject, &self.body, name, staged)
|
||||
.amend(self.amend)
|
||||
.on_amend(move |on, _, cx| {
|
||||
flip.update(cx, |panel, cx| {
|
||||
log::debug!("git panel: amend {on}");
|
||||
panel.amend = on;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.on_commit(move |_, cx| commit.update(cx, |panel, cx| panel.commit(cx)));
|
||||
let recent = (!commits.is_empty()).then(|| {
|
||||
let list = CommitList::new("git-commits", commits);
|
||||
div().flex_1().min_h_0().child(list)
|
||||
});
|
||||
root.child(branch)
|
||||
.children(error)
|
||||
.child(
|
||||
div()
|
||||
.id("git-changes-scroll")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.children(cut(repo).map(|cut| Caption::new(cut).px_2()))
|
||||
.child(changes),
|
||||
)
|
||||
.child(message)
|
||||
.children(recent.is_some().then(|| heading("Commits", cx)))
|
||||
.children(recent)
|
||||
.children(self.diff_dialog(cx))
|
||||
.children(self.discard_dialog(cx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_shown_path_is_one_line_and_one_to_a_path() {
|
||||
let show = |path: &str| shown(Path::new(path)).to_string();
|
||||
assert_eq!(show("src/main.rs"), "src/main.rs");
|
||||
assert_eq!(
|
||||
show("notes/"),
|
||||
"notes",
|
||||
"the list names a folder by its name"
|
||||
);
|
||||
assert_eq!(show("café/ü.txt"), "café/ü.txt");
|
||||
assert_eq!(show("line\nbreak"), "\"line\\nbreak\"");
|
||||
assert_eq!(show("a\"b\\c\td"), "\"a\\\"b\\\\c\\td\"");
|
||||
assert_eq!(show("bell\u{7}"), "\"bell\\007\"");
|
||||
assert_ne!(
|
||||
show("\"line\\nbreak\""),
|
||||
show("line\nbreak"),
|
||||
"a name that spells a quoting reads apart from the name quoted"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
|
||||
let raw = Path::new(OsStr::from_bytes(b"bad\xff.txt"));
|
||||
assert_eq!(shown(raw).as_ref(), "\"bad\\377.txt\"");
|
||||
assert_ne!(shown(raw), shown(Path::new("bad\u{FFFD}.txt")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_discard_says_what_it_costs() {
|
||||
let change = |path: &str, status| Change {
|
||||
path: path.into(),
|
||||
from: None,
|
||||
status,
|
||||
};
|
||||
let untracked = ChangeSection::Untracked;
|
||||
let folder = change("notes/", GitStatus::Untracked);
|
||||
assert_eq!(loss(&folder, untracked), "Its untracked files are deleted.");
|
||||
assert_eq!(
|
||||
loss(&change("a.txt", GitStatus::Untracked), untracked),
|
||||
"It is deleted."
|
||||
);
|
||||
let intended = change("a.txt", GitStatus::Added);
|
||||
assert_eq!(loss(&intended, ChangeSection::Unstaged), "It is deleted.");
|
||||
let edited = change("a.txt", GitStatus::Modified);
|
||||
let lost = "Its unstaged changes are lost.";
|
||||
assert_eq!(loss(&edited, ChangeSection::Unstaged), lost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_section_lists_its_first_files_and_says_so() {
|
||||
let changes = |count: usize| -> Vec<Change> {
|
||||
let change = |n| Change {
|
||||
path: format!("f{n}.txt").into(),
|
||||
from: None,
|
||||
status: GitStatus::Modified,
|
||||
};
|
||||
(0..count).map(change).collect()
|
||||
};
|
||||
let mut repo = Repo {
|
||||
top: PathBuf::from("/r"),
|
||||
branch: None,
|
||||
commit: None,
|
||||
conflicted: Vec::new(),
|
||||
staged: changes(2),
|
||||
unstaged: changes(LISTED),
|
||||
untracked: Vec::new(),
|
||||
};
|
||||
assert_eq!(cut(&repo), None);
|
||||
repo.unstaged = changes(LISTED + 100);
|
||||
assert_eq!(rows(&repo.unstaged).len(), LISTED);
|
||||
let said = format!("{} of {} changes listed.", LISTED + 2, LISTED + 102);
|
||||
assert_eq!(cut(&repo), Some(said));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_reads_as_its_branch_or_its_commit() {
|
||||
let repo = |branch: Option<&str>| Repo {
|
||||
top: PathBuf::from("/r"),
|
||||
branch: branch.map(Into::into),
|
||||
commit: Some("1a2b3c4d5e6f".into()),
|
||||
conflicted: Vec::new(),
|
||||
staged: Vec::new(),
|
||||
unstaged: Vec::new(),
|
||||
untracked: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
head(&repo(Some("main"))),
|
||||
(IconName::GitBranch, "main".into())
|
||||
);
|
||||
let detached = (IconName::GitCommitHorizontal, "1a2b3c4".into());
|
||||
assert_eq!(head(&repo(None)), detached);
|
||||
}
|
||||
}
|
||||
+75
-4
@@ -1,5 +1,6 @@
|
||||
use std::{env, fs};
|
||||
|
||||
use ely_gpui_component::git::{LineKind, Stretch};
|
||||
use gpui::SharedString;
|
||||
|
||||
use super::*;
|
||||
@@ -257,7 +258,7 @@ fn files_stage_and_unstage_by_their_literal_paths() {
|
||||
assert_eq!(state.untracked.len(), 2);
|
||||
|
||||
stage(&root, &state.untracked).expect("a stage");
|
||||
commit(&root, "first").expect("a commit");
|
||||
commit(&root, "first", false).expect("a commit");
|
||||
prepare(&root, &["mv", "star1.txt", "moved.txt"]);
|
||||
unstage(&root, &read(&root).staged).expect("an unstaged rename");
|
||||
let state = read(&root);
|
||||
@@ -290,10 +291,10 @@ fn a_commit_takes_the_staged_files_and_heads_the_log() {
|
||||
let root = repository("git-commit");
|
||||
write(&root, "a.txt", "a\n");
|
||||
stage(&root, &[change("a.txt", GitStatus::Untracked)]).expect("a stage");
|
||||
commit(&root, "first subject\n\nIts body.").expect("a commit");
|
||||
commit(&root, "first subject\n\nIts body.", false).expect("a commit");
|
||||
write(&root, "b.txt", "b\n");
|
||||
stage(&root, &[change("b.txt", GitStatus::Untracked)]).expect("a stage");
|
||||
commit(&root, "second").expect("a commit");
|
||||
commit(&root, "second", false).expect("a commit");
|
||||
|
||||
let commits = log(&root, 10).expect("a log");
|
||||
let [newest, first] = commits.as_slice() else {
|
||||
@@ -309,7 +310,7 @@ fn a_commit_takes_the_staged_files_and_heads_the_log() {
|
||||
assert_eq!(newest.refs, [SharedString::from("HEAD -> main")]);
|
||||
assert!(first.refs.is_empty());
|
||||
assert_eq!(log(&root, 1).expect("a log").len(), 1);
|
||||
let error = commit(&root, "nothing").expect_err("nothing is staged");
|
||||
let error = commit(&root, "nothing", false).expect_err("nothing is staged");
|
||||
assert!(format!("{error:#}").contains("nothing"), "{error:#}");
|
||||
}
|
||||
|
||||
@@ -373,4 +374,74 @@ fn names_that_show_alike_stay_apart() {
|
||||
assert_eq!(read(&root).staged, left, "the other name stays staged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_amend_rewrites_the_last_commit() {
|
||||
let root = repository("git-amend");
|
||||
assert!(
|
||||
commit(&root, "nothing yet", true).is_err(),
|
||||
"no commit to amend"
|
||||
);
|
||||
write(&root, "a.txt", "a\n");
|
||||
stage(&root, &[change("a.txt", GitStatus::Untracked)]).expect("a stage");
|
||||
commit(&root, "first", false).expect("a commit");
|
||||
commit(&root, "first, reworded", true).expect("an amend with nothing staged");
|
||||
let commits = log(&root, 10).expect("a log");
|
||||
let subjects: Vec<&str> = commits
|
||||
.iter()
|
||||
.map(|commit| commit.subject.as_ref())
|
||||
.collect();
|
||||
assert_eq!(subjects, ["first, reworded"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_discard_restores_edits_and_deletes_new_files() {
|
||||
let root = repository("git-discard");
|
||||
for path in ["edited.txt", "both.txt", "gone.txt"] {
|
||||
write(&root, path, "base\n");
|
||||
}
|
||||
prepare(&root, &["add", "."]);
|
||||
prepare(&root, &["commit", "-qm", "base"]);
|
||||
write(&root, "edited.txt", "edit\n");
|
||||
write(&root, "both.txt", "staged\n");
|
||||
prepare(&root, &["add", "both.txt"]);
|
||||
write(&root, "both.txt", "staged, then edited\n");
|
||||
fs::remove_file(root.join("gone.txt")).expect("a removal");
|
||||
write(&root, "intended.txt", "intended\n");
|
||||
prepare(&root, &["add", "-N", "intended.txt"]);
|
||||
write(&root, "loose.txt", "loose\n");
|
||||
write(&root, "notes/todo.txt", "todo\n");
|
||||
write(&root, "nested/inner.txt", "inner\n");
|
||||
prepare(&root.join("nested"), &["init", "-q"]);
|
||||
|
||||
let state = read(&root);
|
||||
for change in &state.unstaged {
|
||||
discard(&root, change, ChangeSection::Unstaged).expect("a discarded edit");
|
||||
}
|
||||
let (nested, loose): (Vec<_>, Vec<_>) =
|
||||
(state.untracked.iter()).partition(|change| change.path == Path::new("nested/"));
|
||||
for change in loose {
|
||||
discard(&root, change, ChangeSection::Untracked).expect("a deleted file");
|
||||
}
|
||||
let error = discard(&root, nested[0], ChangeSection::Untracked).expect_err("git skips it");
|
||||
assert!(
|
||||
format!("{error:#}").contains("removed nothing"),
|
||||
"{error:#}"
|
||||
);
|
||||
let staged = read(&root).staged;
|
||||
let kept = discard(&root, &staged[0], ChangeSection::Staged);
|
||||
assert!(kept.is_err(), "a staged change is not discarded");
|
||||
|
||||
let text = |path| fs::read_to_string(root.join(path)).expect("a file");
|
||||
assert_eq!(text("edited.txt"), "base\n");
|
||||
assert_eq!(text("both.txt"), "staged\n", "the staged part stays");
|
||||
assert_eq!(text("gone.txt"), "base\n", "a deletion is undone");
|
||||
for path in ["intended.txt", "loose.txt", "notes"] {
|
||||
assert!(!root.join(path).exists(), "{path} goes");
|
||||
}
|
||||
let state = read(&root);
|
||||
assert_eq!(state.staged, [change("both.txt", GitStatus::Modified)]);
|
||||
assert!(state.unstaged.is_empty(), "{:?}", state.unstaged);
|
||||
assert_eq!(state.untracked, [change("nested/", GitStatus::Untracked)]);
|
||||
}
|
||||
|
||||
mod diffs;
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use ely_gpui_component::git::DiffLine;
|
||||
|
||||
use super::*;
|
||||
use crate::git::diff::hunks;
|
||||
|
||||
fn line(kind: LineKind, at: (Option<usize>, Option<usize>), text: &str) -> DiffLine {
|
||||
lined(kind, at, text, Vec::new())
|
||||
|
||||
+26
-10
@@ -9,11 +9,12 @@ use ely_gpui_component::{
|
||||
use gpui::{
|
||||
Action, App, AppContext, Context, Entity, EntityId, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement,
|
||||
Styled, Subscription, Window, actions, div, prelude::FluentBuilder,
|
||||
StyleRefinement, Styled, Subscription, Window, actions, div, prelude::FluentBuilder,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
files::Files,
|
||||
git::panel::{Acted, GitPanel},
|
||||
sessions::{self, Session},
|
||||
settings::{PanelTab, Settings, SettingsStore, StartIn},
|
||||
term::{
|
||||
@@ -82,7 +83,9 @@ pub struct Workspace {
|
||||
/// The session shown.
|
||||
active: usize,
|
||||
files: Entity<Files>,
|
||||
git: Entity<GitPanel>,
|
||||
_settings: Subscription,
|
||||
_acted: Subscription,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
@@ -94,12 +97,20 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let git = cx.new(|cx| GitPanel::new(launch.cwd.clone(), window, cx));
|
||||
// A change the Git panel made shows in Files' marks too.
|
||||
let acted = cx.subscribe(&git, |this, _, _: &Acted, cx| {
|
||||
let folder = this.sessions[this.active].folder.clone();
|
||||
this.files.update(cx, |files, cx| files.show(&folder, cx));
|
||||
});
|
||||
let mut workspace = Self {
|
||||
focus: cx.focus_handle(),
|
||||
sessions: Vec::new(),
|
||||
active: 0,
|
||||
files: cx.new(|_| Files::new(launch.cwd.clone())),
|
||||
git,
|
||||
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
||||
_acted: acted,
|
||||
};
|
||||
workspace.add(pty, events, launch, window, cx);
|
||||
workspace.select(0, window, cx);
|
||||
@@ -171,6 +182,7 @@ impl Workspace {
|
||||
window.focus(&session.terminal.focus_handle(cx), cx);
|
||||
let folder = session.folder.clone();
|
||||
self.files.update(cx, |files, cx| files.show(&folder, cx));
|
||||
self.git.update(cx, |git, cx| git.show(&folder, cx));
|
||||
self.active = index;
|
||||
log::debug!("app: session {} shown", index + 1);
|
||||
self.retitle(window);
|
||||
@@ -237,6 +249,7 @@ impl Workspace {
|
||||
session.reports = true;
|
||||
if index == self.active {
|
||||
self.files.update(cx, |files, cx| files.show(folder, cx));
|
||||
self.git.update(cx, |git, cx| git.show(folder, cx));
|
||||
}
|
||||
}
|
||||
TerminalEvent::Bell if index != self.active => {
|
||||
@@ -360,20 +373,23 @@ impl Render for Workspace {
|
||||
.get(self.active)
|
||||
.is_some_and(|session| !session.reports);
|
||||
let caption = || Caption::new("This shell reports no folder.").px_3().pt_2();
|
||||
// Hidden, not dropped, so each keeps its state.
|
||||
let body = |on: bool| {
|
||||
div()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.p_2()
|
||||
.when(!on, |body| body.hidden())
|
||||
};
|
||||
// Git's list draws every row, so terminal output must not redraw it.
|
||||
let fill = || StyleRefinement::default().size_full();
|
||||
shell = shell.column(
|
||||
ShellColumn::new("panel")
|
||||
.width(width)
|
||||
.leading(switch)
|
||||
.children(quiet.then(caption))
|
||||
// Hidden, not dropped, so the tree keeps its open folders.
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.p_2()
|
||||
.when(tab != PanelTab::Files, |body| body.hidden())
|
||||
.child(self.files.clone()),
|
||||
),
|
||||
.child(body(tab == PanelTab::Git).child(self.git.clone().cached(fill())))
|
||||
.child(body(tab == PanelTab::Files).child(self.files.clone())),
|
||||
);
|
||||
}
|
||||
// The context sits above the focus root, so its keys work wherever focus is.
|
||||
|
||||
Reference in new issue
Block a user