Add download file actions
This commit is contained in:
Generated
+1
@@ -2202,6 +2202,7 @@ dependencies = [
|
|||||||
"gpui",
|
"gpui",
|
||||||
"gpui-component",
|
"gpui-component",
|
||||||
"gpui-component-assets",
|
"gpui-component-assets",
|
||||||
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ ely_domain = { path = "../ely_domain" }
|
|||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
gpui-component-assets.workspace = true
|
gpui-component-assets.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod services;
|
||||||
mod shell;
|
mod shell;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use std::{
|
||||||
|
fs, io,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Command, ExitStatus},
|
||||||
|
};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DownloadFileAction {
|
||||||
|
Open,
|
||||||
|
Reveal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DownloadFileError {
|
||||||
|
#[error("download file is unavailable: {path:?}")]
|
||||||
|
FileUnavailable { path: PathBuf },
|
||||||
|
|
||||||
|
#[error("failed to launch {action} for download file: {path:?}: {source}")]
|
||||||
|
LaunchFailed { action: &'static str, path: PathBuf, source: io::Error },
|
||||||
|
|
||||||
|
#[error("{action} failed for download file: {path:?} ({status})")]
|
||||||
|
CommandFailed { action: &'static str, path: PathBuf, status: ExitStatus },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadFileAction {
|
||||||
|
pub fn run(self, path: &Path) -> Result<(), DownloadFileError> {
|
||||||
|
ensure_regular_file(path)?;
|
||||||
|
let status =
|
||||||
|
self.command(path).status().map_err(|source| DownloadFileError::LaunchFailed {
|
||||||
|
action: self.label(),
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(DownloadFileError::CommandFailed {
|
||||||
|
action: self.label(),
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(self, path: &Path) -> Command {
|
||||||
|
let mut command = Command::new("/usr/bin/open");
|
||||||
|
match self {
|
||||||
|
Self::Open => {
|
||||||
|
command.arg(path);
|
||||||
|
}
|
||||||
|
Self::Reveal => {
|
||||||
|
command.arg("-R").arg(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Open => "open",
|
||||||
|
Self::Reveal => "reveal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_regular_file(path: &Path) -> Result<(), DownloadFileError> {
|
||||||
|
match fs::metadata(path) {
|
||||||
|
Ok(metadata) if metadata.is_file() => Ok(()),
|
||||||
|
Ok(_) | Err(_) => Err(DownloadFileError::FileUnavailable { path: path.to_path_buf() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod download_files;
|
||||||
@@ -75,6 +75,9 @@ impl ElyShell {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.when_some(self.download_file_error.clone(), |this, message| {
|
||||||
|
this.child(render_download_file_error(message))
|
||||||
|
})
|
||||||
.child(self.render_downloads_list(snapshot, cx)),
|
.child(self.render_downloads_list(snapshot, cx)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,7 @@ impl ElyShell {
|
|||||||
div()
|
div()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.truncate()
|
.truncate()
|
||||||
.child(download_destination_label(entry.destination())),
|
.child(download_entry_location_label(entry)),
|
||||||
)
|
)
|
||||||
.when(entry.security().requires_prompt(), |this| {
|
.when(entry.security().requires_prompt(), |this| {
|
||||||
this.child(render_security_prompt(entry.security()))
|
this.child(render_security_prompt(entry.security()))
|
||||||
@@ -277,6 +280,34 @@ impl ElyShell {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.when(
|
||||||
|
matches!(entry.state(), DownloadState::Completed)
|
||||||
|
&& entry.target_file_path().is_some(),
|
||||||
|
|this| {
|
||||||
|
let open_id = entry.id().clone();
|
||||||
|
let reveal_id = entry.id().clone();
|
||||||
|
|
||||||
|
this.child(
|
||||||
|
download_action_button("open", index, IconName::ExternalLink, "Open File")
|
||||||
|
.on_click(cx.listener(move |shell, _, _, cx| {
|
||||||
|
shell.open_download_file(&open_id, cx);
|
||||||
|
}))
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
download_action_button(
|
||||||
|
"reveal",
|
||||||
|
index,
|
||||||
|
IconName::FolderOpen,
|
||||||
|
"Reveal in Finder",
|
||||||
|
)
|
||||||
|
.on_click(cx.listener(move |shell, _, _, cx| {
|
||||||
|
shell.reveal_download_file(&reveal_id, cx);
|
||||||
|
}))
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,6 +351,30 @@ fn download_destination_label(destination: &DownloadDestination) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn download_entry_location_label(entry: &DownloadEntry) -> String {
|
||||||
|
match entry.target_file_path() {
|
||||||
|
Some(path) => path.display().to_string(),
|
||||||
|
None => download_destination_label(entry.destination()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_download_file_error(message: String) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(colors::ERROR))
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(colors::ERROR))
|
||||||
|
.child(IconName::TriangleAlert)
|
||||||
|
.child(message)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
fn render_security_prompt(security: &DownloadSecurity) -> AnyElement {
|
fn render_security_prompt(security: &DownloadSecurity) -> AnyElement {
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui_component::input::{InputEvent, InputState, SelectAll};
|
|||||||
use crate::{
|
use crate::{
|
||||||
CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab,
|
CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab,
|
||||||
OpenSettings, RestoreClosedTab, SelectNextTab, SelectPreviousTab, ToggleFavoriteTab,
|
OpenSettings, RestoreClosedTab, SelectNextTab, SelectPreviousTab, ToggleFavoriteTab,
|
||||||
TogglePinnedTab,
|
TogglePinnedTab, services::download_files::DownloadFileAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum ShellState {
|
enum ShellState {
|
||||||
@@ -22,6 +22,7 @@ pub struct ElyShell {
|
|||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
command_input: Entity<InputState>,
|
command_input: Entity<InputState>,
|
||||||
last_intent: Option<CommandIntent>,
|
last_intent: Option<CommandIntent>,
|
||||||
|
download_file_error: Option<String>,
|
||||||
_command_subscription: Subscription,
|
_command_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ impl ElyShell {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
command_input,
|
command_input,
|
||||||
last_intent: None,
|
last_intent: None,
|
||||||
|
download_file_error: None,
|
||||||
_command_subscription: command_subscription,
|
_command_subscription: command_subscription,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +249,32 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_download_file(&mut self, download_id: &DownloadId, cx: &mut Context<Self>) {
|
||||||
|
self.run_download_file_action(download_id, DownloadFileAction::Open, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reveal_download_file(&mut self, download_id: &DownloadId, cx: &mut Context<Self>) {
|
||||||
|
self.run_download_file_action(download_id, DownloadFileAction::Reveal, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_download_file_action(
|
||||||
|
&mut self,
|
||||||
|
download_id: &DownloadId,
|
||||||
|
action: DownloadFileAction,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let result = match &self.state {
|
||||||
|
ShellState::Ready(core) => core
|
||||||
|
.download_target_file_path(download_id)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
.and_then(|path| action.run(&path).map_err(|error| error.to_string())),
|
||||||
|
ShellState::StartupError(message) => Err(message.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.download_file_error = result.err();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn on_close_current_tab(
|
fn on_close_current_tab(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &CloseCurrentTab,
|
_: &CloseCurrentTab,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ pub enum CoreError {
|
|||||||
#[error("download not found: {id}")]
|
#[error("download not found: {id}")]
|
||||||
DownloadNotFound { id: DownloadId },
|
DownloadNotFound { id: DownloadId },
|
||||||
|
|
||||||
|
#[error("download target path is unavailable: {id}")]
|
||||||
|
DownloadTargetPathUnavailable { id: DownloadId },
|
||||||
|
|
||||||
#[error("favorite limit reached: {limit}")]
|
#[error("favorite limit reached: {limit}")]
|
||||||
FavoriteLimitReached { limit: usize },
|
FavoriteLimitReached { limit: usize },
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::time::SystemTime;
|
use std::{path::PathBuf, time::SystemTime};
|
||||||
|
|
||||||
use ely_domain::{DownloadEntry, DownloadId, UrlText};
|
use ely_domain::{DownloadEntry, DownloadId, UrlText};
|
||||||
|
|
||||||
@@ -70,6 +70,17 @@ impl BrowserCore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn download_target_file_path(
|
||||||
|
&self,
|
||||||
|
download_id: &DownloadId,
|
||||||
|
) -> Result<PathBuf, CoreError> {
|
||||||
|
let entry = self.visible_download_entry(download_id)?;
|
||||||
|
entry
|
||||||
|
.target_file_path()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| CoreError::DownloadTargetPathUnavailable { id: download_id.clone() })
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn visible_downloads(&self) -> Vec<DownloadEntry> {
|
pub(super) fn visible_downloads(&self) -> Vec<DownloadEntry> {
|
||||||
self.download_entries
|
self.download_entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -78,6 +89,17 @@ impl BrowserCore {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn visible_download_entry(
|
||||||
|
&self,
|
||||||
|
download_id: &DownloadId,
|
||||||
|
) -> Result<&DownloadEntry, CoreError> {
|
||||||
|
self.download_entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.profile_id() == &self.active_profile_id)
|
||||||
|
.find(|entry| entry.id() == download_id)
|
||||||
|
.ok_or_else(|| CoreError::DownloadNotFound { id: download_id.clone() })
|
||||||
|
}
|
||||||
|
|
||||||
fn download_entry_mut(
|
fn download_entry_mut(
|
||||||
&mut self,
|
&mut self,
|
||||||
download_id: &DownloadId,
|
download_id: &DownloadId,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::error::Error;
|
use std::{error::Error, path::Path};
|
||||||
|
|
||||||
use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig};
|
use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig};
|
||||||
use ely_domain::{
|
use ely_domain::{
|
||||||
@@ -21,6 +21,7 @@ fn download_entries_stay_with_active_profile() -> Result<(), Box<dyn Error>> {
|
|||||||
assert_eq!(default_snapshot.download_entries.len(), 1);
|
assert_eq!(default_snapshot.download_entries.len(), 1);
|
||||||
assert_eq!(default_snapshot.download_entries[0].file_name(), "report.pdf");
|
assert_eq!(default_snapshot.download_entries[0].file_name(), "report.pdf");
|
||||||
assert_eq!(default_snapshot.download_entries[0].profile_id(), &default_profile_id);
|
assert_eq!(default_snapshot.download_entries[0].profile_id(), &default_profile_id);
|
||||||
|
assert_eq!(default_snapshot.download_entries[0].target_file_path(), None);
|
||||||
|
|
||||||
core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?;
|
core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?;
|
||||||
let personal_snapshot = core.snapshot()?;
|
let personal_snapshot = core.snapshot()?;
|
||||||
@@ -82,10 +83,51 @@ fn records_active_profile_download_policy_on_started_entry() -> Result<(), Box<d
|
|||||||
let entry = active_download(&core)?;
|
let entry = active_download(&core)?;
|
||||||
assert_eq!(snapshot.active_download_policy, policy);
|
assert_eq!(snapshot.active_download_policy, policy);
|
||||||
assert_eq!(entry.destination(), policy.destination());
|
assert_eq!(entry.destination(), policy.destination());
|
||||||
|
assert_eq!(entry.target_file_path(), Some(Path::new("/tmp/ely-work-downloads/installer.dmg")));
|
||||||
assert_eq!(entry.security(), &DownloadSecurity::DangerousExtension);
|
assert_eq!(entry.security(), &DownloadSecurity::DangerousExtension);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_visible_download_target_file_path() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
let profile_id = core.active_tab()?.profile_id().clone();
|
||||||
|
core.set_profile_download_policy(
|
||||||
|
&profile_id,
|
||||||
|
DownloadPolicy::fixed_directory("/tmp/ely-work-downloads")?,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let download_id = core.record_download_started(
|
||||||
|
UrlText::parse("https://example.com/report.pdf")?,
|
||||||
|
"report.pdf",
|
||||||
|
Some(2048),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
core.download_target_file_path(&download_id)?,
|
||||||
|
Path::new("/tmp/ely-work-downloads/report.pdf")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_target_path_for_ask_every_time_download() -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
let download_id = core.record_download_started(
|
||||||
|
UrlText::parse("https://example.com/report.pdf")?,
|
||||||
|
"report.pdf",
|
||||||
|
Some(2048),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let error = match core.download_target_file_path(&download_id) {
|
||||||
|
Ok(_) => return Err("download target path should require a fixed destination".into()),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(error, CoreError::DownloadTargetPathUnavailable { id: download_id });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn download_policies_stay_with_profile() -> Result<(), Box<dyn Error>> {
|
fn download_policies_stay_with_profile() -> Result<(), Box<dyn Error>> {
|
||||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ pub struct DownloadEntry {
|
|||||||
source_url: UrlText,
|
source_url: UrlText,
|
||||||
file_name: String,
|
file_name: String,
|
||||||
destination: DownloadDestination,
|
destination: DownloadDestination,
|
||||||
|
target_file_path: Option<PathBuf>,
|
||||||
security: DownloadSecurity,
|
security: DownloadSecurity,
|
||||||
state: DownloadState,
|
state: DownloadState,
|
||||||
received_bytes: u64,
|
received_bytes: u64,
|
||||||
@@ -95,6 +96,14 @@ impl DownloadDestination {
|
|||||||
Self::FixedDirectory(path) => Some(path.as_path()),
|
Self::FixedDirectory(path) => Some(path.as_path()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn target_file_path(&self, file_name: &str) -> Result<Option<PathBuf>, DomainError> {
|
||||||
|
validate_file_name(file_name)?;
|
||||||
|
Ok(match self {
|
||||||
|
Self::AskEveryTime => None,
|
||||||
|
Self::FixedDirectory(path) => Some(path.join(file_name)),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DownloadPolicy {
|
impl DownloadPolicy {
|
||||||
@@ -143,6 +152,7 @@ impl DownloadEntry {
|
|||||||
return Err(DomainError::EmptyField { field: "file_name" });
|
return Err(DomainError::EmptyField { field: "file_name" });
|
||||||
}
|
}
|
||||||
validate_file_name(file_name)?;
|
validate_file_name(file_name)?;
|
||||||
|
let target_file_path = destination.target_file_path(file_name)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: DownloadId::new(),
|
id: DownloadId::new(),
|
||||||
@@ -150,6 +160,7 @@ impl DownloadEntry {
|
|||||||
source_url,
|
source_url,
|
||||||
file_name: file_name.to_string(),
|
file_name: file_name.to_string(),
|
||||||
destination,
|
destination,
|
||||||
|
target_file_path,
|
||||||
security: DownloadSecurity::for_file_name(file_name),
|
security: DownloadSecurity::for_file_name(file_name),
|
||||||
state: DownloadState::InProgress,
|
state: DownloadState::InProgress,
|
||||||
received_bytes: 0,
|
received_bytes: 0,
|
||||||
@@ -229,6 +240,11 @@ impl DownloadEntry {
|
|||||||
&self.destination
|
&self.destination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn target_file_path(&self) -> Option<&Path> {
|
||||||
|
self.target_file_path.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn security(&self) -> &DownloadSecurity {
|
pub fn security(&self) -> &DownloadSecurity {
|
||||||
&self.security
|
&self.security
|
||||||
|
|||||||
Reference in New Issue
Block a user