Add current page download pipeline
This commit is contained in:
@@ -42,6 +42,9 @@ impl ElyShell {
|
||||
if install_plugin_from_file_command(command) {
|
||||
self.choose_plugin_package(window, cx);
|
||||
}
|
||||
if download_current_page_command(command) {
|
||||
self.download_active_tab(window, cx);
|
||||
}
|
||||
|
||||
match space_file_command(command) {
|
||||
Some(SpaceFileCommand::ExportActiveSpace) => self.export_active_space(window, cx),
|
||||
@@ -83,6 +86,18 @@ fn install_plugin_from_file_command(command: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn download_current_page_command(command: &str) -> bool {
|
||||
matches!(
|
||||
command.trim().to_ascii_lowercase().as_str(),
|
||||
"download-current-page"
|
||||
| "download current page"
|
||||
| "save-page"
|
||||
| "save page"
|
||||
| "save-current-page"
|
||||
| "save current page"
|
||||
)
|
||||
}
|
||||
|
||||
fn space_file_command(command: &str) -> Option<SpaceFileCommand> {
|
||||
match command.trim().to_ascii_lowercase().as_str() {
|
||||
"export-space" | "export space" | "export-active-space" | "export active space" => {
|
||||
@@ -134,8 +149,8 @@ fn local_data_file_command(command: &str) -> Option<LocalDataFileCommand> {
|
||||
mod tests {
|
||||
use super::{
|
||||
BookmarkFileCommand, LocalDataFileCommand, ShortcutFileCommand, SpaceFileCommand,
|
||||
bookmark_file_command, install_plugin_from_file_command, local_data_file_command,
|
||||
shortcut_file_command, space_file_command,
|
||||
bookmark_file_command, download_current_page_command, install_plugin_from_file_command,
|
||||
local_data_file_command, shortcut_file_command, space_file_command,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -151,6 +166,13 @@ mod tests {
|
||||
assert!(!install_plugin_from_file_command("open plugins"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_current_page_command_matches_save_aliases() {
|
||||
assert!(download_current_page_command("download-current-page"));
|
||||
assert!(download_current_page_command("save current page"));
|
||||
assert!(!download_current_page_command("open downloads"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_file_command_matches_export_and_import_aliases() {
|
||||
assert_eq!(space_file_command("export-space"), Some(SpaceFileCommand::ExportActiveSpace));
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::UserDirs;
|
||||
use ely_browser_core::BrowserCore;
|
||||
use ely_domain::{DownloadDestination, UrlText};
|
||||
use url::Url;
|
||||
|
||||
pub(super) enum ActiveTabDownloadTarget {
|
||||
Prompt { url: UrlText, directory: PathBuf, file_name: String },
|
||||
Ready { url: UrlText, target_path: PathBuf },
|
||||
}
|
||||
|
||||
pub(super) fn active_tab_download_target(
|
||||
core: &BrowserCore,
|
||||
) -> Result<ActiveTabDownloadTarget, String> {
|
||||
let tab = core.active_tab().map_err(|error| error.to_string())?;
|
||||
let parsed = Url::parse(tab.url().as_str()).map_err(|error| error.to_string())?;
|
||||
match parsed.scheme() {
|
||||
"http" | "https" => {}
|
||||
_ => return Err("Only HTTP and HTTPS pages can be downloaded.".to_string()),
|
||||
}
|
||||
|
||||
let snapshot = core.snapshot().map_err(|error| error.to_string())?;
|
||||
let file_name = suggested_download_file_name(tab.url(), tab.title());
|
||||
match snapshot.active_download_policy.destination() {
|
||||
DownloadDestination::AskEveryTime => Ok(ActiveTabDownloadTarget::Prompt {
|
||||
url: tab.url().clone(),
|
||||
directory: default_download_directory()?,
|
||||
file_name,
|
||||
}),
|
||||
DownloadDestination::FixedDirectory(directory) => Ok(ActiveTabDownloadTarget::Ready {
|
||||
url: tab.url().clone(),
|
||||
target_path: unique_download_target_path(directory, &file_name),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_download_directory() -> Result<PathBuf, String> {
|
||||
UserDirs::new()
|
||||
.and_then(|dirs| dirs.download_dir().map(Path::to_path_buf))
|
||||
.ok_or_else(|| "Downloads directory is unavailable.".to_string())
|
||||
}
|
||||
|
||||
fn suggested_download_file_name(url: &UrlText, title: &str) -> String {
|
||||
let parsed = Url::parse(url.as_str()).ok();
|
||||
let url_file_name = parsed
|
||||
.as_ref()
|
||||
.filter(|url| !url.path().ends_with('/'))
|
||||
.and_then(|url| url.path_segments())
|
||||
.and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
|
||||
.map(str::to_string);
|
||||
let raw_file_name = url_file_name
|
||||
.or_else(|| page_title_file_name(title))
|
||||
.or_else(|| {
|
||||
parsed.as_ref().and_then(|url| url.host_str().map(|host| format!("{host}.html")))
|
||||
})
|
||||
.unwrap_or_else(|| "download.html".to_string());
|
||||
|
||||
sanitize_download_file_name(&raw_file_name)
|
||||
}
|
||||
|
||||
fn page_title_file_name(title: &str) -> Option<String> {
|
||||
let title = title.trim();
|
||||
(!title.is_empty()).then(|| format!("{title}.html"))
|
||||
}
|
||||
|
||||
fn sanitize_download_file_name(file_name: &str) -> String {
|
||||
let cleaned = file_name
|
||||
.trim()
|
||||
.chars()
|
||||
.filter_map(|ch| match ch {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => Some('-'),
|
||||
ch if ch.is_control() => None,
|
||||
ch => Some(ch),
|
||||
})
|
||||
.take(160)
|
||||
.collect::<String>();
|
||||
let cleaned = cleaned.trim_matches(|ch| ch == '.' || ch == ' ').to_string();
|
||||
|
||||
match cleaned.as_str() {
|
||||
"" | "." | ".." => "download.html".to_string(),
|
||||
_ => cleaned,
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_download_target_path(directory: &Path, file_name: &str) -> PathBuf {
|
||||
let candidate = directory.join(file_name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
let (stem, extension) = split_file_name(file_name);
|
||||
for suffix in 1..1_000 {
|
||||
let file_name = match extension {
|
||||
Some(extension) => format!("{stem} ({suffix}).{extension}"),
|
||||
None => format!("{stem} ({suffix})"),
|
||||
};
|
||||
let candidate = directory.join(file_name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
directory.join(format!("{stem} (1000)"))
|
||||
}
|
||||
|
||||
fn split_file_name(file_name: &str) -> (&str, Option<&str>) {
|
||||
match file_name.rsplit_once('.') {
|
||||
Some((stem, extension)) if !stem.is_empty() && !extension.is_empty() => {
|
||||
(stem, Some(extension))
|
||||
}
|
||||
_ => (file_name, None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{error::Error, fs};
|
||||
|
||||
use ely_domain::UrlText;
|
||||
|
||||
use super::{suggested_download_file_name, unique_download_target_path};
|
||||
|
||||
#[test]
|
||||
fn suggested_download_file_name_prefers_url_leaf() -> Result<(), Box<dyn Error>> {
|
||||
let url = UrlText::parse("https://example.com/files/report.pdf?token=1")?;
|
||||
|
||||
assert_eq!(suggested_download_file_name(&url, "Example"), "report.pdf");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggested_download_file_name_uses_title_for_directory_urls() -> Result<(), Box<dyn Error>> {
|
||||
let url = UrlText::parse("https://example.com/articles/")?;
|
||||
|
||||
assert_eq!(suggested_download_file_name(&url, "A/B: Research"), "A-B- Research.html");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_download_target_path_preserves_existing_files() -> Result<(), Box<dyn Error>> {
|
||||
let directory = std::env::temp_dir().join("ely-unique-download-test");
|
||||
fs::create_dir_all(&directory)?;
|
||||
let existing = directory.join("report.pdf");
|
||||
fs::write(&existing, [])?;
|
||||
|
||||
assert_eq!(
|
||||
unique_download_target_path(&directory, "report.pdf"),
|
||||
directory.join("report (1).pdf")
|
||||
);
|
||||
|
||||
fs::remove_file(existing)?;
|
||||
fs::remove_dir(directory)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
use ely_domain::DownloadId;
|
||||
use gpui::Context;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_domain::{DownloadId, UrlText};
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use crate::services::{
|
||||
download_checksums::DownloadChecksumCalculator, download_files::DownloadFileAction,
|
||||
download_checksums::DownloadChecksumCalculator,
|
||||
download_files::DownloadFileAction,
|
||||
http_downloads::{HttpDownloadError, HttpDownloadPlan, HttpDownloadResult, download_to_file},
|
||||
};
|
||||
|
||||
use super::{ElyShell, ShellState};
|
||||
use super::{
|
||||
ElyShell, ShellState,
|
||||
download_targets::{ActiveTabDownloadTarget, active_tab_download_target},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct PendingDownloadFileAction {
|
||||
@@ -29,6 +36,57 @@ impl PendingDownloadFileAction {
|
||||
}
|
||||
|
||||
impl ElyShell {
|
||||
pub(super) fn download_active_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.download_action_error = None;
|
||||
let target = match &self.state {
|
||||
ShellState::Ready(core) => active_tab_download_target(core),
|
||||
ShellState::StartupError(message) => Err(message.clone()),
|
||||
};
|
||||
let target = match target {
|
||||
Ok(target) => target,
|
||||
Err(message) => {
|
||||
self.set_download_action_error(message, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match target {
|
||||
ActiveTabDownloadTarget::Ready { url, target_path } => {
|
||||
self.start_download_to_path(url, target_path, window, cx);
|
||||
}
|
||||
ActiveTabDownloadTarget::Prompt { url, directory, file_name } => {
|
||||
let prompt = cx.prompt_for_new_path(&directory, Some(&file_name));
|
||||
|
||||
cx.spawn_in(window, async move |shell, window| {
|
||||
let selected_path = match prompt.await {
|
||||
Ok(Ok(path)) => path,
|
||||
Ok(Err(error)) => {
|
||||
_ = shell.update_in(window, |shell, _, cx| {
|
||||
shell.set_download_action_error(error.to_string(), cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
_ = shell.update_in(window, |shell, _, cx| {
|
||||
shell.set_download_action_error(error.to_string(), cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(path) = selected_path else {
|
||||
return;
|
||||
};
|
||||
|
||||
_ = shell.update_in(window, |shell, window, cx| {
|
||||
shell.start_download_to_path(url, path, window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_download(&mut self, download_id: &DownloadId, cx: &mut Context<Self>) {
|
||||
if let ShellState::Ready(core) = &mut self.state
|
||||
&& core.pause_download(download_id).is_ok()
|
||||
@@ -103,6 +161,80 @@ impl ElyShell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_download_to_path(
|
||||
&mut self,
|
||||
source_url: UrlText,
|
||||
target_path: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let download_id = match &mut self.state {
|
||||
ShellState::Ready(core) => match core.record_download_started_at_path(
|
||||
source_url.clone(),
|
||||
target_path.clone(),
|
||||
None,
|
||||
) {
|
||||
Ok(download_id) => Ok(download_id),
|
||||
Err(error) => Err(error.to_string()),
|
||||
},
|
||||
ShellState::StartupError(message) => Err(message.clone()),
|
||||
};
|
||||
|
||||
let download_id = match download_id {
|
||||
Ok(download_id) => download_id,
|
||||
Err(message) => {
|
||||
self.set_download_action_error(message, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.open_downloads(window, cx);
|
||||
cx.notify();
|
||||
|
||||
let plan = HttpDownloadPlan::new(source_url, target_path);
|
||||
cx.spawn_in(window, async move |shell, window| {
|
||||
let result =
|
||||
window.background_executor().spawn(async move { download_to_file(plan) }).await;
|
||||
_ = shell.update_in(window, |shell, _, cx| {
|
||||
shell.handle_http_download_result(download_id, result, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn handle_http_download_result(
|
||||
&mut self,
|
||||
download_id: DownloadId,
|
||||
result: Result<HttpDownloadResult, HttpDownloadError>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let update_result = match (&mut self.state, result) {
|
||||
(ShellState::Ready(core), Ok(result)) => core
|
||||
.complete_download(&download_id, result.received_bytes())
|
||||
.map(|()| None)
|
||||
.map_err(|error| error.to_string()),
|
||||
(ShellState::Ready(core), Err(error)) => {
|
||||
let message = error.to_string();
|
||||
match core.fail_download(&download_id) {
|
||||
Ok(()) => Ok(Some(message)),
|
||||
Err(state_error) => Err(format!("{message}; {state_error}")),
|
||||
}
|
||||
}
|
||||
(ShellState::StartupError(message), _) => Err(message.clone()),
|
||||
};
|
||||
|
||||
match update_result {
|
||||
Ok(message) => self.download_action_error = message,
|
||||
Err(message) => self.download_action_error = Some(message),
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_download_action_error(&mut self, message: String, cx: &mut Context<Self>) {
|
||||
self.download_action_error = Some(message);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(super) fn open_download_file(&mut self, download_id: &DownloadId, cx: &mut Context<Self>) {
|
||||
self.run_download_file_action(download_id, DownloadFileAction::Open, cx);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ mod archive_labels;
|
||||
mod bookmark_files;
|
||||
mod bookmarks;
|
||||
mod command_actions;
|
||||
mod download_targets;
|
||||
mod downloads;
|
||||
mod focus;
|
||||
mod history;
|
||||
@@ -44,9 +45,9 @@ use plugins::{PendingPluginInstall, PendingPluginUninstall};
|
||||
use web_surface::WebSurfaceStore;
|
||||
|
||||
use crate::{
|
||||
CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab,
|
||||
OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, SelectNextTab, SelectPreviousTab,
|
||||
ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut,
|
||||
CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads,
|
||||
OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab,
|
||||
SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut,
|
||||
};
|
||||
|
||||
enum ShellState {
|
||||
@@ -348,6 +349,15 @@ impl ElyShell {
|
||||
self.open_downloads(window, cx);
|
||||
}
|
||||
|
||||
fn on_download_current_page(
|
||||
&mut self,
|
||||
_: &DownloadCurrentPage,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.download_active_tab(window, cx);
|
||||
}
|
||||
|
||||
fn on_open_history(&mut self, _: &OpenHistory, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open_history(window, cx);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ impl ElyShell {
|
||||
.on_action(cx.listener(Self::on_close_current_tab))
|
||||
.on_action(cx.listener(Self::on_focus_address_bar))
|
||||
.on_action(cx.listener(Self::on_focus_command_mode))
|
||||
.on_action(cx.listener(Self::on_download_current_page))
|
||||
.on_action(cx.listener(Self::on_open_downloads))
|
||||
.on_action(cx.listener(Self::on_open_history))
|
||||
.on_action(cx.listener(Self::on_open_new_tab))
|
||||
|
||||
Reference in New Issue
Block a user