mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
fix(files): tell a failed search from an empty one, and name the file a write failed on
Two ways the file tree answered a failure with something that reads as a
result.
A search was `unwrap_or_default()`ed inside the worker, so a host that
refused the walk left `hits` empty and the column printed "Nothing matches
{query}" — byte-identical to a genuine zero-hit search. The worker now
reports `(ok, hits)` the way `spawn_load` already reports a listing, and a
failed walk draws a `SearchFailed` note in the danger colour, the same
distinction `FileTreeState.unreadable` draws for a directory.
Creating and renaming pushed the bare `io::Error`, so the toast was
literally "Permission denied (os error 13)" — neither which file nor what
was being done to it. Both now go through `HostOps::notify_err` like
delete and drop-copy already do, naming the file; a rename names the name
it is leaving, which is the one still on screen.
This commit is contained in:
+139
-39
@@ -66,6 +66,9 @@ pub(crate) enum TreeNote {
|
||||
/// The search stopped at `SEARCH_LIMIT`; the list is a prefix, not the
|
||||
/// whole answer, and has to say so.
|
||||
SearchCapped,
|
||||
/// The search never ran to an answer — the host refused it or the link to
|
||||
/// it went away. An empty list here means nothing at all.
|
||||
SearchFailed,
|
||||
}
|
||||
|
||||
/// `landed` is how many entries the listing returned, or `None` when nothing
|
||||
@@ -122,6 +125,10 @@ struct SearchState {
|
||||
pending: String,
|
||||
hidden: bool,
|
||||
hits: Vec<TreeEntry>,
|
||||
/// Whether the last search came back as a failure rather than as no hits.
|
||||
/// The two used to print the same "Nothing matches …", which is the same
|
||||
/// lie `unreadable` was added to stop a directory listing from telling.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
@@ -134,22 +141,25 @@ impl SearchState {
|
||||
self.hidden = show_hidden;
|
||||
if query.is_empty() {
|
||||
self.hits.clear();
|
||||
self.failed = false;
|
||||
return None;
|
||||
}
|
||||
Some(self.generation)
|
||||
}
|
||||
|
||||
fn accept(&mut self, generation: u64, hits: Vec<TreeEntry>) -> bool {
|
||||
fn accept(&mut self, generation: u64, ok: bool, hits: Vec<TreeEntry>) -> bool {
|
||||
if self.generation != generation {
|
||||
return false;
|
||||
}
|
||||
self.hits = hits;
|
||||
self.failed = !ok;
|
||||
true
|
||||
}
|
||||
|
||||
fn restart(&mut self) {
|
||||
self.generation += 1;
|
||||
self.pending.clear();
|
||||
self.failed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +448,14 @@ impl FileTreeState {
|
||||
host,
|
||||
cx,
|
||||
move |h| {
|
||||
h.search(&roots, &query, SEARCH_LIMIT, SEARCH_MAX_DIRS, show_hidden)
|
||||
// `(ok, hits)` the way `spawn_load` reports a listing:
|
||||
// a search the host refused is not a search with no
|
||||
// hits, and the column has to be able to tell them
|
||||
// apart before it says "Nothing matches".
|
||||
let found =
|
||||
h.search(&roots, &query, SEARCH_LIMIT, SEARCH_MAX_DIRS, show_hidden);
|
||||
let ok = found.is_ok();
|
||||
let hits = found
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|hit| TreeEntry {
|
||||
@@ -447,10 +464,11 @@ impl FileTreeState {
|
||||
is_dir: hit.is_dir,
|
||||
ignored: hit.ignored,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.collect::<Vec<_>>();
|
||||
(ok, hits)
|
||||
},
|
||||
move |app, hits, cx| {
|
||||
if app.file_tree.search.accept(generation, hits) {
|
||||
move |app, (ok, hits), cx| {
|
||||
if app.file_tree.search.accept(generation, ok, hits) {
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
@@ -461,35 +479,51 @@ impl FileTreeState {
|
||||
}
|
||||
|
||||
fn search_rows(&self) -> Vec<TreeRow> {
|
||||
let mut rows: Vec<TreeRow> = self
|
||||
.search
|
||||
.hits
|
||||
.iter()
|
||||
.map(|e| TreeRow {
|
||||
entry: e.clone(),
|
||||
depth: 0,
|
||||
is_root: false,
|
||||
expanded: false,
|
||||
note: None,
|
||||
})
|
||||
.collect();
|
||||
if rows.len() >= SEARCH_LIMIT {
|
||||
rows.push(TreeRow {
|
||||
entry: TreeEntry {
|
||||
name: String::new(),
|
||||
path: PathBuf::new(),
|
||||
is_dir: false,
|
||||
ignored: false,
|
||||
},
|
||||
depth: 0,
|
||||
is_root: false,
|
||||
expanded: false,
|
||||
note: Some(TreeNote::SearchCapped),
|
||||
});
|
||||
}
|
||||
rows
|
||||
search_rows(&self.search)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows a search puts in the column, and the note that stands for whatever
|
||||
/// they do not say by themselves.
|
||||
fn search_rows(search: &SearchState) -> Vec<TreeRow> {
|
||||
let mut rows: Vec<TreeRow> = search
|
||||
.hits
|
||||
.iter()
|
||||
.map(|e| TreeRow {
|
||||
entry: e.clone(),
|
||||
depth: 0,
|
||||
is_root: false,
|
||||
expanded: false,
|
||||
note: None,
|
||||
})
|
||||
.collect();
|
||||
let note = if search.failed {
|
||||
// Ahead of the cap: a failed search has no hits to have capped, and
|
||||
// this is the one thing worth saying about it.
|
||||
Some(TreeNote::SearchFailed)
|
||||
} else if rows.len() >= SEARCH_LIMIT {
|
||||
Some(TreeNote::SearchCapped)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(note) = note {
|
||||
rows.push(TreeRow {
|
||||
entry: TreeEntry {
|
||||
name: String::new(),
|
||||
path: PathBuf::new(),
|
||||
is_dir: false,
|
||||
ignored: false,
|
||||
},
|
||||
depth: 0,
|
||||
is_root: false,
|
||||
expanded: false,
|
||||
note: Some(note),
|
||||
});
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
impl FileTreeState {
|
||||
pub(crate) fn visible_rows(
|
||||
&self,
|
||||
host: HostId,
|
||||
@@ -1116,6 +1150,20 @@ impl Tty7App {
|
||||
}
|
||||
|
||||
let target = new_path.clone();
|
||||
// The same gap `file_tree_delete` closed: on its own, "Permission
|
||||
// denied (os error 13)" says neither which file nor what was being
|
||||
// done to it, and those are the only two things worth knowing here.
|
||||
// A rename is named by the name it is leaving, which is the one still
|
||||
// on screen to find.
|
||||
let (context, failed_name) = match &edit {
|
||||
TreeEdit::Rename { path, .. } => (
|
||||
L10nKey::FileTreeRenameFailed,
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| name.clone()),
|
||||
),
|
||||
_ => (L10nKey::FileTreeCreateFailed, name.clone()),
|
||||
};
|
||||
HostOps::run_in(
|
||||
host,
|
||||
window,
|
||||
@@ -1141,8 +1189,12 @@ impl Tty7App {
|
||||
{
|
||||
code.selected = None;
|
||||
}
|
||||
use gpui_component::WindowExt as _;
|
||||
window.push_notification(format!("{e}"), cx);
|
||||
HostOps::notify_err(
|
||||
window,
|
||||
cx,
|
||||
&t_fmt(context, &[("name", &failed_name)]),
|
||||
&e,
|
||||
);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
@@ -1503,6 +1555,7 @@ impl Tty7App {
|
||||
TreeNote::HiddenOnly => (L10nKey::TreeDirHiddenOnly, muted),
|
||||
TreeNote::Unreadable => (L10nKey::TreeDirUnreadable, cx.theme().danger),
|
||||
TreeNote::SearchCapped => (L10nKey::TreeSearchCapped, muted),
|
||||
TreeNote::SearchFailed => (L10nKey::TreeSearchFailed, cx.theme().danger),
|
||||
};
|
||||
return vec![
|
||||
h_flex()
|
||||
@@ -1519,9 +1572,10 @@ impl Tty7App {
|
||||
TreeNote::SearchCapped => t_fmt(key, &[("n", &SEARCH_LIMIT.to_string())]),
|
||||
_ => t(key).to_string(),
|
||||
})
|
||||
// Every note but the capped-search one stands for a real
|
||||
// directory, and carries its path; that one stands for the
|
||||
// rest of a search and has nowhere to put anything.
|
||||
// Every note but the two search ones stands for a real
|
||||
// directory, and carries its path; those stand for the rest
|
||||
// of a search, or for one that never ran, and have nowhere
|
||||
// to put anything.
|
||||
.when(!path.as_os_str().is_empty(), |d| {
|
||||
d.drag_over::<ExternalPaths>(|s, _, _, cx| {
|
||||
s.bg(cx.theme().drag_border.opacity(0.14))
|
||||
@@ -2058,6 +2112,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_search_says_so_instead_of_drawing_no_rows() {
|
||||
let mut search = SearchState::default();
|
||||
let walk = search.retarget("foo", false).expect("a new query walks");
|
||||
search.accept(walk, false, Vec::new());
|
||||
assert_eq!(
|
||||
search_rows(&search)
|
||||
.iter()
|
||||
.filter_map(|r| r.note)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![TreeNote::SearchFailed],
|
||||
"without a note the column falls through to \"Nothing matches\""
|
||||
);
|
||||
|
||||
// A search that ran and found nothing still draws nothing.
|
||||
let walk = search
|
||||
.retarget("bar", false)
|
||||
.expect("a changed query walks");
|
||||
search.accept(walk, true, Vec::new());
|
||||
assert!(search_rows(&search).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listing_superseded_in_flight_is_still_shown() {
|
||||
let mut loads: InFlight<DirKey> = InFlight::default();
|
||||
@@ -2426,10 +2502,10 @@ mod tests {
|
||||
assert_ne!(first, second);
|
||||
|
||||
assert!(
|
||||
!search.accept(first, vec![entry("stale.rs", false)]),
|
||||
!search.accept(first, true, vec![entry("stale.rs", false)]),
|
||||
"the overtaken walk's hits are dropped"
|
||||
);
|
||||
assert!(search.accept(second, vec![entry("foo.rs", false)]));
|
||||
assert!(search.accept(second, true, vec![entry("foo.rs", false)]));
|
||||
assert_eq!(search.hits.len(), 1);
|
||||
|
||||
let third = search
|
||||
@@ -2445,6 +2521,30 @@ mod tests {
|
||||
assert!(search.retarget("foo", true).is_some(), "restart re-walks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_that_failed_is_not_a_search_with_no_hits() {
|
||||
let mut search = SearchState::default();
|
||||
let walk = search.retarget("foo", false).expect("a new query walks");
|
||||
assert!(search.accept(walk, false, Vec::new()));
|
||||
assert!(
|
||||
search.failed,
|
||||
"an empty list from a host that refused the walk is not an answer"
|
||||
);
|
||||
|
||||
// And it is not carried past the query it belongs to.
|
||||
let next = search
|
||||
.retarget("food", false)
|
||||
.expect("a changed query walks");
|
||||
assert!(search.accept(next, true, vec![entry("food.rs", false)]));
|
||||
assert!(!search.failed);
|
||||
|
||||
let last = search.retarget("foodie", false).expect("and again");
|
||||
assert!(search.accept(last, false, Vec::new()));
|
||||
assert!(search.failed);
|
||||
search.retarget("", false);
|
||||
assert!(!search.failed, "an emptied box has nothing to report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tree_reads_the_same_listing_out_of_the_host() {
|
||||
let host = tty7_core::host::local::LocalHost::new();
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::TreeDirHiddenOnly => "Only hidden files",
|
||||
L10nKey::TreeDirUnreadable => "Could not be read",
|
||||
L10nKey::TreeSearchCapped => "First {n} matches",
|
||||
L10nKey::TreeSearchFailed => "Search failed",
|
||||
L10nKey::FileChangedOnDisk => "File changed on disk",
|
||||
L10nKey::Reload => "Reload",
|
||||
L10nKey::KeepMine => "Keep mine",
|
||||
@@ -846,6 +847,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
"The file will be deleted on {host}. There is no trash on the far side."
|
||||
}
|
||||
L10nKey::FileTreeDeleteFailed => "Could not delete {name}",
|
||||
L10nKey::FileTreeCreateFailed => "Could not create {name}",
|
||||
L10nKey::FileTreeRenameFailed => "Could not rename {name}",
|
||||
L10nKey::FileTreeContextOpen => "Open",
|
||||
L10nKey::FileTreeContextCdHere => "cd Here",
|
||||
L10nKey::FileTreeContextInsertPath => "Insert Path in Terminal",
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::TreeDirHiddenOnly => "隠しファイルのみ",
|
||||
L10nKey::TreeDirUnreadable => "読み取れません",
|
||||
L10nKey::TreeSearchCapped => "最初の {n} 件のみ",
|
||||
L10nKey::TreeSearchFailed => "検索に失敗しました",
|
||||
L10nKey::FileChangedOnDisk => "ディスク上でファイルが変更されました",
|
||||
L10nKey::Reload => "再読み込み",
|
||||
L10nKey::KeepMine => "自分の変更を保持",
|
||||
@@ -888,6 +889,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
"{host} 上でファイルが削除されます。リモート側にゴミ箱はありません。"
|
||||
}
|
||||
L10nKey::FileTreeDeleteFailed => "{name} を削除できませんでした",
|
||||
L10nKey::FileTreeCreateFailed => "{name} を作成できませんでした",
|
||||
L10nKey::FileTreeRenameFailed => "{name} の名前を変更できませんでした",
|
||||
L10nKey::FileTreeContextOpen => "開く",
|
||||
L10nKey::FileTreeContextCdHere => "ここで cd",
|
||||
L10nKey::FileTreeContextInsertPath => "ターミナルにパスを挿入",
|
||||
|
||||
@@ -107,6 +107,7 @@ l10n_keys! {
|
||||
TreeDirHiddenOnly,
|
||||
TreeDirUnreadable,
|
||||
TreeSearchCapped,
|
||||
TreeSearchFailed,
|
||||
FileChangedOnDisk,
|
||||
Reload,
|
||||
KeepMine,
|
||||
@@ -648,6 +649,8 @@ l10n_keys! {
|
||||
SftpDeleteFolderBody,
|
||||
SftpDeleteFileBody,
|
||||
FileTreeDeleteFailed,
|
||||
FileTreeCreateFailed,
|
||||
FileTreeRenameFailed,
|
||||
FileTreeContextOpen,
|
||||
FileTreeContextCdHere,
|
||||
FileTreeContextInsertPath,
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::TreeDirHiddenOnly => "只有隐藏文件",
|
||||
L10nKey::TreeDirUnreadable => "无法读取",
|
||||
L10nKey::TreeSearchCapped => "只显示前 {n} 个匹配",
|
||||
L10nKey::TreeSearchFailed => "搜索失败",
|
||||
L10nKey::FileChangedOnDisk => "文件在磁盘上已被修改",
|
||||
L10nKey::Reload => "重新加载",
|
||||
L10nKey::KeepMine => "保留我的版本",
|
||||
@@ -811,6 +812,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
}
|
||||
L10nKey::SftpDeleteFileBody => "该文件将在 {host} 上被删除。远端没有回收站。",
|
||||
L10nKey::FileTreeDeleteFailed => "无法删除 {name}",
|
||||
L10nKey::FileTreeCreateFailed => "无法创建 {name}",
|
||||
L10nKey::FileTreeRenameFailed => "无法重命名 {name}",
|
||||
L10nKey::FileTreeContextOpen => "打开",
|
||||
L10nKey::FileTreeContextCdHere => "cd 到此处",
|
||||
L10nKey::FileTreeContextInsertPath => "在终端中插入路径",
|
||||
|
||||
Reference in New Issue
Block a user