feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): bibliothèque d'icônes projet personnalisées importées par l'utilisateur — #358 (QA verte)
domain: variante ProjectIconSet::Custom, métadonnées CustomProjectIconAsset (id opaque, media type png/jpeg/webp, byte_size, dimensions décodées, sha256, nom de fichier original sans chemin, created_at), CustomProjectIconContent et port CustomProjectIconAssetStore (list/read/exists/create/delete) avec CustomProjectIconStoreError dédiée. application: use case project_icon_assets — import validé png/jpeg/webp avec rejet du contenu SVG, dimensionnement et digest sha256, bibliothèque partagée à l'instance indépendante de tout projet, suppression refusée tant que l'asset est référencé par un projet. infrastructure: store fichier store/project_icon_asset.rs — index canonique + octets, création atomique sous extension validée. backend/web-server: contrat HTTP /api/project-icon-assets (GET liste, POST import, GET lecture, GET /usage, DELETE) avec prévol OPTIONS et tests contrat incluant le rejet SVG. app-tauri: commandes import/list/read/get_usage/delete_custom_project_icon_asset exposées au frontend. frontend: port ProjectIconAssetGateway (list/import/delete/getUrl sans exposer de chemin filesystem), gateways http+mock avec tests, DI, section Icônes personnalisées du ProjectIconPicker (import fichier accept png/jpeg/webp, sélection role=option, suppression avec retrait de sélection, erreurs lisibles i18n FR/EN), tests projectIcons étendus. QA verte: contrats HTTP et Tauri identiques, suites cargo et npm ciblées vertes.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -102,6 +102,7 @@ dependencies = [
|
||||
"chrono-tz",
|
||||
"dirs",
|
||||
"domain",
|
||||
"hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@ -54,21 +54,22 @@ use crate::dto::{
|
||||
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
|
||||
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
|
||||
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
|
||||
CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
CreateTemplateRequestDto, CustomProjectIconAssetContentDto, CustomProjectIconAssetDto,
|
||||
CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
|
||||
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
|
||||
EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto,
|
||||
ExecuteSlashCommandRequestDto, ExecuteSlashCommandResponseDto, FirstRunStateDto,
|
||||
FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
|
||||
GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
|
||||
HealthRequestDto, HealthResponseDto, ImportChatAttachmentsRequestDto,
|
||||
ImportChatAttachmentsResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
ManagedTempGcResponseDto, ManagedTempHandleRequestDto, ManagedTempLeaseDto,
|
||||
ManagedTempLeaseResponseDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
|
||||
ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto,
|
||||
OpenProjectRestoreStateDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto,
|
||||
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
|
||||
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
|
||||
ImportChatAttachmentsResponseDto, ImportCustomProjectIconAssetRequestDto,
|
||||
InspectConversationRequestDto, InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto,
|
||||
LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, ManagedTempGcResponseDto,
|
||||
ManagedTempHandleRequestDto, ManagedTempLeaseDto, ManagedTempLeaseResponseDto, MemoryDto,
|
||||
MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto,
|
||||
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto,
|
||||
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
|
||||
RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto,
|
||||
@ -893,6 +894,81 @@ pub async fn update_project_settings(
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Imports a PNG, JPEG or WebP into the instance-local shared icon library.
|
||||
#[tauri::command]
|
||||
pub async fn import_custom_project_icon_asset(
|
||||
request: ImportCustomProjectIconAssetRequestDto,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CustomProjectIconAssetDto, ErrorDto> {
|
||||
let bytes = STANDARD
|
||||
.decode(request.data_base64.trim())
|
||||
.map_err(|error| ErrorDto::invalid(format!("invalid base64 image bytes: {error}")))?;
|
||||
state
|
||||
.import_custom_project_icon_asset
|
||||
.execute(request.original_filename, request.media_type, bytes)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Lists the shared custom icon library.
|
||||
#[tauri::command]
|
||||
pub async fn list_custom_project_icon_assets(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<CustomProjectIconAssetDto>, ErrorDto> {
|
||||
state
|
||||
.list_custom_project_icon_assets
|
||||
.execute()
|
||||
.await
|
||||
.map(|assets| assets.into_iter().map(Into::into).collect())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Reads one shared icon without exposing its filesystem path.
|
||||
#[tauri::command]
|
||||
pub async fn read_custom_project_icon_asset(
|
||||
asset_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CustomProjectIconAssetContentDto, ErrorDto> {
|
||||
state
|
||||
.read_custom_project_icon_asset
|
||||
.execute(&asset_id)
|
||||
.await
|
||||
.map(|output| CustomProjectIconAssetContentDto {
|
||||
metadata: output.metadata.into(),
|
||||
data_base64: STANDARD.encode(output.bytes),
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Returns current registered-project usage for one shared icon.
|
||||
#[tauri::command]
|
||||
pub async fn get_custom_project_icon_asset_usage(
|
||||
asset_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CustomProjectIconUsageDto, ErrorDto> {
|
||||
state
|
||||
.get_custom_project_icon_asset_usage
|
||||
.execute(&asset_id)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Deletes one shared icon when it is unused.
|
||||
#[tauri::command]
|
||||
pub async fn delete_custom_project_icon_asset(
|
||||
asset_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<CustomProjectIconUsageDto, ErrorDto> {
|
||||
state
|
||||
.delete_custom_project_icon_asset
|
||||
.execute(&asset_id)
|
||||
.await
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// `get_project_permissions` — read `.ideai/permissions.json`.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@ -267,6 +267,11 @@ pub fn run() {
|
||||
commands::update_user_settings,
|
||||
commands::read_project_settings,
|
||||
commands::update_project_settings,
|
||||
commands::import_custom_project_icon_asset,
|
||||
commands::list_custom_project_icon_assets,
|
||||
commands::read_custom_project_icon_asset,
|
||||
commands::get_custom_project_icon_asset_usage,
|
||||
commands::delete_custom_project_icon_asset,
|
||||
commands::get_project_permissions,
|
||||
commands::update_project_permissions,
|
||||
commands::update_agent_permissions,
|
||||
|
||||
@ -13,6 +13,7 @@ async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
# Resolves the OpenCode cache dir (`~/.cache/opencode/models.json`) for the
|
||||
# dynamic provider catalogue (ticket #92 follow-up). See
|
||||
|
||||
@ -41,6 +41,15 @@ pub enum AppError {
|
||||
#[error("concurrency conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// A custom icon cannot be removed while projects reference it.
|
||||
#[error("custom icon {asset_id} is used by {used_by_project_count} project(s)")]
|
||||
CustomIconInUse {
|
||||
/// Stable asset id.
|
||||
asset_id: String,
|
||||
/// Current usage count.
|
||||
used_by_project_count: u64,
|
||||
},
|
||||
|
||||
/// A filesystem operation failed.
|
||||
#[error("filesystem error: {0}")]
|
||||
FileSystem(String),
|
||||
@ -161,6 +170,7 @@ impl AppError {
|
||||
Self::Invalid(_) => "INVALID",
|
||||
Self::AmbiguousSkillName { .. } => "ambiguousSkillName",
|
||||
Self::Conflict(_) => "CONFLICT",
|
||||
Self::CustomIconInUse { .. } => "CUSTOM_ICON_IN_USE",
|
||||
Self::FileSystem(_) => "FILESYSTEM",
|
||||
Self::Store(_) => "STORE",
|
||||
Self::Process(_) => "PROCESS",
|
||||
|
||||
@ -33,6 +33,7 @@ pub mod orchestrator;
|
||||
pub mod permission;
|
||||
pub mod plugin;
|
||||
pub mod project;
|
||||
pub mod project_icon_assets;
|
||||
pub mod project_plugin;
|
||||
pub mod remote;
|
||||
pub mod scheduled_task;
|
||||
@ -220,6 +221,12 @@ pub use project::{
|
||||
RetryOpenProjectRestore, SaveOpenProjectRestoreOrder, UpdateProjectContext,
|
||||
UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
pub use project_icon_assets::{
|
||||
CustomProjectIconAssetView, CustomProjectIconContentView, CustomProjectIconUsage,
|
||||
DeleteCustomProjectIconAsset, GetCustomProjectIconAssetUsage, ImportCustomProjectIconAsset,
|
||||
ListCustomProjectIconAssets, ReadCustomProjectIconAsset, CUSTOM_PROJECT_ICON_MAX_BYTES,
|
||||
CUSTOM_PROJECT_ICON_MAX_DIMENSION,
|
||||
};
|
||||
pub use project_plugin::{
|
||||
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
|
||||
MutateAgentPluginSkill, MutateAgentPluginTool, ProjectPluginResolver,
|
||||
|
||||
502
crates/application/src/project_icon_assets.rs
Normal file
502
crates/application/src/project_icon_assets.rs
Normal file
@ -0,0 +1,502 @@
|
||||
//! Use cases for the instance-local custom project icon library.
|
||||
|
||||
use crate::AppError;
|
||||
use domain::{
|
||||
Clock, CustomProjectIconAsset, CustomProjectIconAssetStore, CustomProjectIconContent,
|
||||
CustomProjectIconStoreError, IdGenerator, ProjectIconSet, ProjectSettingsStore, ProjectStore,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Maximum accepted encoded image size (2 MiB).
|
||||
pub const CUSTOM_PROJECT_ICON_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||
/// Maximum accepted width or height.
|
||||
pub const CUSTOM_PROJECT_ICON_MAX_DIMENSION: u32 = 4096;
|
||||
|
||||
/// Asset metadata enriched with its current project usage count.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomProjectIconAssetView {
|
||||
/// Persisted metadata.
|
||||
pub asset: CustomProjectIconAsset,
|
||||
/// Number of registered projects currently referencing the asset.
|
||||
pub used_by_project_count: u64,
|
||||
}
|
||||
|
||||
/// Asset bytes enriched with usage metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomProjectIconContentView {
|
||||
/// Metadata and usage.
|
||||
pub metadata: CustomProjectIconAssetView,
|
||||
/// Exact validated raster bytes.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Usage result returned before deletion or for user feedback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomProjectIconUsage {
|
||||
/// Stable asset id.
|
||||
pub asset_id: String,
|
||||
/// Number of registered projects referencing it.
|
||||
pub used_by_project_count: u64,
|
||||
}
|
||||
|
||||
/// Imports one validated raster into the shared library.
|
||||
pub struct ImportCustomProjectIconAsset {
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl ImportCustomProjectIconAsset {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { store, ids, clock }
|
||||
}
|
||||
|
||||
/// Validates real content, declared MIME and filename extension before persisting.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
original_filename: String,
|
||||
declared_media_type: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<CustomProjectIconAssetView, AppError> {
|
||||
if bytes.is_empty() || bytes.len() > CUSTOM_PROJECT_ICON_MAX_BYTES {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"custom project icon must contain 1..={CUSTOM_PROJECT_ICON_MAX_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
let filename = safe_basename(&original_filename)?;
|
||||
let detected = detect_raster(&bytes)?;
|
||||
if declared_media_type.trim().to_ascii_lowercase() != detected.media_type {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"declared media type does not match {} content",
|
||||
detected.media_type
|
||||
)));
|
||||
}
|
||||
let filename_extension = filename
|
||||
.rsplit_once('.')
|
||||
.map(|(_, extension)| extension.to_ascii_lowercase())
|
||||
.ok_or_else(|| {
|
||||
AppError::Invalid("custom project icon filename needs an extension".to_owned())
|
||||
})?;
|
||||
if !detected.extensions.contains(&filename_extension.as_str()) {
|
||||
return Err(AppError::Invalid(
|
||||
"custom project icon filename extension does not match its content".to_owned(),
|
||||
));
|
||||
}
|
||||
if detected.width == 0
|
||||
|| detected.height == 0
|
||||
|| detected.width > CUSTOM_PROJECT_ICON_MAX_DIMENSION
|
||||
|| detected.height > CUSTOM_PROJECT_ICON_MAX_DIMENSION
|
||||
{
|
||||
return Err(AppError::Invalid(format!(
|
||||
"custom project icon dimensions must be 1..={CUSTOM_PROJECT_ICON_MAX_DIMENSION} pixels"
|
||||
)));
|
||||
}
|
||||
|
||||
let asset = CustomProjectIconAsset {
|
||||
id: self.ids.new_uuid().simple().to_string(),
|
||||
media_type: detected.media_type.to_owned(),
|
||||
byte_size: bytes.len() as u64,
|
||||
width: detected.width,
|
||||
height: detected.height,
|
||||
sha256: hex::encode(Sha256::digest(&bytes)),
|
||||
original_filename: filename,
|
||||
created_at: self.clock.now_millis(),
|
||||
};
|
||||
self.store
|
||||
.create(&asset, detected.extension, &bytes)
|
||||
.await?;
|
||||
Ok(CustomProjectIconAssetView {
|
||||
asset,
|
||||
used_by_project_count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists all custom icons with live usage counts.
|
||||
pub struct ListCustomProjectIconAssets {
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
}
|
||||
|
||||
impl ListCustomProjectIconAssets {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
projects,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
/// Lists metadata in stable order.
|
||||
pub async fn execute(&self) -> Result<Vec<CustomProjectIconAssetView>, AppError> {
|
||||
let assets = self.store.list().await?;
|
||||
let mut output = Vec::with_capacity(assets.len());
|
||||
for asset in assets {
|
||||
let count = usage_count(&asset.id, &self.projects, &self.settings).await?;
|
||||
output.push(CustomProjectIconAssetView {
|
||||
asset,
|
||||
used_by_project_count: count,
|
||||
});
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one custom icon and its current usage.
|
||||
pub struct ReadCustomProjectIconAsset {
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
}
|
||||
|
||||
impl ReadCustomProjectIconAsset {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
projects,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
/// Reads exact bytes after validating the opaque id shape.
|
||||
pub async fn execute(&self, asset_id: &str) -> Result<CustomProjectIconContentView, AppError> {
|
||||
validate_asset_id(asset_id)?;
|
||||
let CustomProjectIconContent { asset, bytes } = self.store.read(asset_id).await?;
|
||||
let count = usage_count(asset_id, &self.projects, &self.settings).await?;
|
||||
Ok(CustomProjectIconContentView {
|
||||
metadata: CustomProjectIconAssetView {
|
||||
asset,
|
||||
used_by_project_count: count,
|
||||
},
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets live project usage for one existing asset.
|
||||
pub struct GetCustomProjectIconAssetUsage {
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
}
|
||||
|
||||
impl GetCustomProjectIconAssetUsage {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
projects,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
/// Returns usage, rejecting unknown ids.
|
||||
pub async fn execute(&self, asset_id: &str) -> Result<CustomProjectIconUsage, AppError> {
|
||||
validate_asset_id(asset_id)?;
|
||||
if !self.store.exists(asset_id).await? {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"custom project icon asset {asset_id}"
|
||||
)));
|
||||
}
|
||||
Ok(CustomProjectIconUsage {
|
||||
asset_id: asset_id.to_owned(),
|
||||
used_by_project_count: usage_count(asset_id, &self.projects, &self.settings).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes one asset only when no registered project references it.
|
||||
pub struct DeleteCustomProjectIconAsset {
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
}
|
||||
|
||||
impl DeleteCustomProjectIconAsset {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn CustomProjectIconAssetStore>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
settings: Arc<dyn ProjectSettingsStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
projects,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
/// Deletes an unused asset and returns its former usage.
|
||||
pub async fn execute(&self, asset_id: &str) -> Result<CustomProjectIconUsage, AppError> {
|
||||
validate_asset_id(asset_id)?;
|
||||
if !self.store.exists(asset_id).await? {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"custom project icon asset {asset_id}"
|
||||
)));
|
||||
}
|
||||
let used_by_project_count = usage_count(asset_id, &self.projects, &self.settings).await?;
|
||||
if used_by_project_count > 0 {
|
||||
return Err(AppError::CustomIconInUse {
|
||||
asset_id: asset_id.to_owned(),
|
||||
used_by_project_count,
|
||||
});
|
||||
}
|
||||
self.store.delete(asset_id).await?;
|
||||
Ok(CustomProjectIconUsage {
|
||||
asset_id: asset_id.to_owned(),
|
||||
used_by_project_count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_asset_id(asset_id: &str) -> Result<(), AppError> {
|
||||
let valid = !asset_id.is_empty()
|
||||
&& asset_id.len() <= 64
|
||||
&& asset_id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
|
||||
valid
|
||||
.then_some(())
|
||||
.ok_or_else(|| AppError::Invalid("invalid custom project icon asset id".to_owned()))
|
||||
}
|
||||
|
||||
async fn usage_count(
|
||||
asset_id: &str,
|
||||
projects: &Arc<dyn ProjectStore>,
|
||||
settings: &Arc<dyn ProjectSettingsStore>,
|
||||
) -> Result<u64, AppError> {
|
||||
let mut count = 0_u64;
|
||||
for project in projects.list_projects().await? {
|
||||
let project_settings = settings.load(&project.root).await?;
|
||||
if project_settings
|
||||
.icon
|
||||
.as_ref()
|
||||
.is_some_and(|icon| icon.set() == ProjectIconSet::Custom && icon.name() == asset_id)
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn safe_basename(raw: &str) -> Result<String, AppError> {
|
||||
let name = raw.rsplit(['/', '\\']).next().unwrap_or("").trim();
|
||||
if name.is_empty() || name.len() > 255 || name.chars().any(char::is_control) {
|
||||
return Err(AppError::Invalid(
|
||||
"invalid custom project icon filename".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
struct DetectedRaster {
|
||||
media_type: &'static str,
|
||||
extension: &'static str,
|
||||
extensions: &'static [&'static str],
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
fn detect_raster(bytes: &[u8]) -> Result<DetectedRaster, AppError> {
|
||||
if bytes.len() >= 45
|
||||
&& bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|
||||
&& bytes[8..12] == 13_u32.to_be_bytes()
|
||||
&& &bytes[12..16] == b"IHDR"
|
||||
&& valid_png_chunks(bytes)
|
||||
{
|
||||
return Ok(DetectedRaster {
|
||||
media_type: "image/png",
|
||||
extension: "png",
|
||||
extensions: &["png"],
|
||||
width: u32::from_be_bytes(bytes[16..20].try_into().unwrap()),
|
||||
height: u32::from_be_bytes(bytes[20..24].try_into().unwrap()),
|
||||
});
|
||||
}
|
||||
if bytes.len() >= 30
|
||||
&& bytes.starts_with(b"RIFF")
|
||||
&& &bytes[8..12] == b"WEBP"
|
||||
&& usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).ok()
|
||||
== Some(bytes.len() - 8)
|
||||
{
|
||||
if &bytes[12..16] == b"VP8X" {
|
||||
let width = 1 + u32::from_le_bytes([bytes[24], bytes[25], bytes[26], 0]);
|
||||
let height = 1 + u32::from_le_bytes([bytes[27], bytes[28], bytes[29], 0]);
|
||||
return Ok(DetectedRaster {
|
||||
media_type: "image/webp",
|
||||
extension: "webp",
|
||||
extensions: &["webp"],
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
if &bytes[12..16] == b"VP8L" && bytes.len() >= 25 && bytes[20] == 0x2f {
|
||||
let bits = u32::from_le_bytes(bytes[21..25].try_into().unwrap());
|
||||
return Ok(DetectedRaster {
|
||||
media_type: "image/webp",
|
||||
extension: "webp",
|
||||
extensions: &["webp"],
|
||||
width: (bits & 0x3fff) + 1,
|
||||
height: ((bits >> 14) & 0x3fff) + 1,
|
||||
});
|
||||
}
|
||||
if &bytes[12..16] == b"VP8 " && bytes.len() >= 30 && bytes[23..26] == [0x9d, 0x01, 0x2a] {
|
||||
let width = u16::from_le_bytes(bytes[26..28].try_into().unwrap()) & 0x3fff;
|
||||
let height = u16::from_le_bytes(bytes[28..30].try_into().unwrap()) & 0x3fff;
|
||||
return Ok(DetectedRaster {
|
||||
media_type: "image/webp",
|
||||
extension: "webp",
|
||||
extensions: &["webp"],
|
||||
width: u32::from(width),
|
||||
height: u32::from(height),
|
||||
});
|
||||
}
|
||||
return Err(AppError::Invalid(
|
||||
"unsupported or malformed WebP image".to_owned(),
|
||||
));
|
||||
}
|
||||
if bytes.starts_with(&[0xff, 0xd8]) && bytes.ends_with(&[0xff, 0xd9]) {
|
||||
let (width, height) = jpeg_dimensions(bytes)?;
|
||||
return Ok(DetectedRaster {
|
||||
media_type: "image/jpeg",
|
||||
extension: "jpg",
|
||||
extensions: &["jpg", "jpeg"],
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
Err(AppError::Invalid(
|
||||
"custom project icon must be PNG, JPEG or WebP; SVG and GIF are not supported".to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
fn valid_png_chunks(bytes: &[u8]) -> bool {
|
||||
let mut cursor = 8_usize;
|
||||
while cursor + 12 <= bytes.len() {
|
||||
let length = u32::from_be_bytes(bytes[cursor..cursor + 4].try_into().unwrap()) as usize;
|
||||
let Some(end) = cursor
|
||||
.checked_add(12)
|
||||
.and_then(|value| value.checked_add(length))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if end > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
if &bytes[cursor + 4..cursor + 8] == b"IEND" {
|
||||
return length == 0 && end == bytes.len();
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn jpeg_dimensions(bytes: &[u8]) -> Result<(u32, u32), AppError> {
|
||||
let mut cursor = 2;
|
||||
while cursor + 4 <= bytes.len() {
|
||||
while cursor < bytes.len() && bytes[cursor] != 0xff {
|
||||
cursor += 1;
|
||||
}
|
||||
while cursor < bytes.len() && bytes[cursor] == 0xff {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
let marker = bytes[cursor];
|
||||
cursor += 1;
|
||||
if marker == 0xd9 || marker == 0xda {
|
||||
break;
|
||||
}
|
||||
if marker == 0x01 || (0xd0..=0xd7).contains(&marker) {
|
||||
continue;
|
||||
}
|
||||
if cursor + 2 > bytes.len() {
|
||||
break;
|
||||
}
|
||||
let length = usize::from(u16::from_be_bytes(
|
||||
bytes[cursor..cursor + 2].try_into().unwrap(),
|
||||
));
|
||||
if length < 2 || cursor + length > bytes.len() {
|
||||
break;
|
||||
}
|
||||
if matches!(marker, 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf) && length >= 7 {
|
||||
let height = u32::from(u16::from_be_bytes(
|
||||
bytes[cursor + 3..cursor + 5].try_into().unwrap(),
|
||||
));
|
||||
let width = u32::from(u16::from_be_bytes(
|
||||
bytes[cursor + 5..cursor + 7].try_into().unwrap(),
|
||||
));
|
||||
return Ok((width, height));
|
||||
}
|
||||
cursor += length;
|
||||
}
|
||||
Err(AppError::Invalid("malformed JPEG image".to_owned()))
|
||||
}
|
||||
|
||||
impl From<CustomProjectIconStoreError> for AppError {
|
||||
fn from(error: CustomProjectIconStoreError) -> Self {
|
||||
match error {
|
||||
CustomProjectIconStoreError::NotFound => {
|
||||
Self::NotFound("custom project icon asset".to_owned())
|
||||
}
|
||||
CustomProjectIconStoreError::Format(message) => Self::Store(message),
|
||||
CustomProjectIconStoreError::Io(message) => Self::FileSystem(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_png_dimensions_and_rejects_svg() {
|
||||
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
png.extend_from_slice(&32_u32.to_be_bytes());
|
||||
png.extend_from_slice(&48_u32.to_be_bytes());
|
||||
png.extend_from_slice(&[8, 6, 0, 0, 0]);
|
||||
png.extend_from_slice(&[0, 0, 0, 0]);
|
||||
png.extend_from_slice(&[0, 0, 0, 0]);
|
||||
png.extend_from_slice(b"IEND");
|
||||
png.extend_from_slice(&[0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
(
|
||||
detect_raster(&png).unwrap().width,
|
||||
detect_raster(&png).unwrap().height
|
||||
),
|
||||
(32, 48)
|
||||
);
|
||||
assert!(detect_raster(b"<svg></svg>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confines_filename_to_basename() {
|
||||
assert_eq!(safe_basename("/tmp/user/icon.png").unwrap(), "icon.png");
|
||||
assert_eq!(
|
||||
safe_basename(r"C:\\Users\\me\\icon.webp").unwrap(),
|
||||
"icon.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
use crate::AppError;
|
||||
use domain::{
|
||||
IdeaUserSettings, Project, ProjectIconRef, ProjectSettings, ProjectSettingsStore, ThemeColor,
|
||||
UserSettingsStore,
|
||||
CustomProjectIconAssetStore, IdeaUserSettings, Project, ProjectIconRef, ProjectIconSet,
|
||||
ProjectSettings, ProjectSettingsStore, ThemeColor, UserSettingsStore,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -56,12 +56,16 @@ impl ReadProjectSettings {
|
||||
/// Updates the settings owned by one project.
|
||||
pub struct UpdateProjectSettings {
|
||||
store: Arc<dyn ProjectSettingsStore>,
|
||||
icon_assets: Arc<dyn CustomProjectIconAssetStore>,
|
||||
}
|
||||
impl UpdateProjectSettings {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectSettingsStore>) -> Self {
|
||||
Self { store }
|
||||
pub fn new(
|
||||
store: Arc<dyn ProjectSettingsStore>,
|
||||
icon_assets: Arc<dyn CustomProjectIconAssetStore>,
|
||||
) -> Self {
|
||||
Self { store, icon_assets }
|
||||
}
|
||||
/// Applies the supplied fields and returns the accepted project settings.
|
||||
///
|
||||
@ -73,6 +77,17 @@ impl UpdateProjectSettings {
|
||||
theme_color: Option<Option<ThemeColor>>,
|
||||
icon: Option<Option<ProjectIconRef>>,
|
||||
) -> Result<ProjectSettings, AppError> {
|
||||
if let Some(Some(icon)) = &icon {
|
||||
if icon.set() == ProjectIconSet::Custom {
|
||||
crate::project_icon_assets::validate_asset_id(icon.name())?;
|
||||
if !self.icon_assets.exists(icon.name()).await? {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"custom project icon asset does not exist: {}",
|
||||
icon.name()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut settings = self.store.load(&project.root).await?;
|
||||
let mut changed = false;
|
||||
if let Some(theme_color) = theme_color {
|
||||
@ -94,10 +109,41 @@ impl UpdateProjectSettings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{ProjectIconSet, ProjectPath, RemoteRef, StoreError};
|
||||
use domain::{
|
||||
CustomProjectIconAsset, CustomProjectIconContent, CustomProjectIconStoreError,
|
||||
ProjectIconSet, ProjectPath, RemoteRef, StoreError,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MemoryProjectSettings(Mutex<ProjectSettings>);
|
||||
struct MemoryIconAssets;
|
||||
|
||||
#[async_trait]
|
||||
impl CustomProjectIconAssetStore for MemoryIconAssets {
|
||||
async fn list(&self) -> Result<Vec<CustomProjectIconAsset>, CustomProjectIconStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn read(
|
||||
&self,
|
||||
_asset_id: &str,
|
||||
) -> Result<CustomProjectIconContent, CustomProjectIconStoreError> {
|
||||
Err(CustomProjectIconStoreError::NotFound)
|
||||
}
|
||||
async fn exists(&self, asset_id: &str) -> Result<bool, CustomProjectIconStoreError> {
|
||||
Ok(asset_id == "known")
|
||||
}
|
||||
async fn create(
|
||||
&self,
|
||||
_asset: &CustomProjectIconAsset,
|
||||
_extension: &str,
|
||||
_bytes: &[u8],
|
||||
) -> Result<(), CustomProjectIconStoreError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, _asset_id: &str) -> Result<(), CustomProjectIconStoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProjectSettingsStore for MemoryProjectSettings {
|
||||
@ -132,7 +178,7 @@ mod tests {
|
||||
theme_color: Some(ThemeColor::new("#123456").unwrap()),
|
||||
icon: Some(ProjectIconRef::new(ProjectIconSet::Devicon, "rust").unwrap()),
|
||||
})));
|
||||
let update = UpdateProjectSettings::new(store);
|
||||
let update = UpdateProjectSettings::new(store, Arc::new(MemoryIconAssets));
|
||||
let project = project();
|
||||
|
||||
let icon = ProjectIconRef::new(ProjectIconSet::Lucide, "star").unwrap();
|
||||
@ -170,4 +216,18 @@ mod tests {
|
||||
Some("#ABCDEF")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_settings_rejects_missing_custom_icon() {
|
||||
let store = Arc::new(MemoryProjectSettings(
|
||||
Mutex::new(ProjectSettings::default()),
|
||||
));
|
||||
let update = UpdateProjectSettings::new(store, Arc::new(MemoryIconAssets));
|
||||
let icon = ProjectIconRef::new(ProjectIconSet::Custom, "missing").unwrap();
|
||||
let error = update
|
||||
.execute(&project(), None, Some(Some(icon)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code(), "INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
@ -3358,6 +3358,88 @@ pub struct ProjectIconRefDto {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Metadata for one user-imported icon, including live project usage.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProjectIconAssetDto {
|
||||
/// Opaque stable asset id.
|
||||
pub id: String,
|
||||
/// Validated raster media type.
|
||||
pub media_type: String,
|
||||
/// Stored encoded byte count.
|
||||
pub byte_size: u64,
|
||||
/// Pixel width.
|
||||
pub width: u32,
|
||||
/// Pixel height.
|
||||
pub height: u32,
|
||||
/// SHA-256 digest.
|
||||
pub sha256: String,
|
||||
/// Original user-facing basename.
|
||||
pub original_filename: String,
|
||||
/// Creation time as epoch milliseconds.
|
||||
pub created_at: i64,
|
||||
/// Current number of registered projects using the asset.
|
||||
pub used_by_project_count: u64,
|
||||
}
|
||||
|
||||
impl From<application::CustomProjectIconAssetView> for CustomProjectIconAssetDto {
|
||||
fn from(value: application::CustomProjectIconAssetView) -> Self {
|
||||
Self {
|
||||
id: value.asset.id,
|
||||
media_type: value.asset.media_type,
|
||||
byte_size: value.asset.byte_size,
|
||||
width: value.asset.width,
|
||||
height: value.asset.height,
|
||||
sha256: value.asset.sha256,
|
||||
original_filename: value.asset.original_filename,
|
||||
created_at: value.asset.created_at,
|
||||
used_by_project_count: value.used_by_project_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Import payload shared by Tauri and HTTP JSON clients.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ImportCustomProjectIconAssetRequestDto {
|
||||
/// User-facing source basename (paths are stripped server-side).
|
||||
pub original_filename: String,
|
||||
/// Claimed media type, verified against file bytes.
|
||||
pub media_type: String,
|
||||
/// Standard base64-encoded image bytes.
|
||||
pub data_base64: String,
|
||||
}
|
||||
|
||||
/// Tauri-safe read response; HTTP serves these bytes directly instead.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProjectIconAssetContentDto {
|
||||
/// Metadata and usage.
|
||||
#[serde(flatten)]
|
||||
pub metadata: CustomProjectIconAssetDto,
|
||||
/// Standard base64-encoded image bytes.
|
||||
pub data_base64: String,
|
||||
}
|
||||
|
||||
/// Current usage for one custom icon.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProjectIconUsageDto {
|
||||
/// Stable asset id.
|
||||
pub asset_id: String,
|
||||
/// Number of registered projects using it.
|
||||
pub used_by_project_count: u64,
|
||||
}
|
||||
|
||||
impl From<application::CustomProjectIconUsage> for CustomProjectIconUsageDto {
|
||||
fn from(value: application::CustomProjectIconUsage) -> Self {
|
||||
Self {
|
||||
asset_id: value.asset_id,
|
||||
used_by_project_count: value.used_by_project_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<domain::ProjectIconRef> for ProjectIconRefDto {
|
||||
fn from(value: domain::ProjectIconRef) -> Self {
|
||||
Self {
|
||||
|
||||
@ -21,30 +21,33 @@ use application::{
|
||||
ClearChatDraft, CloneOpenCodeProfileFromSeed, CloneProfileFromSeed, CloseProject, CloseTab,
|
||||
CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
||||
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelArtifact, DeleteModelServer, DeleteProfile,
|
||||
DeleteSkill, DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
|
||||
DetectProfiles, DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand,
|
||||
FirstRunState, GcManagedTemp, GetAppExitWorkGuardState, GetLiveStateLean, GetMemory,
|
||||
GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState, GitBranches,
|
||||
GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus, GitUnstage,
|
||||
HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments, InspectConversation,
|
||||
InstallPluginFromArchive, InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent,
|
||||
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||
ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers,
|
||||
ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles,
|
||||
ListProjectFiles, ListProjectPluginSettings, ListProjects, ListResumableAgents, ListSkills,
|
||||
ListSlashCommands, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
||||
MarkIssueAttachmentSummarized, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
|
||||
MutateAgentPluginSkill, MutateAgentPluginTool, MutateLayout, OnnxModelView,
|
||||
OpenPluginLayoutWindow, OpenProject, OpenTerminal, OpenTicketAssistant, OrchestratorService,
|
||||
PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, PluginCommandTasks,
|
||||
PluginConfigDocuments, PluginEventSubscriptions, PluginStorageAccess,
|
||||
PluginToolchainDiagnostics, PluginWorkspaceAccess, PluginWorkspaceExecTool,
|
||||
ProjectPluginResolver, ProposeContext, PurgeManagedTemp, QueryProjectStructure,
|
||||
ReadAgentContext, ReadChatDraft, ReadContext, ReadConversationPage, ReadIssue,
|
||||
ReadIssueAttachment, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent,
|
||||
DeleteCustomProjectIconAsset, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
||||
DeleteModelArtifact, DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint,
|
||||
DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
||||
DismissEmbedderSuggestion, EnsureLocalModelServer, ExecuteSlashCommand, FirstRunState,
|
||||
GcManagedTemp, GetAppExitWorkGuardState, GetCustomProjectIconAssetUsage, GetLiveStateLean,
|
||||
GetMemory, GetProjectPermissions, GetProjectSystemPermissions, GetProjectWorkState,
|
||||
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
|
||||
GitUnstage, HarvestMemoryFromTurn, HealthUseCase, ImportChatAttachments,
|
||||
ImportCustomProjectIconAsset, InspectConversation, InstallPluginFromArchive,
|
||||
InstallPluginFromDirectory, JsonPluginManifestValidator, LaunchAgent, LaunchAgentInput,
|
||||
LinkIssues, ListAgents, ListAgentsInput, ListClaudeModels, ListCodexModels,
|
||||
ListCustomProjectIconAssets, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts,
|
||||
ListMemories, ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions,
|
||||
ListPlugins, ListProfiles, ListProjectFiles, ListProjectPluginSettings, ListProjects,
|
||||
ListResumableAgents, ListSkills, ListSlashCommands, ListSprints, ListTemplates,
|
||||
LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
|
||||
LiveStateReadProvider, LoadLayout, MarkIssueAttachmentSummarized, McpRuntime,
|
||||
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateAgentPluginSkill, MutateAgentPluginTool,
|
||||
MutateLayout, OnnxModelView, OpenPluginLayoutWindow, OpenProject, OpenTerminal,
|
||||
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||
PermissionProjectorRegistry, PluginCommandTasks, PluginConfigDocuments,
|
||||
PluginEventSubscriptions, PluginStorageAccess, PluginToolchainDiagnostics,
|
||||
PluginWorkspaceAccess, PluginWorkspaceExecTool, ProjectPluginResolver, ProposeContext,
|
||||
PurgeManagedTemp, QueryProjectStructure, ReadAgentContext, ReadChatDraft, ReadContext,
|
||||
ReadConversationPage, ReadCustomProjectIconAsset, ReadIssue, ReadIssueAttachment,
|
||||
ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex,
|
||||
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectSettings, ReadSkill, ReadTemplate,
|
||||
ReadUserSettings, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
|
||||
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
||||
@ -74,16 +77,16 @@ use domain::events::AgentRestartPhase;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, DeviceSessionStore, Embedder,
|
||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore, EnvironmentReader, EventBus,
|
||||
FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, ManagedTempStore,
|
||||
McpToolPermissionStore, MemoryRecall, MemoryStore, ModelArtifactDownloader,
|
||||
OpenProjectRestoreStore, PermissionStore, PluginManifestValidator, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryStore, PluginStorageStore, ProcessSpawner, ProfileStore,
|
||||
ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore, PtyHandle, PtyPort,
|
||||
RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore, SprintStore,
|
||||
StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore, ToolInvoker,
|
||||
UserSettingsStore, WakeError, WakeReason, WindowStateStore,
|
||||
BackgroundTaskStore, ChatAttachmentStore, ChatDraftStore, Clock, CustomProjectIconAssetStore,
|
||||
DeviceSessionStore, Embedder, EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptStore,
|
||||
EnvironmentReader, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
||||
IssueStore, ManagedTempStore, McpToolPermissionStore, MemoryRecall, MemoryStore,
|
||||
ModelArtifactDownloader, OpenProjectRestoreStore, PermissionStore, PluginManifestValidator,
|
||||
PluginMcpSupervisor, PluginPackageStore, PluginRegistryStore, PluginStorageStore,
|
||||
ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
|
||||
PtyHandle, PtyPort, RuntimePermissionProbe, ScheduledTask, Scheduler, SecretStore, SkillStore,
|
||||
SprintStore, StructuredSessionEnvironmentPreparer, SystemPermissionStore, TemplateStore,
|
||||
ToolInvoker, UserSettingsStore, WakeError, WakeReason, WindowStateStore,
|
||||
};
|
||||
use domain::profile::{
|
||||
AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter,
|
||||
@ -104,22 +107,22 @@ use infrastructure::{
|
||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||
DiagnosticPluginWorkspaceExecAudit, EmbeddedCompatibilityMatrix, EmbedderEnvProbe,
|
||||
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore,
|
||||
FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsDeviceSessionStore,
|
||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||
FsIssueStore, FsLiveStateStore, FsManagedTempStore, FsMcpToolPermissionStore, FsMemoryStore,
|
||||
FsModelServerRegistry, FsOpenProjectRestoreStore, FsOrchestratorWatcher, FsPermissionStore,
|
||||
FsPluginPackageStore, FsPluginRegistryStore, FsPluginStorageStore, FsProfileStore,
|
||||
FsProjectPluginSettingsStore, FsProjectSettingsStore, FsProjectStore, FsProviderSessionStore,
|
||||
FsScheduledProjectTaskStore, FsSecretStore, FsSkillStore, FsSprintStore,
|
||||
FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore, FsWindowStateStore,
|
||||
FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
||||
HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue, IdeaiContextStore,
|
||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||
LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner,
|
||||
LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer, MediatedInbox, NaiveMemoryRecall,
|
||||
OrchestratorWatchHandle, PortablePtyAdapter, ProcessCliVersionReader,
|
||||
ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory, SystemClock,
|
||||
SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
FsChatAttachmentStore, FsChatDraftStore, FsConversationLog, FsCustomProjectIconAssetStore,
|
||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsManagedTempStore,
|
||||
FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOpenProjectRestoreStore,
|
||||
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||
FsPluginStorageStore, FsProfileStore, FsProjectPluginSettingsStore, FsProjectSettingsStore,
|
||||
FsProjectStore, FsProviderSessionStore, FsScheduledProjectTaskStore, FsSecretStore,
|
||||
FsSkillStore, FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsUserSettingsStore,
|
||||
FsWindowStateStore, FsWorkspaceToolProvider, Git2Repository, HeuristicHandoffSummarizer,
|
||||
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, HttpProviderModelCatalogue,
|
||||
IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter,
|
||||
LlamaCppRuntime, LocalEnvironmentReader, LocalFileSystem, LocalManagedProcess,
|
||||
LocalProcessSpawner, LocalWorkspaceToolRunner, ManagedTempToolProvider, McpServer,
|
||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter,
|
||||
ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard, StructuredSessionFactory,
|
||||
SystemClock, SystemMillisClock, TemplateToolProvider, TicketAssistantEnvironmentPreparer,
|
||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||
VectorMemoryRecall, WorkspaceToolProvider, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
@ -2267,6 +2270,16 @@ pub struct BackendCore {
|
||||
pub read_project_settings: Arc<ReadProjectSettings>,
|
||||
/// Updates settings owned by the active project.
|
||||
pub update_project_settings: Arc<UpdateProjectSettings>,
|
||||
/// Imports into the shared custom icon library.
|
||||
pub import_custom_project_icon_asset: Arc<ImportCustomProjectIconAsset>,
|
||||
/// Lists shared custom icons with live usage.
|
||||
pub list_custom_project_icon_assets: Arc<ListCustomProjectIconAssets>,
|
||||
/// Reads one custom icon and exact bytes.
|
||||
pub read_custom_project_icon_asset: Arc<ReadCustomProjectIconAsset>,
|
||||
/// Gets current project usage for one custom icon.
|
||||
pub get_custom_project_icon_asset_usage: Arc<GetCustomProjectIconAssetUsage>,
|
||||
/// Deletes an unused custom icon.
|
||||
pub delete_custom_project_icon_asset: Arc<DeleteCustomProjectIconAsset>,
|
||||
/// Read the project permission document.
|
||||
pub get_project_permissions: Arc<GetProjectPermissions>,
|
||||
/// Update project-level default permissions.
|
||||
@ -2635,6 +2648,10 @@ impl BackendCore {
|
||||
let project_settings_store = Arc::new(FsProjectSettingsStore::new(Arc::clone(&fs_port)));
|
||||
let project_settings_store_port =
|
||||
Arc::clone(&project_settings_store) as Arc<dyn ProjectSettingsStore>;
|
||||
let custom_project_icon_asset_store =
|
||||
Arc::new(FsCustomProjectIconAssetStore::new(&app_data_dir));
|
||||
let custom_project_icon_asset_store_port =
|
||||
Arc::clone(&custom_project_icon_asset_store) as Arc<dyn CustomProjectIconAssetStore>;
|
||||
let read_user_settings =
|
||||
Arc::new(ReadUserSettings::new(Arc::clone(&user_settings_store_port)));
|
||||
let update_user_settings = Arc::new(UpdateUserSettings::new(Arc::clone(
|
||||
@ -2643,9 +2660,35 @@ impl BackendCore {
|
||||
let read_project_settings = Arc::new(ReadProjectSettings::new(Arc::clone(
|
||||
&project_settings_store_port,
|
||||
)));
|
||||
let update_project_settings = Arc::new(UpdateProjectSettings::new(Arc::clone(
|
||||
&project_settings_store_port,
|
||||
)));
|
||||
let update_project_settings = Arc::new(UpdateProjectSettings::new(
|
||||
Arc::clone(&project_settings_store_port),
|
||||
Arc::clone(&custom_project_icon_asset_store_port),
|
||||
));
|
||||
let import_custom_project_icon_asset = Arc::new(ImportCustomProjectIconAsset::new(
|
||||
Arc::clone(&custom_project_icon_asset_store_port),
|
||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||
));
|
||||
let list_custom_project_icon_assets = Arc::new(ListCustomProjectIconAssets::new(
|
||||
Arc::clone(&custom_project_icon_asset_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&project_settings_store_port),
|
||||
));
|
||||
let read_custom_project_icon_asset = Arc::new(ReadCustomProjectIconAsset::new(
|
||||
Arc::clone(&custom_project_icon_asset_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&project_settings_store_port),
|
||||
));
|
||||
let get_custom_project_icon_asset_usage = Arc::new(GetCustomProjectIconAssetUsage::new(
|
||||
Arc::clone(&custom_project_icon_asset_store_port),
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&project_settings_store_port),
|
||||
));
|
||||
let delete_custom_project_icon_asset = Arc::new(DeleteCustomProjectIconAsset::new(
|
||||
custom_project_icon_asset_store_port,
|
||||
Arc::clone(&store_port),
|
||||
Arc::clone(&project_settings_store_port),
|
||||
));
|
||||
let open_project_restore_store_port =
|
||||
Arc::clone(&open_project_restore_store) as Arc<dyn OpenProjectRestoreStore>;
|
||||
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
||||
@ -4514,6 +4557,11 @@ impl BackendCore {
|
||||
update_user_settings,
|
||||
read_project_settings,
|
||||
update_project_settings,
|
||||
import_custom_project_icon_asset,
|
||||
list_custom_project_icon_assets,
|
||||
read_custom_project_icon_asset,
|
||||
get_custom_project_icon_asset_usage,
|
||||
delete_custom_project_icon_asset,
|
||||
get_project_permissions,
|
||||
update_project_permissions,
|
||||
update_agent_permissions,
|
||||
|
||||
@ -270,8 +270,9 @@ pub use scheduled_task::{
|
||||
};
|
||||
|
||||
pub use settings::{
|
||||
DefaultAgentTerminalInterface, IdeaLanguage, IdeaUserSettings, ProjectIconRef, ProjectIconSet,
|
||||
ProjectSettings, ThemeColor, MAX_PROJECT_ICON_NAME_LEN,
|
||||
CustomProjectIconAsset, CustomProjectIconContent, DefaultAgentTerminalInterface, IdeaLanguage,
|
||||
IdeaUserSettings, ProjectIconRef, ProjectIconSet, ProjectSettings, ThemeColor,
|
||||
MAX_PROJECT_ICON_NAME_LEN,
|
||||
};
|
||||
|
||||
pub use orchestrator::{
|
||||
@ -283,19 +284,20 @@ pub use ports::{
|
||||
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
||||
BackgroundTaskStore, CliVersionReader, Clock, CompatibilityMatrixSource, ContextInjectionPlan,
|
||||
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
|
||||
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||
IdGenerator, IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError,
|
||||
LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall,
|
||||
MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStorageError,
|
||||
PluginStorageStore, PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext,
|
||||
ProcessError, ProcessSpawner, ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore,
|
||||
ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost,
|
||||
RemotePath, RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
|
||||
CustomProjectIconAssetStore, CustomProjectIconStoreError, DirEntry, Embedder,
|
||||
EmbedderEnvInspector, EmbedderEnvReport, EmbedderError, EmbedderProfileStore,
|
||||
EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem,
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
IssueAttachmentContent, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore,
|
||||
LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
|
||||
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError,
|
||||
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||
PluginRegistryError, PluginRegistryStore, PluginStorageError, PluginStorageStore,
|
||||
PluginStoreError, PluginWorkspaceExecAuditSink, PreparedContext, ProcessError, ProcessSpawner,
|
||||
ProfileStore, ProjectPluginSettingsStore, ProjectSettingsStore, ProjectStore,
|
||||
ProviderModelCatalogue, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath,
|
||||
RuntimeError, RuntimePermissionProbe, ScheduledProjectTaskStore, ScheduledTask,
|
||||
ScheduledTaskStoreError, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
||||
TemplateStore, UserSettingsStore, WindowStateStore, WorkspaceToolExecError,
|
||||
|
||||
@ -66,6 +66,7 @@ use crate::plugin::{
|
||||
use crate::profile::{AgentProfile, EmbedderProfile, StructuredAdapter};
|
||||
use crate::project::{Project, ProjectPath};
|
||||
use crate::remote::RemoteKind;
|
||||
use crate::settings::{CustomProjectIconAsset, CustomProjectIconContent};
|
||||
use crate::skill::{Skill, SkillScope};
|
||||
use crate::sprint::{Sprint, SprintIndexEntry, SprintVersion};
|
||||
use crate::system_permissions::{ProjectSystemPermissions, RuntimePermissionSnapshot};
|
||||
@ -564,6 +565,43 @@ pub trait ProjectSettingsStore: Send + Sync {
|
||||
) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// Failures from the instance-local custom project icon library.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum CustomProjectIconStoreError {
|
||||
/// Asset id is absent from the canonical index.
|
||||
#[error("custom project icon asset not found")]
|
||||
NotFound,
|
||||
/// Persisted data is malformed.
|
||||
#[error("custom project icon asset store format error: {0}")]
|
||||
Format(String),
|
||||
/// Filesystem operation failed.
|
||||
#[error("custom project icon asset store I/O error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// Persistence for the workspace-wide user icon library.
|
||||
#[async_trait]
|
||||
pub trait CustomProjectIconAssetStore: Send + Sync {
|
||||
/// Lists persisted metadata in stable creation order.
|
||||
async fn list(&self) -> Result<Vec<CustomProjectIconAsset>, CustomProjectIconStoreError>;
|
||||
/// Reads metadata and bytes for one id.
|
||||
async fn read(
|
||||
&self,
|
||||
asset_id: &str,
|
||||
) -> Result<CustomProjectIconContent, CustomProjectIconStoreError>;
|
||||
/// Returns whether an id exists in the canonical index.
|
||||
async fn exists(&self, asset_id: &str) -> Result<bool, CustomProjectIconStoreError>;
|
||||
/// Atomically adds metadata and bytes under the validated extension.
|
||||
async fn create(
|
||||
&self,
|
||||
asset: &CustomProjectIconAsset,
|
||||
extension: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), CustomProjectIconStoreError>;
|
||||
/// Deletes one unused asset.
|
||||
async fn delete(&self, asset_id: &str) -> Result<(), CustomProjectIconStoreError>;
|
||||
}
|
||||
|
||||
/// Manifest parser and validator.
|
||||
pub trait PluginManifestValidator: Send + Sync {
|
||||
/// Validates raw manifest bytes.
|
||||
|
||||
@ -38,6 +38,8 @@ pub struct IdeaUserSettings {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ProjectIconSet {
|
||||
/// User-imported icon from the instance-local shared library.
|
||||
Custom,
|
||||
/// Unicode emoji catalogue.
|
||||
Emoji,
|
||||
/// Devicon language and platform catalogue.
|
||||
@ -50,6 +52,37 @@ pub enum ProjectIconSet {
|
||||
Idea,
|
||||
}
|
||||
|
||||
/// Metadata for one user-imported project icon.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProjectIconAsset {
|
||||
/// Opaque stable ASCII id used by [`ProjectIconRef`].
|
||||
pub id: String,
|
||||
/// Validated image media type.
|
||||
pub media_type: String,
|
||||
/// Stored byte count.
|
||||
pub byte_size: u64,
|
||||
/// Decoded pixel width.
|
||||
pub width: u32,
|
||||
/// Decoded pixel height.
|
||||
pub height: u32,
|
||||
/// Lowercase SHA-256 digest of the stored bytes.
|
||||
pub sha256: String,
|
||||
/// Basename supplied by the user; never a local path.
|
||||
pub original_filename: String,
|
||||
/// Creation time as epoch milliseconds.
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Stored custom icon together with its validated image bytes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CustomProjectIconContent {
|
||||
/// Persisted metadata.
|
||||
pub asset: CustomProjectIconAsset,
|
||||
/// Exact raster bytes.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Maximum byte length of a stable project icon name.
|
||||
pub const MAX_PROJECT_ICON_NAME_LEN: usize = 64;
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ fn language_roundtrips_as_stable_codes_and_rejects_unknown_values() {
|
||||
#[test]
|
||||
fn project_icon_roundtrips_for_every_known_set() {
|
||||
for (set, wire) in [
|
||||
(ProjectIconSet::Custom, "custom"),
|
||||
(ProjectIconSet::Emoji, "emoji"),
|
||||
(ProjectIconSet::Devicon, "devicon"),
|
||||
(ProjectIconSet::SimpleIcons, "simple-icons"),
|
||||
|
||||
@ -111,6 +111,7 @@ pub use sandbox::{default_enforcer, NoopSandbox};
|
||||
pub use scheduler::TokioScheduler;
|
||||
pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory};
|
||||
pub use sprints::FsSprintStore;
|
||||
pub use store::FsCustomProjectIconAssetStore;
|
||||
pub use store::FsProjectPluginSettingsStore;
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
pub use store::OnnxEmbedder;
|
||||
|
||||
@ -15,6 +15,7 @@ mod open_project_restore;
|
||||
mod permission;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod project_icon_asset;
|
||||
mod project_plugin;
|
||||
mod scheduled_task;
|
||||
mod secrets;
|
||||
@ -44,6 +45,7 @@ pub use open_project_restore::FsOpenProjectRestoreStore;
|
||||
pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use project_icon_asset::FsCustomProjectIconAssetStore;
|
||||
pub use project_plugin::FsProjectPluginSettingsStore;
|
||||
pub use scheduled_task::FsScheduledProjectTaskStore;
|
||||
pub use secrets::FsSecretStore;
|
||||
|
||||
277
crates/infrastructure/src/store/project_icon_asset.rs
Normal file
277
crates/infrastructure/src/store/project_icon_asset.rs
Normal file
@ -0,0 +1,277 @@
|
||||
//! Filesystem adapter for the shared user-imported project icon library.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
CustomProjectIconAsset, CustomProjectIconAssetStore, CustomProjectIconContent,
|
||||
CustomProjectIconStoreError,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const INDEX_VERSION: u32 = 1;
|
||||
|
||||
/// File-backed library rooted below app data at `.ideai/icon-assets`.
|
||||
pub struct FsCustomProjectIconAssetStore {
|
||||
root: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AssetIndex {
|
||||
#[serde(default = "index_version")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
assets: Vec<AssetRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AssetRecord {
|
||||
#[serde(flatten)]
|
||||
asset: CustomProjectIconAsset,
|
||||
extension: String,
|
||||
}
|
||||
|
||||
const fn index_version() -> u32 {
|
||||
INDEX_VERSION
|
||||
}
|
||||
|
||||
impl FsCustomProjectIconAssetStore {
|
||||
/// Builds an adapter rooted in the instance-local application data directory.
|
||||
#[must_use]
|
||||
pub fn new(app_data_dir: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: app_data_dir.as_ref().join(".ideai").join("icon-assets"),
|
||||
lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn index_path(&self) -> PathBuf {
|
||||
self.root.join("index.json")
|
||||
}
|
||||
fn icons_dir(&self) -> PathBuf {
|
||||
self.root.join("icons")
|
||||
}
|
||||
|
||||
async fn load_index(&self) -> Result<AssetIndex, CustomProjectIconStoreError> {
|
||||
match tokio::fs::read(self.index_path()).await {
|
||||
Ok(bytes) => {
|
||||
let index: AssetIndex = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CustomProjectIconStoreError::Format(error.to_string()))?;
|
||||
if index.version != INDEX_VERSION {
|
||||
return Err(CustomProjectIconStoreError::Format(format!(
|
||||
"unsupported index version {}",
|
||||
index.version
|
||||
)));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AssetIndex {
|
||||
version: INDEX_VERSION,
|
||||
assets: Vec::new(),
|
||||
}),
|
||||
Err(error) => Err(io_error(&self.index_path(), error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_index(&self, index: &AssetIndex) -> Result<(), CustomProjectIconStoreError> {
|
||||
tokio::fs::create_dir_all(&self.root)
|
||||
.await
|
||||
.map_err(|error| io_error(&self.root, error))?;
|
||||
let mut bytes = serde_json::to_vec_pretty(index)
|
||||
.map_err(|error| CustomProjectIconStoreError::Format(error.to_string()))?;
|
||||
bytes.push(b'\n');
|
||||
let temporary = self.root.join("index.json.tmp");
|
||||
tokio::fs::write(&temporary, bytes)
|
||||
.await
|
||||
.map_err(|error| io_error(&temporary, error))?;
|
||||
tokio::fs::rename(&temporary, self.index_path())
|
||||
.await
|
||||
.map_err(|error| io_error(&self.index_path(), error))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CustomProjectIconAssetStore for FsCustomProjectIconAssetStore {
|
||||
async fn list(&self) -> Result<Vec<CustomProjectIconAsset>, CustomProjectIconStoreError> {
|
||||
let _guard = self.lock.lock().await;
|
||||
Ok(self
|
||||
.load_index()
|
||||
.await?
|
||||
.assets
|
||||
.into_iter()
|
||||
.map(|record| record.asset)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
asset_id: &str,
|
||||
) -> Result<CustomProjectIconContent, CustomProjectIconStoreError> {
|
||||
validate_component(asset_id)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
let index = self.load_index().await?;
|
||||
let record = index
|
||||
.assets
|
||||
.into_iter()
|
||||
.find(|record| record.asset.id == asset_id)
|
||||
.ok_or(CustomProjectIconStoreError::NotFound)?;
|
||||
validate_extension(&record.extension)?;
|
||||
let path = self
|
||||
.icons_dir()
|
||||
.join(format!("{}.{}", record.asset.id, record.extension));
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
CustomProjectIconStoreError::Format(format!(
|
||||
"indexed asset file is missing: {}",
|
||||
record.asset.id
|
||||
))
|
||||
} else {
|
||||
io_error(&path, error)
|
||||
}
|
||||
})?;
|
||||
Ok(CustomProjectIconContent {
|
||||
asset: record.asset,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exists(&self, asset_id: &str) -> Result<bool, CustomProjectIconStoreError> {
|
||||
validate_component(asset_id)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
Ok(self
|
||||
.load_index()
|
||||
.await?
|
||||
.assets
|
||||
.iter()
|
||||
.any(|record| record.asset.id == asset_id))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
asset: &CustomProjectIconAsset,
|
||||
extension: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), CustomProjectIconStoreError> {
|
||||
validate_component(&asset.id)?;
|
||||
validate_extension(extension)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
let mut index = self.load_index().await?;
|
||||
if index
|
||||
.assets
|
||||
.iter()
|
||||
.any(|record| record.asset.id == asset.id)
|
||||
{
|
||||
return Err(CustomProjectIconStoreError::Format(format!(
|
||||
"duplicate asset id {}",
|
||||
asset.id
|
||||
)));
|
||||
}
|
||||
let icons = self.icons_dir();
|
||||
tokio::fs::create_dir_all(&icons)
|
||||
.await
|
||||
.map_err(|error| io_error(&icons, error))?;
|
||||
let path = icons.join(format!("{}.{}", asset.id, extension));
|
||||
tokio::fs::write(&path, bytes)
|
||||
.await
|
||||
.map_err(|error| io_error(&path, error))?;
|
||||
index.assets.push(AssetRecord {
|
||||
asset: asset.clone(),
|
||||
extension: extension.to_owned(),
|
||||
});
|
||||
if let Err(error) = self.save_index(&index).await {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, asset_id: &str) -> Result<(), CustomProjectIconStoreError> {
|
||||
validate_component(asset_id)?;
|
||||
let _guard = self.lock.lock().await;
|
||||
let mut index = self.load_index().await?;
|
||||
let position = index
|
||||
.assets
|
||||
.iter()
|
||||
.position(|record| record.asset.id == asset_id)
|
||||
.ok_or(CustomProjectIconStoreError::NotFound)?;
|
||||
let record = index.assets.remove(position);
|
||||
validate_extension(&record.extension)?;
|
||||
self.save_index(&index).await?;
|
||||
let path = self
|
||||
.icons_dir()
|
||||
.join(format!("{}.{}", asset_id, record.extension));
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_error(&path, error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_component(value: &str) -> Result<(), CustomProjectIconStoreError> {
|
||||
(!value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')))
|
||||
.then_some(())
|
||||
.ok_or_else(|| CustomProjectIconStoreError::Format("unsafe asset id".to_owned()))
|
||||
}
|
||||
|
||||
fn validate_extension(value: &str) -> Result<(), CustomProjectIconStoreError> {
|
||||
matches!(value, "png" | "jpg" | "webp")
|
||||
.then_some(())
|
||||
.ok_or_else(|| {
|
||||
CustomProjectIconStoreError::Format("unsupported stored extension".to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn io_error(path: &Path, error: std::io::Error) -> CustomProjectIconStoreError {
|
||||
CustomProjectIconStoreError::Io(format!("{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn asset(id: &str) -> CustomProjectIconAsset {
|
||||
CustomProjectIconAsset {
|
||||
id: id.to_owned(),
|
||||
media_type: "image/png".to_owned(),
|
||||
byte_size: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
sha256: "abc".to_owned(),
|
||||
original_filename: "icon.png".to_owned(),
|
||||
created_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_lists_reads_and_deletes_inside_confined_root() {
|
||||
let root = std::env::temp_dir().join(format!("idea-icon-assets-{}", uuid::Uuid::new_v4()));
|
||||
let store = FsCustomProjectIconAssetStore::new(&root);
|
||||
store
|
||||
.create(&asset("stable_id"), "png", b"png")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.list().await.unwrap(), vec![asset("stable_id")]);
|
||||
assert_eq!(store.read("stable_id").await.unwrap().bytes, b"png");
|
||||
assert!(root
|
||||
.join(".ideai/icon-assets/icons/stable_id.png")
|
||||
.is_file());
|
||||
store.delete("stable_id").await.unwrap();
|
||||
assert!(!store.exists("stable_id").await.unwrap());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_path_like_ids_and_extensions() {
|
||||
let store = FsCustomProjectIconAssetStore::new(std::env::temp_dir());
|
||||
assert!(store.read("../escape").await.is_err());
|
||||
assert!(store.create(&asset("safe"), "svg", b"x").await.is_err());
|
||||
}
|
||||
}
|
||||
@ -86,17 +86,18 @@ use backend::dto::{
|
||||
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
|
||||
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
|
||||
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
|
||||
DeleteLayoutRequestDto, DeleteLayoutResultDto, DetectProfilesRequestDto,
|
||||
DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
|
||||
ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto,
|
||||
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
|
||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
|
||||
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto,
|
||||
OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||
ProjectPermissionsDto, ProjectSettingsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
CustomProjectIconAssetDto, CustomProjectIconUsageDto, DeleteLayoutRequestDto,
|
||||
DeleteLayoutResultDto, DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto,
|
||||
EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto,
|
||||
GitCheckoutRequestDto, GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto,
|
||||
GitStatusListDto, GraphCommitListDto, HealthRequestDto, HealthResponseDto,
|
||||
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto,
|
||||
LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto, MemoryDto, MemoryIndexDto,
|
||||
MemoryLinksDto, MemoryListDto, OpenCodeProviderListDto, OpenProjectRestoreStateDto,
|
||||
OpenTerminalRequestDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
|
||||
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
|
||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||
ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
|
||||
RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||
@ -138,6 +139,7 @@ const WS_PATH: &str = "/api/ws";
|
||||
const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
const WS_MAX_PAYLOAD: usize = 64 * 1024;
|
||||
const WS_OUTPUT_BUFFER: usize = 512;
|
||||
const HTTP_MAX_REQUEST_BODY_BYTES: usize = 3 * 1024 * 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
type RequestBody = Full<Bytes>;
|
||||
@ -1078,7 +1080,7 @@ async fn handle_tcp_connection(
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if buffer.len() > 1024 * 1024 {
|
||||
if buffer.len() > HTTP_MAX_REQUEST_BODY_BYTES {
|
||||
return Err("request too large".to_owned());
|
||||
}
|
||||
}
|
||||
@ -2299,7 +2301,7 @@ async fn dispatch_http(
|
||||
return cors_response(StatusCode::NO_CONTENT, origin.as_deref());
|
||||
}
|
||||
|
||||
match (method, uri.path()) {
|
||||
match (method.clone(), uri.path()) {
|
||||
(Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await,
|
||||
(Method::POST, "/api/pairing-code") => {
|
||||
let Some(token) = session_cookie(&headers) else {
|
||||
@ -2366,6 +2368,25 @@ async fn dispatch_http(
|
||||
refresh_session_cookie(&mut response, &state, &token);
|
||||
response
|
||||
}
|
||||
(_, path)
|
||||
if path == "/api/project-icon-assets"
|
||||
|| path.starts_with("/api/project-icon-assets/") =>
|
||||
{
|
||||
let (token, _device) = match authenticated_device(&headers, &state).await {
|
||||
Ok(auth) => auth,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let mut response = project_icon_asset_response(
|
||||
&method,
|
||||
path,
|
||||
body,
|
||||
Arc::clone(&state),
|
||||
origin.as_deref(),
|
||||
)
|
||||
.await;
|
||||
refresh_session_cookie(&mut response, &state, &token);
|
||||
response
|
||||
}
|
||||
(Method::POST, "/api/devices/revoke-all") => {
|
||||
let (_token, _device) = match authenticated_device(&headers, &state).await {
|
||||
Ok(auth) => auth,
|
||||
@ -2498,6 +2519,135 @@ async fn dispatch_http(
|
||||
}
|
||||
}
|
||||
|
||||
async fn project_icon_asset_response(
|
||||
method: &Method,
|
||||
path: &str,
|
||||
body: Bytes,
|
||||
state: Arc<ServerState>,
|
||||
origin: Option<&str>,
|
||||
) -> Response<ResponseBody> {
|
||||
if method == Method::GET && path == "/api/project-icon-assets" {
|
||||
return match state.app.list_custom_project_icon_assets.execute().await {
|
||||
Ok(assets) => json_response(
|
||||
StatusCode::OK,
|
||||
&serde_json::to_value(
|
||||
assets
|
||||
.into_iter()
|
||||
.map(CustomProjectIconAssetDto::from)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap(),
|
||||
origin,
|
||||
),
|
||||
Err(error) => app_error_response(error, origin),
|
||||
};
|
||||
}
|
||||
if method == Method::POST && path == "/api/project-icon-assets" {
|
||||
let request = match serde_json::from_slice::<ImportCustomProjectIconAssetRequestDto>(&body)
|
||||
{
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"INVALID",
|
||||
format!("invalid import request: {error}"),
|
||||
origin,
|
||||
)
|
||||
}
|
||||
};
|
||||
let bytes = match base64_decode(&request.data_base64) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => return error_dto_response(StatusCode::BAD_REQUEST, error, origin),
|
||||
};
|
||||
return match state
|
||||
.app
|
||||
.import_custom_project_icon_asset
|
||||
.execute(request.original_filename, request.media_type, bytes)
|
||||
.await
|
||||
{
|
||||
Ok(asset) => json_response(
|
||||
StatusCode::CREATED,
|
||||
&serde_json::to_value(CustomProjectIconAssetDto::from(asset)).unwrap(),
|
||||
origin,
|
||||
),
|
||||
Err(error) => app_error_response(error, origin),
|
||||
};
|
||||
}
|
||||
let Some(asset_path) = path.strip_prefix("/api/project-icon-assets/") else {
|
||||
return error_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"METHOD_NOT_ALLOWED",
|
||||
"method not allowed",
|
||||
origin,
|
||||
);
|
||||
};
|
||||
if method == Method::GET && asset_path.ends_with("/usage") {
|
||||
let asset_id = asset_path.trim_end_matches("/usage").trim_end_matches('/');
|
||||
return match state
|
||||
.app
|
||||
.get_custom_project_icon_asset_usage
|
||||
.execute(asset_id)
|
||||
.await
|
||||
{
|
||||
Ok(usage) => json_response(
|
||||
StatusCode::OK,
|
||||
&serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(),
|
||||
origin,
|
||||
),
|
||||
Err(error) => app_error_response(error, origin),
|
||||
};
|
||||
}
|
||||
if method == Method::GET {
|
||||
return match state
|
||||
.app
|
||||
.read_custom_project_icon_asset
|
||||
.execute(asset_path)
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
let mut response = Response::new(ResponseBody::bytes(Bytes::from(output.bytes)));
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
if let Ok(value) = HeaderValue::from_str(&output.metadata.asset.media_type) {
|
||||
response.headers_mut().insert(CONTENT_TYPE, value);
|
||||
}
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
add_cors_headers(&mut response, origin);
|
||||
response
|
||||
}
|
||||
Err(error) => app_error_response(error, origin),
|
||||
};
|
||||
}
|
||||
if method == Method::DELETE {
|
||||
return match state
|
||||
.app
|
||||
.delete_custom_project_icon_asset
|
||||
.execute(asset_path)
|
||||
.await
|
||||
{
|
||||
Ok(usage) => json_response(
|
||||
StatusCode::OK,
|
||||
&serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(),
|
||||
origin,
|
||||
),
|
||||
Err(error) => app_error_response(error, origin),
|
||||
};
|
||||
}
|
||||
error_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"METHOD_NOT_ALLOWED",
|
||||
"method not allowed",
|
||||
origin,
|
||||
)
|
||||
}
|
||||
|
||||
fn app_error_response(error: AppError, origin: Option<&str>) -> Response<ResponseBody> {
|
||||
let error = ErrorDto::from(error);
|
||||
error_dto_response(error_status(&error), error, origin)
|
||||
}
|
||||
|
||||
fn is_api_path(path: &str) -> bool {
|
||||
path == "/api" || path.starts_with("/api/")
|
||||
}
|
||||
@ -5886,6 +6036,9 @@ fn api_route_allows_missing_origin(method: &Method, path: &str) -> bool {
|
||||
| "/api/project-files/download-file"
|
||||
| "/api/project-files/download-directory"
|
||||
)
|
||||
|| (method == Method::GET
|
||||
&& (path == "/api/project-icon-assets"
|
||||
|| path.starts_with("/api/project-icon-assets/")))
|
||||
}
|
||||
|
||||
fn validate_reverse_proxy(
|
||||
@ -6098,7 +6251,7 @@ fn error_status(error: &ErrorDto) -> StatusCode {
|
||||
match error.code.as_str() {
|
||||
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
||||
"CONFLICT" => StatusCode::CONFLICT,
|
||||
"CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT,
|
||||
"INVALID" => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
@ -6127,7 +6280,7 @@ fn add_cors_headers(response: &mut Response<ResponseBody>, origin: Option<&str>)
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"access-control-allow-methods",
|
||||
HeaderValue::from_static("POST, OPTIONS"),
|
||||
HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6138,7 +6291,7 @@ fn status_for_error(error: &ErrorDto) -> StatusCode {
|
||||
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
|
||||
"INVALID" => StatusCode::BAD_REQUEST,
|
||||
"NOT_FOUND" => StatusCode::NOT_FOUND,
|
||||
"CONFLICT" => StatusCode::CONFLICT,
|
||||
"CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT,
|
||||
"FORBIDDEN" => StatusCode::FORBIDDEN,
|
||||
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@ -6738,6 +6891,7 @@ mod tests {
|
||||
StructuredSessionDescriptor,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyStream};
|
||||
use domain::{
|
||||
AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy,
|
||||
@ -10280,6 +10434,152 @@ mod tests {
|
||||
assert!(!entries.iter().any(|entry| entry["name"] == ".ideai"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_project_icon_routes_import_list_read_usage_and_delete() {
|
||||
let state = state();
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
||||
|
||||
let (status, imported, _) = response_json(
|
||||
request(
|
||||
Arc::clone(&state),
|
||||
Method::POST,
|
||||
"/api/project-icon-assets",
|
||||
json!({
|
||||
"originalFilename": "/home/user/avatar.png",
|
||||
"mediaType": "image/png",
|
||||
"dataBase64": png_base64
|
||||
}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(imported["mediaType"], "image/png");
|
||||
assert_eq!(imported["originalFilename"], "avatar.png");
|
||||
assert_eq!(imported["width"], 1);
|
||||
assert_eq!(imported["height"], 1);
|
||||
assert_eq!(imported["usedByProjectCount"], 0);
|
||||
let asset_id = imported["id"].as_str().unwrap().to_owned();
|
||||
|
||||
let (status, listed, _) = response_json(
|
||||
request(
|
||||
Arc::clone(&state),
|
||||
Method::GET,
|
||||
"/api/project-icon-assets",
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(listed.as_array().unwrap().len(), 1);
|
||||
|
||||
let (status, bytes, headers) = response_bytes(
|
||||
request(
|
||||
Arc::clone(&state),
|
||||
Method::GET,
|
||||
&format!("/api/project-icon-assets/{asset_id}"),
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(headers.get(CONTENT_TYPE).unwrap(), "image/png");
|
||||
assert_eq!(STANDARD.decode(png_base64).unwrap(), bytes);
|
||||
|
||||
let project_id = create_project_for_test(&state, "Custom Icon User").await;
|
||||
let (update_status, _) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
&cookie,
|
||||
"update_project_settings",
|
||||
json!({
|
||||
"projectId": project_id,
|
||||
"icon": { "set": "custom", "name": asset_id.clone() }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(update_status, StatusCode::OK);
|
||||
|
||||
let (status, usage, _) = response_json(
|
||||
request(
|
||||
Arc::clone(&state),
|
||||
Method::GET,
|
||||
&format!("/api/project-icon-assets/{asset_id}/usage"),
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(usage["usedByProjectCount"], 1);
|
||||
|
||||
let (status, conflict, _) = response_json(
|
||||
request(
|
||||
Arc::clone(&state),
|
||||
Method::DELETE,
|
||||
&format!("/api/project-icon-assets/{asset_id}"),
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(conflict["code"], "CUSTOM_ICON_IN_USE");
|
||||
|
||||
let (clear_status, _) = invoke_request_for_test(
|
||||
Arc::clone(&state),
|
||||
&cookie,
|
||||
"update_project_settings",
|
||||
json!({ "projectId": project_id, "icon": null }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(clear_status, StatusCode::OK);
|
||||
|
||||
let (status, deleted, _) = response_json(
|
||||
request(
|
||||
Arc::clone(&state),
|
||||
Method::DELETE,
|
||||
&format!("/api/project-icon-assets/{asset_id}"),
|
||||
json!({}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(deleted["assetId"], asset_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_project_icon_import_rejects_svg_content() {
|
||||
let state = state();
|
||||
let cookie = pair_and_cookie(Arc::clone(&state)).await;
|
||||
let (status, error, _) = response_json(
|
||||
request(
|
||||
state,
|
||||
Method::POST,
|
||||
"/api/project-icon-assets",
|
||||
json!({
|
||||
"originalFilename": "unsafe.svg",
|
||||
"mediaType": "image/svg+xml",
|
||||
"dataBase64": STANDARD.encode(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
|
||||
}),
|
||||
&[("cookie", &cookie)],
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error["code"], "INVALID");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorized_project_file_gets_allow_missing_origin_header() {
|
||||
let state = state();
|
||||
|
||||
@ -122,6 +122,11 @@ export class HttpInvoker {
|
||||
return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
|
||||
}
|
||||
|
||||
/** Resolves a same-server REST path for browser-owned resources such as images. */
|
||||
absoluteUrl(path: string): string {
|
||||
return `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one authenticated REST request and maps the response exactly like
|
||||
* {@link invoke} (401 routing + `ErrorDto` preservation).
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
HttpPermissionGateway,
|
||||
HttpProfileGateway,
|
||||
HttpProjectGateway,
|
||||
HttpProjectIconAssetGateway,
|
||||
HttpSkillGateway,
|
||||
HttpTemplateGateway,
|
||||
HttpWorkStateGateway,
|
||||
@ -126,6 +127,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
|
||||
input: new HttpInputGateway(http),
|
||||
terminal: new HttpTerminalGateway(ws),
|
||||
project: new HttpProjectGateway(http),
|
||||
projectIconAsset: new HttpProjectIconAssetGateway(http),
|
||||
layout: new HttpLayoutGateway(http),
|
||||
git: new HttpGitGateway(http),
|
||||
remote: new WebRemoteGateway(),
|
||||
|
||||
27
frontend/src/adapters/http/projectIconAssetGateway.test.ts
Normal file
27
frontend/src/adapters/http/projectIconAssetGateway.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HttpInvoker } from "./httpInvoker";
|
||||
import { HttpProjectIconAssetGateway } from "./requestResponseGateways";
|
||||
|
||||
describe("HttpProjectIconAssetGateway", () => {
|
||||
it("maps list/import/delete/read URLs to the dedicated REST resource", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://idea.test" });
|
||||
const request = vi.spyOn(http, "request").mockResolvedValue({ id: "asset-1" } as never);
|
||||
const gateway = new HttpProjectIconAssetGateway(http);
|
||||
|
||||
await gateway.list();
|
||||
const file = {
|
||||
name: "icon.webp", type: "image/webp",
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
} as File;
|
||||
await gateway.import(file);
|
||||
await gateway.delete("asset/with spaces");
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/project-icon-assets", undefined, "custom project icons");
|
||||
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/project-icon-assets", {
|
||||
originalFilename: "icon.webp", mediaType: "image/webp", dataBase64: "AQID",
|
||||
}, "custom project icon import");
|
||||
expect(request).toHaveBeenNthCalledWith(3, "DELETE", "/api/project-icon-assets/asset%2Fwith%20spaces", undefined, "custom project icon deletion");
|
||||
await expect(gateway.getUrl("asset-1")).resolves.toBe("https://idea.test/api/project-icon-assets/asset-1");
|
||||
});
|
||||
});
|
||||
@ -74,6 +74,8 @@ import type {
|
||||
PermissionGateway,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
ProjectIconAssetGateway,
|
||||
CustomProjectIconAsset,
|
||||
ProjectSettings,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
SkillGateway,
|
||||
@ -170,6 +172,43 @@ export class HttpProjectGateway implements ProjectGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpProjectIconAssetGateway implements ProjectIconAssetGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
|
||||
list(): Promise<CustomProjectIconAsset[]> {
|
||||
return this.http.request("GET", "/api/project-icon-assets", undefined, "custom project icons");
|
||||
}
|
||||
|
||||
async import(file: File): Promise<CustomProjectIconAsset> {
|
||||
const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||
return this.http.request("POST", "/api/project-icon-assets", {
|
||||
originalFilename: file.name,
|
||||
mediaType: file.type,
|
||||
dataBase64,
|
||||
}, "custom project icon import");
|
||||
}
|
||||
|
||||
async delete(assetId: string): Promise<void> {
|
||||
await this.http.request("DELETE", projectIconAssetPath(assetId), undefined, "custom project icon deletion");
|
||||
}
|
||||
|
||||
async getUrl(assetId: string): Promise<string> {
|
||||
return this.http.absoluteUrl(projectIconAssetPath(assetId));
|
||||
}
|
||||
}
|
||||
|
||||
function projectIconAssetPath(assetId: string): string {
|
||||
return `/api/project-icon-assets/${encodeURIComponent(assetId)}`;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function projectFilesPath(endpoint: string, projectId: string, path: string): string {
|
||||
const params = new URLSearchParams({ projectId, path });
|
||||
return `${endpoint}?${params.toString()}`;
|
||||
|
||||
@ -16,6 +16,7 @@ import { TauriSystemGateway } from "./system";
|
||||
import { TauriAgentGateway } from "./agent";
|
||||
import { TauriInputGateway } from "./input";
|
||||
import { TauriProjectGateway } from "./project";
|
||||
import { TauriProjectIconAssetGateway } from "./projectIconAsset";
|
||||
import { TauriTerminalGateway } from "./terminal";
|
||||
import { TauriLayoutGateway } from "./layout";
|
||||
import { TauriProfileGateway } from "./profile";
|
||||
@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways {
|
||||
input: new TauriInputGateway(),
|
||||
terminal: new TauriTerminalGateway(),
|
||||
project: new TauriProjectGateway(),
|
||||
projectIconAsset: new TauriProjectIconAssetGateway(),
|
||||
layout: new TauriLayoutGateway(),
|
||||
git: new TauriGitGateway(),
|
||||
remote: new TauriRemoteGateway(),
|
||||
@ -101,6 +103,7 @@ export {
|
||||
TauriAgentGateway,
|
||||
TauriInputGateway,
|
||||
TauriProjectGateway,
|
||||
TauriProjectIconAssetGateway,
|
||||
TauriTerminalGateway,
|
||||
TauriLayoutGateway,
|
||||
TauriProfileGateway,
|
||||
|
||||
@ -138,6 +138,8 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
ProjectIconAssetGateway,
|
||||
CustomProjectIconAsset,
|
||||
ProjectSettings,
|
||||
PermissionGateway,
|
||||
PluginConfigDocumentReadInput,
|
||||
@ -1287,6 +1289,59 @@ export class MockTerminalGateway implements TerminalGateway {
|
||||
}
|
||||
}
|
||||
|
||||
export class MockProjectIconAssetGateway implements ProjectIconAssetGateway {
|
||||
private readonly assets = new Map<string, CustomProjectIconAsset>();
|
||||
private readonly urls = new Map<string, string>();
|
||||
private sequence = 0;
|
||||
|
||||
async list(): Promise<CustomProjectIconAsset[]> {
|
||||
return [...this.assets.values()].map((asset) => ({ ...asset }));
|
||||
}
|
||||
|
||||
async import(file: File): Promise<CustomProjectIconAsset> {
|
||||
if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
|
||||
throw { code: "INVALID_ICON_MEDIA_TYPE", message: "PNG, JPEG or WebP required" } as GatewayError;
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
throw { code: "ICON_TOO_LARGE", message: "Icon exceeds 2 MiB" } as GatewayError;
|
||||
}
|
||||
const id = `mock-icon-${++this.sequence}`;
|
||||
const asset: CustomProjectIconAsset = {
|
||||
id,
|
||||
mediaType: file.type as CustomProjectIconAsset["mediaType"],
|
||||
byteSize: file.size,
|
||||
width: 0,
|
||||
height: 0,
|
||||
sha256: `mock-${id}`,
|
||||
originalFilename: file.name,
|
||||
createdAt: Date.now(),
|
||||
usedByProjectCount: 0,
|
||||
};
|
||||
this.assets.set(id, asset);
|
||||
const createObjectURL = URL.createObjectURL?.bind(URL);
|
||||
this.urls.set(id, createObjectURL ? createObjectURL(file) : `data:${file.type};base64,`);
|
||||
return { ...asset };
|
||||
}
|
||||
|
||||
async delete(assetId: string): Promise<void> {
|
||||
const asset = this.assets.get(assetId);
|
||||
if (!asset) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError;
|
||||
if (asset.usedByProjectCount > 0) {
|
||||
throw { code: "CUSTOM_ICON_IN_USE", message: `icon is used by ${asset.usedByProjectCount} projects` } as GatewayError;
|
||||
}
|
||||
const url = this.urls.get(assetId);
|
||||
if (url?.startsWith("blob:")) URL.revokeObjectURL?.(url);
|
||||
this.assets.delete(assetId);
|
||||
this.urls.delete(assetId);
|
||||
}
|
||||
|
||||
async getUrl(assetId: string): Promise<string> {
|
||||
const url = this.urls.get(assetId);
|
||||
if (!url) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export class MockProjectGateway implements ProjectGateway {
|
||||
private projects: Project[] = [];
|
||||
private contexts = new Map<string, string>();
|
||||
@ -5002,6 +5057,7 @@ export function createMockGateways(): Gateways {
|
||||
input: new MockInputGateway(),
|
||||
terminal: new MockTerminalGateway(),
|
||||
project: new MockProjectGateway(),
|
||||
projectIconAsset: new MockProjectIconAssetGateway(),
|
||||
layout: new MockLayoutGateway(),
|
||||
git: new MockGitGateway(),
|
||||
remote: new MockRemoteGateway(),
|
||||
|
||||
@ -35,6 +35,7 @@ describe("createMockGateways", () => {
|
||||
"pluginWorkspace",
|
||||
"profile",
|
||||
"project",
|
||||
"projectIconAsset",
|
||||
"remote",
|
||||
"scheduledTask",
|
||||
"skill",
|
||||
|
||||
38
frontend/src/adapters/projectIconAsset.test.ts
Normal file
38
frontend/src/adapters/projectIconAsset.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invoke = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) }));
|
||||
|
||||
import { TauriProjectIconAssetGateway } from "./projectIconAsset";
|
||||
|
||||
describe("TauriProjectIconAssetGateway", () => {
|
||||
beforeEach(() => invoke.mockReset());
|
||||
|
||||
it("uses the shared backend commands and base64 import DTO", async () => {
|
||||
const gateway = new TauriProjectIconAssetGateway();
|
||||
invoke.mockResolvedValueOnce([]);
|
||||
await gateway.list();
|
||||
expect(invoke).toHaveBeenCalledWith("list_custom_project_icon_assets");
|
||||
|
||||
invoke.mockResolvedValueOnce({ id: "asset-1" });
|
||||
const file = {
|
||||
name: "icon.png", type: "image/png",
|
||||
arrayBuffer: async () => new Uint8Array([0, 1, 2]).buffer,
|
||||
} as File;
|
||||
await gateway.import(file);
|
||||
expect(invoke).toHaveBeenCalledWith("import_custom_project_icon_asset", {
|
||||
request: { originalFilename: "icon.png", mediaType: "image/png", dataBase64: "AAEC" },
|
||||
});
|
||||
|
||||
invoke.mockResolvedValueOnce(undefined);
|
||||
await gateway.delete("asset-1");
|
||||
expect(invoke).toHaveBeenCalledWith("delete_custom_project_icon_asset", { assetId: "asset-1" });
|
||||
});
|
||||
|
||||
it("turns the Tauri read DTO into a browser-safe data URL", async () => {
|
||||
invoke.mockResolvedValueOnce({ mediaType: "image/webp", dataBase64: "AQID" });
|
||||
await expect(new TauriProjectIconAssetGateway().getUrl("asset-1"))
|
||||
.resolves.toBe("data:image/webp;base64,AQID");
|
||||
expect(invoke).toHaveBeenCalledWith("read_custom_project_icon_asset", { assetId: "asset-1" });
|
||||
});
|
||||
});
|
||||
41
frontend/src/adapters/projectIconAsset.ts
Normal file
41
frontend/src/adapters/projectIconAsset.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { CustomProjectIconAsset, ProjectIconAssetGateway } from "@/ports";
|
||||
|
||||
interface CustomProjectIconAssetContent extends CustomProjectIconAsset {
|
||||
dataBase64: string;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export class TauriProjectIconAssetGateway implements ProjectIconAssetGateway {
|
||||
list(): Promise<CustomProjectIconAsset[]> {
|
||||
return invoke("list_custom_project_icon_assets");
|
||||
}
|
||||
|
||||
async import(file: File): Promise<CustomProjectIconAsset> {
|
||||
const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
|
||||
return invoke("import_custom_project_icon_asset", {
|
||||
request: {
|
||||
originalFilename: file.name,
|
||||
mediaType: file.type,
|
||||
dataBase64,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(assetId: string): Promise<void> {
|
||||
await invoke("delete_custom_project_icon_asset", { assetId });
|
||||
}
|
||||
|
||||
getUrl(assetId: string): Promise<string> {
|
||||
return invoke<CustomProjectIconAssetContent>("read_custom_project_icon_asset", { assetId })
|
||||
.then((content) => `data:${content.mediaType};base64,${content.dataBase64}`);
|
||||
}
|
||||
}
|
||||
@ -80,3 +80,8 @@ export function useGateways(): Gateways {
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Optional access for leaf renderers that retain a graceful no-provider fallback. */
|
||||
export function useOptionalGateways(): Gateways | null {
|
||||
return useContext(GatewaysContext);
|
||||
}
|
||||
|
||||
@ -1,69 +1,147 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ProjectIconRef } from "@/ports";
|
||||
import { Button, Input } from "@/shared";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useGateways } from "@/app/di";
|
||||
import type { CustomProjectIconAsset, ProjectIconRef } from "@/ports";
|
||||
import { Button, Input, Spinner } from "@/shared";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
PROJECT_ICON_CATALOG,
|
||||
ProjectIcon,
|
||||
type ProjectIconGroup,
|
||||
toEmojiIconRef,
|
||||
} from "./projectIcons";
|
||||
import { PROJECT_ICON_CATALOG, ProjectIcon, type ProjectIconGroup, toEmojiIconRef } from "./projectIcons";
|
||||
|
||||
const GROUPS: readonly ProjectIconGroup[] = ["smileys", "nature", "food", "activities", "travel", "objects", "symbols"];
|
||||
const GROUP_LABELS = {
|
||||
smileys: "project.icon.group.smileys",
|
||||
nature: "project.icon.group.nature",
|
||||
food: "project.icon.group.food",
|
||||
activities: "project.icon.group.activities",
|
||||
travel: "project.icon.group.travel",
|
||||
objects: "project.icon.group.objects",
|
||||
smileys: "project.icon.group.smileys", nature: "project.icon.group.nature", food: "project.icon.group.food",
|
||||
activities: "project.icon.group.activities", travel: "project.icon.group.travel", objects: "project.icon.group.objects",
|
||||
symbols: "project.icon.group.symbols",
|
||||
} as const;
|
||||
const MAX_ICON_BYTES = 2 * 1024 * 1024;
|
||||
const ACCEPTED_ICON_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
||||
|
||||
function readableError(error: unknown, fallback: string): string {
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function ProjectIconPicker({ value, onApply, onCancel }: {
|
||||
value: ProjectIconRef | null;
|
||||
onApply(value: ProjectIconRef | null): void;
|
||||
onCancel(): void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { projectIconAsset } = useGateways();
|
||||
const { t, tr } = useI18n();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<ProjectIconRef | null>(() => toEmojiIconRef(value));
|
||||
const results = useMemo(() => {
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word))));
|
||||
}, [query]);
|
||||
const groupedResults = useMemo(() => GROUPS.map((group) => ({
|
||||
group,
|
||||
entries: results.filter((entry) => entry.group === group),
|
||||
})).filter(({ entries }) => entries.length > 0), [results]);
|
||||
const [selected, setSelected] = useState<ProjectIconRef | null>(() => value?.set === "custom" ? value : toEmojiIconRef(value));
|
||||
const [customAssets, setCustomAssets] = useState<CustomProjectIconAsset[]>([]);
|
||||
const [loadingAssets, setLoadingAssets] = useState(true);
|
||||
const [assetBusy, setAssetBusy] = useState(false);
|
||||
const [assetError, setAssetError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingAssets(true);
|
||||
void projectIconAsset.list().then((assets) => {
|
||||
if (!cancelled) setCustomAssets(assets);
|
||||
}).catch((error) => {
|
||||
if (!cancelled) setAssetError(readableError(error, tr("Impossible de charger vos icônes.", "Unable to load your icons.")));
|
||||
}).finally(() => { if (!cancelled) setLoadingAssets(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [projectIconAsset, tr]);
|
||||
|
||||
const words = useMemo(() => query.trim().toLowerCase().split(/\s+/).filter(Boolean), [query]);
|
||||
const results = useMemo(() => PROJECT_ICON_CATALOG.filter((entry) =>
|
||||
words.every((word) => entry.keywords.some((key) => key.includes(word)))), [words]);
|
||||
const visibleCustomAssets = useMemo(() => customAssets.filter((asset) => {
|
||||
const searchable = `${asset.originalFilename} ${asset.id}`.toLowerCase();
|
||||
return words.every((word) => searchable.includes(word));
|
||||
}), [customAssets, words]);
|
||||
const groupedResults = useMemo(() => GROUPS.map((group) => ({ group, entries: results.filter((entry) => entry.group === group) }))
|
||||
.filter(({ entries }) => entries.length > 0), [results]);
|
||||
|
||||
async function importIcon(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!ACCEPTED_ICON_TYPES.has(file.type)) {
|
||||
setAssetError(tr("Format non pris en charge. Choisissez un fichier PNG, JPEG ou WebP.", "Unsupported format. Choose a PNG, JPEG or WebP file."));
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_ICON_BYTES) {
|
||||
setAssetError(tr("Cette icône dépasse la limite de 2 Mo.", "This icon exceeds the 2 MB limit."));
|
||||
return;
|
||||
}
|
||||
setAssetBusy(true);
|
||||
setAssetError(null);
|
||||
try {
|
||||
const imported = await projectIconAsset.import(file);
|
||||
setCustomAssets((current) => [imported, ...current.filter(({ id }) => id !== imported.id)]);
|
||||
setSelected({ set: "custom", name: imported.id });
|
||||
} catch (error) {
|
||||
setAssetError(readableError(error, tr("Impossible d’importer cette icône.", "Unable to import this icon.")));
|
||||
} finally { setAssetBusy(false); }
|
||||
}
|
||||
|
||||
async function deleteIcon(asset: CustomProjectIconAsset) {
|
||||
if (asset.usedByProjectCount > 0) return;
|
||||
setAssetBusy(true);
|
||||
setAssetError(null);
|
||||
try {
|
||||
await projectIconAsset.delete(asset.id);
|
||||
setCustomAssets((current) => current.filter(({ id }) => id !== asset.id));
|
||||
if (selected?.set === "custom" && selected.name === asset.id) setSelected(null);
|
||||
} catch (error) {
|
||||
setAssetError(readableError(error, tr("Impossible de supprimer cette icône.", "Unable to delete this icon.")));
|
||||
} finally { setAssetBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4">
|
||||
<div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl">
|
||||
<h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3>
|
||||
<Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />
|
||||
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-4 overflow-y-auto pr-1">
|
||||
{groupedResults.map(({ group, entries }) => (
|
||||
<div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
|
||||
<h4 id={`project-icon-group-${group}`} className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{t(GROUP_LABELS[group])}
|
||||
</h4>
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
|
||||
{entries.map((entry) => {
|
||||
const active = selected?.set === entry.set && selected.name === entry.name;
|
||||
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
|
||||
<ProjectIcon icon={entry} size={32} decorative />
|
||||
<span className="line-clamp-2">{entry.label}</span>
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-5 overflow-y-auto pr-1">
|
||||
<section aria-labelledby="project-icon-custom-heading">
|
||||
<div className="sticky top-0 z-20 mb-2 flex items-center justify-between gap-2 bg-surface py-1">
|
||||
<h4 id="project-icon-custom-heading" className="text-xs font-semibold uppercase tracking-wide text-muted">{tr("Mes icônes", "My icons")}</h4>
|
||||
<Button size="sm" variant="secondary" disabled={assetBusy} onClick={() => fileInput.current?.click()}>
|
||||
{assetBusy ? <Spinner size={14} /> : null}{tr("Importer une icône", "Import an icon")}
|
||||
</Button>
|
||||
<input ref={fileInput} type="file" className="sr-only" accept="image/png,image/jpeg,image/webp" onChange={(event) => void importIcon(event)} aria-label={tr("Importer une icône", "Import an icon")} />
|
||||
</div>
|
||||
))}
|
||||
<p className="mb-2 text-xs text-muted">{tr("PNG, JPEG ou WebP — 2 Mo maximum.", "PNG, JPEG or WebP — 2 MB maximum.")}</p>
|
||||
{loadingAssets ? <div className="flex items-center gap-2 text-sm text-muted"><Spinner size={14} />{t("common.loading")}</div> : null}
|
||||
{!loadingAssets && customAssets.length === 0 ? <p className="text-sm text-muted">{tr("Aucune icône importée.", "No imported icons yet.")}</p> : null}
|
||||
{visibleCustomAssets.length > 0 ? <div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
|
||||
{visibleCustomAssets.map((asset) => {
|
||||
const active = selected?.set === "custom" && selected.name === asset.id;
|
||||
const inUse = asset.usedByProjectCount > 0;
|
||||
const usage = tr(`Utilisée par ${asset.usedByProjectCount} projet${asset.usedByProjectCount > 1 ? "s" : ""}`, `Used by ${asset.usedByProjectCount} project${asset.usedByProjectCount === 1 ? "" : "s"}`);
|
||||
return <div key={asset.id} className="relative min-w-0">
|
||||
<button type="button" role="option" aria-selected={active} aria-label={asset.originalFilename} onClick={() => setSelected({ set: "custom", name: asset.id })} className={`flex min-h-20 w-full flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
|
||||
<ProjectIcon icon={{ set: "custom", name: asset.id }} size={36} decorative />
|
||||
<span className="line-clamp-2 break-all">{asset.originalFilename}</span>
|
||||
</button>
|
||||
<button type="button" disabled={assetBusy} aria-disabled={inUse || undefined} title={inUse ? usage : tr("Supprimer de la bibliothèque", "Delete from library")} aria-label={inUse ? `${tr("Suppression impossible", "Cannot delete")}: ${usage}` : `${tr("Supprimer", "Delete")} ${asset.originalFilename}`} onClick={() => inUse ? setAssetError(`${tr("Suppression impossible", "Cannot delete")}: ${usage}.`) : void deleteIcon(asset)} className="absolute right-1 top-1 rounded bg-surface/90 px-1.5 py-0.5 text-xs text-muted hover:text-content disabled:cursor-not-allowed disabled:opacity-50">×</button>
|
||||
</div>;
|
||||
})}
|
||||
</div> : null}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="project-icon-emoji-heading">
|
||||
<h4 id="project-icon-emoji-heading" className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">Emojis</h4>
|
||||
<div className="space-y-4">{groupedResults.map(({ group, entries }) => <div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
|
||||
<h5 id={`project-icon-group-${group}`} className="mb-2 text-xs font-semibold text-muted">{t(GROUP_LABELS[group])}</h5>
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">{entries.map((entry) => {
|
||||
const active = selected?.set === entry.set && selected.name === entry.name;
|
||||
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
|
||||
<ProjectIcon icon={entry} size={32} decorative /><span className="line-clamp-2">{entry.label}</span>
|
||||
</button>;
|
||||
})}</div>
|
||||
</div>)}</div>
|
||||
</section>
|
||||
</div>
|
||||
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
|
||||
{assetError ? <p role="alert" className="text-sm text-danger">{assetError}</p> : null}
|
||||
{results.length === 0 && visibleCustomAssets.length === 0 ? <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p> : null}
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button>
|
||||
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
|
||||
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button disabled={assetBusy} onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { CustomProjectIconAsset } from "@/ports";
|
||||
import { ProjectIconPicker } from "./ProjectIconPicker";
|
||||
import {
|
||||
PROJECT_ICON_CATALOG,
|
||||
@ -63,8 +67,12 @@ describe("project emoji catalogue", () => {
|
||||
});
|
||||
|
||||
describe("ProjectIconPicker", () => {
|
||||
function renderWithGateways(ui: ReactNode, gateways = createMockGateways()) {
|
||||
return render(<DIProvider gateways={gateways}>{ui}</DIProvider>);
|
||||
}
|
||||
|
||||
function renderPicker(value = null as Parameters<typeof ProjectIconPicker>[0]["value"]) {
|
||||
render(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
|
||||
renderWithGateways(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
|
||||
}
|
||||
|
||||
it("shows phone-like emoji categories only", () => {
|
||||
@ -89,7 +97,7 @@ describe("ProjectIconPicker", () => {
|
||||
|
||||
it("selects and persists an emoji reference", () => {
|
||||
const onApply = vi.fn();
|
||||
render(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
|
||||
renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "rocket" } });
|
||||
fireEvent.click(screen.getByRole("option", { name: "Rocket" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
|
||||
@ -98,8 +106,62 @@ describe("ProjectIconPicker", () => {
|
||||
|
||||
it("normalizes a known legacy selection before applying it", () => {
|
||||
const onApply = vi.fn();
|
||||
render(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
|
||||
renderWithGateways(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
|
||||
expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "snake" });
|
||||
});
|
||||
|
||||
it("imports a supported image into Mes icônes and selects its opaque id", async () => {
|
||||
const gateways = createMockGateways();
|
||||
const imported: CustomProjectIconAsset = {
|
||||
id: "asset-opaque-1", mediaType: "image/png", byteSize: 12, width: 32, height: 32,
|
||||
sha256: "abc", originalFilename: "équipe.png", createdAt: 1, usedByProjectCount: 0,
|
||||
};
|
||||
vi.spyOn(gateways.projectIconAsset, "import").mockResolvedValue(imported);
|
||||
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
|
||||
const onApply = vi.fn();
|
||||
const { container } = renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />, gateways);
|
||||
|
||||
expect(screen.getByText("Mes icônes")).toBeTruthy();
|
||||
expect(screen.getByText("PNG, JPEG ou WebP — 2 Mo maximum.")).toBeTruthy();
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File(["png"], "équipe.png", { type: "image/png" })] } });
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: "équipe.png" })).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
|
||||
expect(onApply).toHaveBeenCalledWith({ set: "custom", name: "asset-opaque-1" });
|
||||
});
|
||||
|
||||
it("blocks deletion while a custom icon is used by projects", async () => {
|
||||
const gateways = createMockGateways();
|
||||
vi.spyOn(gateways.projectIconAsset, "list").mockResolvedValue([{
|
||||
id: "used-icon", mediaType: "image/webp", byteSize: 10, width: 16, height: 16,
|
||||
sha256: "def", originalFilename: "used.webp", createdAt: 1, usedByProjectCount: 2,
|
||||
}]);
|
||||
const deleteSpy = vi.spyOn(gateways.projectIconAsset, "delete");
|
||||
renderWithGateways(<ProjectIconPicker value={null} onApply={vi.fn()} onCancel={vi.fn()} />, gateways);
|
||||
|
||||
const remove = await screen.findByRole("button", { name: /Suppression impossible: Utilisée par 2 projets/ });
|
||||
expect(remove.getAttribute("aria-disabled")).toBe("true");
|
||||
fireEvent.click(remove);
|
||||
expect(screen.getByRole("alert").textContent).toContain("Utilisée par 2 projets");
|
||||
expect(deleteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom ProjectIcon", () => {
|
||||
it("renders a contained image and falls back cleanly when the asset is missing", async () => {
|
||||
const gateways = createMockGateways();
|
||||
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
|
||||
const { getByRole, rerender } = render(
|
||||
<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "asset-1" }} /></DIProvider>,
|
||||
);
|
||||
await waitFor(() => expect(getByRole("img").querySelector("img")?.style.objectFit).toBe("contain"));
|
||||
expect(getByRole("img").dataset.resolvedProjectIcon).toBe("custom:asset-1");
|
||||
|
||||
vi.spyOn(gateways.projectIconAsset, "getUrl").mockRejectedValueOnce({ code: "NOT_FOUND" });
|
||||
rerender(<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "missing" }} fallbackLabel="Alpha" /></DIProvider>);
|
||||
await waitFor(() => expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial"));
|
||||
expect(getByRole("img").textContent).toBe("A");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import { useEffect, useState, type CSSProperties, type HTMLAttributes } from "react";
|
||||
import { useOptionalGateways } from "@/app/di";
|
||||
import type { ProjectIconRef } from "@/ports";
|
||||
|
||||
export type ProjectIconGroup =
|
||||
@ -206,8 +207,24 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
|
||||
decorative?: boolean;
|
||||
surface?: ProjectIconSurface;
|
||||
} & Omit<HTMLAttributes<HTMLSpanElement>, "children" | "style" | "className">) {
|
||||
const gateways = useOptionalGateways();
|
||||
const entry = resolveProjectIcon(ref, surface);
|
||||
const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
|
||||
const customAssetId = ref?.set === "custom" ? ref.name : null;
|
||||
const [customUrl, setCustomUrl] = useState<string | null>(null);
|
||||
const [customMissing, setCustomMissing] = useState(false);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCustomUrl(null);
|
||||
setCustomMissing(false);
|
||||
if (!customAssetId || !gateways) return () => { cancelled = true; };
|
||||
void gateways.projectIconAsset.getUrl(customAssetId).then((url) => {
|
||||
if (!cancelled) setCustomUrl(url);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setCustomMissing(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [customAssetId, gateways]);
|
||||
const label = customAssetId ? (fallbackLabel ? `${fallbackLabel} — custom icon` : "Custom icon") : entry?.label ?? fallbackInitial(ref, fallbackLabel);
|
||||
const numericSize = typeof size === "number" ? size : null;
|
||||
const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel);
|
||||
return (
|
||||
@ -219,7 +236,7 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
|
||||
aria-label={decorative ? undefined : label}
|
||||
title={decorative ? undefined : label}
|
||||
data-project-icon={ref ? `${ref.set}:${ref.name}` : "none"}
|
||||
data-resolved-project-icon={entry ? `emoji:${entry.name}` : "initial"}
|
||||
data-resolved-project-icon={customUrl && !customMissing ? `custom:${customAssetId}` : entry ? `emoji:${entry.name}` : "initial"}
|
||||
style={{
|
||||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||||
width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
|
||||
@ -227,7 +244,15 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
|
||||
fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
|
||||
}}
|
||||
>
|
||||
{glyph}
|
||||
{customUrl && !customMissing ? (
|
||||
<img
|
||||
src={customUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
onError={() => setCustomMissing(true)}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
|
||||
/>
|
||||
) : glyph}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -495,13 +495,35 @@ export interface UserSettingsGateway {
|
||||
updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings>;
|
||||
}
|
||||
|
||||
export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea";
|
||||
export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea" | "custom";
|
||||
|
||||
export interface ProjectIconRef {
|
||||
set: ProjectIconSet;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One user-imported raster icon in the instance-wide project icon library. */
|
||||
export interface CustomProjectIconAsset {
|
||||
id: string;
|
||||
mediaType: "image/png" | "image/jpeg" | "image/webp";
|
||||
byteSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
sha256: string;
|
||||
originalFilename: string;
|
||||
createdAt: number | string;
|
||||
usedByProjectCount: number;
|
||||
}
|
||||
|
||||
/** Shared custom-project-icon library, independent from any single project. */
|
||||
export interface ProjectIconAssetGateway {
|
||||
list(): Promise<CustomProjectIconAsset[]>;
|
||||
import(file: File): Promise<CustomProjectIconAsset>;
|
||||
delete(assetId: string): Promise<void>;
|
||||
/** Returns a browser-loadable URL without exposing a local filesystem path. */
|
||||
getUrl(assetId: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
themeColor: string | null;
|
||||
/** Absent only when talking to a pre-#325 backend. */
|
||||
@ -1892,6 +1914,7 @@ export interface Gateways {
|
||||
input: InputGateway;
|
||||
terminal: TerminalGateway;
|
||||
project: ProjectGateway;
|
||||
projectIconAsset: ProjectIconAssetGateway;
|
||||
layout: LayoutGateway;
|
||||
git: GitGateway;
|
||||
remote: RemoteGateway;
|
||||
|
||||
Reference in New Issue
Block a user