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
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 9m12s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 19:09:21 -04:00
1 parent 2ddc40d939
commit 8369d96b36
11 files changed
+700 -27

No files matched your search

+1 -1
View File
@@ -81,7 +81,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
window
.observe_window_appearance(|_, cx| themes::apply(cx))
.detach();
cx.new(|cx| Workspace::new(pty, events, launch.cwd, window, cx))
cx.new(|cx| Workspace::new(pty, events, launch, window, cx))
})?;
log::info!("app: main window open");
Ok(window)
+246
View File
@@ -0,0 +1,246 @@
use std::{
collections::{HashMap, HashSet},
fs, io,
path::{Path, PathBuf},
};
use ely_gpui_component::{
data_display::Tone,
lists::{GitStatus, Tree, TreeNode},
primitives::IconName,
};
use gpui::{AppContext, Context, IntoElement, Render, SharedString, Styled, Task, Window};
use crate::{git, sessions};
/// A folder's entry: its name, and whether it is a folder, links followed.
#[derive(Debug, PartialEq)]
struct Entry {
name: String,
folder: bool,
}
/// A folder's entries, or why it could not be read.
type Listing = Result<Vec<Entry>, String>;
/// Reads `folder`: folders first, then files, each by name ignoring case.
fn list(folder: &Path) -> Listing {
let read = || -> io::Result<Vec<Entry>> {
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<PathBuf, GitStatus>, HashSet<PathBuf>) {
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<GitStatus>, 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<PathBuf, Listing>,
/// The root and the folders the tree opened.
wanted: HashSet<PathBuf>,
marks: HashMap<PathBuf, GitStatus>,
/// Folders holding a mark.
dirty: HashSet<PathBuf>,
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<Self>) {
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<Option<Vec<(PathBuf, GitStatus)>>>,
cx: &mut Context<Self>,
) {
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<Self>) {
let folders: Vec<PathBuf> = self.wanted.iter().cloned().collect();
let read = cx.background_spawn(async move {
let read = folders.into_iter().map(|folder| (list(&folder), folder));
read.collect::<Vec<_>>()
});
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<Self>) {
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<Self>) {
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<Self>) -> 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;
+135
View File
@@ -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(&notes, IconName::Folder).children([
untracked(node(&notes.join("deep"), IconName::Folder).pending()),
untracked(node(&notes.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);
}
+218
View File
@@ -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<Output> {
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<Option<Vec<(PathBuf, GitStatus)>>> {
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::<anyhow::Result<_>>()
.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<Vec<(String, GitStatus)>> {
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<GitStatus> {
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);
}
}
+2
View File
@@ -1,4 +1,6 @@
pub mod app;
pub mod files;
pub mod git;
pub mod sessions;
pub mod settings;
pub mod term;
+2
View File
@@ -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,
+6
View File
@@ -47,6 +47,8 @@ pub struct Launch {
pub program: Option<(String, Vec<String>)>,
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");
+5 -1
View File
@@ -87,6 +87,7 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
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<Launch> {
],
));
}
_ => 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)
+82 -24
View File
@@ -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<Session>,
/// The session shown.
active: usize,
files: Entity<Files>,
_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>,
) -> 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::<SettingsStore>(|_, 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<Self>,
) {
@@ -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::<ShellIntegration>().0,
)
.and_then(|launch| spawn(&launch, (80, 24), (8, 16), config(settings)));
let started = launch(&settings.shell, folder, &cx.global::<ShellIntegration>().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<Self>) -> 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()