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