T15: no action runs until the shown folder has read, so none reaches the repository left behind, and the view blanks on a folder outside the shown work tree; a folder change drops a diff still loading; actions run each after the one asked before it; a commit empties only the fields that still hold what it committed, so a draft typed while it ran stays
This commit is contained in:
2 files changed
+64
-33
No files matched your search
@@ -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.
|
||||
+63
-32
@@ -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<TextInput>,
|
||||
@@ -60,10 +61,10 @@ pub struct GitPanel {
|
||||
diff: Option<Diff>,
|
||||
layout: DiffLayout,
|
||||
discarding: Option<(Change, ChangeSection)>,
|
||||
/// One action at a time, in the order asked.
|
||||
turn: Arc<Mutex<()>>,
|
||||
reading: Task<()>,
|
||||
opening: Task<()>,
|
||||
/// The last action asked; each waits for the one before it.
|
||||
acting: Task<()>,
|
||||
}
|
||||
|
||||
impl EventEmitter<Acted> 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<Self>) {
|
||||
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<Self>) {
|
||||
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>,
|
||||
) {
|
||||
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<Self>| {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>| {
|
||||
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<Self>| {
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user