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:
817 files changed
+143101
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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])?)
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
)?)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"), ©)
|
||||
.expect("starts")
|
||||
.follow(|_| {})
|
||||
.expect("clones");
|
||||
let list = structure::submodules(&git(), ©).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(©, &update);
|
||||
let list = structure::submodules(&git(), ©).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(), ©).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);
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user