From 8369d96b36f464176346bf5661de1acf8414a7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 19:09:21 -0400 Subject: [PATCH] T13: the right column's Git and Files switch, kept in settings; Files shows the shown session's folder as a tree, folders first, Git's marks on names from git status without optional locks, unreadable folders noted; a double press opens a folder, or a file in the system's app; it reloads when the session reports a folder, so at each prompt; a program without shell integration gets a line saying it reports no folder; header controls keep their presses from the header's double-press zoom --- AGENTS.md | 3 +- examples/shot.rs | 1 + src/app.rs | 2 +- src/files.rs | 246 +++++++++++++++++++++++++++++++++++++++++++++ src/files/tests.rs | 135 +++++++++++++++++++++++++ src/git.rs | 218 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/sessions.rs | 2 + src/term/pty.rs | 6 ++ src/term/shell.rs | 6 +- src/workspace.rs | 106 ++++++++++++++----- 11 files changed, 700 insertions(+), 27 deletions(-) create mode 100644 src/files.rs create mode 100644 src/files/tests.rs create mode 100644 src/git.rs diff --git a/AGENTS.md b/AGENTS.md index 11cd2f3..a8fc92d 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 title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. +- `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. - `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. @@ -61,4 +61,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title and sends only `CursorBlinkingChange`, in the parser's order, synchronized updates too; on that event the view replays the title through `set_options`, which sends it. - 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings. - 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=v1 -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. 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: settings refuse unknown fields; a bad settings file stops the start and is never overwritten. diff --git a/examples/shot.rs b/examples/shot.rs index 6819514..d0cd8bb 100644 --- a/examples/shot.rs +++ b/examples/shot.rs @@ -23,6 +23,7 @@ fn main() { program: Some((program, args.collect())), cwd: std::env::current_dir().expect("a current folder"), env: Vec::new(), + reports: false, }; gpui_platform::application() .with_assets(Assets) diff --git a/src/app.rs b/src/app.rs index 0c768db..a58fd23 100644 --- a/src/app.rs +++ b/src/app.rs @@ -81,7 +81,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result, String>; + +/// Reads `folder`: folders first, then files, each by name ignoring case. +fn list(folder: &Path) -> Listing { + let read = || -> io::Result> { + fs::read_dir(folder)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok(Entry { + folder: kind.is_dir() || (kind.is_symlink() && entry.path().is_dir()), + name: entry.file_name().to_string_lossy().into_owned(), + }) + }) + .collect() + }; + let mut entries = read().map_err(|error| error.to_string())?; + entries + .sort_by_cached_key(|entry| (!entry.folder, entry.name.to_lowercase(), entry.name.clone())); + Ok(entries) +} + +/// Changes by path, and the folders from `root` down that hold one. +fn marks( + root: &Path, + changes: Vec<(PathBuf, GitStatus)>, +) -> (HashMap, HashSet) { + let dirty = changes + .iter() + .flat_map(|(path, _)| { + let up = path.ancestors().skip(1); + up.take_while(|folder| folder.starts_with(root)) + }) + .map(Path::to_path_buf) + .collect(); + (changes.into_iter().collect(), dirty) +} + +/// Git's mark on a node: its letter and tint; a dot on a folder that holds changes. +fn badge(node: TreeNode, mark: Option, dirty: bool) -> TreeNode { + let (note, tone) = match mark { + Some(GitStatus::Modified) => ("M", Tone::Warning), + Some(GitStatus::Added) => ("A", Tone::Success), + Some(GitStatus::Deleted) => ("D", Tone::Danger), + Some(GitStatus::Untracked) => ("U", Tone::Success), + Some(GitStatus::Renamed) => ("R", Tone::Info), + Some(GitStatus::Conflicted) => ("!", Tone::Danger), + None if dirty => ("•", Tone::Warning), + None => return node, + }; + node.note(note).tone(tone) +} + +fn key(path: &Path) -> SharedString { + path.to_string_lossy().into_owned().into() +} + +/// The Files panel: the shown session's folder as a tree, Git's marks on names. +pub struct Files { + root: PathBuf, + /// Counts roots, so each root starts a fresh tree. + generation: usize, + listings: HashMap, + /// The root and the folders the tree opened. + wanted: HashSet, + marks: HashMap, + /// Folders holding a mark. + dirty: HashSet, + reading: Task<()>, + checking: Task<()>, +} + +impl Files { + pub fn new(root: PathBuf) -> Self { + Self { + wanted: HashSet::from([root.clone()]), + root, + generation: 0, + listings: HashMap::new(), + marks: HashMap::new(), + dirty: HashSet::new(), + reading: Task::ready(()), + checking: Task::ready(()), + } + } + + /// Shows `folder`, read afresh with its marks; a new folder starts a new tree. + pub fn show(&mut self, folder: &Path, cx: &mut Context) { + if folder != self.root { + log::info!("files: the tree shows {}", folder.display()); + *self = Self { + generation: self.generation + 1, + ..Self::new(folder.to_path_buf()) + }; + } + self.read(cx); + let root = self.root.clone(); + let changes = cx.background_spawn(async move { git::changes(&root) }); + self.checking = cx.spawn(async move |files, cx| { + let changes = changes.await; + files.update(cx, |files, cx| files.mark(changes, cx)).ok(); + }); + } + + fn mark( + &mut self, + changes: anyhow::Result>>, + cx: &mut Context, + ) { + let changes = match changes { + Ok(changes) => changes.unwrap_or_default(), + Err(error) => { + log::error!("files: no Git marks in {}: {error:#}", self.root.display()); + Vec::new() + } + }; + let (marks, dirty) = marks(&self.root, changes); + if marks != self.marks { + log::debug!( + "files: {} Git marks in {}", + marks.len(), + self.root.display() + ); + } + (self.marks, self.dirty) = (marks, dirty); + cx.notify(); + } + + /// Reads the root and every folder the tree opened, off the main thread. + fn read(&mut self, cx: &mut Context) { + let folders: Vec = self.wanted.iter().cloned().collect(); + let read = cx.background_spawn(async move { + let read = folders.into_iter().map(|folder| (list(&folder), folder)); + read.collect::>() + }); + self.reading = cx.spawn(async move |files, cx| { + let read = read.await; + files.update(cx, |files, cx| files.store(read, cx)).ok(); + }); + } + + fn store(&mut self, read: Vec<(Listing, PathBuf)>, cx: &mut Context) { + let mut changed = false; + for (listing, folder) in read { + if self.listings.get(&folder) == Some(&listing) { + continue; + } + match &listing { + Ok(entries) => { + log::debug!("files: {} holds {}", folder.display(), entries.len()) + } + Err(error) => log::warn!("files: {} is unreadable: {error}", folder.display()), + } + self.listings.insert(folder, listing); + changed = true; + } + if changed { + cx.notify(); + } + } + + /// The tree opened `folder`, unread so far. + fn load(&mut self, folder: PathBuf, cx: &mut Context) { + if self.wanted.insert(folder) { + self.read(cx); + } + } + + /// Folder `path`'s node, its entries once read; `untracked` when an untracked folder holds it. + fn folder(&self, path: &Path, label: String, untracked: bool) -> TreeNode { + let own = |path: &Path| self.marks.get(path).copied(); + let mark = own(path).or(untracked.then_some(GitStatus::Untracked)); + let inside = mark == Some(GitStatus::Untracked); + let node = TreeNode::new(key(path), label).icon(IconName::Folder); + let node = match self.listings.get(path) { + None => node.pending(), + Some(Err(_)) => return node.children([]).note("unreadable").tone(Tone::Danger), + Some(Ok(entries)) => node.children(entries.iter().map(|entry| { + let path = path.join(&entry.name); + if entry.folder { + return self.folder(&path, entry.name.clone(), inside); + } + let file = TreeNode::new(key(&path), entry.name.clone()).icon(IconName::File); + badge( + file, + own(&path).or(inside.then_some(GitStatus::Untracked)), + false, + ) + })), + }; + badge(node, mark, self.dirty.contains(path)) + } +} + +impl Render for Files { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let home = dirs::home_dir(); + let label = match self.root.file_name() { + Some(name) if home.as_deref() != Some(self.root.as_path()) => { + name.to_string_lossy().into() + } + _ => sessions::shown(&self.root, home.as_deref()), + }; + let files = cx.entity(); + Tree::new( + ("files", self.generation), + [self.folder(&self.root, label, false)], + ) + .open([key(&self.root)]) + .on_load(move |key, _, cx| { + let folder = PathBuf::from(key.as_ref()); + files.update(cx, |files, cx| files.load(folder, cx)) + }) + .on_activate(|key, _, cx| { + log::info!("files: opening {key}"); + cx.open_with_system(Path::new(key.as_ref())) + }) + .size_full() + } +} + +#[cfg(test)] +mod tests; diff --git a/src/files/tests.rs b/src/files/tests.rs new file mode 100644 index 0000000..1f7d744 --- /dev/null +++ b/src/files/tests.rs @@ -0,0 +1,135 @@ +use std::env; + +use super::*; + +/// A fresh folder under the temp dir. +fn folder(name: &str) -> PathBuf { + let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id())); + if folder.exists() { + fs::remove_dir_all(&folder).expect("a stale test folder goes"); + } + fs::create_dir_all(&folder).expect("a test folder"); + folder +} + +#[test] +fn folders_come_first_then_files_by_name_ignoring_case() { + let root = folder("files-list"); + for name in ["Zeta", "alpha"] { + fs::create_dir(root.join(name)).expect("a folder"); + } + for name in ["c.txt", "B.txt", "a.txt"] { + fs::write(root.join(name), "").expect("a file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + symlink(root.join("Zeta"), root.join("link")).expect("a link to a folder"); + symlink(root.join("nowhere"), root.join("broken")).expect("a broken link"); + } + let expected: &[(&str, bool)] = if cfg!(unix) { + &[ + ("alpha", true), + ("link", true), + ("Zeta", true), + ("a.txt", false), + ("B.txt", false), + ("broken", false), + ("c.txt", false), + ] + } else { + &[ + ("alpha", true), + ("Zeta", true), + ("a.txt", false), + ("B.txt", false), + ("c.txt", false), + ] + }; + let listed: Vec<_> = list(&root).expect("a listing"); + let listed: Vec<_> = listed + .iter() + .map(|entry| (entry.name.as_str(), entry.folder)) + .collect(); + assert_eq!(listed, expected); + assert!(list(&root.join("missing")).is_err()); +} + +#[test] +fn a_mark_dots_each_folder_above_it_up_to_the_root() { + let root = Path::new("/r"); + let changes = vec![ + ( + root.join("src").join("ui").join("a.rs"), + GitStatus::Modified, + ), + (root.join("gone.txt"), GitStatus::Deleted), + ]; + let (marks, dirty) = marks(root, changes); + assert_eq!(marks.len(), 2); + let expected = HashSet::from([ + root.to_path_buf(), + root.join("src"), + root.join("src").join("ui"), + ]); + assert_eq!(dirty, expected); +} + +#[test] +fn the_tree_wears_marks_and_an_untracked_folder_marks_what_it_holds() { + let root = PathBuf::from("/r"); + let (notes, src, locked) = (root.join("notes"), root.join("src"), root.join("locked")); + let entry = |name: &str, folder| Entry { + name: name.into(), + folder, + }; + let mut files = Files::new(root.clone()); + let top = vec![ + entry("notes", true), + entry("src", true), + entry("locked", true), + entry("a.txt", false), + ]; + files.listings.insert(root.clone(), Ok(top)); + let inside = vec![entry("deep", true), entry("todo.txt", false)]; + files.listings.insert(notes.clone(), Ok(inside)); + files + .listings + .insert(src.clone(), Ok(vec![entry("main.rs", false)])); + files.listings.insert(locked.clone(), Err("denied".into())); + let changes = vec![ + (notes.clone(), GitStatus::Untracked), + (src.join("main.rs"), GitStatus::Modified), + ]; + (files.marks, files.dirty) = marks(&root, changes); + + let node = |path: &Path, icon| { + let label = path + .file_name() + .expect("a name") + .to_string_lossy() + .to_string(); + TreeNode::new(key(path), label).icon(icon) + }; + let untracked = |node: TreeNode| node.note("U").tone(Tone::Success); + let dotted = |node: TreeNode| node.note("•").tone(Tone::Warning); + let expected = dotted( + node(&root, IconName::Folder).children([ + untracked(node(¬es, IconName::Folder).children([ + untracked(node(¬es.join("deep"), IconName::Folder).pending()), + untracked(node(¬es.join("todo.txt"), IconName::File)), + ])), + dotted( + node(&src, IconName::Folder).children([node(&src.join("main.rs"), IconName::File) + .note("M") + .tone(Tone::Warning)]), + ), + node(&locked, IconName::Folder) + .children([]) + .note("unreadable") + .tone(Tone::Danger), + node(&root.join("a.txt"), IconName::File), + ]), + ); + assert_eq!(files.folder(&root, "r".into(), false), expected); +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..102f8af --- /dev/null +++ b/src/git.rs @@ -0,0 +1,218 @@ +use std::{ + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +use anyhow::{Context as _, bail}; +use ely_gpui_component::lists::GitStatus; + +/// Runs git in `folder` with English messages, taking no lock the user's own git could meet. +fn git(folder: &Path, args: &[&str]) -> anyhow::Result { + Command::new("git") + .arg("-C") + .arg(folder) + .arg("--no-optional-locks") + .args(args) + .env("LC_ALL", "C") + .output() + .with_context(|| format!("git: `git {}` did not start", args.join(" "))) +} + +/// Each change at or under `folder`, by its full path; none outside a work tree. +pub fn changes(folder: &Path) -> anyhow::Result>> { + let place = git( + folder, + &["rev-parse", "--is-inside-work-tree", "--show-prefix"], + )?; + if !place.status.success() { + let error = String::from_utf8_lossy(&place.stderr); + if error.contains("not a git repository") { + return Ok(None); + } + bail!( + "git: rev-parse in {} failed: {}", + folder.display(), + error.trim() + ); + } + let place = String::from_utf8_lossy(&place.stdout); + let mut lines = place.lines(); + if lines.next() != Some("true") { + return Ok(None); + } + let prefix = lines.next().context("git: rev-parse printed no prefix")?; + let status = git(folder, &["status", "--porcelain=v1", "-z", "--", "."])?; + if !status.status.success() { + let error = String::from_utf8_lossy(&status.stderr); + bail!( + "git: status in {} failed: {}", + folder.display(), + error.trim() + ); + } + parse(&status.stdout)? + .into_iter() + .map(|(path, status)| { + let Some(rest) = path.strip_prefix(prefix) else { + bail!("git: {path} lies outside {prefix}"); + }; + let parts = rest.split('/').filter(|part| !part.is_empty()); + Ok(( + parts.fold(folder.to_path_buf(), |path, part| path.join(part)), + status, + )) + }) + .collect::>() + .map(Some) +} + +/// Porcelain v1 records, each ended by NUL: `XY path`, a rename's or copy's old path after it. +fn parse(out: &[u8]) -> anyhow::Result> { + let mut fields = out + .split(|&byte| byte == 0) + .filter(|field| !field.is_empty()); + let mut changes = Vec::new(); + while let Some(field) = fields.next() { + let record = || String::from_utf8_lossy(field); + let (x, y, path) = match field { + [x, y, b' ', path @ ..] if !path.is_empty() => (*x, *y, path), + _ => bail!("git: status record {:?} is malformed", record()), + }; + let Some(status) = kind(x, y) else { + bail!("git: status record {:?} has an unknown code", record()); + }; + if [x, y].iter().any(|code| matches!(code, b'R' | b'C')) { + fields + .next() + .with_context(|| format!("git: {:?} lacks its old path", record()))?; + } + changes.push((String::from_utf8_lossy(path).into_owned(), status)); + } + Ok(changes) +} + +/// One mark for a record's index and work tree codes; a copy is a new file. +fn kind(x: u8, y: u8) -> Option { + Some(match (x, y) { + (b'?', b'?') => GitStatus::Untracked, + (b'U', _) | (_, b'U') | (b'A', b'A') | (b'D', b'D') => GitStatus::Conflicted, + (b'D', _) | (_, b'D') => GitStatus::Deleted, + (b'R', _) | (_, b'R') => GitStatus::Renamed, + (b'A' | b'C', _) | (_, b'A' | b'C') => GitStatus::Added, + (b'M' | b'T', _) | (_, b'M' | b'T') => GitStatus::Modified, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use std::{env, fs}; + + use super::*; + + /// A fresh folder under the temp dir. + fn folder(name: &str) -> PathBuf { + let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id())); + if folder.exists() { + fs::remove_dir_all(&folder).expect("a stale test folder goes"); + } + fs::create_dir_all(&folder).expect("a test folder"); + folder + } + + /// Runs git in `folder` as a fixed author, unsigned. + fn run(folder: &Path, args: &[&str]) { + let done = Command::new("git") + .arg("-C") + .arg(folder) + .args(["-c", "user.name=Ely", "-c", "user.email=ely@example.com"]) + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .output() + .expect("git runs"); + let error = String::from_utf8_lossy(&done.stderr); + assert!(done.status.success(), "git {args:?}: {error}"); + } + + #[test] + fn records_read_as_marks_skipping_old_paths() { + let out = b"RM new.rs\0old.rs\0 M src/a.rs\0?? notes/\0AA both.txt\0UU clash.rs\0\ + A added.rs\0 D gone.rs\0C copy.rs\0from.rs\0 T link\0MM twice.rs\0DU kept.rs\0"; + let marks = parse(out).expect("records"); + let expected = [ + ("new.rs", GitStatus::Renamed), + ("src/a.rs", GitStatus::Modified), + ("notes/", GitStatus::Untracked), + ("both.txt", GitStatus::Conflicted), + ("clash.rs", GitStatus::Conflicted), + ("added.rs", GitStatus::Added), + ("gone.rs", GitStatus::Deleted), + ("copy.rs", GitStatus::Added), + ("link", GitStatus::Modified), + ("twice.rs", GitStatus::Modified), + ("kept.rs", GitStatus::Conflicted), + ] + .map(|(path, status)| (path.to_string(), status)); + assert_eq!(marks, expected); + } + + #[test] + fn records_git_never_writes_are_errors() { + for out in [ + &b"M\0"[..], + b"MM \0", + b"!! ignored\0", + b"R new.rs\0", + b" x\0", + ] { + assert!(parse(out).is_err(), "{:?}", String::from_utf8_lossy(out)); + } + } + + #[test] + fn a_work_tree_reports_changes_under_the_folder() { + let root = folder("git"); + run(&root, &["init", "-q"]); + run(&root, &["config", "status.showUntrackedFiles", "normal"]); + fs::create_dir_all(root.join("src/inner")).expect("folders"); + for (path, text) in [ + ("kept.txt", "kept"), + ("gone.txt", "gone"), + ("src/main.rs", "fn main() {}"), + ("src/inner/deep.rs", "deep"), + ] { + fs::write(root.join(path), text).expect("a file"); + } + run(&root, &["add", "."]); + run(&root, &["commit", "-qm", "first"]); + fs::write(root.join("src/main.rs"), "fn main() { }").expect("an edit"); + fs::remove_file(root.join("gone.txt")).expect("a removal"); + fs::write(root.join("added.txt"), "new").expect("a new file"); + run(&root, &["add", "added.txt"]); + fs::create_dir_all(root.join("notes/sub")).expect("an untracked folder"); + fs::write(root.join("notes/sub/todo.txt"), "todo").expect("an untracked file"); + + let mut all = changes(&root).expect("status").expect("a work tree"); + all.sort_by(|a, b| a.0.cmp(&b.0)); + let expected = [ + ("added.txt", GitStatus::Added), + ("gone.txt", GitStatus::Deleted), + ("notes", GitStatus::Untracked), + ("src/main.rs", GitStatus::Modified), + ] + .map(|(path, status)| (root.join(path), status)); + assert_eq!(all, expected); + let src = root.join("src"); + let main = (src.join("main.rs"), GitStatus::Modified); + assert_eq!(changes(&src).expect("status"), Some(vec![main])); + let sub = root.join("notes").join("sub"); + let whole = (sub.clone(), GitStatus::Untracked); + assert_eq!( + changes(&sub).expect("status"), + Some(vec![whole]), + "a folder inside an untracked one is untracked whole" + ); + assert_eq!(changes(&root.join(".git")).expect("rev-parse"), None); + assert_eq!(changes(&folder("git-outside")).expect("rev-parse"), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0276414..7a1e62b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod app; +pub mod files; +pub mod git; pub mod sessions; pub mod settings; pub mod term; diff --git a/src/sessions.rs b/src/sessions.rs index 35243c5..749aa67 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -20,6 +20,8 @@ pub struct Session { pub title: String, /// The folder it reports, else the one it started in. pub folder: PathBuf, + /// Its shell has integration, or it reported a folder anyway. + pub reports: bool, /// A bell rang while it was hidden. pub bell: bool, pub _events: Subscription, diff --git a/src/term/pty.rs b/src/term/pty.rs index e41b47b..38c9474 100644 --- a/src/term/pty.rs +++ b/src/term/pty.rs @@ -47,6 +47,8 @@ pub struct Launch { pub program: Option<(String, Vec)>, pub cwd: PathBuf, pub env: Vec<(String, String)>, + /// Shell integration makes it report its folder. + pub reports: bool, } /// A running program on a pseudo-terminal and the grid it draws into. @@ -239,6 +241,7 @@ mod tests { program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])), cwd: std::env::temp_dir(), env: vec![("ELY".into(), "ok".into())], + reports: false, }; let (pty, mut events) = spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts"); @@ -298,6 +301,7 @@ mod tests { program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])), cwd: std::env::temp_dir(), env: Vec::new(), + reports: false, }; let (pty, mut events) = spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts"); @@ -342,6 +346,7 @@ mod tests { program: None, cwd: "/nonexistent/ely".into(), env: Vec::new(), + reports: false, }; let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default())) .err() @@ -359,6 +364,7 @@ mod tests { program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])), cwd: folder.clone(), env: Vec::new(), + reports: false, }; let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default())); std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700"); diff --git a/src/term/shell.rs b/src/term/shell.rs index e141cf4..c854daa 100644 --- a/src/term/shell.rs +++ b/src/term/shell.rs @@ -87,6 +87,7 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result { program: (!shell.is_empty()).then(|| (path.clone(), Vec::new())), cwd, env: Vec::new(), + reports: true, }; let script = |name: &str| dir.join(name).display().to_string(); match name.as_str() { @@ -130,7 +131,10 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result { ], )); } - _ => log::info!("shell integration: none for {path}; its folder goes unreported"), + _ => { + launch.reports = false; + log::info!("shell integration: none for {path}; its folder goes unreported") + } } log::info!("shell integration: {path} as {name}"); Ok(launch) diff --git a/src/workspace.rs b/src/workspace.rs index 56953c7..421afcc 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,23 +1,23 @@ -use std::path::PathBuf; - use ely_gpui_component::{ - buttons::{ButtonVariant, IconButton}, + buttons::{ButtonVariant, IconButton, SegmentedControl}, lists::List, primitives::{FocusScope, IconName}, shell::{ColumnShell, ResizeBorder, ShellColumn}, theme::{ActiveTheme, ControlSize}, + typography::Caption, }; use gpui::{ - Action, App, AppContext, Context, EntityId, FocusHandle, Focusable, InteractiveElement, - IntoElement, KeyBinding, ParentElement, Render, StatefulInteractiveElement, Styled, - Subscription, Window, actions, div, + Action, App, AppContext, Context, Entity, EntityId, FocusHandle, Focusable, InteractiveElement, + IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement, + Styled, Subscription, Window, actions, div, prelude::FluentBuilder, }; use crate::{ + files::Files, sessions::{self, Session}, - settings::{Settings, SettingsStore, StartIn}, + settings::{PanelTab, Settings, SettingsStore, StartIn}, term::{ - Events, Pty, TerminalEvent, TerminalView, config, + Events, Launch, Pty, TerminalEvent, TerminalView, config, shell::{ShellIntegration, launch}, spawn, }, @@ -81,15 +81,16 @@ pub struct Workspace { sessions: Vec, /// The session shown. active: usize, + files: Entity, _settings: Subscription, } impl Workspace { - /// The window's content with its first session, `pty` started in `folder`. + /// The window's content with its first session, `pty` started from `launch`. pub fn new( pty: Pty, events: Events, - folder: PathBuf, + launch: Launch, window: &mut Window, cx: &mut Context, ) -> Self { @@ -97,9 +98,10 @@ impl Workspace { focus: cx.focus_handle(), sessions: Vec::new(), active: 0, + files: cx.new(|_| Files::new(launch.cwd.clone())), _settings: cx.observe_global::(|_, cx| cx.notify()), }; - workspace.add(pty, events, folder, window, cx); + workspace.add(pty, events, launch, window, cx); workspace.select(0, window, cx); workspace } @@ -108,7 +110,7 @@ impl Workspace { &mut self, pty: Pty, events: Events, - folder: PathBuf, + launch: Launch, window: &mut Window, cx: &mut Context, ) { @@ -118,11 +120,12 @@ impl Workspace { this.session_event(id, event, window, cx) }); let pid = terminal.read(cx).pid(); - log::info!("app: session {pid} opens in {}", folder.display()); + log::info!("app: session {pid} opens in {}", launch.cwd.display()); self.sessions.push(Session { terminal, title: String::new(), - folder, + folder: launch.cwd, + reports: launch.reports, bell: false, _events: events, }); @@ -142,15 +145,11 @@ impl Workspace { .get(self.active) .map_or(home, |session| session.folder.clone()), }; - let started = launch( - &settings.shell, - folder.clone(), - &cx.global::().0, - ) - .and_then(|launch| spawn(&launch, (80, 24), (8, 16), config(settings))); + let started = launch(&settings.shell, folder, &cx.global::().0) + .and_then(|launch| Ok((spawn(&launch, (80, 24), (8, 16), config(settings))?, launch))); match started { - Ok((pty, events)) => { - self.add(pty, events, folder, window, cx); + Ok(((pty, events), launch)) => { + self.add(pty, events, launch, window, cx); self.select(self.sessions.len() - 1, window, cx); } Err(error) => log::error!("app: no new session: {error:#}"), @@ -170,6 +169,8 @@ impl Workspace { let session = &mut self.sessions[index]; session.bell = false; window.focus(&session.terminal.focus_handle(cx), cx); + let folder = session.folder.clone(); + self.files.update(cx, |files, cx| files.show(&folder, cx)); self.active = index; log::debug!("app: session {} shown", index + 1); self.retitle(window); @@ -231,7 +232,13 @@ impl Workspace { let session = &mut self.sessions[index]; match event { TerminalEvent::Title(title) => session.title.clone_from(title), - TerminalEvent::Folder(folder) => session.folder.clone_from(folder), + TerminalEvent::Folder(folder) => { + session.folder.clone_from(folder); + session.reports = true; + if index == self.active { + self.files.update(cx, |files, cx| files.show(folder, cx)); + } + } TerminalEvent::Bell if index != self.active => { if !session.bell { log::debug!("app: a bell in hidden session {}", index + 1); @@ -276,6 +283,22 @@ impl Workspace { } } +/// Shows Git or Files in the right column, as the settings keep it. +fn show_tab(value: &SharedString, cx: &mut App) { + let panel = match value.as_ref() { + "git" => PanelTab::Git, + "files" => PanelTab::Files, + other => unreachable!("app: no {other} tab"), + }; + if SettingsStore::get(cx).panel == panel { + return; + } + match SettingsStore::update(cx, |settings| settings.panel = panel) { + Ok(()) => log::info!("app: the right column shows {panel:?}"), + Err(error) => log::error!("app: the right column stays: {error:#}"), + } +} + impl Render for Workspace { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let settings = SettingsStore::get(cx); @@ -293,6 +316,7 @@ impl Render for Workspace { .size(ControlSize::Sm) .tooltip("New session") .on_click(move |_, window, cx| { + cx.stop_propagation(); workspace.update(cx, |this, cx| this.new_session(window, cx)) }); shell = shell.column( @@ -316,7 +340,41 @@ impl Render for Workspace { .children(shown), ); if settings.show_panel { - shell = shell.column(ShellColumn::new("panel").width(width)); + let tab = settings.panel; + let chosen = match tab { + PanelTab::Git => "git", + PanelTab::Files => "files", + }; + let switch = SegmentedControl::new("panel-tab", chosen) + .segment("git", "Git", None) + .segment("files", "Files", None) + .size(ControlSize::Sm) + .on_change(|value, _, cx| show_tab(value, cx)); + // Its presses stay off the header, whose double press zooms. + let switch = div() + .id("panel-switch") + .on_click(|_, _, cx| cx.stop_propagation()) + .child(switch); + let quiet = self + .sessions + .get(self.active) + .is_some_and(|session| !session.reports); + let caption = || Caption::new("This shell reports no folder.").px_3().pt_2(); + 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()), + ), + ); } // The context sits above the focus root, so its keys work wherever focus is. div()