mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
* 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>
2453 lines
83 KiB
Rust
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);
|
|
}
|
|
}
|