Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+363
View File
@@ -0,0 +1,363 @@
//! The window's dialogs: clone, discard, failures, identity.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Checkbox, Input, TextInput},
overlays::{AlertDialog, ConfirmDialog, Dialog, PromptDialog},
primitives::Severity,
};
use gpui::{
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _,
Window, div,
};
use crate::{
status::{Entry, State},
tree::{Tree, text},
work::Write,
};
/// What a dialog asks.
pub enum Ask {
Url(Entity<TextInput>),
Discard {
repo: u64,
entry: Entry,
},
/// A failure with Git's whole account, such as a hook's.
Failed {
title: SharedString,
detail: SharedString,
},
/// A command that waits for a yes, this once.
Confirm {
repo: u64,
title: SharedString,
message: SharedString,
go: SharedString,
write: Write,
},
NewBranch {
/// The repository it was opened for.
repo: u64,
name: Entity<TextInput>,
at: Option<String>,
switch: bool,
},
NewTag {
/// The repository it was opened for.
repo: u64,
name: Entity<TextInput>,
message: Entity<TextInput>,
at: Option<String>,
},
Rename {
/// The repository it was opened for.
repo: u64,
old: String,
name: Entity<TextInput>,
},
Identity {
/// The repository it was opened for.
repo: u64,
name: Entity<TextInput>,
email: Entity<TextInput>,
global: bool,
},
Stash {
/// The repository it was opened for.
repo: u64,
message: Entity<TextInput>,
untracked: bool,
},
/// A new remote, or an old one's name and URL.
Remote {
/// The repository it was opened for.
repo: u64,
old: Option<(String, String)>,
name: Entity<TextInput>,
url: Entity<TextInput>,
},
Worktree {
/// The repository it was opened for.
repo: u64,
path: std::path::PathBuf,
branch: Entity<TextInput>,
new: bool,
},
/// A forced push, with what it would overwrite.
Force {
repo: u64,
plan: crate::remotes::Force,
},
/// One of several names, each a button.
Choose {
/// The repository it was opened for.
repo: u64,
title: SharedString,
choices: Vec<String>,
then: Choice,
},
/// What a clean would remove, read before the yes.
Clean {
repo: u64,
ignored: bool,
preview: Option<Result<crate::recover::Clean, String>>,
},
/// HEAD's branch to a reflog commit, previewed.
Move {
repo: u64,
plan: crate::recover::Move,
},
/// Another Git holds a lock; retry, never delete it.
Locked {
repo: u64,
write: Write,
detail: SharedString,
},
}
/// What a chosen name is for.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Choice {
/// Push this branch there and track it.
Publish(String),
/// Track that remote branch from this one.
Upstream(String),
}
impl Tree {
pub fn dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let this = cx.weak_entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |tree, cx| {
tree.ask = None;
cx.notify();
})
.ok();
}
};
match self.ask.as_ref()? {
Ask::Url(field) => {
let empty = text(cx, "tree.clone.empty", &[]);
let prompt = PromptDialog::new(
"tree-clone-url",
text(cx, "tree.clone.title", &[]),
field,
dismiss,
)
.label(text(cx, "tree.clone.url", &[]))
.submit(text(cx, "tree.clone.next", &[]))
.check(move |url| {
if url.trim().is_empty() {
Err(empty.clone())
} else {
Ok(())
}
})
.on_submit(move |url, _, cx| {
let url = url.trim().to_string();
this.update(cx, |tree, cx| tree.pick_clone_folder(url, cx))
.ok();
});
Some(prompt.into_any_element())
}
Ask::Discard { repo, entry } => {
let key = if entry.state == State::Untracked {
"tree.discard.untracked"
} else {
"tree.discard.tracked"
};
let message = text(cx, key, &[("path", &entry.path)]);
let (repo, write) = (*repo, Write::Discard(entry.clone()));
let confirm = ConfirmDialog::new(
"tree-discard",
text(cx, "tree.discard.title", &[("path", &entry.path)]),
message,
dismiss,
)
.confirm(text(cx, "tree.discard.go", &[]))
.destructive()
.on_confirm(move |_, cx| {
this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx))
.ok();
});
Some(confirm.into_any_element())
}
Ask::Failed { title, detail } => Some(
AlertDialog::new(
"tree-failed",
Severity::Danger,
title.clone(),
detail.clone(),
dismiss,
)
.label(text(cx, "tree.ok", &[]))
.into_any_element(),
),
Ask::Confirm {
repo,
title,
message,
go,
write,
} => {
let (repo, write) = (*repo, write.clone());
let confirm =
ConfirmDialog::new("tree-confirm", title.clone(), message.clone(), dismiss)
.confirm(go.clone())
.destructive()
.on_confirm(move |_, cx| {
this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx))
.ok();
});
Some(confirm.into_any_element())
}
Ask::NewBranch { name, switch, .. } => {
let label = |key: &str| text(cx, key, &[]);
let rows = DescriptionList::new().item(
label("tree.branch.name"),
div().w_full().child(Input::new(name)),
);
let toggle = {
let this = this.clone();
Checkbox::new("tree-branch-switch", *switch)
.label(label("tree.branch.switch"))
.on_change(move |on, _, cx| {
this.update(cx, |tree, cx| {
if let Some(Ask::NewBranch { switch, .. }) = &mut tree.ask {
*switch = on;
cx.notify();
}
})
.ok();
})
};
let (title, go) = (label("tree.branch.title"), label("tree.branch.create"));
Some(self.form(
"tree-new-branch",
title,
go,
rows,
Some(toggle.into_any_element()),
dismiss,
cx,
))
}
Ask::NewTag { name, message, .. } => {
let label = |key: &str| text(cx, key, &[]);
let rows = DescriptionList::new()
.item(
label("tree.tag.name"),
div().w_full().child(Input::new(name)),
)
.item(
label("tree.tag.message"),
div().w_full().child(Input::new(message)),
);
let (title, go) = (label("tree.tag.title"), label("tree.tag.create"));
Some(self.form("tree-new-tag", title, go, rows, None, dismiss, cx))
}
Ask::Rename { old, name, .. } => {
let label = |key: &str| text(cx, key, &[]);
let rows = DescriptionList::new().item(
label("tree.branch.name"),
div().w_full().child(Input::new(name)),
);
let (title, go) = (
text(cx, "tree.rename.title", &[("name", old)]),
label("tree.rename.go"),
);
Some(self.form("tree-rename", title, go, rows, None, dismiss, cx))
}
Ask::Stash { .. }
| Ask::Remote { .. }
| Ask::Worktree { .. }
| Ask::Force { .. }
| Ask::Choose { .. } => Some(self.place_dialog(dismiss, cx)?),
Ask::Clean { .. } | Ask::Move { .. } | Ask::Locked { .. } => {
Some(self.recover_dialog(dismiss, cx)?)
}
Ask::Identity {
name,
email,
global,
..
} => {
let label = |key: &str| text(cx, key, &[]);
let rows = DescriptionList::new()
.item(
label("tree.identity.name"),
div().w_full().child(Input::new(name)),
)
.item(
label("tree.identity.email"),
div().w_full().child(Input::new(email)),
);
let scope = {
let this = this.clone();
Checkbox::new("tree-identity-global", *global)
.label(label("tree.identity.global"))
.on_change(move |on, _, cx| {
this.update(cx, |tree, cx| {
if let Some(Ask::Identity { global, .. }) = &mut tree.ask {
*global = on;
cx.notify();
}
})
.ok();
})
};
let (save, cancel) = (label("tree.identity.save"), label("tree.cancel"));
let card = Dialog::new("tree-identity", label("tree.identity.title"), dismiss)
.child(rows)
.child(scope)
.action(move |close| {
Button::new("tree-identity-cancel", cancel)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |_| {
Button::new("tree-identity-save", save)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.set_identity(cx)).ok();
})
});
Some(card.into_any_element())
}
}
}
/// A dialog of fields whose go button answers it.
pub(crate) fn form(
&self,
id: &'static str,
title: gpui::SharedString,
go: gpui::SharedString,
rows: DescriptionList,
extra: Option<AnyElement>,
dismiss: impl Fn(&mut Window, &mut App) + 'static,
cx: &mut Context<Self>,
) -> AnyElement {
let this = cx.weak_entity();
let cancel = text(cx, "tree.cancel", &[]);
Dialog::new(id, title, dismiss)
.child(rows)
.children(extra)
.action(move |close| {
Button::new("tree-form-cancel", cancel)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |_| {
Button::new("tree-form-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.answer(cx)).ok();
})
})
.into_any_element()
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Dialogs for stashes, remotes, worktrees and pushes.
use ely_gpui_component::{
buttons::Button,
data_display::DescriptionList,
forms::{Checkbox, Input},
overlays::{ConfirmDialog, Dialog},
};
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Window, div};
use crate::{
asks::{Ask, Choice},
tree::{Tree, text},
work::Write,
};
impl Tree {
pub fn place_dialog(
&self,
dismiss: impl Fn(&mut Window, &mut App) + Clone + 'static,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let this = cx.weak_entity();
let label = |key: &str| text(cx, key, &[]);
let tick = |id: &'static str, on: bool, key: &str, set: fn(&mut Ask, bool)| {
let this = this.clone();
Checkbox::new(id, on)
.label(text(cx, key, &[]))
.on_change(move |on, _, cx| {
this.update(cx, |tree, cx| {
if let Some(ask) = &mut tree.ask {
set(ask, on);
cx.notify();
}
})
.ok();
})
.into_any_element()
};
Some(match self.ask.as_ref()? {
Ask::Stash {
message, untracked, ..
} => {
let rows = DescriptionList::new().item(
label("tree.stash.message"),
div().w_full().child(Input::new(message)),
);
let extra = tick(
"tree-stash-untracked",
*untracked,
"tree.stash.untracked",
|ask, on| {
if let Ask::Stash { untracked, .. } = ask {
*untracked = on;
}
},
);
let (title, go) = (label("tree.stash.title"), label("tree.stash.go"));
self.form("tree-stash", title, go, rows, Some(extra), dismiss, cx)
}
Ask::Remote { old, name, url, .. } => {
let rows = DescriptionList::new()
.item(
label("tree.remote.name"),
div().w_full().child(Input::new(name)),
)
.item(
label("tree.remote.url"),
div().w_full().child(Input::new(url)),
);
let title = match old {
Some((was, _)) => text(cx, "tree.remote.edit", &[("name", was)]),
None => label("tree.remote.add"),
};
let go = label("tree.remote.save");
self.form("tree-remote", title, go, rows, None, dismiss, cx)
}
Ask::Worktree {
path, branch, new, ..
} => {
let rows = DescriptionList::new().item(
label("tree.worktree.branch"),
div().w_full().child(Input::new(branch)),
);
let extra = tick("tree-worktree-new", *new, "tree.worktree.new", |ask, on| {
if let Ask::Worktree { new, .. } = ask {
*new = on;
}
});
let shown = path.display().to_string();
let title = text(cx, "tree.worktree.title", &[("path", &shown)]);
let go = label("tree.worktree.go");
self.form("tree-worktree", title, go, rows, Some(extra), dismiss, cx)
}
Ask::Force { repo, plan } => {
let lost = match plan.lost.len() {
0 => label("tree.force.nothing"),
n => text(
cx,
"tree.force.lost",
&[("n", &n.to_string()), ("commits", &plan.lost.join("\n"))],
),
};
let message = text(
cx,
"tree.force.message",
&[
("branch", &plan.branch),
("remote", &plan.remote),
("target", &plan.target),
("lost", &lost),
],
);
let (repo, write) = (*repo, Write::Remote(plan.args()));
ConfirmDialog::new("tree-force", label("tree.force.title"), message, dismiss)
.confirm(label("tree.force.go"))
.destructive()
.on_confirm(move |_, cx| {
this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx))
.ok();
})
.into_any_element()
}
Ask::Choose {
repo,
title,
choices,
then,
} => {
let buttons = choices.iter().enumerate().map(|(at, choice)| {
let (this, then, choice, repo) =
(this.clone(), then.clone(), choice.clone(), *repo);
Button::new(("tree-choice", at), choice.clone()).on_click(move |_, _, cx| {
this.update(cx, |tree, cx| {
tree.ask = None;
match &then {
Choice::Publish(branch) => tree.publish(repo, branch, &choice, cx),
Choice::Upstream(branch) => {
tree.set_upstream(repo, branch, &choice, cx)
}
}
cx.notify();
})
.ok();
})
});
let cancel = label("tree.cancel");
Dialog::new("tree-choose", title.clone(), dismiss)
.child(div().flex().flex_col().gap_2().children(buttons))
.action(move |close| {
Button::new("tree-choose-cancel", cancel)
.on_click(move |_, window, cx| close(window, cx))
})
.into_any_element()
}
_ => return None,
})
}
}
+87
View File
@@ -0,0 +1,87 @@
//! Who last changed each line, from `blame --porcelain` (TREE-06).
use std::{collections::HashMap, io, path::Path};
use crate::git::Git;
/// A commit as blame names it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Owner {
pub commit: String,
pub author: String,
pub at: i64,
pub subject: String,
}
impl Owner {
/// Lines not committed yet carry an all-zero id.
pub fn uncommitted(&self) -> bool {
self.commit.bytes().all(|b| b == b'0')
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Blamed {
pub code: String,
/// Each line's owner, as a place in `owners`.
pub lines: Vec<usize>,
pub owners: Vec<Owner>,
}
fn bad(line: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread blame line {line:?}"),
)
}
pub fn parse(bytes: &[u8]) -> io::Result<Blamed> {
let text = std::str::from_utf8(bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "the file is not UTF-8 text"))?;
let mut blamed = Blamed {
code: String::new(),
lines: Vec::new(),
owners: Vec::new(),
};
let mut known: HashMap<String, usize> = HashMap::new();
let mut current: Option<usize> = None;
for line in text.split('\n').filter(|line| !line.is_empty()) {
if let Some(code) = line.strip_prefix('\t') {
blamed.lines.push(current.ok_or_else(|| bad(line))?);
blamed.code.push_str(code);
blamed.code.push('\n');
continue;
}
let (key, value) = line.split_once(' ').unwrap_or((line, ""));
let header = key.len() >= 40 && key.bytes().all(|b| b.is_ascii_hexdigit());
let owner = current.map(|at| &mut blamed.owners[at]);
match (header, key, owner) {
(true, _, _) => {
let next = blamed.owners.len();
let at = *known.entry(key.to_string()).or_insert(next);
if at == next {
blamed.owners.push(Owner {
commit: key.into(),
author: String::new(),
at: 0,
subject: String::new(),
});
}
current = Some(at);
}
(false, "author", Some(owner)) => owner.author = value.to_string(),
(false, "author-time", Some(owner)) => {
owner.at = value.parse().map_err(|_| bad(line))?
}
(false, "summary", Some(owner)) => owner.subject = value.to_string(),
// Mail, committer, filename and the rest are not shown.
_ => {}
}
}
Ok(blamed)
}
/// The work tree's file, line by line.
pub fn read(git: &Git, root: &Path, path: &str) -> io::Result<Blamed> {
parse(&git.run(root, &["blame", "--porcelain", "--", path])?)
}
+125
View File
@@ -0,0 +1,125 @@
//! Branches and tags as `for-each-ref` lists them (TREE-07).
use std::{io, path::Path};
use crate::git::Git;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Ref {
/// `main`, `origin/main`, `v1`.
pub name: String,
/// `refs/heads/main`: names a branch and a tag apart.
pub full: String,
pub kind: Kind,
pub current: bool,
pub upstream: Option<String>,
/// Ahead and behind; None without a live upstream.
pub track: Option<(usize, usize)>,
/// The commit it names, through an annotated tag.
pub commit: String,
pub subject: String,
pub at: i64,
/// An annotated tag's own message.
pub message: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Local,
Remote,
Tag,
}
const FORMAT: &str = "--format=%(refname)%00%(HEAD)%00%(upstream:short)%00%(upstream:track,nobracket)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(contents:subject)%00%(*subject)%00%(creatordate:unix)%1e";
fn bad(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread ref {what:?}"),
)
}
/// `ahead 2, behind 1` as (2, 1); `gone` as none.
fn track(words: &str) -> Option<(usize, usize)> {
if words == "gone" {
return None;
}
let mut counts = (0, 0);
for part in words.split(", ").filter(|part| !part.is_empty()) {
match part.split_once(' ')? {
("ahead", n) => counts.0 = n.parse().ok()?,
("behind", n) => counts.1 = n.parse().ok()?,
_ => return None,
}
}
Some(counts)
}
pub fn parse(bytes: &[u8]) -> io::Result<Vec<Ref>> {
let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?;
let mut refs = Vec::new();
for record in text
.split('\u{1e}')
.map(|record| record.trim_start_matches('\n'))
.filter(|record| !record.is_empty())
{
let fields: Vec<&str> = record.split('\0').collect();
let [
full,
head,
upstream,
tracking,
object_kind,
object,
peeled,
subject,
peeled_subject,
at,
] = fields[..]
else {
return Err(bad(record));
};
// Names come off the full ref; short ones clash.
let (kind, name) = if let Some(name) = full.strip_prefix("refs/heads/") {
(Kind::Local, name)
} else if let Some(name) = full
.strip_prefix("refs/remotes/")
.filter(|name| !name.ends_with("/HEAD"))
{
(Kind::Remote, name)
} else if let Some(name) = full.strip_prefix("refs/tags/") {
(Kind::Tag, name)
} else {
// `origin/HEAD` points at a branch; it is not one.
continue;
};
let annotated = object_kind == "tag";
refs.push(Ref {
name: name.to_string(),
full: full.to_string(),
kind,
current: head == "*",
upstream: (!upstream.is_empty()).then(|| upstream.to_string()),
track: (!upstream.is_empty()).then(|| track(tracking)).flatten(),
commit: if annotated { peeled } else { object }.to_string(),
subject: if annotated { peeled_subject } else { subject }.to_string(),
at: at.parse().map_err(|_| bad(record))?,
message: annotated.then(|| subject.to_string()),
});
}
Ok(refs)
}
/// Every branch, remote branch and tag.
pub fn read(git: &Git, root: &Path) -> io::Result<Vec<Ref>> {
parse(&git.run(
root,
&[
"for-each-ref",
FORMAT,
"refs/heads",
"refs/remotes",
"refs/tags",
],
)?)
}
+165
View File
@@ -0,0 +1,165 @@
//! The left panel: the work tree's files, or history.
use ely_gpui_component::{
forms::Choice,
git::{Changed, ChangesList},
lists::GitStatus,
navigation::Tabs,
primitives::IconName,
};
use gpui::{
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render,
StatefulInteractiveElement as _, Styled as _, Subscription, WeakEntity, Window, div,
prelude::FluentBuilder as _,
};
use crate::{
status::{Lines, State, Status},
tree::{Side, Tree, text},
work::Write,
};
/// The left panel; it reads the active repository.
pub struct Changes {
tree: WeakEntity<Tree>,
_follow: Subscription,
}
impl Changes {
pub fn new(tree: &Entity<Tree>, cx: &mut Context<Self>) -> Self {
Changes {
tree: tree.downgrade(),
_follow: cx.observe(tree, |_, _, cx| cx.notify()),
}
}
}
fn badge(letter: char) -> GitStatus {
match letter {
'A' | 'C' => GitStatus::Added,
'D' => GitStatus::Deleted,
'R' => GitStatus::Renamed,
_ => GitStatus::Modified,
}
}
/// The five sections: conflicted, staged, unstaged, untracked, ignored.
pub fn sections(status: &Status) -> [Vec<Changed>; 5] {
let mut sections: [Vec<Changed>; 5] = Default::default();
let file = |path: &str, status, lines: Option<&Lines>| Changed {
path: path.to_string().into(),
status,
lines: lines.and_then(|lines| lines.get(path).copied().flatten()),
};
for entry in &status.entries {
let path = entry.path.as_str();
match &entry.state {
State::Conflicted(_) => sections[0].push(file(path, GitStatus::Conflicted, None)),
State::Tracked { staged, unstaged } => {
if *staged != '.' {
sections[1].push(file(path, badge(*staged), Some(&status.staged)));
}
if *unstaged != '.' {
sections[2].push(file(path, badge(*unstaged), Some(&status.unstaged)));
}
}
State::Untracked => sections[3].push(file(path, GitStatus::Untracked, None)),
State::Ignored => sections[4].push(file(path, GitStatus::Untracked, None)),
}
}
sections
}
impl Render for Changes {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(tree) = self.tree.upgrade() else {
return div().into_any_element();
};
let side = tree.read(cx).side;
let panel = match side {
Side::Changes => changes(&tree, cx),
Side::History => crate::side_log::history(&tree, cx),
Side::Branches => crate::side_refs::branches(&tree, cx),
Side::Places => crate::side_places::places(&tree, cx),
};
let this = tree.downgrade();
let tabs = [
Choice::new("changes", text(cx, "tree.side.changes", &[])).icon(IconName::SquarePen),
Choice::new("history", text(cx, "tree.side.history", &[])).icon(IconName::History),
Choice::new("branches", text(cx, "tree.side.branches", &[])).icon(IconName::GitBranch),
Choice::new("places", text(cx, "tree.side.places", &[])).icon(IconName::FolderOpen),
];
let chosen = match side {
Side::Changes => "changes",
Side::History => "history",
Side::Branches => "branches",
Side::Places => "places",
};
div()
.size_full()
.flex()
.flex_col()
.child(
Tabs::new("tree-side", tabs, chosen).on_change(move |value, _, cx| {
let side = match value.as_ref() {
"history" => Side::History,
"branches" => Side::Branches,
"places" => Side::Places,
_ => Side::Changes,
};
this.update(cx, |tree, cx| tree.set_side(side, cx)).ok();
}),
)
.child(div().flex_1().min_h_0().child(panel))
.into_any_element()
}
}
/// The work tree's files, then the commit box.
fn changes(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
let read = tree.read(cx);
let Some((id, Some(Ok(status)))) = read.active().map(|opened| (opened.id, &opened.status))
else {
return div().into_any_element();
};
let [conflicted, staged, unstaged, untracked, ignored] = sections(status);
let staged_count = staged.len();
let branch = crate::view::branch(&status.head, cx);
let (on_file, on_all) = (tree.downgrade(), tree.downgrade());
let list = ChangesList::new(("tree-changes", id), staged, unstaged)
.conflicted(conflicted)
.untracked(untracked)
.ignored(ignored)
.when_some(
read.shown.as_ref().filter(|shown| shown.repo == id),
|list, shown| list.selected(shown.section, shown.path.clone()),
)
.on_action(move |path, section, action, _, cx| {
on_file
.update(cx, |tree, cx| tree.file_action(path, section, action, cx))
.ok();
})
.on_all(move |stage, _, cx| {
let write = if stage {
Write::StageAll
} else {
Write::UnstageAll
};
on_all.update(cx, |tree, cx| tree.write(id, write, cx)).ok();
});
div()
.size_full()
.flex()
.flex_col()
.child(
div()
.id("tree-changes-scroll")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.p_2()
.child(list),
)
.child(crate::side_log::commit_box(tree, branch, staged_count, cx))
.into_any_element()
}
+153
View File
@@ -0,0 +1,153 @@
//! Cloning with progress and a stop (TREE-01).
use std::{io, path::PathBuf};
use ely_gpui_component::{forms::TextInput, primitives::Severity};
use futures::{
StreamExt as _,
channel::{mpsc, oneshot},
};
use gpui::{AppContext as _, Context, SharedString, Window};
use crate::{
asks::Ask,
running::Stop,
tree::{Tree, text},
};
/// A clone on its way.
pub struct Cloning {
pub into: PathBuf,
pub said: SharedString,
pub stop: Stop,
/// Asked to stop; its failure is then expected.
pub stopping: bool,
}
/// A URL's last part, less `.git`.
pub fn suggested(url: &str) -> Option<&str> {
let last = url.trim_end_matches('/').rsplit(['/', ':', '\\']).next()?;
Some(last.strip_suffix(".git").unwrap_or(last)).filter(|name| !name.is_empty())
}
impl Tree {
pub fn ask_url(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.cloning.is_some() {
let words = text(cx, "tree.clone.busy", &[]);
return self.toast(words, Severity::Warning, cx);
}
self.ask = Some(Ask::Url(cx.new(|cx| TextInput::new(window, cx))));
cx.notify();
}
/// Asks where the clone goes, beside the last repository opened.
pub fn pick_clone_folder(&mut self, url: String, cx: &mut Context<Self>) {
let near = self
.recent
.first()
.and_then(|last| last.parent())
.map(PathBuf::from)
.or_else(std::env::home_dir);
let Some(near) = near else {
return self.failed("tree.error.clone", "no home folder", cx);
};
let picked = cx.prompt_for_new_path(&near, suggested(&url));
cx.spawn(async move |this, cx| {
let picked = picked
.await
.map_err(|error| error.to_string())
.and_then(|picked| picked.map_err(|error| error.to_string()));
this.update(cx, |tree, cx| match picked {
Ok(Some(into)) => tree.start_clone(url, into, cx),
Ok(None) => log::info!("tree: the clone folder panel was cancelled"),
Err(error) => tree.failed("tree.error.panel", &error, cx),
})
.ok();
})
.detach();
}
fn start_clone(&mut self, url: String, into: PathBuf, cx: &mut Context<Self>) {
let Some(git) = self.git(cx) else { return };
let running = match git.clone_repo(&url, &into) {
Ok(running) => running,
Err(error) => return self.failed("tree.error.clone", &error.to_string(), cx),
};
self.cloning = Some(Cloning {
into,
said: SharedString::default(),
stop: running.stop(),
stopping: false,
});
let (said, mut lines) = mpsc::unbounded();
let (done, result) = oneshot::channel();
// Git's progress blocks a thread until the clone ends.
std::thread::spawn(move || {
let followed = running.follow(|line| {
let _ = said.unbounded_send(SharedString::from(line.to_string()));
});
let _ = done.send(followed);
});
cx.spawn(async move |this, cx| {
while let Some(line) = lines.next().await {
let shown = this.update(cx, |tree, cx| {
if let Some(cloning) = &mut tree.cloning {
cloning.said = line;
cx.notify();
}
});
if shown.is_err() {
return;
}
}
let ended = result
.await
.unwrap_or_else(|_| Err(io::Error::other("the clone thread ended without a word")));
this.update(cx, |tree, cx| tree.cloned(ended.map(drop), cx))
.ok();
})
.detach();
cx.notify();
}
fn cloned(&mut self, ended: io::Result<()>, cx: &mut Context<Self>) {
let Some(cloning) = self.cloning.take() else {
return;
};
cx.notify();
match ended {
Ok(()) => self.open_path(cloning.into, cx),
Err(_) if cloning.stopping => {
let words = text(cx, "tree.clone.stopped", &[]);
self.toast(words, Severity::Info, cx);
}
Err(error) => self.failed("tree.error.clone", &error.to_string(), cx),
}
}
/// Stops the clone; Git removes what it began.
pub fn stop_clone(&mut self, cx: &mut Context<Self>) {
let Some(cloning) = &mut self.cloning else {
return;
};
cloning.stopping = true;
if let Err(error) = cloning.stop.now() {
self.failed("tree.error.stop", &error.to_string(), cx);
}
cx.notify();
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_url_suggests_its_last_part() {
assert_eq!(
super::suggested("https://github.com/a/ely.git"),
Some("ely")
);
assert_eq!(super::suggested("git@github.com:a/ely.git/"), Some("ely"));
assert_eq!(super::suggested("/srv/repos/notes"), Some("notes"));
assert_eq!(super::suggested("https://host/.git"), None);
}
}
+54
View File
@@ -0,0 +1,54 @@
//! Committing: message, amend, identity, signing as set (TREE-05).
use std::{io, path::Path};
use crate::git::Git;
/// Who commits, and how Git signs, as Git's settings say.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Me {
pub name: Option<String>,
pub email: Option<String>,
/// `openpgp`, `ssh` or `x509` when commits are signed.
pub signs: Option<String>,
}
/// A setting as Git resolves it; None when unset.
fn setting(git: &Git, root: &Path, key: &str) -> io::Result<Option<String>> {
let said = git.run_or_one(root, &["config", "--get", key])?;
let said = String::from_utf8_lossy(&said).trim().to_string();
Ok((!said.is_empty()).then_some(said))
}
pub fn me(git: &Git, root: &Path) -> io::Result<Me> {
let signs = git.run_or_one(root, &["config", "--type=bool", "--get", "commit.gpgsign"])?;
let signs = match String::from_utf8_lossy(&signs).trim() {
"true" => Some(setting(git, root, "gpg.format")?.unwrap_or_else(|| "openpgp".into())),
_ => None,
};
Ok(Me {
name: setting(git, root, "user.name")?,
email: setting(git, root, "user.email")?,
signs,
})
}
/// The last commit's whole message, to amend.
pub fn last_message(git: &Git, root: &Path) -> io::Result<String> {
let said = git.run(root, &["log", "-1", "--format=%B"])?;
Ok(String::from_utf8_lossy(&said).trim_end().to_string())
}
/// Subject and body as one message: a blank line between.
pub fn message(subject: &str, body: &str) -> String {
match body.trim() {
"" => subject.trim().to_string(),
body => format!("{}\n\n{body}", subject.trim()),
}
}
/// A message back to subject and body.
pub fn split(message: &str) -> (String, String) {
let (subject, body) = message.split_once('\n').unwrap_or((message, ""));
(subject.trim().to_string(), body.trim().to_string())
}
+218
View File
@@ -0,0 +1,218 @@
//! History as Git prints it: graph, file commits, search (TREE-06).
use std::{io, path::Path};
use crate::git::Git;
/// Commits read per page.
pub const PAGE: usize = 2000;
/// One commit as a log lists it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Logged {
pub id: String,
pub parents: Vec<String>,
pub author: String,
/// Author time, seconds since the epoch.
pub at: i64,
pub subject: String,
pub refs: Vec<String>,
}
/// What a search looks in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Find {
#[default]
Message,
Author,
Hash,
/// Commits adding or removing the text (`-S`).
Content,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Query {
pub find: Find,
pub text: String,
}
const FORMAT: &str = "--format=%x1e%H%x00%P%x00%an%x00%at%x00%s%x00%D";
fn bad(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread log record {what:?}"),
)
}
fn text(bytes: &[u8]) -> io::Result<&str> {
std::str::from_utf8(bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "a log that is not UTF-8"))
}
/// `HEAD -> main, origin/main, tag: v1` as `main`, `origin/main`, `v1`.
fn refs(decorations: &str) -> Vec<String> {
decorations
.split(", ")
.filter(|name| !name.is_empty() && *name != "HEAD")
.map(|name| {
name.trim_start_matches("HEAD -> ")
.trim_start_matches("tag: ")
.to_string()
})
.collect()
}
fn record(fields: &[&str], whole: &str) -> io::Result<Logged> {
let [id, parents, author, at, subject, ..] = fields else {
return Err(bad(whole));
};
Ok(Logged {
id: id.to_string(),
parents: parents
.split(' ')
.filter(|p| !p.is_empty())
.map(String::from)
.collect(),
author: author.to_string(),
at: at.parse().map_err(|_| bad(whole))?,
subject: subject.to_string(),
refs: fields
.get(5)
.map(|d| refs(d.trim_end()))
.unwrap_or_default(),
})
}
/// Reads `log` output in `FORMAT`.
pub fn parse(bytes: &[u8]) -> io::Result<Vec<Logged>> {
text(bytes)?
.split('\u{1e}')
.filter(|chunk| !chunk.trim().is_empty())
.map(|chunk| {
record(
&chunk.trim_end_matches('\n').split('\0').collect::<Vec<_>>(),
chunk,
)
})
.collect()
}
/// A page of history: every ref, or a search.
pub fn read(git: &Git, root: &Path, skip: usize, query: Option<&Query>) -> io::Result<Vec<Logged>> {
let (skip, page) = (format!("--skip={skip}"), format!("--max-count={PAGE}"));
let mut args: Vec<String> = ["log", "--topo-order", FORMAT, &page, &skip]
.map(String::from)
.to_vec();
match query {
Some(Query {
find: Find::Hash,
text,
}) => {
let found = git.run_or_one(
root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("{text}^{{commit}}"),
],
)?;
let id = String::from_utf8_lossy(&found).trim().to_string();
if id.is_empty() || skip != "--skip=0" {
return Ok(Vec::new());
}
args = ["log", FORMAT, "--max-count=1", &id]
.map(String::from)
.to_vec();
}
Some(Query { find, text }) => {
args.extend(
[
"--exclude=refs/stash",
"--all",
"--regexp-ignore-case",
"--fixed-strings",
]
.map(String::from),
);
args.push(match find {
Find::Author => format!("--author={text}"),
Find::Content => format!("-S{text}"),
_ => format!("--grep={text}"),
});
}
None => args.extend(["--exclude=refs/stash", "--all"].map(String::from)),
}
let args: Vec<&str> = args.iter().map(String::as_str).collect();
parse(&git.run(root, &args)?)
}
/// The refs as they stand, to see whether history moved.
pub fn refs_now(git: &Git, root: &Path) -> io::Result<Vec<u8>> {
git.run_or_one(root, &["show-ref", "--head"])
}
/// A file's commit, the path it had, and its lines.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Touch {
pub commit: Logged,
pub path: String,
pub from: Option<String>,
/// None for binary files or commits without a diff.
pub lines: Option<(usize, usize)>,
}
/// The file's commits newest first, through renames.
pub fn file(git: &Git, root: &Path, path: &str) -> io::Result<Vec<Touch>> {
let format = "--format=%x1e%H%x00%P%x00%an%x00%at%x00%s";
let out = git.run(
root,
&["log", "--follow", format, "--numstat", "-z", "--", path],
)?;
let mut now = path.to_string();
let mut touches = Vec::new();
for chunk in text(&out)?
.split('\u{1e}')
.filter(|chunk| !chunk.is_empty())
{
let fields: Vec<&str> = chunk.splitn(6, '\0').collect();
let commit = record(&fields[..fields.len().min(5)], chunk)?;
let stat = fields
.get(5)
.copied()
.unwrap_or("")
.trim_start_matches('\n');
let mut parts = stat.split('\0').filter(|part| !part.is_empty());
let (lines, path, from) = match parts.next() {
None => (None, now.clone(), None),
Some(head) => {
let mut cells = head.splitn(3, '\t');
let (added, removed, named) =
(cells.next(), cells.next(), cells.next().unwrap_or(""));
let lines = added
.zip(removed)
.and_then(|(a, r)| Some((a.parse().ok()?, r.parse().ok()?)));
match named {
"" => {
let from = parts.next().ok_or_else(|| bad(chunk))?.to_string();
(
lines,
parts.next().ok_or_else(|| bad(chunk))?.to_string(),
Some(from),
)
}
named => (lines, named.to_string(), None),
}
}
};
now = from.clone().unwrap_or_else(|| path.clone());
touches.push(Touch {
commit,
path,
from,
lines,
});
}
Ok(touches)
}
+224
View File
@@ -0,0 +1,224 @@
//! The commit box: message, amend, identity (TREE-05).
use ely_gpui_component::forms::TextInput;
use gpui::{AppContext as _, Context, Window};
use crate::{
asks::Ask,
commit::{self, Me},
tree::{Tree, text},
work::Write,
};
/// A repository's commit box, kept while another is shown.
#[derive(Clone, Debug, Default)]
pub struct Held {
pub subject: String,
pub body: String,
pub amend: bool,
pub draft: Option<(String, String)>,
}
impl Tree {
/// Shows repository `id`, swapping its commit box in.
pub fn activate(&mut self, id: Option<u64>, cx: &mut Context<Self>) {
if self.active == id {
return cx.notify();
}
// A last message still loading is not carried across.
if self.amend_loading {
self.set_amend(false, cx);
}
let held = Held {
subject: self.subject.read(cx).text().to_string(),
body: self.body.read(cx).text().to_string(),
amend: self.amend,
draft: self.draft.take(),
};
if let Some(old) = self.active.and_then(|old| self.opened_mut(old)) {
old.held = held;
}
self.active = id;
self.amend_ask += 1;
let held = id
.and_then(|id| self.opened_mut(id))
.map(|opened| std::mem::take(&mut opened.held))
.unwrap_or_default();
(self.amend, self.draft) = (held.amend, held.draft);
self.fill(&held.subject, &held.body, cx);
self.ensure_side(cx);
cx.notify();
}
/// Rereads who commits and whether Git signs.
pub fn read_me(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.opened_mut(id) else {
return;
};
opened.me_asks += 1;
let (root, ask) = (opened.repo.root.clone(), opened.me_asks);
let task = cx.background_spawn(async move { commit::me(&git, &root) });
cx.spawn(async move |this, cx| {
let me = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| {
if let Some(opened) = tree.opened_mut(id).filter(|opened| opened.me_asks == ask) {
opened.me = Some(me);
cx.notify();
}
})
.ok();
})
.detach();
}
fn fill(&self, subject: &str, body: &str, cx: &mut Context<Self>) {
self.subject
.update(cx, |field, cx| field.set_text(subject.to_string(), cx));
self.body
.update(cx, |field, cx| field.set_text(body.to_string(), cx));
}
/// Amend loads the last message; off restores the draft.
pub fn set_amend(&mut self, on: bool, cx: &mut Context<Self>) {
// Any later choice voids a read still on its way.
self.amend_ask += 1;
if !on {
(self.amend, self.amend_loading) = (false, false);
if let Some((subject, body)) = self.draft.take() {
self.fill(&subject, &body, cx);
}
return cx.notify();
}
let Some(git) = self.git(cx) else { return };
let Some((repo, root)) = self
.active()
.map(|opened| (opened.id, opened.repo.root.clone()))
else {
return;
};
// The tick shows at once; the message follows.
(self.amend, self.amend_loading) = (true, true);
self.draft = Some((
self.subject.read(cx).text().to_string(),
self.body.read(cx).text().to_string(),
));
let ask = self.amend_ask;
let task = cx.background_spawn(async move { commit::last_message(&git, &root) });
cx.spawn(async move |this, cx| {
let last = task.await;
this.update(cx, |tree, cx| {
if tree.amend_ask != ask || tree.active != Some(repo) {
return log::info!("tree: an amend read came back late");
}
match last {
Ok(message) => {
let (subject, body) = commit::split(&message);
tree.fill(&subject, &body, cx);
tree.amend_loading = false;
cx.notify();
}
Err(error) => {
tree.set_amend(false, cx);
tree.failed("tree.error.amend", &error.to_string(), cx);
}
}
})
.ok();
})
.detach();
cx.notify();
}
pub fn commit(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.active.filter(|_| !self.amend_loading) else {
return;
};
let message = commit::message(self.subject.read(cx).text(), self.body.read(cx).text());
let amend = self.amend;
log::info!("tree: commit asked, amend {amend}");
self.write(id, Write::Commit { message, amend }, cx);
}
/// A commit went in: its box empties if unchanged since.
pub fn committed(&mut self, id: u64, message: &str, cx: &mut Context<Self>) {
if self.active == Some(id) {
let now = commit::message(self.subject.read(cx).text(), self.body.read(cx).text());
if now == message {
(self.amend, self.draft) = (false, None);
self.fill("", "", cx);
}
} else if let Some(opened) = self.opened_mut(id) {
if commit::message(&opened.held.subject, &opened.held.body) == message {
opened.held = Held::default();
}
}
cx.notify();
}
/// Opens the identity dialog, filled with Git's settings.
pub fn ask_identity(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let me: Me = match self.active().and_then(|opened| opened.me.clone()) {
Some(Ok(me)) => me,
_ => Me::default(),
};
let field =
|value: Option<String>, key: &str, window: &mut Window, cx: &mut Context<Self>| {
// Named for assistive tech; the dialog draws the label.
let label = text(cx, key, &[]);
cx.new(|cx| {
let mut field = TextInput::new(window, cx).label(label);
field.set_text(value.unwrap_or_default(), cx);
field
})
};
let name = field(me.name, "tree.identity.name", window, cx);
let email = field(me.email, "tree.identity.email", window, cx);
let Some(repo) = self.active else { return };
self.ask = Some(Ask::Identity {
repo,
name,
email,
global: false,
});
cx.notify();
}
/// Writes the name and address, here or for every repository.
pub fn set_identity(&mut self, cx: &mut Context<Self>) {
let Some(Ask::Identity {
repo,
name,
email,
global,
}) = &self.ask
else {
return;
};
let id = *repo;
let (name, email, global) = (
name.read(cx).text().trim().to_string(),
email.read(cx).text().trim().to_string(),
*global,
);
if name.is_empty() || email.is_empty() {
let words = text(cx, "tree.identity.empty", &[]);
return self.toast(words, ely_gpui_component::primitives::Severity::Warning, cx);
}
self.ask = None;
for (key, value) in [("user.name", name), ("user.email", email)] {
self.write(
id,
Write::Config {
key: key.into(),
value,
global,
},
cx,
);
}
cx.notify();
}
}
+313
View File
@@ -0,0 +1,313 @@
//! A conflict's three stages and hand-kept result (TREE-09).
use std::{io, path::Path, rc::Rc};
use ely_gpui_component::{
git::{MARKER, Region, RegionKind, Take, conflicts, marked, resolve},
primitives::Severity,
};
use gpui::Context;
use crate::{
asks::Ask,
git::Git,
tree::{Tree, text},
work::Write,
};
/// What Git holds for a conflicted path.
#[derive(Clone, Debug, PartialEq)]
pub enum Stages {
/// Text on all three sides, and the marked work file.
Text {
sides: [Option<String>; 3],
work: String,
regions: Vec<Region>,
/// Marker width, from `conflict-marker-size`.
size: usize,
},
/// Links, submodules, bytes, missing sides: taken whole.
Whole { present: [bool; 3] },
}
/// Stage, mode and blob id from `ls-files -u -z`.
fn staged(bytes: &[u8]) -> io::Result<Vec<(usize, String, String)>> {
let text = std::str::from_utf8(bytes).map_err(io::Error::other)?;
text.split('\0')
.filter(|record| !record.is_empty())
.map(|record| {
let bad = || {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread stage {record:?}"),
)
};
let (head, _) = record.split_once('\t').ok_or_else(bad)?;
let mut fields = head.split(' ');
let (mode, id, stage) = (
fields.next().ok_or_else(bad)?,
fields.next().ok_or_else(bad)?,
fields.next().ok_or_else(bad)?,
);
let stage: usize = stage.parse().map_err(|_| bad())?;
if !(1..=3).contains(&stage) {
return Err(bad());
}
Ok((stage, mode.to_string(), id.to_string()))
})
.collect()
}
/// Each block's base, from Git's own diff3 merge.
fn bases(
git: &Git,
root: &Path,
sides: &[Option<String>; 3],
size: usize,
) -> io::Result<Vec<Vec<String>>> {
let dir = tempfile::tempdir()?;
let mut names = Vec::new();
for (side, text) in ["base", "ours", "theirs"].iter().zip(sides) {
let file = dir.path().join(side);
std::fs::write(&file, text.as_deref().unwrap_or(""))?;
names.push(file.to_string_lossy().into_owned());
}
let out = git.run_counted(
root,
&[
"merge-file",
"-p",
"--zdiff3",
&format!("--marker-size={size}"),
&names[1],
&names[0],
&names[2],
],
)?;
let merged = String::from_utf8(out).map_err(io::Error::other)?;
Ok(marked(&merged, size)
.into_iter()
.filter(|region| region.kind == RegionKind::Conflict)
.map(|region| region.base)
.collect())
}
/// The marker width `conflict-marker-size` sets for `path`.
fn marker_size(git: &Git, root: &Path, path: &str) -> io::Result<usize> {
let said = String::from_utf8(git.run(
root,
&["check-attr", "-z", "conflict-marker-size", "--", path],
)?)
.map_err(io::Error::other)?;
// `-z` gives path, attribute and value, each NUL-ended.
Ok(said.split('\0').nth(2).map_or(MARKER, git_width))
}
/// As Git's `strtol_i`: whole value, else 7.
fn git_width(value: &str) -> usize {
match value
.trim_start_matches(|c: char| c.is_ascii_whitespace() || c == '\x0b')
.parse::<i32>()
{
Ok(size) if size > 0 => size as usize,
_ => MARKER,
}
}
/// A NUL early on: Git calls it binary.
fn binary(bytes: &[u8]) -> bool {
bytes[..bytes.len().min(8000)].contains(&0)
}
/// What Git holds for `path`: marked text, or whole files.
pub fn read(git: &Git, root: &Path, path: &str) -> io::Result<Stages> {
let mut sides: [Option<String>; 3] = Default::default();
let (mut present, mut text) = ([false; 3], true);
for (stage, mode, id) in staged(&git.run(root, &["ls-files", "-u", "-z", "--", path])?)? {
present[stage - 1] = true;
// Links and submodules are no text to merge by line.
if mode == "120000" || mode == "160000" {
text = false;
continue;
}
let bytes = git.run(root, &["cat-file", "blob", &id])?;
match String::from_utf8(bytes)
.ok()
.filter(|side| !binary(side.as_bytes()))
{
Some(side) => sides[stage - 1] = Some(side),
None => text = false,
}
}
let file = root.join(path);
let work = match std::fs::symlink_metadata(&file) {
Ok(meta) if meta.file_type().is_file() => String::from_utf8(std::fs::read(&file)?).ok(),
_ => None,
};
let Some(work) = work.filter(|_| text && present[1] && present[2]) else {
return Ok(Stages::Whole { present });
};
let size = marker_size(git, root, path)?;
let mut regions = marked(&work, size);
let blocks = conflicts(&work, size);
let bases = bases(git, root, &sides, size)?;
// Fill only blocks Git left without a base.
if bases.len() == blocks.len() {
let conflicted = regions
.iter_mut()
.filter(|region| region.kind == RegionKind::Conflict);
for ((region, block), base) in conflicted.zip(&blocks).zip(bases) {
if block.base.is_none() {
region.base = base;
}
}
}
Ok(Stages::Text {
sides,
work,
regions,
size,
})
}
/// A text conflict as the middle holds it, being worked.
pub struct Merging {
pub stages: Stages,
pub regions: Rc<Vec<Region>>,
pub takes: Vec<Option<Take>>,
/// The text last loaded or saved, to tell unsaved edits.
pub saved: String,
}
impl Merging {
/// This file's marker width.
pub fn size(&self) -> usize {
match self.stages {
Stages::Text { size, .. } => size,
Stages::Whole { .. } => MARKER,
}
}
/// None for a whole-file conflict.
pub fn new(stages: Stages) -> Option<Self> {
let Stages::Text { work, regions, .. } = &stages else {
return None;
};
let count = regions
.iter()
.filter(|region| region.kind == RegionKind::Conflict)
.count();
let (regions, saved) = (Rc::new(regions.clone()), work.clone());
Some(Merging {
stages,
regions,
takes: vec![None; count],
saved,
})
}
}
impl Tree {
/// Settles conflict `at` in the result, among those open.
pub fn take(&mut self, at: usize, take: Take, cx: &mut Context<Self>) {
let Some(merging) = self.merging.as_mut() else {
return;
};
let result = self.merge_editor.read(cx).text().to_string();
let open = merging.takes.iter().filter(|taken| taken.is_none()).count();
// Hand edits that moved the markers end block choices.
if merging.takes.get(at).copied().flatten().is_some()
|| conflicts(&result, merging.size()).len() != open
{
let words = text(cx, "tree.conflict.hand", &[]);
return self.toast(words, Severity::Warning, cx);
}
let place = merging.takes[..at]
.iter()
.filter(|taken| taken.is_none())
.count();
let Some(settled) = resolve(&result, place, take, merging.size()) else {
return;
};
merging.takes[at] = Some(take);
self.merge_editor
.update(cx, |editor, cx| editor.set_text(settled, cx));
cx.notify();
}
/// Queues the result for its work file; staging is apart.
pub fn save_merge(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let (repo, path) = (shown.repo, shown.path.clone());
let text = self.merge_editor.read(cx).text().to_string();
self.write(repo, Write::Save { path, text }, cx);
}
/// A save landed; it counts if still this file's.
pub fn merge_saved(&mut self, id: u64, path: &str, text: &str, cx: &mut Context<Self>) {
let here = self
.shown
.as_ref()
.is_some_and(|shown| shown.repo == id && shown.path == path);
if let Some(merging) = self.merging.as_mut().filter(|_| here) {
merging.saved = text.to_string();
cx.notify();
}
}
/// Marks the saved result resolved; markers left ask first.
pub fn mark_resolved(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let (repo, path) = (shown.repo, shown.path.clone());
let result = self.merge_editor.read(cx).text().to_string();
if self
.merging
.as_ref()
.is_some_and(|merging| merging.saved != result)
{
let words = text(cx, "tree.conflict.unsaved", &[]);
return self.toast(words, Severity::Warning, cx);
}
let write = Write::Git(vec!["add".into(), "--".into(), path.clone()]);
let size = self.merging.as_ref().map_or(MARKER, Merging::size);
if conflicts(&result, size).is_empty() {
return self.write(repo, write, cx);
}
self.ask = Some(Ask::Confirm {
repo,
title: text(cx, "tree.conflict.marked.title", &[("name", &path)]),
message: text(cx, "tree.conflict.marked.message", &[("name", &path)]),
go: text(cx, "tree.conflict.marked.go", &[]),
write,
});
cx.notify();
}
/// One side's whole file into the work tree.
pub fn take_side(&mut self, ours: bool, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let side = if ours { "--ours" } else { "--theirs" };
let write = Write::Git(vec![
"checkout".into(),
side.into(),
"--".into(),
shown.path.clone(),
]);
let repo = shown.repo;
self.write(repo, write, cx);
}
/// Settles the conflict by removing the file; asks first.
pub fn resolve_deleted(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let (repo, path) = (shown.repo, shown.path.clone());
self.ask = Some(Ask::Confirm {
repo,
title: text(cx, "tree.conflict.delete.title", &[("name", &path)]),
message: text(cx, "tree.conflict.delete.message", &[("name", &path)]),
go: text(cx, "tree.conflict.delete.go", &[]),
write: Write::Git(vec!["rm".into(), "--".into(), path]),
});
cx.notify();
}
}
+222
View File
@@ -0,0 +1,222 @@
//! A conflict's middle, and the running operation's alert.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::{Alert, EmptyState},
git::ThreeWayMerge,
primitives::{IconName, Severity},
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _, Window,
div,
};
use crate::{
conflict::Stages,
ops::{Doing, Op},
tree::{Tree, text},
};
type Act = Box<dyn Fn(&mut Tree, &mut Context<Tree>)>;
/// Words for ours and theirs, as this operation means.
pub fn sides(doing: Option<&Doing>, cx: &App) -> [SharedString; 2] {
match doing {
Some(doing) => {
let (ours, theirs) = match doing.op {
Op::Merge => ("tree.ours.merge", "tree.theirs.merge"),
Op::Rebase => ("tree.ours.rebase", "tree.theirs.rebase"),
Op::CherryPick => ("tree.ours.pick", "tree.theirs.pick"),
Op::Revert => ("tree.ours.revert", "tree.theirs.revert"),
};
[
text(cx, ours, &[("name", &doing.ours)]),
text(cx, theirs, &[("name", &doing.theirs)]),
]
}
None => [text(cx, "tree.ours", &[]), text(cx, "tree.theirs", &[])],
}
}
fn button(
id: &str,
key: &str,
variant: ButtonVariant,
act: Act,
cx: &mut Context<Tree>,
) -> AnyElement {
let this = cx.weak_entity();
Button::new(SharedString::from(id.to_string()), text(cx, key, &[]))
.size(ControlSize::Sm)
.variant(variant)
.on_click(move |_, _: &mut Window, cx: &mut App| {
this.update(cx, |tree, cx| act(tree, cx)).ok();
})
.into_any_element()
}
impl Tree {
/// A stopped operation: continue, skip or abort.
pub fn op_alert(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let Some(Some(Ok(Some(doing)))) = self.active().map(|opened| opened.doing.as_ref()) else {
return None;
};
let op = doing.op;
let key = match op {
Op::Merge => "tree.op.merging",
Op::Rebase => "tree.op.rebasing",
Op::CherryPick => "tree.op.picking",
Op::Revert => "tree.op.reverting",
};
let title = text(cx, key, &[("ours", &doing.ours), ("theirs", &doing.theirs)]);
let mut alert = Alert::new("tree-op", Severity::Warning, title)
.body(text(cx, "tree.op.next", &[]))
.action(button(
"tree-op-continue",
"tree.op.continue",
ButtonVariant::Primary,
Box::new(move |tree, cx| tree.op_continue(op, cx)),
cx,
));
if op.skips() {
alert = alert.action(button(
"tree-op-skip",
"tree.op.skip",
ButtonVariant::Secondary,
Box::new(move |tree, cx| tree.op_skip(op, cx)),
cx,
));
}
Some(
alert
.action(button(
"tree-op-abort",
"tree.op.abort",
ButtonVariant::Danger,
Box::new(move |tree, cx| tree.op_abort(op, cx)),
cx,
))
.into_any_element(),
)
}
pub fn conflict_pane(&self, path: &str, stages: &Stages, cx: &mut Context<Self>) -> AnyElement {
let colors = cx.theme().colors.clone();
let doing = self.active().and_then(|opened| match &opened.doing {
Some(Ok(doing)) => doing.clone(),
_ => None,
});
let [ours, theirs] = sides(doing.as_ref(), cx);
let (text_view, present) = match stages {
Stages::Text { .. } => (self.merging.as_ref(), [true; 3]),
Stages::Whole { present } => (None, *present),
};
let mut actions: Vec<(&str, &str, ButtonVariant, Act)> = Vec::new();
if text_view.is_some() {
actions.push((
"tree-merge-save",
"tree.conflict.save",
ButtonVariant::Secondary,
Box::new(|tree, cx| tree.save_merge(cx)),
));
}
actions.push((
"tree-merge-done",
"tree.conflict.resolved",
ButtonVariant::Primary,
Box::new(|tree, cx| tree.mark_resolved(cx)),
));
// A side's whole file only where that side has one.
if present[1] {
actions.push((
"tree-merge-ours",
"tree.conflict.use_ours",
ButtonVariant::Ghost,
Box::new(|tree, cx| tree.take_side(true, cx)),
));
}
if present[2] {
actions.push((
"tree-merge-theirs",
"tree.conflict.use_theirs",
ButtonVariant::Ghost,
Box::new(|tree, cx| tree.take_side(false, cx)),
));
}
if !present[1] || !present[2] {
actions.push((
"tree-merge-delete",
"tree.conflict.delete",
ButtonVariant::Danger,
Box::new(|tree, cx| tree.resolve_deleted(cx)),
));
}
let buttons: Vec<AnyElement> = actions
.into_iter()
.map(|(id, key, variant, act)| button(id, key, variant, act, cx))
.collect();
let head = div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1p5()
.border_b_1()
.border_color(colors.border)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child(path.to_string()),
)
.children(buttons);
let Some(merging) = text_view else {
let key = match present {
[_, false, _] => "tree.conflict.gone_ours",
[_, _, false] => "tree.conflict.gone_theirs",
_ => "tree.conflict.whole",
};
let body = EmptyState::new(
"tree-conflict-whole",
IconName::TriangleAlert,
text(cx, key, &[]),
)
.body(format!("{ours} · {theirs}"));
return div()
.size_full()
.flex()
.flex_col()
.child(head)
.child(body)
.into_any_element();
};
let base = text(cx, "tree.base", &[]);
let this = cx.weak_entity();
let merge = ThreeWayMerge::new(
SharedString::from(format!("tree-merge-{path}")),
[ours.as_ref(), base.as_ref(), theirs.as_ref()],
merging.regions.clone(),
)
.takes(merging.takes.clone())
.on_take(move |at, take, _, cx| {
this.update(cx, |tree, cx| tree.take(at, take, cx)).ok();
});
div()
.size_full()
.flex()
.flex_col()
.child(head)
.child(div().flex_1().min_h_0().p_2().child(merge))
.child(
div()
.flex_1()
.min_h_0()
.border_t_1()
.border_color(colors.border)
.child(self.merge_editor.clone()),
)
.into_any_element()
}
}
+218
View File
@@ -0,0 +1,218 @@
//! One commit: who, when, its message, signature and files (TREE-06).
use std::{io, path::Path};
use crate::{
diff::Space,
git::Git,
shown::{self, Found},
status,
};
/// A name, an address and a time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Person {
pub name: String,
pub email: String,
pub at: i64,
}
/// A file the commit changed, against its first parent.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Touched {
pub path: String,
pub from: Option<String>,
/// Git's letter: `A`, `M`, `D`, `R`, `C`, `T`.
pub status: char,
pub lines: Option<(usize, usize)>,
/// The commit holding it, when not the detail's own.
pub commit: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Detail {
pub id: String,
pub parents: Vec<String>,
pub author: Person,
pub committer: Person,
pub refs: String,
/// `%G?`: `G` good, `N` none, and Git's other letters.
pub signed: char,
pub signer: String,
pub message: String,
pub files: Vec<Touched>,
}
fn bad(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread commit {what:?}"),
)
}
const FORMAT: &str =
"--format=%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%D%x00%G?%x00%GS%x00%B";
/// What a commit is compared with: first parent, or nothing.
pub fn base(git: &Git, root: &Path, parents: &[String]) -> io::Result<String> {
match parents.first() {
Some(parent) => Ok(parent.clone()),
None => {
let empty = git.run_input(root, &["hash-object", "-t", "tree", "--stdin"], b"")?;
Ok(String::from_utf8_lossy(&empty).trim().to_string())
}
}
}
/// `--name-status -z`: letters, then one path or two.
fn statuses(bytes: &[u8]) -> io::Result<Vec<(char, Option<String>, String)>> {
let text = std::str::from_utf8(bytes).map_err(|_| bad("names not UTF-8"))?;
let mut parts = text.split('\0').filter(|part| !part.is_empty());
let mut out = Vec::new();
while let Some(letters) = parts.next() {
let letter = letters.chars().next().ok_or_else(|| bad(letters))?;
let first = parts.next().ok_or_else(|| bad(letters))?.to_string();
match letter {
'R' | 'C' => out.push((
letter,
Some(first),
parts.next().ok_or_else(|| bad(letters))?.to_string(),
)),
_ => out.push((letter, None, first)),
}
}
Ok(out)
}
pub fn read(git: &Git, root: &Path, id: &str) -> io::Result<Detail> {
let out = git.run(root, &["show", "-s", FORMAT, id, "--"])?;
let text = String::from_utf8(out).map_err(|_| bad("not UTF-8"))?;
let fields: Vec<&str> = text.splitn(12, '\0').collect();
let [
id,
parents,
an,
ae,
at,
cn,
ce,
ct,
refs,
signed,
signer,
message,
] = fields[..]
else {
return Err(bad(&text));
};
let time = |at: &str| at.parse::<i64>().map_err(|_| bad(at));
let mut detail = Detail {
id: id.to_string(),
parents: parents
.split(' ')
.filter(|p| !p.is_empty())
.map(String::from)
.collect(),
author: Person {
name: an.into(),
email: ae.into(),
at: time(at)?,
},
committer: Person {
name: cn.into(),
email: ce.into(),
at: time(ct)?,
},
refs: refs.to_string(),
signed: signed.chars().next().unwrap_or('N'),
signer: signer.to_string(),
message: message.trim_end().to_string(),
files: Vec::new(),
};
let base = base(git, root, &detail.parents)?;
let names = git.run(
root,
&["diff", "--name-status", "-z", "-M", &base, id, "--"],
)?;
let lines =
status::lines(&git.run(root, &["diff", "--numstat", "-z", "-M", &base, id, "--"])?)?;
detail.files = statuses(&names)?
.into_iter()
.map(|(status, from, path)| Touched {
lines: lines.get(&path).copied().flatten(),
commit: None,
path,
from,
status,
})
.collect();
Ok(detail)
}
/// Whether `name`, as `rev:path`, names something.
pub fn exists(git: &Git, root: &Path, name: &str) -> io::Result<bool> {
Ok(!git
.run_or_one(root, &["rev-parse", "--verify", "--quiet", name])?
.is_empty())
}
/// A file's change in `id`; status from which sides exist.
pub fn change(
git: &Git,
root: &Path,
id: &str,
parents: &[String],
path: &str,
from: Option<String>,
) -> io::Result<Found> {
let base = base(git, root, parents)?;
let was = from.as_deref().unwrap_or(path);
let status = match (
exists(git, root, &format!("{base}:{was}"))?,
exists(git, root, &format!("{id}:{path}"))?,
) {
(false, _) => 'A',
(_, false) => 'D',
_ if from.is_some() => 'R',
_ => 'M',
};
file(
git,
root,
id,
parents,
&Touched {
path: path.to_string(),
from,
status,
lines: None,
commit: None,
},
)
}
/// One file's change in a commit, for the middle.
pub fn file(
git: &Git,
root: &Path,
id: &str,
parents: &[String],
touched: &Touched,
) -> io::Result<Found> {
let base = base(git, root, parents)?;
let mut args = shown::base(Space::Exact);
args.extend([
"-M".into(),
base.clone(),
id.to_string(),
"--".into(),
touched.path.clone(),
]);
args.extend(touched.from.clone());
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let seen = git.run(root, &args)?;
let was = touched.from.as_deref().unwrap_or(&touched.path);
let old = (touched.status != 'A').then(|| format!("{base}:{was}"));
let new = (touched.status != 'D').then(|| Some(format!("{id}:{}", touched.path)));
shown::compare(git, root, seen, &touched.path, old, new)
}
+256
View File
@@ -0,0 +1,256 @@
//! Git's own diff, read and cut into patches (TREE-03, TREE-04).
use std::{collections::BTreeSet, io};
use ely_gpui_component::git::LineKind;
/// How whitespace counts: exact, `-b`, or `-w`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Space {
#[default]
Exact,
Changes,
All,
}
impl Space {
pub fn flag(self) -> Option<&'static str> {
match self {
Space::Exact => None,
Space::Changes => Some("--ignore-space-change"),
Space::All => Some("--ignore-all-space"),
}
}
}
/// One line as Git printed it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Raw {
pub kind: LineKind,
pub text: String,
/// False when Git said "No newline at end of file".
pub newline: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hunk {
/// Start and count, as in `@@ -s,c +s,c @@`.
pub old: (usize, usize),
pub new: (usize, usize),
pub tail: String,
pub lines: Vec<Raw>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Body {
Text(Vec<Hunk>),
Binary,
/// Not UTF-8; shown as a file, staged as a file.
Undecodable,
}
/// One file's diff: its header lines and body.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileDiff {
pub head: Vec<String>,
pub body: Body,
}
impl FileDiff {
/// One per `diff --git` header; a type change prints two.
pub fn sections(&self) -> usize {
self.head
.iter()
.filter(|line| line.starts_with("diff --git "))
.count()
}
/// Hunks and lines stage only for a plain content change.
pub fn partial(&self) -> bool {
const WHOLE: [&str; 7] = [
"new file",
"deleted file",
"rename ",
"copy ",
"old mode",
"new mode",
"similarity",
];
matches!(self.body, Body::Text(_))
&& !self
.head
.iter()
.any(|line| WHOLE.iter().any(|w| line.starts_with(w)))
}
}
fn bad(line: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread diff line {line:?}"),
)
}
/// `-s,c` or `+s` as start and count.
fn range(part: &str, sign: char, line: &str) -> io::Result<(usize, usize)> {
let part = part.strip_prefix(sign).ok_or_else(|| bad(line))?;
let (start, count) = part.split_once(',').unwrap_or((part, "1"));
Ok((
start.parse().map_err(|_| bad(line))?,
count.parse().map_err(|_| bad(line))?,
))
}
/// Reads one file's `git diff` output.
pub fn parse(bytes: &[u8]) -> io::Result<FileDiff> {
let Ok(text) = std::str::from_utf8(bytes) else {
return Ok(FileDiff {
head: Vec::new(),
body: Body::Undecodable,
});
};
let mut head = Vec::new();
let mut hunks: Vec<Hunk> = Vec::new();
let mut lines = text.split('\n').peekable();
// A type change prints one path as two sections.
let mut in_head = true;
while let Some(line) = lines.next() {
if line.is_empty() && lines.peek().is_none() {
break;
}
if let Some(rest) = line.strip_prefix("@@ ") {
let (ranges, tail) = rest.split_once(" @@").ok_or_else(|| bad(line))?;
let (old, new) = ranges.split_once(' ').ok_or_else(|| bad(line))?;
hunks.push(Hunk {
old: range(old, '-', line)?,
new: range(new, '+', line)?,
tail: tail.trim_start().to_string(),
lines: Vec::new(),
});
in_head = false;
continue;
}
in_head |= line.starts_with("diff --git ");
let Some(hunk) = hunks.last_mut().filter(|_| !in_head) else {
if line.starts_with("Binary files ") {
return Ok(FileDiff {
head,
body: Body::Binary,
});
}
head.push(line.to_string());
continue;
};
let (kind, rest) = match line.split_at_checked(1) {
Some((" ", rest)) => (LineKind::Same, rest),
Some(("+", rest)) => (LineKind::Added, rest),
Some(("-", rest)) => (LineKind::Removed, rest),
Some(("\\", _)) => {
hunk.lines.last_mut().ok_or_else(|| bad(line))?.newline = false;
continue;
}
_ => return Err(bad(line)),
};
hunk.lines.push(Raw {
kind,
text: rest.to_string(),
newline: true,
});
}
Ok(FileDiff {
head,
body: Body::Text(hunks),
})
}
/// Lines before a range: the start, less one unless empty.
fn before((start, count): (usize, usize)) -> usize {
if count == 0 { start } else { start - 1 }
}
fn start(before: usize, count: usize) -> usize {
if count == 0 { before } else { before + 1 }
}
/// Patch of chosen `(hunk, line)`s; `reverse` unstages.
pub fn patch(diff: &FileDiff, chosen: &BTreeSet<(usize, usize)>, reverse: bool) -> Option<String> {
let Body::Text(hunks) = &diff.body else {
return None;
};
let mut out = String::new();
// New less old lines in the hunks written so far.
let mut shift: isize = 0;
for (at, hunk) in hunks.iter().enumerate() {
let picked = |line: usize| chosen.contains(&(at, line));
if !hunk
.lines
.iter()
.enumerate()
.any(|(line, raw)| raw.kind != LineKind::Same && picked(line))
{
continue;
}
// Kept lines: sign, text, newline.
let mut cut: Vec<(char, &str, bool)> = Vec::new();
for (line, raw) in hunk.lines.iter().enumerate() {
let sign = match (raw.kind, picked(line), reverse) {
(LineKind::Same, _, _)
| (LineKind::Removed, false, false)
| (LineKind::Added, false, true) => ' ',
(LineKind::Removed, true, _) => '-',
(LineKind::Added, true, _) => '+',
(LineKind::Removed, false, true) | (LineKind::Added, false, false) => continue,
};
cut.push((sign, &raw.text, raw.newline));
}
let mut body = String::new();
let (mut olds, mut news) = (0, 0);
let mut line = |sign: char, text: &str, newline: bool| {
olds += usize::from(sign != '+');
news += usize::from(sign != '-');
body.push(sign);
body.push_str(text);
body.push('\n');
if !newline {
body.push_str("\\ No newline at end of file\n");
}
};
for (at, &(sign, text, newline)) in cut.iter().enumerate() {
// A line gains a newline where lines follow it.
let followed = |side: char| {
cut[at + 1..]
.iter()
.any(|(next, ..)| *next == ' ' || *next == side)
};
let (old, new) = (newline || followed('-'), newline || followed('+'));
match sign {
'-' => line('-', text, old),
'+' => line('+', text, new),
_ if old == new => line(' ', text, old),
_ => {
line('-', text, old);
line('+', text, new);
}
}
}
// The index side keeps Git's numbers; the other follows.
let (old, new) = if reverse {
let fixed = before(hunk.new);
let other = fixed.checked_add_signed(-shift)?;
((start(other, olds), olds), (start(fixed, news), news))
} else {
let fixed = before(hunk.old);
let other = fixed.checked_add_signed(shift)?;
((start(fixed, olds), olds), (start(other, news), news))
};
shift += news as isize - olds as isize;
out.push_str(&format!(
"@@ -{},{} +{},{} @@\n{body}",
old.0, old.1, new.0, new.1
));
}
if out.is_empty() {
return None;
}
Some(format!("{}\n{out}", diff.head.join("\n")))
}
+132
View File
@@ -0,0 +1,132 @@
//! Git as a program: argument arrays, never a shell (§13.1).
use std::{
io::{self, Write as _},
path::{Path, PathBuf},
process::{Command, Output, Stdio},
};
/// A Git binary and its version.
#[derive(Clone, Debug)]
pub struct Git {
pub path: PathBuf,
pub version: String,
/// Ely's own build, not a system Git.
pub bundled: bool,
}
impl Git {
/// `ELY_GIT` or the setting picks Git; else Ely's own.
pub fn find() -> io::Result<Self> {
let (path, bundled) = ely_platform::git::locate()?;
if !path.is_file() {
let hint = if bundled {
"run scripts/build-git.sh"
} else {
"check ELY_GIT or the Git setting"
};
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no Git at {} ({hint})", path.display()),
));
}
let version = match bundled {
true => bundled_version(&path)?,
false => ely_platform::git::system_version(&path)?,
};
log::info!(
"tree: {version} at {} ({})",
path.display(),
if bundled { "bundled" } else { "system" }
);
Ok(Git {
path,
version,
bundled,
})
}
/// Runs Git in `dir`; failure carries Git's words.
pub fn run(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
let output = self.command(dir, args).output()?;
checked(args, output)
}
/// Runs Git with `input` on its standard input.
pub fn run_input(&self, dir: &Path, args: &[&str], input: &[u8]) -> io::Result<Vec<u8>> {
let mut child = self
.command(dir, args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| io::Error::other("no stdin"))?;
let input = input.to_vec();
// Writing apart from reading cannot fill both pipes.
let writer = std::thread::spawn(move || stdin.write_all(&input));
let output = child.wait_with_output()?;
writer
.join()
.map_err(|_| io::Error::other("the stdin writer panicked"))??;
checked(args, output)
}
/// Exit 1 is an answer: files differ, key unset.
pub fn run_or_one(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
let output = self.command(dir, args).output()?;
match output.status.code() {
Some(1) => Ok(output.stdout),
_ => checked(args, output),
}
}
/// `merge-file` exits with its conflict count; past 127 fails.
pub fn run_counted(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
let output = self.command(dir, args).output()?;
match output.status.code() {
Some(0..=127) => Ok(output.stdout),
_ => checked(args, output),
}
}
pub(crate) fn command(&self, dir: &Path, args: &[&str]) -> Command {
ely_platform::git::command(&self.path, dir, args)
}
}
fn checked(args: &[&str], output: Output) -> io::Result<Vec<u8>> {
// The verb follows any `-c name=value` pairs.
let verb = args
.iter()
.copied()
.find(|arg| !arg.starts_with('-') && !arg.contains('='))
.unwrap_or("");
match output.status.success() {
true => {
log::debug!("tree: git {verb} done");
Ok(output.stdout)
}
false => {
// Conflicts go to stdout, hooks to stderr.
let said: Vec<String> = [&output.stdout, &output.stderr]
.into_iter()
.map(|bytes| String::from_utf8_lossy(bytes).trim().to_string())
.filter(|said| !said.is_empty())
.collect();
log::warn!("tree: git {verb} failed, {}", output.status);
Err(io::Error::other(format!(
"git {verb} ({}):\n{}",
output.status,
said.join("\n")
)))
}
}
}
/// Ely's own Git, whatever version it was pinned at.
fn bundled_version(path: &Path) -> io::Result<String> {
ely_platform::git::version_of(path).map(|(version, _)| version)
}
+379
View File
@@ -0,0 +1,379 @@
//! History in the window: pages, search, a picked commit (TREE-06).
use std::rc::Rc;
use ely_gpui_component::git::{Commit, GraphRow, lanes};
use gpui::{AppContext as _, Context, SharedString};
use crate::{
commits::{self, Logged, PAGE, Query},
detail::{self, Detail},
shown::{self, View},
stash,
tree::Tree,
};
/// One repository's history as read so far.
#[derive(Default)]
pub struct Hist {
pub logged: Vec<Logged>,
pub rows: Rc<Vec<Commit>>,
pub graph: Rc<Vec<GraphRow>>,
pub loading: bool,
/// The last page came back short.
pub done: bool,
/// Read at least once.
pub read: bool,
pub query: Option<Query>,
pub error: Option<String>,
/// `show-ref --head` when last read.
refs: Vec<u8>,
/// Refs may have moved during a read; check after it.
stale: bool,
serial: u64,
pub pick: Picked,
}
/// A commit in the middle: its files, one file's diff.
#[derive(Default)]
pub struct Picked {
pub commit: Option<String>,
pub detail: Option<Result<Detail, String>>,
pub file: Option<(usize, Option<Result<View, String>>)>,
}
/// Whose picked commit: history's or a stash's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Whose {
History,
Stash,
Reflog,
}
/// A time in this machine's zone, to the minute.
pub fn when(at: i64) -> SharedString {
match jiff::Timestamp::from_second(at) {
Ok(at) => at
.to_zoned(jiff::tz::TimeZone::system())
.strftime("%Y-%m-%d %H:%M")
.to_string()
.into(),
Err(error) => {
log::warn!("tree: time {at} is out of range: {error}");
at.to_string().into()
}
}
}
/// The rows a commit list draws.
pub fn rows(logged: &[Logged]) -> Vec<Commit> {
logged
.iter()
.map(|commit| Commit {
id: commit.id.clone().into(),
parents: commit.parents.iter().map(|id| id.clone().into()).collect(),
subject: commit.subject.clone().into(),
author: commit.author.clone().into(),
when: when(commit.at),
refs: commit.refs.iter().map(|name| name.clone().into()).collect(),
})
.collect()
}
fn graph(logged: &[Logged]) -> Vec<GraphRow> {
let parents: Vec<Vec<&str>> = logged
.iter()
.map(|commit| commit.parents.iter().map(String::as_str).collect())
.collect();
lanes(
logged
.iter()
.zip(&parents)
.map(|(commit, parents)| (commit.id.as_str(), parents.as_slice())),
)
}
impl Tree {
pub fn set_side(&mut self, side: crate::tree::Side, cx: &mut Context<Self>) {
self.side = side;
self.ensure_side(cx);
cx.notify();
}
/// A panel's lists are read once it first shows.
pub fn ensure_side(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.active else { return };
let Some(opened) = self.opened(id) else {
return;
};
match self.side {
crate::tree::Side::History if !opened.hist.read && !opened.hist.loading => {
self.load_log(id, true, cx)
}
crate::tree::Side::Branches if opened.refs.is_none() => self.read_refs(id, cx),
crate::tree::Side::Places if opened.places.read.is_none() => self.read_places(id, cx),
_ => {}
}
}
/// The next page, once the list nears its end.
pub fn more_log(&mut self, cx: &mut Context<Self>) {
if let Some(id) = self.active {
self.load_log(id, false, cx);
}
}
fn hist_mut(&mut self, id: u64) -> Option<&mut Hist> {
self.opened_mut(id).map(|opened| &mut opened.hist)
}
/// Reads history from the top, or the next page.
pub fn load_log(&mut self, id: u64, fresh: bool, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else {
return;
};
let Some(hist) = self.hist_mut(id) else {
return;
};
if !fresh && (hist.loading || hist.done) {
return;
}
hist.serial += 1;
hist.loading = true;
let (serial, skip, query) = (
hist.serial,
if fresh { 0 } else { hist.logged.len() },
hist.query.clone(),
);
let task = cx.background_spawn(async move {
let refs = commits::refs_now(&git, &root)?;
Ok::<_, std::io::Error>((refs, commits::read(&git, &root, skip, query.as_ref())?))
});
cx.spawn(async move |this, cx| {
let read = task.await;
this.update(cx, |tree, cx| tree.log_read(id, serial, fresh, read, cx))
.ok();
})
.detach();
}
fn log_read(
&mut self,
id: u64,
serial: u64,
fresh: bool,
read: std::io::Result<(Vec<u8>, Vec<Logged>)>,
cx: &mut Context<Self>,
) {
let Some(hist) = self.hist_mut(id).filter(|hist| hist.serial == serial) else {
return;
};
hist.loading = false;
hist.read = true;
match read {
Ok((refs, page)) => {
if fresh {
hist.logged.clear();
}
hist.done = page.len() < PAGE;
hist.logged.extend(page);
hist.refs = refs;
hist.error = None;
hist.rows = Rc::new(rows(&hist.logged));
hist.graph = Rc::new(if hist.query.is_none() {
graph(&hist.logged)
} else {
Vec::new()
});
log::info!("tree: history shows {} commits, searched {}", hist.logged.len(), hist.query.is_some());
}
Err(error) => {
log::warn!("tree: reading history failed: {error}");
hist.error = Some(error.to_string());
}
}
if std::mem::take(&mut hist.stale) {
self.refresh_log(id, cx);
}
cx.notify();
}
/// After a status read: reread history if refs moved.
pub fn refresh_log(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(hist) = self.hist_mut(id) else {
return;
};
if hist.loading {
hist.stale = true;
return;
}
if !hist.read {
return;
}
let was = hist.refs.clone();
let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else {
return;
};
let task = cx.background_spawn(async move { commits::refs_now(&git, &root) });
cx.spawn(async move |this, cx| {
let now = task.await;
this.update(cx, |tree, cx| match now {
Ok(now) if now != was => tree.load_log(id, true, cx),
// A shown error goes once a read succeeds again.
Ok(_)
if tree
.opened(id)
.is_some_and(|opened| opened.hist.error.is_some()) =>
{
tree.load_log(id, true, cx)
}
Ok(_) => {}
// Shown in the panel, in Git's words.
Err(error) => {
log::warn!("tree: reading refs failed: {error}");
if let Some(hist) = tree.hist_mut(id) {
hist.error = Some(error.to_string());
cx.notify();
}
}
})
.ok();
})
.detach();
}
/// Runs the search field's query; empty shows all history.
pub fn search(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.active else { return };
let text = self.search.read(cx).text().trim().to_string();
let query = (!text.is_empty()).then(|| Query {
find: self.find,
text,
});
let Some(hist) = self.hist_mut(id) else {
return;
};
hist.query = query;
self.load_log(id, true, cx);
}
fn picked_mut(&mut self, id: u64, whose: Whose) -> Option<&mut Picked> {
let opened = self.opened_mut(id)?;
Some(match whose {
Whose::History => &mut opened.hist.pick,
Whose::Stash | Whose::Reflog => &mut opened.places.pick,
})
}
/// Shows a commit's details in the middle.
pub fn pick_commit(&mut self, commit: &str, cx: &mut Context<Self>) {
self.pick_in(Whose::History, commit, cx);
}
/// Reads a commit, or a stash with its untracked files.
pub fn pick_in(&mut self, whose: Whose, commit: &str, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(id) = self.active else { return };
let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else {
return;
};
let Some(picked) = self.picked_mut(id, whose) else {
return;
};
*picked = Picked {
commit: Some(commit.to_string()),
..Picked::default()
};
let commit = commit.to_string();
let task = cx.background_spawn({
let commit = commit.clone();
async move {
match whose {
Whose::History | Whose::Reflog => detail::read(&git, &root, &commit),
Whose::Stash => stash::detail(&git, &root, &commit),
}
}
});
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| {
let Some(picked) = tree
.picked_mut(id, whose)
.filter(|picked| picked.commit.as_deref() == Some(commit.as_str()))
else {
return;
};
picked.detail = Some(read);
cx.notify();
})
.ok();
})
.detach();
cx.notify();
}
/// Shows one file of the picked commit.
pub fn pick_touched(&mut self, whose: Whose, at: usize, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(id) = self.active else { return };
let Some(root) = self.opened(id).map(|opened| opened.repo.root.clone()) else {
return;
};
let Some(picked) = self.picked_mut(id, whose) else {
return;
};
let Some(Ok(detail)) = &picked.detail else {
return;
};
let Some(touched) = detail.files.get(at).cloned() else {
return;
};
// A stash's untracked files sit in their own commit.
let (holder, parents) = match &touched.commit {
Some(holder) => (holder.clone(), Vec::new()),
None => (detail.id.clone(), detail.parents.clone()),
};
let commit = detail.id.clone();
picked.file = Some((at, None));
let task =
cx.background_spawn(
async move { detail::file(&git, &root, &holder, &parents, &touched) },
);
cx.spawn(async move |this, cx| {
let found = task.await;
this.update(cx, |tree, cx| {
// Only for the file and commit still shown.
let Some(picked) = tree
.picked_mut(id, whose)
.filter(|picked| picked.commit.as_deref() == Some(commit.as_str()))
else {
return;
};
let Some((now, view)) = &mut picked.file else {
return;
};
if *now == at {
*view = Some(
found
.and_then(shown::view)
.map_err(|error| error.to_string()),
);
cx.notify();
}
})
.ok();
})
.detach();
cx.notify();
}
}
+322
View File
@@ -0,0 +1,322 @@
//! The middle in History: the picked commit (TREE-06).
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton},
data_display::DescriptionList,
feedback::{EmptyState, ErrorView},
lists::{List, ListItem},
primitives::IconName,
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, Context, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _,
Role, SharedString, StatefulInteractiveElement as _, Styled as _, div,
prelude::FluentBuilder as _, px,
};
use crate::{
detail::{Detail, Person},
hist::{Hist, Picked, Whose, when},
tree::{Tree, text},
};
/// `%G?` in words.
fn signature(letter: char) -> &'static str {
match letter {
'G' => "tree.sig.good",
'B' => "tree.sig.bad",
'U' => "tree.sig.untrusted",
'X' => "tree.sig.expired",
'Y' => "tree.sig.expired_key",
'R' => "tree.sig.revoked",
'E' => "tree.sig.unchecked",
_ => "tree.sig.none",
}
}
fn person(who: &Person) -> SharedString {
format!("{} <{}>, {}", who.name, who.email, when(who.at)).into()
}
impl Tree {
/// A file's history and blame, as two buttons.
pub fn look_buttons(&self, path: &str, cx: &mut Context<Self>) -> [IconButton; 2] {
let (log, blame, at, blamed) = (
cx.weak_entity(),
cx.weak_entity(),
path.to_string(),
path.to_string(),
);
[
IconButton::new("tree-file-log", IconName::History)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(text(cx, "tree.file_log", &[]))
.on_click(move |_, _, cx| {
log.update(cx, |tree, cx| tree.file_log(&at, cx)).ok();
}),
IconButton::new("tree-blame", IconName::User)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(text(cx, "tree.blame", &[]))
.on_click(move |_, _, cx| {
blame.update(cx, |tree, cx| tree.blame(&blamed, cx)).ok();
}),
]
}
/// A committed file's path, its history and its blame.
fn file_head(&self, path: &str, cx: &mut Context<Self>) -> AnyElement {
let colors = cx.theme().colors.clone();
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1p5()
.border_b_1()
.border_color(colors.border)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child(path.to_string()),
)
.children(self.look_buttons(path, cx))
.into_any_element()
}
pub fn commit_pane(&self, hist: &Hist, cx: &mut Context<Self>) -> AnyElement {
self.picked_pane(&hist.pick, Whose::History, cx)
}
/// A history commit or a stash, as read so far.
pub fn picked_pane(&self, picked: &Picked, whose: Whose, cx: &mut Context<Self>) -> AnyElement {
match (&picked.commit, &picked.detail) {
(None, _) => EmptyState::new(
"tree-commit-none",
IconName::GitCommitHorizontal,
text(cx, "tree.commit.pick", &[]),
)
.into_any_element(),
(Some(_), None) => div().into_any_element(),
(Some(_), Some(Err(error))) => ErrorView::new(
"tree-commit-error",
IconName::TriangleAlert,
text(cx, "tree.commit.unread", &[]),
)
.detail(error.clone())
.into_any_element(),
(Some(_), Some(Ok(detail))) => {
let head = match whose {
Whose::History => self.commit_head(detail, cx),
Whose::Stash => self.stash_head(detail, cx),
Whose::Reflog => self.reflog_head(detail, cx),
};
self.commit_detail(detail, picked, whose, head, cx)
}
}
}
fn commit_head(&self, detail: &Detail, cx: &mut Context<Self>) -> AnyElement {
let colors = cx.theme().colors.clone();
let (subject, body) = detail
.message
.split_once('\n')
.unwrap_or((&detail.message, ""));
let label = |key: &str| text(cx, key, &[]);
let mut rows =
DescriptionList::new().text(label("tree.commit.author"), person(&detail.author));
if (&detail.committer.name, &detail.committer.email)
!= (&detail.author.name, &detail.author.email)
{
rows = rows.text(label("tree.commit.committer"), person(&detail.committer));
}
let parents = detail.parents.iter().map(|parent| {
let (this, id) = (cx.weak_entity(), parent.clone());
Button::new(
SharedString::from(format!("tree-parent-{parent}")),
parent[..parent.len().min(7)].to_string(),
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.pick_commit(&id, cx)).ok();
})
});
let signed = match detail.signer.as_str() {
"" => label(signature(detail.signed)),
signer => format!("{}, {signer}", label(signature(detail.signed))).into(),
};
rows = rows
.text(label("tree.commit.id"), detail.id.clone())
.item(label("tree.commit.parents"), div().flex().children(parents))
.text(label("tree.commit.signature"), signed);
if !detail.refs.is_empty() {
rows = rows.text(label("tree.commit.refs"), detail.refs.clone());
}
div()
.id("tree-commit-head")
.flex_none()
.max_h(px(280.0))
.overflow_y_scroll()
.flex()
.flex_col()
.gap_2()
.p_3()
.border_b_1()
.border_color(colors.border)
.child(
div()
.id("tree-commit-subject")
.role(Role::Heading)
.aria_label(SharedString::from(subject.to_string()))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(subject.to_string()),
)
.when(!body.trim().is_empty(), |head| {
let body = SharedString::from(body.trim().to_string());
head.child(
div()
.id("tree-commit-body")
.role(Role::Label)
.aria_label(body.clone())
.text_color(colors.fg_muted)
.child(body),
)
})
.child(rows)
.child(self.commit_actions(detail, cx))
.into_any_element()
}
/// Pick, revert, or branch or tag at this commit.
fn commit_actions(&self, detail: &Detail, cx: &mut Context<Self>) -> AnyElement {
let (id, parents) = (detail.id.clone(), detail.parents.len());
let acts: [(
&str,
Box<dyn Fn(&mut Tree, &mut gpui::Window, &mut Context<Tree>)>,
); 4] = [
("tree.commit.pick_it", {
let id = id.clone();
Box::new(move |tree, _, cx| tree.cherry_pick(&id, parents, cx))
}),
("tree.commit.revert_it", {
let id = id.clone();
Box::new(move |tree, _, cx| tree.revert(&id, parents, cx))
}),
("tree.ref.branch_here", {
let id = id.clone();
Box::new(move |tree, window, cx| tree.ask_branch(Some(id.clone()), window, cx))
}),
("tree.ref.tag_here", {
let id = id.clone();
Box::new(move |tree, window, cx| tree.ask_tag(Some(id.clone()), window, cx))
}),
];
div()
.flex()
.flex_wrap()
.gap_2()
.children(acts.into_iter().map(|(key, act)| {
let this = cx.weak_entity();
Button::new(
SharedString::from(format!("{key}-button")),
text(cx, key, &[]),
)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
this.update(cx, |tree, cx| act(tree, window, cx)).ok();
})
}))
.into_any_element()
}
fn commit_detail(
&self,
detail: &Detail,
pick: &Picked,
whose: Whose,
head: AnyElement,
cx: &mut Context<Self>,
) -> AnyElement {
let colors = cx.theme().colors.clone();
let picked = pick.file.as_ref().map(|(at, _)| *at);
let weak = cx.weak_entity();
let files: Vec<AnyElement> = detail
.files
.iter()
.enumerate()
.map(|(at, file)| {
let this = weak.clone();
let title = match &file.from {
Some(from) => format!("{from} → {}", file.path),
None => file.path.clone(),
};
let about = match file.lines {
Some((added, removed)) => format!("{} · +{added} −{removed}", file.status),
None => file.status.to_string(),
};
ListItem::new(SharedString::from(format!("tree-touched-{at}")), title)
.description(about)
.selected(picked == Some(at))
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.pick_touched(whose, at, cx))
.ok();
})
.into_any_element()
})
.collect();
let shown = match &pick.file {
Some((at, view)) => {
let path = &detail.files[*at].path;
let diff = self.view_only(
SharedString::from(format!("tree-commit-diff-{}", detail.id)).into(),
path,
view.as_ref(),
cx,
);
div()
.size_full()
.flex()
.flex_col()
.child(self.file_head(path, cx))
.child(div().flex_1().min_h_0().child(diff))
.into_any_element()
}
None => EmptyState::new(
"tree-file-none",
IconName::FileText,
text(cx, "tree.commit.pick_file", &[]),
)
.into_any_element(),
};
div()
.size_full()
.flex()
.flex_col()
.child(head)
.child(
div()
.flex_1()
.min_h_0()
.flex()
.child(
div()
.id("tree-touched")
.flex_none()
.w_72()
.h_full()
.overflow_y_scroll()
.p_2()
.border_r_1()
.border_color(colors.border)
.child(List::new().children(files)),
)
.child(div().flex_1().min_w_0().h_full().child(shown)),
)
.into_any_element()
}
}
+212
View File
@@ -0,0 +1,212 @@
//! A file's history or blame, over the middle (TREE-06).
use std::rc::Rc;
use ely_gpui_component::git::Commit;
use gpui::{AppContext as _, Context};
use crate::{
blame::{self, Blamed},
commits::{self, Touch},
hist,
shown::{self, View},
tree::Tree,
};
pub enum Look {
FileLog {
repo: u64,
/// Unique per opening, so late reads are dropped.
serial: u64,
path: String,
touches: Option<Result<Vec<Touch>, String>>,
rows: Rc<Vec<(Commit, Option<(usize, usize)>)>>,
picked: Option<usize>,
view: Option<Result<View, String>>,
},
Blame {
repo: u64,
serial: u64,
path: String,
read: Option<Result<Blamed, String>>,
},
}
impl Look {
pub fn repo(&self) -> u64 {
match self {
Look::FileLog { repo, .. } | Look::Blame { repo, .. } => *repo,
}
}
}
impl Tree {
/// Opens `path`'s commits, newest first, through renames.
pub fn file_log(&mut self, path: &str, cx: &mut Context<Self>) {
let Some(git) = self.git(cx) else { return };
let Some((repo, root)) = self
.active()
.map(|opened| (opened.id, opened.repo.root.clone()))
else {
return;
};
let path = path.to_string();
log::info!("tree: file history of {path}");
self.looks += 1;
let serial = self.looks;
self.look = Some(Look::FileLog {
repo,
serial,
path: path.clone(),
touches: None,
rows: Rc::default(),
picked: None,
view: None,
});
let task = cx.background_spawn({
let path = path.clone();
async move { commits::file(&git, &root, &path) }
});
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| {
let Some(Look::FileLog {
serial: now,
touches,
rows,
..
}) = &mut tree.look
else {
return;
};
if *now != serial {
return;
}
if let Ok(read) = &read {
let logged: Vec<_> = read.iter().map(|touch| touch.commit.clone()).collect();
*rows = Rc::new(
hist::rows(&logged)
.into_iter()
.zip(read.iter().map(|touch| touch.lines))
.collect(),
);
}
*touches = Some(read);
cx.notify();
})
.ok();
})
.detach();
cx.notify();
}
/// Shows the file's change in one of its commits.
pub fn pick_touch(&mut self, at: usize, cx: &mut Context<Self>) {
let Some(git) = self.git(cx) else { return };
let Some(Look::FileLog {
repo,
serial,
touches: Some(Ok(touches)),
picked,
view,
..
}) = &mut self.look
else {
return;
};
let Some(touch) = touches.get(at).cloned() else {
return;
};
(*picked, *view) = (Some(at), None);
let (repo, serial) = (*repo, *serial);
let Some(root) = self.opened(repo).map(|opened| opened.repo.root.clone()) else {
return;
};
let task = cx.background_spawn(async move {
let commit = &touch.commit;
crate::detail::change(
&git,
&root,
&commit.id,
&commit.parents,
&touch.path,
touch.from.clone(),
)
});
cx.spawn(async move |this, cx| {
let found = task.await;
this.update(cx, |tree, cx| {
let Some(Look::FileLog {
serial: now,
picked: Some(was),
view,
..
}) = &mut tree.look
else {
return;
};
if *now == serial && *was == at {
*view = Some(
found
.and_then(shown::view)
.map_err(|error| error.to_string()),
);
cx.notify();
}
})
.ok();
})
.detach();
cx.notify();
}
/// Who last changed each line of `path`.
pub fn blame(&mut self, path: &str, cx: &mut Context<Self>) {
let Some(git) = self.git(cx) else { return };
let Some((repo, root)) = self
.active()
.map(|opened| (opened.id, opened.repo.root.clone()))
else {
return;
};
let path = path.to_string();
self.looks += 1;
let serial = self.looks;
self.look = Some(Look::Blame {
repo,
serial,
path: path.clone(),
read: None,
});
let task = cx.background_spawn({
let path = path.clone();
async move { blame::read(&git, &root, &path) }
});
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| {
let Some(Look::Blame {
serial: now,
read: slot,
..
}) = &mut tree.look
else {
return;
};
if *now == serial {
*slot = Some(read);
cx.notify();
}
})
.ok();
})
.detach();
cx.notify();
}
/// Closes a file's history or blame.
pub fn close_look(&mut self, cx: &mut Context<Self>) {
self.look = None;
cx.notify();
}
}
+195
View File
@@ -0,0 +1,195 @@
//! The middle for a file's history or blame (TREE-06).
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton},
feedback::{EmptyState, ErrorView},
git::{Blame, BlameView, FileHistory},
primitives::IconName,
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div,
prelude::FluentBuilder as _,
};
use crate::{
blame::Blamed,
hist::when,
look::Look,
tree::{Side, Tree, text},
};
/// Blame's rows: each owner's words and its age, newest 0.
fn blames(blamed: &Blamed, uncommitted: &SharedString) -> Vec<Blame> {
let times = blamed
.owners
.iter()
.filter(|owner| !owner.uncommitted())
.map(|owner| owner.at);
let (oldest, newest) = (times.clone().min().unwrap_or(0), times.max().unwrap_or(0));
let span = (newest - oldest).max(1) as f32;
blamed
.owners
.iter()
.map(|owner| match owner.uncommitted() {
true => Blame {
commit: owner.commit.clone().into(),
author: uncommitted.clone(),
when: SharedString::default(),
subject: SharedString::default(),
age: 0.0,
},
false => Blame {
commit: owner.commit.clone().into(),
author: owner.author.clone().into(),
when: when(owner.at),
subject: owner.subject.clone().into(),
age: (newest - owner.at) as f32 / span,
},
})
.collect()
}
impl Tree {
/// Shows a commit from blame in History.
fn show_commit(&mut self, commit: &str, cx: &mut Context<Self>) {
self.look = None;
self.set_side(Side::History, cx);
self.pick_commit(commit, cx);
}
pub fn look_pane(&self, look: &Look, cx: &mut Context<Self>) -> AnyElement {
let colors = cx.theme().colors.clone();
let (key, path) = match look {
Look::FileLog { path, .. } => ("tree.file_log.of", path),
Look::Blame { path, .. } => ("tree.blame.of", path),
};
let this = cx.weak_entity();
let head = div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1p5()
.border_b_1()
.border_color(colors.border)
.child(div().flex_1().min_w_0().text_color(colors.fg).child(text(
cx,
key,
&[("path", path)],
)))
.child(
IconButton::new("tree-look-close", IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(text(cx, "tree.close_look", &[]))
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.close_look(cx)).ok();
}),
);
let body = match look {
Look::FileLog { touches: None, .. } | Look::Blame { read: None, .. } => {
div().into_any_element()
}
Look::FileLog {
touches: Some(Err(error)),
..
}
| Look::Blame {
read: Some(Err(error)),
..
} => ErrorView::new(
"tree-look-error",
IconName::TriangleAlert,
text(cx, "tree.look.unread", &[]),
)
.detail(error.clone())
.into_any_element(),
Look::FileLog {
touches: Some(Ok(touches)),
rows,
picked,
view,
repo,
..
} => {
let this = cx.weak_entity();
let list = FileHistory::new(("tree-file-log", *repo), rows.clone())
.when_some(
picked.map(|at| touches[at].commit.id.clone()),
|list, id| list.selected(id),
)
.on_pick({
let ids: Vec<String> = touches
.iter()
.map(|touch| touch.commit.id.clone())
.collect();
move |id, _, cx| {
if let Some(at) = ids.iter().position(|known| known == id.as_ref()) {
this.update(cx, |tree, cx| tree.pick_touch(at, cx)).ok();
}
}
});
let shown = match picked {
Some(at) => {
let id = SharedString::from(format!(
"tree-file-log-diff-{}",
touches[*at].commit.id
));
self.view_only(id.into(), &touches[*at].path, view.as_ref(), cx)
}
None => EmptyState::new(
"tree-file-log-none",
IconName::History,
text(cx, "tree.commit.pick", &[]),
)
.into_any_element(),
};
div()
.size_full()
.flex()
.child(
div()
.flex_none()
.w_96()
.h_full()
.p_2()
.border_r_1()
.border_color(colors.border)
.child(list),
)
.child(div().flex_1().min_w_0().h_full().child(shown))
.into_any_element()
}
Look::Blame {
read: Some(Ok(blamed)),
repo,
..
} => {
let uncommitted = text(cx, "tree.blame.uncommitted", &[]);
let this = cx.weak_entity();
BlameView::new(
("tree-blame", *repo),
blamed.code.clone(),
blamed.lines.clone(),
blames(blamed, &uncommitted),
)
.on_commit(move |commit, _, cx| {
// Lines not yet committed have no commit to show.
if !commit.bytes().all(|b| b == b'0') {
this.update(cx, |tree, cx| tree.show_commit(commit, cx))
.ok();
}
})
.into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.child(head)
.child(div().flex_1().min_h_0().child(body))
.into_any_element()
}
}
+53
View File
@@ -0,0 +1,53 @@
mod asks;
mod asks_places;
mod blame;
mod branches;
mod changes;
mod cloning;
mod commit;
mod commits;
mod committing;
mod conflict;
mod conflict_pane;
mod detail;
mod diff;
mod git;
mod hist;
mod hist_pane;
mod look;
mod look_pane;
mod menu;
mod moves;
mod network;
mod ops;
mod pane;
mod pick;
mod places;
mod places_pane;
mod recover;
mod recovering;
mod refs_pane;
mod remotes;
mod repo;
mod running;
mod shown;
mod side_log;
mod side_places;
mod side_refs;
mod stash;
mod status;
mod structure;
mod tools;
mod tree;
mod view;
mod watch;
mod work;
#[cfg(test)]
mod tests;
use ely_platform::AppId;
fn main() {
ely_ui::run(AppId::Tree, tree::open);
}
+97
View File
@@ -0,0 +1,97 @@
//! The Repository menu, and File's New and Open as repositories.
use ely_ui::commands;
use std::path::PathBuf;
use gpui::{App, Context, KeyBinding, Menu, MenuItem, PathPromptOptions, Window, actions};
use crate::tree::{Tree, text};
actions!(
tree,
[
CloneRepository,
CloseRepository,
Fetch,
Pull,
Push,
ForcePush,
OpenTerminal,
Reveal,
CleanUntracked,
ChooseEditor
]
);
pub const CONTEXT: &str = "tree";
pub fn bind(cx: &mut App) {
cx.bind_keys([KeyBinding::new(
"secondary-w",
CloseRepository,
Some(CONTEXT),
)]);
}
pub fn menus(cx: &App) -> Vec<Menu> {
vec![Menu::new(text(cx, "tree.menu.repository", &[])).items([
MenuItem::action(text(cx, "tree.clone", &[]), CloneRepository),
MenuItem::action(text(cx, "tree.close", &[]), CloseRepository),
MenuItem::separator(),
MenuItem::action(text(cx, "tree.net.fetch", &[]), Fetch),
MenuItem::action(text(cx, "tree.net.pull", &[]), Pull),
MenuItem::action(text(cx, "tree.net.push", &[]), Push),
MenuItem::action(text(cx, "tree.force.open", &[]), ForcePush),
MenuItem::separator(),
MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal),
MenuItem::action(text(cx, "tree.tool.reveal", &[]), Reveal),
MenuItem::action(text(cx, "tree.clean.open", &[]), CleanUntracked),
MenuItem::action(text(cx, "tree.tool.editor", &[]), ChooseEditor),
])]
}
impl Tree {
/// Asks for folders; `then` gets each one picked.
fn pick(
&mut self,
multiple: bool,
then: fn(&mut Tree, PathBuf, &mut Context<Tree>),
cx: &mut Context<Self>,
) {
let picked = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple,
prompt: None,
});
cx.spawn(async move |this, cx| {
let picked = picked
.await
.map_err(|error| error.to_string())
.and_then(|picked| picked.map_err(|error| error.to_string()));
this.update(cx, |tree, cx| match picked {
Ok(Some(paths)) => paths.into_iter().for_each(|path| then(tree, path, cx)),
Ok(None) => log::info!("tree: the folder panel was cancelled"),
Err(error) => tree.failed("tree.error.panel", &error, cx),
})
.ok();
})
.detach();
}
pub fn on_open(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context<Self>) {
self.pick(true, Tree::open_path, cx);
}
pub fn on_new(&mut self, _: &commands::New, _: &mut Window, cx: &mut Context<Self>) {
self.pick(false, Tree::init_at, cx);
}
pub fn on_clone(&mut self, _: &CloneRepository, window: &mut Window, cx: &mut Context<Self>) {
self.ask_url(window, cx);
}
pub fn on_close(&mut self, _: &CloseRepository, _: &mut Window, cx: &mut Context<Self>) {
self.close_active(cx);
}
}
+320
View File
@@ -0,0 +1,320 @@
//! Branches, tags, merge, rebase, pick and revert (TREE-07, TREE-08).
use ely_gpui_component::{forms::TextInput, primitives::Severity};
use gpui::{AppContext as _, Context, SharedString, Window};
use crate::{
asks::Ask,
branches::{self, Kind},
ops::{self, Op},
tree::{Tree, text},
work::Write,
};
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
/// `-m 1`: a merge commit goes against its first parent.
fn mainline(parents: usize) -> Vec<String> {
if parents > 1 {
args(&["-m", "1"])
} else {
Vec::new()
}
}
impl Tree {
/// Rereads branches and tags; only the latest read lands.
pub fn read_refs(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.opened_mut(id) else {
return;
};
opened.refs_asks += 1;
let (root, ask) = (opened.repo.root.clone(), opened.refs_asks);
let task = cx.background_spawn(async move { branches::read(&git, &root) });
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| {
if let Some(opened) = tree.opened_mut(id).filter(|opened| opened.refs_asks == ask) {
opened.refs = Some(read);
cx.notify();
}
})
.ok();
})
.detach();
}
/// Rereads what operation is under way.
pub fn read_doing(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.opened_mut(id) else {
return;
};
opened.doing_asks += 1;
let (root, dir, ask) = (
opened.repo.root.clone(),
opened.repo.git_dir.clone(),
opened.doing_asks,
);
let task = cx.background_spawn(async move { ops::doing(&git, &root, &dir) });
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| {
if let Some(opened) = tree
.opened_mut(id)
.filter(|opened| opened.doing_asks == ask)
{
opened.doing = Some(read);
cx.notify();
}
})
.ok();
})
.detach();
}
/// A failure: a conflict stop says so, else Git's words.
pub fn git_failed(&mut self, stopped: bool, error: String, cx: &mut Context<Self>) {
if stopped {
let words = text(cx, "tree.op.stopped", &[]);
return self.toast(words, Severity::Warning, cx);
}
self.ask = Some(Ask::Failed {
title: text(cx, "tree.op.failed", &[]),
detail: error.into(),
});
cx.notify();
}
pub(crate) fn run_git(&mut self, words: Vec<String>, cx: &mut Context<Self>) {
let Some(id) = self.active else { return };
log::info!("tree: git {}", words.first().map_or("", String::as_str));
self.write(id, Write::Git(words), cx);
}
/// Asks before a command; each yes counts once.
pub(crate) fn confirm(
&mut self,
key: &str,
name: &str,
words: Vec<String>,
cx: &mut Context<Self>,
) {
let Some(repo) = self.active else { return };
self.ask = Some(Ask::Confirm {
repo,
title: text(cx, &format!("{key}.title"), &[("name", name)]),
message: text(cx, &format!("{key}.message"), &[("name", name)]),
go: text(cx, &format!("{key}.go"), &[]),
write: Write::Git(words),
});
cx.notify();
}
pub fn switch(&mut self, name: &str, kind: Kind, cx: &mut Context<Self>) {
match kind {
// Track by full ref, never a same-named tag.
Kind::Remote => self.run_git(
args(&["switch", "--track", &format!("refs/remotes/{name}")]),
cx,
),
_ => self.run_git(args(&["switch", name]), cx),
}
}
/// Asks which remote branch `branch` tracks.
pub fn ask_upstream(&mut self, branch: &str, cx: &mut Context<Self>) {
let choices: Vec<String> = match self.active().and_then(|opened| opened.refs.as_ref()) {
Some(Ok(refs)) => refs
.iter()
.filter(|found| found.kind == Kind::Remote)
.map(|found| found.name.clone())
.collect(),
_ => Vec::new(),
};
if choices.is_empty() {
let words = text(cx, "tree.upstream.none", &[]);
return self.toast(words, Severity::Warning, cx);
}
let Some(repo) = self.active else { return };
self.ask = Some(Ask::Choose {
repo,
title: text(cx, "tree.upstream.title", &[("branch", branch)]),
choices,
then: crate::asks::Choice::Upstream(branch.to_string()),
});
cx.notify();
}
pub fn set_upstream(&mut self, repo: u64, branch: &str, remote: &str, cx: &mut Context<Self>) {
let to = format!("--set-upstream-to=refs/remotes/{remote}");
self.write(repo, Write::Git(args(&["branch", &to, "--", branch])), cx);
}
pub fn unset_upstream(&mut self, branch: &str, cx: &mut Context<Self>) {
self.run_git(args(&["branch", "--unset-upstream", "--", branch]), cx);
}
pub fn merge(&mut self, name: &str, cx: &mut Context<Self>) {
self.run_git(args(&["merge", name]), cx);
}
/// `onto` is the full ref; `name` is how it reads.
pub fn rebase(&mut self, onto: &str, name: &str, cx: &mut Context<Self>) {
self.confirm("tree.rebase", name, args(&["rebase", onto]), cx);
}
pub fn delete_branch(&mut self, name: &str, force: bool, cx: &mut Context<Self>) {
log::info!("tree: delete of {name} asked, force {force}");
let (key, flag) = if force {
("tree.force_delete", "-D")
} else {
("tree.delete_branch", "-d")
};
self.confirm(key, name, args(&["branch", flag, name]), cx);
}
pub fn delete_tag(&mut self, name: &str, cx: &mut Context<Self>) {
self.confirm("tree.delete_tag", name, args(&["tag", "-d", name]), cx);
}
pub fn cherry_pick(&mut self, commit: &str, parents: usize, cx: &mut Context<Self>) {
let mut words = args(&["cherry-pick"]);
words.extend(mainline(parents));
words.push(commit.to_string());
self.run_git(words, cx);
}
pub fn revert(&mut self, commit: &str, parents: usize, cx: &mut Context<Self>) {
let mut words = args(&["revert"]);
words.extend(mainline(parents));
words.push(commit.to_string());
self.run_git(words, cx);
}
pub fn op_continue(&mut self, op: Op, cx: &mut Context<Self>) {
self.run_git(args(&[op.verb(), "--continue"]), cx);
}
/// Abort and skip throw work away; each asks.
pub fn op_abort(&mut self, op: Op, cx: &mut Context<Self>) {
let name = text(cx, &format!("tree.opname.{}", op.verb()), &[]);
self.confirm("tree.abort", &name, args(&[op.verb(), "--abort"]), cx);
}
pub fn op_skip(&mut self, op: Op, cx: &mut Context<Self>) {
let name = text(cx, &format!("tree.opname.{}", op.verb()), &[]);
self.confirm("tree.skip", &name, args(&[op.verb(), "--skip"]), cx);
}
pub(crate) fn field_with(
&self,
value: &str,
key: &str,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Entity<TextInput> {
let label = text(cx, key, &[]);
let value = value.to_string();
cx.new(|cx| {
let mut field = TextInput::new(window, cx).label(label);
field.set_text(value, cx);
field
})
}
/// A new branch at `at`, or at HEAD.
pub fn ask_branch(&mut self, at: Option<String>, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo) = self.active else { return };
let name = self.field_with("", "tree.branch.name", window, cx);
self.ask = Some(Ask::NewBranch {
repo,
name,
at,
switch: true,
});
cx.notify();
}
pub fn ask_tag(&mut self, at: Option<String>, window: &mut Window, cx: &mut Context<Self>) {
let name = self.field_with("", "tree.tag.name", window, cx);
let message = self.field_with("", "tree.tag.message", window, cx);
let Some(repo) = self.active else { return };
self.ask = Some(Ask::NewTag {
repo,
name,
message,
at,
});
cx.notify();
}
pub fn ask_rename(&mut self, old: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo) = self.active else { return };
let name = self.field_with(old, "tree.branch.name", window, cx);
self.ask = Some(Ask::Rename {
repo,
old: old.to_string(),
name,
});
cx.notify();
}
/// The dialog's fields as arguments; empty names wait.
pub fn answer(&mut self, cx: &mut Context<Self>) {
let words = match &self.ask {
Some(Ask::NewBranch {
repo,
name,
at,
switch,
}) => {
let name = name.read(cx).text().trim().to_string();
let mut words = if *switch {
args(&["switch", "-c", &name])
} else {
args(&["branch", &name])
};
words.extend(at.clone());
(*repo, name, words)
}
Some(Ask::NewTag {
repo,
name,
message,
at,
}) => {
let (name, message) = (
name.read(cx).text().trim().to_string(),
message.read(cx).text().trim().to_string(),
);
let mut words = match message.is_empty() {
true => args(&["tag", &name]),
false => args(&["tag", "-a", &name, "-m", &message]),
};
words.extend(at.clone());
(*repo, name, words)
}
Some(Ask::Rename { repo, old, name }) => {
let name = name.read(cx).text().trim().to_string();
(*repo, name.clone(), args(&["branch", "-m", old, &name]))
}
_ => return self.answer_places(cx),
};
let (repo, name, words) = words;
if name.is_empty() {
let words: SharedString = text(cx, "tree.name.empty", &[]);
return self.toast(words, Severity::Warning, cx);
}
self.ask = None;
self.write(repo, Write::Git(words), cx);
cx.notify();
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Fetch, pull and push, with progress and a stop (TREE-11).
use std::io;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::Alert,
primitives::Severity,
theme::ControlSize,
};
use futures::{
StreamExt as _,
channel::{mpsc, oneshot},
};
use gpui::{AnyElement, AppContext as _, Context, IntoElement, SharedString};
use crate::{
asks::{Ask, Choice},
ops, remotes,
running::Stop,
tree::{Tree, text},
work::Write,
};
/// A remote run under way in one repository.
pub struct Net {
pub verb: String,
pub said: SharedString,
pub stop: Stop,
/// Asked to stop; its failure is then expected.
pub stopping: bool,
}
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
impl Tree {
/// Runs a queued remote write, line by line.
pub(crate) fn start_remote(&mut self, id: u64, args: Vec<String>, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.opened_mut(id) else {
return;
};
let root = opened.repo.root.clone();
let words: Vec<&str> = args.iter().map(String::as_str).collect();
let running = match git.start(&root, &words) {
Ok(running) => running,
Err(error) => return self.written(id, &Write::Remote(args), Err(error), false, cx),
};
opened.net = Some(Net {
verb: args.first().cloned().unwrap_or_default(),
said: SharedString::default(),
stop: running.stop(),
stopping: false,
});
let (said, mut lines) = mpsc::unbounded();
let (done, result) = oneshot::channel();
let asked = args.clone();
// Git's progress blocks a thread until it ends.
std::thread::spawn(move || {
let followed = running.follow(|line| {
let _ = said.unbounded_send(SharedString::from(line.to_string()));
});
let stopped = match &followed {
Err(error) => ops::stopped(&git, &root, &asked, &error.to_string()),
Ok(_) => false,
};
let _ = done.send((followed.map(drop), stopped));
});
cx.spawn(async move |this, cx| {
while let Some(line) = lines.next().await {
let shown = this.update(cx, |tree, cx| {
if let Some(net) = tree.opened_mut(id).and_then(|o| o.net.as_mut()) {
net.said = line;
cx.notify();
}
});
if shown.is_err() {
return;
}
}
let (ended, stopped) = result
.await
.unwrap_or_else(|_| (Err(io::Error::other("the run ended without a word")), false));
this.update(cx, |tree, cx| {
tree.remote_done(id, args, ended, stopped, cx)
})
.ok();
})
.detach();
cx.notify();
}
fn remote_done(
&mut self,
id: u64,
args: Vec<String>,
ended: io::Result<()>,
stopped: bool,
cx: &mut Context<Self>,
) {
let Some(opened) = self.opened_mut(id) else {
return;
};
let net = opened.net.take();
if ended.is_err() && net.is_some_and(|net| net.stopping) {
// Asked for; later writes counted on it.
opened.writes.clear();
let words = text(cx, "tree.net.stopped", &[]);
self.toast(words, Severity::Info, cx);
return self.written(id, &Write::Remote(args), Ok(()), false, cx);
}
self.written(id, &Write::Remote(args), ended, stopped, cx);
}
/// Stops this repository's remote run; Git cleans up.
pub fn stop_remote(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(net) = self.opened_mut(id).and_then(|opened| opened.net.as_mut()) else {
return;
};
net.stopping = true;
if let Err(error) = net.stop.now() {
self.failed("tree.error.stop", &error.to_string(), cx);
}
cx.notify();
}
/// Nothing runs on behind a closed window.
pub fn stop_all(&mut self, cx: &mut Context<Self>) {
self.stop_clone(cx);
let running: Vec<u64> = self
.repos
.iter()
.filter(|opened| opened.net.is_some())
.map(|opened| opened.id)
.collect();
for id in running {
self.stop_remote(id, cx);
}
}
fn remote(&mut self, words: &[&str], cx: &mut Context<Self>) {
let Some(id) = self.active else { return };
self.write(id, Write::Remote(args(words)), cx);
}
pub fn fetch_all(&mut self, cx: &mut Context<Self>) {
self.remote(&["fetch", "--progress", "--all"], cx);
}
pub fn fetch_one(&mut self, name: &str, cx: &mut Context<Self>) {
self.remote(&["fetch", "--progress", "--", name], cx);
}
pub fn pull(&mut self, cx: &mut Context<Self>) {
self.remote(&["pull", "--progress"], cx);
}
/// With no upstream, asks where it goes first.
pub fn push(&mut self, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.active() else { return };
let Some(Ok(status)) = &opened.status else {
return;
};
let Some(branch) = status.head.branch.clone() else {
let words = text(cx, "tree.push.detached", &[]);
return self.toast(words, Severity::Warning, cx);
};
if status.head.upstream.is_some() {
return self.remote(&["push", "--progress"], cx);
}
let (repo, root) = (opened.id, opened.repo.root.clone());
let task = cx.background_spawn(async move { remotes::list(&git, &root) });
cx.spawn(async move |this, cx| {
let read = task.await;
this.update(cx, |tree, cx| {
if tree.active != Some(repo) {
return;
}
tree.ask = Some(match read {
Ok(list) if list.is_empty() => {
let words = text(cx, "tree.push.no_remote", &[]);
return tree.toast(words, Severity::Warning, cx);
}
Ok(list) => Ask::Choose {
repo,
title: text(cx, "tree.push.where", &[("branch", &branch)]),
choices: list.into_iter().map(|remote| remote.name).collect(),
then: Choice::Publish(branch),
},
Err(error) => Ask::Failed {
title: text(cx, "tree.remotes.unread", &[]),
detail: error.to_string().into(),
},
});
cx.notify();
})
.ok();
})
.detach();
}
/// Pushes `branch` to `remote` and tracks it there.
pub fn publish(&mut self, repo: u64, branch: &str, remote: &str, cx: &mut Context<Self>) {
let target = format!("refs/heads/{branch}:refs/heads/{branch}");
let words = [
"push",
"--progress",
"--set-upstream",
"--",
remote,
&target,
];
self.write(repo, Write::Remote(args(&words)), cx);
}
/// Reads what a forced push would overwrite, then asks.
pub fn ask_force(&mut self, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.active() else { return };
let (repo, root) = (opened.id, opened.repo.root.clone());
let task = cx.background_spawn(async move { remotes::force(&git, &root) });
cx.spawn(async move |this, cx| {
let plan = task.await;
this.update(cx, |tree, cx| {
if tree.active != Some(repo) {
return;
}
tree.ask = Some(match plan {
Ok(plan) => Ask::Force { repo, plan },
Err(error) => Ask::Failed {
title: text(cx, "tree.force.unready", &[]),
detail: error.to_string().into(),
},
});
cx.notify();
})
.ok();
})
.detach();
}
/// The running remote command, and its stop.
pub fn net_alert(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let opened = self.active()?;
let net = opened.net.as_ref()?;
let id = opened.id;
let verb = text(cx, &format!("tree.net.running.{}", net.verb), &[]);
let this = cx.weak_entity();
let stop = Button::new("tree-net-stop", text(cx, "tree.net.stop", &[]))
.size(ControlSize::Sm)
.variant(ButtonVariant::Secondary)
.disabled(net.stopping)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.stop_remote(id, cx)).ok();
});
Some(
Alert::new("tree-net", Severity::Info, verb)
.body(net.said.clone())
.action(stop.into_any_element())
.into_any_element(),
)
}
}
+121
View File
@@ -0,0 +1,121 @@
//! A merge, rebase, cherry-pick or revert under way (TREE-08, §13.3).
use std::{io, path::Path};
use crate::git::Git;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Op {
Merge,
Rebase,
CherryPick,
Revert,
}
impl Op {
/// The Git command that continues, aborts or skips it.
pub fn verb(self) -> &'static str {
match self {
Op::Merge => "merge",
Op::Rebase => "rebase",
Op::CherryPick => "cherry-pick",
Op::Revert => "revert",
}
}
/// A merge has no step to skip.
pub fn skips(self) -> bool {
self != Op::Merge
}
}
/// The operation, and what its two sides stand for now.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Doing {
pub op: Op,
/// Git's "ours": what HEAD holds as the operation stands.
pub ours: String,
/// Git's "theirs": what is being brought in.
pub theirs: String,
}
/// Stopped on a conflict: Git said so and left some.
pub fn stopped(git: &Git, root: &Path, args: &[String], said: &str) -> bool {
let operation = matches!(
args.first().map(String::as_str),
Some("merge" | "rebase" | "cherry-pick" | "revert" | "pull")
);
if !operation || !said.lines().any(|line| line.starts_with("CONFLICT (")) {
return false;
}
match git.run(root, &["ls-files", "--unmerged", "-z"]) {
Ok(unmerged) => !unmerged.is_empty(),
Err(error) => {
log::error!("tree: unmerged entries unread: {error}");
false
}
}
}
/// A commit as `abc1234 subject`.
fn describe(git: &Git, root: &Path, rev: &str) -> io::Result<String> {
let said = git.run(root, &["log", "-1", "--format=%h %s", rev, "--"])?;
Ok(String::from_utf8_lossy(&said).trim().to_string())
}
/// The branch HEAD is on, or its commit when detached.
fn head(git: &Git, root: &Path) -> io::Result<String> {
let branch = git.run_or_one(root, &["symbolic-ref", "--short", "-q", "HEAD"])?;
match String::from_utf8_lossy(&branch).trim() {
"" => describe(git, root, "HEAD"),
branch => Ok(branch.to_string()),
}
}
/// What is under way in the work tree of `git_dir`.
pub fn doing(git: &Git, root: &Path, git_dir: &Path) -> io::Result<Option<Doing>> {
let has = |name: &str| git_dir.join(name).exists();
let read = |name: &str| {
std::fs::read_to_string(git_dir.join(name)).map(|text| text.trim().to_string())
};
let doing = if has("rebase-merge") || has("rebase-apply") {
let dir = if has("rebase-merge") {
"rebase-merge"
} else {
"rebase-apply"
};
// In a rebase, ours is the base; theirs, the replay.
let onto = describe(git, root, &read(&format!("{dir}/onto"))?)?;
let theirs = if has("REBASE_HEAD") {
describe(git, root, "REBASE_HEAD")?
} else {
String::new()
};
Doing {
op: Op::Rebase,
ours: onto,
theirs,
}
} else if has("MERGE_HEAD") {
Doing {
op: Op::Merge,
ours: head(git, root)?,
theirs: describe(git, root, "MERGE_HEAD")?,
}
} else if has("CHERRY_PICK_HEAD") {
Doing {
op: Op::CherryPick,
ours: head(git, root)?,
theirs: describe(git, root, "CHERRY_PICK_HEAD")?,
}
} else if has("REVERT_HEAD") {
Doing {
op: Op::Revert,
ours: head(git, root)?,
theirs: describe(git, root, "REVERT_HEAD")?,
}
} else {
return Ok(None);
};
Ok(Some(doing))
}
+341
View File
@@ -0,0 +1,341 @@
//! The middle: the shown file's diff, picture or size (TREE-03).
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
data_display::BeforeAfter,
feedback::{EmptyState, ErrorView},
git::{ChangeSection, DiffLayout, DiffStat, DiffViewer, LineKind, Stretch},
primitives::{IconName, Image},
theme::{ActiveTheme as _, ControlSize},
typography::format,
};
use gpui::{
AnyElement, Context, ElementId, IntoElement, ObjectFit, ParentElement as _, Styled as _, div,
prelude::FluentBuilder as _,
};
use crate::{
diff::Space,
pick::Shown,
shown::View,
tree::{Tree, text},
};
fn space_key(space: Space) -> &'static str {
match space {
Space::Exact => "exact",
Space::Changes => "changes",
Space::All => "all",
}
}
impl Tree {
/// The header: path, counts, whitespace, layout, picked lines.
fn pane_head(&self, shown: &Shown, cx: &mut Context<Self>) -> AnyElement {
let colors = cx.theme().colors.clone();
let this = cx.weak_entity();
let entry_from = self.active().and_then(|opened| match &opened.status {
Some(Ok(status)) => status
.entries
.iter()
.find(|entry| entry.path == shown.path)?
.from
.clone(),
_ => None,
});
let path = match (&entry_from, shown.section) {
(Some(from), ChangeSection::Staged) => format!("{from} → {}", shown.path),
_ => shown.path.clone(),
};
let stat = match &shown.view {
Some(Ok(View::Text { stretches, .. })) => Some(count(stretches)),
_ => None,
};
let text_view = matches!(shown.view, Some(Ok(View::Text { .. })));
let space = SegmentedControl::new("tree-space", space_key(self.space))
.size(ControlSize::Sm)
.segment("exact", text(cx, "tree.space.exact", &[]), None)
.segment("changes", text(cx, "tree.space.changes", &[]), None)
.segment("all", text(cx, "tree.space.all", &[]), None)
.on_change({
let this = this.clone();
move |key, _, cx| {
let space = match key.as_ref() {
"changes" => Space::Changes,
"all" => Space::All,
_ => Space::Exact,
};
this.update(cx, |tree, cx| tree.set_space(space, cx)).ok();
}
});
let layout = SegmentedControl::new(
"tree-layout",
if self.layout == DiffLayout::Split {
"split"
} else {
"unified"
},
)
.size(ControlSize::Sm)
.segment("unified", text(cx, "tree.layout.unified", &[]), None)
.segment("split", text(cx, "tree.layout.split", &[]), None)
.on_change({
let this = this.clone();
move |key, _, cx| {
let layout = if key.as_ref() == "split" {
DiffLayout::Split
} else {
DiffLayout::Unified
};
this.update(cx, |tree, cx| {
tree.layout = layout;
cx.notify();
})
.ok();
}
});
let key = if shown.section == ChangeSection::Staged {
"tree.lines.unstage"
} else {
"tree.lines.stage"
};
let lines = (!shown.picked.is_empty() && self.partial(shown)).then(|| {
let this = this.clone();
let n = shown.picked.len().to_string();
Button::new("tree-lines", text(cx, key, &[("n", &n)]))
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.stage_picked(cx)).ok();
})
});
// History and blame need a file Git already tracks.
let tracked = matches!(
shown.section,
ChangeSection::Staged | ChangeSection::Unstaged | ChangeSection::Conflicted
);
let looks = tracked.then(|| self.look_buttons(&shown.path, cx));
let open = {
let (this, path) = (this.clone(), shown.path.clone());
IconButton::new("tree-open-file", IconName::ExternalLink)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(text(cx, "tree.open_file", &[]))
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.open_file(&path, cx)).ok();
})
};
let tools = self.tool_buttons(&shown.path, cx);
div()
.flex()
.items_center()
.gap_3()
.px_2()
.py_1p5()
.border_b_1()
.border_color(colors.border)
.child(div().flex_1().min_w_0().text_color(colors.fg).child(path))
.children(stat.map(|(added, removed)| DiffStat::new(added, removed)))
.children(lines)
.when(text_view, |head| head.child(space).child(layout))
.children(looks.into_iter().flatten())
.children(tools)
.child(open)
.into_any_element()
}
/// Hunks and lines stage only on an exact plain diff.
fn partial(&self, shown: &Shown) -> bool {
let staging = matches!(
shown.section,
ChangeSection::Staged | ChangeSection::Unstaged
);
let plain = matches!(&shown.view, Some(Ok(View::Text { diff, .. })) if diff.partial());
staging && plain && self.space == Space::Exact
}
/// Why hunks and lines do not stage here.
fn whole_note(&self, shown: &Shown, cx: &mut Context<Self>) -> Option<AnyElement> {
let staging = matches!(
shown.section,
ChangeSection::Staged | ChangeSection::Unstaged | ChangeSection::Untracked
);
if !matches!(shown.view, Some(Ok(View::Text { .. }))) || !staging || self.partial(shown) {
return None;
}
let key = if self.space == Space::Exact {
"tree.whole"
} else {
"tree.space.note"
};
let colors = cx.theme().colors.clone();
Some(
div()
.px_2()
.py_1()
.text_color(colors.fg_muted)
.child(text(cx, key, &[]))
.into_any_element(),
)
}
pub fn pane(&self, shown: &Shown, cx: &mut Context<Self>) -> AnyElement {
if let Some(Ok(View::Conflict(stages))) = &shown.view {
return self.conflict_pane(&shown.path, stages, cx);
}
let body = match &shown.view {
Some(Ok(View::Text { stretches, .. })) if hunks(stretches) => {
let this = cx.weak_entity();
let (open, line, hunk) = (this.clone(), this.clone(), this);
let mut viewer = DiffViewer::from_stretches(
("tree-diff", shown.repo),
shown.path.clone(),
stretches.clone(),
)
.headless()
.layout(self.layout)
.open(shown.opened.clone())
.on_open(move |stretch, _, cx| {
open.update(cx, |tree, cx| {
if let Some(shown) = &mut tree.shown {
shown.opened.push(stretch);
cx.notify();
}
})
.ok();
});
if self.partial(shown) {
let key = if shown.section == ChangeSection::Staged {
"tree.hunk.unstage"
} else {
"tree.hunk.stage"
};
viewer = viewer
.selected(shown.picked.iter().copied())
.on_line(move |place, shift, _, cx| {
line.update(cx, |tree, cx| tree.pick_line(place, shift, cx))
.ok();
})
.hunk_action(text(cx, key, &[]), move |stretch, _, cx| {
hunk.update(cx, |tree, cx| tree.stage_hunk(stretch, cx))
.ok();
});
}
viewer.into_any_element()
}
view => self.still(view.as_ref(), cx),
};
div()
.size_full()
.flex()
.flex_col()
.child(self.pane_head(shown, cx))
.children(self.whole_note(shown, cx))
.child(div().flex_1().min_h_0().child(body))
.into_any_element()
}
/// A diff with nothing to stage: commits and file history.
pub fn view_only(
&self,
id: ElementId,
path: &str,
view: Option<&Result<View, String>>,
cx: &mut Context<Self>,
) -> AnyElement {
match view {
Some(Ok(View::Text { stretches, .. })) if hunks(stretches) => {
DiffViewer::from_stretches(id, path.to_string(), stretches.clone())
.headless()
.layout(self.layout)
.into_any_element()
}
view => self.still(view, cx),
}
}
/// Everything but a text diff with lines to show.
fn still(&self, view: Option<&Result<View, String>>, cx: &mut Context<Self>) -> AnyElement {
match view {
None => div().into_any_element(),
Some(Err(error)) => ErrorView::new(
"tree-diff-error",
IconName::TriangleAlert,
text(cx, "tree.diff.unread", &[]),
)
.detail(error.clone())
.into_any_element(),
Some(Ok(View::Text { .. })) => EmptyState::new(
"tree-diff-none",
IconName::Check,
text(cx, "tree.diff.none", &[]),
)
.into_any_element(),
Some(Ok(View::Picture { old, new })) => {
let picture = |id: &'static str, image: &std::sync::Arc<gpui::Image>| {
Image::new(id, image.clone())
.fit(ObjectFit::Contain)
.size_full()
};
let shown = match (old, new) {
(Some(old), Some(new)) => BeforeAfter::new(
"tree-picture",
picture("tree-old", old),
picture("tree-new", new),
)
.size_full()
.into_any_element(),
(Some(only), None) | (None, Some(only)) => {
picture("tree-only", only).into_any_element()
}
(None, None) => div().into_any_element(),
};
div().size_full().p_4().child(shown).into_any_element()
}
Some(Ok(View::Binary { old, new })) => {
let size = |bytes: &Option<u64>| {
bytes.map_or_else(|| "—".to_string(), |bytes| format::file_size(bytes, false))
};
let sizes = format!("{} → {}", size(old), size(new));
EmptyState::new("tree-binary", IconName::File, text(cx, "tree.binary", &[]))
.body(sizes)
.into_any_element()
}
Some(Ok(View::Folder)) => EmptyState::new(
"tree-folder",
IconName::Folder,
text(cx, "tree.folder", &[]),
)
.into_any_element(),
Some(Ok(View::Conflict(_))) => EmptyState::new(
"tree-conflicted",
IconName::TriangleAlert,
text(cx, "tree.conflicted", &[]),
)
.into_any_element(),
}
}
}
/// Whether Git printed any hunk; a pure rename has none.
fn hunks(stretches: &[Stretch]) -> bool {
stretches
.iter()
.any(|stretch| matches!(stretch, Stretch::Hunk { .. }))
}
/// Lines added and removed in the hunks.
fn count(stretches: &[Stretch]) -> (usize, usize) {
stretches
.iter()
.filter_map(|stretch| match stretch {
Stretch::Hunk { lines, .. } => Some(lines),
Stretch::Folded(_) => None,
})
.flatten()
.fold((0, 0), |(added, removed), line| match line.kind {
LineKind::Added => (added + 1, removed),
LineKind::Removed => (added, removed + 1),
LineKind::Same => (added, removed),
})
}
+246
View File
@@ -0,0 +1,246 @@
//! The shown file: reading it, picking lines, staging them (TREE-04).
use std::{collections::BTreeSet, sync::Arc};
use ely_gpui_component::git::ChangeSection;
use gpui::{AppContext as _, Context};
use crate::{
diff::{self, Space},
shown::{self, View},
status::{Entry, State},
tree::Tree,
work::Write,
};
/// A line's place in the viewer: stretch, then line.
pub type Place = (usize, usize);
pub struct Shown {
pub repo: u64,
pub section: ChangeSection,
pub path: String,
pub view: Option<Result<View, String>>,
pub picked: BTreeSet<Place>,
/// Where a Shift-press range starts.
pub anchor: Option<Place>,
/// Folded stretches opened.
pub opened: Vec<usize>,
/// Bumped per read; an older read is dropped.
serial: u64,
}
/// Whether `entry` is listed in `section`.
fn listed(entry: &Entry, section: ChangeSection) -> bool {
match (&entry.state, section) {
(State::Tracked { staged, .. }, ChangeSection::Staged) => *staged != '.',
(State::Tracked { unstaged, .. }, ChangeSection::Unstaged) => *unstaged != '.',
(State::Untracked, ChangeSection::Untracked) => true,
(State::Conflicted(_), ChangeSection::Conflicted) => true,
_ => false,
}
}
impl Tree {
/// Shows `path` as listed in `section`.
pub fn show(&mut self, path: &str, section: ChangeSection, cx: &mut Context<Self>) {
let Some(repo) = self.active else { return };
self.shown = Some(Shown {
repo,
section,
path: path.to_string(),
view: None,
picked: BTreeSet::new(),
anchor: None,
opened: Vec::new(),
serial: 0,
});
self.merging = None;
self.read_shown(cx);
}
/// The shown file's entry, if its repository still lists it.
fn shown_entry(&self) -> Option<(Entry, bool)> {
let shown = self.shown.as_ref()?;
let Some(Ok(status)) = &self.opened(shown.repo)?.status else {
return None;
};
let entry = status
.entries
.iter()
.find(|entry| entry.path == shown.path && listed(entry, shown.section))?;
Some((entry.clone(), status.head.oid.is_some()))
}
/// Rereads the shown file; it goes when no longer listed.
pub fn read_shown(&mut self, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some((entry, born)) = self.shown_entry() else {
if self.shown.take().is_some() {
cx.notify();
}
return;
};
let space = self.space;
let Some(repo) = self.shown.as_ref().map(|shown| shown.repo) else {
return;
};
let Some(root) = self.opened(repo).map(|opened| opened.repo.root.clone()) else {
return;
};
let Some(shown) = &mut self.shown else { return };
// Unique across files, so a late read is dropped.
self.reads += 1;
shown.serial = self.reads;
let (serial, section) = (shown.serial, shown.section);
let task =
cx.background_spawn(
async move { shown::read(&git, &root, &entry, section, space, born) },
);
cx.spawn(async move |this, cx| {
let found = task.await;
this.update(cx, |tree, cx| tree.shown_read(serial, found, cx))
.ok();
})
.detach();
}
pub(crate) fn shown_read(
&mut self,
serial: u64,
found: std::io::Result<shown::Found>,
cx: &mut Context<Self>,
) {
let Some(shown) = self.shown.as_mut().filter(|shown| shown.serial == serial) else {
return;
};
let view = found
.and_then(shown::view)
.map_err(|error| error.to_string());
if let Err(error) = &view {
log::warn!("tree: reading {} failed: {error}", shown.path);
}
// Picks hold only while the diff is unchanged.
let same = matches!((&shown.view, &view), (Some(Ok(View::Text { seen: was, .. })), Ok(View::Text { seen: now, .. })) if was == now);
if !same {
(shown.picked, shown.anchor, shown.opened) = (BTreeSet::new(), None, Vec::new());
}
let stages = match &view {
Ok(View::Conflict(stages)) => Some(stages.clone()),
_ => None,
};
shown.view = Some(view);
if let Some(stages) = stages {
self.adopt_merge(stages, cx);
}
cx.notify();
}
/// A conflict read again keeps unsaved hand edits.
fn adopt_merge(&mut self, stages: crate::conflict::Stages, cx: &mut Context<Self>) {
let text = self.merge_editor.read(cx).text().to_string();
match &self.merging {
Some(merging) if merging.stages == stages => {}
Some(merging) if merging.saved != text => {
let words = crate::tree::text(cx, "tree.conflict.kept", &[]);
self.toast(words, ely_gpui_component::primitives::Severity::Warning, cx);
}
_ => {
self.merging = crate::conflict::Merging::new(stages);
let work = self
.merging
.as_ref()
.map(|merging| merging.saved.clone())
.unwrap_or_default();
self.merge_editor
.update(cx, |editor, cx| editor.set_text(work, cx));
}
}
}
pub fn set_space(&mut self, space: Space, cx: &mut Context<Self>) {
self.space = space;
self.read_shown(cx);
cx.notify();
}
/// Toggles a line; Shift takes a range.
pub fn pick_line(&mut self, place: Place, shift: bool, cx: &mut Context<Self>) {
let Some(shown) = &mut self.shown else { return };
let range = shown.anchor.filter(|anchor| shift && anchor.0 == place.0);
match range {
Some(anchor) => {
let (from, to) = (anchor.1.min(place.1), anchor.1.max(place.1));
shown.picked.extend((from..=to).map(|line| (place.0, line)));
}
None if !shown.picked.remove(&place) => {
shown.picked.insert(place);
}
None => {}
}
shown.anchor = Some(place);
cx.notify();
}
/// Stages, or unstages, every change in one hunk.
pub fn stage_hunk(&mut self, stretch: usize, cx: &mut Context<Self>) {
let Some(Shown {
view: Some(Ok(View::Text { stretches, .. })),
..
}) = &self.shown
else {
return;
};
let Some(ely_gpui_component::git::Stretch::Hunk { lines, .. }) = stretches.get(stretch)
else {
return;
};
let places: BTreeSet<Place> = (0..lines.len()).map(|line| (stretch, line)).collect();
self.stage_places(&places, cx);
}
pub fn stage_picked(&mut self, cx: &mut Context<Self>) {
let Some(shown) = &self.shown else { return };
let picked = shown.picked.clone();
self.stage_places(&picked, cx);
}
/// Cuts a patch of `places` and queues it.
fn stage_places(&mut self, places: &BTreeSet<Place>, cx: &mut Context<Self>) {
let Some((entry, _)) = self.shown_entry() else {
return;
};
let space = self.space;
let Some(shown) = &self.shown else { return };
let Some(Ok(View::Text {
diff, seen, hunks, ..
})) = &shown.view
else {
return;
};
let chosen: BTreeSet<(usize, usize)> = places
.iter()
.filter_map(|(stretch, line)| {
hunks
.get(*stretch)
.copied()
.flatten()
.map(|hunk| (hunk, *line))
})
.collect();
let reverse = shown.section == ChangeSection::Staged;
let Some(patch) = diff::patch(diff, &chosen, reverse) else {
return log::info!("tree: no changed line was picked");
};
let write = Write::Patch {
patch,
reverse,
check: shown::args(&entry, shown.section, space),
seen: Arc::clone(seen),
};
let repo = shown.repo;
self.write(repo, write, cx);
}
}
+319
View File
@@ -0,0 +1,319 @@
//! Stashes, remotes, worktrees, submodules, LFS (TREE-10 to 12).
use std::path::{Path, PathBuf};
use ely_gpui_component::{forms::TextInput, git::StashAction, primitives::Severity};
use gpui::{AppContext as _, Context, Window};
use crate::{
asks::Ask,
git::Git,
hist::{Picked, Whose},
recover,
remotes::{self, Remote},
stash::{self, Stash},
structure::{self, LfsFile, Submodule, Worktree},
tree::{Tree, text},
work::Write,
};
/// The Repository panel's lists for one repository.
#[derive(Default)]
pub struct Places {
pub read: Option<Read>,
/// Reads started; only the latest lands.
pub asks: u64,
/// The stash shown in the middle.
pub pick: Picked,
}
/// Each list apart; one failing hides none.
pub struct Read {
pub stashes: Result<Vec<Stash>, String>,
pub remotes: Result<Vec<Remote>, String>,
pub worktrees: Result<Vec<Worktree>, String>,
pub submodules: Result<Vec<Submodule>, String>,
pub lfs: Result<Vec<LfsFile>, String>,
pub reflog: Result<Vec<recover::Moved>, String>,
}
fn read_all(git: &Git, root: &Path) -> Read {
let said = |error: std::io::Error| error.to_string();
Read {
stashes: stash::list(git, root).map_err(said),
remotes: remotes::list(git, root).map_err(said),
worktrees: structure::worktrees(git, root).map_err(said),
submodules: structure::submodules(git, root).map_err(said),
lfs: structure::lfs(git, root).map_err(said),
reflog: recover::reflog(git, root).map_err(said),
}
}
/// What the Repository panel has picked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Place {
Stash(String),
Remote(String),
Worktree(PathBuf),
Submodule(String),
/// One reflog event, known by itself, not its place.
Reflog(recover::Moved),
}
fn args(words: &[&str]) -> Vec<String> {
words.iter().map(|word| word.to_string()).collect()
}
impl Tree {
pub fn read_places(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.opened_mut(id) else {
return;
};
opened.places.asks += 1;
let (root, ask) = (opened.repo.root.clone(), opened.places.asks);
let task = cx.background_spawn(async move { read_all(&git, &root) });
cx.spawn(async move |this, cx| {
let read = task.await;
this.update(cx, |tree, cx| {
let Some(places) = tree
.opened_mut(id)
.map(|opened| &mut opened.places)
.filter(|places| places.asks == ask)
else {
return;
};
places.read = Some(read);
cx.notify();
})
.ok();
})
.detach();
}
pub fn pick_place(&mut self, place: Place, cx: &mut Context<Self>) {
match &place {
Place::Stash(oid) => self.pick_in(Whose::Stash, oid, cx),
Place::Reflog(moved) => self.pick_in(Whose::Reflog, &moved.oid, cx),
_ => {}
}
self.picked_place = Some(place);
cx.notify();
}
/// The stash list as last read.
pub fn stashes(&self) -> &[Stash] {
match self.active().and_then(|opened| opened.places.read.as_ref()) {
Some(Read {
stashes: Ok(stashes),
..
}) => stashes,
_ => &[],
}
}
pub fn ask_stash(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo) = self.active else { return };
let label = text(cx, "tree.stash.message", &[]);
let message = cx.new(|cx| TextInput::new(window, cx).label(label));
self.ask = Some(Ask::Stash {
repo,
message,
untracked: false,
});
cx.notify();
}
/// Apply and pop run; a drop asks each time.
pub fn stash_act(&mut self, at: usize, action: StashAction, cx: &mut Context<Self>) {
let Some(repo) = self.active else { return };
let Some(oid) = self.stashes().get(at).map(|stash| stash.oid.clone()) else {
return;
};
let verb = match action {
StashAction::Apply => "apply",
StashAction::Pop => "pop",
StashAction::Drop => "drop",
};
let write = Write::Stash { verb, at, oid };
if action != StashAction::Drop {
return self.write(repo, write, cx);
}
let name = format!("stash@{{{at}}}");
self.ask = Some(Ask::Confirm {
repo,
title: text(cx, "tree.stash.drop.title", &[("name", &name)]),
message: text(cx, "tree.stash.drop.message", &[("name", &name)]),
go: text(cx, "tree.stash.drop.go", &[]),
write,
});
cx.notify();
}
/// A new remote, or `old` renamed or moved.
pub fn ask_remote(&mut self, old: Option<Remote>, window: &mut Window, cx: &mut Context<Self>) {
let (name, url) = match &old {
Some(remote) => (
remote.name.clone(),
remote.urls.first().cloned().unwrap_or_default(),
),
None => (String::new(), String::new()),
};
let name = self.field_with(&name, "tree.remote.name", window, cx);
let url = self.field_with(&url, "tree.remote.url", window, cx);
let old = old.map(|remote| {
let url = remote.urls.first().cloned().unwrap_or_default();
(remote.name, url)
});
let Some(repo) = self.active else { return };
self.ask = Some(Ask::Remote {
repo,
old,
name,
url,
});
cx.notify();
}
pub fn remove_remote(&mut self, name: &str, cx: &mut Context<Self>) {
let words = args(&["remote", "remove", "--", name]);
self.confirm("tree.remote.remove", name, words, cx);
}
/// Asks for a new folder, then the branch it holds.
pub fn add_worktree(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(opened) = self.active() else { return };
let repo = opened.id;
let Some(near) = opened.repo.root.parent().map(Path::to_path_buf) else {
return;
};
let picked = cx.prompt_for_new_path(&near, None);
cx.spawn_in(window, async move |this, cx| {
let picked = picked
.await
.map_err(|error| error.to_string())
.and_then(|picked| picked.map_err(|error| error.to_string()));
this.update_in(cx, |tree, window, cx| match picked {
Ok(Some(path)) => tree.ask_worktree(repo, path, window, cx),
Ok(None) => log::info!("tree: the worktree folder panel was cancelled"),
Err(error) => tree.failed("tree.error.panel", &error, cx),
})
.ok();
})
.detach();
}
fn ask_worktree(
&mut self,
repo: u64,
path: PathBuf,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.opened(repo).is_none() {
return log::info!("tree: the worktree's repository closed");
}
let branch = self.field_with("", "tree.worktree.branch", window, cx);
self.ask = Some(Ask::Worktree {
repo,
path,
branch,
new: true,
});
cx.notify();
}
pub fn remove_worktree(&mut self, path: &Path, cx: &mut Context<Self>) {
let shown = path.display().to_string();
let words = args(&["worktree", "remove", "--", &shown]);
self.confirm("tree.worktree.remove", &shown, words, cx);
}
pub fn prune_worktrees(&mut self, cx: &mut Context<Self>) {
self.run_git(args(&["worktree", "prune"]), cx);
}
pub fn update_submodule(&mut self, path: &str, cx: &mut Context<Self>) {
let Some(id) = self.active else { return };
let words = ["submodule", "update", "--init", "--progress", "--", path];
self.write(id, Write::Remote(args(&words)), cx);
}
pub fn open_submodule(&mut self, path: &str, cx: &mut Context<Self>) {
let Some(root) = self.active().map(|opened| opened.repo.root.join(path)) else {
return;
};
self.open_path(root, cx);
}
/// The Repository panel's dialogs, as answered.
pub fn answer_places(&mut self, cx: &mut Context<Self>) {
let field = |field: &gpui::Entity<TextInput>, cx: &Context<Self>| {
field.read(cx).text().trim().to_string()
};
let (repo, words): (u64, Vec<Vec<String>>) = match &self.ask {
Some(Ask::Stash {
repo,
message,
untracked,
}) => {
let mut words = args(&["stash", "push"]);
words.extend(untracked.then(|| "--include-untracked".into()));
let message = field(message, cx);
if !message.is_empty() {
words.extend(["-m".into(), message]);
}
(*repo, vec![words])
}
Some(Ask::Remote {
repo,
old,
name,
url,
}) => {
let (name, url) = (field(name, cx), field(url, cx));
if name.is_empty() || url.is_empty() {
let words = text(cx, "tree.remote.empty", &[]);
return self.toast(words, Severity::Warning, cx);
}
let words = match old {
None => vec![args(&["remote", "add", "--", &name, &url])],
Some((was, was_url)) => {
let mut words = Vec::new();
if *was != name {
words.push(args(&["remote", "rename", "--", was, &name]));
}
if *was_url != url {
words.push(args(&["remote", "set-url", "--", &name, &url]));
}
words
}
};
(*repo, words)
}
Some(Ask::Worktree {
repo,
path,
branch,
new,
}) => {
let branch = field(branch, cx);
let path = path.display().to_string();
let words = match (branch.is_empty(), new) {
(true, _) => args(&["worktree", "add", "--detach", "--", &path]),
(false, true) => args(&["worktree", "add", "-b", &branch, "--", &path]),
(false, false) => args(&["worktree", "add", "--", &path, &branch]),
};
(*repo, vec![words])
}
_ => return,
};
self.ask = None;
for words in words {
self.write(repo, Write::Git(words), cx);
}
cx.notify();
}
}
+332
View File
@@ -0,0 +1,332 @@
//! The middle in Repository: the picked stash, remote and more.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
feedback::EmptyState,
git::StashAction,
primitives::IconName,
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, Context, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _,
Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
};
use crate::{
detail::Detail,
hist::{Whose, when},
places::{Place, Read},
structure::Checkout,
tree::{Tree, text},
};
type Act = Box<dyn Fn(&mut Tree, &mut Window, &mut Context<Tree>)>;
impl Tree {
fn acts(&self, acts: Vec<(&'static str, bool, Act)>, cx: &mut Context<Self>) -> AnyElement {
div()
.flex()
.flex_wrap()
.gap_2()
.children(acts.into_iter().map(|(key, danger, act)| {
let this = cx.weak_entity();
Button::new(
SharedString::from(format!("{key}-button")),
text(cx, key, &[]),
)
.size(ControlSize::Sm)
.variant(if danger {
ButtonVariant::Danger
} else {
ButtonVariant::Secondary
})
.on_click(move |_, window, cx| {
this.update(cx, |tree, cx| act(tree, window, cx)).ok();
})
}))
.into_any_element()
}
/// A heading, its facts, and what can be done.
fn card(
&self,
title: String,
rows: DescriptionList,
acts: AnyElement,
cx: &mut Context<Self>,
) -> AnyElement {
let colors = cx.theme().colors.clone();
div()
.size_full()
.flex()
.flex_col()
.gap_3()
.p_3()
.child(
div()
.id("tree-place-title")
.role(Role::Heading)
.aria_label(SharedString::from(title.clone()))
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(title),
)
.child(rows)
.child(acts)
.into_any_element()
}
/// A stash's head: its words, when, and its three actions.
pub fn stash_head(&self, detail: &Detail, cx: &mut Context<Self>) -> AnyElement {
let at = self
.stashes()
.iter()
.position(|stash| stash.oid == detail.id);
let subject = detail.message.lines().next().unwrap_or("").to_string();
let label = |key: &str| text(cx, key, &[]);
let rows = DescriptionList::new()
.text(label("tree.stash.when"), when(detail.author.at))
.text(label("tree.commit.id"), detail.id.clone());
let acts = match at {
Some(at) => self.acts(
vec![
(
"tree.stash.apply",
false,
Box::new(move |t, _, cx| t.stash_act(at, StashAction::Apply, cx)),
),
(
"tree.stash.pop",
false,
Box::new(move |t, _, cx| t.stash_act(at, StashAction::Pop, cx)),
),
(
"tree.stash.drop",
true,
Box::new(move |t, _, cx| t.stash_act(at, StashAction::Drop, cx)),
),
],
cx,
),
None => div().into_any_element(),
};
let colors = cx.theme().colors.clone();
div()
.flex_none()
.border_b_1()
.border_color(colors.border)
.child(self.card(subject, rows, acts, cx))
.into_any_element()
}
/// Where HEAD was, and the two ways back.
pub fn reflog_head(&self, detail: &Detail, cx: &mut Context<Self>) -> AnyElement {
// The picked entry itself, while it still names this commit.
let moved = match (
self.active().and_then(|o| o.places.read.as_ref()),
&self.picked_place,
) {
(
Some(Read {
reflog: Ok(moves), ..
}),
Some(Place::Reflog(was)),
) => moves
.iter()
.find(|m| m.same(was) && m.oid == detail.id)
.cloned(),
_ => None,
};
let label = |key: &str| text(cx, key, &[]);
let mut rows = DescriptionList::new();
if let Some(moved) = &moved {
rows = rows
.text(label("tree.reflog.where"), moved.name.clone())
.text(label("tree.reflog.when"), when(moved.at));
}
rows = rows.text(label("tree.commit.id"), detail.id.clone());
let (branch, back) = (detail.id.clone(), detail.id.clone());
let acts = self.acts(
vec![
(
"tree.ref.branch_here",
false,
Box::new(move |t, window, cx| t.ask_branch(Some(branch.clone()), window, cx)),
),
(
"tree.move.open",
true,
Box::new(move |t, _, cx| t.ask_move(&back, cx)),
),
],
cx,
);
let title = moved.map_or_else(
|| detail.message.lines().next().unwrap_or("").to_string(),
|moved| moved.said,
);
let colors = cx.theme().colors.clone();
div()
.flex_none()
.border_b_1()
.border_color(colors.border)
.child(self.card(title, rows, acts, cx))
.into_any_element()
}
pub fn places_pane(&self, cx: &mut Context<Self>) -> AnyElement {
let none = || {
EmptyState::new(
"tree-place-none",
IconName::Archive,
text(cx, "tree.places.pick", &[]),
)
.into_any_element()
};
let Some(opened) = self.active() else {
return none();
};
let (Some(read), Some(place)) = (&opened.places.read, &self.picked_place) else {
return none();
};
let label = |key: &str| text(cx, key, &[]);
match place {
Place::Stash(oid) if opened.places.pick.commit.as_ref() == Some(oid) => {
self.picked_pane(&opened.places.pick, Whose::Stash, cx)
}
Place::Remote(name) => {
let Some(remote) = found(read, |read| &read.remotes, |r| &r.name == name) else {
return none();
};
let push = match remote.push.is_empty() {
true => label("tree.remote.same"),
false => remote.push.join("\n").into(),
};
let rows = DescriptionList::new()
.text(label("tree.remote.url"), remote.urls.join("\n"))
.text(label("tree.remote.push_url"), push);
let (fetch, edit, remove) = (name.clone(), remote.clone(), name.clone());
let acts = self.acts(
vec![
(
"tree.net.fetch",
false,
Box::new(move |t, _, cx| t.fetch_one(&fetch, cx)),
),
(
"tree.remote.change",
false,
Box::new(move |t, window, cx| {
t.ask_remote(Some(edit.clone()), window, cx)
}),
),
(
"tree.remote.delete",
true,
Box::new(move |t, _, cx| t.remove_remote(&remove, cx)),
),
],
cx,
);
self.card(name.clone(), rows, acts, cx)
}
Place::Worktree(path) => {
let Some(tree) = found(read, |read| &read.worktrees, |t| &t.path == path) else {
return none();
};
let mut rows = DescriptionList::new();
rows = match (&tree.branch, &tree.head) {
(Some(branch), _) => rows.text(label("tree.worktree.on"), branch.clone()),
(None, Some(head)) => rows.text(label("tree.commit.id"), head.clone()),
(None, None) => {
rows.text(label("tree.worktree.on"), label("tree.worktree.bare"))
}
};
if let Some(why) = &tree.locked {
rows = rows.text(label("tree.worktree.locked"), why.clone());
}
if let Some(why) = &tree.prunable {
rows = rows.text(label("tree.worktree.prunable"), why.clone());
}
// The main worktree and this one stay.
let main = found(read, |read| &read.worktrees, |_| true)
.is_some_and(|first| first.path == tree.path);
let here = tree.path == opened.repo.root;
let mut acts: Vec<(&'static str, bool, Act)> = Vec::new();
if !here && tree.prunable.is_none() && !tree.bare {
let open = tree.path.clone();
acts.push((
"tree.worktree.open_it",
false,
Box::new(move |t, _, cx| t.open_path(open.clone(), cx)),
));
}
if !main && !here {
let gone = tree.path.clone();
acts.push((
"tree.worktree.delete",
true,
Box::new(move |t, _, cx| t.remove_worktree(&gone, cx)),
));
}
let acts = self.acts(acts, cx);
self.card(tree.path.display().to_string(), rows, acts, cx)
}
Place::Submodule(path) => {
let Some(sub) = found(read, |read| &read.submodules, |s| &s.path == path) else {
return none();
};
let held = match &sub.checkout {
Checkout::Missing => label("tree.submodule.missing"),
Checkout::At(head) => head.clone().into(),
Checkout::Unread(error) => error.clone().into(),
};
let rows = DescriptionList::new()
.text(label("tree.submodule.recorded"), sub.recorded.clone())
.text(label("tree.submodule.held"), held);
let mut acts: Vec<(&'static str, bool, Act)> = Vec::new();
if !matches!(&sub.checkout, Checkout::At(head) if *head == sub.recorded) {
let update = sub.path.clone();
acts.push((
"tree.submodule.update",
false,
Box::new(move |t, _, cx| t.update_submodule(&update, cx)),
));
}
if matches!(sub.checkout, Checkout::At(_)) {
let open = sub.path.clone();
acts.push((
"tree.submodule.open",
false,
Box::new(move |t, _, cx| t.open_submodule(&open, cx)),
));
}
let acts = self.acts(acts, cx);
self.card(sub.path.clone(), rows, acts, cx)
}
// Gone from the reflog, the event shows nothing.
Place::Reflog(was)
if opened.places.pick.commit.as_ref() == Some(&was.oid)
&& matches!(&read.reflog, Ok(moves) if moves.iter().any(|m| m.same(was))) =>
{
self.picked_pane(&opened.places.pick, Whose::Reflog, cx)
}
Place::Stash(_) | Place::Reflog(_) => none(),
}
}
}
/// The first item of a list that read, matching `is`.
fn found<'a, T: Clone + 'a>(
read: &'a Read,
list: impl Fn(&'a Read) -> &'a Result<Vec<T>, String>,
is: impl Fn(&T) -> bool,
) -> Option<T> {
list(read)
.as_ref()
.ok()?
.iter()
.find(|item| is(item))
.cloned()
}
+265
View File
@@ -0,0 +1,265 @@
//! Reflog, moving a branch back, cleaning with a preview (TREE-13).
use std::{io, path::Path};
use crate::git::Git;
fn bad(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread record {what:?}"),
)
}
/// NUL-split records of `n` fields; a field may be empty.
pub fn records(bytes: &[u8], n: usize) -> io::Result<Vec<Vec<&str>>> {
let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?;
let mut fields: Vec<&str> = text.split('\0').collect();
if fields.last() == Some(&"") {
fields.pop();
}
if fields.len() % n != 0 {
return Err(bad(text));
}
Ok(fields.chunks(n).map(<[&str]>::to_vec).collect())
}
/// Where HEAD was; newest first.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Moved {
pub oid: String,
/// `HEAD@{3}`.
pub name: String,
/// What moved it, as Git wrote.
pub said: String,
pub at: i64,
/// Its count from the oldest; new moves leave it be.
pub seq: usize,
}
impl Moved {
/// The same event, wherever it now sits.
pub fn same(&self, other: &Moved) -> bool {
(&self.oid, &self.said, self.at, self.seq) == (&other.oid, &other.said, other.at, other.seq)
}
}
/// How many reflog entries the panel lists.
const KEEP: usize = 200;
pub fn reflog(git: &Git, root: &Path) -> io::Result<Vec<Moved>> {
// With a date mode, `%gd` carries when HEAD moved.
let format = "--format=%H%x00%gd%x00%gs";
let count = format!("-n{KEEP}");
let out = git.run_or_one(
root,
&[
"reflog",
"show",
"-z",
"--date=unix",
format,
&count,
"HEAD",
"--",
],
)?;
// Every entry, to count each one's place from the oldest.
let all = git.run_or_one(root, &["reflog", "show", "-z", "--format=%h", "HEAD", "--"])?;
let total = records(&all, 1)?.len();
records(&out, 3)?
.into_iter()
.enumerate()
.map(|(at, fields)| {
let [oid, when, said] = fields[..] else {
return Err(bad(&fields.join(" ")));
};
let time = when
.strip_prefix("HEAD@{")
.and_then(|when| when.strip_suffix('}'))
.and_then(|when| when.parse().ok())
.ok_or_else(|| bad(when))?;
Ok(Moved {
oid: oid.to_string(),
name: format!("HEAD@{{{at}}}"),
said: said.to_string(),
at: time,
seq: total
.checked_sub(at + 1)
.ok_or_else(|| bad("the reflog grew"))?,
})
})
.collect()
}
/// Moving HEAD's branch to a commit: what leaves it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Move {
/// `refs/heads/main`, or none when detached.
pub branch: Option<String>,
pub head: String,
pub to: String,
/// Commits left behind: `abc1234 subject`.
pub left: Vec<String>,
}
fn words(bytes: Vec<u8>) -> String {
String::from_utf8_lossy(&bytes).trim().to_string()
}
/// HEAD's branch ref, if any, and its commit.
pub fn head_now(git: &Git, root: &Path) -> io::Result<(Option<String>, String)> {
let branch = words(git.run_or_one(root, &["symbolic-ref", "-q", "HEAD"])?);
let head = words(git.run(root, &["rev-parse", "--verify", "HEAD"])?);
Ok(((!branch.is_empty()).then_some(branch), head))
}
/// `reset --keep` would drop staged work; refuse instead.
pub fn nothing_staged(git: &Git, root: &Path) -> io::Result<()> {
if git
.run(root, &["diff", "--cached", "--name-only", "-z"])?
.is_empty()
{
return Ok(());
}
Err(io::Error::other(
"staged changes would be lost; commit or unstage them first",
))
}
pub fn plan_move(git: &Git, root: &Path, to: &str) -> io::Result<Move> {
nothing_staged(git, root)?;
let (branch, head) = head_now(git, root)?;
let range = format!("{to}..{head}");
let left = words(git.run(root, &["log", "--format=%h %s", &range, "--"])?);
Ok(Move {
branch,
head,
to: to.to_string(),
left: left.lines().map(str::to_string).collect(),
})
}
/// Undoes Git's C quoting of a path.
fn unquote(said: &str) -> io::Result<String> {
let Some(inner) = said.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
return Ok(said.to_string());
};
let (mut out, mut chars) = (Vec::new(), inner.chars());
while let Some(c) = chars.next() {
if c != '\\' {
let mut buffer = [0; 4];
out.extend_from_slice(c.encode_utf8(&mut buffer).as_bytes());
continue;
}
let byte = match chars.next().ok_or_else(|| bad(said))? {
'a' => 7,
'b' => 8,
't' => b'\t',
'n' => b'\n',
'v' => 11,
'f' => 12,
'r' => b'\r',
'"' => b'"',
'\\' => b'\\',
digit @ '0'..='3' => {
let rest: String = chars.by_ref().take(2).collect();
u8::from_str_radix(&format!("{digit}{rest}"), 8).map_err(|_| bad(said))?
}
_ => return Err(bad(said)),
};
out.push(byte);
}
String::from_utf8(out).map_err(|_| bad(said))
}
/// The flags a clean runs with, preview and real.
pub fn clean_flags(ignored: bool) -> Vec<String> {
let mut flags = vec!["-d".to_string()];
flags.extend(ignored.then(|| "-x".to_string()));
flags
}
/// What a clean removes, fixed when shown.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Clean {
/// Each file, folders opened up.
pub files: Vec<String>,
/// Folders, deepest first; removed only once empty.
pub dirs: Vec<String>,
}
/// Folders under `dir`, skipping other repositories, deepest first.
fn folders(root: &Path, dir: &str, out: &mut Vec<String>) -> io::Result<()> {
for entry in std::fs::read_dir(root.join(dir))? {
let entry = entry?;
let kind = entry.file_type()?;
let name = entry
.file_name()
.into_string()
.map_err(|_| bad("a folder name"))?;
let inner = format!("{dir}{name}/");
if kind.is_dir() && !entry.path().join(".git").exists() {
folders(root, &inner, out)?;
}
}
out.push(dir.to_string());
Ok(())
}
/// What `git clean` would remove, file by file.
pub fn clean_preview(git: &Git, root: &Path, ignored: bool) -> io::Result<Clean> {
let mut args: Vec<String> = ["-c", "core.quotePath=false", "clean", "-n"]
.map(String::from)
.into();
args.extend(clean_flags(ignored));
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let out = String::from_utf8(git.run(root, &args)?).map_err(|_| bad("not UTF-8"))?;
let mut clean = Clean::default();
for path in out
.lines()
.filter_map(|line| line.strip_prefix("Would remove "))
{
let path = unquote(path)?;
if !path.ends_with('/') {
clean.files.push(path);
continue;
}
let mut list = vec!["ls-files", "--others", "-z"];
if !ignored {
list.push("--exclude-standard");
}
list.extend(["--", &path]);
let inside = git.run(root, &list)?;
let inside = std::str::from_utf8(&inside).map_err(|_| bad("not UTF-8"))?;
// Another repository inside stays, as clean leaves it.
clean.files.extend(
inside
.split('\0')
.filter(|file| !file.is_empty() && !file.ends_with('/'))
.map(str::to_string),
);
folders(root, &path, &mut clean.dirs)?;
}
Ok(clean)
}
#[cfg(test)]
mod tests {
#[test]
fn quoted_paths_come_back_whole() {
let read = |said| super::unquote(said).expect("reads");
assert_eq!(read("plain name.txt"), "plain name.txt");
assert_eq!(read(r#""tab\there.txt""#), "tab\there.txt");
assert_eq!(read(r#""say \"hi\"\\.txt""#), "say \"hi\"\\.txt");
assert_eq!(read(r#""\344\270\255.txt""#), "中.txt");
assert!(super::unquote(r#""bad\q""#).is_err());
}
#[test]
fn records_keep_empty_fields() {
let read = super::records(b"a\0\0c\0d\0e\0f\0", 3).expect("reads");
assert_eq!(read, [vec!["a", "", "c"], vec!["d", "e", "f"]]);
assert!(super::records(b"a\0b\0", 3).is_err());
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Cleaning and moving back, each previewed; lock retries.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::Checkbox,
overlays::{ConfirmDialog, Dialog},
theme::ActiveTheme as _,
};
use gpui::{
AnyElement, App, AppContext as _, Context, InteractiveElement as _, IntoElement,
ParentElement as _, StatefulInteractiveElement as _, Styled as _, Window, div, px,
};
use crate::{
asks::Ask,
recover,
tree::{Tree, text},
work::Write,
};
/// Each file, then each folder, as the dialog lists them.
fn shown(clean: &recover::Clean) -> Vec<String> {
clean.files.iter().chain(&clean.dirs).cloned().collect()
}
impl Tree {
/// Opens the clean dialog; its list comes from Git.
pub fn ask_clean(&mut self, cx: &mut Context<Self>) {
let Some(repo) = self.active else { return };
self.ask = Some(Ask::Clean {
repo,
ignored: false,
preview: None,
});
self.preview_clean(cx);
}
pub fn clean_ignored(&mut self, on: bool, cx: &mut Context<Self>) {
if let Some(Ask::Clean {
ignored, preview, ..
}) = &mut self.ask
{
(*ignored, *preview) = (on, None);
self.preview_clean(cx);
}
}
/// Only the answer to the dialog as it stands lands.
fn preview_clean(&mut self, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(Ask::Clean { repo, ignored, .. }) = &self.ask else {
return;
};
let (repo, ignored) = (*repo, *ignored);
let Some(root) = self.opened(repo).map(|opened| opened.repo.root.clone()) else {
return;
};
let task = cx.background_spawn(async move { recover::clean_preview(&git, &root, ignored) });
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| {
if let Some(Ask::Clean {
repo: now,
ignored: still,
preview,
}) = &mut tree.ask
&& (*now, *still) == (repo, ignored)
{
*preview = Some(read);
cx.notify();
}
})
.ok();
})
.detach();
cx.notify();
}
/// Removes exactly the previewed paths, nothing found later.
fn clean(&mut self, cx: &mut Context<Self>) {
let Some(Ask::Clean {
repo,
ignored,
preview: Some(Ok(clean)),
}) = &self.ask
else {
return;
};
let write = Write::Clean {
files: clean.files.clone(),
dirs: clean.dirs.clone(),
ignored: *ignored,
};
let (repo, count) = (*repo, clean.files.len());
self.ask = None;
log::info!("tree: cleaning {count} files");
self.write(repo, write, cx);
}
/// Reads what moving the branch to `to` leaves behind.
pub fn ask_move(&mut self, to: &str, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.active() else { return };
let (repo, root, to) = (opened.id, opened.repo.root.clone(), to.to_string());
let task = cx.background_spawn(async move { recover::plan_move(&git, &root, &to) });
cx.spawn(async move |this, cx| {
let plan = task.await;
this.update(cx, |tree, cx| {
if tree.active != Some(repo) {
return;
}
tree.ask = Some(match plan {
Ok(plan) => Ask::Move { repo, plan },
Err(error) => Ask::Failed {
title: text(cx, "tree.op.failed", &[]),
detail: error.to_string().into(),
},
});
cx.notify();
})
.ok();
})
.detach();
}
pub fn recover_dialog(
&self,
dismiss: impl Fn(&mut Window, &mut App) + Clone + 'static,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let this = cx.weak_entity();
let label = |key: &str| text(cx, key, &[]);
Some(match self.ask.as_ref()? {
Ask::Clean {
ignored, preview, ..
} => {
let colors = cx.theme().colors.clone();
let toggle = {
let this = this.clone();
Checkbox::new("tree-clean-ignored", *ignored)
.label(label("tree.clean.ignored"))
.on_change(move |on, _, cx| {
this.update(cx, |tree, cx| tree.clean_ignored(on, cx)).ok();
})
};
let (body, ready): (AnyElement, bool) = match preview {
None => (
div().child(label("tree.clean.reading")).into_any_element(),
false,
),
Some(Err(error)) => (
div()
.text_color(colors.danger)
.child(error.clone())
.into_any_element(),
false,
),
Some(Ok(clean)) if shown(clean).is_empty() => (
div().child(label("tree.clean.nothing")).into_any_element(),
false,
),
Some(Ok(clean)) => (
div()
.id("tree-clean-paths")
.text_color(colors.fg)
.max_h(px(280.0))
.overflow_y_scroll()
.flex()
.flex_col()
.children(shown(clean).into_iter().map(|path| div().child(path)))
.into_any_element(),
true,
),
};
let count = match preview {
Some(Ok(clean)) => shown(clean).len(),
_ => 0,
};
let (go, cancel) = (
text(cx, "tree.clean.go", &[("n", &count.to_string())]),
label("tree.cancel"),
);
Dialog::new("tree-clean", label("tree.clean.title"), dismiss)
.child(toggle)
.child(body)
.action(move |close| {
Button::new("tree-clean-cancel", cancel)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |_| {
Button::new("tree-clean-go", go)
.variant(ButtonVariant::Danger)
.disabled(!ready)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.clean(cx)).ok();
})
})
.into_any_element()
}
Ask::Move { repo, plan } => {
let short = |oid: &str| oid[..oid.len().min(7)].to_string();
let left = match plan.left.len() {
0 => label("tree.move.nothing"),
n => text(
cx,
"tree.move.left",
&[("n", &n.to_string()), ("commits", &plan.left.join("\n"))],
),
};
let what = match &plan.branch {
Some(branch) => branch.trim_start_matches("refs/heads/").to_string(),
None => label("tree.move.detached").to_string(),
};
let message = text(
cx,
"tree.move.message",
&[
("what", &what),
("from", &short(&plan.head)),
("to", &short(&plan.to)),
("left", &left),
],
);
let (repo, write) = (
*repo,
Write::Move {
branch: plan.branch.clone(),
head: plan.head.clone(),
to: plan.to.clone(),
},
);
ConfirmDialog::new("tree-move", label("tree.move.title"), message, dismiss)
.confirm(label("tree.move.go"))
.destructive()
.on_confirm(move |_, cx| {
this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx))
.ok();
})
.into_any_element()
}
Ask::Locked {
repo,
write,
detail,
} => {
let (repo, write) = (*repo, write.clone());
let message = format!("{}\n\n{detail}", label("tree.locked.message"));
ConfirmDialog::new("tree-locked", label("tree.locked.title"), message, dismiss)
.confirm(label("tree.locked.retry"))
.on_confirm(move |_, cx| {
this.update(cx, |tree, cx| tree.write(repo, write.clone(), cx))
.ok();
})
.into_any_element()
}
_ => return None,
})
}
}
+176
View File
@@ -0,0 +1,176 @@
//! The picked branch or tag, and its actions.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
feedback::EmptyState,
primitives::IconName,
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, App, Context, FontWeight, IntoElement, ParentElement as _, SharedString,
Styled as _, Window, div,
};
use crate::{
branches::{Kind, Ref},
hist::when,
tree::{Tree, text},
};
type Act = Box<dyn Fn(&mut Tree, &mut Window, &mut Context<Tree>)>;
impl Tree {
pub fn refs_pane(&self, cx: &mut Context<Self>) -> AnyElement {
let found = self
.active()
.and_then(|opened| match (&opened.refs, &self.picked_ref) {
(Some(Ok(refs)), Some((kind, name))) => refs
.iter()
.find(|r| r.kind == *kind && r.name == *name)
.cloned(),
_ => None,
});
let Some(found) = found else {
return EmptyState::new(
"tree-ref-none",
IconName::GitBranch,
text(cx, "tree.ref.pick", &[]),
)
.into_any_element();
};
let colors = cx.theme().colors.clone();
let label = |key: &str| text(cx, key, &[]);
let mut rows = DescriptionList::new()
.text(
label("tree.ref.commit"),
format!(
"{} {}",
&found.commit[..found.commit.len().min(7)],
found.subject
),
)
.text(label("tree.ref.when"), when(found.at));
if let Some(upstream) = &found.upstream {
let track = match found.track {
Some((ahead, behind)) => format!("{upstream} ↑{ahead} ↓{behind}"),
None => text(cx, "tree.upstream_gone", &[("upstream", upstream)]).to_string(),
};
rows = rows.text(label("tree.ref.upstream"), track);
}
if let Some(message) = &found.message {
rows = rows.text(label("tree.ref.message"), message.clone());
}
let buttons = self.ref_actions(&found, cx);
div()
.size_full()
.flex()
.flex_col()
.gap_3()
.p_3()
.child(
div()
.font_weight(FontWeight::SEMIBOLD)
.text_color(colors.fg)
.child(found.name.clone()),
)
.child(rows)
.child(div().flex().flex_wrap().gap_2().children(buttons))
.into_any_element()
}
/// What applies to `found`: the current branch takes no switch.
fn ref_actions(&self, found: &Ref, cx: &mut Context<Self>) -> Vec<AnyElement> {
let name = found.name.clone();
let kind = found.kind;
let at = found.commit.clone();
let mut acts: Vec<(&str, Act)> = Vec::new();
let n = name.clone();
let switchable = match kind {
Kind::Local => !found.current,
Kind::Remote => true,
Kind::Tag => false,
};
if switchable {
acts.push((
"tree.ref.switch",
Box::new(move |tree, _, cx| tree.switch(&n, kind, cx)),
));
}
if !found.current && kind != Kind::Tag {
// Merge and rebase use full refs, never a same-named tag.
let (m, r, shown) = (found.full.clone(), found.full.clone(), name.clone());
acts.push((
"tree.ref.merge",
Box::new(move |tree, _, cx| tree.merge(&m, cx)),
));
acts.push((
"tree.ref.rebase",
Box::new(move |tree, _, cx| tree.rebase(&r, &shown, cx)),
));
}
let here = at.clone();
acts.push((
"tree.ref.branch_here",
Box::new(move |tree, window, cx| tree.ask_branch(Some(here.clone()), window, cx)),
));
match kind {
Kind::Local => {
let (r, d, f) = (name.clone(), name.clone(), name.clone());
let (up, gone) = (name.clone(), name.clone());
acts.push((
"tree.upstream.set",
Box::new(move |tree, _, cx| tree.ask_upstream(&up, cx)),
));
if found.upstream.is_some() {
acts.push((
"tree.upstream.unset",
Box::new(move |tree, _, cx| tree.unset_upstream(&gone, cx)),
));
}
acts.push((
"tree.ref.rename",
Box::new(move |tree, window, cx| tree.ask_rename(&r, window, cx)),
));
if !found.current {
acts.push((
"tree.ref.delete",
Box::new(move |tree, _, cx| tree.delete_branch(&d, false, cx)),
));
acts.push((
"tree.ref.force_delete",
Box::new(move |tree, _, cx| tree.delete_branch(&f, true, cx)),
));
}
}
Kind::Tag => {
let d = name.clone();
acts.push((
"tree.ref.delete_tag",
Box::new(move |tree, _, cx| tree.delete_tag(&d, cx)),
));
}
Kind::Remote => {}
}
acts.into_iter()
.map(|(key, act)| {
let this = cx.weak_entity();
let destructive = key.contains("delete");
Button::new(
SharedString::from(format!("{key}-button")),
text(cx, key, &[]),
)
.size(ControlSize::Sm)
.variant(if destructive {
ButtonVariant::Danger
} else {
ButtonVariant::Secondary
})
.on_click(move |_, window, cx: &mut App| {
this.update(cx, |tree, cx| act(tree, window, cx)).ok();
})
.into_any_element()
})
.collect()
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Remotes, and what a forced push would overwrite (TREE-11, §13.3).
use std::{io, path::Path};
use crate::git::Git;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Remote {
pub name: String,
pub urls: Vec<String>,
/// Push URLs, when set apart from fetch ones.
pub push: Vec<String>,
}
fn bad(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread remote {what:?}"),
)
}
fn words(bytes: Vec<u8>) -> io::Result<String> {
String::from_utf8(bytes)
.map(|text| text.trim_end_matches('\n').to_string())
.map_err(|_| bad("not UTF-8"))
}
/// Each remote Git knows, with its URLs.
pub fn list(git: &Git, root: &Path) -> io::Result<Vec<Remote>> {
let mut remotes: Vec<Remote> = words(git.run(root, &["remote"])?)?
.lines()
.map(|name| Remote {
name: name.to_string(),
urls: Vec::new(),
push: Vec::new(),
})
.collect();
let pattern = r"^remote\..*\.(url|pushurl)$";
let said = words(git.run_or_one(root, &["config", "-z", "--get-regexp", pattern])?)?;
// `-z`: key, newline, value, NUL.
for entry in said.split('\0').filter(|entry| !entry.is_empty()) {
let (key, value) = entry.split_once('\n').ok_or_else(|| bad(entry))?;
let (name, field) = key
.strip_prefix("remote.")
.and_then(|key| key.rsplit_once('.'))
.ok_or_else(|| bad(key))?;
let Some(remote) = remotes.iter_mut().find(|remote| remote.name == name) else {
return Err(bad(name));
};
match field {
"url" => remote.urls.push(value.to_string()),
_ => remote.push.push(value.to_string()),
}
}
Ok(remotes)
}
/// A forced push: where, and what it would overwrite.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Force {
pub branch: String,
pub remote: String,
/// The branch on the remote: `refs/heads/main`.
pub target: String,
/// What the remote held when last fetched.
pub lease: String,
/// The commit shown; pushed whatever the branch is by then.
pub source: String,
/// Commits there and not here: `abc1234 subject`.
pub lost: Vec<String>,
}
/// The current branch's forced push to its upstream.
pub fn force(git: &Git, root: &Path) -> io::Result<Force> {
let branch = words(git.run_or_one(root, &["symbolic-ref", "--short", "-q", "HEAD"])?)?;
if branch.is_empty() {
return Err(io::Error::other("HEAD is not on a branch"));
}
let setting = |key: &str| -> io::Result<String> {
words(git.run_or_one(
root,
&["config", "--get", &format!("branch.{branch}.{key}")],
)?)
};
let (remote, target) = (setting("remote")?, setting("merge")?);
if remote.is_empty() || target.is_empty() {
return Err(io::Error::other(format!("{branch} has no upstream")));
}
let upstream = format!("{branch}@{{upstream}}");
let lease = words(git.run(root, &["rev-parse", "--verify", &upstream])?)?;
let source = words(git.run(root, &["rev-parse", "--verify", "HEAD"])?)?;
let lost = words(git.run(
root,
&["log", "--format=%h %s", &lease, "--not", &source, "--"],
)?)?;
Ok(Force {
lost: lost.lines().map(str::to_string).collect(),
branch,
remote,
target,
lease,
source,
})
}
impl Force {
/// With a lease: refused if the remote moved since.
pub fn args(&self) -> Vec<String> {
vec![
"push".into(),
"--progress".into(),
format!("--force-with-lease={}:{}", self.target, self.lease),
"--".into(),
self.remote.clone(),
format!("{}:{}", self.source, self.target),
]
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Repositories: open, start, clone, and the recent list (TREE-01).
use std::{
io,
path::{Path, PathBuf},
};
use crate::{git::Git, running::Running};
/// How many repositories the recent list keeps.
/// A work tree and its Git directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Repo {
/// The real top of the work tree, links resolved.
pub root: PathBuf,
pub git_dir: PathBuf,
/// Shared refs and objects; a linked worktree's lie elsewhere.
pub common_dir: PathBuf,
}
fn text(bytes: Vec<u8>) -> io::Result<String> {
String::from_utf8(bytes)
.map(|s| s.trim_end_matches('\n').to_string())
.map_err(io::Error::other)
}
/// The repository holding `path`; bare ones are refused.
pub fn open(git: &Git, path: &Path) -> io::Result<Repo> {
if text(git.run(path, &["rev-parse", "--is-bare-repository"])?)? == "true" {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("{} is a bare repository", path.display()),
));
}
let root = text(git.run(path, &["rev-parse", "--show-toplevel"])?)?;
let git_dir = text(git.run(path, &["rev-parse", "--absolute-git-dir"])?)?;
let common = ["rev-parse", "--path-format=absolute", "--git-common-dir"];
let common_dir = text(git.run(path, &common)?)?;
let repo = Repo {
root: std::fs::canonicalize(root)?,
git_dir: std::fs::canonicalize(git_dir)?,
common_dir: std::fs::canonicalize(common_dir)?,
};
log::info!("tree: opened {}", repo.root.display());
Ok(repo)
}
/// A new repository in `dir`, then opened.
pub fn init(git: &Git, dir: &Path) -> io::Result<Repo> {
std::fs::create_dir_all(dir)?;
git.run(dir, &["init", "--quiet"])?;
open(git, dir)
}
impl Git {
/// Starts `git clone url into`; never asks on a terminal.
pub fn clone_repo(&self, url: &str, into: &Path) -> io::Result<Running> {
if into.exists() && std::fs::read_dir(into)?.next().is_some() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} is not empty", into.display()),
));
}
let parent = into
.parent()
.ok_or_else(|| io::Error::other("a clone needs a parent folder"))?;
let into_text = into
.to_str()
.ok_or_else(|| io::Error::other("a path that is not UTF-8"))?;
log::info!("tree: clone into {}", into.display());
self.start(parent, &["clone", "--progress", "--", url, into_text])
}
}
+198
View File
@@ -0,0 +1,198 @@
//! A long Git run: progress, and a stop.
use std::{
io::{self, Read as _},
path::Path,
process::{Child, ChildStderr, ChildStdout, Stdio},
sync::{Arc, Mutex},
};
use crate::git::Git;
/// Git running; progress comes line by line.
pub struct Running {
verb: String,
stdout: Option<ChildStdout>,
stderr: Option<ChildStderr>,
child: Arc<Mutex<Child>>,
}
/// Stops a run from another thread.
#[derive(Clone)]
pub struct Stop(Arc<Mutex<Child>>);
impl Git {
/// Its own session: SSH finds no terminal.
pub fn start(&self, dir: &Path, args: &[&str]) -> io::Result<Running> {
let mut command = self.command(dir, args);
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
// SAFETY: setsid is async-signal-safe.
unsafe {
command.pre_exec(|| match libc::setsid() {
-1 => Err(io::Error::last_os_error()),
_ => Ok(()),
});
}
}
let mut child = command.spawn()?;
let verb = args.first().copied().unwrap_or("").to_string();
log::info!("tree: git {verb} started, {}", child.id());
Ok(Running {
verb,
stdout: child.stdout.take(),
stderr: child.stderr.take(),
child: Arc::new(Mutex::new(child)),
})
}
}
/// Lines as shown; one ended by CR is rewritten.
#[derive(Default)]
struct Said {
lines: Vec<String>,
rewrite: bool,
}
impl Said {
/// Ends a line at `byte`; empty CRLF halves end nothing.
fn end(&mut self, text: String, byte: u8) -> Option<&str> {
if text.is_empty() {
self.rewrite &= byte == b'\r';
return None;
}
if self.rewrite {
self.lines.pop();
}
self.rewrite = byte == b'\r';
self.lines.push(text);
self.lines.last().map(String::as_str)
}
}
impl Running {
pub fn stop(&self) -> Stop {
Stop(Arc::clone(&self.child))
}
/// Reads to the end; failure quotes Git, both streams.
pub fn follow(mut self, mut seen: impl FnMut(&str)) -> io::Result<String> {
let mut stdout = self
.stdout
.take()
.ok_or_else(|| io::Error::other("no output"))?;
let mut stderr = self
.stderr
.take()
.ok_or_else(|| io::Error::other("no progress"))?;
// Reading apart keeps either pipe from filling.
let out = std::thread::spawn(move || {
let mut bytes = Vec::new();
stdout.read_to_end(&mut bytes).map(|_| bytes)
});
let (mut said, mut line, mut buffer) = (Said::default(), Vec::new(), [0u8; 4096]);
let mut flush = |line: &mut Vec<u8>, said: &mut Said, byte: u8| {
let text = String::from_utf8_lossy(line).trim().to_string();
line.clear();
if let Some(text) = said.end(text, byte) {
seen(text);
}
};
loop {
let read = stderr.read(&mut buffer)?;
// Progress rewrites its line with carriage returns.
for &byte in &buffer[..read] {
match byte {
b'\r' | b'\n' => flush(&mut line, &mut said, byte),
_ => line.push(byte),
}
}
if read == 0 {
flush(&mut line, &mut said, b'\n');
break;
}
}
let out = out
.join()
.map_err(|_| io::Error::other("the output reader panicked"))??;
let out = String::from_utf8_lossy(&out).trim().to_string();
let status = self.child.lock().expect("the run lock").wait()?;
let verb = &self.verb;
if !status.success() {
log::warn!("tree: git {verb} failed, {status}");
let all: Vec<String> = [out]
.into_iter()
.chain(said.lines)
.filter(|part| !part.is_empty())
.collect();
return Err(io::Error::other(format!(
"git {verb} ({status}):\n{}",
all.join("\n")
)));
}
log::info!("tree: git {verb} done");
Ok(out)
}
}
impl Stop {
/// Asks Git and its helpers to stop; Git cleans up.
pub fn now(&self) -> io::Result<()> {
let mut child = self.0.lock().expect("the run lock");
// An ended Git's number may be another's now.
if let Some(status) = child.try_wait()? {
log::info!("tree: git {} had ended, {status}", child.id());
return Ok(());
}
log::info!("tree: git {} asked to stop", child.id());
terminate(child.id())
}
}
/// Its own session: the group is Git and its helpers.
#[cfg(unix)]
fn terminate(pid: u32) -> io::Result<()> {
let pid = libc::pid_t::try_from(pid).map_err(io::Error::other)?;
match unsafe { libc::kill(-pid, libc::SIGTERM) } {
0 => Ok(()),
_ => Err(io::Error::last_os_error()),
}
}
#[cfg(not(unix))]
fn terminate(_pid: u32) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"stopping Git is not built on this platform",
))
}
#[cfg(test)]
mod tests {
#[test]
fn only_a_line_ended_by_return_is_rewritten() {
let mut said = super::Said::default();
for (text, byte) in [
("Receiving objects: 1%", b'\r'),
("Receiving objects: 100%", b'\n'),
("remote: POLICY_A required", b'\r'),
("", b'\n'),
("remote: POLICY_B required", b'\n'),
] {
said.end(text.to_string(), byte);
}
assert_eq!(
said.lines,
[
"Receiving objects: 100%",
"remote: POLICY_A required",
"remote: POLICY_B required"
]
);
}
}
+366
View File
@@ -0,0 +1,366 @@
//! The middle's file, read off the UI thread (TREE-03).
use std::{io, path::Path, rc::Rc, sync::Arc};
use ely_gpui_component::git::{ChangeSection, DiffLine, LineKind, Stretch, mark_words};
use gpui::{Image, ImageFormat};
use crate::{
diff::{self, Body, FileDiff, Space},
git::Git,
status::{Entry, State},
};
/// Picture formats shown side by side, by extension.
const PICTURES: [(&str, ImageFormat); 9] = [
("png", ImageFormat::Png),
("jpg", ImageFormat::Jpeg),
("jpeg", ImageFormat::Jpeg),
("gif", ImageFormat::Gif),
("webp", ImageFormat::Webp),
("bmp", ImageFormat::Bmp),
("tif", ImageFormat::Tiff),
("tiff", ImageFormat::Tiff),
("ico", ImageFormat::Ico),
];
/// What a read found, before it becomes a view.
pub enum Found {
Text {
diff: FileDiff,
seen: Vec<u8>,
old: Option<String>,
},
Picture {
format: ImageFormat,
old: Option<Vec<u8>>,
new: Option<Vec<u8>>,
},
Binary {
old: Option<u64>,
new: Option<u64>,
},
Folder,
Conflict(crate::conflict::Stages),
}
/// What the middle draws.
pub enum View {
Text {
diff: Arc<FileDiff>,
/// The output read, to see a change before a patch.
seen: Arc<Vec<u8>>,
stretches: Rc<Vec<Stretch>>,
/// Each stretch's hunk, if it is one.
hunks: Vec<Option<usize>>,
},
Picture {
old: Option<Arc<Image>>,
new: Option<Arc<Image>>,
},
Binary {
old: Option<u64>,
new: Option<u64>,
},
Folder,
Conflict(crate::conflict::Stages),
}
/// How every diff is asked for, before what it compares.
pub fn base(space: Space) -> Vec<String> {
// Blank context lines keep their leading space.
let mut args: Vec<String> = [
"-c",
"diff.suppressBlankEmpty=false",
"diff",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--src-prefix=a/",
"--dst-prefix=b/",
"-U3",
]
.map(String::from)
.to_vec();
args.extend(space.flag().map(String::from));
args
}
/// The arguments that read `entry` in `section`.
pub fn args(entry: &Entry, section: ChangeSection, space: Space) -> Vec<String> {
let mut args = base(space);
match section {
ChangeSection::Staged => {
args.extend(["--cached", "-M", "--"].map(String::from));
args.push(entry.path.clone());
args.extend(entry.from.clone());
}
ChangeSection::Untracked => {
args.extend(["--no-index", "--", "/dev/null"].map(String::from));
args.push(entry.path.clone());
}
_ => {
args.push("--".into());
args.push(entry.path.clone());
}
}
args
}
/// What Git compares: a link's own path, else the file.
fn work_bytes(path: &Path) -> io::Result<Vec<u8>> {
match std::fs::symlink_metadata(path)?.file_type().is_symlink() {
true => Ok(std::fs::read_link(path)?
.into_os_string()
.into_encoded_bytes()),
false => std::fs::read(path),
}
}
fn blob(git: &Git, root: &Path, name: &str) -> io::Result<Vec<u8>> {
git.run(root, &["cat-file", "blob", name])
}
fn size(git: &Git, root: &Path, name: &str) -> io::Result<u64> {
let said = git.run(root, &["cat-file", "-s", name])?;
String::from_utf8_lossy(&said)
.trim()
.parse()
.map_err(io::Error::other)
}
/// Old and new blobs; None means the work tree.
fn sides(
entry: &Entry,
section: ChangeSection,
born: bool,
) -> (Option<String>, Option<Option<String>>) {
let deleted = |letter| matches!(entry.state, State::Tracked { staged, unstaged } if (if section == ChangeSection::Staged { staged } else { unstaged }) == letter);
let old = match section {
ChangeSection::Staged if born && !deleted('A') => Some(format!(
"HEAD:{}",
entry.from.as_deref().unwrap_or(&entry.path)
)),
ChangeSection::Unstaged => Some(format!(":{}", entry.path)),
_ => None,
};
let new = match (section, deleted('D')) {
(_, true) => None,
(ChangeSection::Staged, false) => Some(Some(format!(":{}", entry.path))),
_ => Some(None),
};
(old, new)
}
/// Reads what `entry` changed, as Git sees it.
pub fn read(
git: &Git,
root: &Path,
entry: &Entry,
section: ChangeSection,
space: Space,
born: bool,
) -> io::Result<Found> {
if matches!(entry.state, State::Conflicted(_)) {
return Ok(Found::Conflict(crate::conflict::read(
git,
root,
&entry.path,
)?));
}
if entry.path.ends_with('/') {
return Ok(Found::Folder);
}
let args = args(entry, section, space);
let args: Vec<&str> = args.iter().map(String::as_str).collect();
let seen = match section {
ChangeSection::Untracked => git.run_or_one(root, &args)?,
_ => git.run(root, &args)?,
};
let (old, new) = sides(entry, section, born);
compare(git, root, seen, &entry.path, old, new)
}
/// Makes what Git printed for `path` into a view's parts.
pub fn compare(
git: &Git,
root: &Path,
seen: Vec<u8>,
path: &str,
old: Option<String>,
new: Option<Option<String>>,
) -> io::Result<Found> {
let diff = diff::parse(&seen)?;
let work = root.join(path);
let extension = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let picture = PICTURES
.iter()
.find(|(e, _)| extension.as_deref() == Some(*e))
.map(|(_, f)| *f);
match (&diff.body, picture) {
(Body::Binary, Some(format)) => {
let old = old.map(|name| blob(git, root, &name)).transpose()?;
let new = match new {
Some(Some(name)) => Some(blob(git, root, &name)?),
Some(None) => Some(work_bytes(&work)?),
None => None,
};
Ok(Found::Picture { format, old, new })
}
(Body::Binary | Body::Undecodable, _) => {
let old = old.map(|name| size(git, root, &name)).transpose()?;
let new = match new {
Some(Some(name)) => Some(size(git, root, &name)?),
Some(None) => Some(std::fs::symlink_metadata(&work)?.len()),
None => None,
};
Ok(Found::Binary { old, new })
}
(Body::Text(_), _) => {
let old = old.map(|name| blob(git, root, &name)).transpose()?;
let new = match new {
Some(Some(name)) => Some(blob(git, root, &name)?),
Some(None) => Some(work_bytes(&work)?),
None => None,
};
// Not UTF-8 throughout: shown by size.
let text = |bytes: &Option<Vec<u8>>| {
bytes
.as_deref()
.is_none_or(|b| std::str::from_utf8(b).is_ok())
};
if !text(&old) || !text(&new) {
let size = |bytes: Option<Vec<u8>>| bytes.map(|b| b.len() as u64);
return Ok(Found::Binary {
old: size(old),
new: size(new),
});
}
let old = old
.map(String::from_utf8)
.transpose()
.map_err(io::Error::other)?;
Ok(Found::Text { diff, seen, old })
}
}
}
fn line(raw: &diff::Raw, old: Option<usize>, new: Option<usize>) -> DiffLine {
DiffLine {
kind: raw.kind,
old,
new,
text: raw.text.trim_end_matches('\r').to_string().into(),
words: Vec::new(),
}
}
/// Hunks from Git, the unchanged lines between folded.
pub fn stretches(
diff: &FileDiff,
old: Option<&str>,
) -> io::Result<(Vec<Stretch>, Vec<Option<usize>>)> {
let Body::Text(hunks) = &diff.body else {
return Ok((Vec::new(), Vec::new()));
};
// An empty file has no lines, not one empty line.
let olds: Vec<&str> = match old {
Some(text) if !text.is_empty() => text
.strip_suffix('\n')
.unwrap_or(text)
.split('\n')
.collect(),
_ => Vec::new(),
};
let (mut out, mut places) = (Vec::new(), Vec::new());
// The next old line not yet shown, from one.
let mut next = 1;
let fold = |from: usize, to: usize, shift: isize| -> io::Result<Vec<DiffLine>> {
(from..to)
.map(|n| {
let text = olds
.get(n - 1)
.ok_or_else(|| io::Error::other("the old file is shorter than its diff"))?;
Ok(DiffLine {
kind: LineKind::Same,
old: Some(n),
new: n.checked_add_signed(shift),
text: text.trim_end_matches('\r').to_string().into(),
words: Vec::new(),
})
})
.collect()
};
// Two sections share no old text to fold.
let folds = old.is_some() && diff.sections() < 2;
// New less old line numbers, after the last hunk.
let mut shift = 0isize;
for (at, hunk) in hunks.iter().enumerate() {
let (mut o, mut n) = (diff_start(hunk.old), diff_start(hunk.new));
let before = n as isize - o as isize;
if folds && o > next {
out.push(Stretch::Folded(fold(next, o, before)?));
places.push(None);
}
let mut lines = Vec::new();
for raw in &hunk.lines {
lines.push(match raw.kind {
LineKind::Same => line(raw, Some(o), Some(n)),
LineKind::Removed => line(raw, Some(o), None),
LineKind::Added => line(raw, None, Some(n)),
});
o += usize::from(raw.kind != LineKind::Added);
n += usize::from(raw.kind != LineKind::Removed);
}
mark_words(&mut lines);
let header = format!(
"@@ -{},{} +{},{} @@ {}",
hunk.old.0, hunk.old.1, hunk.new.0, hunk.new.1, hunk.tail
);
out.push(Stretch::Hunk {
header: header.trim_end().to_string().into(),
lines,
});
places.push(Some(at));
next = o;
shift = n as isize - o as isize;
}
if folds && next <= olds.len() {
out.push(Stretch::Folded(fold(next, olds.len() + 1, shift)?));
places.push(None);
}
Ok((out, places))
}
/// A range's first line number; an empty range starts after.
fn diff_start((start, count): (usize, usize)) -> usize {
if count == 0 { start + 1 } else { start }
}
/// Makes the view on the UI thread.
pub fn view(found: Found) -> io::Result<View> {
Ok(match found {
Found::Text { diff, seen, old } => {
let (stretches, hunks) = stretches(&diff, old.as_deref())?;
View::Text {
diff: Arc::new(diff),
seen: Arc::new(seen),
stretches: Rc::new(stretches),
hunks,
}
}
Found::Picture { format, old, new } => {
let image = |bytes: Vec<u8>| Arc::new(Image::from_bytes(format, bytes));
View::Picture {
old: old.map(image),
new: new.map(image),
}
}
Found::Binary { old, new } => View::Binary { old, new },
Found::Folder => View::Folder,
Found::Conflict(stages) => View::Conflict(stages),
})
}
+171
View File
@@ -0,0 +1,171 @@
//! The commit box, and history in the left panel.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::{EmptyState, ErrorView},
forms::{Choice, SearchInput, Select},
git::{CommitInput, CommitList},
primitives::IconName,
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, div,
prelude::FluentBuilder as _,
};
use crate::{
changes::Changes,
commits::Find,
tree::{Tree, text},
};
/// Who commits, how Git signs, and the message.
pub fn commit_box(
tree: &Entity<Tree>,
branch: SharedString,
staged: usize,
cx: &mut Context<Changes>,
) -> AnyElement {
let read = tree.read(cx);
let colors = cx.theme().colors.clone();
let (who, signs) = match read.active().and_then(|opened| opened.me.as_ref()) {
Some(Ok(me)) => {
let who = match (&me.name, &me.email) {
(Some(name), Some(email)) => format!("{name} <{email}>").into(),
_ => text(cx, "tree.me.unset", &[]),
};
let signs = me
.signs
.as_deref()
.map(|format| text(cx, "tree.me.signs", &[("format", format)]));
(who, signs)
}
Some(Err(error)) => (text(cx, "tree.me.unread", &[("error", error)]), None),
None => (SharedString::default(), None),
};
let (me, amend, commit) = (tree.downgrade(), tree.downgrade(), tree.downgrade());
let input = CommitInput::new("tree-commit", &read.subject, &read.body, branch, staged)
.amend(read.amend)
.on_amend(move |on, _, cx| {
amend.update(cx, |tree, cx| tree.set_amend(on, cx)).ok();
})
.when(!read.amend_loading, |input| {
input.on_commit(move |_, cx| {
commit.update(cx, |tree, cx| tree.commit(cx)).ok();
})
});
div()
.flex()
.flex_col()
.gap_1()
.p_2()
.border_t_1()
.border_color(colors.border)
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
Button::new("tree-me", who)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
me.update(cx, |tree, cx| tree.ask_identity(window, cx)).ok();
}),
)
.children(signs.map(|signs| div().text_color(colors.fg_muted).child(signs))),
)
.child(input)
.into_any_element()
}
fn find_key(find: Find) -> &'static str {
match find {
Find::Message => "message",
Find::Author => "author",
Find::Hash => "hash",
Find::Content => "content",
}
}
/// The search row, then the commits.
pub fn history(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
let read = tree.read(cx);
let Some(opened) = read.active() else {
return div().into_any_element();
};
let (id, hist) = (opened.id, &opened.hist);
let choices = ["message", "author", "hash", "content"]
.map(|key| Choice::new(key, text(cx, &format!("tree.find.{key}"), &[])));
let finding = tree.downgrade();
let find = Select::new("tree-find", choices)
.label(text(cx, "tree.find", &[]))
.size(ControlSize::Sm)
.selected(find_key(read.find))
.on_change(move |key, _, cx| {
let find = match key.as_ref() {
"author" => Find::Author,
"hash" => Find::Hash,
"content" => Find::Content,
_ => Find::Message,
};
finding
.update(cx, |tree, cx| {
tree.find = find;
tree.search(cx);
})
.ok();
});
let list = match (&hist.error, hist.rows.is_empty()) {
(Some(error), _) => ErrorView::new(
"tree-log-error",
IconName::TriangleAlert,
text(cx, "tree.log.unread", &[]),
)
.detail(error.clone())
.into_any_element(),
(None, true) if hist.read && !hist.loading => {
let key = if hist.query.is_some() {
"tree.log.none_found"
} else {
"tree.log.none"
};
EmptyState::new(
"tree-log-empty",
IconName::GitCommitHorizontal,
text(cx, key, &[]),
)
.into_any_element()
}
_ => {
let (pick, more) = (tree.downgrade(), tree.downgrade());
CommitList::new(("tree-log", id), hist.rows.clone())
.when(hist.query.is_none(), |list| list.graph(hist.graph.clone()))
.when_some(hist.pick.commit.clone(), |list, picked| {
list.selected(picked)
})
.on_pick(move |commit, _, cx| {
pick.update(cx, |tree, cx| tree.pick_commit(commit, cx))
.ok();
})
.on_end(move |cx| {
more.update(cx, |tree, cx| tree.more_log(cx)).ok();
})
.into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.child(
div().flex().gap_2().p_2().child(find).child(
div()
.flex_1()
.child(SearchInput::new("tree-search", &read.search).size(ControlSize::Sm)),
),
)
.child(div().flex_1().min_h_0().child(list))
.into_any_element()
}
+336
View File
@@ -0,0 +1,336 @@
//! The Repository panel: stashes, remotes, worktrees, more.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
git::{Stash, StashList},
lists::{List, ListItem},
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
prelude::FluentBuilder as _,
};
use crate::{
changes::Changes,
hist::when,
places::{Place, Read},
structure::{Checkout, LfsFile},
tree::{Tree, text},
};
type Act = Box<dyn Fn(&mut Tree, &mut Window, &mut Context<Tree>)>;
fn button(tree: &Entity<Tree>, id: &'static str, words: SharedString, act: Act) -> Button {
let this = tree.downgrade();
Button::new(id, words)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| {
this.update(cx, |tree, cx| act(tree, window, cx)).ok();
})
}
/// A list's name, with its own buttons.
fn heading(name: SharedString, buttons: Vec<Button>, cx: &App) -> AnyElement {
let colors = cx.theme().colors.clone();
div()
.flex()
.items_center()
.gap_1()
.pt_2()
.child(
div()
.id(SharedString::from(format!("tree-heading-{name}")))
.role(Role::Heading)
.aria_label(name.clone())
.flex_1()
.text_color(colors.fg_muted)
.child(name),
)
.children(buttons)
.into_any_element()
}
/// A list Git could not give: its own words.
fn unread(error: &str, cx: &App) -> AnyElement {
let colors = cx.theme().colors.clone();
div()
.text_color(colors.danger)
.child(error.to_string())
.into_any_element()
}
fn size(bytes: u64) -> String {
match bytes {
0..1024 => format!("{bytes} B"),
1024..1_048_576 => format!("{:.1} KB", bytes as f64 / 1024.0),
_ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
}
}
fn lfs_row(at: usize, file: &LfsFile, cx: &App) -> AnyElement {
let key = match (file.checked_out, file.downloaded) {
(true, _) => "tree.lfs.here",
(false, true) => "tree.lfs.stored",
(false, false) => "tree.lfs.pointer",
};
ListItem::new(("tree-lfs", at), file.path.clone())
.description(format!("{} · {}", text(cx, key, &[]), size(file.size)))
.into_any_element()
}
pub fn places(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
let read_tree = tree.read(cx);
let Some(opened) = read_tree.active() else {
return div().into_any_element();
};
let Some(read) = &opened.places.read else {
return div().into_any_element();
};
let picked = read_tree.picked_place.clone();
let label = |key: &str| text(cx, key, &[]);
let pick = |place: Place| {
let this = tree.downgrade();
move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| {
this.update(cx, |tree, cx| tree.pick_place(place.clone(), cx))
.ok();
}
};
let net = div().flex().flex_wrap().gap_1().children([
button(
tree,
"tree-fetch",
label("tree.net.fetch"),
Box::new(|t, _, cx| t.fetch_all(cx)),
),
button(
tree,
"tree-pull",
label("tree.net.pull"),
Box::new(|t, _, cx| t.pull(cx)),
),
button(
tree,
"tree-push",
label("tree.net.push"),
Box::new(|t, _, cx| t.push(cx)),
),
button(
tree,
"tree-force",
label("tree.force.open"),
Box::new(|t, _, cx| t.ask_force(cx)),
),
]);
let local = div().flex().flex_wrap().gap_1().children([
button(
tree,
"tree-terminal",
label("tree.tool.terminal"),
Box::new(|t, _, cx| t.open_terminal(cx)),
),
button(
tree,
"tree-reveal",
label("tree.tool.reveal"),
Box::new(|t, _, cx| t.reveal(cx)),
),
button(
tree,
"tree-clean",
label("tree.clean.open"),
Box::new(|t, _, cx| t.ask_clean(cx)),
),
]);
let mut out: Vec<AnyElement> = vec![net.into_any_element(), local.into_any_element()];
out.push(heading(
label("tree.places.stashes"),
vec![button(
tree,
"tree-stash-new",
label("tree.stash.open"),
Box::new(|t, window, cx| t.ask_stash(window, cx)),
)],
cx,
));
out.push(stashes(tree, read, picked.as_ref(), cx));
out.push(heading(
label("tree.places.remotes"),
vec![button(
tree,
"tree-remote-new",
label("tree.remote.open"),
Box::new(|t, window, cx| t.ask_remote(None, window, cx)),
)],
cx,
));
out.push(match &read.remotes {
Err(error) => unread(error, cx),
Ok(remotes) => List::new()
.children(remotes.iter().enumerate().map(|(at, remote)| {
let place = Place::Remote(remote.name.clone());
ListItem::new(("tree-remote", at), remote.name.clone())
.description(remote.urls.join(", "))
.selected(picked.as_ref() == Some(&place))
.on_click(pick(place))
.into_any_element()
}))
.into_any_element(),
});
let prunable =
matches!(&read.worktrees, Ok(trees) if trees.iter().any(|t| t.prunable.is_some()));
let mut tree_buttons = vec![button(
tree,
"tree-worktree-new",
label("tree.worktree.open"),
Box::new(|t, window, cx| t.add_worktree(window, cx)),
)];
if prunable {
tree_buttons.push(button(
tree,
"tree-worktree-prune",
label("tree.worktree.prune"),
Box::new(|t, _, cx| t.prune_worktrees(cx)),
));
}
out.push(heading(label("tree.places.worktrees"), tree_buttons, cx));
out.push(match &read.worktrees {
Err(error) => unread(error, cx),
Ok(trees) => List::new()
.children(trees.iter().enumerate().map(|(at, worktree)| {
let place = Place::Worktree(worktree.path.clone());
let about = match (&worktree.branch, &worktree.head, &worktree.prunable) {
(_, _, Some(_)) => label("tree.worktree.prunable"),
(Some(branch), _, _) => branch.clone().into(),
(None, Some(head), _) => text(
cx,
"tree.branch.detached",
&[("oid", &head[..head.len().min(7)])],
),
(None, None, _) => label("tree.worktree.bare"),
};
ListItem::new(
("tree-worktree", at),
worktree.path.file_name().map_or_else(
|| worktree.path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
),
)
.description(about)
.selected(picked.as_ref() == Some(&place))
.on_click(pick(place))
.into_any_element()
}))
.into_any_element(),
});
match &read.submodules {
Ok(subs) if subs.is_empty() => {}
Err(error) => {
out.push(heading(label("tree.places.submodules"), Vec::new(), cx));
out.push(unread(error, cx));
}
Ok(subs) => {
out.push(heading(label("tree.places.submodules"), Vec::new(), cx));
out.push(
List::new()
.children(subs.iter().enumerate().map(|(at, sub)| {
let place = Place::Submodule(sub.path.clone());
let key = match (&sub.checkout, sub.conflicted) {
(_, true) => "tree.submodule.conflicted",
(Checkout::Missing, _) => "tree.submodule.missing",
(Checkout::At(head), _) if *head == sub.recorded => {
"tree.submodule.matches"
}
(Checkout::At(_), _) => "tree.submodule.differs",
(Checkout::Unread(_), _) => "tree.submodule.unread",
};
ListItem::new(("tree-submodule", at), sub.path.clone())
.description(label(key))
.selected(picked.as_ref() == Some(&place))
.on_click(pick(place))
.into_any_element()
}))
.into_any_element(),
);
}
}
match &read.lfs {
Ok(files) if files.is_empty() => {}
Err(error) => {
out.push(heading(label("tree.places.lfs"), Vec::new(), cx));
out.push(unread(error, cx));
}
Ok(files) => {
out.push(heading(label("tree.places.lfs"), Vec::new(), cx));
out.push(
List::new()
.children(
files
.iter()
.enumerate()
.map(|(at, file)| lfs_row(at, file, cx)),
)
.into_any_element(),
);
}
}
out.push(heading(label("tree.places.reflog"), Vec::new(), cx));
out.push(match &read.reflog {
Err(error) => unread(error, cx),
Ok(moves) => List::new()
.children(moves.iter().enumerate().map(|(at, moved)| {
let chosen = matches!(&picked, Some(Place::Reflog(was)) if was.same(moved));
ListItem::new(("tree-reflog", at), moved.said.clone())
.description(format!("{} · {}", moved.name, when(moved.at)))
.selected(chosen)
.on_click(pick(Place::Reflog(moved.clone())))
.into_any_element()
}))
.into_any_element(),
});
div()
.id("tree-places-scroll")
.size_full()
.overflow_y_scroll()
.p_2()
.flex()
.flex_col()
.gap_1()
.children(out)
.into_any_element()
}
fn stashes(tree: &Entity<Tree>, read: &Read, picked: Option<&Place>, cx: &App) -> AnyElement {
let list = match &read.stashes {
Err(error) => return unread(error, cx),
Ok(list) => list,
};
let rows = list.iter().map(|stash| Stash {
message: stash.message.clone().into(),
branch: stash.branch.clone().into(),
when: when(stash.at),
});
let oids: Vec<String> = list.iter().map(|stash| stash.oid.clone()).collect();
let (pick, act) = (tree.downgrade(), tree.downgrade());
StashList::new("tree-stashes", rows)
.when_some(
picked.and_then(|place| match place {
Place::Stash(oid) => oids.iter().position(|known| known == oid),
_ => None,
}),
|list, at| list.selected(at),
)
.on_pick(move |at, _, cx| {
if let Some(oid) = oids.get(at).cloned() {
pick.update(cx, |tree, cx| tree.pick_place(Place::Stash(oid), cx))
.ok();
}
})
.on_action(move |at, action, _, cx| {
act.update(cx, |tree, cx| tree.stash_act(at, action, cx))
.ok();
})
.into_any_element()
}
+174
View File
@@ -0,0 +1,174 @@
//! The Branches panel: branches, remote branches and tags (TREE-07).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::ErrorView,
git::{Branch, BranchList, GitTag, TagList},
primitives::IconName,
theme::ControlSize,
};
use gpui::{
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
StatefulInteractiveElement as _, Styled as _, div, prelude::FluentBuilder as _,
};
use crate::{
branches::{Kind, Ref},
changes::Changes,
hist::when,
tree::{Tree, text},
};
fn branch(found: &Ref) -> Branch {
let (ahead, behind) = found.track.unwrap_or((0, 0));
Branch {
name: found.name.clone().into(),
remote: found.kind == Kind::Remote,
current: found.current,
ahead,
behind,
subject: found.subject.clone().into(),
when: when(found.at),
}
}
fn tag(found: &Ref) -> GitTag {
GitTag {
name: found.name.clone().into(),
message: found.message.clone().map(Into::into),
commit: found.commit.clone().into(),
when: when(found.at),
}
}
pub fn branches(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
let read = tree.read(cx);
let Some(opened) = read.active() else {
return div().into_any_element();
};
let refs = match &opened.refs {
None => return div().into_any_element(),
Some(Err(error)) => {
return ErrorView::new(
"tree-refs-error",
IconName::TriangleAlert,
text(cx, "tree.refs.unread", &[]),
)
.detail(error.clone())
.into_any_element();
}
Some(Ok(refs)) => refs,
};
let picked = read.picked_ref.clone();
// A name shows picked only in its own kind's list.
let picked_in = move |kind: Kind| {
picked
.clone()
.filter(|(at, _)| *at == kind)
.map(|(_, name)| name)
};
let pick = |kind: Kind| {
let this = tree.downgrade();
move |name: &gpui::SharedString, _: &mut gpui::Window, cx: &mut gpui::App| {
let name = name.to_string();
this.update(cx, |tree, cx| {
tree.picked_ref = Some((kind, name));
cx.notify();
})
.ok();
}
};
let switch = |kind: Kind| {
let this = tree.downgrade();
move |name: &gpui::SharedString, _: &mut gpui::Window, cx: &mut gpui::App| {
this.update(cx, |tree, cx| tree.switch(name, kind, cx)).ok();
}
};
// Each delete asks first, as from the details.
let delete = || {
let this = tree.downgrade();
move |name: &gpui::SharedString, _: &mut gpui::Window, cx: &mut gpui::App| {
this.update(cx, |tree, cx| tree.delete_branch(name, false, cx))
.ok();
}
};
// Local branches, the current first, then remote ones.
let mut local: Vec<&Ref> = refs.iter().filter(|r| r.kind == Kind::Local).collect();
local.sort_by_key(|r| !r.current);
let remote: Vec<&Ref> = refs.iter().filter(|r| r.kind == Kind::Remote).collect();
let mut tags: Vec<&Ref> = refs.iter().filter(|r| r.kind == Kind::Tag).collect();
tags.sort_by_key(|r| std::cmp::Reverse(r.at));
let (new_branch, new_tag) = (tree.downgrade(), tree.downgrade());
let actions = div()
.flex()
.gap_2()
.p_2()
.child(
Button::new("tree-new-branch", text(cx, "tree.branch.new", &[]))
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| {
new_branch
.update(cx, |tree, cx| tree.ask_branch(None, window, cx))
.ok();
}),
)
.child(
Button::new("tree-new-tag", text(cx, "tree.tag.new", &[]))
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| {
new_tag
.update(cx, |tree, cx| tree.ask_tag(None, window, cx))
.ok();
}),
);
let selected = |list: BranchList, kind: Kind| match picked_in(kind) {
Some(name) => list.selected(name),
None => list,
};
div()
.size_full()
.flex()
.flex_col()
.child(actions)
.child(
div()
.id("tree-refs-scroll")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.px_2()
.flex()
.flex_col()
.gap_2()
.child(
selected(
BranchList::new("tree-local", local.iter().map(|r| branch(r))),
Kind::Local,
)
.on_pick(pick(Kind::Local))
.on_switch(switch(Kind::Local))
.on_delete(delete()),
)
.when(!remote.is_empty(), |list| {
list.child(
selected(
BranchList::new("tree-remote", remote.iter().map(|r| branch(r))),
Kind::Remote,
)
.on_pick(pick(Kind::Remote))
.on_switch(switch(Kind::Remote)),
)
})
.when(!tags.is_empty(), |list| {
let tags_list = TagList::new("tree-tags", tags.iter().map(|r| tag(r)))
.on_pick(pick(Kind::Tag));
list.child(match picked_in(Kind::Tag) {
Some(name) => tags_list.selected(name),
None => tags_list,
})
}),
)
.into_any_element()
}
+84
View File
@@ -0,0 +1,84 @@
//! Stashes: the list, and what one holds (TREE-10).
use std::{io, path::Path};
use crate::{
detail::{self, Detail, Touched},
git::Git,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stash {
pub oid: String,
/// What the user wrote, or Git's `WIP` line.
pub message: String,
pub branch: String,
pub at: i64,
}
fn bad(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread stash {what:?}"),
)
}
/// `On main: words` or `WIP on main: abc subject`.
fn subject(said: &str) -> (String, String) {
let rest = said
.strip_prefix("WIP on ")
.or_else(|| said.strip_prefix("On "));
match rest.and_then(|rest| rest.split_once(": ")) {
Some((branch, message)) => (branch.to_string(), message.to_string()),
None => (String::new(), said.to_string()),
}
}
/// Newest first, as `stash@{n}` counts.
pub fn list(git: &Git, root: &Path) -> io::Result<Vec<Stash>> {
let out = git.run(root, &["stash", "list", "-z", "--format=%H%x00%ct%x00%gs"])?;
crate::recover::records(&out, 3)?
.into_iter()
.map(|fields| {
let [oid, at, said] = fields[..] else {
return Err(bad(&fields.join(" ")));
};
let (branch, message) = subject(said);
Ok(Stash {
oid: oid.to_string(),
message,
branch,
at: at.parse().map_err(|_| bad(at))?,
})
})
.collect()
}
/// Its changes, then the untracked files it took.
pub fn detail(git: &Git, root: &Path, oid: &str) -> io::Result<Detail> {
let mut detail = detail::read(git, root, oid)?;
if let Some(untracked) = detail.parents.get(2).cloned() {
let held = detail::read(git, root, &untracked)?;
detail
.files
.extend(held.files.into_iter().map(|file| Touched {
commit: Some(untracked.clone()),
..file
}));
}
Ok(detail)
}
#[cfg(test)]
mod tests {
#[test]
fn a_subject_gives_branch_and_message() {
let read = |said| super::subject(said);
assert_eq!(read("On main: a: b"), ("main".into(), "a: b".into()));
assert_eq!(
read("WIP on (no branch): 1a2b c"),
("(no branch)".into(), "1a2b c".into())
);
assert_eq!(read("odd"), (String::new(), "odd".into()));
}
}
+291
View File
@@ -0,0 +1,291 @@
//! The work tree from porcelain v2 status (TREE-02).
use std::{collections::HashMap, io, path::Path};
use crate::git::Git;
/// The arguments; untracked and ignored folders stay one entry.
pub const ARGS: [&str; 6] = [
"status",
"--porcelain=v2",
"-z",
"--branch",
"--untracked-files=normal",
"--ignored",
];
/// Where HEAD stands.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Head {
/// None before the first commit.
pub oid: Option<String>,
/// None when detached.
pub branch: Option<String>,
pub upstream: Option<String>,
/// Ahead and behind; None when the upstream is gone.
pub counts: Option<(u32, u32)>,
}
/// What a path is to Git.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum State {
/// Index and work tree letters, `.` for none.
Tracked {
staged: char,
unstaged: char,
},
/// Both sides' letters, as `UU`, `AA`, `DU`.
Conflicted(String),
Untracked,
Ignored,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub path: String,
/// The old path of a rename or copy.
pub from: Option<String>,
pub state: State,
}
/// Added and removed lines by path; None if binary.
pub type Lines = HashMap<String, Option<(usize, usize)>>;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Status {
pub head: Head,
pub entries: Vec<Entry>,
/// Index against HEAD.
pub staged: Lines,
/// Work tree against the index.
pub unstaged: Lines,
}
fn bad(record: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread status record {record:?}"),
)
}
/// The path after `skip` space-separated fields.
fn after(record: &str, skip: usize) -> io::Result<String> {
record
.splitn(skip + 1, ' ')
.nth(skip)
.map(str::to_string)
.ok_or_else(|| bad(record))
}
fn letters(record: &str) -> io::Result<(char, char)> {
let mut xy = record.get(2..4).ok_or_else(|| bad(record))?.chars();
Ok((
xy.next().ok_or_else(|| bad(record))?,
xy.next().ok_or_else(|| bad(record))?,
))
}
/// A changed file's letters, each one Git documents.
fn tracked(record: &str) -> io::Result<State> {
let (staged, unstaged) = letters(record)?;
match [staged, unstaged]
.iter()
.all(|letter| ".MTADRC".contains(*letter))
{
true => Ok(State::Tracked { staged, unstaged }),
false => Err(bad(record)),
}
}
fn header(head: &mut Head, line: &str) -> io::Result<()> {
let (key, value) = line.split_once(' ').ok_or_else(|| bad(line))?;
match key {
"branch.oid" => head.oid = (value != "(initial)").then(|| value.to_string()),
"branch.head" => head.branch = (value != "(detached)").then(|| value.to_string()),
"branch.upstream" => head.upstream = Some(value.to_string()),
"branch.ab" => {
let (ahead, behind) = value.split_once(' ').ok_or_else(|| bad(line))?;
let count = |n: &str, sign| {
n.strip_prefix(sign)
.and_then(|n| n.parse().ok())
.ok_or_else(|| bad(line))
};
head.counts = Some((count(ahead, '+')?, count(behind, '-')?));
}
// Stash counts and later headers are not read here.
_ => {}
}
Ok(())
}
/// Reads porcelain v2 with `-z`: records end in NUL.
pub fn parse(bytes: &[u8]) -> io::Result<Status> {
let text = std::str::from_utf8(bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "status that is not UTF-8"))?;
let mut records = text.split('\0').filter(|record| !record.is_empty());
let mut status = Status::default();
while let Some(record) = records.next() {
let entry = match record.as_bytes()[0] {
b'#' => {
header(&mut status.head, &after(record, 1)?)?;
continue;
}
b'1' => Entry {
path: after(record, 8)?,
from: None,
state: tracked(record)?,
},
b'2' => {
let state = tracked(record)?;
let from = records.next().ok_or_else(|| bad(record))?.to_string();
Entry {
path: after(record, 9)?,
from: Some(from),
state,
}
}
b'u' => {
let (ours, theirs) = letters(record)?;
Entry {
path: after(record, 10)?,
from: None,
state: State::Conflicted(format!("{ours}{theirs}")),
}
}
b'?' => Entry {
path: after(record, 1)?,
from: None,
state: State::Untracked,
},
b'!' => Entry {
path: after(record, 1)?,
from: None,
state: State::Ignored,
},
_ => return Err(bad(record)),
};
status.entries.push(entry);
}
Ok(status)
}
/// Reads `diff --numstat -z`; renames keyed by new path.
pub fn lines(bytes: &[u8]) -> io::Result<Lines> {
let text = std::str::from_utf8(bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "numstat that is not UTF-8"))?;
let mut records = text.split('\0');
let mut lines = Lines::new();
while let Some(record) = records.next().filter(|record| !record.is_empty()) {
let mut fields = record.splitn(3, '\t');
let (added, removed, path) = (
fields.next(),
fields.next(),
fields.next().ok_or_else(|| bad(record))?,
);
let path = match path {
"" => records.nth(1).ok_or_else(|| bad(record))?,
path => path,
};
let count = match (added, removed) {
(Some("-"), Some("-")) => None,
(Some(added), Some(removed)) => Some((
added.parse().map_err(|_| bad(record))?,
removed.parse().map_err(|_| bad(record))?,
)),
_ => return Err(bad(record)),
};
lines.insert(path.to_string(), count);
}
Ok(lines)
}
/// The work tree at `dir`, read without taking Git's locks.
pub fn read(git: &Git, dir: &Path) -> io::Result<Status> {
let mut status = parse(&git.run(dir, &ARGS)?)?;
let tracked = status
.entries
.iter()
.any(|entry| matches!(entry.state, State::Tracked { .. }));
if tracked {
status.staged = lines(&git.run(dir, &["diff", "--cached", "--numstat", "-z"])?)?;
status.unstaged = lines(&git.run(dir, &["diff", "--numstat", "-z"])?)?;
}
Ok(status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_record_kind_reads() {
let bytes = b"# branch.oid (initial)\0# branch.head main\0# branch.upstream origin/main\0# branch.ab +2 -1\0\
1 .M N... 100644 100644 100644 aaa bbb a b.txt\0\
2 R. N... 100644 100644 100644 aaa bbb R100 new name.txt\0old name.txt\0\
u UU N... 100644 100644 100644 100644 a b c clash.txt\0? new.txt\0! target/\0";
let status = parse(bytes).expect("reads");
assert_eq!(
status.head,
Head {
oid: None,
branch: Some("main".into()),
upstream: Some("origin/main".into()),
counts: Some((2, 1)),
}
);
let states: Vec<(&str, &State)> = status
.entries
.iter()
.map(|e| (e.path.as_str(), &e.state))
.collect();
assert_eq!(
states,
[
(
"a b.txt",
&State::Tracked {
staged: '.',
unstaged: 'M'
}
),
(
"new name.txt",
&State::Tracked {
staged: 'R',
unstaged: '.'
}
),
("clash.txt", &State::Conflicted("UU".into())),
("new.txt", &State::Untracked),
("target/", &State::Ignored),
]
);
assert_eq!(status.entries[1].from.as_deref(), Some("old name.txt"));
}
#[test]
fn numstat_reads_counts_binaries_and_renames() {
let read = lines(b"3\t1\ta b.txt\0-\t-\tlogo.png\0" as &[u8]).expect("reads");
assert_eq!(read["a b.txt"], Some((3, 1)));
assert_eq!(read["logo.png"], None, "binary");
let read = lines(b"0\t0\t\0old.txt\0new.txt\0").expect("reads");
assert_eq!(read.keys().collect::<Vec<_>>(), ["new.txt"]);
assert!(lines(b"x\t1\tf\0").is_err() && lines(b"1\0").is_err());
}
#[test]
fn a_strange_record_is_an_error() {
let gone = parse(b"# branch.head main\0# branch.upstream origin/main\0").expect("reads");
assert_eq!(gone.head.counts, None, "no counts is not zero");
assert!(parse(b"7 what\0").is_err());
assert!(
parse(b"1 .X N... 100644 100644 100644 aaa bbb f\0").is_err(),
"an unknown letter"
);
assert!(parse(b"1 .M\0").is_err(), "cut short");
assert!(
parse(b"u\0").is_err() && parse(b"?\0").is_err(),
"no panic on a stub"
);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Worktrees, submodules and LFS files (TREE-12).
use std::{
io,
path::{Path, PathBuf},
};
use crate::git::Git;
fn bad(what: &str) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("an unread listing {what:?}"),
)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Worktree {
pub path: PathBuf,
pub head: Option<String>,
/// Short name; none when detached.
pub branch: Option<String>,
pub bare: bool,
/// Why it is locked, maybe empty.
pub locked: Option<String>,
/// Why Git would prune it.
pub prunable: Option<String>,
}
/// `worktree list --porcelain -z`: fields, records end empty.
pub fn worktrees(git: &Git, root: &Path) -> io::Result<Vec<Worktree>> {
let out = git.run(root, &["worktree", "list", "--porcelain", "-z"])?;
let text = std::str::from_utf8(&out).map_err(|_| bad("not UTF-8"))?;
let mut found = Vec::new();
let mut now: Option<Worktree> = None;
for field in text.split('\0') {
if field.is_empty() {
found.extend(now.take());
continue;
}
let (key, value) = field.split_once(' ').unwrap_or((field, ""));
let tree = match (&mut now, key) {
(None, "worktree") => {
now = Some(Worktree {
path: PathBuf::from(value),
..Worktree::default()
});
continue;
}
(Some(tree), _) => tree,
(None, _) => return Err(bad(field)),
};
match key {
"HEAD" => tree.head = Some(value.to_string()),
"branch" => {
let short = value.strip_prefix("refs/heads/").unwrap_or(value);
tree.branch = Some(short.to_string());
}
"bare" => tree.bare = true,
"locked" => tree.locked = Some(value.to_string()),
"prunable" => tree.prunable = Some(value.to_string()),
"detached" => {}
_ => return Err(bad(field)),
}
}
found.extend(now);
Ok(found)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Checkout {
/// Not cloned into its folder yet.
Missing,
At(String),
Unread(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Submodule {
pub path: String,
/// The commit this repository records.
pub recorded: String,
pub conflicted: bool,
pub checkout: Checkout,
}
/// Gitlinks in the index, and what each folder holds.
pub fn submodules(git: &Git, root: &Path) -> io::Result<Vec<Submodule>> {
let out = git.run(root, &["ls-files", "--stage", "-z"])?;
let text = std::str::from_utf8(&out).map_err(|_| bad("not UTF-8"))?;
let mut found: Vec<Submodule> = Vec::new();
for entry in text.split('\0').filter(|entry| !entry.is_empty()) {
let (head, path) = entry.split_once('\t').ok_or_else(|| bad(entry))?;
let mut parts = head.split(' ');
let (Some(mode), Some(oid), Some(stage)) = (parts.next(), parts.next(), parts.next())
else {
return Err(bad(entry));
};
if mode != "160000" {
continue;
}
if let Some(known) = found.iter_mut().find(|known| known.path == path) {
known.conflicted = true;
continue;
}
let folder = root.join(path);
// Without its own `.git`, Git here would be ours.
let checkout = if !folder.join(".git").exists() {
Checkout::Missing
} else {
match git.run(&folder, &["rev-parse", "HEAD"]) {
Ok(head) => Checkout::At(String::from_utf8_lossy(&head).trim().to_string()),
Err(error) => Checkout::Unread(error.to_string()),
}
};
found.push(Submodule {
path: path.to_string(),
recorded: oid.to_string(),
conflicted: stage != "0",
checkout,
});
}
Ok(found)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LfsFile {
pub path: String,
pub size: u64,
/// The work tree holds the content, not the pointer.
pub checked_out: bool,
/// The object is in this repository's store.
pub downloaded: bool,
}
/// `git lfs ls-files` as JSON; `files` is null with none.
pub fn lfs(git: &Git, root: &Path) -> io::Result<Vec<LfsFile>> {
let out = git.run(root, &["lfs", "ls-files", "--long", "--json"])?;
let read: serde_json::Value = serde_json::from_slice(&out).map_err(io::Error::other)?;
let files = match &read["files"] {
serde_json::Value::Null => return Ok(Vec::new()),
serde_json::Value::Array(files) => files,
other => return Err(bad(&other.to_string())),
};
files
.iter()
.map(|file| {
Ok(LfsFile {
path: file["name"]
.as_str()
.ok_or_else(|| bad("name"))?
.to_string(),
size: file["size"].as_u64().ok_or_else(|| bad("size"))?,
checked_out: file["checkout"].as_bool().ok_or_else(|| bad("checkout"))?,
downloaded: file["downloaded"]
.as_bool()
.ok_or_else(|| bad("downloaded"))?,
})
})
.collect()
}
+336
View File
@@ -0,0 +1,336 @@
//! Repositories, status and writes against the bundled Git (C13.1).
use std::path::{Path, PathBuf};
use ely_gpui_component::{git::Changed, lists::GitStatus};
use crate::{
changes::sections,
git::Git,
repo,
status::{self, State},
work::Write,
};
mod boxes;
mod branching;
mod diffs;
mod flows;
mod history;
mod place_flows;
mod places;
mod cleaning;
mod recovery;
mod window;
pub(crate) fn git() -> Git {
Git::find().expect("the bundled Git; run scripts/build-git.sh")
}
/// Runs Git with a test identity and no signing.
pub(crate) fn sh(dir: &Path, args: &[&str]) -> String {
let mut all = vec![
"-c",
"user.name=Ely",
"-c",
"user.email=ely@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgSign=false",
];
all.extend(args);
String::from_utf8(git().run(dir, &all).expect("git runs")).expect("utf-8")
}
/// A repository with one commit of `a.txt`.
pub(crate) fn committed(dir: &Path) -> PathBuf {
let root = dir.join("work");
repo::init(&git(), &root).expect("init");
std::fs::write(root.join("a.txt"), "one\ntwo\n").expect("writes");
sh(&root, &["add", "a.txt"]);
sh(&root, &["commit", "-q", "-m", "first"]);
root
}
fn paths(files: &[Changed]) -> Vec<(&str, GitStatus, Option<(usize, usize)>)> {
files
.iter()
.map(|file| (file.path.as_ref(), file.status, file.lines))
.collect()
}
#[test]
fn a_repository_opens_from_inside_and_a_bare_one_does_not() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::create_dir(root.join("deep")).expect("a folder");
let opened = repo::open(&git(), &root.join("deep")).expect("opens");
assert_eq!(
opened.root,
std::fs::canonicalize(&root).expect("real"),
"the real top"
);
assert!(opened.git_dir.ends_with(".git"));
let bare = dir.path().join("bare.git");
sh(
dir.path(),
&["init", "-q", "--bare", bare.to_str().expect("utf-8")],
);
let refused = repo::open(&git(), &bare).expect_err("bare");
assert_eq!(refused.kind(), std::io::ErrorKind::Unsupported);
}
#[test]
fn the_work_tree_reads_into_five_sections() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("b.txt"), "b\n").expect("writes");
std::fs::write(root.join("clash.txt"), "base\n").expect("writes");
sh(&root, &["add", "."]);
sh(&root, &["commit", "-q", "-m", "second"]);
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
sh(&root, &["checkout", "-q", "-b", "side"]);
std::fs::write(root.join("clash.txt"), "side\n").expect("writes");
sh(&root, &["commit", "-q", "-am", "side"]);
sh(&root, &["checkout", "-q", &main]);
std::fs::write(root.join("clash.txt"), "main\n").expect("writes");
sh(&root, &["commit", "-q", "-am", "main"]);
assert!(
git().run(&root, &["merge", "-q", "side"]).is_err(),
"a conflict"
);
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\n").expect("writes");
sh(&root, &["mv", "b.txt", "c.txt"]);
std::fs::write(root.join("*.txt"), "\0binary").expect("writes");
std::fs::write(root.join(".gitignore"), "build/\n").expect("writes");
std::fs::create_dir(root.join("build")).expect("a folder");
std::fs::write(root.join("build/out"), "x").expect("writes");
let read = status::read(&git(), &root).expect("reads");
assert_eq!(read.head.branch.as_deref(), Some(main.as_str()));
let [conflicted, staged, unstaged, untracked, ignored] = sections(&read);
assert_eq!(
paths(&conflicted),
[("clash.txt", GitStatus::Conflicted, None)]
);
assert_eq!(
paths(&staged),
[("c.txt", GitStatus::Renamed, Some((0, 0)))]
);
assert_eq!(
paths(&unstaged),
[("a.txt", GitStatus::Modified, Some((1, 0)))]
);
assert_eq!(
paths(&untracked),
[
("*.txt", GitStatus::Untracked, None),
(".gitignore", GitStatus::Untracked, None)
]
);
assert_eq!(
paths(&ignored),
[("build/", GitStatus::Untracked, None)],
"a folder is one entry"
);
assert_eq!(
read.entries
.iter()
.find(|e| e.path == "c.txt")
.and_then(|e| e.from.as_deref()),
Some("b.txt")
);
}
/// Runs one write as the app does.
fn write(root: &Path, write: Write) {
let born = status::read(&git(), root)
.expect("reads")
.head
.oid
.is_some();
let args = write.args(born);
git()
.run(root, &args.iter().map(String::as_str).collect::<Vec<_>>())
.expect("the write runs");
}
pub(crate) fn entry(root: &Path, path: &str) -> status::Entry {
let read = status::read(&git(), root).expect("reads");
read.entries
.into_iter()
.find(|entry| entry.path == path)
.expect("listed")
}
#[test]
fn writes_stage_unstage_and_discard_one_literal_path() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("*.txt"), "star\n").expect("writes");
std::fs::write(root.join("x.txt"), "x\n").expect("writes");
write(&root, Write::Stage(entry(&root, "*.txt")));
let staged = |root: &Path| {
status::read(&git(), root)
.expect("reads")
.staged
.into_keys()
.collect::<Vec<_>>()
};
assert_eq!(staged(&root), ["*.txt"], "the star is no pattern");
write(&root, Write::Unstage(entry(&root, "*.txt")));
assert!(staged(&root).is_empty());
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
write(&root, Write::Discard(entry(&root, "a.txt")));
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
"one\ntwo\n"
);
write(&root, Write::Discard(entry(&root, "*.txt")));
assert!(
!root.join("*.txt").exists() && root.join("x.txt").exists(),
"only that file goes"
);
std::fs::write(root.join("a.txt"), "again\n").expect("writes");
write(&root, Write::StageAll);
assert_eq!(staged(&root), ["a.txt"], "tracked changes only");
write(&root, Write::UnstageAll);
assert!(staged(&root).is_empty());
}
#[test]
fn before_the_first_commit_unstaging_still_works() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("new");
repo::init(&git(), &root).expect("init");
std::fs::write(root.join("a.txt"), "a\n").expect("writes");
write(&root, Write::Stage(entry(&root, "a.txt")));
let read = status::read(&git(), &root).expect("reads");
assert_eq!(read.head.oid, None);
assert_eq!(read.staged.get("a.txt"), Some(&Some((1, 0))));
write(&root, Write::Unstage(entry(&root, "a.txt")));
assert_eq!(entry(&root, "a.txt").state, State::Untracked);
write(&root, Write::Stage(entry(&root, "a.txt")));
std::fs::write(root.join("a.txt"), "a\nedited\n").expect("writes");
write(&root, Write::Unstage(entry(&root, "a.txt")));
assert_eq!(
entry(&root, "a.txt").state,
State::Untracked,
"staged and edited"
);
write(&root, Write::Stage(entry(&root, "a.txt")));
std::fs::write(root.join("a.txt"), "a\nagain\n").expect("writes");
write(&root, Write::UnstageAll);
assert_eq!(entry(&root, "a.txt").state, State::Untracked);
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
"a\nagain\n"
);
}
#[test]
fn a_clone_reports_progress_and_refuses_a_full_folder() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = committed(dir.path());
let into = dir.path().join("copy");
let mut seen = Vec::new();
let url = source.to_str().expect("utf-8");
git()
.clone_repo(url, &into)
.expect("starts")
.follow(|line| seen.push(line.to_string()))
.expect("clones");
assert!(into.join("a.txt").is_file());
assert!(
seen.iter().any(|line| line.starts_with("Cloning into")),
"{seen:?}"
);
let full = git().clone_repo(url, &into).err().expect("refused");
assert_eq!(full.kind(), std::io::ErrorKind::AlreadyExists);
}
#[cfg(unix)]
#[test]
fn a_stopped_clone_ends_and_leaves_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
// A server that never answers keeps Git waiting.
let silent = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let url = format!("git://{}/nothing", silent.local_addr().expect("an address"));
let into = dir.path().join("never");
let running = git().clone_repo(&url, &into).expect("starts");
let stop = running.stop();
let (said, heard) = std::sync::mpsc::channel();
let followed = std::thread::spawn(move || {
running.follow(|line| {
let _ = said.send(line.to_string());
})
});
let _held = silent.accept().expect("Git connects");
let first = heard
.recv_timeout(std::time::Duration::from_secs(5))
.expect("a line while waiting");
assert!(first.starts_with("Cloning into"), "{first}");
stop.now().expect("stops");
assert!(
followed.join().expect("joins").is_err(),
"a stopped clone fails"
);
assert!(!into.exists(), "Git removed what it began");
stop.now().expect("stopping an ended clone is a no-op");
}
#[test]
fn a_staged_rename_edited_again_stages_by_its_new_path() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["mv", "a.txt", "c.txt"]);
std::fs::write(root.join("c.txt"), "one\ntwo\nmore\n").expect("writes");
write(&root, Write::Stage(entry(&root, "c.txt")));
let read = status::read(&git(), &root).expect("reads");
assert!(read.unstaged.is_empty(), "the edit is staged");
write(&root, Write::Unstage(entry(&root, "c.txt")));
let read = status::read(&git(), &root).expect("reads");
assert!(read.staged.is_empty(), "both paths go back");
}
#[test]
fn a_gone_upstream_has_no_counts() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = committed(dir.path());
let into = dir.path().join("copy");
let url = source.to_str().expect("utf-8");
git()
.clone_repo(url, &into)
.expect("starts")
.follow(|_| {})
.expect("clones");
let head = status::read(&git(), &into).expect("reads").head;
assert_eq!(head.counts, Some((0, 0)));
let upstream = head.upstream.expect("an upstream");
sh(
&into,
&["update-ref", "-d", &format!("refs/remotes/{upstream}")],
);
let head = status::read(&git(), &into).expect("reads").head;
assert_eq!(
(head.upstream.as_deref(), head.counts),
(Some(upstream.as_str()), None)
);
}
#[cfg(unix)]
#[test]
fn a_linked_file_keeps_its_own_name() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = std::fs::canonicalize(dir.path()).expect("real");
std::fs::create_dir(root.join("docs")).expect("a folder");
std::fs::write(root.join("real.md"), "a").expect("a file");
let outside = tempfile::tempdir().expect("another dir");
std::fs::write(outside.path().join("far.md"), "b").expect("a far file");
std::os::unix::fs::symlink("../real.md", root.join("docs/link.md")).expect("a link");
std::os::unix::fs::symlink(outside.path().join("far.md"), root.join("far.md")).expect("an outside link");
// Git tracks the link, not what it points at.
assert_eq!(crate::tree::inside(&root, &root.join("docs/link.md")).expect("inside"), "docs/link.md");
assert_eq!(crate::tree::inside(&root, &root.join("far.md")).expect("inside"), "far.md");
assert!(crate::tree::inside(&root, &outside.path().join("far.md")).is_err());
}
+227
View File
@@ -0,0 +1,227 @@
//! Commit box, history refresh and settings, windowed (C13.3).
use gpui::{App, Entity, TestAppContext, VisualTestContext};
use super::{
committed, sh,
window::{branch, settle, window},
};
use crate::tree::Tree;
pub(crate) fn subject(tree: &Entity<Tree>, cx: &mut VisualTestContext) -> String {
cx.update(|_, app| tree.read(app).subject.read(app).text().to_string())
}
pub(crate) fn type_subject(tree: &Entity<Tree>, cx: &mut VisualTestContext, words: &str) {
let words = words.to_string();
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.subject
.update(cx, |field, cx| field.set_text(words, cx))
})
});
}
/// Two repositories, each with its own identity set.
pub(crate) fn two(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
let (a, b) = (committed(&dir.join("a")), committed(&dir.join("b")));
for root in [&a, &b] {
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
] {
sh(root, &["config", key, value]);
}
}
(a, b)
}
#[test]
fn each_repository_keeps_its_own_commit_box() {
let dir = tempfile::tempdir().expect("a temp dir");
let (a, b) = two(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
for (path, id) in [(a.clone(), 1), (b.clone(), 2)] {
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(path, cx)));
settle(cx, |app| tree.read(app).active == Some(id));
}
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(1), cx)));
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_amend(true, cx)));
settle(cx, |app| {
tree.read(app).amend && !tree.read(app).amend_loading
});
assert_eq!(subject(&tree, cx), "first", "a's last message");
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(2), cx)));
assert!(
!cx.update(|_, app| tree.read(app).amend),
"b does not amend"
);
assert_eq!(subject(&tree, cx), "");
type_subject(&tree, cx, "b's draft");
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(1), cx)));
assert!(cx.update(|_, app| tree.read(app).amend));
assert_eq!(subject(&tree, cx), "first");
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(2), cx)));
assert_eq!(subject(&tree, cx), "b's draft");
}
#[test]
fn unticking_amend_voids_a_read_on_its_way() {
let dir = tempfile::tempdir().expect("a temp dir");
let (a, _) = two(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
type_subject(&tree, cx, "mine");
// Two clicks on the box, as the control sends them.
for _ in 0..2 {
cx.update(|_, app| {
tree.update(app, |tree, cx| {
let on = !tree.amend;
tree.set_amend(on, cx)
})
});
}
for _ in 0..20 {
settle(cx, |_| true);
}
assert!(!cx.update(|_, app| tree.read(app).amend));
assert_eq!(subject(&tree, cx), "mine");
}
#[test]
fn typing_during_a_commit_is_kept() {
let dir = tempfile::tempdir().expect("a temp dir");
let (a, _) = two(dir.path());
std::fs::write(a.join("a.txt"), "changed\n").expect("writes");
sh(&a, &["add", "a.txt"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
type_subject(&tree, cx, "submitted");
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.commit(cx);
tree.subject
.update(cx, |field, cx| field.set_text("next draft".to_string(), cx));
})
});
settle(cx, |_| {
sh(&a, &["log", "-1", "--format=%s"]).trim() == "submitted"
});
for _ in 0..5 {
settle(cx, |_| true);
}
assert_eq!(subject(&tree, cx), "next draft");
}
#[test]
fn only_the_latest_blame_takes_its_read() {
use crate::look::Look;
let dir = tempfile::tempdir().expect("a temp dir");
let (a, b) = two(dir.path());
std::fs::write(b.join("a.txt"), "other\n").expect("writes");
sh(&b, &["commit", "-qam", "b's own"]);
let b_head = sh(&b, &["rev-parse", "HEAD"]).trim().to_string();
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
for (path, id) in [(a.clone(), 1), (b.clone(), 2)] {
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(path, cx)));
settle(cx, |app| tree.read(app).active == Some(id));
}
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.activate(Some(1), cx);
tree.blame("a.txt", cx);
tree.activate(Some(2), cx);
tree.blame("a.txt", cx);
})
});
let owner = |app: &App| match &tree.read(app).look {
Some(Look::Blame {
read: Some(Ok(read)),
..
}) => Some(read.owners[read.lines[0]].commit.clone()),
_ => None,
};
settle(cx, |app| owner(app).is_some());
for _ in 0..10 {
settle(cx, |_| true);
}
assert_eq!(cx.update(|_, app| owner(app)), Some(b_head));
}
#[test]
fn history_catches_up_and_shows_ref_errors() {
use crate::tree::Side;
let dir = tempfile::tempdir().expect("a temp dir");
let (a, _) = two(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let rows = |app: &App| {
tree.read(app)
.active()
.map_or(0, |opened| opened.hist.rows.len())
};
// A refresh asked mid-read is held, then run.
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.set_side(Side::History, cx);
tree.refresh_log(1, cx);
})
});
std::fs::write(a.join("a.txt"), "outside\n").expect("writes");
sh(&a, &["commit", "-qam", "outside"]);
settle(cx, |app| rows(app) == 2);
let error = |app: &App| {
tree.read(app)
.active()
.and_then(|opened| opened.hist.error.clone())
};
std::fs::write(a.join(".git/refs/heads/broken"), "broken-ref\n").expect("writes");
cx.update(|_, app| tree.update(app, |tree, cx| tree.refresh_log(1, cx)));
settle(cx, |app| {
error(app).is_some_and(|error| error.contains("broken"))
});
std::fs::remove_file(a.join(".git/refs/heads/broken")).expect("removes");
cx.update(|_, app| tree.update(app, |tree, cx| tree.refresh_log(1, cx)));
settle(cx, |app| error(app).is_none() && rows(app) == 2);
}
#[test]
fn identity_follows_outside_settings_and_names_its_fields() {
use crate::asks::Ask;
let dir = tempfile::tempdir().expect("a temp dir");
let (a, _) = two(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
let name = |app: &App| match tree.read(app).active().and_then(|opened| opened.me.clone()) {
Some(Ok(me)) => me.name,
_ => None,
};
settle(cx, |app| name(app).as_deref() == Some("Ely"));
sh(&a, &["config", "user.name", "Changed Identity"]);
// Any outside change rereads the settings too.
std::fs::write(a.join("poke.txt"), "x\n").expect("writes");
settle(cx, |app| name(app).as_deref() == Some("Changed Identity"));
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_identity(window, cx)));
let labels = cx.update(|_, app| match &tree.read(app).ask {
Some(Ask::Identity { name, email, .. }) => (
name.read(app).label_text().to_string(),
email.read(app).label_text().to_string(),
),
_ => (String::new(), String::new()),
});
let want = cx.update(|_, app| {
let text = |key| crate::tree::text(app, key, &[]).to_string();
(text("tree.identity.name"), text("tree.identity.email"))
});
assert_eq!(labels, want);
}
+430
View File
@@ -0,0 +1,430 @@
//! Branches, tags, operations under way, conflicts (C13.4).
use std::path::Path;
use super::{committed, git, sh};
use ely_gpui_component::git::{MARKER, RegionKind, conflicts};
use crate::{
branches::{self, Kind},
conflict::{self, Stages},
ops::{self, Op},
work::{self, Write},
};
/// A merge of `side` that conflicts on a.txt.
pub(crate) fn conflicted(dir: &Path) -> (std::path::PathBuf, String) {
let root = committed(dir);
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
] {
sh(&root, &["config", key, value]);
}
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
sh(&root, &["checkout", "-q", "-b", "side"]);
std::fs::write(root.join("a.txt"), "one\nside\n").expect("writes");
sh(&root, &["commit", "-qam", "side change"]);
sh(&root, &["checkout", "-q", &main]);
std::fs::write(root.join("a.txt"), "one\nmain\n").expect("writes");
sh(&root, &["commit", "-qam", "main change"]);
(root, main)
}
#[test]
fn refs_read_branches_remotes_and_both_kinds_of_tag() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = committed(&dir.path().join("source"));
let into = dir.path().join("copy");
git()
.clone_repo(source.to_str().expect("utf-8"), &into)
.expect("starts")
.follow(|_| {})
.expect("clones");
sh(&into, &["branch", "topic"]);
sh(&into, &["tag", "light"]);
sh(&into, &["tag", "-a", "heavy", "-m", "Release notes"]);
std::fs::write(into.join("a.txt"), "ahead\n").expect("writes");
sh(&into, &["commit", "-qam", "ahead"]);
let refs = branches::read(&git(), &into).expect("reads");
let find = |name: &str| {
refs.iter()
.find(|r| r.name == name)
.unwrap_or_else(|| panic!("{name} in {refs:?}"))
};
let main = sh(&into, &["branch", "--show-current"]).trim().to_string();
assert!(find(&main).current && find(&main).kind == Kind::Local);
assert_eq!(find(&main).track, Some((1, 0)), "one ahead of its upstream");
assert_eq!(find(&format!("origin/{main}")).kind, Kind::Remote);
assert!(
!refs
.iter()
.any(|r| r.name == "origin/HEAD" || r.name == "origin"),
"origin/HEAD is no branch"
);
assert_eq!(find("light").message, None);
let heavy = find("heavy");
assert_eq!(
(heavy.message.as_deref(), heavy.subject.as_str()),
(Some("Release notes"), "first")
);
assert_eq!(
heavy.commit,
find("light").commit,
"an annotated tag names its commit"
);
}
#[test]
fn operations_under_way_name_their_sides() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, main) = conflicted(dir.path());
let doing = |root: &Path| ops::doing(&git(), root, &root.join(".git")).expect("reads");
assert_eq!(doing(&root), None);
assert!(git().run(&root, &["merge", "side"]).is_err());
let merge = doing(&root).expect("a merge");
assert_eq!(merge.op, Op::Merge);
assert_eq!(merge.ours, main);
assert!(merge.theirs.ends_with("side change"), "{}", merge.theirs);
sh(&root, &["merge", "--abort"]);
sh(&root, &["checkout", "-q", "side"]);
assert!(git().run(&root, &["rebase", &main]).is_err());
let rebase = doing(&root).expect("a rebase");
assert_eq!(rebase.op, Op::Rebase);
assert!(
rebase.ours.ends_with("main change") && rebase.theirs.ends_with("side change"),
"{rebase:?}"
);
sh(&root, &["rebase", "--abort"]);
let side = sh(&root, &["rev-parse", "side"]).trim().to_string();
sh(&root, &["checkout", "-q", &main]);
assert!(git().run(&root, &["cherry-pick", &side]).is_err());
assert_eq!(doing(&root).expect("a pick").op, Op::CherryPick);
}
#[test]
fn a_conflict_reads_its_stages_and_gits_own_blocks() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
assert!(git().run(&root, &["merge", "side"]).is_err());
let Stages::Text {
sides,
work,
regions,
..
} = conflict::read(&git(), &root, "a.txt").expect("reads")
else {
panic!("a text conflict")
};
assert_eq!(
sides,
[
Some("one\ntwo\n".into()),
Some("one\nmain\n".into()),
Some("one\nside\n".into())
]
);
assert!(work.contains("<<<<<<<"));
let conflict = regions
.iter()
.find(|region| region.kind == RegionKind::Conflict)
.expect("a block");
assert_eq!(
(conflict.ours.clone(), conflict.base.clone()),
(vec!["main\n".to_string()], vec!["two\n".to_string()]),
"base from Git's diff3"
);
}
#[test]
fn near_conflicts_stay_one_block_as_git_wrote_them() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
] {
sh(&root, &["config", key, value]);
}
std::fs::write(root.join("a.txt"), "first\na\nunchanged\nb\nlast\n").expect("writes");
sh(&root, &["commit", "-qam", "base"]);
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
sh(&root, &["checkout", "-q", "-b", "side"]);
std::fs::write(
root.join("a.txt"),
"first\nTHEIRS a\nunchanged\nTHEIRS b\nlast\n",
)
.expect("writes");
sh(&root, &["commit", "-qam", "theirs"]);
sh(&root, &["checkout", "-q", &main]);
std::fs::write(
root.join("a.txt"),
"first\nOURS a\nunchanged\nOURS b\nlast\n",
)
.expect("writes");
sh(&root, &["commit", "-qam", "ours"]);
assert!(git().run(&root, &["merge", "side"]).is_err());
let stages = conflict::read(&git(), &root, "a.txt").expect("reads");
let merging = conflict::Merging::new(stages).expect("text");
let markers = conflicts(&merging.saved, MARKER).len();
assert_eq!(
merging.takes.len(),
markers,
"the view's blocks are Git's blocks"
);
}
#[cfg(unix)]
#[test]
fn links_and_bytes_conflict_whole_and_a_save_spares_a_link() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
] {
sh(&root, &["config", key, value]);
}
std::fs::write(root.join("target"), "keep target\n").expect("writes");
symlink("base-target", root.join("link")).expect("links");
std::fs::write(root.join("blob.bin"), b"\0base\xff").expect("writes");
sh(&root, &["add", "-A"]);
sh(&root, &["commit", "-qm", "base"]);
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
sh(&root, &["checkout", "-q", "-b", "side"]);
std::fs::remove_file(root.join("link")).expect("removes");
symlink("theirs-target", root.join("link")).expect("links");
std::fs::write(root.join("blob.bin"), b"\0theirs\xff").expect("writes");
sh(&root, &["commit", "-qam", "theirs"]);
sh(&root, &["checkout", "-q", &main]);
std::fs::remove_file(root.join("link")).expect("removes");
symlink("target", root.join("link")).expect("links");
std::fs::write(root.join("blob.bin"), b"\0ours\xff").expect("writes");
sh(&root, &["commit", "-qam", "ours"]);
assert!(git().run(&root, &["merge", "side"]).is_err());
for path in ["link", "blob.bin"] {
assert_eq!(
conflict::read(&git(), &root, path).expect("reads"),
Stages::Whole { present: [true; 3] },
"{path}"
);
}
let save = Write::Save {
path: "link".into(),
text: "overwritten".into(),
};
assert!(
work::run(&git(), &root, &save, &save.args(true)).is_err(),
"no save through a link"
);
assert_eq!(
std::fs::read_to_string(root.join("target")).expect("reads"),
"keep target\n"
);
}
#[test]
fn a_branch_and_a_tag_of_one_name_keep_their_own_names() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["branch", "topic"]);
sh(&root, &["tag", "topic"]);
let refs = branches::read(&git(), &root).expect("reads");
let named: Vec<(&str, &str)> = refs
.iter()
.filter(|r| r.name == "topic")
.map(|r| (r.name.as_str(), r.full.as_str()))
.collect();
assert_eq!(
named,
[("topic", "refs/heads/topic"), ("topic", "refs/tags/topic")]
);
sh(&root, &["switch", "-q", "topic"]);
assert_eq!(sh(&root, &["branch", "--show-current"]).trim(), "topic");
}
/// Two branches that both change `a.txt` from `base` on.
fn both_change(
dir: &Path,
base: &str,
ours: &str,
theirs: &str,
setup: &[(&str, &str)],
) -> std::path::PathBuf {
let root = committed(dir);
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
]
.iter()
.chain(setup)
{
sh(&root, &["config", key, value]);
}
std::fs::write(root.join("a.txt"), base).expect("writes");
sh(&root, &["add", "-A"]);
sh(&root, &["commit", "-qm", "base"]);
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
sh(&root, &["checkout", "-q", "-b", "side"]);
std::fs::write(root.join("a.txt"), theirs).expect("writes");
sh(&root, &["commit", "-qam", "theirs"]);
sh(&root, &["checkout", "-q", &main]);
std::fs::write(root.join("a.txt"), ours).expect("writes");
sh(&root, &["commit", "-qam", "ours"]);
assert!(git().run(&root, &["merge", "side"]).is_err(), "a conflict");
root
}
#[test]
fn markers_follow_conflict_marker_size_and_keep_lookalike_lines() {
for size in [5usize, 10] {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::create_dir_all(dir.path().join("work")).expect("a folder");
let root = committed(dir.path());
std::fs::write(
root.join(".gitattributes"),
format!("a.txt conflict-marker-size={size}\n"),
)
.expect("writes");
let root = {
sh(&root, &["add", ".gitattributes"]);
sh(&root, &["commit", "-qm", "attributes"]);
root
};
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
] {
sh(&root, &["config", key, value]);
}
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
sh(&root, &["checkout", "-q", "-b", "side"]);
std::fs::write(
root.join("a.txt"),
"theirs-top\n======= literal\nTHEIRS-END\n",
)
.expect("writes");
sh(&root, &["commit", "-qam", "theirs"]);
sh(&root, &["checkout", "-q", &main]);
std::fs::write(root.join("a.txt"), "ours\n").expect("writes");
sh(&root, &["commit", "-qam", "ours"]);
assert!(git().run(&root, &["merge", "side"]).is_err());
let merging =
conflict::Merging::new(conflict::read(&git(), &root, "a.txt").expect("reads"))
.expect("text");
assert_eq!(
(merging.size(), merging.takes.len()),
(size, 1),
"width {size}"
);
let settled = ely_gpui_component::git::resolve(
&merging.saved,
0,
ely_gpui_component::git::Take::Theirs,
size,
);
assert_eq!(
settled.as_deref(),
Some("theirs-top\n======= literal\nTHEIRS-END\n")
);
}
}
#[test]
fn nul_bytes_that_are_utf8_still_conflict_whole() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = both_change(
dir.path(),
"one\0base\n",
"one\0ours\n",
"one\0theirs\n",
&[],
);
assert_eq!(
conflict::read(&git(), &root, "a.txt").expect("reads"),
Stages::Whole { present: [true; 3] }
);
}
#[test]
fn each_block_keeps_its_own_base() {
let same: String = (1..=10).map(|n| format!("same {n}\n")).collect();
let base = format!("base-first\n{same}");
let ours = format!("OURS first\n{same}ours insert\n");
let theirs = format!("THEIRS first\n{same}theirs insert\n");
for style in ["merge", "diff3"] {
let dir = tempfile::tempdir().expect("a temp dir");
let root = both_change(
dir.path(),
&base,
&ours,
&theirs,
&[("merge.conflictStyle", style)],
);
let Stages::Text { regions, .. } = conflict::read(&git(), &root, "a.txt").expect("reads")
else {
panic!("text")
};
let bases: Vec<Vec<String>> = regions
.into_iter()
.filter(|r| r.kind == RegionKind::Conflict)
.map(|r| r.base)
.collect();
assert_eq!(
bases,
[vec!["base-first\n".to_string()], Vec::new()],
"{style}"
);
}
}
#[test]
fn a_remote_branch_and_a_tag_of_one_name_switch_apart() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["update-ref", "refs/remotes/origin/topic", "HEAD"]);
sh(&root, &["tag", "origin/topic"]);
sh(&root, &["config", "remote.origin.url", "/nowhere"]);
sh(
&root,
&[
"config",
"remote.origin.fetch",
"+refs/heads/*:refs/remotes/origin/*",
],
);
let write = Write::Git(vec![
"switch".into(),
"--track".into(),
"refs/remotes/origin/topic".into(),
]);
work::run(&git(), &root, &write, &write.args(true)).expect("switches");
assert_eq!(sh(&root, &["branch", "--show-current"]).trim(), "topic");
}
#[test]
fn marker_sizes_git_cannot_read_mean_seven() {
for value in ["0", "-1", "abc", "10abc"] {
let dir = tempfile::tempdir().expect("a temp dir");
let root = both_change(dir.path(), "base\n", "ours\n", "theirs\n", &[]);
std::fs::write(
root.join(".gitattributes"),
format!("a.txt conflict-marker-size={value}\n"),
)
.expect("writes");
let merging =
conflict::Merging::new(conflict::read(&git(), &root, "a.txt").expect("reads"))
.expect("text");
assert_eq!(
(merging.size(), merging.takes.len()),
(MARKER, 1),
"{value}"
);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Cleaning untracked files: preview, then only what was shown (C13.6).
use gpui::TestAppContext;
use super::{
committed, git,
recovery::run,
sh,
window::{branch, settle, window},
};
use crate::{asks::Ask, recover, work::Write};
#[test]
fn a_clean_removes_what_was_shown_and_nothing_new() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join(".gitignore"), "*.log\n").expect("writes");
sh(&root, &["add", ".gitignore"]);
sh(&root, &["commit", "-qm", "ignore logs"]);
for name in ["tab\there.txt", "say \"hi\".txt", "中文.txt", "build.log"] {
std::fs::write(root.join(name), "x").expect("writes");
}
std::fs::create_dir_all(root.join("out/deep")).expect("makes");
std::fs::create_dir_all(root.join("empty")).expect("makes");
std::fs::write(root.join("out/deep/f.txt"), "x").expect("writes");
std::fs::write(root.join("out/deep/kept.log"), "x").expect("writes");
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
let mut files = clean.files.clone();
files.sort();
assert_eq!(
files,
[
"out/deep/f.txt",
"say \"hi\".txt",
"tab\there.txt",
"中文.txt"
]
);
// `out/` holds an ignored file, so Git names its files.
assert_eq!(clean.dirs, ["empty/"]);
let with = recover::clean_preview(&git(), &root, true).expect("previews");
assert!(with.files.contains(&"build.log".to_string()));
assert!(with.files.contains(&"out/deep/kept.log".to_string()));
// Late files, at the top and inside a shown folder.
std::fs::write(root.join("late.txt"), "x").expect("writes");
std::fs::write(root.join("out/late.txt"), "x").expect("writes");
let write = Write::Clean {
files: clean.files,
dirs: clean.dirs,
ignored: false,
};
run(&root, write).expect("cleans");
assert!(root.join("late.txt").exists() && root.join("out/late.txt").exists());
assert!(root.join("build.log").exists() && root.join("out/deep/kept.log").exists());
assert!(!root.join("out/deep/f.txt").exists() && !root.join("empty").exists());
assert!(!root.join("tab\there.txt").exists() && !root.join("中文.txt").exists());
assert!(!root.join("say \"hi\".txt").exists());
}
#[test]
fn the_clean_dialog_previews_then_cleans() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("junk.txt"), "x").expect("writes");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.ask_clean(cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Clean { preview: Some(Ok(clean)), .. }) if clean.files == ["junk.txt"]),
);
std::fs::write(root.join("late.txt"), "x").expect("writes");
cx.update(|_, app| {
tree.update(app, |tree, cx| {
let Some(Ask::Clean {
repo,
ignored,
preview: Some(Ok(clean)),
}) = tree.ask.take()
else {
panic!("a preview");
};
let write = Write::Clean {
files: clean.files,
dirs: clean.dirs,
ignored,
};
tree.write(repo, write, cx);
})
});
settle(cx, |_| !root.join("junk.txt").exists());
assert!(root.join("late.txt").exists());
}
#[test]
fn a_shown_file_now_a_folder_stops_the_clean() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::create_dir_all(root.join("out")).expect("makes");
std::fs::write(root.join("out/early.txt"), "x").expect("writes");
std::fs::write(root.join("top.txt"), "x").expect("writes");
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
std::fs::remove_file(root.join("out/early.txt")).expect("removes");
std::fs::create_dir_all(root.join("out/early.txt")).expect("makes");
std::fs::write(root.join("out/early.txt/late.txt"), "x").expect("writes");
let write = Write::Clean {
files: clean.files,
dirs: clean.dirs,
ignored: false,
};
let refused = run(&root, write).expect_err("a folder now");
assert!(refused.to_string().contains("changed"), "{refused}");
assert!(root.join("out/early.txt/late.txt").exists() && root.join("top.txt").exists());
}
#[test]
fn a_shown_file_staged_since_is_kept() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("new.txt"), "first\n").expect("writes");
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
sh(&root, &["add", "new.txt"]);
std::fs::write(root.join("new.txt"), "edited\n").expect("writes");
let write = Write::Clean {
files: clean.files,
dirs: clean.dirs,
ignored: false,
};
run(&root, write).expect("runs");
assert_eq!(
std::fs::read_to_string(root.join("new.txt")).expect("kept"),
"edited\n"
);
}
#[cfg(unix)]
#[test]
fn a_folder_turned_link_stops_the_clean() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::create_dir_all(root.join("out")).expect("makes");
std::fs::write(root.join("out/early.txt"), "x").expect("writes");
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&outside).expect("makes");
std::fs::write(outside.join("early.txt"), "mine").expect("writes");
std::fs::rename(root.join("out"), root.join("saved-out")).expect("moves");
std::os::unix::fs::symlink(&outside, root.join("out")).expect("links");
let write = Write::Clean {
files: clean.files,
dirs: clean.dirs,
ignored: false,
};
assert!(run(&root, write).is_err(), "a link in the path");
assert!(outside.join("early.txt").exists());
}
#[test]
fn twin_reflog_events_stay_apart() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
for _ in 0..2 {
let reset = std::process::Command::new(&git().path)
.args(["reset", "-q", "--keep", "HEAD"])
.current_dir(&root)
.env("GIT_COMMITTER_DATE", "2030-01-01T00:00:00Z")
.status()
.expect("runs");
assert!(reset.success());
}
let moves = recover::reflog(&git(), &root).expect("reads");
assert_eq!((moves[0].at, &moves[0].said), (moves[1].at, &moves[1].said));
assert!(!moves[0].same(&moves[1]), "two events, two identities");
let picked = moves[1].clone();
sh(&root, &["checkout", "-q", "--detach", "HEAD"]);
let again = recover::reflog(&git(), &root).expect("reads");
let found: Vec<usize> = (0..again.len())
.filter(|ix| again[*ix].same(&picked))
.collect();
assert_eq!(found, [2], "it moved one down, alone");
}
+450
View File
@@ -0,0 +1,450 @@
//! Diffs from Git, cut into hunk and line patches (C13.2).
use std::{collections::BTreeSet, path::Path, sync::Arc};
use ely_gpui_component::git::{ChangeSection, LineKind, Stretch};
use super::{committed, git, sh};
use crate::{
diff::{self, Body, Space},
shown::{self, Found},
status::{self, Entry},
work::{self, Write},
};
fn entry(root: &Path, path: &str) -> Entry {
let read = status::read(&git(), root).expect("reads");
read.entries
.into_iter()
.find(|entry| entry.path == path)
.expect("listed")
}
fn read(
root: &Path,
path: &str,
section: ChangeSection,
) -> (diff::FileDiff, Vec<u8>, Option<String>) {
let born = status::read(&git(), root)
.expect("reads")
.head
.oid
.is_some();
match shown::read(
&git(),
root,
&entry(root, path),
section,
Space::Exact,
born,
)
.expect("reads")
{
Found::Text { diff, seen, old } => (diff, seen, old),
_ => panic!("a text diff"),
}
}
/// Applies the chosen `(hunk, line)`s as the app does.
fn apply(
root: &Path,
path: &str,
section: ChangeSection,
chosen: &[(usize, usize)],
) -> std::io::Result<()> {
let (diff, seen, _) = read(root, path, section);
let reverse = section == ChangeSection::Staged;
let chosen: BTreeSet<_> = chosen.iter().copied().collect();
let write = Write::Patch {
patch: diff::patch(&diff, &chosen, reverse).expect("a patch"),
reverse,
check: shown::args(&entry(root, path), section, Space::Exact),
seen: Arc::new(seen),
};
work::run(&git(), root, &write, &write.args(true))
}
fn index(root: &Path, path: &str) -> String {
sh(root, &["cat-file", "blob", &format!(":{path}")])
}
fn numbered(lines: std::ops::RangeInclusive<usize>) -> String {
lines.map(|n| format!("{n}\n")).collect()
}
#[test]
fn a_diff_reads_hunks_missing_newlines_and_binaries() {
let out = b"diff --git a/a b/a\nindex 1..2 100644\n--- a/a\n+++ b/a\n@@ -1,2 +1,2 @@ fn x\n a\n-b\n\\ No newline at end of file\n+c\n\\ No newline at end of file\n";
let read = diff::parse(out).expect("reads");
let Body::Text(hunks) = &read.body else {
panic!("text")
};
assert_eq!(
(hunks[0].old, hunks[0].new, hunks[0].tail.as_str()),
((1, 2), (1, 2), "fn x")
);
let shape: Vec<_> = hunks[0]
.lines
.iter()
.map(|raw| (raw.kind, raw.text.as_str(), raw.newline))
.collect();
assert_eq!(
shape,
[
(LineKind::Same, "a", true),
(LineKind::Removed, "b", false),
(LineKind::Added, "c", false)
]
);
assert!(read.partial());
let binary = diff::parse(b"diff --git a/p b/p\nindex 1..2\nBinary files a/p and b/p differ\n")
.expect("reads");
assert_eq!(binary.body, Body::Binary);
assert_eq!(
diff::parse(b"\xff\xfe").expect("reads").body,
Body::Undecodable
);
assert!(diff::parse(b"@@ -1 +1 @@\n?what\n").is_err());
}
#[test]
fn one_hunk_stages_and_the_other_stays() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), numbered(1..=20)).expect("writes");
sh(&root, &["commit", "-qam", "twenty"]);
let word = |n: usize| match n {
2 => "two\n".to_string(),
18 => "eighteen\n".to_string(),
n => format!("{n}\n"),
};
let changed: String = (1..=20).map(word).collect();
std::fs::write(root.join("a.txt"), &changed).expect("writes");
let (diff, _, old) = read(&root, "a.txt", ChangeSection::Unstaged);
let (stretches, hunks) = shown::stretches(&diff, old.as_deref()).expect("stretches");
assert_eq!(hunks, [Some(0), None, Some(1)], "two hunks, a fold between");
let Stretch::Folded(between) = &stretches[1] else {
panic!("a fold")
};
assert_eq!((between[0].old, between[0].new), (Some(6), Some(6)));
// The second hunk's lines: three context, removed, added, ...
apply(&root, "a.txt", ChangeSection::Unstaged, &[(1, 3), (1, 4)]).expect("stages");
let only: String = (1..=20)
.map(|n| {
if n == 18 {
"eighteen\n".into()
} else {
format!("{n}\n")
}
})
.collect();
assert_eq!(index(&root, "a.txt"), only);
let (rest, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
let Body::Text(rest) = rest.body else {
panic!("text")
};
assert_eq!(rest.len(), 1, "the first hunk is still unstaged");
}
#[test]
fn single_lines_stage_and_unstage_as_picked() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\nthree\nfour\n").expect("writes");
// Hunk: one, -two, +three, +four.
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 3)]).expect("stages one added line");
assert_eq!(
index(&root, "a.txt"),
"one\ntwo\nfour\n",
"only four, two kept"
);
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 1)]).expect("stages one removal");
assert_eq!(index(&root, "a.txt"), "one\nfour\n");
sh(&root, &["add", "a.txt"]);
let (staged, _, _) = read(&root, "a.txt", ChangeSection::Staged);
let Body::Text(hunks) = &staged.body else {
panic!("text")
};
let three = hunks[0]
.lines
.iter()
.position(|raw| raw.text == "three")
.expect("three");
apply(&root, "a.txt", ChangeSection::Staged, &[(0, three)]).expect("unstages one line");
assert_eq!(index(&root, "a.txt"), "one\nfour\n", "three goes back out");
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
"one\nthree\nfour\n"
);
}
#[test]
fn a_last_line_without_newline_stages() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\nend").expect("writes");
sh(&root, &["commit", "-qam", "no newline"]);
std::fs::write(root.join("a.txt"), "one\nfin").expect("writes");
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 1), (0, 2)]).expect("stages");
assert_eq!(index(&root, "a.txt"), "one\nfin");
}
#[test]
fn a_patch_from_an_older_diff_is_refused() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\nTWO\n").expect("writes");
let (diff, seen, _) = read(&root, "a.txt", ChangeSection::Unstaged);
std::fs::write(root.join("a.txt"), "ONE\nTWO\n").expect("writes");
let chosen: BTreeSet<_> = [(0, 1), (0, 2)].into();
let write = Write::Patch {
patch: diff::patch(&diff, &chosen, false).expect("a patch"),
reverse: false,
check: shown::args(
&entry(&root, "a.txt"),
ChangeSection::Unstaged,
Space::Exact,
),
seen: Arc::new(seen),
};
let refused = work::run(&git(), &root, &write, &write.args(true)).expect_err("stale");
assert!(refused.to_string().contains("changed since"), "{refused}");
assert_eq!(
index(&root, "a.txt"),
"one\ntwo\n",
"the index is untouched"
);
}
#[test]
fn whole_files_pictures_and_binaries_read_as_such() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("new.txt"), "fresh\n").expect("writes");
let (untracked, _, old) = read(&root, "new.txt", ChangeSection::Untracked);
assert!(
!untracked.partial() && old.is_none(),
"a new file stages whole"
);
sh(&root, &["mv", "a.txt", "b.txt"]);
let (renamed, _, _) = read(&root, "b.txt", ChangeSection::Staged);
assert!(
renamed
.head
.iter()
.any(|line| line.starts_with("rename from a.txt"))
&& !renamed.partial()
);
let png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
std::fs::write(root.join("p.png"), &png).expect("writes");
std::fs::write(root.join("blob.bin"), b"\0\x01\x02").expect("writes");
let born = true;
let picture = shown::read(
&git(),
&root,
&entry(&root, "p.png"),
ChangeSection::Untracked,
Space::Exact,
born,
)
.expect("reads");
assert!(matches!(picture, Found::Picture { old: None, new: Some(bytes), .. } if bytes == png));
let binary = shown::read(
&git(),
&root,
&entry(&root, "blob.bin"),
ChangeSection::Untracked,
Space::Exact,
born,
)
.expect("reads");
assert!(matches!(
binary,
Found::Binary {
old: None,
new: Some(3)
}
));
}
#[test]
fn ignoring_whitespace_hides_a_space_only_change() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one \ntwo\n").expect("writes");
let entry = entry(&root, "a.txt");
let found = shown::read(
&git(),
&root,
&entry,
ChangeSection::Unstaged,
Space::All,
true,
)
.expect("reads");
let Found::Text { diff, .. } = found else {
panic!("text")
};
assert_eq!(diff.body, Body::Text(Vec::new()));
}
#[test]
fn a_kept_last_line_gains_its_newline_when_a_line_follows() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\ntwo").expect("writes");
sh(&root, &["commit", "-qam", "no newline"]);
std::fs::write(root.join("a.txt"), "one\nTWO\nmore\n").expect("writes");
// one, -two, +TWO, +more: only +more.
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 3)]).expect("stages");
assert_eq!(index(&root, "a.txt"), "one\ntwo\nmore\n");
sh(&root, &["add", "a.txt"]);
let (staged, _, _) = read(&root, "a.txt", ChangeSection::Staged);
let Body::Text(hunks) = &staged.body else {
panic!("text")
};
let more = hunks[0]
.lines
.iter()
.position(|raw| raw.text == "more")
.expect("more");
apply(&root, "a.txt", ChangeSection::Staged, &[(0, more)]).expect("unstages");
assert_eq!(index(&root, "a.txt"), "one\nTWO\n");
}
#[test]
fn picked_bytes_go_in_whatever_apply_whitespace_says() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["config", "apply.whitespace", "fix"]);
std::fs::write(root.join("a.txt"), "one\nTWO \n").expect("writes");
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 1), (0, 2)]).expect("stages");
assert_eq!(index(&root, "a.txt"), "one\nTWO \n");
}
#[test]
fn bytes_that_are_not_utf8_outside_the_diff_show_by_size() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let mut bytes = b"\xff\n".to_vec();
bytes.extend(numbered(2..=30).into_bytes());
std::fs::write(root.join("a.txt"), &bytes).expect("writes");
sh(&root, &["commit", "-qam", "latin"]);
bytes.extend(b"last\n");
std::fs::write(root.join("a.txt"), &bytes).expect("writes");
let found = shown::read(
&git(),
&root,
&entry(&root, "a.txt"),
ChangeSection::Unstaged,
Space::Exact,
true,
)
.expect("reads");
assert!(matches!(found, Found::Binary { .. }), "not a text view");
}
#[test]
fn blank_context_lines_read_whatever_the_config() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\n\ntwo\n").expect("writes");
sh(&root, &["commit", "-qam", "blank"]);
sh(&root, &["config", "diff.suppressBlankEmpty", "true"]);
std::fs::write(root.join("a.txt"), "one\n\nTWO\n").expect("writes");
let (diff, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
assert!(diff.partial());
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 2), (0, 3)]).expect("stages");
assert_eq!(index(&root, "a.txt"), "one\n\nTWO\n");
}
#[test]
fn an_empty_old_file_folds_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "").expect("writes");
sh(&root, &["commit", "-qam", "empty"]);
std::fs::write(root.join("a.txt"), "hello\n").expect("writes");
let (diff, _, old) = read(&root, "a.txt", ChangeSection::Unstaged);
let (stretches, hunks) = shown::stretches(&diff, old.as_deref()).expect("stretches");
assert_eq!(hunks, [Some(0)], "one hunk, no fold");
let Stretch::Hunk { lines, .. } = &stretches[0] else {
panic!("a hunk")
};
assert_eq!(lines.len(), 1);
}
#[test]
fn a_restored_last_line_keeps_its_newline_when_lines_follow() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\ntwo").expect("writes");
sh(&root, &["commit", "-qam", "no newline"]);
std::fs::write(root.join("a.txt"), "one\nTWO\nmore\n").expect("writes");
sh(&root, &["add", "a.txt"]);
let (staged, _, _) = read(&root, "a.txt", ChangeSection::Staged);
let Body::Text(hunks) = &staged.body else {
panic!("text")
};
let two = hunks[0]
.lines
.iter()
.position(|raw| raw.text == "two")
.expect("two");
apply(&root, "a.txt", ChangeSection::Staged, &[(0, two)]).expect("unstages");
assert_eq!(index(&root, "a.txt"), "one\ntwo\nTWO\nmore\n");
}
#[cfg(unix)]
#[test]
fn a_file_turned_into_a_link_reads_in_both_sections() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("other.txt"), "other\n").expect("writes");
std::fs::remove_file(root.join("a.txt")).expect("removes");
std::os::unix::fs::symlink("other.txt", root.join("a.txt")).expect("links");
for section in [ChangeSection::Unstaged, ChangeSection::Staged] {
if section == ChangeSection::Staged {
sh(&root, &["add", "a.txt"]);
}
let (diff, _, old) = read(&root, "a.txt", section);
assert!(!diff.partial(), "{section:?} stages whole");
let (stretches, _) = shown::stretches(&diff, old.as_deref()).expect("stretches");
// Every row, folds included, with its numbers.
let rows: Vec<(String, Option<usize>, Option<usize>)> = stretches
.iter()
.flat_map(|stretch| match stretch {
Stretch::Hunk { lines, .. } | Stretch::Folded(lines) => lines.iter(),
})
.map(|line| (line.text.to_string(), line.old, line.new))
.collect();
let expected = [
("one".to_string(), Some(1), None),
("two".to_string(), Some(2), None),
("other.txt".to_string(), None, Some(1)),
];
assert_eq!(rows, expected, "{section:?}: the text, then the link");
}
}
#[cfg(unix)]
#[test]
fn a_link_compares_by_its_own_path_whatever_it_points_at() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::remove_file(root.join("a.txt")).expect("removes");
std::os::unix::fs::symlink("missing.txt", root.join("a.txt")).expect("links");
let (diff, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
assert!(
format!("{diff:?}").contains("missing.txt"),
"a dangling link reads"
);
std::fs::write(root.join("latin.txt"), b"\xff\n").expect("writes");
std::fs::remove_file(root.join("a.txt")).expect("removes");
std::os::unix::fs::symlink("latin.txt", root.join("a.txt")).expect("links");
let (diff, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
assert!(
format!("{diff:?}").contains("latin.txt"),
"a link to bytes is text"
);
}
+258
View File
@@ -0,0 +1,258 @@
//! Branches, operations and conflicts, in the window (C13.4).
use ely_gpui_component::git::{ChangeSection, Take};
use gpui::{App, TestAppContext};
use super::{
branching::conflicted,
sh,
window::{branch, settle, window},
};
use crate::{asks::Ask, branches::Kind, tree::Side};
fn conflicts(tree: &crate::tree::Tree) -> usize {
match tree.active().and_then(|opened| opened.status.as_ref()) {
Some(Ok(status)) => status
.entries
.iter()
.filter(|e| matches!(e.state, crate::status::State::Conflicted(_)))
.count(),
_ => 0,
}
}
#[test]
fn a_merge_conflict_is_settled_saved_resolved_and_continued() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.merge("side", cx)));
settle(cx, |app| conflicts(tree.read(app)) == 1);
let doing = |app: &App| {
matches!(
tree.read(app).active().and_then(|o| o.doing.clone()),
Some(Ok(Some(_)))
)
};
settle(cx, doing);
assert!(
cx.update(|_, app| tree.read(app).ask.is_none()),
"a stop is not a failure dialog"
);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.show("a.txt", ChangeSection::Conflicted, cx)
})
});
settle(cx, |app| tree.read(app).merging.is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.take(0, Take::Theirs, cx)));
let result = cx.update(|_, app| tree.read(app).merge_editor.read(app).text().to_string());
assert_eq!(result, "one\nside\n");
// Resolving before saving is refused; saving writes the file.
cx.update(|_, app| tree.update(app, |tree, cx| tree.mark_resolved(cx)));
assert_eq!(
sh(&root, &["diff", "--name-only", "--diff-filter=U"]).trim(),
"a.txt"
);
cx.update(|_, app| tree.update(app, |tree, cx| tree.save_merge(cx)));
settle(cx, |_| {
std::fs::read_to_string(root.join("a.txt")).expect("reads") == "one\nside\n"
});
settle(cx, |app| {
tree.read(app)
.merging
.as_ref()
.is_some_and(|m| m.saved == "one\nside\n")
});
cx.update(|_, app| tree.update(app, |tree, cx| tree.mark_resolved(cx)));
settle(cx, |app| conflicts(tree.read(app)) == 0);
cx.update(|_, app| tree.update(app, |tree, cx| tree.op_continue(crate::ops::Op::Merge, cx)));
settle(cx, |_| {
sh(&root, &["log", "-1", "--format=%p"])
.split_whitespace()
.count()
== 2
});
settle(cx, |app| {
matches!(
tree.read(app).active().and_then(|o| o.doing.clone()),
Some(Ok(None))
)
});
}
#[test]
fn branches_are_made_renamed_and_deleted_each_with_its_own_yes() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, main) = conflicted(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(Side::Branches, cx)));
let names = |app: &App| match tree.read(app).active().and_then(|o| o.refs.clone()) {
Some(Ok(refs)) => refs
.into_iter()
.filter(|r| r.kind == Kind::Local)
.map(|r| r.name)
.collect::<Vec<_>>(),
_ => Vec::new(),
};
settle(cx, |app| names(app).len() == 2);
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_branch(None, window, cx)));
cx.update(|_, app| {
tree.update(app, |tree, cx| {
if let Some(Ask::NewBranch { name, .. }) = &tree.ask {
name.update(cx, |field, cx| field.set_text("topic".to_string(), cx));
}
tree.answer(cx);
})
});
settle(cx, |app| branch(tree.read(app)).as_deref() == Some("topic"));
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_rename("topic", window, cx)));
cx.update(|_, app| {
tree.update(app, |tree, cx| {
if let Some(Ask::Rename { name, .. }) = &tree.ask {
name.update(cx, |field, cx| field.set_text("renamed".to_string(), cx));
}
tree.answer(cx);
})
});
settle(cx, |app| {
branch(tree.read(app)).as_deref() == Some("renamed")
});
sh(&root, &["checkout", "-q", &main]);
settle(cx, |app| {
branch(tree.read(app)).as_deref() == Some(main.as_str())
});
// Delete waits for its own yes; nothing runs before it.
cx.update(|_, app| tree.update(app, |tree, cx| tree.delete_branch("renamed", false, cx)));
assert!(matches!(
cx.update(|_, app| tree
.read(app)
.ask
.as_ref()
.map(|a| matches!(a, Ask::Confirm { .. }))),
Some(true)
));
assert!(names_now(&root).contains(&"renamed".to_string()));
let write = cx.update(|_, app| match &tree.read(app).ask {
Some(Ask::Confirm { write, .. }) => write.clone(),
_ => panic!("a confirm"),
});
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.ask = None;
tree.write(1, write, cx)
})
});
settle(cx, |_| !names_now(&root).contains(&"renamed".to_string()));
// The next delete asks again.
cx.update(|_, app| tree.update(app, |tree, cx| tree.delete_branch("side", false, cx)));
assert!(cx.update(|_, app| tree.read(app).ask.is_some()));
}
fn names_now(root: &std::path::Path) -> Vec<String> {
sh(root, &["branch", "--format=%(refname:short)"])
.lines()
.map(String::from)
.collect()
}
#[test]
fn a_failure_that_is_no_stop_shows_gits_words() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
std::fs::write(root.join("a.txt"), "dirty\n").expect("writes");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
// A dirty file blocks the switch; Git says so.
cx.update(|_, app| tree.update(app, |tree, cx| tree.switch("side", Kind::Local, cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("a.txt")),
);
}
#[cfg(unix)]
#[test]
fn a_hook_that_stops_a_continue_says_why() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
assert!(super::git().run(&root, &["merge", "side"]).is_err());
std::fs::write(root.join("a.txt"), "one\nsettled\n").expect("writes");
sh(&root, &["add", "a.txt"]);
let hook = root.join(".git/hooks/pre-commit");
std::fs::write(
&hook,
"#!/bin/sh\necho 'CONFLICT (hook): REVIEW_HOOK_DENIED'\nexit 1\n",
)
.expect("writes");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.op_continue(crate::ops::Op::Merge, cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("REVIEW_HOOK_DENIED")),
);
}
#[cfg(unix)]
#[test]
fn a_hook_that_stops_a_side_take_says_why_while_conflicts_remain() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
assert!(super::git().run(&root, &["merge", "side"]).is_err());
let hook = root.join(".git/hooks/post-checkout");
std::fs::write(
&hook,
"#!/bin/sh\necho REVIEW_POST_CHECKOUT_DENIED >&2\nexit 1\n",
)
.expect("writes");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| conflicts(tree.read(app)) == 1);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.show("a.txt", ChangeSection::Conflicted, cx)
})
});
settle(cx, |app| tree.read(app).merging.is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.take_side(true, cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("REVIEW_POST_CHECKOUT_DENIED")),
);
}
#[test]
fn a_pick_that_conflicts_stops_without_a_dialog() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
let theirs = sh(&root, &["rev-parse", "side"]).trim().to_string();
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.cherry_pick(&theirs, 1, cx)));
settle(cx, |app| conflicts(tree.read(app)) == 1);
settle(cx, |app| {
matches!(
tree.read(app).active().and_then(|o| o.doing.clone()),
Some(Ok(Some(_)))
)
});
assert!(cx.update(|_, app| tree.read(app).ask.is_none()));
}
+345
View File
@@ -0,0 +1,345 @@
//! History, a commit's detail, blame and committing (C13.3).
use std::path::Path;
use super::{committed, git, sh};
use crate::{
blame, commit,
commits::{self, Find, Query},
detail,
shown::Found,
work::{self, Write},
};
fn commit_all(root: &Path, message: &str) {
sh(root, &["add", "-A"]);
sh(root, &["commit", "-q", "-m", message]);
}
fn head(root: &Path) -> String {
sh(root, &["rev-parse", "HEAD"]).trim().to_string()
}
#[test]
fn history_reads_branches_tags_merges_and_searches() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
sh(&root, &["checkout", "-q", "-b", "side"]);
std::fs::write(root.join("side.txt"), "side needle\n").expect("writes");
commit_all(&root, "Side work");
sh(&root, &["checkout", "-q", &main]);
std::fs::write(root.join("main.txt"), "main\n").expect("writes");
commit_all(&root, "Main work");
sh(
&root,
&[
"-c",
"user.name=Bea",
"merge",
"-q",
"--no-ff",
"side",
"-m",
"Merge side",
],
);
sh(&root, &["tag", "v1"]);
let all = commits::read(&git(), &root, 0, None).expect("reads");
assert_eq!(all.len(), 4);
assert_eq!(all[0].subject, "Merge side");
assert_eq!(all[0].parents.len(), 2, "a merge has two parents");
assert!(
all[0].refs.contains(&main) && all[0].refs.contains(&"v1".to_string()),
"{:?}",
all[0].refs
);
assert_eq!(
commits::read(&git(), &root, 3, None).expect("reads").len(),
1,
"a later page"
);
let find = |find, text: &str| {
let query = Query {
find,
text: text.into(),
};
commits::read(&git(), &root, 0, Some(&query))
.expect("searches")
.into_iter()
.map(|c| c.subject)
.collect::<Vec<_>>()
};
assert_eq!(
find(Find::Message, "side WORK"),
["Side work"],
"message, any case"
);
assert_eq!(find(Find::Content, "needle"), ["Side work"], "added text");
assert_eq!(find(Find::Author, "nobody"), Vec::<String>::new());
let first = all.last().expect("first").id.clone();
assert_eq!(find(Find::Hash, &first[..8]), ["first"], "an id prefix");
assert!(find(Find::Hash, "zzzz").is_empty());
}
#[test]
fn a_files_history_follows_its_renames() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\nTWO\nthree\n").expect("writes");
commit_all(&root, "two");
sh(&root, &["mv", "a.txt", "b.txt"]);
commit_all(&root, "move");
let touches = commits::file(&git(), &root, "b.txt").expect("reads");
let shape: Vec<_> = touches
.iter()
.map(|t| {
(
t.commit.subject.as_str(),
t.path.as_str(),
t.from.as_deref(),
t.lines,
)
})
.collect();
assert_eq!(
shape,
[
("move", "b.txt", Some("a.txt"), Some((0, 0))),
("two", "a.txt", None, Some((2, 1))),
("first", "a.txt", None, Some((2, 0)))
]
);
let change = |at: usize| {
let t = &touches[at];
detail::change(
&git(),
&root,
&t.commit.id,
&t.commit.parents,
&t.path,
t.from.clone(),
)
.expect("reads")
};
assert!(matches!(change(1), Found::Text { .. }));
assert!(
matches!(change(2), Found::Text { old: None, .. }),
"added in the first commit"
);
}
#[test]
fn a_commit_details_its_people_files_and_diffs() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let first = detail::read(&git(), &root, &head(&root)).expect("reads");
assert!(first.parents.is_empty());
assert_eq!(
first
.files
.iter()
.map(|f| (f.status, f.path.as_str()))
.collect::<Vec<_>>(),
[('A', "a.txt")]
);
assert!(matches!(
detail::file(&git(), &root, &first.id, &first.parents, &first.files[0]).expect("reads"),
Found::Text { old: None, .. }
));
std::fs::write(root.join("new.txt"), "new\n").expect("writes");
sh(&root, &["mv", "a.txt", "renamed.txt"]);
sh(&root, &["add", "-A"]);
sh(&root, &["commit", "-q", "-m", "Rename\n\nWith a body."]);
let read = detail::read(&git(), &root, &head(&root)).expect("reads");
assert_eq!(read.message, "Rename\n\nWith a body.");
assert_eq!(
(read.author.name.as_str(), read.author.email.as_str()),
("Ely", "ely@example.com")
);
assert_eq!(read.signed, 'N');
let files: Vec<_> = read
.files
.iter()
.map(|f| (f.status, f.from.as_deref(), f.path.as_str(), f.lines))
.collect();
assert_eq!(
files,
[
('A', None, "new.txt", Some((1, 0))),
('R', Some("a.txt"), "renamed.txt", Some((0, 0)))
]
);
}
#[test]
fn blame_names_each_lines_commit_and_what_is_not_committed() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\n").expect("writes");
commit_all(&root, "third line");
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\nfour\n").expect("writes");
let read = blame::read(&git(), &root, "a.txt").expect("reads");
assert_eq!(read.code, "one\ntwo\nthree\nfour\n");
let owner = |line: usize| &read.owners[read.lines[line]];
assert_eq!(
(owner(0).subject.as_str(), owner(2).subject.as_str()),
("first", "third line")
);
assert_eq!(owner(0).author, "Ely");
assert!(owner(3).uncommitted() && !owner(0).uncommitted());
assert_eq!(
read.lines[0], read.lines[1],
"one owner for both first lines"
);
}
fn write(root: &Path, write: Write) -> std::io::Result<()> {
work::run(&git(), root, &write, &write.args(true))
}
#[test]
fn commits_go_in_with_their_message_and_amend_replaces_the_last() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["config", "user.name", "Ely"]);
sh(&root, &["config", "user.email", "ely@example.com"]);
sh(&root, &["config", "commit.gpgsign", "false"]);
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
sh(&root, &["add", "a.txt"]);
let message = commit::message(" Change a ", "Line one.\n# kept, not a comment\n");
write(
&root,
Write::Commit {
message,
amend: false,
},
)
.expect("commits");
assert_eq!(
commit::last_message(&git(), &root).expect("reads"),
"Change a\n\nLine one.\n# kept, not a comment"
);
write(
&root,
Write::Commit {
message: "Change a, better".into(),
amend: true,
},
)
.expect("amends");
let all = commits::read(&git(), &root, 0, None).expect("reads");
assert_eq!(
all.iter().map(|c| c.subject.as_str()).collect::<Vec<_>>(),
["Change a, better", "first"]
);
assert_eq!(
commit::split("Subject\n\nBody\nmore"),
("Subject".into(), "Body\nmore".into())
);
}
#[cfg(unix)]
#[test]
fn a_failing_hook_says_why() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["config", "user.name", "Ely"]);
sh(&root, &["config", "user.email", "ely@example.com"]);
sh(&root, &["config", "commit.gpgsign", "false"]);
let hook = root.join(".git/hooks/pre-commit");
std::fs::write(
&hook,
"#!/bin/sh\necho 'lint found 3 problems' >&2\nexit 1\n",
)
.expect("writes");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
sh(&root, &["add", "a.txt"]);
let failed = write(
&root,
Write::Commit {
message: "x".into(),
amend: false,
},
)
.expect_err("the hook stops it");
assert!(
failed.to_string().contains("lint found 3 problems"),
"{failed}"
);
}
#[test]
fn identity_and_signing_come_from_git() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
write(
&root,
Write::Config {
key: "user.name".into(),
value: "Ann Lee".into(),
global: false,
},
)
.expect("sets");
write(
&root,
Write::Config {
key: "user.email".into(),
value: "ann@example.com".into(),
global: false,
},
)
.expect("sets");
sh(&root, &["config", "commit.gpgsign", "true"]);
sh(&root, &["config", "gpg.format", "ssh"]);
let me = commit::me(&git(), &root).expect("reads");
assert_eq!(
(me.name.as_deref(), me.email.as_deref(), me.signs.as_deref()),
(Some("Ann Lee"), Some("ann@example.com"), Some("ssh"))
);
sh(&root, &["config", "commit.gpgsign", "false"]);
assert_eq!(commit::me(&git(), &root).expect("reads").signs, None);
}
#[test]
fn hash_lines_stay_whatever_commit_cleanup_says() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
("commit.cleanup", "strip"),
] {
sh(&root, &["config", key, value]);
}
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
sh(&root, &["add", "a.txt"]);
write(
&root,
Write::Commit {
message: "probe\n\n# required body".into(),
amend: false,
},
)
.expect("commits");
assert_eq!(
commit::last_message(&git(), &root).expect("reads"),
"probe\n\n# required body"
);
write(
&root,
Write::Commit {
message: "probe 2\n\n# still here".into(),
amend: true,
},
)
.expect("amends");
assert_eq!(
commit::last_message(&git(), &root).expect("reads"),
"probe 2\n\n# still here"
);
}
+282
View File
@@ -0,0 +1,282 @@
//! The Repository panel in the window (C13.5).
use ely_gpui_component::git::StashAction;
use gpui::{App, Entity, TestAppContext};
use super::{
committed,
places::me,
sh,
window::{branch, settle, window},
};
use crate::{
asks::Ask,
places::Place,
stash::Stash,
tree::{Side, Tree},
};
fn stashes(tree: &Tree) -> Vec<Stash> {
tree.stashes().to_vec()
}
fn running(tree: &Entity<Tree>, app: &App) -> bool {
tree.read(app)
.active()
.is_some_and(|opened| opened.net.is_some())
}
#[test]
fn a_stash_is_made_shown_and_popped_from_the_window() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
std::fs::write(root.join("a.txt"), "one\nkept\n").expect("writes");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(Side::Places, cx)));
settle(cx, |app| {
tree.read(app)
.active()
.is_some_and(|o| o.places.read.is_some())
});
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_stash(window, cx)));
cx.update(|_, app| {
tree.update(app, |tree, cx| {
if let Some(Ask::Stash { message, .. }) = &tree.ask {
message.update(cx, |field, cx| field.set_text("kept for later", cx));
}
tree.answer(cx);
})
});
settle(cx, |app| stashes(tree.read(app)).len() == 1);
let stash = cx.update(|_, app| stashes(tree.read(app))[0].clone());
assert_eq!(stash.message, "kept for later");
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
"one\ntwo\n"
);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.pick_place(Place::Stash(stash.oid.clone()), cx)
})
});
settle(cx, |app| {
matches!(
tree.read(app).active().and_then(|o| o.places.pick.detail.as_ref()),
Some(Ok(detail)) if detail.files.iter().any(|f| f.path == "a.txt")
)
});
cx.update(|_, app| tree.update(app, |tree, cx| tree.stash_act(0, StashAction::Drop, cx)));
assert!(
cx.update(|_, app| matches!(tree.read(app).ask, Some(Ask::Confirm { .. }))),
"a drop asks"
);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.ask = None;
tree.stash_act(0, StashAction::Pop, cx)
})
});
settle(cx, |app| stashes(tree.read(app)).is_empty());
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
"one\nkept\n"
);
}
/// A bare remote and a clone of it.
fn cloned(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
let source = committed(&dir.join("source"));
let bare = dir.join("remote.git");
let (from, to) = (
source.to_str().expect("utf-8"),
bare.to_str().expect("utf-8"),
);
sh(dir, &["clone", "-q", "--bare", from, to]);
let into = dir.join("copy");
sh(dir, &["clone", "-q", to, into.to_str().expect("utf-8")]);
me(&into);
(bare, into)
}
#[test]
fn fetch_runs_with_progress_and_lands() {
let dir = tempfile::tempdir().expect("a temp dir");
let (bare, into) = cloned(dir.path());
let other = dir.path().join("other");
sh(
dir.path(),
&[
"clone",
"-q",
bare.to_str().expect("utf-8"),
other.to_str().expect("utf-8"),
],
);
std::fs::write(other.join("a.txt"), "moved\n").expect("writes");
sh(&other, &["commit", "-qam", "moved"]);
sh(&other, &["push", "-q"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let name = cx.update(|_, app| branch(tree.read(app)).expect("a branch"));
cx.update(|_, app| tree.update(app, |tree, cx| tree.fetch_all(cx)));
let tracked = format!("refs/remotes/origin/{name}");
let moved = sh(&other, &["rev-parse", "HEAD"]);
settle(cx, |app| {
!running(&tree, app) && sh(&into, &["rev-parse", &tracked]) == moved
});
assert!(cx.update(|_, app| tree.read(app).ask.is_none()));
}
#[cfg(unix)]
#[test]
fn a_stopped_fetch_ends_quietly() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let silent = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let url = format!("git://{}/nothing", silent.local_addr().expect("an address"));
sh(&root, &["remote", "add", "origin", &url]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.fetch_one("origin", cx)));
settle(cx, |app| running(&tree, app));
let _held = silent.accept().expect("Git connects");
let id = cx.update(|_, app| tree.read(app).active.expect("open"));
cx.update(|_, app| tree.update(app, |tree, cx| tree.stop_remote(id, cx)));
settle(cx, |app| !running(&tree, app));
assert!(
cx.update(|_, app| tree.read(app).ask.is_none()),
"a stop is no failure"
);
}
#[test]
fn a_push_with_no_upstream_asks_where_then_tracks_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let (bare, into) = cloned(dir.path());
sh(&into, &["switch", "-q", "-c", "topic"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).as_deref() == Some("topic"));
cx.update(|_, app| tree.update(app, |tree, cx| tree.push(cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Choose { choices, .. }) if choices == &["origin"]),
);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.ask = None;
tree.publish(tree.active.expect("open"), "topic", "origin", cx)
})
});
settle(cx, |app| {
!running(&tree, app)
&& sh(&into, &["config", "--get", "branch.topic.remote"]).trim() == "origin"
});
assert_eq!(
sh(&bare, &["rev-parse", "topic"]),
sh(&into, &["rev-parse", "HEAD"])
);
}
#[test]
fn a_forced_push_shows_what_it_would_overwrite() {
let dir = tempfile::tempdir().expect("a temp dir");
let (bare, into) = cloned(dir.path());
let other = dir.path().join("other");
sh(
dir.path(),
&[
"clone",
"-q",
bare.to_str().expect("utf-8"),
other.to_str().expect("utf-8"),
],
);
std::fs::write(other.join("a.txt"), "theirs\n").expect("writes");
sh(&other, &["commit", "-qam", "theirs"]);
sh(&other, &["push", "-q"]);
sh(&into, &["fetch", "-q"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.ask_force(cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Force { plan, .. }) if plan.lost.len() == 1),
);
}
#[test]
fn an_upstream_is_set_and_unset_from_the_branches() {
let dir = tempfile::tempdir().expect("a temp dir");
let (_, into) = cloned(dir.path());
let main = sh(&into, &["branch", "--show-current"]).trim().to_string();
sh(&into, &["branch", "topic"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let remote = format!("origin/{main}");
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.set_upstream(tree.active.expect("open"), "topic", &remote, cx)
})
});
let merge = || {
sh(
&into,
&["config", "--default", "", "--get", "branch.topic.merge"],
)
};
settle(cx, |_| merge().trim() == format!("refs/heads/{main}"));
cx.update(|_, app| tree.update(app, |tree, cx| tree.unset_upstream("topic", cx)));
settle(cx, |_| merge().trim().is_empty());
}
#[test]
fn a_dialog_answers_for_the_repository_it_opened_on() {
let dir = tempfile::tempdir().expect("a temp dir");
let a = committed(&dir.path().join("a"));
let b = committed(&dir.path().join("b"));
for root in [&a, &b] {
me(root);
std::fs::write(root.join("a.txt"), "one\nchanged\n").expect("writes");
}
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let first = cx.update(|_, app| tree.read(app).active.expect("open"));
cx.update(|window, app| {
tree.update(app, |tree, cx| {
tree.open_path(b.clone(), cx);
tree.ask_stash(window, cx)
})
});
settle(cx, |app| tree.read(app).active != Some(first));
cx.update(|_, app| {
tree.update(app, |tree, cx| {
if let Some(Ask::Stash { message, .. }) = &tree.ask {
message.update(cx, |field, cx| field.set_text("for A", cx));
}
tree.answer(cx);
})
});
let count = |root: &std::path::Path| {
crate::stash::list(&super::git(), root)
.expect("reads")
.len()
};
settle(cx, |_| count(&a) == 1);
assert_eq!(count(&b), 0, "B keeps its change");
}
+435
View File
@@ -0,0 +1,435 @@
//! Stashes, remotes, worktrees, submodules, LFS (C13.5).
use std::path::{Path, PathBuf};
use super::{committed, git, sh};
use crate::{
detail, remotes, stash,
structure::{self, Checkout},
work::{self, Write},
};
/// Identity in the repository's own config, for writes.
pub(crate) fn me(root: &Path) {
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
] {
sh(root, &["config", key, value]);
}
}
fn run(root: &Path, write: Write) -> std::io::Result<()> {
work::run(&git(), root, &write, &write.args(true))
}
fn words(list: &[&str]) -> Vec<String> {
list.iter().map(|word| word.to_string()).collect()
}
#[test]
fn stashes_list_newest_first_with_untracked_files_apart() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
std::fs::write(root.join("a.txt"), "one\nchanged\n").expect("writes");
std::fs::write(root.join("new.txt"), "fresh\n").expect("writes");
let push = |more: &[&str]| {
let mut args = words(&["stash", "push"]);
args.extend(words(more));
run(&root, Write::Git(args)).expect("stashes");
};
push(&["--include-untracked", "-m", "first: words"]);
std::fs::write(root.join("a.txt"), "one\nagain\n").expect("writes");
push(&[]);
let list = stash::list(&git(), &root).expect("reads");
let branch = sh(&root, &["branch", "--show-current"]).trim().to_string();
assert_eq!(list.len(), 2);
assert_eq!(
(list[1].branch.as_str(), list[1].message.as_str()),
(branch.as_str(), "first: words")
);
assert_eq!(list[0].branch, branch);
assert!(list[0].message.ends_with("first"), "{}", list[0].message);
let held = stash::detail(&git(), &root, &list[1].oid).expect("reads");
let files: Vec<(&str, bool)> = held
.files
.iter()
.map(|file| (file.path.as_str(), file.commit.is_some()))
.collect();
assert_eq!(files, [("a.txt", false), ("new.txt", true)]);
let untracked = &held.files[1];
let holder = untracked.commit.clone().expect("its own commit");
let found = detail::file(&git(), &root, &holder, &[], untracked).expect("reads");
let crate::shown::View::Text { seen, .. } = crate::shown::view(found).expect("a view") else {
panic!("text");
};
assert!(String::from_utf8_lossy(&seen).contains("+fresh"));
}
#[test]
fn a_pop_that_conflicts_keeps_its_stash() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
std::fs::write(root.join("a.txt"), "one\nstashed\n").expect("writes");
sh(&root, &["stash", "push", "-q"]);
std::fs::write(root.join("a.txt"), "one\ncommitted\n").expect("writes");
sh(&root, &["commit", "-qam", "meanwhile"]);
let oid = stash::list(&git(), &root).expect("reads")[0].oid.clone();
let popped = run(
&root,
Write::Stash {
verb: "pop",
at: 0,
oid: oid.clone(),
},
);
assert!(
popped
.expect_err("conflicts")
.to_string()
.contains("CONFLICT")
);
let left = stash::list(&git(), &root).expect("reads");
assert_eq!(
left.iter().map(|s| s.oid.as_str()).collect::<Vec<_>>(),
[oid.as_str()]
);
}
#[test]
fn a_stash_command_refuses_a_moved_list() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
std::fs::write(root.join("a.txt"), "one\nfirst\n").expect("writes");
sh(&root, &["stash", "push", "-q"]);
let first = stash::list(&git(), &root).expect("reads")[0].oid.clone();
std::fs::write(root.join("a.txt"), "one\nsecond\n").expect("writes");
sh(&root, &["stash", "push", "-q"]);
let dropped = run(
&root,
Write::Stash {
verb: "drop",
at: 0,
oid: first,
},
);
assert!(
dropped
.expect_err("refused")
.to_string()
.contains("changed")
);
assert_eq!(stash::list(&git(), &root).expect("reads").len(), 2);
}
#[test]
fn remotes_list_urls_and_push_urls() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
for args in [
words(&["remote", "add", "--", "origin", "/srv/one"]),
words(&["remote", "add", "--", "a.b", "/srv/two"]),
words(&["remote", "set-url", "--push", "--", "a.b", "/srv/push"]),
] {
run(&root, Write::Git(args)).expect("runs");
}
let list = remotes::list(&git(), &root).expect("reads");
let shown: Vec<(&str, Vec<&str>, Vec<&str>)> = list
.iter()
.map(|r| {
(
r.name.as_str(),
r.urls.iter().map(String::as_str).collect(),
r.push.iter().map(String::as_str).collect(),
)
})
.collect();
assert_eq!(
shown,
[
("a.b", vec!["/srv/two"], vec!["/srv/push"]),
("origin", vec!["/srv/one"], vec![]),
]
);
}
/// A bare remote and two clones of it.
fn shared(dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
let source = committed(&dir.join("source"));
let bare = dir.join("remote.git");
sh(
dir,
&[
"clone",
"-q",
"--bare",
source.to_str().expect("utf-8"),
bare.to_str().expect("utf-8"),
],
);
let clone = |name: &str| {
let into = dir.join(name);
git()
.clone_repo(bare.to_str().expect("utf-8"), &into)
.expect("starts")
.follow(|_| {})
.expect("clones");
me(&into);
into
};
let (a, b) = (clone("a"), clone("b"));
(bare, a, b)
}
#[test]
fn a_forced_push_names_what_it_overwrites_and_holds_its_lease() {
let dir = tempfile::tempdir().expect("a temp dir");
let (bare, a, b) = shared(dir.path());
std::fs::write(a.join("a.txt"), "theirs\n").expect("writes");
sh(&a, &["commit", "-qam", "theirs"]);
run(&a, Write::Remote(words(&["push", "--progress"]))).expect("pushes");
std::fs::write(b.join("a.txt"), "mine\n").expect("writes");
sh(&b, &["commit", "-qam", "mine"]);
run(
&b,
Write::Remote(words(&["fetch", "--progress", "--", "origin"])),
)
.expect("fetches");
let plan = remotes::force(&git(), &b).expect("a plan");
let branch = sh(&b, &["branch", "--show-current"]).trim().to_string();
assert_eq!(
(
plan.branch.as_str(),
plan.remote.as_str(),
plan.target.as_str()
),
(
branch.as_str(),
"origin",
format!("refs/heads/{branch}").as_str()
)
);
assert_eq!(plan.lost.len(), 1);
assert!(plan.lost[0].ends_with(" theirs"), "{:?}", plan.lost);
// The remote moves after the plan; the lease refuses.
std::fs::write(a.join("a.txt"), "theirs again\n").expect("writes");
sh(&a, &["commit", "-qam", "theirs again"]);
run(&a, Write::Remote(words(&["push", "--progress"]))).expect("pushes");
let refused = run(&b, Write::Remote(plan.args())).expect_err("stale lease");
assert!(refused.to_string().contains("stale info"), "{refused}");
run(&b, Write::Remote(words(&["fetch", "--progress", "--all"]))).expect("fetches");
let plan = remotes::force(&git(), &b).expect("a plan");
assert_eq!(plan.lost.len(), 2);
run(&b, Write::Remote(plan.args())).expect("forces");
let there = sh(&bare, &["rev-parse", &format!("refs/heads/{branch}")]);
assert_eq!(there, sh(&b, &["rev-parse", "HEAD"]));
}
#[test]
fn a_detached_head_or_no_upstream_has_no_forced_push() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let none = remotes::force(&git(), &root).expect_err("no upstream");
assert!(none.to_string().contains("no upstream"), "{none}");
sh(&root, &["checkout", "-q", "--detach"]);
assert!(remotes::force(&git(), &root).is_err());
}
#[test]
fn worktrees_list_branches_and_prunable_folders() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let other = dir.path().join("other tree");
let path = other.to_str().expect("utf-8");
run(
&root,
Write::Git(words(&["worktree", "add", "-b", "side", "--", path])),
)
.expect("adds");
let list = structure::worktrees(&git(), &root).expect("reads");
assert_eq!(list.len(), 2);
assert_eq!(list[0].path, std::fs::canonicalize(&root).expect("real"));
assert_eq!(
(list[1].path.clone(), list[1].branch.as_deref()),
(std::fs::canonicalize(&other).expect("real"), Some("side"))
);
std::fs::remove_dir_all(&other).expect("removes");
let list = structure::worktrees(&git(), &root).expect("reads");
assert!(list[1].prunable.is_some(), "{list:?}");
run(&root, Write::Git(words(&["worktree", "prune"]))).expect("prunes");
assert_eq!(structure::worktrees(&git(), &root).expect("reads").len(), 1);
}
#[test]
fn submodules_say_whether_their_commit_is_checked_out() {
let dir = tempfile::tempdir().expect("a temp dir");
let sub = committed(&dir.path().join("s"));
let main = committed(&dir.path().join("m"));
let file = ["-c", "protocol.file.allow=always"];
let add = [
&file[..],
&[
"submodule",
"add",
"-q",
sub.to_str().expect("utf-8"),
"lib",
],
]
.concat();
sh(&main, &add);
sh(&main, &["commit", "-qm", "with lib"]);
let recorded = sh(&sub, &["rev-parse", "HEAD"]).trim().to_string();
let list = structure::submodules(&git(), &main).expect("reads");
assert_eq!(list.len(), 1);
assert_eq!(
(list[0].path.as_str(), &list[0].checkout, list[0].conflicted),
("lib", &Checkout::At(recorded.clone()), false)
);
let copy = dir.path().join("copy");
git()
.clone_repo(main.to_str().expect("utf-8"), &copy)
.expect("starts")
.follow(|_| {})
.expect("clones");
let list = structure::submodules(&git(), &copy).expect("reads");
assert_eq!(
(list[0].recorded.as_str(), &list[0].checkout),
(recorded.as_str(), &Checkout::Missing)
);
let update = [
&file[..],
&["submodule", "update", "--init", "--progress", "--", "lib"],
]
.concat();
sh(&copy, &update);
let list = structure::submodules(&git(), &copy).expect("reads");
assert_eq!(list[0].checkout, Checkout::At(recorded));
}
#[test]
fn lfs_files_say_whether_the_object_is_here() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
assert!(structure::lfs(&git(), &root).expect("reads").is_empty());
sh(&root, &["lfs", "install", "--local"]);
sh(&root, &["lfs", "track", "*.bin"]);
std::fs::write(root.join("big.bin"), vec![7u8; 3000]).expect("writes");
sh(&root, &["add", "."]);
sh(&root, &["commit", "-qm", "lfs"]);
let here = structure::lfs(&git(), &root).expect("reads");
assert_eq!(here.len(), 1);
assert_eq!(
(
here[0].path.as_str(),
here[0].size,
here[0].checked_out,
here[0].downloaded
),
("big.bin", 3000, true, true)
);
let copy = dir.path().join("copy");
let cloned = std::process::Command::new(&git().path)
.args([
"clone",
"-q",
root.to_str().expect("utf-8"),
copy.to_str().expect("utf-8"),
])
.env("GIT_LFS_SKIP_SMUDGE", "1")
.status()
.expect("runs");
assert!(cloned.success());
let there = structure::lfs(&git(), &copy).expect("reads");
assert_eq!((there[0].checked_out, there[0].downloaded), (false, false));
}
#[test]
fn a_forced_push_sends_the_commit_it_showed() {
let dir = tempfile::tempdir().expect("a temp dir");
let (bare, b, _) = shared(dir.path());
let base = sh(&b, &["rev-parse", "HEAD"]).trim().to_string();
std::fs::write(b.join("a.txt"), "keep me\n").expect("writes");
sh(&b, &["commit", "-qam", "keep-me"]);
run(&b, Write::Remote(words(&["push", "--progress"]))).expect("pushes");
let plan = remotes::force(&git(), &b).expect("a plan");
assert!(plan.lost.is_empty());
// The branch moves back before the yes.
sh(&b, &["reset", "-q", "--hard", &base]);
run(&b, Write::Remote(plan.args())).expect("pushes");
let branch = &plan.branch;
assert_eq!(
sh(&bare, &["rev-parse", &format!("refs/heads/{branch}")]).trim(),
plan.source,
"keep-me stays"
);
}
#[cfg(unix)]
#[test]
fn every_line_a_hook_says_is_kept() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let (bare, b, _) = shared(dir.path());
let hook = bare.join("hooks/pre-receive");
std::fs::write(
&hook,
"#!/bin/sh\necho POLICY_A required >&2\necho POLICY_B required >&2\nexit 1\n",
)
.expect("writes");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
std::fs::write(b.join("a.txt"), "refused\n").expect("writes");
sh(&b, &["commit", "-qam", "refused"]);
let said = run(&b, Write::Remote(words(&["push", "--progress"]))).expect_err("refused");
let said = said.to_string();
assert!(
said.contains("POLICY_A required") && said.contains("POLICY_B required"),
"{said}"
);
}
#[test]
fn a_remote_named_with_a_dash_is_a_name() {
let dir = tempfile::tempdir().expect("a temp dir");
let (bare, b, _) = shared(dir.path());
let path = bare.to_str().expect("utf-8");
run(
&b,
Write::Git(words(&["remote", "add", "--", "-dash", path])),
)
.expect("adds");
sh(&b, &["switch", "-q", "-c", "topic"]);
let push = words(&[
"push",
"--progress",
"--set-upstream",
"--",
"-dash",
"refs/heads/topic:refs/heads/topic",
]);
run(&b, Write::Remote(push)).expect("pushes");
assert_eq!(
sh(&b, &["config", "--get", "branch.topic.remote"]).trim(),
"-dash"
);
}
#[test]
fn a_stash_with_an_empty_message_still_lists() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
std::fs::write(root.join("a.txt"), "one\nfirst\n").expect("writes");
sh(&root, &["stash", "push", "-q", "-m", "named"]);
std::fs::write(root.join("a.txt"), "one\nbare\n").expect("writes");
let oid = sh(&root, &["stash", "create"]).trim().to_string();
sh(&root, &["stash", "store", "-m", "", &oid]);
let list = stash::list(&git(), &root).expect("reads");
let shown: Vec<&str> = list.iter().map(|s| s.message.as_str()).collect();
assert_eq!(shown, ["", "named"]);
assert_eq!(list[0].oid, oid);
}
+385
View File
@@ -0,0 +1,385 @@
//! Reflog, moving back, cleaning, locks, failures (C13.6).
use std::path::Path;
use gpui::{App, Entity, TestAppContext};
use super::{
branching::conflicted,
committed, git,
places::me,
sh,
window::{branch, settle, window},
};
use crate::{
asks::Ask,
ops::Op,
recover,
tree::Tree,
work::{self, Write},
};
pub(super) fn run(root: &Path, write: Write) -> std::io::Result<()> {
work::run(&git(), root, &write, &write.args(true))
}
/// Says yes to the dialog's write, as its button would.
fn confirm(tree: &Entity<Tree>, app: &mut App) {
tree.update(app, |tree, cx| {
let Some(Ask::Confirm { repo, write, .. }) = tree.ask.take() else {
panic!("a confirm dialog");
};
tree.write(repo, write, cx);
});
}
#[test]
fn the_reflog_lists_where_head_was_newest_first() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
std::fs::write(root.join("a.txt"), "two\n").expect("writes");
sh(&root, &["commit", "-qam", "second"]);
sh(&root, &["checkout", "-q", "--detach", "HEAD~1"]);
let moves = recover::reflog(&git(), &root).expect("reads");
let names: Vec<&str> = moves.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, ["HEAD@{0}", "HEAD@{1}", "HEAD@{2}"]);
assert!(moves[0].said.starts_with("checkout:"), "{}", moves[0].said);
assert!(moves[1].said.ends_with("second"), "{}", moves[1].said);
}
fn move_of(plan: &recover::Move) -> Write {
Write::Move {
branch: plan.branch.clone(),
head: plan.head.clone(),
to: plan.to.clone(),
}
}
#[test]
fn moving_back_keeps_changes_and_refuses_a_moved_head() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
std::fs::write(root.join("b.txt"), "b\n").expect("writes");
sh(&root, &["add", "b.txt"]);
sh(&root, &["commit", "-qm", "gone soon"]);
std::fs::write(root.join("a.txt"), "one\ntwo\nkept\n").expect("writes");
let plan = recover::plan_move(&git(), &root, &first).expect("a plan");
assert_eq!(plan.left.len(), 1);
assert!(plan.left[0].ends_with("gone soon"));
sh(&root, &["commit", "-q", "--allow-empty", "-m", "meanwhile"]);
let refused = run(&root, move_of(&plan)).expect_err("HEAD moved");
assert!(refused.to_string().contains("moved"), "{refused}");
sh(&root, &["reset", "-q", "--keep", "HEAD~1"]);
run(&root, move_of(&plan)).expect("moves");
assert_eq!(sh(&root, &["rev-parse", "HEAD"]).trim(), first);
assert_eq!(
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
"one\ntwo\nkept\n"
);
assert!(!root.join("b.txt").exists());
}
#[test]
fn staged_work_refuses_a_move_rather_than_lose_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
sh(&root, &["commit", "-q", "--allow-empty", "-m", "second"]);
std::fs::write(root.join("a.txt"), "staged\n").expect("writes");
sh(&root, &["add", "a.txt"]);
std::fs::write(root.join("a.txt"), "unstaged\n").expect("writes");
assert!(
recover::plan_move(&git(), &root, &first).is_err(),
"no plan"
);
// Staged after the plan: the write refuses too.
sh(&root, &["reset", "-q"]);
let plan = recover::plan_move(&git(), &root, &first).expect("a plan");
sh(&root, &["add", "a.txt"]);
let refused = run(&root, move_of(&plan)).expect_err("staged");
assert!(refused.to_string().contains("staged"), "{refused}");
assert_eq!(sh(&root, &["show", ":a.txt"]), "unstaged\n", "index kept");
}
#[test]
fn a_move_refuses_another_branch_or_a_detached_head() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
sh(&root, &["commit", "-q", "--allow-empty", "-m", "second"]);
let second = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
sh(&root, &["branch", "other"]);
let plan = recover::plan_move(&git(), &root, &first).expect("a plan");
sh(&root, &["switch", "-q", "other"]);
assert!(
run(&root, move_of(&plan)).is_err(),
"same commit, other branch"
);
sh(&root, &["switch", "-q", "--detach", &second]);
assert!(run(&root, move_of(&plan)).is_err(), "detached now");
for name in ["other", "HEAD"] {
assert_eq!(sh(&root, &["rev-parse", name]).trim(), second);
}
}
#[test]
fn a_held_lock_waits_for_a_retry_and_stays() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\nstaged\n").expect("writes");
let lock = root.join(".git/index.lock");
std::fs::write(&lock, "").expect("writes");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let id = cx.update(|_, app| tree.read(app).active.expect("open"));
let entry = super::entry(&root, "a.txt");
cx.update(|_, app| tree.update(app, |tree, cx| tree.write(id, Write::Stage(entry), cx)));
settle(cx, |app| {
matches!(tree.read(app).ask, Some(Ask::Locked { .. }))
});
assert!(lock.exists(), "Ely deletes no lock");
std::fs::remove_file(&lock).expect("the other Git ends");
cx.update(|_, app| {
tree.update(app, |tree, cx| {
let Some(Ask::Locked { repo, write, .. }) = tree.ask.take() else {
panic!("locked");
};
tree.write(repo, write, cx);
})
});
settle(cx, |_| {
sh(&root, &["diff", "--cached", "--name-only"]).trim() == "a.txt"
});
}
#[test]
fn a_refused_credential_says_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
// A server that wants a password nobody gives.
let server = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
let url = format!(
"http://{}/repo.git",
server.local_addr().expect("an address")
);
std::thread::spawn(move || {
for stream in server.incoming().flatten() {
use std::io::{Read as _, Write as _};
let mut stream = stream;
let mut buffer = [0u8; 4096];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(
b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
}
});
sh(&root, &["remote", "add", "origin", &url]);
sh(&root, &["config", "credential.helper", ""]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.fetch_one("origin", cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("terminal prompts disabled")),
);
}
#[test]
fn an_interrupted_rebase_aborts_back_to_where_it_was() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, _) = conflicted(dir.path());
let before = sh(&root, &["rev-parse", "HEAD"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.rebase("refs/heads/side", "side", cx)));
cx.update(|_, app| confirm(&tree, app));
let doing = |app: &App, op: Option<Op>| {
matches!(
tree.read(app).active().and_then(|o| o.doing.clone()),
Some(Ok(now)) if now.as_ref().map(|d| d.op) == op
)
};
settle(cx, |app| doing(app, Some(Op::Rebase)));
assert!(
cx.update(|_, app| tree.read(app).ask.is_none()),
"a stop, no failure"
);
cx.update(|_, app| tree.update(app, |tree, cx| tree.op_abort(Op::Rebase, cx)));
cx.update(|_, app| confirm(&tree, app));
settle(cx, |app| doing(app, None));
assert_eq!(sh(&root, &["rev-parse", "HEAD"]), before);
assert!(sh(&root, &["status", "--porcelain"]).trim().is_empty());
}
#[test]
fn a_reflog_entry_shows_and_moves_the_branch_back() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
me(&root);
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
sh(&root, &["commit", "-q", "--allow-empty", "-m", "later"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(crate::tree::Side::Places, cx)));
settle(cx, |app| {
tree.read(app)
.active()
.and_then(|o| o.places.read.as_ref())
.is_some_and(|read| matches!(&read.reflog, Ok(moves) if moves.len() == 2))
});
let place = cx.update(|_, app| reflog_at(&tree, app, 1));
cx.update(|_, app| tree.update(app, |tree, cx| tree.pick_place(place, cx)));
settle(cx, |app| {
matches!(
tree.read(app).active().and_then(|o| o.places.pick.detail.as_ref()),
Some(Ok(detail)) if detail.id == first
)
});
cx.update(|_, app| tree.update(app, |tree, cx| tree.ask_move(&first, cx)));
settle(
cx,
|app| matches!(&tree.read(app).ask, Some(Ask::Move { plan, .. }) if plan.left.len() == 1),
);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
let Some(Ask::Move { repo, plan }) = tree.ask.take() else {
panic!("a move");
};
tree.write(repo, move_of(&plan), cx);
})
});
settle(cx, |_| sh(&root, &["rev-parse", "HEAD"]).trim() == first);
}
#[test]
fn the_reflog_says_when_head_moved_not_when_committed() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("work");
crate::repo::init(&git(), &root).expect("init");
let old = std::process::Command::new(&git().path)
.args([
"-c",
"user.name=Ely",
"-c",
"user.email=e@x",
"commit",
"-q",
"--allow-empty",
"-m",
"old",
])
.current_dir(&root)
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.status()
.expect("runs");
assert!(old.success());
sh(&root, &["checkout", "-q", "-b", "other"]);
let moves = recover::reflog(&git(), &root).expect("reads");
assert!(
moves[0].at > 1_700_000_000,
"the checkout's own time: {}",
moves[0].at
);
}
/// The reflog event at `at`, as picked.
fn reflog_at(tree: &Entity<Tree>, app: &App, at: usize) -> crate::places::Place {
let read = tree.read(app).active().and_then(|o| o.places.read.as_ref());
let Some(crate::places::Read {
reflog: Ok(moves), ..
}) = read
else {
panic!("a reflog");
};
crate::places::Place::Reflog(moves[at].clone())
}
#[test]
fn a_picked_reflog_event_stays_itself_as_others_arrive() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
sh(&root, &["checkout", "-q", "-b", "other"]);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(crate::tree::Side::Places, cx)));
let count = |app: &App| match tree.read(app).active().and_then(|o| o.places.read.as_ref()) {
Some(crate::places::Read {
reflog: Ok(moves), ..
}) => moves.len(),
_ => 0,
};
settle(cx, |app| count(app) == 2);
// The initial commit and the checkout share one commit.
let picked = cx.update(|_, app| reflog_at(&tree, app, 1));
let newer = cx.update(|_, app| reflog_at(&tree, app, 0));
cx.update(|_, app| tree.update(app, |tree, cx| tree.pick_place(picked.clone(), cx)));
let crate::places::Place::Reflog(was) = &picked else {
panic!("a reflog event");
};
let crate::places::Place::Reflog(new) = &newer else {
panic!("a reflog event");
};
assert!(!was.same(new), "one commit, two events");
sh(&root, &["checkout", "-q", "--detach", "HEAD"]);
settle(cx, |app| count(app) == 3);
let now = cx.update(|_, app| reflog_at(&tree, app, 2));
let crate::places::Place::Reflog(now) = now else {
panic!("a reflog event");
};
assert!(now.same(was), "the event moved to HEAD@{{2}}");
assert!(cx.update(|_, app| tree.read(app).picked_place.as_ref() == Some(&picked)));
}
#[test]
fn a_held_config_lock_waits_for_a_retry_too() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let lock = root.join(".git/config.lock");
std::fs::write(&lock, "").expect("writes");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
let id = cx.update(|_, app| tree.read(app).active.expect("open"));
let write = Write::Config {
key: "user.name".into(),
value: "Locked Out".into(),
global: false,
};
cx.update(|_, app| tree.update(app, |tree, cx| tree.write(id, write, cx)));
settle(cx, |app| {
matches!(tree.read(app).ask, Some(Ask::Locked { .. }))
});
assert!(lock.exists());
std::fs::remove_file(&lock).expect("the other Git ends");
cx.update(|_, app| {
tree.update(app, |tree, cx| {
let Some(Ask::Locked { repo, write, .. }) = tree.ask.take() else {
panic!("locked");
};
tree.write(repo, write, cx);
})
});
settle(cx, |_| {
let read = git()
.run(&root, &["config", "--get", "user.name"])
.expect("reads");
String::from_utf8_lossy(&read).trim() == "Locked Out"
});
}
+322
View File
@@ -0,0 +1,322 @@
//! The window: tabs, and rereads after outside Git (TREE-01, TREE-02).
use std::time::{Duration, Instant};
use gpui::{App, Entity, TestAppContext, VisualTestContext};
use super::{committed, sh};
use crate::{status::State, tree::Tree};
/// Steps, lets the watcher's timer pass, and redraws until `done`.
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
cx.executor().advance_clock(Duration::from_millis(300));
for _ in 0..100 {
if !cx.executor().tick() {
break;
}
}
cx.update(|window, _| window.refresh());
if cx.update(|_, app| done(app)) {
return;
}
}
panic!("the state never settled");
}
pub(crate) fn window<'a>(
cx: &'a mut TestAppContext,
data: &std::path::Path,
) -> (Entity<Tree>, &'a mut VisualTestContext) {
cx.executor().allow_parking();
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
});
let recent = data.join("recent.json");
cx.add_window_view(move |window, cx| Tree::new(recent, window, cx))
}
pub(crate) fn untracked(tree: &Tree) -> Vec<String> {
let Some(Some(Ok(status))) = tree.active().map(|opened| &opened.status) else {
return Vec::new();
};
status
.entries
.iter()
.filter(|entry| entry.state == State::Untracked)
.map(|entry| entry.path.clone())
.collect()
}
pub(crate) fn branch(tree: &Tree) -> Option<String> {
let Some(Some(Ok(status))) = tree.active().map(|opened| &opened.status) else {
return None;
};
status.head.branch.clone()
}
#[test]
fn outside_git_and_edits_show_without_a_press() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
std::fs::write(root.join("new.txt"), "new\n").expect("writes");
settle(cx, |app| untracked(tree.read(app)) == ["new.txt"]);
sh(&root, &["checkout", "-q", "-b", "elsewhere"]);
settle(cx, |app| {
branch(tree.read(app)).as_deref() == Some("elsewhere")
});
}
#[test]
fn one_tab_per_repository_and_reopening_selects_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let (first, second) = (
committed(&dir.path().join("1")),
committed(&dir.path().join("2")),
);
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
for (path, active) in [
(first.clone(), 1),
(second.clone(), 2),
(first.join("."), 1),
] {
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(path, cx)));
settle(cx, |app| tree.read(app).active == Some(active));
}
let tabs = cx.update(|_, app| tree.read(app).repos.len());
assert_eq!(tabs, 2, "the same work tree is one tab");
let recent = cx.update(|_, app| tree.read(app).recent.clone());
assert_eq!(
recent[0],
std::fs::canonicalize(&first).expect("real"),
"newest first"
);
cx.update(|_, app| tree.update(app, |tree, cx| tree.close_active(cx)));
assert_eq!(cx.update(|_, app| tree.read(app).active), Some(2));
let missing = dir.path().join("gone");
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.recent.insert(0, missing.clone());
tree.open_path(missing.clone(), cx)
})
});
settle(cx, |app| !tree.read(app).recent.contains(&missing));
}
pub(crate) fn oid(tree: &Tree) -> Option<String> {
let Some(Some(Ok(status))) = tree.active().map(|opened| &opened.status) else {
return None;
};
status.head.oid.clone()
}
#[test]
fn a_linked_worktree_follows_refs_moved_from_the_main_one() {
let dir = tempfile::tempdir().expect("a temp dir");
let main = committed(dir.path());
let linked = dir.path().join("linked");
sh(
&main,
&[
"worktree",
"add",
"-q",
"-b",
"linked",
linked.to_str().expect("utf-8"),
],
);
std::fs::write(main.join("b.txt"), "b\n").expect("writes");
sh(&main, &["add", "b.txt"]);
sh(&main, &["commit", "-q", "-m", "second"]);
let second = sh(&main, &["rev-parse", "HEAD"]).trim().to_string();
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(linked.clone(), cx)));
settle(cx, |app| {
branch(tree.read(app)).as_deref() == Some("linked")
});
sh(&main, &["update-ref", "refs/heads/linked", &second]);
settle(cx, |app| {
oid(tree.read(app)).as_deref() == Some(second.as_str())
});
}
#[test]
fn picked_lines_stage_from_the_window_and_the_picks_clear() {
use ely_gpui_component::git::ChangeSection;
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("a.txt"), "one\nthree\nfour\n").expect("writes");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.show("a.txt", ChangeSection::Unstaged, cx)
})
});
let text = |app: &App| {
matches!(
tree.read(app).shown.as_ref().and_then(|s| s.view.as_ref()),
Some(Ok(crate::shown::View::Text { .. }))
)
};
settle(cx, text);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.pick_line((0, 1), false, cx);
tree.pick_line((0, 3), true, cx);
})
});
let picked = cx.update(|_, app| tree.read(app).shown.as_ref().expect("shown").picked.clone());
assert_eq!(
picked.into_iter().collect::<Vec<_>>(),
[(0, 1), (0, 2), (0, 3)],
"Shift takes the range"
);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.pick_line((0, 1), false, cx);
tree.pick_line((0, 2), false, cx);
tree.stage_picked(cx)
})
});
settle(cx, |_| {
sh(&root, &["cat-file", "blob", ":a.txt"]) == "one\ntwo\nfour\n"
});
settle(cx, |app| {
tree.read(app)
.shown
.as_ref()
.is_some_and(|shown| shown.picked.is_empty())
});
}
#[test]
fn a_late_read_of_another_file_is_dropped() {
use ely_gpui_component::git::ChangeSection;
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
std::fs::write(root.join("b.txt"), "before-b\n").expect("writes");
sh(&root, &["add", "b.txt"]);
sh(&root, &["commit", "-qm", "b"]);
std::fs::write(root.join("a.txt"), "after-a\n").expect("writes");
std::fs::write(root.join("b.txt"), "after-b\n").expect("writes");
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.show("a.txt", ChangeSection::Unstaged, cx)
})
});
let first = cx.update(|_, app| tree.read(app).reads);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.show("b.txt", ChangeSection::Unstaged, cx)
})
});
let shows = |app: &App, word: &str| match tree
.read(app)
.shown
.as_ref()
.and_then(|shown| shown.view.as_ref())
{
Some(Ok(crate::shown::View::Text { diff, .. })) => format!("{diff:?}").contains(word),
_ => false,
};
settle(cx, |app| shows(app, "after-b"));
// a.txt's read lands after b.txt is shown.
let late = crate::shown::read(
&super::git(),
&root,
&super::entry(&root, "a.txt"),
ChangeSection::Unstaged,
crate::diff::Space::Exact,
true,
);
cx.update(|_, app| tree.update(app, |tree, cx| tree.shown_read(first, late, cx)));
assert!(
cx.update(|_, app| shows(app, "after-b")),
"b.txt stays shown"
);
}
#[test]
fn a_commit_from_the_box_shows_in_history_with_its_files() {
use crate::tree::Side;
let dir = tempfile::tempdir().expect("a temp dir");
let root = committed(dir.path());
for (key, value) in [
("user.name", "Ely"),
("user.email", "ely@example.com"),
("commit.gpgsign", "false"),
] {
sh(&root, &["config", key, value]);
}
let mut cx = TestAppContext::single();
let (tree, cx) = window(&mut cx, dir.path());
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
settle(cx, |app| branch(tree.read(app)).is_some());
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(Side::History, cx)));
let rows = |app: &App| {
tree.read(app)
.active()
.map_or(0, |opened| opened.hist.rows.len())
};
settle(cx, |app| rows(app) == 1);
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
sh(&root, &["add", "a.txt"]);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.subject.update(cx, |field, cx| {
field.set_text("From the box".to_string(), cx)
});
tree.commit(cx)
})
});
// HEAD moved, so history rereads on its own.
settle(cx, |app| rows(app) == 2);
let newest = cx.update(|_, app| tree.read(app).active().expect("open").hist.rows[0].clone());
assert_eq!(newest.subject.as_ref(), "From the box");
assert!(
cx.update(|_, app| tree.read(app).subject.read(app).text().is_empty()),
"the box empties"
);
cx.update(|_, app| tree.update(app, |tree, cx| tree.pick_commit(&newest.id, cx)));
let detail = |app: &App| {
matches!(
tree.read(app)
.active()
.and_then(|o| o.hist.pick.detail.as_ref()),
Some(Ok(_))
)
};
settle(cx, detail);
cx.update(|_, app| {
tree.update(app, |tree, cx| {
tree.pick_touched(crate::hist::Whose::History, 0, cx)
})
});
let file = |app: &App| {
matches!(
tree.read(app)
.active()
.and_then(|o| o.hist.pick.file.as_ref()),
Some((0, Some(Ok(_))))
)
};
settle(cx, file);
}
+271
View File
@@ -0,0 +1,271 @@
//! Terminal, file manager, a chosen editor, ElyMark (TREE-14).
use std::{
io,
path::{Path, PathBuf},
process::Command,
};
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton},
primitives::IconName,
theme::ControlSize,
};
use ely_platform::AppId;
use gpui::{AppContext as _, Context, PathPromptOptions};
use crate::tree::{Tree, text};
/// Read once; written whenever the editor changes.
pub struct Tools {
file: PathBuf,
pub editor: Option<PathBuf>,
/// Why the file was unreadable; never overwritten.
pub broken: Option<String>,
}
impl Tools {
/// The kept editor; a broken file is said, never overwritten.
pub fn load(file: PathBuf) -> Tools {
match Tools::read(file.clone()) {
Ok(tools) => tools,
Err(error) => Tools {
file,
editor: None,
broken: Some(error.to_string()),
},
}
}
/// The editor kept in `file`; none until chosen.
fn read(file: PathBuf) -> io::Result<Tools> {
let editor = match std::fs::read(&file) {
Ok(bytes) => {
let said = |what: String| io::Error::other(format!("{}: {what}", file.display()));
let read: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|error| said(error.to_string()))?;
match read.get("editor") {
_ if !read.is_object() => return Err(said("not an object".into())),
None => None,
Some(serde_json::Value::String(editor)) => Some(PathBuf::from(editor)),
Some(other) => return Err(said(format!("editor is {other}"))),
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error),
};
Ok(Tools {
file,
editor,
broken: None,
})
}
fn keep(&mut self, editor: PathBuf) -> io::Result<()> {
if let Some(why) = &self.broken {
return Err(io::Error::other(why.clone()));
}
let bytes = serde_json::to_vec(&serde_json::json!({ "editor": editor }))
.map_err(io::Error::other)?;
if let Some(dir) = self.file.parent() {
std::fs::create_dir_all(dir)?;
}
ely_files::save_atomic(&self.file, &bytes, 0)?;
self.editor = Some(editor);
Ok(())
}
}
/// A bundle opens through `open`, waited on.
fn launch(editor: &Path, file: &Path) -> io::Result<()> {
if cfg!(target_os = "macos") && editor.extension().is_some_and(|ext| ext == "app") {
let out = Command::new("open")
.arg("-a")
.arg(editor)
.arg(file)
.output()?;
if !out.status.success() {
let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(io::Error::other(format!("{}: {said}", editor.display())));
}
return Ok(());
}
Command::new(editor)
.arg(file)
.spawn()
.map(drop)
.map_err(|error| io::Error::other(format!("{}: {error}", editor.display())))
}
#[cfg(target_os = "macos")]
fn terminal_at(dir: &Path) -> io::Result<()> {
Command::new("open")
.args(["-a", "Terminal"])
.arg(dir)
.spawn()
.map(drop)
}
#[cfg(not(target_os = "macos"))]
fn terminal_at(_dir: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"opening a terminal is not built on this platform",
))
}
impl Tree {
fn root(&self) -> Option<PathBuf> {
self.active().map(|opened| opened.repo.root.clone())
}
pub fn open_terminal(&mut self, cx: &mut Context<Self>) {
let Some(root) = self.root() else { return };
log::info!("tree: terminal at {}", root.display());
if let Err(error) = terminal_at(&root) {
self.failed("tree.error.tool", &error.to_string(), cx);
}
}
pub fn reveal(&mut self, cx: &mut Context<Self>) {
let Some(root) = self.root() else { return };
cx.reveal_path(&root);
}
/// Asks which editor; `then` opens a file in it.
pub fn pick_editor(&mut self, then: Option<String>, cx: &mut Context<Self>) {
let picked = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
let picked = picked
.await
.map_err(|error| error.to_string())
.and_then(|picked| picked.map_err(|error| error.to_string()));
this.update(cx, |tree, cx| match picked {
Ok(Some(mut paths)) if !paths.is_empty() => {
let editor = paths.remove(0);
if let Err(error) = tree.tools.keep(editor) {
return tree.failed("tree.error.tool", &error.to_string(), cx);
}
if let Some(path) = then {
tree.open_in_editor(&path, cx);
}
}
Ok(_) => log::info!("tree: the editor panel was cancelled"),
Err(error) => tree.failed("tree.error.panel", &error, cx),
})
.ok();
})
.detach();
}
/// A repository file in the chosen editor; asks first.
pub fn open_in_editor(&mut self, path: &str, cx: &mut Context<Self>) {
let Some(root) = self.root() else { return };
let Some(editor) = self.tools.editor.clone() else {
return self.pick_editor(Some(path.to_string()), cx);
};
let file = root.join(path);
log::info!("tree: {} opens a file", editor.display());
// The launcher's own failure arrives after it starts.
let task = cx.background_spawn(async move { launch(&editor, &file) });
cx.spawn(async move |this, cx| {
if let Err(error) = task.await {
this.update(cx, |tree, cx| {
tree.failed("tree.error.tool", &error.to_string(), cx)
})
.ok();
}
})
.detach();
}
pub fn open_in_mark(&mut self, path: &str, cx: &mut Context<Self>) {
let Some(root) = self.root() else { return };
if let Err(error) = ely_platform::apps::open(AppId::Mark, &[root.join(path)]) {
self.failed("tree.error.tool", &error.to_string(), cx);
}
}
}
impl Tree {
/// The chosen editor, and ElyMark for Markdown.
pub fn tool_buttons(&self, path: &str, cx: &mut Context<Self>) -> Vec<IconButton> {
let (edit, mark) = (cx.weak_entity(), cx.weak_entity());
let (at, md) = (path.to_string(), path.to_string());
let mut buttons = vec![
IconButton::new("tree-open-editor", IconName::Pencil)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(text(cx, "tree.tool.in_editor", &[]))
.on_click(move |_, _, cx| {
edit.update(cx, |tree, cx| tree.open_in_editor(&at, cx))
.ok();
}),
];
if markdown(path) {
buttons.push(
IconButton::new("tree-open-mark", IconName::FileText)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(text(cx, "tree.tool.in_mark", &[]))
.on_click(move |_, _, cx| {
mark.update(cx, |tree, cx| tree.open_in_mark(&md, cx)).ok();
}),
);
}
buttons
}
}
/// Markdown opens in ElyMark too.
pub fn markdown(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown"))
}
#[cfg(test)]
mod tests {
#[test]
fn the_editor_is_kept_and_read_back() {
let dir = tempfile::tempdir().expect("a temp dir");
let file = dir.path().join("data/tools.json");
let mut tools = super::Tools::read(file.clone()).expect("none yet");
assert_eq!(tools.editor, None);
tools.keep("/Applications/Zed.app".into()).expect("keeps");
let again = super::Tools::read(file.clone()).expect("reads");
assert_eq!(
again.editor.as_deref(),
Some(std::path::Path::new("/Applications/Zed.app"))
);
std::fs::write(&file, "not json").expect("writes");
let mut broken = super::Tools::load(file.clone());
assert!(broken.broken.is_some(), "a broken file is said");
assert!(broken.keep("/x".into()).is_err());
assert_eq!(
std::fs::read(&file).expect("reads"),
b"not json",
"left alone"
);
}
#[cfg(target_os = "macos")]
#[test]
fn a_gone_editor_app_says_so() {
let gone = std::path::Path::new("/nonexistent/DeletedEditor.app");
let said = super::launch(gone, std::path::Path::new("/tmp")).expect_err("gone");
assert!(said.to_string().contains("DeletedEditor.app"), "{said}");
}
#[test]
fn markdown_files_are_known_by_their_ending() {
assert!(super::markdown("docs/READ.ME.md") && super::markdown("a.Markdown"));
assert!(!super::markdown("notes.txt") && !super::markdown("md"));
}
}
+490
View File
@@ -0,0 +1,490 @@
//! The window: one tab per repository (TREE-01).
use std::{
collections::VecDeque,
io,
path::{Path, PathBuf},
};
use ely_gpui_component::{
forms::{InputEvent, TextInput},
primitives::Severity,
};
use ely_platform::AppId;
use ely_ui::Root;
use gpui::{
App, AppContext as _, Context, Entity, FocusHandle, SharedString, Subscription, WeakEntity,
Window,
};
use notify::RecommendedWatcher;
use crate::{
changes::Changes,
commit::Me,
commits::Find,
git::Git,
hist::Hist,
look::Look,
repo::{self, Repo},
status::{self, Status},
work::Write,
};
/// An open repository and what was last read of it.
pub struct Opened {
pub id: u64,
pub repo: Repo,
pub status: Option<Result<Status, String>>,
/// A read runs; `again` asks one more.
pub reading: bool,
pub again: bool,
/// Writes wait here and run one at a time (§13.3).
pub writes: VecDeque<Write>,
pub writing: bool,
pub hist: Hist,
pub refs: Option<Result<Vec<crate::branches::Ref>, String>>,
pub refs_asks: u64,
/// A merge, rebase, pick or revert under way.
pub doing: Option<Result<Option<crate::ops::Doing>, String>>,
pub doing_asks: u64,
/// This repository's commit box while another is shown.
pub held: crate::committing::Held,
/// Who commits here, and whether Git signs.
pub me: Option<Result<Me, String>>,
/// Identity reads started; only the latest lands.
pub me_asks: u64,
pub places: crate::places::Places,
pub net: Option<crate::network::Net>,
/// Kept alive; dropping it stops the watch.
_watcher: Option<RecommendedWatcher>,
}
/// What the left panel lists.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Changes,
History,
Branches,
Places,
}
pub struct Tree {
pub git: Result<Git, String>,
pub repos: Vec<Opened>,
pub active: Option<u64>,
next: u64,
pub recent: Vec<PathBuf>,
/// Why the recent list could not be read; shown once.
unread: Option<String>,
recent_file: PathBuf,
pub tools: crate::tools::Tools,
pub cloning: Option<crate::cloning::Cloning>,
pub ask: Option<crate::asks::Ask>,
pub focus: FocusHandle,
pub root: WeakEntity<Root>,
pub changes: Entity<Changes>,
pub shown: Option<crate::pick::Shown>,
pub space: crate::diff::Space,
pub layout: ely_gpui_component::git::DiffLayout,
/// Diff reads started, numbering each.
pub reads: u64,
pub side: Side,
pub look: Option<Look>,
pub subject: Entity<TextInput>,
pub body: Entity<TextInput>,
pub amend: bool,
/// The message typed before amend filled in the last one.
pub draft: Option<(String, String)>,
pub search: Entity<TextInput>,
pub find: Find,
/// Amend reads asked; switching or unticking voids older ones.
pub amend_ask: u64,
/// Amend is ticked; the last message is on its way.
pub amend_loading: bool,
/// File history and blame opened, numbering each.
pub looks: u64,
/// The branch or tag picked in the Branches panel.
pub picked_ref: Option<(crate::branches::Kind, String)>,
pub picked_place: Option<crate::places::Place>,
/// The conflicted file being merged by hand.
pub merging: Option<crate::conflict::Merging>,
pub merge_editor: Entity<ely_gpui_component::editor::CodeEditor>,
_quit: Subscription,
_searching: Subscription,
_returning: Subscription,
}
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
ely_ui::i18n::text(cx, key, args)
}
/// The window, with the repositories it was started with.
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
crate::menu::bind(cx);
let recent_file = match ely_platform::data_dir(app) {
Ok(dir) => dir.join("recent.json"),
Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")),
};
let tree = cx.new(|cx| Tree::new(recent_file, window, cx));
let root = ely_ui::tool_root(app, tree.clone().into(), crate::menu::menus(cx), window, cx);
tree.update(cx, |tree, cx| {
tree.root = root.downgrade();
window.focus(&tree.focus, cx);
if let Some(error) = tree.unread.take() {
tree.failed("tree.error.recent", &error, cx);
}
if let Some(error) = tree.tools.broken.clone() {
tree.failed("tree.error.tools_file", &error, cx);
}
});
for path in ely_ui::launch_files(cx) {
tree.update(cx, |tree, cx| tree.open_path(path, cx));
}
let opening = tree.clone();
ely_ui::on_open(cx, move |path, cx| {
opening.update(cx, |tree, cx| tree.open_path(path, cx))
});
// A clone is not left running behind a closed window.
let closing = tree.clone();
window.on_window_should_close(cx, move |_, cx| {
closing.update(cx, |tree, cx| tree.stop_all(cx));
true
});
root
}
/// `file` as Git names it; a symlink stays itself.
pub(crate) fn inside(root: &Path, file: &Path) -> io::Result<String> {
let name = file.file_name().ok_or_else(|| io::Error::other("a path without a name"))?;
let dir = std::fs::canonicalize(file.parent().unwrap_or(Path::new(".")))?;
let rel = dir.join(name);
let rel = rel.strip_prefix(root).map_err(io::Error::other)?;
rel.to_str().map(str::to_owned).ok_or_else(|| io::Error::other("a path that is not UTF-8"))
}
impl Tree {
pub(crate) fn new(recent_file: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
let git = Git::find().map_err(|error| error.to_string());
if let Err(error) = &git {
log::error!("tree: {error}");
}
let (recent, unread) = match ely_files::recent::recent(&recent_file) {
Ok(list) => (list, None),
Err(error) => (Vec::new(), Some(error.to_string())),
};
let this = cx.entity();
let changes = cx.new(|cx| Changes::new(&this, cx));
let subject = text(cx, "tree.commit.subject", &[]);
let subject = cx.new(|cx| TextInput::new(window, cx).placeholder(subject));
let body = text(cx, "tree.commit.body", &[]);
let body = cx.new(|cx| {
TextInput::new(window, cx)
.placeholder(body)
.multi_line(3, 8)
});
let merge_editor =
cx.new(|cx| ely_gpui_component::editor::CodeEditor::new(String::new(), window, cx));
let search = text(cx, "tree.search", &[]);
let search = cx.new(|cx| TextInput::new(window, cx).placeholder(search));
let searching = cx.subscribe(&search, |tree: &mut Tree, _, event: &InputEvent, cx| {
if *event == InputEvent::Submit {
tree.search(cx);
}
});
Tree {
git,
repos: Vec::new(),
active: None,
next: 1,
recent,
unread,
tools: crate::tools::Tools::load(recent_file.with_file_name("tools.json")),
recent_file,
cloning: None,
ask: None,
focus: cx.focus_handle(),
root: WeakEntity::new_invalid(),
changes,
shown: None,
space: crate::diff::Space::Exact,
layout: ely_gpui_component::git::DiffLayout::Unified,
reads: 0,
side: Side::Changes,
look: None,
subject,
body,
amend: false,
draft: None,
search,
find: Find::Message,
amend_ask: 0,
amend_loading: false,
looks: 0,
picked_ref: None,
picked_place: None,
merging: None,
merge_editor,
_searching: searching,
// Global settings change outside any repository's watch.
_returning: cx.observe_window_activation(window, |tree, window, cx| {
if let Some(id) = tree.active.filter(|_| window.is_window_active()) {
tree.read_me(id, cx);
}
}),
_quit: cx.on_app_quit(|tree, cx| {
tree.stop_all(cx);
async {}
}),
}
}
pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) {
log::warn!("tree: {text}");
if let Some(root) = self.root.upgrade() {
root.update(cx, |root, cx| root.toast(text, severity, cx));
}
}
pub fn failed(&self, key: &str, error: &str, cx: &mut App) {
let words = text(cx, key, &[("error", error)]);
self.toast(words, Severity::Danger, cx);
}
/// The Git to run, or why there is none.
pub fn git(&self, cx: &mut App) -> Option<Git> {
match &self.git {
Ok(git) => Some(git.clone()),
Err(error) => {
self.failed("tree.error.no_git", error, cx);
None
}
}
}
pub fn opened(&self, id: u64) -> Option<&Opened> {
self.repos.iter().find(|opened| opened.id == id)
}
pub fn opened_mut(&mut self, id: u64) -> Option<&mut Opened> {
self.repos.iter_mut().find(|opened| opened.id == id)
}
pub fn active(&self) -> Option<&Opened> {
self.active.and_then(|id| self.opened(id))
}
/// Opens `path`'s repository; a file also shows its history.
pub fn open_path(&mut self, path: PathBuf, cx: &mut Context<Self>) {
let Some(git) = self.git(cx) else { return };
let task = cx.background_spawn(async move {
let file = path.is_file().then(|| path.clone());
let dir = file.as_ref().and_then(|file| file.parent()).unwrap_or(&path);
let opened = repo::open(&git, dir).map_err(|error| (path.clone(), error));
// The file as Git names it, from the repository's top.
let inside = match (&opened, file) {
(Ok(repo), Some(file)) => Some(inside(&repo.root, &file).map_err(|error| (file, error))),
_ => None,
};
(opened, inside)
});
cx.spawn(async move |this, cx| {
let (opened, inside) = task.await;
this.update(cx, |tree, cx| {
tree.add(opened, cx);
match inside {
Some(Ok(rel)) => tree.file_log(&rel, cx),
Some(Err((file, error))) => {
let shown = file.display().to_string();
let words = text(cx, "tree.error.open", &[("path", &shown), ("error", &error.to_string())]);
tree.toast(words, Severity::Danger, cx);
}
None => {}
}
})
.ok();
})
.detach();
}
/// Starts a repository in `dir`, then opens it.
pub fn init_at(&mut self, dir: PathBuf, cx: &mut Context<Self>) {
let Some(git) = self.git(cx) else { return };
let task = cx
.background_spawn(async move { repo::init(&git, &dir).map_err(|error| (dir, error)) });
cx.spawn(async move |this, cx| {
let made = task.await;
this.update(cx, |tree, cx| tree.add(made, cx)).ok();
})
.detach();
}
fn add(&mut self, opened: Result<Repo, (PathBuf, io::Error)>, cx: &mut Context<Self>) {
let repo = match opened {
Ok(repo) => repo,
Err((path, error)) => return self.not_opened(&path, &error, cx),
};
self.recent = match ely_files::recent::remember(&self.recent_file, &repo.root) {
Ok(list) => list,
Err(error) => {
self.failed("tree.error.recent", &error.to_string(), cx);
self.recent.clone()
}
};
if let Some(known) = self
.repos
.iter()
.find(|opened| opened.repo.root == repo.root)
{
let known = known.id;
return self.activate(Some(known), cx);
}
let id = self.next;
self.next += 1;
let watcher = match crate::watch::start(id, &repo, cx) {
Ok(watcher) => Some(watcher),
Err(error) => {
self.failed("tree.error.watch", &error.to_string(), cx);
None
}
};
self.repos.push(Opened {
id,
repo,
status: None,
reading: false,
again: false,
writes: VecDeque::new(),
writing: false,
hist: Hist::default(),
refs: None,
refs_asks: 0,
doing: None,
doing_asks: 0,
held: Default::default(),
me: None,
me_asks: 0,
places: Default::default(),
net: None,
_watcher: watcher,
});
self.activate(Some(id), cx);
self.read(id, cx);
self.read_me(id, cx);
}
/// A recent entry that no longer opens leaves the list.
fn not_opened(&mut self, path: &Path, error: &io::Error, cx: &mut Context<Self>) {
let shown = path.display().to_string();
if self.recent.iter().any(|known| known == path) {
self.recent.retain(|known| known != path);
if let Err(error) = ely_files::recent::forget(&self.recent_file, path) {
self.failed("tree.error.recent", &error.to_string(), cx);
}
let words = text(
cx,
"tree.error.recent_gone",
&[("path", &shown), ("error", &error.to_string())],
);
self.toast(words, Severity::Danger, cx);
} else {
let words = text(
cx,
"tree.error.open",
&[("path", &shown), ("error", &error.to_string())],
);
self.toast(words, Severity::Danger, cx);
}
cx.notify();
}
/// Reads the work tree; overlapping asks become one.
pub fn read(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.opened_mut(id) else {
return;
};
if opened.reading {
opened.again = true;
return;
}
opened.reading = true;
let root = opened.repo.root.clone();
let task = cx.background_spawn(async move { status::read(&git, &root) });
cx.spawn(async move |this, cx| {
let read = task.await.map_err(|error| error.to_string());
this.update(cx, |tree, cx| tree.read_done(id, read, cx))
.ok();
})
.detach();
}
fn read_done(&mut self, id: u64, read: Result<Status, String>, cx: &mut Context<Self>) {
let Some(opened) = self.opened_mut(id) else {
return;
};
if let Err(error) = &read {
log::warn!(
"tree: reading {} failed: {error}",
opened.repo.root.display()
);
}
opened.status = Some(read);
opened.reading = false;
if std::mem::take(&mut opened.again) {
self.read(id, cx);
}
if self.shown.as_ref().is_some_and(|shown| shown.repo == id) {
self.read_shown(cx);
}
self.refresh_log(id, cx);
// Settings may have moved with the files.
self.read_me(id, cx);
self.read_doing(id, cx);
if self.opened(id).is_some_and(|opened| opened.refs.is_some()) {
self.read_refs(id, cx);
}
if self
.opened(id)
.is_some_and(|opened| opened.places.read.is_some())
{
self.read_places(id, cx);
}
cx.notify();
}
pub fn close_active(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.active else { return };
self.close(id, cx);
}
/// Closes a tab; writes already queued finish first.
pub fn close(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(at) = self.repos.iter().position(|opened| opened.id == id) else {
return;
};
if self.repos[at].writing || !self.repos[at].writes.is_empty() {
let words = text(cx, "tree.busy", &[]);
return self.toast(words, Severity::Warning, cx);
}
let closed = self.repos.remove(at);
if self.shown.as_ref().is_some_and(|shown| shown.repo == id) {
self.shown = None;
}
if self.look.as_ref().is_some_and(|look| look.repo() == id) {
self.look = None;
}
log::info!("tree: closed {}", closed.repo.root.display());
if self.active == Some(id) {
// The closed box goes with its repository.
self.active = None;
let next = self
.repos
.get(at.min(self.repos.len().saturating_sub(1)))
.map(|opened| opened.id);
self.activate(next, cx);
}
cx.notify();
}
}
+265
View File
@@ -0,0 +1,265 @@
//! What the window draws.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::{EmptyState, ErrorView},
lists::{List, ListItem},
navigation::{EditorTab, EditorTabs},
primitives::IconName,
shell::{StatusBar, StatusBarItem},
};
use ely_platform::AppId;
use ely_ui::{Shell, commands};
use gpui::{
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div,
};
use crate::{
menu,
status::{Head, State},
tree::{Side, Tree, text},
};
fn name(path: &std::path::Path) -> String {
path.file_name().map_or_else(
|| path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
)
}
/// Where HEAD is, in words.
pub fn branch(head: &Head, cx: &App) -> SharedString {
match (&head.branch, &head.oid) {
(Some(branch), Some(_)) => branch.clone().into(),
(Some(branch), None) => text(cx, "tree.branch.unborn", &[("branch", branch)]),
(None, oid) => {
let short = oid.as_deref().map_or("", |oid| &oid[..oid.len().min(7)]);
text(cx, "tree.branch.detached", &[("oid", short)])
}
}
}
impl Tree {
fn tab_strip(&self, cx: &mut Context<Self>) -> Option<EditorTabs> {
if self.repos.is_empty() {
return None;
}
let tabs = self
.repos
.iter()
.map(|opened| EditorTab::new(opened.id.to_string(), name(&opened.repo.root)));
let (select, close) = (cx.weak_entity(), cx.weak_entity());
let strip = EditorTabs::new("tree-tabs", tabs.collect::<Vec<_>>())
.on_select(move |id, _, cx| {
let id = id.parse().expect("tab ids are numbers");
select
.update(cx, |tree, cx| tree.activate(Some(id), cx))
.ok();
})
.on_close(move |id, _, cx| {
let id = id.parse().expect("tab ids are numbers");
close.update(cx, |tree, cx| tree.close(id, cx)).ok();
});
Some(match self.active {
Some(id) => strip.selected(id.to_string()),
None => strip,
})
}
fn status_bar(&self, cx: &mut Context<Self>) -> StatusBar {
let mut bar = StatusBar::new();
if let Some(opened) = self.active() {
if let Some(Ok(status)) = &opened.status {
let head = &status.head;
bar = bar.left(
StatusBarItem::new("tree-branch")
.icon(IconName::GitBranch)
.label(branch(head, cx)),
);
if let Some(upstream) = &head.upstream {
let words = match head.counts {
Some((ahead, behind)) => text(
cx,
"tree.ahead_behind",
&[
("upstream", upstream),
("ahead", &ahead.to_string()),
("behind", &behind.to_string()),
],
),
None => text(cx, "tree.upstream_gone", &[("upstream", upstream)]),
};
bar = bar.left(StatusBarItem::new("tree-upstream").label(words));
}
}
bar = bar.left(
StatusBarItem::new("tree-path").label(opened.repo.root.display().to_string()),
);
}
if let Ok(git) = &self.git {
let key = if git.bundled {
"tree.git.bundled"
} else {
"tree.git.system"
};
bar = bar.right(StatusBarItem::new("tree-git").label(text(
cx,
key,
&[
("version", &git.version),
("path", &git.path.display().to_string()),
],
)));
}
bar
}
fn start_page(&self, cx: &mut Context<Self>) -> AnyElement {
let this = cx.weak_entity();
let button = |id: &'static str, key: &str, run: fn(&mut Window, &mut App)| {
Button::new(id, text(cx, key, &[])).on_click(move |_, window, cx| run(window, cx))
};
let empty = EmptyState::new(
"tree-start",
IconName::GitBranch,
text(cx, "tree.empty", &[]),
)
.action(
button("tree-open", "tree.open", |window, cx| {
window.dispatch_action(Box::new(commands::Open), cx)
})
.variant(ButtonVariant::Primary),
)
.action(button("tree-new", "tree.new", |window, cx| {
window.dispatch_action(Box::new(commands::New), cx)
}))
.action(button("tree-clone", "tree.clone", |window, cx| {
window.dispatch_action(Box::new(menu::CloneRepository), cx)
}));
let recent = self.recent.iter().enumerate().map(|(at, path)| {
let (this, path) = (this.clone(), path.clone());
ListItem::new(("tree-recent", at), name(&path))
.description(path.display().to_string())
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.open_path(path.clone(), cx))
.ok();
})
.into_any_element()
});
div()
.flex()
.flex_col()
.items_center()
.child(empty)
.child(div().w_96().child(List::new().children(recent)))
.into_any_element()
}
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
if let Err(error) = &self.git {
return ErrorView::new(
"tree-no-git",
IconName::TriangleAlert,
text(cx, "tree.no_git", &[]),
)
.detail(error.clone())
.into_any_element();
}
if let Some(cloning) = &self.cloning {
let this = cx.weak_entity();
let stop = Button::new("tree-clone-stop", text(cx, "tree.clone.stop", &[]))
.disabled(cloning.stopping)
.on_click(move |_, _, cx| {
this.update(cx, |tree, cx| tree.stop_clone(cx)).ok();
});
let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]);
return EmptyState::new("tree-cloning", IconName::Download, title)
.body(cloning.said.clone())
.action(stop)
.into_any_element();
}
let Some(opened) = self.active() else {
return self.start_page(cx);
};
if let Some(look) = self.look.as_ref().filter(|look| look.repo() == opened.id) {
return self.look_pane(look, cx);
}
match self.side {
Side::History => return self.commit_pane(&opened.hist, cx),
Side::Branches => return self.refs_pane(cx),
Side::Places => return self.places_pane(cx),
Side::Changes => {}
}
if let Some(shown) = self.shown.as_ref().filter(|shown| shown.repo == opened.id) {
return self.pane(shown, cx);
}
match &opened.status {
None => div().into_any_element(),
Some(Err(error)) => ErrorView::new(
"tree-unread",
IconName::TriangleAlert,
text(cx, "tree.unreadable", &[]),
)
.detail(error.clone())
.into_any_element(),
Some(Ok(status)) => {
let changed = status
.entries
.iter()
.filter(|entry| entry.state != State::Ignored)
.count();
let (icon, title) = match changed {
0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])),
n => (
IconName::GitBranch,
text(cx, "tree.changed", &[("n", &n.to_string())]),
),
};
EmptyState::new(("tree-summary", opened.id), icon, title).into_any_element()
}
}
}
}
impl Render for Tree {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let title: SharedString = self.active().map_or_else(
|| AppId::Tree.name().into(),
|opened| name(&opened.repo.root).into(),
);
let left = self
.active()
.filter(|opened| matches!(opened.status, Some(Ok(_))))
.map(|_| self.changes.clone().into());
let mut shell = Shell::new(title, &self.focus)
.left(left)
.status(self.status_bar(cx))
.children(self.net_alert(cx))
.children(self.op_alert(cx))
.child(self.body(cx));
if let Some(strip) = self.tab_strip(cx) {
shell = shell.tabs(strip);
}
div()
.size_full()
.key_context(menu::CONTEXT)
.track_focus(&self.focus)
.on_action(cx.listener(Tree::on_open))
.on_action(cx.listener(Tree::on_new))
.on_action(cx.listener(Tree::on_clone))
.on_action(cx.listener(Tree::on_close))
.on_action(cx.listener(|tree, _: &menu::Fetch, _, cx| tree.fetch_all(cx)))
.on_action(cx.listener(|tree, _: &menu::Pull, _, cx| tree.pull(cx)))
.on_action(cx.listener(|tree, _: &menu::Push, _, cx| tree.push(cx)))
.on_action(cx.listener(|tree, _: &menu::ForcePush, _, cx| tree.ask_force(cx)))
.on_action(cx.listener(|tree, _: &menu::OpenTerminal, _, cx| tree.open_terminal(cx)))
.on_action(cx.listener(|tree, _: &menu::Reveal, _, cx| tree.reveal(cx)))
.on_action(cx.listener(|tree, _: &menu::CleanUntracked, _, cx| tree.ask_clean(cx)))
.on_action(
cx.listener(|tree, _: &menu::ChooseEditor, _, cx| tree.pick_editor(None, cx)),
)
.child(shell)
.children(self.dialog(cx))
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Rereads after outside changes (TREE-02, §13.3).
use std::time::Duration;
use futures::{StreamExt as _, channel::mpsc};
use gpui::Context;
use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
use crate::{repo::Repo, tree::Tree};
/// Bursts, such as a checkout, settle into one read.
const SETTLE: Duration = Duration::from_millis(250);
/// Watches work tree and Git directory until dropped.
pub fn start(id: u64, repo: &Repo, cx: &mut Context<Tree>) -> notify::Result<RecommendedWatcher> {
let (sender, mut changes) = mpsc::unbounded();
let root = repo.root.display().to_string();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| match event {
Ok(_) => {
let _ = sender.unbounded_send(());
}
Err(error) => log::warn!("tree: watching {root} failed: {error}"),
})?;
let mut watched = vec![repo.root.clone()];
// A linked worktree's Git directories lie elsewhere.
for dir in [&repo.common_dir, &repo.git_dir] {
if !watched.iter().any(|top| dir.starts_with(top)) {
watched.push(dir.clone());
}
}
for dir in &watched {
watcher.watch(dir, RecursiveMode::Recursive)?;
}
cx.spawn(async move |this, cx| {
while changes.next().await.is_some() {
cx.background_executor().timer(SETTLE).await;
while changes.try_recv().is_ok() {}
if this.update(cx, |tree, cx| tree.read(id, cx)).is_err() {
break;
}
}
})
.detach();
log::info!("tree: watching {}", repo.root.display());
Ok(watcher)
}
+485
View File
@@ -0,0 +1,485 @@
//! Stage and discard, one write at a time (§13.3).
use std::{io, sync::Arc};
use ely_gpui_component::{
git::{ChangeAction, ChangeSection},
primitives::Severity,
};
use gpui::{AppContext as _, Context, SharedString};
use crate::{
asks::Ask,
git::Git,
ops, recover,
status::{Entry, State},
tree::{Tree, text},
};
/// A write to the index or the work tree.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Write {
Stage(Entry),
Unstage(Entry),
Discard(Entry),
StageAll,
UnstageAll,
/// The staged changes, or amending the last commit.
Commit {
message: String,
amend: bool,
},
/// A merge result into its work file, between Git's writes.
Save {
path: String,
text: String,
},
/// A branch, tag, history or conflict command, as given.
Git(Vec<String>),
/// Talks to a remote: progress shows, and it can stop.
Remote(Vec<String>),
/// `reset --keep to`, once HEAD is still where shown.
Move {
branch: Option<String>,
head: String,
to: String,
},
/// The shown files, then folders left empty.
Clean {
files: Vec<String>,
dirs: Vec<String>,
ignored: bool,
},
/// `stash <verb> stash@{at}`, once it is still `oid`.
Stash {
verb: &'static str,
at: usize,
oid: String,
},
/// `git config` for this repository, or for the user.
Config {
key: String,
value: String,
global: bool,
},
/// Picked lines; `check` rereads the diff they were cut from.
Patch {
patch: String,
reverse: bool,
check: Vec<String>,
seen: Arc<Vec<u8>>,
},
}
impl Write {
/// Git's arguments; `born` is false before the first commit.
pub fn args(&self, born: bool) -> Vec<String> {
// Unstaging a rename restores its old path too.
let paths = |entry: &Entry, both: bool| {
let mut paths = vec!["--".to_string(), entry.path.clone()];
paths.extend(entry.from.clone().filter(|_| both));
paths
};
let mut args: Vec<String> = match (self, born) {
(Write::Stage(_), _) => vec!["add".into(), "--all".into()],
(Write::Unstage(_), true) => vec!["restore".into(), "--staged".into()],
// `-f`: the index may differ from the file.
(Write::Unstage(_), false) => vec![
"rm".into(),
"--cached".into(),
"-f".into(),
"-r".into(),
"-q".into(),
],
(
Write::Discard(Entry {
state: State::Untracked,
..
}),
_,
) => vec!["clean".into(), "-f".into(), "-d".into(), "-q".into()],
(Write::Discard(_), _) => vec!["restore".into(), "--worktree".into()],
(Write::StageAll, _) => vec!["add".into(), "--update".into()],
(Write::UnstageAll, true) => vec!["reset".into(), "-q".into()],
(Write::UnstageAll, false) => vec![
"rm".into(),
"--cached".into(),
"-f".into(),
"-r".into(),
"-q".into(),
"--".into(),
".".into(),
],
(Write::Git(args) | Write::Remote(args), _) => args.clone(),
(Write::Move { to, .. }, _) => vec!["reset".into(), "--keep".into(), to.clone()],
// Files only: Git keeps tracked ones and stops at links.
(Write::Clean { files, ignored, .. }, _) => {
let mut args = vec!["clean".into(), "-f".into()];
args.extend(ignored.then(|| "-x".into()));
args.push("--".into());
args.extend(files.iter().cloned());
args
}
(Write::Stash { verb, at, .. }, _) => {
vec!["stash".into(), (*verb).into(), format!("stash@{{{at}}}")]
}
(Write::Save { .. }, _) => Vec::new(),
(Write::Commit { amend, .. }, _) => {
// `whitespace`: lines starting `#` stay, whatever the config.
let mut args = vec![
"commit".into(),
"--cleanup=whitespace".into(),
"-F".into(),
"-".into(),
];
args.extend(amend.then(|| "--amend".into()));
args
}
(Write::Config { key, value, global }, _) => {
let mut args = vec!["config".into()];
args.extend(global.then(|| "--global".into()));
args.extend([key.clone(), value.clone()]);
args
}
(Write::Patch { reverse, .. }, _) => {
// `nowarn`: the picked bytes go in as they are.
let mut args = vec![
"apply".into(),
"--cached".into(),
"--whitespace=nowarn".into(),
];
args.extend(reverse.then(|| "--reverse".into()));
args
}
};
match self {
Write::Unstage(entry) => args.extend(paths(entry, true)),
Write::Stage(entry) | Write::Discard(entry) => args.extend(paths(entry, false)),
Write::StageAll
| Write::UnstageAll
| Write::Commit { .. }
| Write::Git(_)
| Write::Remote(_)
| Write::Stash { .. }
| Write::Move { .. }
| Write::Clean { .. }
| Write::Save { .. }
| Write::Config { .. }
| Write::Patch { .. } => {}
}
args
}
}
fn strs(args: &[String]) -> Vec<&str> {
args.iter().map(String::as_str).collect()
}
pub(crate) fn run(
git: &Git,
root: &std::path::Path,
write: &Write,
args: &[String],
) -> io::Result<()> {
match write {
Write::Save { path, text } => {
let file = root.join(path);
// A link here now would carry the text elsewhere.
if std::fs::symlink_metadata(&file).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(io::Error::other(format!(
"{path} is now a link; nothing was saved"
)));
}
std::fs::write(&file, text)
}
// The message goes in on stdin, never through a shell.
Write::Commit { message, .. } => git
.run_input(root, &strs(args), message.as_bytes())
.map(drop),
Write::Patch {
patch, check, seen, ..
} => {
// A patch from an older diff is refused.
if git.run(root, &strs(check))? != **seen {
return Err(io::Error::other(
"the file changed since it was shown; nothing was staged",
));
}
git.run_input(root, &strs(args), patch.as_bytes()).map(drop)
}
Write::Stash { at, oid, .. } => {
// Another stash since shifts every number.
let now = git.run_or_one(
root,
&["rev-parse", "-q", "--verify", &format!("stash@{{{at}}}")],
)?;
if String::from_utf8_lossy(&now).trim() != oid {
return Err(io::Error::other(
"the stash list changed since it was shown; nothing was done",
));
}
git.run(root, &strs(args)).map(drop)
}
Write::Move { branch, head, .. } => {
if recover::head_now(git, root)? != (branch.clone(), head.clone()) {
return Err(io::Error::other(
"HEAD moved since the preview; nothing was done",
));
}
recover::nothing_staged(git, root)?;
git.run(root, &strs(args)).map(drop)
}
Write::Clean { files, dirs, .. } => {
for file in files {
as_shown(root, file, false)?;
}
for dir in dirs {
as_shown(root, dir, true)?;
}
if !files.is_empty() {
git.run(root, &strs(args))?;
}
// A folder that gained a file since stays.
for dir in dirs {
match std::fs::remove_dir(root.join(dir)) {
Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => {
log::info!("tree: {dir} kept; it is not empty");
}
Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
_ => {}
}
}
Ok(())
}
Write::Remote(_) => git.start(root, &strs(args))?.follow(|_| {}).map(drop),
_ => git.run(root, &strs(args)).map(drop),
}
}
/// A shown path: same kind, reached through no link.
fn as_shown(root: &std::path::Path, path: &str, folder: bool) -> io::Result<()> {
let changed = || {
io::Error::other(format!(
"{path} changed since the preview; nothing was removed"
))
};
let mut at = root.to_path_buf();
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
for (ix, part) in parts.iter().enumerate() {
at.push(part);
let kind = std::fs::symlink_metadata(&at)?.file_type();
let last = ix + 1 == parts.len();
if (!last && !kind.is_dir()) || (last && kind.is_dir() != folder) {
return Err(changed());
}
}
Ok(())
}
/// Git found another process's lock file in place.
fn locked(said: &str) -> bool {
said.contains("File exists") && (said.contains(".lock") || said.contains("could not lock"))
}
impl Tree {
/// A press on a file in the changes list.
pub fn file_action(
&mut self,
path: &SharedString,
section: ChangeSection,
action: ChangeAction,
cx: &mut Context<Self>,
) {
let Some(opened) = self.active() else { return };
let id = opened.id;
let Some(Ok(status)) = &opened.status else {
return;
};
let Some(entry) = status
.entries
.iter()
.find(|entry| entry.path == path.as_ref())
.cloned()
else {
return log::warn!("tree: {path} is no longer listed");
};
match action {
ChangeAction::Open if section == ChangeSection::Ignored => {
self.open_file(&entry.path, cx)
}
ChangeAction::Open => self.show(&entry.path, section, cx),
ChangeAction::Stage => self.write(id, Write::Stage(entry), cx),
ChangeAction::Unstage => self.write(id, Write::Unstage(entry), cx),
// Each discard asks; one yes is not a standing one.
ChangeAction::Discard => {
self.ask = Some(Ask::Discard { repo: id, entry });
cx.notify();
}
}
}
/// Opens a repository file with its own app.
pub fn open_file(&self, path: &str, cx: &mut Context<Self>) {
let Some(opened) = self.active() else { return };
let file = opened.repo.root.join(path);
if file.exists() {
cx.open_with_system(&file);
} else {
let words = text(cx, "tree.gone", &[("path", path)]);
self.toast(words, Severity::Warning, cx);
}
}
/// Queues `write` behind the repository's other writes.
pub fn write(&mut self, id: u64, write: Write, cx: &mut Context<Self>) {
let Some(opened) = self.opened_mut(id) else {
return;
};
opened.writes.push_back(write);
self.next_write(id, cx);
}
fn next_write(&mut self, id: u64, cx: &mut Context<Self>) {
let Some(git) = self.git.as_ref().ok().cloned() else {
return;
};
let Some(opened) = self.opened_mut(id).filter(|opened| !opened.writing) else {
return;
};
let Some(write) = opened.writes.pop_front() else {
return;
};
let born = matches!(&opened.status, Some(Ok(status)) if status.head.oid.is_some());
opened.writing = true;
if let Write::Remote(args) = write {
return self.start_remote(id, args, cx);
}
let (root, args) = (opened.repo.root.clone(), write.args(born));
log::info!("tree: {:?} in {}", args.first(), root.display());
let task = cx.background_spawn(async move {
let done = run(&git, &root, &write, &args);
let stopped = match (&write, &done) {
(Write::Git(args), Err(error)) => {
ops::stopped(&git, &root, args, &error.to_string())
}
_ => false,
};
(write, done, stopped)
});
cx.spawn(async move |this, cx| {
let (write, done, stopped) = task.await;
this.update(cx, |tree, cx| tree.written(id, &write, done, stopped, cx))
.ok();
})
.detach();
}
pub(crate) fn written(
&mut self,
id: u64,
write: &Write,
done: io::Result<()>,
stopped: bool,
cx: &mut Context<Self>,
) {
let Some(opened) = self.opened_mut(id) else {
return;
};
opened.writing = false;
if done.is_err() {
// Later writes counted on this one.
opened.writes.clear();
}
match (write, done) {
// A held lock waits for a retry; Ely deletes none.
(_, Err(error)) if locked(&error.to_string()) => {
self.ask = Some(Ask::Locked {
repo: id,
write: write.clone(),
detail: error.to_string().into(),
});
cx.notify();
}
(Write::Commit { message, .. }, Ok(())) => self.committed(id, message, cx),
// Hooks and signing speak at length; show all of it.
(Write::Commit { .. }, Err(error)) => {
self.ask = Some(Ask::Failed {
title: text(cx, "tree.commit.failed", &[]),
detail: error.to_string().into(),
});
cx.notify();
}
(Write::Config { .. }, Ok(())) => self.read_me(id, cx),
(Write::Save { path, text }, Ok(())) => self.merge_saved(id, path, text, cx),
// A conflict stop is no failure; Git says which.
(
Write::Git(_)
| Write::Remote(_)
| Write::Stash { .. }
| Write::Move { .. }
| Write::Clean { .. },
Err(error),
) => self.git_failed(stopped, error.to_string(), cx),
(_, Ok(())) => {}
(_, Err(error)) => self.failed("tree.error.write", &error.to_string(), cx),
}
self.read(id, cx);
self.next_write(id, cx);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(path: &str, from: Option<&str>, state: State) -> Entry {
Entry {
path: path.into(),
from: from.map(Into::into),
state,
}
}
#[test]
fn each_write_names_its_paths_after_the_separator() {
let renamed = entry(
"new.txt",
Some("old.txt"),
State::Tracked {
staged: 'R',
unstaged: '.',
},
);
assert_eq!(
Write::Unstage(renamed.clone()).args(true),
["restore", "--staged", "--", "new.txt", "old.txt"]
);
assert_eq!(
Write::Discard(renamed).args(true),
["restore", "--worktree", "--", "new.txt"],
"the old path is gone"
);
let untracked = entry("-n", None, State::Untracked);
assert_eq!(
Write::Discard(untracked).args(true),
["clean", "-f", "-d", "-q", "--", "-n"]
);
let changed = entry(
"a.txt",
None,
State::Tracked {
staged: '.',
unstaged: 'M',
},
);
assert_eq!(
Write::Discard(changed.clone()).args(true),
["restore", "--worktree", "--", "a.txt"]
);
assert_eq!(
Write::Unstage(changed).args(false),
["rm", "--cached", "-f", "-r", "-q", "--", "a.txt"]
);
assert_eq!(Write::UnstageAll.args(true), ["reset", "-q"]);
}
}