//! 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>, /// 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, pub writing: bool, pub hist: Hist, pub refs: Option, String>>, pub refs_asks: u64, /// A merge, rebase, pick or revert under way. pub doing: Option, 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>, /// Identity reads started; only the latest lands. pub me_asks: u64, pub places: crate::places::Places, /// The branch or tag picked in the Branches panel. pub picked_ref: Option<(crate::branches::Kind, String)>, pub picked_place: Option, pub net: Option, /// Kept alive; dropping it stops the watch. _watcher: Option, } /// What the left panel lists. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Side { Changes, History, Branches, Places, } pub struct Tree { pub git: Result, pub repos: Vec, pub active: Option, next: u64, pub recent: Vec, /// Why the recent list could not be read; shown once. unread: Option, recent_file: PathBuf, pub tools: crate::tools::Tools, pub cloning: Option, pub ask: Option, pub focus: FocusHandle, pub root: WeakEntity, pub changes: Entity, pub shown: Option, 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, pub subject: Entity, pub body: Entity, pub amend: bool, /// The message typed before amend filled in the last one. pub draft: Option<(String, String)>, pub search: Entity, 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 conflicted file being merged by hand. pub merging: Option, pub merge_editor: Entity, _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 { 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 { let name = file.file_name().ok_or_else(|| io::Error::other("a path without a name"))?; let dir = dunce::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 { 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, 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 { 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) { 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) { 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, cx: &mut Context) { 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(), picked_ref: None, picked_place: None, 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) { 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) { 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, cx: &mut Context) { 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) { 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) { 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(); } }