diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 08a14a1f..2b0057db 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -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, + /// 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) -> bool { + fn accept(&mut self, generation: u64, ok: bool, hits: Vec) -> 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::>() + .collect::>(); + (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 { - let mut rows: Vec = 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 { + let mut rows: Vec = 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::(|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![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 = 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(); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 4247b3e2..65229f14 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -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", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 2b3b8a7e..f544bd57 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -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 => "ターミナルにパスを挿入", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index afe4646d..7bd0f62c 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -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, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 4b11ddd5..45aba1f7 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -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 => "在终端中插入路径",