use wsb::prelude::*;
use axum::{
body::{boxed, Full},
extract::{Query, State},
http::StatusCode,
response::Response,
Json,
};
use super::sessions::ErrorResponse;
use wsb::extensions::InstalledExtension;
use crate::web::state::WebState;
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: String,
#[serde(default = "default_offset")]
pub offset: usize,
#[serde(default = "default_size")]
pub size: usize,
}
fn default_offset() -> usize {
0
}
fn default_size() -> usize {
20
}
fn validate_open_vsx_download_url(
download_url: &str,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
if !download_url.starts_with("https://open-vsx.org/") {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "download URL must be from open-vsx.org".to_string(),
}),
));
}
Ok(())
}
#[derive(Debug, Serialize)]
pub struct MarketplaceSearchResponse {
pub extensions: Vec<MarketplaceExtension>,
pub total: usize,
}
#[derive(Debug, Serialize)]
pub struct MarketplaceExtension {
pub namespace: String,
pub name: String,
pub display_name: String,
pub description: String,
pub version: String,
pub download_url: String,
pub icon_url: Option<String>,
pub download_count: u64,
pub average_rating: Option<f64>,
pub verified: bool,
}
#[derive(Debug, Deserialize)]
pub struct MarketplaceReadmeQuery {
pub namespace: String,
pub name: String,
pub version: String,
}
#[derive(Debug, Deserialize)]
struct OpenVsxSearchResponse {
#[serde(default)]
extensions: Vec<OpenVsxSearchEntry>,
#[serde(rename = "totalSize", default)]
total_size: usize,
}
#[derive(Debug, Deserialize)]
struct OpenVsxSearchEntry {
#[serde(default)]
namespace: String,
#[serde(default)]
name: String,
#[serde(rename = "displayName", default)]
display_name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
version: String,
#[serde(default)]
files: std::collections::HashMap<String, String>,
#[serde(rename = "downloadCount", default)]
download_count: u64,
#[serde(rename = "averageRating")]
average_rating: Option<f64>,
#[serde(default)]
verified: bool,
}
#[derive(Debug, Deserialize)]
struct OpenVsxExtensionDetail {
#[serde(default)]
files: std::collections::HashMap<String, String>,
}
pub async fn search_marketplace(
Query(query): Query<SearchQuery>,
) -> Result<Json<MarketplaceSearchResponse>, (StatusCode, Json<ErrorResponse>)> {
let encoded_query: String = url::form_urlencoded::byte_serialize(query.q.as_bytes()).collect();
let url = format!(
"https://open-vsx.org/api/-/search?query={}&offset={}&size={}",
encoded_query, query.offset, query.size,
);
let response = reqwest::get(&url)
.await
.map_err(|e| internal_error(format!("marketplace request failed: {e}")))?;
let body: OpenVsxSearchResponse = response
.json()
.await
.map_err(|e| internal_error(format!("marketplace response parse failed: {e}")))?;
let extensions = body
.extensions
.into_iter()
.map(|entry| MarketplaceExtension {
display_name: entry.display_name.unwrap_or_else(|| entry.name.clone()),
description: entry.description.unwrap_or_default(),
download_url: entry.files.get("download").cloned().unwrap_or_default(),
icon_url: entry.files.get("icon").cloned(),
namespace: entry.namespace,
name: entry.name,
version: entry.version,
download_count: entry.download_count,
average_rating: entry.average_rating,
verified: entry.verified,
})
.collect();
Ok(Json(MarketplaceSearchResponse {
extensions,
total: body.total_size,
}))
}
pub async fn get_marketplace_readme(
Query(query): Query<MarketplaceReadmeQuery>,
) -> Result<Response, (StatusCode, Json<ErrorResponse>)> {
let detail_url = format!(
"https://open-vsx.org/api/{}/{}/{}",
query.namespace, query.name, query.version,
);
let detail_response = reqwest::get(&detail_url)
.await
.map_err(|e| internal_error(format!("marketplace readme metadata request failed: {e}")))?;
if !detail_response.status().is_success() {
return Err(internal_error(format!(
"marketplace readme metadata request returned status {}",
detail_response.status()
)));
}
let detail: OpenVsxExtensionDetail = detail_response
.json()
.await
.map_err(|e| internal_error(format!("marketplace readme metadata parse failed: {e}")))?;
let readme_url = detail.files.get("readme").ok_or_else(|| {
internal_error(format!(
"marketplace extension '{}.{}@{}' does not expose a README",
query.namespace, query.name, query.version
))
})?;
let readme_response = reqwest::get(readme_url)
.await
.map_err(|e| internal_error(format!("marketplace readme request failed: {e}")))?;
if !readme_response.status().is_success() {
return Err(internal_error(format!(
"marketplace readme request returned status {}",
readme_response.status()
)));
}
let readme = readme_response
.text()
.await
.map_err(|e| internal_error(format!("marketplace readme body parse failed: {e}")))?;
Response::builder()
.status(StatusCode::OK)
.header(
axum::http::header::CONTENT_TYPE,
"text/markdown; charset=utf-8",
)
.body(boxed(Full::from(readme)))
.map_err(|e| internal_error(format!("failed to build marketplace readme response: {e}")))
}
#[derive(Debug, Deserialize)]
pub struct InstallRequest {
pub download_url: String,
}
#[derive(Debug, Serialize)]
pub struct MarketplaceInspectionResponse {
pub extension_id: String,
pub display_name: String,
pub version: String,
pub activation_events: Vec<String>,
pub runtime_entrypoint: Option<String>,
pub commands: Vec<String>,
pub languages: Vec<String>,
pub grammars: Vec<String>,
pub menus: Vec<String>,
pub configuration_properties: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct InstallResponse {
pub installed: bool,
pub path: String,
}
pub async fn install_extension(
State(state): State<WebState>,
Json(request): Json<InstallRequest>,
) -> Result<Json<InstallResponse>, (StatusCode, Json<ErrorResponse>)> {
validate_open_vsx_download_url(&request.download_url)?;
let response = reqwest::get(&request.download_url)
.await
.map_err(|e| internal_error(format!("failed to download VSIX: {e}")))?;
if !response.status().is_success() {
return Err(internal_error(format!(
"VSIX download returned status {}",
response.status()
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| internal_error(format!("failed to read VSIX bytes: {e}")))?;
let filename = request
.download_url
.rsplit('/')
.next()
.unwrap_or("extension.vsix");
let filename = if filename.ends_with(".vsix") {
filename.to_string()
} else {
format!("{filename}.vsix")
};
let extensions_dir = state.workspace_root.join(".wsb").join("extensions");
std::fs::create_dir_all(&extensions_dir)
.map_err(|e| internal_error(format!("failed to create extensions directory: {e}")))?;
let dest = extensions_dir.join(&filename);
std::fs::write(&dest, &bytes)
.map_err(|e| internal_error(format!("failed to write VSIX: {e}")))?;
let relative_path = dest
.strip_prefix(&state.workspace_root)
.unwrap_or(&dest)
.display()
.to_string();
Ok(Json(InstallResponse {
installed: true,
path: relative_path,
}))
}
pub async fn inspect_marketplace_extension(
Json(request): Json<InstallRequest>,
) -> Result<Json<MarketplaceInspectionResponse>, (StatusCode, Json<ErrorResponse>)> {
validate_open_vsx_download_url(&request.download_url)?;
let response = reqwest::get(&request.download_url)
.await
.map_err(|e| internal_error(format!("failed to download VSIX: {e}")))?;
if !response.status().is_success() {
return Err(internal_error(format!(
"VSIX download returned status {}",
response.status()
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| internal_error(format!("failed to read VSIX bytes: {e}")))?;
let (extension, warnings) =
InstalledExtension::from_vsix_bytes(&request.download_url, bytes.to_vec())
.map_err(internal_error)?;
let menus = extension
.contributes
.menus
.iter()
.flat_map(|(menu_id, entries)| {
entries
.iter()
.map(move |entry| format!("{menu_id}:{}", entry.command))
})
.collect::<Vec<_>>();
let configuration_properties = extension
.contributes
.configuration
.as_ref()
.and_then(|configuration| configuration.properties.as_object())
.map(|properties| {
let mut keys = properties.keys().cloned().collect::<Vec<_>>();
keys.sort();
keys
})
.unwrap_or_default();
Ok(Json(MarketplaceInspectionResponse {
extension_id: extension.id,
display_name: extension.display_name,
version: extension.version,
activation_events: extension.activation_events,
runtime_entrypoint: extension
.runtime
.as_ref()
.map(|runtime| runtime.entrypoint.clone()),
commands: extension
.contributes
.commands
.iter()
.map(|command| format!("{} ({})", command.title, command.command))
.collect(),
languages: extension
.contributes
.languages
.iter()
.map(|language| language.id.clone())
.collect(),
grammars: extension
.contributes
.grammars
.iter()
.map(|grammar| match &grammar.language {
Some(language) => format!("{language} ({})", grammar.scope_name),
None => grammar.scope_name.clone(),
})
.collect(),
menus,
configuration_properties,
warnings,
}))
}
#[derive(Debug, Deserialize)]
pub struct UninstallRequest {
pub extension_id: String,
}
#[derive(Debug, Serialize)]
pub struct UninstallResponse {
pub uninstalled: bool,
}
pub async fn uninstall_extension(
State(state): State<WebState>,
Json(request): Json<UninstallRequest>,
) -> Result<Json<UninstallResponse>, (StatusCode, Json<ErrorResponse>)> {
use wsb::extensions::ExtensionHost;
let host = ExtensionHost::new(state.workspace_root.clone());
let (extensions, _) = host
.discover()
.map_err(|e| internal_error(format!("failed to discover extensions: {e}")))?;
let target = extensions
.iter()
.find(|ext| ext.id == request.extension_id)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("extension '{}' is not installed", request.extension_id),
}),
)
})?;
std::fs::remove_file(&target.package_path)
.map_err(|e| internal_error(format!("failed to delete {}: {e}", target.package_path)))?;
Ok(Json(UninstallResponse { uninstalled: true }))
}
#[derive(Debug, Deserialize)]
pub struct ToggleExtensionRequest {
pub extension_id: String,
}
#[derive(Debug, Serialize)]
pub struct ToggleExtensionResponse {
pub ok: bool,
}
pub async fn disable_extension(
State(state): State<WebState>,
Json(request): Json<ToggleExtensionRequest>,
) -> Result<Json<ToggleExtensionResponse>, (StatusCode, Json<ErrorResponse>)> {
use wsb::extensions::ExtensionHost;
let host = ExtensionHost::new(state.workspace_root.clone());
host.disable_extension(&request.extension_id)
.map_err(|e| internal_error(format!("{e}")))?;
Ok(Json(ToggleExtensionResponse { ok: true }))
}
pub async fn enable_extension(
State(state): State<WebState>,
Json(request): Json<ToggleExtensionRequest>,
) -> Result<Json<ToggleExtensionResponse>, (StatusCode, Json<ErrorResponse>)> {
use wsb::extensions::ExtensionHost;
let host = ExtensionHost::new(state.workspace_root.clone());
host.enable_extension(&request.extension_id)
.map_err(|e| internal_error(format!("{e}")))?;
Ok(Json(ToggleExtensionResponse { ok: true }))
}
fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<ErrorResponse>) {
let message = format!("{error:#}");
tracing::error!("{message}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: message }),
)
}