Merge pull request #1390 from Cheong-Lau/enter-network-path

feat(tab): allow entering of network uri as path
This commit is contained in:
Jeremy Soller 2025-12-01 14:38:10 -07:00 • committed by GitHub
commit 3baaf4b452
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 165 additions and 95 deletions

View file

@ -3004,7 +3004,12 @@ impl Application for App {
.as_ref() .as_ref()
.map_or_else(|| &tab.location, |x| &x.location); .map_or_else(|| &tab.location, |x| &x.location);
// Try to add text to end of location // Try to add text to end of location
if let Some(path) = location.path_opt() { if let Location::Network(uri, ..) = location {
let mut uri_string = uri.clone();
uri_string.push_str(&text);
tab.edit_location =
Some(location.with_uri(uri_string).into());
} else if let Some(path) = location.path_opt() {
let mut path_string = let mut path_string =
path.to_string_lossy().into_owned(); path.to_string_lossy().into_owned();
path_string.push_str(&text); path_string.push_str(&text);

View file

@ -16,6 +16,24 @@ use crate::{
const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri"; const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri";
fn resolve_uri(uri: &str) -> (String, gio::File) {
let file = gio::File::for_uri(uri);
// Resolve the target-uri if it exists
if let Ok(file_info) = file.query_info(
TARGET_URI_ATTRIBUTE,
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
) {
if let Some(resolved_uri) = file_info.attribute_as_string(TARGET_URI_ATTRIBUTE) {
let resolved_uri = String::from(resolved_uri);
let file = gio::File::for_uri(&resolved_uri);
return (resolved_uri, file);
}
}
(uri.to_string(), file)
}
fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option<PathBuf> { fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option<PathBuf> {
if let Some(themed_icon) = icon.downcast_ref::<gio::ThemedIcon>() { if let Some(themed_icon) = icon.downcast_ref::<gio::ThemedIcon>() {
for name in themed_icon.names() { for name in themed_icon.names() {
@ -83,19 +101,8 @@ fn items(monitor: &gio::VolumeMonitor, sizes: IconSizes) -> MounterItems {
} }
fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> { fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
let mut file = gio::File::for_uri(uri);
let force_dir = uri.starts_with("network:///"); let force_dir = uri.starts_with("network:///");
let (_, file) = resolve_uri(uri);
// Resolve the target-uri if it exists
if let Ok(file_info) = file.query_info(
TARGET_URI_ATTRIBUTE,
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
) {
if let Some(resolved_uri) = file_info.attribute_as_string(TARGET_URI_ATTRIBUTE) {
file = gio::File::for_uri(resolved_uri.as_str());
}
}
// Read .hidden file if present // Read .hidden file if present
let hidden_files: Box<[String]> = if let Some(path) = file.path() { let hidden_files: Box<[String]> = if let Some(path) = file.path() {
@ -210,6 +217,17 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
Ok(items) Ok(items)
} }
fn dir_info(uri: &str) -> Result<(String, String), glib::Error> {
let (resolved_uri, file) = resolve_uri(uri);
let info = file.query_info(
gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME,
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
)?;
Ok((resolved_uri, info.display_name().into()))
}
fn mount_op(uri: String, event_tx: mpsc::UnboundedSender<Event>) -> gio::MountOperation { fn mount_op(uri: String, event_tx: mpsc::UnboundedSender<Event>) -> gio::MountOperation {
let mount_op = gio::MountOperation::new(); let mount_op = gio::MountOperation::new();
mount_op.connect_ask_password( mount_op.connect_ask_password(
@ -270,6 +288,7 @@ enum Cmd {
IconSizes, IconSizes,
mpsc::Sender<Result<Vec<tab::Item>, String>>, mpsc::Sender<Result<Vec<tab::Item>, String>>,
), ),
DirInfo(String, mpsc::Sender<Result<(String, String), glib::Error>>),
Unmount(MounterItem), Unmount(MounterItem),
} }
@ -494,38 +513,27 @@ impl Gvfs {
} }
); );
} }
Cmd::NetworkScan(mut uri, sizes, items_tx) => { Cmd::NetworkScan(uri, sizes, items_tx) => {
let original_uri = uri.clone(); let (resolved_uri, file) = resolve_uri(&uri);
let mut file = gio::File::for_uri(&uri);
if let Ok(file_info) = file.query_info(
TARGET_URI_ATTRIBUTE,
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
) {
if let Some(resolved_uri) = file_info.attribute_as_string(TARGET_URI_ATTRIBUTE) {
uri = resolved_uri.into();
file = gio::File::for_uri(&uri);
}
}
let needs_mount = uri != "network:///" && match file.find_enclosing_mount(gio::Cancellable::NONE) { let needs_mount = resolved_uri != "network:///" && match file.find_enclosing_mount(gio::Cancellable::NONE) {
Ok(_) => false, Ok(_) => false,
Err(err) => matches!(err.kind::<gio::IOErrorEnum>(), Some(gio::IOErrorEnum::NotMounted)) Err(err) => matches!(err.kind::<gio::IOErrorEnum>(), Some(gio::IOErrorEnum::NotMounted))
}; };
if needs_mount { if needs_mount {
let mount_op = mount_op(uri.clone(), event_tx.clone()); let mount_op = mount_op(resolved_uri.clone(), event_tx.clone());
let event_tx = event_tx.clone(); let event_tx = event_tx.clone();
file.mount_enclosing_volume( file.mount_enclosing_volume(
gio::MountMountFlags::empty(), gio::MountMountFlags::empty(),
Some(&mount_op), Some(&mount_op),
gio::Cancellable::NONE, gio::Cancellable::NONE,
move |res| { move |res| {
log::info!("network scan mounted {uri}: result {res:?}"); log::info!("network scan mounted {resolved_uri}: result {res:?}");
// FIXME sometimes a uri can be mounted and then not recognized as mounted... // FIXME sometimes a uri can be mounted and then not recognized as mounted...
// seems to be related to uri with a path // seems to be related to uri with a path
items_tx.blocking_send(network_scan(&original_uri, sizes)).unwrap(); items_tx.blocking_send(network_scan(&uri, sizes)).unwrap();
event_tx.send(Event::NetworkResult(uri, match res { event_tx.send(Event::NetworkResult(resolved_uri, match res {
Ok(()) => { Ok(()) => {
Ok(true) Ok(true)
}, },
@ -537,9 +545,12 @@ impl Gvfs {
} }
); );
} else { } else {
items_tx.send(network_scan(&original_uri, sizes)).await.unwrap(); items_tx.send(network_scan(&uri, sizes)).await.unwrap();
} }
} }
Cmd::DirInfo(uri, result_tx) => {
result_tx.send(dir_info(&uri)).await.unwrap();
}
Cmd::Unmount(mounter_item) => { Cmd::Unmount(mounter_item) => {
let MounterItem::Gvfs(item) = mounter_item else { continue }; let MounterItem::Gvfs(item) = mounter_item else { continue };
let ItemKind::Mount = item.kind else { continue }; let ItemKind::Mount = item.kind else { continue };
@ -640,6 +651,14 @@ impl Mounter for Gvfs {
items_rx.blocking_recv() items_rx.blocking_recv()
} }
fn dir_info(&self, uri: &str) -> Option<(String, String)> {
let (result_tx, mut result_rx) = mpsc::channel(1);
self.command_tx
.send(Cmd::DirInfo(uri.to_string(), result_tx))
.unwrap();
result_rx.blocking_recv().and_then(|res| res.ok())
}
fn unmount(&self, item: MounterItem) -> Task<()> { fn unmount(&self, item: MounterItem) -> Task<()> {
let command_tx = self.command_tx.clone(); let command_tx = self.command_tx.clone();
Task::future(async move { Task::future(async move {

View file

@ -116,6 +116,7 @@ pub trait Mounter: Send + Sync {
fn mount(&self, item: MounterItem) -> Task<()>; fn mount(&self, item: MounterItem) -> Task<()>;
fn network_drive(&self, uri: String) -> Task<()>; fn network_drive(&self, uri: String) -> Task<()>;
fn network_scan(&self, uri: &str, sizes: IconSizes) -> Option<Result<Vec<tab::Item>, String>>; fn network_scan(&self, uri: &str, sizes: IconSizes) -> Option<Result<Vec<tab::Item>, String>>;
fn dir_info(&self, uri: &str) -> Option<(String, String)>;
fn unmount(&self, item: MounterItem) -> Task<()>; fn unmount(&self, item: MounterItem) -> Task<()>;
fn subscription(&self) -> Subscription<MounterMessage>; fn subscription(&self) -> Subscription<MounterMessage>;
} }

View file

@ -1362,12 +1362,19 @@ pub struct EditLocation {
impl EditLocation { impl EditLocation {
pub fn resolve(&self) -> Option<Location> { pub fn resolve(&self) -> Option<Location> {
let Some(selected) = self.selected else { if let Location::Network(uri, _, path) = &self.location {
return Some(self.location.clone()); MOUNTERS
}; .values()
let completions = self.completions.as_ref()?; .find_map(|mounter| mounter.dir_info(uri))
let completion = completions.get(selected)?; .map(|(uri, display_name)| Location::Network(uri, display_name, path.clone()))
Some(self.location.with_path(completion.1.clone())) } else {
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) { pub fn select(&mut self, forwards: bool) {
@ -1430,7 +1437,15 @@ impl std::fmt::Display for Location {
impl Location { impl Location {
pub fn normalize(&self) -> Self { pub fn normalize(&self) -> Self {
if let Some(mut path) = self.path_opt().cloned() { if let Location::Network(uri, ..) = self {
if !uri.ends_with('/') {
let mut uri = uri.clone();
uri.push('/');
self.with_uri(uri)
} else {
self.clone()
}
} else if let Some(mut path) = self.path_opt().cloned() {
// Add trailing slash if location is a path // Add trailing slash if location is a path
path.push(""); path.push("");
self.with_path(path) self.with_path(path)
@ -1482,12 +1497,19 @@ impl Location {
Self::Search(_, term, show_hidden, time) => { Self::Search(_, term, show_hidden, time) => {
Self::Search(path, term.clone(), *show_hidden, *time) Self::Search(path, term.clone(), *show_hidden, *time)
} }
Self::Network(id, name, path) => Self::Network(id.clone(), name.clone(), path.clone()),
other => other.clone(), other => other.clone(),
} }
} }
pub fn with_uri(&self, uri: String) -> Self {
if let Self::Network(_, name, path) = self {
Self::Network(uri, name.clone(), path.clone())
} else {
self.clone()
}
}
pub fn scan(&self, sizes: IconSizes) -> (Option<Item>, Vec<Item>) { pub fn scan(&self, sizes: IconSizes) -> (Option<Item>, Vec<Item>) {
let items = match self { let items = match self {
Self::Desktop(path, display, desktop_config) => { Self::Desktop(path, display, desktop_config) => {
@ -3064,10 +3086,14 @@ impl Tab {
{ {
let mut remove = false; let mut remove = false;
if let Some(last_location) = self.history.last() { if let Some(last_location) = self.history.last() {
if let Some(last_path) = last_location.path_opt() { if let Location::Network(last_uri, ..) = last_location
if let Some(path) = location.path_opt() { && let Location::Network(uri, ..) = location
remove = last_path == path; {
} remove = last_uri == uri;
} else if let Some(last_path) = last_location.path_opt()
&& let Some(path) = location.path_opt()
{
remove = last_path == path;
} }
} }
if remove { if remove {
@ -3409,8 +3435,10 @@ impl Tab {
} }
Message::EditLocationComplete(selected) => { Message::EditLocationComplete(selected) => {
if let Some(mut edit_location) = self.edit_location.take() { if let Some(mut edit_location) = self.edit_location.take() {
edit_location.selected = Some(selected); if !matches!(edit_location.location, Location::Network(..)) {
cd = edit_location.resolve(); edit_location.selected = Some(selected);
cd = edit_location.resolve();
}
} }
} }
Message::EditLocationEnable => { Message::EditLocationEnable => {
@ -4634,65 +4662,82 @@ impl Tab {
.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(edit_location) = &self.edit_location { if let Some(edit_location) = &self.edit_location {
if let Some(location) = edit_location.resolve() { let mut text_input = None;
//TODO: allow editing other locations
if let Some(path) = location.path_opt() { //TODO: allow editing other locations
row = row.push( if let Location::Network(ref uri, ..) = edit_location.location {
widget::button::custom( let location = edit_location.location.clone();
widget::icon::from_name("window-close-symbolic").size(16), text_input = Some(
) widget::text_input("", uri.clone())
.on_press(Message::EditLocation(None)) .id(self.edit_location_id.clone())
.padding(space_xxs) .on_input(move |input| {
.class(theme::Button::Icon), Message::EditLocation(Some(location.with_uri(input).into()))
); })
let text_input = widget::text_input("", path.to_string_lossy().into_owned()) .on_submit(|_| Message::EditLocationSubmit)
.line_height(1.0),
);
} else if let Some(resolved_location) = edit_location.resolve()
&& let Some(path) = resolved_location.path_opt().cloned()
{
text_input = Some(
widget::text_input("", path.to_string_lossy().into_owned())
.id(self.edit_location_id.clone()) .id(self.edit_location_id.clone())
.on_input(move |input| { .on_input(move |input| {
Message::EditLocation(Some( Message::EditLocation(Some(
location.with_path(PathBuf::from(input)).into(), resolved_location.with_path(PathBuf::from(input)).into(),
)) ))
}) })
.on_submit(|_| Message::EditLocationSubmit) .on_submit(|_| Message::EditLocationSubmit)
.line_height(1.0); .line_height(1.0),
let mut popover = );
widget::popover(text_input).position(widget::popover::Position::Bottom); }
if let Some(completions) = &edit_location.completions { if let Some(text_input) = text_input {
if !completions.is_empty() { row = row.push(
let mut column = widget::button::custom(
widget::column::with_capacity(completions.len()).padding(space_xxs); widget::icon::from_name("window-close-symbolic").size(16),
for (i, (name, _path)) in completions.iter().enumerate() { )
let selected = edit_location.selected == Some(i); .on_press(Message::EditLocation(None))
column = column.push( .padding(space_xxs)
widget::button::custom(widget::text::body(name)) .class(theme::Button::Icon),
//TODO: match to design );
.class(if selected { let mut popover =
theme::Button::Standard widget::popover(text_input).position(widget::popover::Position::Bottom);
} else { if let Some(completions) = &edit_location.completions {
theme::Button::HeaderBar if !completions.is_empty() {
}) let mut column =
.on_press(Message::EditLocationComplete(i)) widget::column::with_capacity(completions.len()).padding(space_xxs);
.padding(space_xxs) for (i, (name, _path)) in completions.iter().enumerate() {
.width(Length::Fill), let selected = edit_location.selected == Some(i);
); column = column.push(
} widget::button::custom(widget::text::body(name))
popover = popover.popup( //TODO: match to design
widget::container(column) .class(if selected {
.class(theme::Container::Dropdown) theme::Button::Standard
//TODO: This is a hack to get the popover to be the right width } else {
.max_width(size.width - 140.0), theme::Button::HeaderBar
})
.on_press(Message::EditLocationComplete(i))
.padding(space_xxs)
.width(Length::Fill),
); );
} }
popover = popover.popup(
widget::container(column)
.class(theme::Container::Dropdown)
//TODO: This is a hack to get the popover to be the right width
.max_width(size.width - 140.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();
} }
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();
} }
} else if let Some(path) = self.location.path_opt() { } else if let Some(path) = self.location.path_opt() {
row = row.push( row = row.push(