Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use warp_core::ui::icons::Icon;
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
use warpui::elements::{ChildView, Element, Empty, ParentElement, Wrap};
|
||||
use warpui::{AppContext, Entity, TypedActionView, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::notebooks::NotebookId;
|
||||
use crate::terminal::input::MenuPositioning;
|
||||
|
||||
use super::file_button_label;
|
||||
use super::Artifact;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ActionButtonTheme, ButtonSize, SecondaryTheme, TooltipAlignment,
|
||||
};
|
||||
|
||||
const BUTTON_SPACING: f32 = 8.;
|
||||
const BUTTON_MAX_TEXT_WIDTH: f32 = 200.;
|
||||
|
||||
/// A view that renders a set of artifact buttons (plans, branches, PRs)
|
||||
pub struct ArtifactButtonsRow {
|
||||
buttons: Vec<ViewHandle<ActionButton>>,
|
||||
theme: Arc<dyn ActionButtonTheme>,
|
||||
}
|
||||
|
||||
impl ArtifactButtonsRow {
|
||||
pub fn new(artifacts: &[Artifact], ctx: &mut ViewContext<Self>) -> Self {
|
||||
let theme: Arc<dyn ActionButtonTheme> = Arc::new(SecondaryTheme);
|
||||
Self {
|
||||
buttons: collect_buttons(artifacts, &theme, ctx),
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_theme(
|
||||
artifacts: &[Artifact],
|
||||
theme: Arc<dyn ActionButtonTheme>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
buttons: collect_buttons(artifacts, &theme, ctx),
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_artifacts(&mut self, artifacts: &[Artifact], ctx: &mut ViewContext<Self>) {
|
||||
self.buttons = collect_buttons(artifacts, &self.theme, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.buttons.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ArtifactButtonsRowEvent {
|
||||
OpenPlan { notebook_uid: NotebookId },
|
||||
CopyBranch { branch: String },
|
||||
OpenPullRequest { url: String },
|
||||
ViewScreenshots { artifact_uids: Vec<String> },
|
||||
DownloadFile { artifact_uid: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ArtifactButtonAction {
|
||||
OpenPlan { notebook_uid: NotebookId },
|
||||
CopyBranch { branch: String },
|
||||
OpenPullRequest { url: String },
|
||||
ViewScreenshots { artifact_uids: Vec<String> },
|
||||
DownloadFile { artifact_uid: String },
|
||||
}
|
||||
|
||||
impl Entity for ArtifactButtonsRow {
|
||||
type Event = ArtifactButtonsRowEvent;
|
||||
}
|
||||
|
||||
impl View for ArtifactButtonsRow {
|
||||
fn ui_name() -> &'static str {
|
||||
"ArtifactButtonsRow"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
if self.buttons.is_empty() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
Wrap::row()
|
||||
.with_spacing(BUTTON_SPACING)
|
||||
.with_run_spacing(BUTTON_SPACING)
|
||||
.with_children(
|
||||
self.buttons
|
||||
.iter()
|
||||
.map(|button| ChildView::new(button).finish()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for ArtifactButtonsRow {
|
||||
type Action = ArtifactButtonAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
let event = match action {
|
||||
ArtifactButtonAction::OpenPlan { notebook_uid } => ArtifactButtonsRowEvent::OpenPlan {
|
||||
notebook_uid: *notebook_uid,
|
||||
},
|
||||
ArtifactButtonAction::CopyBranch { branch } => ArtifactButtonsRowEvent::CopyBranch {
|
||||
branch: branch.clone(),
|
||||
},
|
||||
ArtifactButtonAction::OpenPullRequest { url } => {
|
||||
ArtifactButtonsRowEvent::OpenPullRequest { url: url.clone() }
|
||||
}
|
||||
ArtifactButtonAction::ViewScreenshots { artifact_uids } => {
|
||||
ArtifactButtonsRowEvent::ViewScreenshots {
|
||||
artifact_uids: artifact_uids.clone(),
|
||||
}
|
||||
}
|
||||
ArtifactButtonAction::DownloadFile { artifact_uid } => {
|
||||
ArtifactButtonsRowEvent::DownloadFile {
|
||||
artifact_uid: artifact_uid.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ctx.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_buttons(
|
||||
artifacts: &[Artifact],
|
||||
theme: &Arc<dyn ActionButtonTheme>,
|
||||
ctx: &mut ViewContext<ArtifactButtonsRow>,
|
||||
) -> Vec<ViewHandle<ActionButton>> {
|
||||
let mut buttons = Vec::new();
|
||||
let mut screenshot_uids = Vec::new();
|
||||
|
||||
for artifact in artifacts {
|
||||
match artifact {
|
||||
Artifact::Plan {
|
||||
title,
|
||||
notebook_uid,
|
||||
document_uid: _,
|
||||
} => {
|
||||
// Only show plan button if synced to Warp Drive (has notebook_uid)
|
||||
if let Some(notebook_uid) = notebook_uid {
|
||||
let button_text = title.clone().unwrap_or("Untitled Plan".to_string());
|
||||
let theme = theme.clone();
|
||||
buttons.push(ctx.add_typed_action_view(move |_| {
|
||||
make_plan_button(button_text, *notebook_uid, theme)
|
||||
}));
|
||||
}
|
||||
}
|
||||
Artifact::PullRequest {
|
||||
url,
|
||||
branch,
|
||||
repo,
|
||||
number,
|
||||
} => {
|
||||
if !branch.is_empty() {
|
||||
let theme = theme.clone();
|
||||
buttons.push(
|
||||
ctx.add_typed_action_view(move |_| {
|
||||
make_branch_button(branch.clone(), theme)
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if !url.is_empty() {
|
||||
let theme = theme.clone();
|
||||
buttons.push(ctx.add_typed_action_view(move |_| {
|
||||
make_pr_button(url.clone(), repo.clone(), *number, theme)
|
||||
}));
|
||||
}
|
||||
}
|
||||
Artifact::Screenshot {
|
||||
artifact_uid,
|
||||
mime_type: _,
|
||||
description: _,
|
||||
} => {
|
||||
screenshot_uids.push(artifact_uid.clone());
|
||||
}
|
||||
Artifact::File {
|
||||
artifact_uid,
|
||||
filepath,
|
||||
filename,
|
||||
..
|
||||
} => {
|
||||
let button_text = file_button_label(filename, filepath);
|
||||
let theme = theme.clone();
|
||||
buttons.push(ctx.add_typed_action_view(move |_| {
|
||||
make_file_button(button_text, artifact_uid.clone(), theme)
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !screenshot_uids.is_empty() {
|
||||
let theme = theme.clone();
|
||||
buttons.push(ctx.add_typed_action_view(move |_| {
|
||||
make_screenshot_button("Screenshots".to_string(), screenshot_uids, theme)
|
||||
}));
|
||||
}
|
||||
|
||||
buttons
|
||||
}
|
||||
|
||||
fn make_plan_button(
|
||||
title: String,
|
||||
notebook_uid: NotebookId,
|
||||
theme: Arc<dyn ActionButtonTheme>,
|
||||
) -> ActionButton {
|
||||
make_artifact_button(
|
||||
title,
|
||||
Icon::Compass,
|
||||
"Open plan",
|
||||
None,
|
||||
ArtifactButtonAction::OpenPlan { notebook_uid },
|
||||
theme,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_branch_button(branch: String, theme: Arc<dyn ActionButtonTheme>) -> ActionButton {
|
||||
make_artifact_button(
|
||||
branch.clone(),
|
||||
Icon::GitBranch,
|
||||
"Copy branch name",
|
||||
Some(AnsiColorIdentifier::Green),
|
||||
ArtifactButtonAction::CopyBranch { branch },
|
||||
theme,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_pr_button(
|
||||
url: String,
|
||||
repo: Option<String>,
|
||||
number: Option<u32>,
|
||||
theme: Arc<dyn ActionButtonTheme>,
|
||||
) -> ActionButton {
|
||||
let display_text = match (repo, number) {
|
||||
(Some(repo), Some(num)) => format!("{repo} #{num}"),
|
||||
// When we deserialize, we either get both values or neither, hence the
|
||||
// wildcard match here.
|
||||
_ => String::from("PR"),
|
||||
};
|
||||
make_artifact_button(
|
||||
display_text,
|
||||
Icon::Github,
|
||||
"Open pull request",
|
||||
None,
|
||||
ArtifactButtonAction::OpenPullRequest { url },
|
||||
theme,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_screenshot_button(
|
||||
label: String,
|
||||
artifact_uids: Vec<String>,
|
||||
theme: Arc<dyn ActionButtonTheme>,
|
||||
) -> ActionButton {
|
||||
make_artifact_button(
|
||||
label,
|
||||
Icon::Image,
|
||||
"View screenshots",
|
||||
None,
|
||||
ArtifactButtonAction::ViewScreenshots { artifact_uids },
|
||||
theme,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_file_button(
|
||||
label: String,
|
||||
artifact_uid: String,
|
||||
theme: Arc<dyn ActionButtonTheme>,
|
||||
) -> ActionButton {
|
||||
make_artifact_button(
|
||||
label,
|
||||
Icon::File,
|
||||
"Download file",
|
||||
None,
|
||||
ArtifactButtonAction::DownloadFile { artifact_uid },
|
||||
theme,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_artifact_button(
|
||||
display_text: String,
|
||||
icon: Icon,
|
||||
tooltip: &str,
|
||||
icon_color: Option<AnsiColorIdentifier>,
|
||||
action: ArtifactButtonAction,
|
||||
theme: Arc<dyn ActionButtonTheme>,
|
||||
) -> ActionButton {
|
||||
let mut button = ActionButton::new_with_boxed_theme(display_text, theme)
|
||||
.with_size(ButtonSize::Small)
|
||||
.with_icon(icon)
|
||||
.with_tooltip(tooltip)
|
||||
.with_tooltip_alignment(TooltipAlignment::Center)
|
||||
.with_tooltip_positioning_provider(Arc::new(MenuPositioning::BelowInputBox))
|
||||
.with_max_label_width(BUTTON_MAX_TEXT_WIDTH)
|
||||
.on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(action.clone());
|
||||
});
|
||||
|
||||
if let Some(color) = icon_color {
|
||||
button = button.with_icon_ansi_color(color);
|
||||
}
|
||||
|
||||
button
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
use std::path::Path;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
|
||||
use warp_core::report_error;
|
||||
use warp_multi_agent_api as api;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use warpui::platform::SaveFilePickerConfiguration;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::artifact_download::default_download_filename;
|
||||
use crate::ai::artifact_download::sanitized_basename;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::artifact_download::{default_download_directory, download_artifact_bytes};
|
||||
use crate::notebooks::NotebookId;
|
||||
use crate::server::server_api::ai::ArtifactDownloadResponse;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::ToastStack;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
pub mod buttons;
|
||||
pub use buttons::{ArtifactButtonsRow, ArtifactButtonsRowEvent};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
|
||||
#[serde(tag = "artifact_type", content = "data")]
|
||||
pub enum Artifact {
|
||||
#[serde(rename = "PLAN")]
|
||||
Plan {
|
||||
document_uid: String,
|
||||
/// None until the plan is synced to Warp Drive.
|
||||
notebook_uid: Option<NotebookId>,
|
||||
title: Option<String>,
|
||||
},
|
||||
#[serde(rename = "PULL_REQUEST")]
|
||||
PullRequest {
|
||||
url: String,
|
||||
branch: String,
|
||||
#[serde(skip_serializing)] // We derive this field from the url on deserialize
|
||||
repo: Option<String>,
|
||||
#[serde(skip_serializing)] // We derive this field from the url on deserialize
|
||||
number: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "SCREENSHOT")]
|
||||
Screenshot {
|
||||
artifact_uid: String,
|
||||
mime_type: String,
|
||||
description: Option<String>,
|
||||
},
|
||||
#[serde(rename = "FILE")]
|
||||
File {
|
||||
artifact_uid: String,
|
||||
filepath: String,
|
||||
filename: String,
|
||||
mime_type: String,
|
||||
description: Option<String>,
|
||||
size_bytes: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "artifact_type", content = "data")]
|
||||
enum ArtifactHelper {
|
||||
#[serde(rename = "PLAN")]
|
||||
Plan {
|
||||
document_uid: String,
|
||||
notebook_uid: Option<NotebookId>,
|
||||
title: Option<String>,
|
||||
},
|
||||
#[serde(rename = "PULL_REQUEST")]
|
||||
PullRequest { url: String, branch: String },
|
||||
#[serde(rename = "SCREENSHOT")]
|
||||
Screenshot {
|
||||
artifact_uid: String,
|
||||
mime_type: String,
|
||||
description: Option<String>,
|
||||
},
|
||||
#[serde(rename = "FILE")]
|
||||
File {
|
||||
artifact_uid: String,
|
||||
filepath: String,
|
||||
filename: String,
|
||||
mime_type: String,
|
||||
description: Option<String>,
|
||||
size_bytes: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for Artifact {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let helper = ArtifactHelper::deserialize(deserializer)?;
|
||||
Ok(match helper {
|
||||
ArtifactHelper::Plan {
|
||||
document_uid,
|
||||
notebook_uid,
|
||||
title,
|
||||
} => Artifact::Plan {
|
||||
document_uid,
|
||||
notebook_uid,
|
||||
title,
|
||||
},
|
||||
ArtifactHelper::PullRequest { url, branch } => {
|
||||
let (repo, number) = parse_github_pr_url(&url).unzip();
|
||||
Artifact::PullRequest {
|
||||
url,
|
||||
branch,
|
||||
repo,
|
||||
number,
|
||||
}
|
||||
}
|
||||
ArtifactHelper::Screenshot {
|
||||
artifact_uid,
|
||||
mime_type,
|
||||
description,
|
||||
} => Artifact::Screenshot {
|
||||
artifact_uid,
|
||||
mime_type,
|
||||
description,
|
||||
},
|
||||
ArtifactHelper::File {
|
||||
artifact_uid,
|
||||
filepath,
|
||||
filename,
|
||||
mime_type,
|
||||
description,
|
||||
size_bytes,
|
||||
} => Artifact::File {
|
||||
artifact_uid,
|
||||
filepath,
|
||||
filename,
|
||||
mime_type,
|
||||
description,
|
||||
size_bytes,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<api::message::artifact_event::PullRequestArtifact> for Artifact {
|
||||
fn from(pr: api::message::artifact_event::PullRequestArtifact) -> Self {
|
||||
let (repo, number) = parse_github_pr_url(&pr.url).unzip();
|
||||
Artifact::PullRequest {
|
||||
url: pr.url,
|
||||
branch: pr.branch,
|
||||
repo,
|
||||
number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<api::message::artifact_event::ScreenshotArtifact> for Artifact {
|
||||
fn from(screenshot: api::message::artifact_event::ScreenshotArtifact) -> Self {
|
||||
Artifact::Screenshot {
|
||||
artifact_uid: screenshot.artifact_uid,
|
||||
mime_type: screenshot.mime_type,
|
||||
description: if screenshot.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(screenshot.description)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<api::message::artifact_event::FileArtifact> for Artifact {
|
||||
fn from(file: api::message::artifact_event::FileArtifact) -> Self {
|
||||
Artifact::File {
|
||||
artifact_uid: file.artifact_uid,
|
||||
filepath: file.filepath.clone(),
|
||||
filename: Path::new(&file.filepath)
|
||||
.file_name()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.filter(|file_name| !file_name.trim().is_empty())
|
||||
.unwrap_or("File")
|
||||
.to_string(),
|
||||
mime_type: file.mime_type,
|
||||
description: if file.description.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(file.description)
|
||||
},
|
||||
size_bytes: i32::try_from(file.size_bytes).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<api::message::artifact_event::PlanArtifact> for Artifact {
|
||||
fn from(plan: api::message::artifact_event::PlanArtifact) -> Self {
|
||||
Artifact::Plan {
|
||||
document_uid: plan.document_id,
|
||||
notebook_uid: if plan.notebook_uid.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(NotebookId::from(plan.notebook_uid))
|
||||
},
|
||||
title: if plan.title.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(plan.title)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<warp_graphql::ai::AIConversationArtifact> for Artifact {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: warp_graphql::ai::AIConversationArtifact) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
warp_graphql::ai::AIConversationArtifact::PlanArtifact(plan) => Ok(Artifact::Plan {
|
||||
document_uid: plan.document_uid.into_inner(),
|
||||
notebook_uid: plan
|
||||
.notebook_uid
|
||||
.map(|id| NotebookId::from(id.into_inner())),
|
||||
title: plan.title,
|
||||
}),
|
||||
warp_graphql::ai::AIConversationArtifact::PullRequestArtifact(pr) => {
|
||||
let (repo, number) = parse_github_pr_url(&pr.url).unzip();
|
||||
Ok(Artifact::PullRequest {
|
||||
url: pr.url,
|
||||
branch: pr.branch,
|
||||
repo,
|
||||
number,
|
||||
})
|
||||
}
|
||||
warp_graphql::ai::AIConversationArtifact::ScreenshotArtifact(screenshot) => {
|
||||
Ok(Artifact::Screenshot {
|
||||
artifact_uid: screenshot.artifact_uid.into_inner(),
|
||||
mime_type: screenshot.mime_type,
|
||||
description: screenshot.description,
|
||||
})
|
||||
}
|
||||
warp_graphql::ai::AIConversationArtifact::FileArtifact(file) => Ok(Artifact::File {
|
||||
artifact_uid: file.artifact_uid.into_inner(),
|
||||
filepath: file.filepath.clone(),
|
||||
filename: sanitized_basename(&file.filepath).unwrap_or(file.filepath),
|
||||
mime_type: file.mime_type,
|
||||
description: file.description,
|
||||
size_bytes: file.size_bytes,
|
||||
}),
|
||||
warp_graphql::ai::AIConversationArtifact::Unknown => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse GitHub PR URL to extract repo and number.
|
||||
/// Expected format: https://github.com/{owner}/{repo}/pull/{number}
|
||||
pub fn parse_github_pr_url(url: &str) -> Option<(String, u32)> {
|
||||
if !url.contains("github.com") {
|
||||
return None;
|
||||
}
|
||||
let segments: Vec<&str> = url.split('/').collect();
|
||||
segments.windows(3).find_map(|w| {
|
||||
if w[1] != "pull" {
|
||||
return None;
|
||||
}
|
||||
Some((w[0].to_string(), w[2].parse().ok()?))
|
||||
})
|
||||
}
|
||||
|
||||
/// Deserialize artifacts, skipping any that fail to parse.
|
||||
/// This ensures task loading doesn't fail entirely if an artifact has an unknown format.
|
||||
pub fn deserialize_artifacts<'de, D>(deserializer: D) -> Result<Vec<Artifact>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let values: Vec<serde_json::Value> = serde::Deserialize::deserialize(deserializer)?;
|
||||
Ok(values
|
||||
.into_iter()
|
||||
.filter_map(|value| match serde_json::from_value::<Artifact>(value) {
|
||||
Ok(artifact) => Some(artifact),
|
||||
Err(e) => {
|
||||
report_error!(anyhow!("Failed to deserialize artifact, skipping: {}", e));
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn file_button_label(filename: &str, filepath: &str) -> String {
|
||||
if let Some(filename) = non_empty_trimmed(filename) {
|
||||
return filename.to_string();
|
||||
}
|
||||
if let Some(filepath_basename) = sanitized_basename(filepath)
|
||||
.as_deref()
|
||||
.and_then(non_empty_trimmed)
|
||||
{
|
||||
return filepath_basename.to_string();
|
||||
}
|
||||
"File".to_string()
|
||||
}
|
||||
|
||||
pub fn open_screenshot_lightbox<V: warpui::View>(
|
||||
artifact_uids: &[String],
|
||||
ctx: &mut warpui::ViewContext<V>,
|
||||
) {
|
||||
// Open lightbox immediately with Loading placeholders.
|
||||
let loading_images: Vec<LightboxImage> = artifact_uids
|
||||
.iter()
|
||||
.map(|_| LightboxImage {
|
||||
source: LightboxImageSource::Loading,
|
||||
description: None,
|
||||
})
|
||||
.collect();
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::OpenLightbox {
|
||||
images: loading_images,
|
||||
initial_index: 0,
|
||||
});
|
||||
|
||||
// Fetch each signed URL independently and update the lightbox as each resolves.
|
||||
// TODO(QUALITY-318): We should cache the signed URL for each artifact UUID so
|
||||
// we avoid fetching screenshots already in the asset cache.
|
||||
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
|
||||
|
||||
for (i, uid) in artifact_uids.iter().enumerate() {
|
||||
let ai_client = ai_client.clone();
|
||||
let uid = uid.clone();
|
||||
let uid_for_callback = uid.clone();
|
||||
|
||||
ctx.spawn(
|
||||
async move { ai_client.get_artifact_download(&uid).await },
|
||||
move |_me, result, ctx| {
|
||||
if let Some(image) =
|
||||
screenshot_lightbox_image_from_download_result(result, &uid_for_callback, i)
|
||||
{
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::UpdateLightboxImage {
|
||||
index: i,
|
||||
image,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn screenshot_lightbox_image_from_download_result(
|
||||
result: anyhow::Result<ArtifactDownloadResponse>,
|
||||
uid_for_callback: &str,
|
||||
index: usize,
|
||||
) -> Option<LightboxImage> {
|
||||
match result {
|
||||
Ok(ArtifactDownloadResponse::Screenshot { data, .. }) => Some(LightboxImage {
|
||||
source: LightboxImageSource::Resolved {
|
||||
asset_source: asset_cache::url_source(data.download_url),
|
||||
},
|
||||
description: data
|
||||
.description
|
||||
.filter(|description| !description.is_empty()),
|
||||
}),
|
||||
Ok(ArtifactDownloadResponse::File { .. }) => {
|
||||
log::warn!("Artifact {uid_for_callback} was not a screenshot");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load screenshot artifact {index}: {e}");
|
||||
Some(LightboxImage {
|
||||
source: LightboxImageSource::Loading,
|
||||
description: Some("Failed to load".to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn download_file_artifact<V: warpui::View>(
|
||||
artifact_uid: &str,
|
||||
ctx: &mut warpui::ViewContext<V>,
|
||||
) {
|
||||
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
|
||||
let artifact_uid = artifact_uid.to_string();
|
||||
let artifact_uid_for_request = artifact_uid.clone();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
ai_client
|
||||
.get_artifact_download(&artifact_uid_for_request)
|
||||
.await
|
||||
},
|
||||
move |_me, result, ctx| match result {
|
||||
Ok(artifact) => open_file_download_result(&artifact_uid, artifact, ctx),
|
||||
Err(error) => {
|
||||
log::warn!("Failed to load file artifact {artifact_uid}: {error}");
|
||||
show_file_download_toast(
|
||||
&artifact_uid,
|
||||
DismissibleToast::error("Failed to prepare file download.".to_string()),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn open_file_download_result<V: warpui::View>(
|
||||
artifact_uid: &str,
|
||||
artifact: ArtifactDownloadResponse,
|
||||
ctx: &mut warpui::ViewContext<V>,
|
||||
) {
|
||||
match artifact {
|
||||
ArtifactDownloadResponse::File { .. } => {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
open_file_download_picker(artifact, ctx);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
{
|
||||
ctx.open_url(artifact.download_url());
|
||||
}
|
||||
}
|
||||
ArtifactDownloadResponse::Screenshot { .. } => {
|
||||
log::warn!("Artifact {artifact_uid} was not a file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn open_file_download_picker<V: warpui::View>(
|
||||
artifact: ArtifactDownloadResponse,
|
||||
ctx: &mut warpui::ViewContext<V>,
|
||||
) {
|
||||
let mut config = SaveFilePickerConfiguration::new()
|
||||
.with_default_filename(default_download_filename(&artifact));
|
||||
if let Some(default_directory) = default_download_directory() {
|
||||
config = config.with_default_directory(default_directory);
|
||||
}
|
||||
|
||||
ctx.open_save_file_picker(
|
||||
move |path_opt: Option<String>, _me: &mut V, ctx: &mut warpui::ViewContext<V>| {
|
||||
let Some(path) = path_opt else {
|
||||
return;
|
||||
};
|
||||
let server_api = ServerApiProvider::handle(ctx).as_ref(ctx).get();
|
||||
let artifact = artifact.clone();
|
||||
let artifact_uid = artifact.artifact_uid().to_string();
|
||||
let path = PathBuf::from(path);
|
||||
let toast_filename = download_toast_filename(&path);
|
||||
let artifact_for_download = artifact.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
download_artifact_bytes(server_api.http_client(), &artifact_for_download, &path)
|
||||
.await
|
||||
},
|
||||
move |_me, result, ctx| match result {
|
||||
Ok(()) => show_file_download_toast(
|
||||
&artifact_uid,
|
||||
DismissibleToast::success(format!("Downloaded {toast_filename}.")),
|
||||
ctx,
|
||||
),
|
||||
Err(error) => {
|
||||
log::warn!("Failed to download file artifact {artifact_uid}: {error}");
|
||||
show_file_download_toast(
|
||||
&artifact_uid,
|
||||
DismissibleToast::error(format!(
|
||||
"Failed to download {toast_filename}."
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
fn show_file_download_toast<V: warpui::View>(
|
||||
artifact_uid: &str,
|
||||
toast: DismissibleToast<WorkspaceAction>,
|
||||
ctx: &mut warpui::ViewContext<V>,
|
||||
) {
|
||||
let toast_id = format!("artifact_download:{artifact_uid}");
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, move |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(toast.with_object_id(toast_id), window_id, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn download_toast_filename(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|file_name| file_name.to_str())
|
||||
.filter(|file_name| !file_name.is_empty())
|
||||
.unwrap_or("file")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn non_empty_trimmed(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::artifact_download::default_download_filename;
|
||||
use crate::server::server_api::ai::{
|
||||
ArtifactDownloadCommonFields, FileArtifactResponseData, ScreenshotArtifactResponseData,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_pr_url() {
|
||||
assert_eq!(
|
||||
parse_github_pr_url("https://github.com/owner/repo/pull/123"),
|
||||
Some(("repo".to_string(), 123))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_pr_url("https://github.com/my-org/my-repo/pull/456"),
|
||||
Some(("my-repo".to_string(), 456))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_github_pr_url("https://github.com/my-org/my-repo"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_github_pr_url("not a url"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_lightbox_update_for_non_screenshot_artifact() {
|
||||
let image = screenshot_lightbox_image_from_download_result(
|
||||
Ok(ArtifactDownloadResponse::File {
|
||||
common: ArtifactDownloadCommonFields {
|
||||
artifact_uid: "artifact-123".to_string(),
|
||||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(),
|
||||
},
|
||||
data: FileArtifactResponseData {
|
||||
download_url: "https://storage.example.com/report.txt".to_string(),
|
||||
expires_at: Utc.with_ymd_and_hms(2024, 1, 15, 11, 30, 0).unwrap(),
|
||||
content_type: "text/plain".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
filename: "report.txt".to_string(),
|
||||
description: Some("daily summary".to_string()),
|
||||
size_bytes: Some(42),
|
||||
},
|
||||
}),
|
||||
"artifact-123",
|
||||
0,
|
||||
);
|
||||
|
||||
assert!(image.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_failure_placeholder_for_screenshot_load_errors() {
|
||||
let image = screenshot_lightbox_image_from_download_result(
|
||||
Err(anyhow!("network error")),
|
||||
"artifact-123",
|
||||
0,
|
||||
)
|
||||
.expect("expected failure placeholder");
|
||||
|
||||
assert!(matches!(image.source, LightboxImageSource::Loading));
|
||||
assert_eq!(image.description.as_deref(), Some("Failed to load"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_lightbox_image_for_screenshot_artifact() {
|
||||
let image = screenshot_lightbox_image_from_download_result(
|
||||
Ok(ArtifactDownloadResponse::Screenshot {
|
||||
common: ArtifactDownloadCommonFields {
|
||||
artifact_uid: "screenshot-123".to_string(),
|
||||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(),
|
||||
},
|
||||
data: ScreenshotArtifactResponseData {
|
||||
download_url: "https://storage.example.com/screenshot.png".to_string(),
|
||||
expires_at: Utc.with_ymd_and_hms(2024, 1, 15, 11, 30, 0).unwrap(),
|
||||
content_type: "image/png".to_string(),
|
||||
description: Some("dashboard screenshot".to_string()),
|
||||
},
|
||||
}),
|
||||
"screenshot-123",
|
||||
0,
|
||||
)
|
||||
.expect("expected screenshot image");
|
||||
|
||||
assert!(matches!(image.source, LightboxImageSource::Resolved { .. }));
|
||||
assert_eq!(image.description.as_deref(), Some("dashboard screenshot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_button_label_prefers_filename() {
|
||||
assert_eq!(
|
||||
file_button_label("report.txt", "outputs/other.txt"),
|
||||
"report.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_button_label_falls_back_to_filepath_basename() {
|
||||
assert_eq!(file_button_label("", "outputs/report.txt"), "report.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_button_label_falls_back_to_generic_label() {
|
||||
assert_eq!(file_button_label("", ""), "File");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn default_download_filename_prefers_server_filename() {
|
||||
assert_eq!(
|
||||
default_download_filename(&ArtifactDownloadResponse::File {
|
||||
common: ArtifactDownloadCommonFields {
|
||||
artifact_uid: "artifact-123".to_string(),
|
||||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(),
|
||||
},
|
||||
data: FileArtifactResponseData {
|
||||
download_url: "https://storage.example.com/report.txt".to_string(),
|
||||
expires_at: Utc.with_ymd_and_hms(2024, 1, 15, 11, 30, 0).unwrap(),
|
||||
content_type: "text/plain".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
filename: "report.txt".to_string(),
|
||||
description: Some("daily summary".to_string()),
|
||||
size_bytes: Some(42),
|
||||
},
|
||||
}),
|
||||
"report.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn default_download_filename_falls_back_to_artifact_uid_with_extension() {
|
||||
assert_eq!(
|
||||
default_download_filename(&ArtifactDownloadResponse::File {
|
||||
common: ArtifactDownloadCommonFields {
|
||||
artifact_uid: "artifact-123".to_string(),
|
||||
created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap(),
|
||||
},
|
||||
data: FileArtifactResponseData {
|
||||
download_url: "https://storage.example.com/report.txt".to_string(),
|
||||
expires_at: Utc.with_ymd_and_hms(2024, 1, 15, 11, 30, 0).unwrap(),
|
||||
content_type: "text/plain".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
filename: "".to_string(),
|
||||
description: Some("daily summary".to_string()),
|
||||
size_bytes: Some(42),
|
||||
},
|
||||
}),
|
||||
"artifact-artifact-123.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_graphql_file_artifact() {
|
||||
let artifact = Artifact::try_from(warp_graphql::ai::AIConversationArtifact::FileArtifact(
|
||||
warp_graphql::ai::FileArtifact {
|
||||
artifact_uid: "artifact-file-1".into(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
description: Some("Daily summary".to_string()),
|
||||
size_bytes: Some(42),
|
||||
},
|
||||
))
|
||||
.expect("expected file artifact conversion");
|
||||
|
||||
assert_eq!(
|
||||
artifact,
|
||||
Artifact::File {
|
||||
artifact_uid: "artifact-file-1".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
filename: "report.txt".to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
description: Some("Daily summary".to_string()),
|
||||
size_bytes: Some(42),
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user