Files
tty7/src/ui/file_tree.rs
T
8a342f2ca9 feat(ui): GUI localization for en and zh-Hans (#303)
* feat(ui): add GUI localization for en and zh-Hans

* feat(ui): localize search placeholders and relative time

* feat(ui): localize palette, switcher, and sftp strings

* feat(ui): localize home shortcut labels

* feat(ui): localize tray, ssh prompt, and editor strings

* feat(ui): add plural/select i18n helpers and localize sftp/settings labels

* feat(ui): localize settings search, forwards panel, and file tree

* feat(ui): localize code editor and right panel

* feat(ui): localize stop/delete workspace confirmations with plural support

* feat(ui): localize diff overlay with plural-aware summary

* feat(ui): localize pending pane, worktree prompt, and home time strings

* feat(ui): localize app menus, tray, tab strip/sidebar, and remote status strings

* feat(ui): localize switcher, file_tree, machine_mirror fallback strings

* feat(ui): localize ssh prompts, theme presets, host error wrapper, and finish remote strings

* feat(ui): localize command palette strings

* feat(ui): localize app.rs notifications, prompts, placeholders, and parse errors

* feat(ui): localize remaining theme, switcher, settings, and sftp strings

* style: cargo fmt

* feat(ui): add language selector to settings

* fix(ui): refresh locales across windows

* refactor(ui): make GUI language selection explicit

* fix(ui): localize Explorer settings after merge

* fix(ui): keep persisted theme names out of the GUI locale

A theme's name is data, not chrome: it is written into the theme YAML and
matched back with `trim_end_matches(" (custom)")`. Translating it meant a
Chinese GUI forked "Nord" into "Nord(自定义)", the next fork stacked a second
suffix on it, and the name stayed Chinese after switching back to English. The
derived-name fallback had the same problem. Both are English again.

Also in this pass:

- Give each test thread its own locale override. The locale is process-wide and
  tests run in parallel, so the two tests that switched to zh-CN could flip the
  language out from under another thread's English assertions.
- Rebuild the menu bar when gui_language changes in config.json, the way the
  in-app picker already does — otherwise the menus kept the old language.
- Document the values the setting actually accepts. The docs still described
  `auto` and `zh-Hans`, which sanitize() resets to `en`.
- Put the English words back into the Chinese search keywords for the language
  setting; the other 58 keyword sets keep them.
- Drop the unused is_zh_hans helper.

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-03 23:48:29 +08:00

2453 lines
83 KiB
Rust

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::core::config::RightPanelTab;
use crate::ui::app::Tty7App;
use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub};
use crate::ui::host_registry::HostRegistry;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, ExternalPaths, FocusHandle, KeyDownEvent, MouseButton,
PromptLevel, SharedString, Subscription, Window, div, px,
};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
const INDENT: f32 = 14.0;
const REFRESH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
const SEARCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200);
const SEARCH_LIMIT: usize = 200;
const SEARCH_MAX_DIRS: usize = 2000;
#[derive(Clone, PartialEq)]
pub(crate) struct TreeEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub ignored: bool,
}
struct Landed {
superseded: bool,
changed: bool,
}
pub(crate) struct TreeRow {
pub entry: TreeEntry,
pub depth: usize,
pub is_root: bool,
pub expanded: bool,
}
pub(crate) enum TreeEdit {
NewFile {
dir: PathBuf,
input: Entity<InputState>,
},
NewFolder {
dir: PathBuf,
input: Entity<InputState>,
},
Rename {
path: PathBuf,
input: Entity<InputState>,
},
}
impl TreeEdit {
fn input(&self) -> &Entity<InputState> {
match self {
TreeEdit::NewFile { input, .. }
| TreeEdit::NewFolder { input, .. }
| TreeEdit::Rename { input, .. } => input,
}
}
fn host_dir(&self) -> &Path {
match self {
TreeEdit::NewFile { dir, .. } | TreeEdit::NewFolder { dir, .. } => dir,
TreeEdit::Rename { path, .. } => path.parent().unwrap_or(path),
}
}
}
type DirKey = (HostId, PathBuf);
#[derive(Default)]
struct SearchState {
generation: u64,
pending: String,
hidden: bool,
hits: Vec<TreeEntry>,
}
impl SearchState {
fn retarget(&mut self, query: &str, show_hidden: bool) -> Option<u64> {
if self.pending == query && self.hidden == show_hidden {
return None;
}
self.generation += 1;
self.pending = query.to_string();
self.hidden = show_hidden;
if query.is_empty() {
self.hits.clear();
return None;
}
Some(self.generation)
}
fn accept(&mut self, generation: u64, hits: Vec<TreeEntry>) -> bool {
if self.generation != generation {
return false;
}
self.hits = hits;
true
}
fn restart(&mut self) {
self.generation += 1;
self.pending.clear();
}
}
pub(crate) struct FileTreeState {
children: ByHost<PathBuf, Vec<TreeEntry>>,
loads: InFlight<DirKey>,
stale: HashSet<DirKey>,
repo_roots: ByHost<PathBuf, PathBuf>,
repo_root_loads: InFlight<DirKey>,
search: SearchState,
pub(crate) show_hidden: bool,
pub(crate) editing: Option<TreeEdit>,
editing_subs: Vec<Subscription>,
watch: Option<Arc<WatchSub>>,
watch_host: Option<SharedHost>,
watch_opening: bool,
watch_busy: bool,
watch_dirty: bool,
watched: HashSet<PathBuf>,
events_tx: smol::channel::Sender<(HostId, Vec<PathBuf>)>,
pub(crate) focus_handle: FocusHandle,
}
impl FileTreeState {
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self {
let (tx, rx) = smol::channel::unbounded::<(HostId, Vec<PathBuf>)>();
cx.spawn_in(window, async move |app, cx| {
while let Ok((host, first)) = rx.recv().await {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
let mut changed: HashSet<PathBuf> = first.into_iter().collect();
while let Ok((h, more)) = rx.try_recv() {
if h == host {
changed.extend(more);
}
}
let ok = app.update(cx, |app, cx| {
app.file_tree_apply_fs_events(host, &changed, cx);
});
if ok.is_err() {
break;
}
}
})
.detach();
Self {
watch_host: None,
children: ByHost::default(),
loads: InFlight::default(),
stale: HashSet::new(),
repo_roots: ByHost::default(),
repo_root_loads: InFlight::default(),
search: SearchState::default(),
show_hidden: false,
editing: None,
editing_subs: Vec::new(),
watch: None,
watch_opening: false,
watch_busy: false,
watch_dirty: false,
watched: HashSet::new(),
events_tx: tx,
focus_handle: cx.focus_handle(),
}
}
fn sync_watch(&mut self, host: SharedHost, dirs: HashSet<PathBuf>, cx: &mut Context<Tty7App>) {
self.watched = dirs;
let want: Vec<PathBuf> = self.watched.iter().cloned().collect();
if !self
.watch_host
.as_ref()
.is_some_and(|opened_with| Arc::ptr_eq(opened_with, &host))
{
self.watch = None;
self.watch_host = None;
self.watch_busy = false;
self.watch_dirty = false;
}
if let Some(sub) = self.watch.clone() {
if self.watch_busy {
self.watch_dirty = true;
return;
}
self.watch_busy = true;
HostOps::run(
host,
cx,
move |_| sub.set_dirs(&want),
|app: &mut Tty7App, result: std::io::Result<()>, cx| {
app.file_tree.watch_busy = false;
if let Err(e) = result {
log::warn!("file tree: could not update the watched set: {e}");
}
if std::mem::take(&mut app.file_tree.watch_dirty) {
let want = app.file_tree.watched.clone();
let Some(host) = app.active_host(cx) else {
return;
};
app.file_tree.sync_watch(host, want, cx);
}
},
);
return;
}
if self.watch_opening {
return;
}
self.watch_opening = true;
let host_id = host.id();
let opened_host = Arc::clone(&host);
let opened_with = self.watched.clone();
HostOps::run(
host,
cx,
{
let want = want.clone();
move |h| h.watch(&want).map(Arc::new)
},
move |app, result: std::io::Result<Arc<WatchSub>>, cx| {
app.file_tree.watch_opening = false;
let sub = match result {
Ok(sub) => sub,
Err(e) => {
log::warn!("file tree: watcher unavailable: {e}");
return;
}
};
let events = sub.events().clone();
app.file_tree.watch = Some(sub);
app.file_tree.watch_host = Some(opened_host);
cx.spawn(async move |app, cx| {
while let Ok(batch) = events.recv().await {
let ok = app.update(cx, |app, _cx| {
let _ = app.file_tree.events_tx.try_send((host_id, batch));
});
if ok.is_err() {
break;
}
}
})
.detach();
if app.file_tree.watched != opened_with {
let want = app.file_tree.watched.clone();
let Some(host) = app.active_host(cx) else {
return;
};
app.file_tree.sync_watch(host, want, cx);
}
},
);
}
fn request_loads(
&mut self,
host: &SharedHost,
roots: &[PathBuf],
expanded: &HashSet<PathBuf>,
cx: &mut Context<Tty7App>,
) {
for root in roots {
self.request_load(host, root.clone(), root.clone(), cx);
for dir in expanded {
if dir.starts_with(root) {
self.request_load(host, dir.clone(), root.clone(), cx);
}
}
}
}
fn request_load(
&mut self,
host: &SharedHost,
dir: PathBuf,
root: PathBuf,
cx: &mut Context<Tty7App>,
) {
let id = host.id();
let key: DirKey = (id, dir.clone());
let current = self.children.get(id, &dir).is_some() && !self.stale.contains(&key);
if current || !self.loads.begin(key.clone()) {
return;
}
self.spawn_load(host, dir, root, cx);
}
fn spawn_load(
&mut self,
host: &SharedHost,
dir: PathBuf,
root: PathBuf,
cx: &mut Context<Tty7App>,
) {
let id = host.id();
let key: DirKey = (id, dir.clone());
HostOps::run(
host.clone(),
cx,
{
let dir = dir.clone();
let root = root.clone();
move |h| {
let entries = h.read_dir(&dir, Some(&root)).unwrap_or_default();
entries
.into_iter()
.map(|e| TreeEntry {
path: h.join(&dir, &e.name),
name: e.name,
is_dir: e.is_dir,
ignored: e.ignored,
})
.collect::<Vec<_>>()
}
},
move |app, entries, cx| {
let landed = app.file_tree.land_load(&key, id, dir.clone(), entries);
if landed.changed {
cx.notify();
}
if !landed.superseded {
return;
}
let Some(host) = app.active_host(cx) else {
return;
};
if host.id() != id {
return;
}
app.file_tree.loads.begin(key);
app.file_tree.spawn_load(&host, dir, root, cx);
},
);
}
fn land_load(
&mut self,
key: &DirKey,
id: HostId,
dir: PathBuf,
entries: Vec<TreeEntry>,
) -> Landed {
let changed = self.children.get(id, &dir) != Some(&entries);
let superseded = land_listing(
&mut self.loads,
&mut self.children,
&mut self.stale,
key,
id,
dir,
entries,
);
Landed {
superseded,
changed,
}
}
fn sync_search(&mut self, query: &str, roots: &[PathBuf], cx: &mut Context<Tty7App>) {
let Some(generation) = self.search.retarget(query, self.show_hidden) else {
return;
};
let show_hidden = self.show_hidden;
let (query, roots) = (query.to_string(), roots.to_vec());
cx.spawn(async move |app, cx| {
cx.background_executor().timer(SEARCH_DEBOUNCE).await;
let _ = app.update(cx, |app, cx| {
if app.file_tree.search.generation != generation {
return;
}
let Some(host) = app.active_host(cx) else {
return;
};
HostOps::run(
host,
cx,
move |h| {
h.search(&roots, &query, SEARCH_LIMIT, SEARCH_MAX_DIRS, show_hidden)
.unwrap_or_default()
.into_iter()
.map(|hit| TreeEntry {
name: hit.name,
path: hit.path,
is_dir: hit.is_dir,
ignored: hit.ignored,
})
.collect::<Vec<_>>()
},
move |app, hits, cx| {
if app.file_tree.search.accept(generation, hits) {
cx.notify();
}
},
);
});
})
.detach();
}
fn search_rows(&self) -> Vec<TreeRow> {
self.search
.hits
.iter()
.map(|e| TreeRow {
entry: e.clone(),
depth: 0,
is_root: false,
expanded: false,
})
.collect()
}
pub(crate) fn visible_rows(
&self,
host: HostId,
roots: &[PathBuf],
expanded: &HashSet<PathBuf>,
) -> Vec<TreeRow> {
let mut rows = Vec::new();
for root in roots {
let name = root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| root.display().to_string());
rows.push(TreeRow {
entry: TreeEntry {
name,
path: root.clone(),
is_dir: true,
ignored: false,
},
depth: 0,
is_root: true,
expanded: true,
});
self.flatten_dir(host, root, 1, expanded, &mut rows);
}
rows
}
fn flatten_dir(
&self,
host: HostId,
dir: &Path,
depth: usize,
expanded: &HashSet<PathBuf>,
out: &mut Vec<TreeRow>,
) {
let Some(entries) = self.children.get(host, &dir.to_path_buf()) else {
return;
};
for e in entries {
if !self.show_hidden && e.name.starts_with('.') {
continue;
}
let is_expanded = e.is_dir && expanded.contains(&e.path);
out.push(TreeRow {
entry: e.clone(),
depth,
is_root: false,
expanded: is_expanded,
});
if is_expanded {
self.flatten_dir(host, &e.path, depth + 1, expanded, out);
}
}
}
fn invalidate_dir(&mut self, host: HostId, dir: &Path) -> bool {
let key: DirKey = (host, dir.to_path_buf());
let cached = self.children.get(host, dir).is_some();
if cached {
self.stale.insert(key.clone());
}
let pending = self.loads.is_pending(&key);
self.loads.invalidate(&key);
cached || pending
}
fn gitignore_reaches_tree(&self, host: HostId, paths: &HashSet<PathBuf>) -> bool {
paths
.iter()
.filter(|p| p.file_name().is_some_and(|n| n == ".gitignore"))
.filter_map(|p| p.parent())
.any(|dir| {
self.children
.keys()
.any(|(id, cached)| id == host && cached.starts_with(dir))
|| self
.loads
.pending_keys()
.any(|(id, pending)| *id == host && pending.starts_with(dir))
})
}
fn invalidate_all(&mut self) {
self.stale
.extend(self.children.keys().map(|(host, dir)| (host, dir.clone())));
self.loads.invalidate_all();
self.search.restart();
}
fn invalidate_repo_roots(&mut self) -> bool {
let had = !self.repo_roots.is_empty() || !self.repo_root_loads.is_empty();
self.repo_roots.clear();
self.repo_root_loads.invalidate_all();
had
}
fn optimistic(
&mut self,
host: HostId,
dir: &Path,
op: &TreeWrite,
target: &TreeEntry,
) -> Option<Vec<TreeEntry>> {
self.loads.invalidate(&(host, dir.to_path_buf()));
optimistic_write(&mut self.children, host, dir, op, target)
}
fn rollback(&mut self, host: HostId, dir: &Path, before: Option<Vec<TreeEntry>>) {
rollback_write(&mut self.children, host, dir, before)
}
}
pub(crate) fn sort_entries(entries: &mut [TreeEntry]) {
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
}
fn optimistic_write(
children: &mut ByHost<PathBuf, Vec<TreeEntry>>,
host: HostId,
dir: &Path,
op: &TreeWrite,
target: &TreeEntry,
) -> Option<Vec<TreeEntry>> {
let key = dir.to_path_buf();
let before = children.get(host, &key).cloned();
if let Some(mut entries) = children.remove(host, &key) {
match op {
TreeWrite::Rename { from } => entries.retain(|e| e.path != *from),
TreeWrite::Delete => entries.retain(|e| e.path != target.path),
TreeWrite::NewFile | TreeWrite::NewFolder => {}
}
if !matches!(op, TreeWrite::Delete) {
entries.push(target.clone());
sort_entries(&mut entries);
}
children.insert(host, key, entries);
}
before
}
fn rollback_write(
children: &mut ByHost<PathBuf, Vec<TreeEntry>>,
host: HostId,
dir: &Path,
before: Option<Vec<TreeEntry>>,
) {
drop(before);
children.remove(host, &dir.to_path_buf());
}
pub(crate) fn shell_quote(path: &Path) -> String {
let s = path.to_string_lossy();
if !s.is_empty()
&& s.chars()
.all(|c| c.is_alphanumeric() || "/.-_~+".contains(c))
{
return s.into_owned();
}
format!("'{}'", s.replace('\'', r"'\''"))
}
impl Tty7App {
pub(crate) fn active_host(&self, cx: &App) -> Option<SharedHost> {
HostRegistry::lookup(cx, self.spawn_host(cx))
}
pub(crate) fn file_tree_refresh_roots(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let id = self.spawn_host(cx);
let Some(host) = self.active_host(cx) else {
return;
};
let leaves = match self.tabs.get(self.active) {
Some(tab) => tab.pane.terminals(),
None => Vec::new(),
};
let cwds: Vec<PathBuf> = leaves
.iter()
.filter(|leaf| leaf.read(cx).host_id() == id)
.filter_map(|leaf| leaf.read(cx).cwd())
.collect();
let mut roots: Vec<PathBuf> = Vec::new();
let mut resolved = true;
for cwd in &cwds {
match self.file_tree.repo_roots.get(id, cwd) {
Some(root) => {
if !roots.contains(root) {
roots.push(root.clone());
}
}
None => {
resolved = false;
self.file_tree_request_repo_root(&host, cwd.clone(), cx);
}
}
}
if !resolved {
return;
}
if roots.is_empty()
&& id.is_local()
&& let Some(home) = std::env::var_os("HOME")
{
roots.push(PathBuf::from(home));
}
let _ = window;
let Some(code) = self.tab_code_mut_or_init() else {
return;
};
if roots != code.roots {
code.roots = roots;
self.file_tree.invalidate_all();
cx.notify();
}
self.file_tree_sync_watch(host, cx);
}
fn file_tree_sync_watch(&mut self, host: SharedHost, cx: &mut Context<Self>) {
let union: HashSet<PathBuf> = self
.tabs
.iter()
.filter_map(|t| t.code.as_deref())
.flat_map(|c| c.roots.iter().chain(c.expanded.iter()).cloned())
.collect();
if union != self.file_tree.watched {
self.file_tree.sync_watch(host, union, cx);
}
}
fn file_tree_request_repo_root(
&mut self,
host: &SharedHost,
cwd: PathBuf,
cx: &mut Context<Self>,
) {
let id = host.id();
let key: DirKey = (id, cwd.clone());
if !self.file_tree.repo_root_loads.begin(key.clone()) {
return;
}
HostOps::run(
host.clone(),
cx,
{
let cwd = cwd.clone();
move |h| h.repo_root(&cwd).ok().flatten()
},
move |app, root, cx| {
if app.file_tree.repo_root_loads.finish(&key) {
app.file_tree
.repo_roots
.insert(id, cwd.clone(), root.unwrap_or(cwd));
}
cx.notify();
},
);
}
pub(crate) fn file_tree_on_screen(&self, cx: &App) -> bool {
self.right_panel_open(cx)
&& self.right_panel_tab == RightPanelTab::Files
&& self.sftp_panel.open_pane_id.is_none()
}
fn file_tree_query(&self, cx: &App) -> String {
self.file_search.read(cx).value().trim().to_lowercase()
}
pub(crate) fn file_tree_searching(&self, cx: &App) -> bool {
!self.file_tree_query(cx).is_empty()
}
pub(crate) fn file_tree_listings_on_screen(&self, cx: &App) -> bool {
self.file_tree_on_screen(cx) && !self.file_tree_searching(cx)
}
pub(crate) fn file_tree_apply_fs_events(
&mut self,
host: HostId,
paths: &HashSet<PathBuf>,
cx: &mut Context<Self>,
) {
log::debug!(
target: "tty7::file_tree",
"fs events on host {host:?}: {:?}",
paths.iter().take(8).collect::<Vec<_>>()
);
let on_screen = self.file_tree_on_screen(cx);
let listings_on_screen = self.file_tree_listings_on_screen(cx);
let mut roots_moved = false;
if paths.iter().any(|p| {
p.file_name().is_some_and(|n| n == ".git")
|| p.parent()
.and_then(Path::file_name)
.is_some_and(|n| n == ".git")
}) {
roots_moved = self.file_tree.invalidate_repo_roots();
}
let gitignore_touched = paths
.iter()
.any(|p| p.file_name().is_some_and(|n| n == ".gitignore"));
if gitignore_touched && self.file_tree.gitignore_reaches_tree(host, paths) {
self.file_tree.invalidate_all();
if on_screen {
cx.notify();
}
} else {
let mut touched = false;
for dir in dirs_to_relist(paths, self.file_tree.show_hidden) {
touched |= self.file_tree.invalidate_dir(host, dir);
}
if roots_moved && on_screen {
cx.notify();
}
if !touched {
return;
}
if !listings_on_screen {
return;
}
let Some(shared) = self.active_host(cx) else {
return;
};
if shared.id() != host {
return;
}
let (roots, expanded) = match self.tab_code() {
Some(code) => (code.roots.clone(), code.expanded.clone()),
None => return,
};
self.file_tree.request_loads(&shared, &roots, &expanded, cx);
}
}
fn file_tree_toggle_expand(&mut self, dir: &Path, cx: &mut Context<Self>) {
let Some(code) = self.tab_code_mut() else {
return;
};
if !code.expanded.remove(dir) {
code.expanded.insert(dir.to_path_buf());
}
cx.notify();
}
fn file_tree_activate(
&mut self,
row_path: &Path,
is_dir: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(code) = self.tab_code_mut() {
code.selected = Some(row_path.to_path_buf());
}
let searching = !self.file_search.read(cx).value().trim().is_empty();
if is_dir && searching {
self.file_tree_reveal(row_path, cx);
self.file_search
.update(cx, |st, cx| st.set_value("", window, cx));
cx.notify();
return;
}
if is_dir {
self.file_tree_toggle_expand(row_path, cx);
} else {
self.open_file_in_editor(row_path, window, cx);
}
cx.notify();
}
fn file_tree_reveal(&mut self, dir: &Path, cx: &mut Context<Self>) {
let roots = self.tab_code().map(|c| c.roots.clone()).unwrap_or_default();
let Some(root) = roots.iter().find(|r| dir.starts_with(r)).cloned() else {
return;
};
let Some(code) = self.tab_code_mut() else {
return;
};
for a in dir.ancestors().take_while(|a| a.starts_with(&root)) {
code.expanded.insert(a.to_path_buf());
}
cx.notify();
}
fn file_tree_key_down(
&mut self,
ev: &KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let host = self.spawn_host(cx);
let Some(code) = self.tab_code() else {
return;
};
let rows = self
.file_tree
.visible_rows(host, &code.roots, &code.expanded);
if rows.is_empty() {
return;
}
let sel_ix = code
.selected
.as_ref()
.and_then(|s| rows.iter().position(|r| r.entry.path == *s));
let key = ev.keystroke.key.as_str();
match key {
"up" | "down" => {
let next = match (sel_ix, key) {
(None, _) => 0,
(Some(i), "up") => i.saturating_sub(1),
(Some(i), _) => (i + 1).min(rows.len() - 1),
};
let path = rows[next].entry.path.clone();
if let Some(code) = self.tab_code_mut() {
code.selected = Some(path);
}
cx.notify();
}
"left" => {
let Some(i) = sel_ix else { return };
let row = &rows[i];
let (path, is_dir, expanded, is_root) = (
row.entry.path.clone(),
row.entry.is_dir,
row.expanded,
row.is_root,
);
let parent_in_rows = path
.parent()
.is_some_and(|p| rows.iter().any(|r| r.entry.path == p));
if let Some(code) = self.tab_code_mut() {
if is_dir && expanded && !is_root {
code.expanded.remove(&path);
} else if parent_in_rows && let Some(parent) = path.parent() {
code.selected = Some(parent.to_path_buf());
}
}
cx.notify();
}
"right" => {
let Some(i) = sel_ix else { return };
let row = &rows[i];
if row.entry.is_dir && !row.expanded && !row.is_root {
let path = row.entry.path.clone();
if let Some(code) = self.tab_code_mut() {
code.expanded.insert(path);
}
cx.notify();
}
}
"enter" => {
let Some(i) = sel_ix else { return };
let (path, is_dir) = (rows[i].entry.path.clone(), rows[i].entry.is_dir);
self.file_tree_activate(&path, is_dir, window, cx);
}
_ => {}
}
}
fn file_tree_begin_edit(
&mut self,
edit_for: TreeEditKind,
target: &Path,
target_is_dir: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let initial = match edit_for {
TreeEditKind::Rename => target
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
_ => String::new(),
};
let input = cx.new(|cx| {
let mut st = InputState::new(window, cx).placeholder(match edit_for {
TreeEditKind::NewFile => t(L10nKey::FileTreePlaceholderFileName),
TreeEditKind::NewFolder => t(L10nKey::FileTreePlaceholderFolderName),
TreeEditKind::Rename => t(L10nKey::FileTreePlaceholderNewName),
});
st.set_value(initial, window, cx);
st
});
input.update(cx, |st, cx| st.focus(window, cx));
let sub = cx.subscribe_in(
&input,
window,
|this: &mut Tty7App, _input, ev, window, cx| match ev {
InputEvent::PressEnter { .. } => this.file_tree_commit_edit(window, cx),
InputEvent::Blur => this.file_tree_cancel_edit(cx),
_ => {}
},
);
self.file_tree.editing_subs = vec![sub];
let host_dir = if target_is_dir {
target.to_path_buf()
} else {
target.parent().unwrap_or(target).to_path_buf()
};
if !matches!(edit_for, TreeEditKind::Rename)
&& let Some(code) = self.tab_code_mut()
{
code.expanded.insert(host_dir.clone());
}
self.file_tree.editing = Some(match edit_for {
TreeEditKind::NewFile => TreeEdit::NewFile {
dir: host_dir,
input,
},
TreeEditKind::NewFolder => TreeEdit::NewFolder {
dir: host_dir,
input,
},
TreeEditKind::Rename => TreeEdit::Rename {
path: target.to_path_buf(),
input,
},
});
cx.notify();
}
fn file_tree_cancel_edit(&mut self, cx: &mut Context<Self>) {
self.file_tree.editing = None;
self.file_tree.editing_subs.clear();
cx.notify();
}
fn file_tree_commit_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(edit) = self.file_tree.editing.take() else {
return;
};
self.file_tree.editing_subs.clear();
let name = edit.input().read(cx).value().trim().to_string();
if name.is_empty() || name.contains('/') {
cx.notify();
return;
}
let Some(host) = self.active_host(cx) else {
return;
};
let id = host.id();
let dir = edit.host_dir().to_path_buf();
let (new_path, is_dir, op): (PathBuf, bool, TreeWrite) = match &edit {
TreeEdit::NewFile { dir, .. } => (host.join(dir, &name), false, TreeWrite::NewFile),
TreeEdit::NewFolder { dir, .. } => (host.join(dir, &name), true, TreeWrite::NewFolder),
TreeEdit::Rename { path, .. } => {
let was_dir = self
.file_tree
.children
.get(id, &dir)
.and_then(|entries| entries.iter().find(|e| e.path == *path))
.is_some_and(|e| e.is_dir);
let parent = path.parent().unwrap_or(path);
(
host.join(parent, &name),
was_dir,
TreeWrite::Rename { from: path.clone() },
)
}
};
let row = TreeEntry {
name: name.clone(),
path: new_path.clone(),
is_dir,
ignored: false,
};
let rollback = self.file_tree.optimistic(id, &dir, &op, &row);
if let Some(code) = self.tab_code_mut() {
code.selected = Some(new_path.clone());
}
let target = new_path.clone();
HostOps::run_in(
host,
window,
cx,
move |h| match &op {
TreeWrite::NewFile => h.create_file_new(&target),
TreeWrite::NewFolder => h.create_dir(&target, false),
TreeWrite::Rename { from } => h.rename(from, &target),
TreeWrite::Delete => h.remove(&target, is_dir),
},
move |app, result: std::io::Result<()>, window, cx| {
match result {
Ok(()) => {
app.file_tree.invalidate_dir(id, &dir);
if matches!(edit, TreeEdit::NewFile { .. }) {
app.open_file_in_editor(&new_path, window, cx);
}
}
Err(e) => {
app.file_tree.rollback(id, &dir, rollback);
if let Some(code) = app.tab_code_mut()
&& code.selected.as_deref() == Some(&*new_path)
{
code.selected = None;
}
use gpui_component::WindowExt as _;
window.push_notification(format!("{e}"), cx);
}
}
cx.notify();
},
);
cx.notify();
}
fn file_tree_delete(
&mut self,
path: PathBuf,
is_dir: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
let detail = if is_dir {
t(L10nKey::FileTreeDeleteFolderBody)
} else {
t(L10nKey::FileTreeDeleteFileBody)
};
let answer = window.prompt(
PromptLevel::Warning,
&t_fmt(L10nKey::FileTreeDeleteTitle, &[("name", &name)]),
Some(detail),
&[t(L10nKey::Cancel), t(L10nKey::Delete)],
cx,
);
cx.spawn_in(window, async move |app, cx| {
let Ok(1) = answer.await else { return };
let _ = app.update_in(cx, |app, window, cx| {
let Some(host) = app.active_host(cx) else {
return;
};
let id = host.id();
let Some(parent) = path.parent().map(Path::to_path_buf) else {
return;
};
let row = TreeEntry {
name: name.clone(),
path: path.clone(),
is_dir,
ignored: false,
};
let rollback = app
.file_tree
.optimistic(id, &parent, &TreeWrite::Delete, &row);
if let Some(code) = app.tab_code_mut()
&& code.selected.as_deref() == Some(&path)
{
code.selected = None;
}
let target = path.clone();
HostOps::run_in(
host,
window,
cx,
move |h| h.remove(&target, is_dir),
move |app, result: std::io::Result<()>, window, cx| {
match result {
Ok(()) => {
app.file_tree.invalidate_dir(id, &parent);
}
Err(e) => {
app.file_tree.rollback(id, &parent, rollback);
HostOps::notify_err(
window,
cx,
t(L10nKey::FileTreeDeleteFailed),
&e,
);
}
}
cx.notify();
},
);
cx.notify();
});
})
.detach();
}
fn file_tree_cd(&mut self, dir: &Path, window: &mut Window, cx: &mut Context<Self>) {
let Some(leaf) = self
.tabs
.get(self.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
else {
return;
};
leaf.read(cx)
.run_command_line(&format!("cd {}", shell_quote(dir)));
self.focus_active(window, cx);
}
fn file_tree_attach_to_agent(&mut self, path: &Path, cx: &mut Context<Self>) {
let Some(target) = self.agent_target_leaf(cx) else {
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNoRunningCodingAgent));
return;
};
let rel = self
.tab_code()
.into_iter()
.flat_map(|c| c.roots.iter())
.find_map(|r| path.strip_prefix(r).ok())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| path.to_path_buf());
target.update(cx, |view, cx| {
view.paste(format!("@{} ", rel.display()), cx);
});
}
}
#[derive(Clone, Copy)]
enum TreeEditKind {
NewFile,
NewFolder,
Rename,
}
enum TreeWrite {
NewFile,
NewFolder,
Rename { from: PathBuf },
Delete,
}
impl Tty7App {
pub(crate) fn render_file_tree_rows(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
self.file_tree_refresh_roots(window, cx);
let (roots, expanded) = match self.tab_code() {
Some(code) => (code.roots.clone(), code.expanded.clone()),
None => (Vec::new(), std::collections::HashSet::new()),
};
let query = self.file_tree_query(cx);
let host = self.active_host(cx);
let host_id = self.spawn_host(cx);
if let Some(host) = host.clone() {
self.file_tree_sync_watch(host, cx);
}
self.file_tree.sync_search(&query, &roots, cx);
let rows = if self.file_tree_searching(cx) {
self.file_tree.search_rows()
} else {
if let Some(host) = &host {
self.file_tree.request_loads(host, &roots, &expanded, cx);
}
self.file_tree.visible_rows(host_id, &roots, &expanded)
};
let column = v_flex()
.id("right-panel-tree-rows")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.track_scroll(&self.right_panel.tree_scroll)
.px_1()
.pb_1()
.track_focus(&self.file_tree.focus_handle)
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
this.file_tree_key_down(ev, window, cx);
}))
.children(
rows.iter()
.flat_map(|row| self.render_tree_row(row, window, cx)),
);
crate::ui::scrollbar::with_vertical_scrollbar(
"right-panel-tree-scrollbar",
column,
&self.right_panel.tree_scroll,
)
}
fn render_tree_row(
&self,
row: &TreeRow,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let path = row.entry.path.clone();
let is_dir = row.entry.is_dir;
let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path);
let muted = cx.theme().muted_foreground;
let sf = cx.global::<crate::ui::presets::Surfaces>().popover;
let dirty = self
.tab_code()
.is_some_and(|c| c.files.iter().any(|f| f.dirty && f.path == *path));
let renaming = matches!(
&self.file_tree.editing,
Some(TreeEdit::Rename { path: p, .. }) if *p == path
);
let icon = if row.is_root {
IconName::FolderOpen
} else if is_dir {
if row.expanded {
IconName::FolderOpen
} else {
IconName::Folder
}
} else {
IconName::File
};
let label: AnyElement = if renaming {
let input = self.file_tree.editing.as_ref().unwrap().input().clone();
Input::new(&input).xsmall().into_any_element()
} else {
div()
.flex_1()
.min_w_0()
.text_ellipsis()
.text_sm()
.when(row.entry.ignored, |d| {
d.italic().text_color(muted.opacity(0.7))
})
.when(row.is_root, |d| d.font_weight(gpui::FontWeight::MEDIUM))
.child(SharedString::from(row.entry.name.clone()))
.into_any_element()
};
let row_el = h_flex()
.id(SharedString::from(format!("tree-{}", path.display())))
.items_center()
.gap_1()
.pl(px(6.0 + row.depth as f32 * INDENT))
.pr_1()
.py_1()
.rounded(cx.theme().radius)
.cursor_pointer()
.when(selected, |d| d.bg(gpui::rgb(sf.selected)))
.when(!selected, |d| d.hover(|s| s.bg(gpui::rgb(sf.hover))))
.child(Icon::new(icon).xsmall().text_color(if is_dir {
cx.theme().foreground
} else {
muted
}))
.child(label)
.when(dirty, |d| {
d.child(
div()
.flex_none()
.size(px(6.))
.rounded_full()
.bg(cx.theme().warning),
)
})
.on_mouse_down(
MouseButton::Left,
cx.listener({
let path = path.clone();
move |this, _, window, cx| {
this.file_tree.focus_handle.focus(window, cx);
this.file_tree_activate(&path, is_dir, window, cx);
}
}),
)
.on_drag(ExternalPaths(vec![path.clone()].into()), {
let name = row.entry.name.clone();
move |_, _, _, cx| {
let name = name.clone();
cx.new(|_| DragGhost { name })
}
})
.context_menu({
let app = cx.entity().downgrade();
let path = path.clone();
let is_root = row.is_root;
let show_hidden = self.file_tree.show_hidden;
move |menu, _window, cx| {
let danger = cx.theme().danger;
Self::tree_row_context_menu(
menu,
&path,
is_dir,
is_root,
show_hidden,
danger,
&app,
)
}
});
let mut out: Vec<AnyElement> = vec![row_el.into_any_element()];
if let Some(edit) = &self.file_tree.editing {
let host_matches = match edit {
TreeEdit::NewFile { dir, .. } | TreeEdit::NewFolder { dir, .. } => *dir == path,
TreeEdit::Rename { .. } => false,
};
if host_matches {
let input = edit.input().clone();
out.push(
h_flex()
.items_center()
.gap_1()
.pl(px(6.0 + (row.depth + 1) as f32 * INDENT))
.pr_1()
.py_0p5()
.child(Input::new(&input).xsmall())
.into_any_element(),
);
}
}
out
}
fn tree_row_context_menu(
menu: PopupMenu,
path: &Path,
is_dir: bool,
is_root: bool,
show_hidden: bool,
danger: gpui::Hsla,
app: &gpui::WeakEntity<Self>,
) -> PopupMenu {
let mut menu = menu.min_w(px(200.));
let p = path.to_path_buf();
if !is_dir {
menu = menu.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextOpen)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.open_file_in_editor(&p, window, cx));
}
}),
);
}
if is_dir {
menu = menu.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextCdHere)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.file_tree_cd(&p, window, cx));
}
}),
);
}
menu = menu
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextInsertPath)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
if let Some(leaf) = this
.tabs
.get(this.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
{
leaf.update(cx, |view, cx| view.paste(shell_quote(&p), cx));
}
});
}
}),
)
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextAttachAgent)).on_click({
let app = app.clone();
let p = p.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.file_tree_attach_to_agent(&p, cx));
}
}),
)
.separator()
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextNewFile)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(TreeEditKind::NewFile, &p, is_dir, window, cx)
});
}
}),
)
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextNewFolder)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(
TreeEditKind::NewFolder,
&p,
is_dir,
window,
cx,
)
});
}
}),
);
if !is_root {
menu = menu.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextRename)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(TreeEditKind::Rename, &p, is_dir, window, cx)
});
}
}),
);
}
menu = menu
.separator()
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextCopyPath)).on_click({
let p = p.clone();
move |_, _window, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
p.display().to_string(),
));
}
}),
)
.item(
PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({
let p = p.clone();
move |_, _window, cx| {
cx.reveal_path(&p);
}
}),
);
menu = menu.separator().item(dotfiles_menu_item(show_hidden, app));
if !is_root {
menu = menu.separator().item(
PopupMenuItem::element(move |_window, _cx| {
div().text_color(danger).child(t(L10nKey::Delete))
})
.on_click({
let app = app.clone();
move |_, window, cx| {
let p = p.clone();
let _ =
app.update(cx, |this, cx| this.file_tree_delete(p, is_dir, window, cx));
}
}),
);
}
menu
}
}
fn dotfiles_menu_item(show_hidden: bool, app: &gpui::WeakEntity<Tty7App>) -> PopupMenuItem {
let app = app.clone();
PopupMenuItem::new(if show_hidden {
t(L10nKey::FileTreeContextHideDotfiles)
} else {
t(L10nKey::FileTreeContextShowDotfiles)
})
.on_click(move |_, _window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree.show_hidden = !this.file_tree.show_hidden;
cx.notify();
});
})
}
struct DragGhost {
name: String,
}
impl gpui::Render for DragGhost {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.items_center()
.gap_1()
.px_2()
.py_1()
.rounded(cx.theme().radius)
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.text_sm()
.child(Icon::new(IconName::File).xsmall())
.child(SharedString::from(self.name.clone()))
}
}
fn land_listing(
loads: &mut InFlight<DirKey>,
children: &mut ByHost<PathBuf, Vec<TreeEntry>>,
stale: &mut HashSet<DirKey>,
key: &DirKey,
id: HostId,
dir: PathBuf,
entries: Vec<TreeEntry>,
) -> bool {
let superseded = !loads.finish(key);
children.insert(id, dir, entries);
stale.remove(key);
superseded
}
fn dirs_to_relist(paths: &HashSet<PathBuf>, show_hidden: bool) -> HashSet<&Path> {
paths
.iter()
.filter(|p| event_can_change_a_row(p, show_hidden))
.filter_map(|p| p.parent())
.collect()
}
fn event_can_change_a_row(path: &Path, show_hidden: bool) -> bool {
show_hidden
|| !path
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with('.'))
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, is_dir: bool) -> TreeEntry {
TreeEntry {
name: name.to_string(),
path: PathBuf::from(format!("/x/{name}")),
is_dir,
ignored: false,
}
}
#[test]
fn a_listing_superseded_in_flight_is_still_shown() {
let mut loads: InFlight<DirKey> = InFlight::default();
let mut children: ByHost<PathBuf, Vec<TreeEntry>> = ByHost::default();
let id = HostId::LOCAL;
let dir = PathBuf::from("/home/me");
let key: DirKey = (id, dir.clone());
let mut stale: HashSet<DirKey> = HashSet::new();
assert!(loads.begin(key.clone()), "the listing goes out");
loads.invalidate(&key);
let again = land_listing(
&mut loads,
&mut children,
&mut stale,
&key,
id,
dir.clone(),
vec![entry("src", true)],
);
assert!(again, "superseded, so the caller goes round again");
assert!(
children.get(id, &dir).is_some(),
"the snapshot is on screen rather than thrown away"
);
assert!(loads.begin(key.clone()));
let again = land_listing(
&mut loads,
&mut children,
&mut stale,
&key,
id,
dir.clone(),
vec![entry("src", true)],
);
assert!(!again, "nothing superseded it, so one listing is enough");
}
#[test]
fn an_outdated_listing_stays_on_screen_until_its_replacement_lands() {
let mut loads: InFlight<DirKey> = InFlight::default();
let mut children: ByHost<PathBuf, Vec<TreeEntry>> = ByHost::default();
let mut stale: HashSet<DirKey> = HashSet::new();
let id = HostId::LOCAL;
let dir = PathBuf::from("/home/me");
let key: DirKey = (id, dir.clone());
loads.begin(key.clone());
land_listing(
&mut loads,
&mut children,
&mut stale,
&key,
id,
dir.clone(),
vec![entry("src", true)],
);
stale.insert(key.clone());
assert_eq!(
children.get(id, &dir).map(Vec::len),
Some(1),
"the rows are still there to paint"
);
let current = children.get(id, &dir).is_some() && !stale.contains(&key);
assert!(!current, "stale means re-ask");
loads.begin(key.clone());
land_listing(
&mut loads,
&mut children,
&mut stale,
&key,
id,
dir.clone(),
vec![entry("src", true), entry("README", false)],
);
assert!(!stale.contains(&key), "the replacement clears the mark");
assert_eq!(children.get(id, &dir).map(Vec::len), Some(2));
}
#[test]
fn a_directorys_own_event_does_not_relist_it() {
let batch: HashSet<PathBuf> = [
PathBuf::from("/home/me/.claude.json"),
PathBuf::from("/home/me"),
]
.into_iter()
.collect();
let dirs = dirs_to_relist(&batch, false);
assert!(
!dirs.contains(Path::new("/home/me")),
"the home listing is not re-fetched for a dot-file write"
);
assert!(dirs.contains(Path::new("/home")), "its parent is");
let batch: HashSet<PathBuf> = [
PathBuf::from("/home/me/notes.md"),
PathBuf::from("/home/me"),
]
.into_iter()
.collect();
assert!(dirs_to_relist(&batch, false).contains(Path::new("/home/me")));
let batch: HashSet<PathBuf> = [PathBuf::from("/home/me/.claude.json")]
.into_iter()
.collect();
assert!(dirs_to_relist(&batch, true).contains(Path::new("/home/me")));
assert!(dirs_to_relist(&batch, false).is_empty());
}
#[test]
fn an_unshown_dot_file_does_not_trigger_a_relist() {
let hidden = Path::new("/home/me/.claude.json");
let visible = Path::new("/home/me/src");
assert!(!event_can_change_a_row(hidden, false));
assert!(event_can_change_a_row(hidden, true));
assert!(event_can_change_a_row(visible, false));
assert!(!event_can_change_a_row(
Path::new("/home/me/.config"),
false
));
assert!(event_can_change_a_row(Path::new("/home/me/.config"), true));
}
#[test]
fn sort_puts_dirs_first_then_case_insensitive_names() {
let mut v = vec![
entry("zeta.rs", false),
entry("Alpha", true),
entry("beta", true),
entry("Apple.rs", false),
];
sort_entries(&mut v);
let names: Vec<&str> = v.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["Alpha", "beta", "Apple.rs", "zeta.rs"]);
}
#[test]
fn shell_quote_leaves_safe_paths_and_quotes_the_rest() {
assert_eq!(shell_quote(Path::new("/a/b.txt")), "/a/b.txt");
assert_eq!(shell_quote(Path::new("/a dir/f")), "'/a dir/f'");
assert_eq!(shell_quote(Path::new("/a'b")), r"'/a'\''b'");
}
#[test]
fn search_retarget_spawns_once_per_query_and_older_walks_lose() {
let mut search = SearchState::default();
let first = search.retarget("fo", false).expect("a new query walks");
assert!(
search.retarget("fo", false).is_none(),
"a repaint mid-walk must not queue a second one"
);
let second = search
.retarget("foo", false)
.expect("a changed query walks");
assert_ne!(first, second);
assert!(
!search.accept(first, vec![entry("stale.rs", false)]),
"the overtaken walk's hits are dropped"
);
assert!(search.accept(second, vec![entry("foo.rs", false)]));
assert_eq!(search.hits.len(), 1);
let third = search
.retarget("foo", true)
.expect("showing dotfiles re-walks");
assert_ne!(second, third);
assert!(search.retarget("foo", true).is_none());
assert!(search.retarget("", true).is_none());
assert!(search.hits.is_empty());
search.retarget("foo", true).expect("typing again walks");
search.restart();
assert!(search.retarget("foo", true).is_some(), "restart re-walks");
}
#[test]
fn the_tree_reads_the_same_listing_out_of_the_host() {
let host = tty7_core::host::local::LocalHost::new();
let tmp = std::env::temp_dir().join(format!("tty7-tree-host-{}", std::process::id()));
let _ = host.remove(&tmp, true);
host.create_dir(&tmp.join(".git"), true).unwrap();
host.create_dir(&tmp.join("src"), true).unwrap();
host.write_file(&tmp.join(".gitignore"), b"*.log\nbuild/\n")
.unwrap();
host.write_file(&tmp.join("src/.gitignore"), b"!keep.log\n")
.unwrap();
host.write_file(&tmp.join("drop.log"), b"").unwrap();
host.write_file(&tmp.join("src/keep.log"), b"").unwrap();
host.write_file(&tmp.join("src/main.rs"), b"").unwrap();
let list = |dir: &Path| -> Vec<TreeEntry> {
host.read_dir(dir, Some(&tmp))
.unwrap()
.into_iter()
.map(|e| TreeEntry {
path: host.join(dir, &e.name),
name: e.name,
is_dir: e.is_dir,
ignored: e.ignored,
})
.collect()
};
let ignored = |entries: &[TreeEntry], name: &str| {
entries
.iter()
.find(|e| e.name == name)
.unwrap_or_else(|| panic!("{name} missing"))
.ignored
};
let top = list(&tmp);
assert!(ignored(&top, "drop.log"));
assert!(ignored(&top, ".git"));
assert!(!ignored(&top, "src"));
assert_eq!(
top.iter().find(|e| e.name == "src").unwrap().path,
tmp.join("src"),
"entries carry a full path, rebuilt with the host's separator"
);
let nested = list(&tmp.join("src"));
assert!(!ignored(&nested, "keep.log"), "whitelist un-ignores");
assert!(!ignored(&nested, "main.rs"));
let hits = host
.search(
std::slice::from_ref(&tmp),
"log",
SEARCH_LIMIT,
SEARCH_MAX_DIRS,
false,
)
.unwrap();
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
assert_eq!(names, vec!["keep.log"], "ignored hits stay out of search");
let hidden = host
.search(
std::slice::from_ref(&tmp),
"log",
SEARCH_LIMIT,
SEARCH_MAX_DIRS,
true,
)
.unwrap();
let mut names: Vec<&str> = hidden.iter().map(|h| h.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["drop.log", "keep.log"]);
let _ = host.remove(&tmp, true);
}
#[cfg(unix)]
#[test]
fn a_symlink_cycle_cannot_make_the_search_walk_forever() {
let host = tty7_core::host::local::LocalHost::new();
let tmp = std::env::temp_dir().join(format!("tty7-tree-loop-{}", std::process::id()));
let _ = host.remove(&tmp, true);
host.create_dir(&tmp, true).unwrap();
host.write_file(&tmp.join("needle.rs"), b"").unwrap();
host.create_dir(&tmp.join("a"), true).unwrap();
std::os::unix::fs::symlink(tmp.join("a"), tmp.join("a/loop")).unwrap();
let hits = host
.search(
std::slice::from_ref(&tmp),
"needle",
SEARCH_LIMIT,
SEARCH_MAX_DIRS,
false,
)
.expect("the walk terminates rather than recursing forever");
assert_eq!(
hits.iter().map(|h| h.name.as_str()).collect::<Vec<_>>(),
vec!["needle.rs"],
"breadth-first order finds the shallow hit before the cycle deepens"
);
let listed = host.read_dir(&tmp.join("a"), Some(&tmp)).unwrap();
let link = listed.iter().find(|e| e.name == "loop").expect("link");
assert!(link.is_dir, "a link to a directory expands as one");
assert!(link.is_symlink);
let _ = host.remove(&tmp, true);
}
#[test]
fn a_rejected_write_drops_the_row_it_guessed() {
let host = HostId::LOCAL;
let dir = PathBuf::from("/x");
let mut children: ByHost<PathBuf, Vec<TreeEntry>> = ByHost::default();
let names = |children: &ByHost<PathBuf, Vec<TreeEntry>>| -> Vec<String> {
children
.get(host, &dir)
.map(|v| v.iter().map(|e| e.name.clone()).collect())
.unwrap_or_default()
};
let seed = |children: &mut ByHost<PathBuf, Vec<TreeEntry>>| {
children.insert(host, dir.clone(), vec![entry("b.rs", false)]);
};
seed(&mut children);
let new = entry("a.rs", false);
let before = optimistic_write(&mut children, host, &dir, &TreeWrite::NewFile, &new);
assert!(before.is_some());
assert_eq!(names(&children), vec!["a.rs", "b.rs"]);
seed(&mut children);
let renamed = TreeEntry {
name: "z.rs".into(),
path: PathBuf::from("/x/z.rs"),
is_dir: false,
ignored: false,
};
optimistic_write(
&mut children,
host,
&dir,
&TreeWrite::Rename {
from: PathBuf::from("/x/b.rs"),
},
&renamed,
);
assert_eq!(names(&children), vec!["z.rs"]);
seed(&mut children);
let doomed = entry("b.rs", false);
optimistic_write(&mut children, host, &dir, &TreeWrite::Delete, &doomed);
assert!(names(&children).is_empty());
seed(&mut children);
let before = optimistic_write(&mut children, host, &dir, &TreeWrite::NewFile, &new);
rollback_write(&mut children, host, &dir, before);
assert!(
children.get(host, &dir).is_none(),
"a failed write leaves the directory to relist"
);
seed(&mut children);
let before = optimistic_write(&mut children, host, &dir, &TreeWrite::NewFile, &new);
children.insert(host, dir.clone(), vec![entry("fresh.rs", false)]);
rollback_write(&mut children, host, &dir, before);
assert!(
children.get(host, &dir).is_none(),
"the stale snapshot never overwrites a newer listing"
);
let other = PathBuf::from("/y");
let before = optimistic_write(&mut children, host, &other, &TreeWrite::NewFile, &new);
assert!(before.is_none());
assert!(children.get(host, &other).is_none());
rollback_write(&mut children, host, &other, before);
assert!(children.get(host, &other).is_none());
}
}
#[cfg(all(test, unix))]
mod render_idle_gpui_tests {
use super::*;
use crate::daemon::protocol::DaemonMsg;
use crate::ui::app::{render_probe, test_window};
use gpui::{Entity, TestAppContext, VisualTestContext};
use tty7_core::core::config::RightPanelTab;
const BUDGET: u64 = 200;
fn serial() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tty7-idle-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::canonicalize(&dir).unwrap()
}
fn files_panel_on(
cx: &mut TestAppContext,
root: &Path,
) -> (
Entity<Tty7App>,
VisualTestContext,
std::os::unix::net::UnixStream,
) {
let (app, mut vcx, mut pane) = test_window::harness_with_pane(cx);
DaemonMsg::Cwd(root.to_path_buf())
.encode(&mut pane)
.expect("the pane's socket takes the cwd");
app.update_in(&mut vcx, |app, _, cx| {
app.right_panel_visible = true;
app.right_panel_tab = RightPanelTab::Files;
cx.notify();
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
loop {
vcx.background_executor.run_until_parked();
let rooted = app.update_in(&mut vcx, |app, window, cx| {
app.file_tree_refresh_roots(window, cx);
app.tab_code().map(|c| c.roots.clone()).unwrap_or_default()
== vec![root.to_path_buf()]
});
if rooted {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the pane never reported its cwd"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
loop {
app.update_in(&mut vcx, |_, _, cx| cx.notify());
vcx.background_executor.run_until_parked();
let listed = app.update_in(&mut vcx, |app, _, _| {
app.file_tree.children.get(HostId::LOCAL, root).is_some()
});
if listed {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the root was never listed"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
vcx.background_executor.run_until_parked();
(app, vcx, pane)
}
fn rows(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> usize {
app.update_in(vcx, |app, _, _| {
let code = app.tab_code().expect("panel state");
app.file_tree
.visible_rows(HostId::LOCAL, &code.roots, &code.expanded)
.len()
})
}
fn fs_event(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, path: &Path) {
app.update_in(vcx, |app, _, cx| {
app.file_tree_apply_fs_events(HostId::LOCAL, &HashSet::from([path.to_path_buf()]), cx);
});
}
fn settle(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, root: &Path) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
vcx.background_executor.run_until_parked();
let quiet = app.update_in(vcx, |app, _, _| {
app.file_tree.loads.is_empty()
&& !app
.file_tree
.stale
.iter()
.any(|(_, dir)| dir.starts_with(root))
});
if quiet {
vcx.background_executor.run_until_parked();
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
panic!("the tree never went quiet");
}
fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 {
render_probe::arm(BUDGET);
vcx.background_executor.run_until_parked();
vcx.executor()
.advance_clock(std::time::Duration::from_secs(3));
vcx.background_executor.run_until_parked();
render_probe::arm(BUDGET);
vcx.executor()
.advance_clock(std::time::Duration::from_secs(9));
vcx.background_executor.run_until_parked();
render_probe::draws()
}
#[gpui::test]
fn a_settled_files_panel_reaches_render_idle(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("settled");
std::fs::create_dir_all(root.join("src")).unwrap();
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
assert!(rows(&app, &mut vcx) > 1, "the tree listed nothing");
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_settled_files_panel_on_an_empty_directory_reaches_render_idle(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("empty");
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
assert_eq!(rows(&app, &mut vcx), 1, "the root row and nothing else");
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_settled_files_panel_on_hidden_only_content_reaches_render_idle(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("hidden");
std::fs::write(root.join(".hidden"), "").unwrap();
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
assert_eq!(rows(&app, &mut vcx), 1, "the dotfile is filtered out");
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn an_event_reaching_no_cached_listing_costs_no_frames(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("unlisted");
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
std::fs::create_dir_all(root.join("target/debug")).unwrap();
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
let before = rows(&app, &mut vcx);
render_probe::arm(BUDGET);
for n in 0..5 {
let path = root.join(format!("target/debug/artifact{n}.o"));
std::fs::write(&path, "").unwrap();
fs_event(&app, &mut vcx, &path);
settle(&app, &mut vcx, &root);
}
assert_eq!(render_probe::draws(), 0, "nothing on screen changed");
assert_eq!(rows(&app, &mut vcx), before);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn rewriting_a_file_in_a_displayed_directory_costs_no_frames(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("rewrite");
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
let before = rows(&app, &mut vcx);
render_probe::arm(BUDGET);
for n in 0..5 {
let path = root.join("file00.rs");
std::fs::write(&path, format!("line {n}")).unwrap();
fs_event(&app, &mut vcx, &path);
settle(&app, &mut vcx, &root);
}
assert_eq!(render_probe::draws(), 0, "the listing came back identical");
assert_eq!(rows(&app, &mut vcx), before);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_real_change_still_reaches_the_panel(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("realchange");
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
let before = rows(&app, &mut vcx);
let added = root.join("new.rs");
std::fs::write(&added, "").unwrap();
fs_event(&app, &mut vcx, &added);
assert_eq!(
rows(&app, &mut vcx),
before,
"the rows survive the refresh they triggered"
);
settle(&app, &mut vcx, &root);
assert_eq!(rows(&app, &mut vcx), before + 1, "the new file shows up");
std::fs::remove_file(&added).unwrap();
fs_event(&app, &mut vcx, &added);
settle(&app, &mut vcx, &root);
assert_eq!(rows(&app, &mut vcx), before);
assert_eq!(draws_while_idle(&mut vcx), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_gitignore_that_governs_nothing_cached_costs_no_frames(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("gitignore-unlisted");
std::fs::write(root.join("main.rs"), "").unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
render_probe::arm(BUDGET);
for n in 0..5 {
let path = root.join(format!("node_modules/pkg{n}/.gitignore"));
fs_event(&app, &mut vcx, &path);
settle(&app, &mut vcx, &root);
}
assert_eq!(render_probe::draws(), 0, "it cannot reach a cached listing");
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_gitignore_in_the_displayed_tree_still_refreshes(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("gitignore-displayed");
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
std::fs::write(root.join(".gitignore"), "").unwrap();
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
let before = rows(&app, &mut vcx);
let ignore = root.join(".gitignore");
std::fs::write(&ignore, "file00.rs\n").unwrap();
fs_event(&app, &mut vcx, &ignore);
let marked = app.update_in(&mut vcx, |app, _, _| {
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count()
});
assert!(marked > 0, "the batch reached the tree");
assert_eq!(rows(&app, &mut vcx), before, "rows stay while it re-reads");
settle(&app, &mut vcx, &root);
assert_eq!(rows(&app, &mut vcx), before);
let left = app.update_in(&mut vcx, |app, _, _| {
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count()
});
assert_eq!(left, 0, "every marked listing under the root was re-read");
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn untracked_paths_leave_no_bookkeeping_behind(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("bookkeeping");
std::fs::write(root.join("main.rs"), "").unwrap();
std::fs::create_dir_all(root.join("target/debug")).unwrap();
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
for n in 0..50 {
let path = root.join(format!("target/debug/obj{n}.o"));
fs_event(&app, &mut vcx, &path);
}
settle(&app, &mut vcx, &root);
let marks = app.update_in(&mut vcx, |app, _, _| {
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count()
});
assert_eq!(marks, 0, "nothing the tree holds was reached");
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_moved_repository_root_still_gets_its_frame(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("repo-root");
std::fs::write(root.join("main.rs"), "").unwrap();
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
assert!(
app.update_in(&mut vcx, |app, _, _| !app.file_tree.repo_roots.is_empty()),
"the panel resolved its pane's root, so there is a cache to clear"
);
vcx.executor()
.advance_clock(std::time::Duration::from_secs(3));
vcx.background_executor.run_until_parked();
render_probe::arm(BUDGET);
vcx.executor()
.advance_clock(std::time::Duration::from_secs(3));
vcx.background_executor.run_until_parked();
assert_eq!(render_probe::draws(), 0, "the window is at rest");
fs_event(&app, &mut vcx, &root.join(".git"));
vcx.background_executor.run_until_parked();
assert!(
render_probe::draws() > 0,
"clearing the root cache asked for the paint that re-resolves it"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
vcx.background_executor.run_until_parked();
if app.update_in(&mut vcx, |app, _, _| !app.file_tree.repo_roots.is_empty()) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the cleared root cache was never re-resolved"
);
std::thread::sleep(std::time::Duration::from_millis(20));
}
settle(&app, &mut vcx, &root);
assert_eq!(
app.update_in(&mut vcx, |app, _, _| app
.tab_code()
.map(|c| c.roots.clone())
.unwrap_or_default()),
vec![root.clone()],
"the tree is still rooted where it belongs"
);
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_closed_panel_does_no_filesystem_work(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("closed-panel");
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
app.update_in(&mut vcx, |app, _, cx| {
app.right_panel_visible = false;
cx.notify();
});
vcx.background_executor.run_until_parked();
let path = root.join("file00.rs");
std::fs::write(&path, "changed").unwrap();
fs_event(&app, &mut vcx, &path);
let until = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < until {
vcx.background_executor.run_until_parked();
std::thread::sleep(std::time::Duration::from_millis(50));
}
let (in_flight, marked) = app.update_in(&mut vcx, |app, _, _| {
(
app.file_tree.loads.len(),
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count(),
)
});
assert_eq!(in_flight, 0, "nothing was asked of the host");
assert!(marked > 0, "but the change was recorded");
app.update_in(&mut vcx, |app, _, cx| {
app.right_panel_visible = true;
cx.notify();
});
settle(&app, &mut vcx, &root);
let left = app.update_in(&mut vcx, |app, _, _| {
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count()
});
assert_eq!(left, 0, "the marked listing was re-read on reopening");
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn a_searching_tree_does_no_filesystem_work(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("searching-tree");
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
app.update_in(&mut vcx, |app, window, cx| {
app.file_search
.update(cx, |st, cx| st.set_value("file0", window, cx));
cx.notify();
});
vcx.background_executor.run_until_parked();
assert!(
app.update_in(&mut vcx, |app, _, cx| app.file_tree_searching(cx)
&& !app.file_tree_listings_on_screen(cx)),
"the column is drawn, and what it is drawing is not the listings"
);
let path = root.join("file00.rs");
std::fs::write(&path, "changed").unwrap();
fs_event(&app, &mut vcx, &path);
let until = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < until {
vcx.background_executor.run_until_parked();
std::thread::sleep(std::time::Duration::from_millis(50));
}
let (in_flight, marked) = app.update_in(&mut vcx, |app, _, _| {
(
app.file_tree.loads.len(),
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count(),
)
});
assert_eq!(in_flight, 0, "nothing was asked of the host");
assert!(marked > 0, "but the change was recorded");
app.update_in(&mut vcx, |app, window, cx| {
app.file_search
.update(cx, |st, cx| st.set_value("", window, cx));
cx.notify();
});
settle(&app, &mut vcx, &root);
let left = app.update_in(&mut vcx, |app, _, _| {
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count()
});
assert_eq!(left, 0, "the marked listing was re-read on clearing");
let _ = std::fs::remove_dir_all(&root);
}
#[gpui::test]
fn the_sftp_browser_holding_the_column_counts_as_not_drawn(cx: &mut TestAppContext) {
let _serial = serial();
let root = scratch("sftp-column");
for n in 0..12 {
std::fs::write(root.join(format!("file{n:02}.rs")), "").unwrap();
}
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
let path = root.join("file00.rs");
std::fs::write(&path, "changed").unwrap();
let (on_screen, in_flight, marked) = app.update_in(&mut vcx, |app, _, cx| {
app.sftp_panel.open_pane_id = Some(7);
let on_screen = app.file_tree_on_screen(cx);
app.file_tree_apply_fs_events(HostId::LOCAL, &HashSet::from([path.clone()]), cx);
(
on_screen,
app.file_tree.loads.len(),
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count(),
)
});
assert!(!on_screen, "the SFTP browser has the column, not the tree");
assert_eq!(in_flight, 0, "so nothing was asked of the host");
assert!(marked > 0, "but the change was recorded");
app.update_in(&mut vcx, |app, _, cx| {
app.sftp_panel.open_pane_id = None;
cx.notify();
});
settle(&app, &mut vcx, &root);
let left = app.update_in(&mut vcx, |app, _, _| {
app.file_tree
.stale
.iter()
.filter(|(_, dir)| dir.starts_with(&root))
.count()
});
assert_eq!(left, 0, "the marked listing was re-read once it came back");
let _ = std::fs::remove_dir_all(&root);
}
}