Path completion, fixes #86

This commit is contained in:
Jeremy Soller 2025-02-07 09:11:20 -07:00
parent 9ca5bfaf19
commit 6612c5203e
No known key found for this signature in database
GPG key ID: 670FDFB5428E05CA

View file

@ -46,6 +46,7 @@ use std::{
cell::Cell, cell::Cell,
cmp::Ordering, cmp::Ordering,
collections::HashMap, collections::HashMap,
error::Error,
fmt::{self, Display}, fmt::{self, Display},
fs::{self, File, Metadata}, fs::{self, File, Metadata},
io::{BufRead, BufReader}, io::{BufRead, BufReader},
@ -234,6 +235,41 @@ pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Han
.handle() .handle()
} }
fn tab_complete(path: &Path) -> Result<Vec<(String, PathBuf)>, Box<dyn Error>> {
let parent = if path.exists() {
path
} else {
path.parent()
.ok_or_else(|| format!("path has no parent {:?}", path))?
};
let child_os = path.strip_prefix(&parent).unwrap_or_else(|_| Path::new(""));
let child = child_os
.to_str()
.ok_or_else(|| format!("invalid UTF-8 {:?}", child_os))?;
let pattern = format!("^{}", regex::escape(&child));
let regex = regex::RegexBuilder::new(&pattern)
.case_insensitive(true)
.build()?;
let mut completions = Vec::new();
for entry_res in fs::read_dir(&parent)? {
let entry = entry_res?;
let file_name_os = entry.file_name();
let Some(file_name) = file_name_os.to_str() else {
continue;
};
if regex.is_match(&file_name) {
completions.push((file_name.to_string(), entry.path()));
}
}
completions.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.0, &b.0));
println!("{:?}", completions);
Ok(completions)
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub fn trash_entries() -> usize { pub fn trash_entries() -> usize {
0 0
@ -905,6 +941,56 @@ pub fn scan_desktop(
items items
} }
#[derive(Clone, Debug)]
pub struct EditLocation {
pub location: Location,
pub completions: Option<Vec<(String, PathBuf)>>,
pub selected: Option<usize>,
}
impl EditLocation {
pub fn resolve(&self) -> Option<Location> {
let Some(selected) = self.selected else {
return Some(self.location.clone());
};
let completions = self.completions.as_ref()?;
let completion = completions.get(selected)?;
Some(self.location.with_path(completion.1.clone()))
}
pub fn select(&mut self, forwards: bool) {
if let Some(completions) = &self.completions {
if completions.is_empty() {
self.selected = None;
} else {
let mut selected = if forwards {
self.selected.and_then(|x| x.checked_add(1)).unwrap_or(0)
} else {
self.selected
.and_then(|x| x.checked_sub(1))
.unwrap_or(completions.len() - 1)
};
if selected >= completions.len() {
selected = 0;
}
self.selected = Some(selected);
}
} else {
self.selected = None;
}
}
}
impl From<Location> for EditLocation {
fn from(location: Location) -> Self {
Self {
location,
completions: None,
selected: None,
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Location { pub enum Location {
Desktop(PathBuf, String, DesktopConfig), Desktop(PathBuf, String, DesktopConfig),
@ -1032,8 +1118,10 @@ pub enum Message {
LocationContextMenuIndex(Option<usize>), LocationContextMenuIndex(Option<usize>),
LocationMenuAction(LocationMenuAction), LocationMenuAction(LocationMenuAction),
Drag(Option<Rectangle>), Drag(Option<Rectangle>),
EditLocation(Option<Location>), EditLocation(Option<EditLocation>),
EditLocationComplete(usize),
EditLocationEnable, EditLocationEnable,
EditLocationSubmit,
OpenInNewTab(PathBuf), OpenInNewTab(PathBuf),
EmptyTrash, EmptyTrash,
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
@ -1062,6 +1150,7 @@ pub enum Message {
SelectLast, SelectLast,
SetOpenWith(Mime, String), SetOpenWith(Mime, String),
SetSort(HeadingOptions, bool), SetSort(HeadingOptions, bool),
TabComplete(PathBuf, Vec<(String, PathBuf)>),
Thumbnail(PathBuf, ItemThumbnail), Thumbnail(PathBuf, ItemThumbnail),
ToggleShowHidden, ToggleShowHidden,
View(View), View(View),
@ -1654,7 +1743,7 @@ pub struct Tab {
pub scroll_opt: Option<AbsoluteOffset>, pub scroll_opt: Option<AbsoluteOffset>,
pub size_opt: Cell<Option<Size>>, pub size_opt: Cell<Option<Size>>,
pub item_view_size_opt: Cell<Option<Size>>, pub item_view_size_opt: Cell<Option<Size>>,
pub edit_location: Option<Location>, pub edit_location: Option<EditLocation>,
pub edit_location_id: widget::Id, pub edit_location_id: widget::Id,
pub history_i: usize, pub history_i: usize,
pub history: Vec<Location>, pub history: Vec<Location>,
@ -2346,18 +2435,29 @@ impl Tab {
} }
} }
Message::EditLocation(edit_location) => { Message::EditLocation(edit_location) => {
if self.edit_location.is_none() && edit_location.is_some() { self.edit_location = edit_location;
if self.edit_location.is_some() {
commands.push(Command::Iced( commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(), widget::text_input::focus(self.edit_location_id.clone()).into(),
)); ));
} }
self.edit_location = edit_location; }
Message::EditLocationComplete(selected) => {
if let Some(mut edit_location) = self.edit_location.take() {
edit_location.selected = Some(selected);
cd = edit_location.resolve();
}
} }
Message::EditLocationEnable => { Message::EditLocationEnable => {
commands.push(Command::Iced( commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(), widget::text_input::focus(self.edit_location_id.clone()).into(),
)); ));
self.edit_location = Some(self.location.clone()); self.edit_location = Some(self.location.clone().into());
}
Message::EditLocationSubmit => {
if let Some(edit_location) = self.edit_location.take() {
cd = edit_location.resolve();
}
} }
Message::OpenInNewTab(path) => { Message::OpenInNewTab(path) => {
commands.push(Command::OpenInNewTab(path)); commands.push(Command::OpenInNewTab(path));
@ -2450,7 +2550,9 @@ impl Tab {
} }
} }
Message::ItemDown => { Message::ItemDown => {
if self.gallery { if let Some(edit_location) = &mut self.edit_location {
edit_location.select(true);
} else if self.gallery {
for command in self.update(Message::GalleryNext, modifiers) { for command in self.update(Message::GalleryNext, modifiers) {
commands.push(command); commands.push(command);
} }
@ -2574,7 +2676,9 @@ impl Tab {
} }
} }
Message::ItemUp => { Message::ItemUp => {
if self.gallery { if let Some(edit_location) = &mut self.edit_location {
edit_location.select(false);
} else if self.gallery {
for command in self.update(Message::GalleryPrevious, modifiers) { for command in self.update(Message::GalleryPrevious, modifiers) {
commands.push(command); commands.push(command);
} }
@ -2829,6 +2933,16 @@ impl Tab {
self.sort_direction = dir; self.sort_direction = dir;
} }
} }
Message::TabComplete(path, completions) => {
if let Some(edit_location) = &mut self.edit_location {
if edit_location.location.path_opt() == Some(&path) {
edit_location.completions = Some(completions);
commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(),
));
}
}
}
Message::Thumbnail(path, thumbnail) => { Message::Thumbnail(path, thumbnail) => {
if let Some(ref mut items) = self.items_opt { if let Some(ref mut items) = self.items_opt {
let location = Location::Path(path); let location = Location::Path(path);
@ -2980,7 +3094,7 @@ impl Tab {
} }
// Change directory if requested // Change directory if requested
if let Some(location) = cd { if let Some(mut location) = cd {
if matches!(self.mode, Mode::Desktop) { if matches!(self.mode, Mode::Desktop) {
match location { match location {
Location::Path(path) => { Location::Path(path) => {
@ -2991,16 +3105,34 @@ impl Tab {
} }
_ => {} _ => {}
} }
} else if location != self.location { } else {
if location.path_opt().map_or(true, |path| path.is_dir()) { // Select parent if location is not directory
let prev_path = self let mut selected_paths = None;
.location if let Some(path) = location.path_opt() {
.path_opt() if !path.is_dir() {
.map(|path| vec![path.to_path_buf()]); if let Some(parent) = path.parent() {
self.change_location(&location, history_i_opt); selected_paths = Some(vec![path.to_path_buf()]);
commands.push(Command::ChangeLocation(self.title(), location, prev_path)); location = location.with_path(parent.to_path_buf());
} else { }
log::warn!("tried to cd to {:?} which is not a directory", location); }
}
if location != self.location {
if location.path_opt().map_or(true, |path| path.is_dir()) {
if selected_paths.is_none() {
selected_paths = self
.location
.path_opt()
.map(|path| vec![path.to_path_buf()]);
}
self.change_location(&location, history_i_opt);
commands.push(Command::ChangeLocation(
self.title(),
location,
selected_paths,
));
} else {
log::warn!("tried to cd to {:?} which is not a directory", location);
}
} }
} }
} }
@ -3421,34 +3553,62 @@ impl Tab {
let heading_rule = widget::container(horizontal_rule(1)) let heading_rule = widget::container(horizontal_rule(1))
.padding([0, theme::active().cosmic().corner_radii.radius_xs[0] as u16]); .padding([0, theme::active().cosmic().corner_radii.radius_xs[0] as u16]);
if let Some(location) = &self.edit_location { if let Some(edit_location) = &self.edit_location {
//TODO: allow editing other locations if let Some(location) = edit_location.resolve() {
if let Some(path) = location.path_opt() { //TODO: allow editing other locations
row = row.push( if let Some(path) = location.path_opt().map(|x| x.to_path_buf()) {
widget::button::custom( row = row.push(
widget::icon::from_name("window-close-symbolic").size(16), widget::button::custom(
) widget::icon::from_name("window-close-symbolic").size(16),
.on_press(Message::EditLocation(None)) )
.padding(space_xxs) .on_press(Message::EditLocation(None))
.class(theme::Button::Icon), .padding(space_xxs)
); .class(theme::Button::Icon),
row = row.push( );
widget::text_input("", path.to_string_lossy()) let location = location.clone();
let text_input = widget::text_input("", path.to_string_lossy().to_string())
.id(self.edit_location_id.clone()) .id(self.edit_location_id.clone())
.on_input(|input| { .on_input(move |input| {
Message::EditLocation(Some(location.with_path(PathBuf::from(input)))) Message::EditLocation(Some(
location.with_path(PathBuf::from(input)).into(),
))
}) })
.on_submit(Message::Location(location.clone())) .on_submit(Message::EditLocationSubmit)
.line_height(1.0), .line_height(1.0);
); let mut popover =
let mut column = widget::column::with_capacity(4).padding([0, space_s]); widget::popover(text_input).position(widget::popover::Position::Bottom);
column = column.push(row); if let Some(completions) = &edit_location.completions {
column = column.push(accent_rule); if !completions.is_empty() {
if self.config.view == View::List && !condensed { let mut column =
column = column.push(heading_row); widget::column::with_capacity(completions.len()).padding(space_xxs);
column = column.push(heading_rule); for (i, (name, _path)) in completions.iter().enumerate() {
let selected = edit_location.selected == Some(i);
column = column.push(
widget::button::custom(widget::text::body(name))
.class(button_style(selected, false, false, false, false))
.on_press(Message::EditLocationComplete(i))
.padding(space_xxs)
.width(Length::Fill),
);
}
popover = popover.popup(
widget::layer_container(column)
.layer(cosmic_theme::Layer::Background)
//TODO: This is a hack to get the popover to be the right width
.max_width(size.width - 124.0),
);
}
}
row = row.push(popover);
let mut column = widget::column::with_capacity(4).padding([0, space_s]);
column = column.push(row);
column = column.push(accent_rule);
if self.config.view == View::List && !condensed {
column = column.push(heading_row);
column = column.push(heading_rule);
}
return column.into();
} }
return column.into();
} }
} else if let Some(path) = self.location.path_opt() { } else if let Some(path) = self.location.path_opt() {
row = row.push( row = row.push(
@ -3456,7 +3616,7 @@ impl Tab {
widget::button::custom(widget::icon::from_name("edit-symbolic").size(16)) widget::button::custom(widget::icon::from_name("edit-symbolic").size(16))
.padding(space_xxs) .padding(space_xxs)
.class(theme::Button::Icon) .class(theme::Button::Icon)
.on_press(Message::EditLocation(Some(self.location.clone()))), .on_press(Message::EditLocation(Some(self.location.clone().into()))),
) )
.on_middle_press(move |_| Message::OpenInNewTab(path.clone())), .on_middle_press(move |_| Message::OpenInNewTab(path.clone())),
); );
@ -4432,151 +4592,149 @@ impl Tab {
} }
pub fn subscription(&self, preview: bool) -> Subscription<Message> { pub fn subscription(&self, preview: bool) -> Subscription<Message> {
let Some(items) = &self.items_opt else {
return Subscription::none();
};
//TODO: how many thumbnail loads should be in flight at once? //TODO: how many thumbnail loads should be in flight at once?
let jobs = 8; let jobs = 8;
let mut subscriptions = Vec::with_capacity(jobs + 1); let mut subscriptions = Vec::with_capacity(jobs + 3);
//TODO: move to function if let Some(items) = &self.items_opt {
let visible_rect = { //TODO: move to function
let point = match self.scroll_opt { let visible_rect = {
Some(offset) => Point::new(0.0, offset.y), let point = match self.scroll_opt {
None => Point::new(0.0, 0.0), Some(offset) => Point::new(0.0, offset.y),
None => Point::new(0.0, 0.0),
};
let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
Rectangle::new(point, size)
}; };
let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
Rectangle::new(point, size)
};
//TODO: HACK to ensure positions are up to date since subscription runs before view //TODO: HACK to ensure positions are up to date since subscription runs before view
match self.config.view { match self.config.view {
View::Grid => _ = self.grid_view(), View::Grid => _ = self.grid_view(),
View::List => _ = self.list_view(), View::List => _ = self.list_view(),
}; };
for item in items.iter() { for item in items.iter() {
if item.thumbnail_opt.is_some() { if item.thumbnail_opt.is_some() {
// Skip items that already have a mime type and thumbnail // Skip items that already have a mime type and thumbnail
continue; continue;
} }
match item.rect_opt.get() { match item.rect_opt.get() {
Some(rect) => { Some(rect) => {
if !rect.intersects(&visible_rect) { if !rect.intersects(&visible_rect) {
// Skip items that are not visible // Skip items that are not visible
continue;
}
}
None => {
// Skip items with no determined rect (this should include hidden items)
continue; continue;
} }
} }
None => {
// Skip items with no determined rect (this should include hidden items) let Some(path) = item.path_opt().map(|path| path.to_path_buf()) else {
continue; continue;
};
let ItemMetadata::Path { metadata, .. } = item.metadata.clone() else {
continue;
};
let mime = item.mime.clone();
subscriptions.push(Subscription::run_with_id(
("thumbnail", path.clone()),
stream::channel(1, |mut output| async move {
let message = {
let path = path.clone();
tokio::task::spawn_blocking(move || {
let start = Instant::now();
let thumbnail =
ItemThumbnail::new(&path, metadata, mime, THUMBNAIL_SIZE);
log::debug!("thumbnailed {:?} in {:?}", path, start.elapsed());
Message::Thumbnail(path.clone(), thumbnail)
})
.await
.unwrap()
};
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!("failed to send thumbnail for {:?}: {}", &path, err);
}
}
std::future::pending().await
}),
));
if subscriptions.len() >= jobs {
break;
} }
} }
let Some(path) = item.path_opt().map(|path| path.to_path_buf()) else { if preview {
continue; // Load directory size for selected items
}; if let Some(item) = items
let ItemMetadata::Path { metadata, .. } = item.metadata.clone() else { .iter()
continue; .find(|item| item.selected)
}; .or(self.parent_item_opt.as_ref())
let mime = item.mime.clone(); {
// Item must have a path
subscriptions.push(Subscription::run_with_id( if let Some(path) = item.path_opt().map(|path| path.to_path_buf()) {
("thumbnail", path.clone()), // Item must be calculating directory size
stream::channel(1, |mut output| async move { if let DirSize::Calculating(controller) = &item.dir_size {
let message = { let controller = controller.clone();
let path = path.clone(); subscriptions.push(Subscription::run_with_id(
tokio::task::spawn_blocking(move || { ("dir_size", path.clone()),
let start = Instant::now(); stream::channel(1, |mut output| async move {
let thumbnail = let message = {
ItemThumbnail::new(&path, metadata, mime, THUMBNAIL_SIZE); let path = path.clone();
log::debug!("thumbnailed {:?} in {:?}", path, start.elapsed()); tokio::task::spawn_blocking(move || {
Message::Thumbnail(path.clone(), thumbnail) let start = Instant::now();
}) match calculate_dir_size(&path, controller) {
.await Ok(size) => {
.unwrap() log::debug!(
}; "calculated directory size of {:?} in {:?}",
path,
match output.send(message).await { start.elapsed()
Ok(()) => {} );
Err(err) => { Message::DirectorySize(
log::warn!("failed to send thumbnail for {:?}: {}", &path, err); path.clone(),
} DirSize::Directory(size),
} )
}
std::future::pending().await Err(err) => {
}), log::warn!(
));
if subscriptions.len() >= jobs {
break;
}
}
if preview {
// Load directory size for selected items
if let Some(item) = items
.iter()
.find(|item| item.selected)
.or(self.parent_item_opt.as_ref())
{
// Item must have a path
if let Some(path) = item.path_opt().map(|path| path.to_path_buf()) {
// Item must be calculating directory size
if let DirSize::Calculating(controller) = &item.dir_size {
let controller = controller.clone();
subscriptions.push(Subscription::run_with_id(
("dir_size", path.clone()),
stream::channel(1, |mut output| async move {
let message = {
let path = path.clone();
tokio::task::spawn_blocking(move || {
let start = Instant::now();
match calculate_dir_size(&path, controller) {
Ok(size) => {
log::debug!(
"calculated directory size of {:?} in {:?}",
path,
start.elapsed()
);
Message::DirectorySize(
path.clone(),
DirSize::Directory(size),
)
}
Err(err) => {
log::warn!(
"failed to calculate directory size of {:?}: {}", "failed to calculate directory size of {:?}: {}",
path, path,
err err
); );
Message::DirectorySize( Message::DirectorySize(
path.clone(), path.clone(),
DirSize::Error(err), DirSize::Error(err),
) )
}
} }
})
.await
.unwrap()
};
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!(
"failed to send directory size for {:?}: {}",
&path,
err
);
} }
})
.await
.unwrap()
};
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!(
"failed to send directory size for {:?}: {}",
&path,
err
);
} }
}
std::future::pending().await std::future::pending().await
}), }),
)); ));
}
} }
} }
} }
@ -4673,6 +4831,46 @@ impl Tab {
)); ));
} }
if let Some(path) = self
.edit_location
.as_ref()
.and_then(|x| x.location.path_opt())
.map(|x| x.to_path_buf())
{
subscriptions.push(Subscription::run_with_id(
("tab_complete", path.to_string_lossy().to_string()),
stream::channel(1, |mut output| async move {
let message = {
let path = path.clone();
tokio::task::spawn_blocking(move || {
let start = Instant::now();
match tab_complete(&path) {
Ok(completions) => {
log::info!("tab completed {:?} in {:?}", path, start.elapsed());
Message::TabComplete(path.clone(), completions)
}
Err(err) => {
log::warn!("failed to tab complete {:?}: {}", path, err);
Message::TabComplete(path.clone(), Vec::new())
}
}
})
.await
.unwrap()
};
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!("failed to send tab completion for {:?}: {}", path, err);
}
}
std::future::pending().await
}),
));
}
Subscription::batch(subscriptions) Subscription::batch(subscriptions)
} }
} }