diff --git a/AGENTS.md b/AGENTS.md index 204edca..2235eb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,5 +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: 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 off the main thread, each after the one asked before it; 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 folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, in the same repository: a draft typed while it ran stays. 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/panel.rs b/src/git/panel.rs index bf206b3..d3c225f 100644 --- a/src/git/panel.rs +++ b/src/git/panel.rs @@ -1,7 +1,6 @@ use std::{ path::{Path, PathBuf}, rc::Rc, - sync::{Arc, Mutex}, }; use ely_gpui_component::{ @@ -52,6 +51,8 @@ fn listed(repo: &Repo, section: ChangeSection) -> &[Change] { pub struct GitPanel { folder: PathBuf, state: State, + /// Where `state` was read: an action waits until it is `folder`. + read: PathBuf, /// The last action's failure: what failed, and what git said. error: Option<(&'static str, SharedString)>, subject: Entity, @@ -60,10 +61,10 @@ pub struct GitPanel { diff: Option, layout: DiffLayout, discarding: Option<(Change, ChangeSection)>, - /// One action at a time, in the order asked. - turn: Arc>, reading: Task<()>, opening: Task<()>, + /// The last action asked; each waits for the one before it. + acting: Task<()>, } impl EventEmitter for GitPanel {} @@ -77,6 +78,7 @@ impl GitPanel { .placeholder("Description") }); Self { + read: folder.clone(), folder, state: State::Reading, error: None, @@ -86,9 +88,9 @@ impl GitPanel { diff: None, layout: DiffLayout::Unified, discarding: None, - turn: Arc::default(), reading: Task::ready(()), opening: Task::ready(()), + acting: Task::ready(()), } } @@ -96,12 +98,33 @@ impl GitPanel { pub fn show(&mut self, folder: &Path, cx: &mut Context) { if folder != self.folder { log::info!("git panel: shows {}", folder.display()); + // Inside the shown work tree the view stays while it reads, so a cd does not blank it. + let inside = + matches!(&self.state, State::Read(repo, _) if folder.starts_with(&repo.top)); + if !inside { + self.state = State::Reading; + } self.folder = folder.to_path_buf(); (self.error, self.diff, self.discarding) = (None, None, None); + self.opening = Task::ready(()); + cx.notify(); } self.read(cx); } + /// The repository shown, once it was read in the shown folder. + fn current(&self) -> Option<&Repo> { + match &self.state { + State::Read(repo, _) if self.read == self.folder => Some(repo), + _ => None, + } + } + + fn waits(&self, asked: &str) { + let folder = self.folder.display(); + log::info!("git panel: {asked} waits for {folder} to read; nothing done"); + } + /// Reads the repository and its recent commits, off the main thread. fn read(&mut self, cx: &mut Context) { let folder = self.folder.clone(); @@ -132,6 +155,7 @@ impl GitPanel { State::Failed(format!("{error:#}").into()) } }; + self.read = self.folder.clone(); if state == self.state { return; } @@ -153,7 +177,7 @@ impl GitPanel { 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. + /// Runs `work` on the repository at `top` off the main thread once the action before it is done; on success `then`; after it, a fresh read and word to other views. fn act( &mut self, what: &'static str, @@ -163,13 +187,10 @@ impl GitPanel { 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 before = std::mem::replace(&mut self.acting, Task::ready(())); + self.acting = cx.spawn(async move |panel, cx| { + before.await; + let done = cx.background_spawn(async move { work(&top) }).await; let acted = |panel: &mut Self, cx: &mut Context| { match done { Ok(()) => then(panel, cx), @@ -183,8 +204,7 @@ impl GitPanel { cx.notify(); }; panel.update(cx, acted).ok(); - }) - .detach(); + }); cx.notify(); } @@ -196,8 +216,8 @@ impl GitPanel { action: ChangeAction, cx: &mut Context, ) { - let State::Read(repo, _) = &self.state else { - return; + let Some(repo) = self.current() else { + return self.waits(&format!("{action:?} {row}")); }; let found = listed(repo, section) .iter() @@ -226,8 +246,8 @@ impl GitPanel { /// 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 Some(repo) = self.current() else { + return self.waits(if stage { "Stage all" } else { "Unstage all" }); }; let top = repo.top.clone(); if stage { @@ -242,8 +262,8 @@ impl GitPanel { } fn discard(&mut self, change: Change, section: ChangeSection, cx: &mut Context) { - let State::Read(repo, _) = &self.state else { - return; + let Some(repo) = self.current() else { + return self.waits(&format!("Discard {}", change.path.display())); }; let top = repo.top.clone(); log::info!("git panel: discarding {} confirmed", change.path.display()); @@ -251,24 +271,34 @@ impl GitPanel { self.act("Discard failed", top, work, |_, _| {}, cx); } - /// Commits what is staged with the typed message; on success the fields empty. + /// Commits what is staged with the typed message; on success the fields that still hold it empty. fn commit(&mut self, cx: &mut Context) { - let State::Read(repo, _) = &self.state else { - return; + let Some(repo) = self.current() else { + return self.waits("Commit"); }; 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 subject = self.subject.read(cx).text().to_string(); + let body = self.body.read(cx).text().to_string(); + let message = match body.trim() { + "" => subject.trim().to_string(), + body => format!("{}\n\n{body}", subject.trim()), }; 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)); + let committed = top.clone(); + // A draft typed, or a box ticked, while it ran stays, as does all in another repository. + let then = move |panel: &mut Self, cx: &mut Context| { + if !matches!(&panel.state, State::Read(repo, _) if repo.top == committed) { + return; + } + for (input, text) in [(&panel.subject, subject), (&panel.body, body)] { + if input.read(cx).text() == text { + input.update(cx, |input, cx| input.set_text("", cx)); + } + } + if panel.amend == amend { + panel.amend = false; + } }; self.act("Commit failed", top, work, then, cx); } @@ -284,6 +314,7 @@ impl GitPanel { self.error = None; // Git keeps an untracked folder whole, so it has no lines. if section == ChangeSection::Untracked && is_folder(&change.path) { + self.opening = Task::ready(()); let lines = Err("An untracked folder has no diff."); return self.shows( Diff {