add folders to asset library

This commit is contained in:
Skyler Lehmkuhl 2025-12-30 00:45:19 -05:00
parent 1fcad0355d
commit b19f66e648
12 changed files with 2615 additions and 9 deletions

View File

@ -0,0 +1,183 @@
//! Create folder action
//!
//! Handles creating a new folder in the asset library.
use crate::action::Action;
use crate::asset_folder::AssetFolder;
use crate::document::{AssetCategory, Document};
use uuid::Uuid;
/// Action that creates a new folder in an asset category
pub struct CreateFolderAction {
/// Asset category for this folder
category: AssetCategory,
/// Folder name
name: String,
/// Parent folder ID (None = root level)
parent_id: Option<Uuid>,
/// ID of the created folder (set after execution)
created_folder_id: Option<Uuid>,
}
impl CreateFolderAction {
/// Create a new folder action
///
/// # Arguments
///
/// * `category` - Which asset category to create the folder in
/// * `name` - The name for the new folder
/// * `parent_id` - Optional parent folder ID (None = root level)
pub fn new(
category: AssetCategory,
name: impl Into<String>,
parent_id: Option<Uuid>,
) -> Self {
Self {
category,
name: name.into(),
parent_id,
created_folder_id: None,
}
}
/// Get the ID of the created folder (after execution)
pub fn created_folder_id(&self) -> Option<Uuid> {
self.created_folder_id
}
}
impl Action for CreateFolderAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
// Create the folder
let folder = AssetFolder::new(&self.name, self.parent_id);
let folder_id = folder.id;
// Add to the appropriate folder tree
let tree = document.get_folder_tree_mut(self.category);
tree.add_folder(folder);
// Store the ID for rollback
self.created_folder_id = Some(folder_id);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
// Remove the created folder if it exists
if let Some(folder_id) = self.created_folder_id {
let tree = document.get_folder_tree_mut(self.category);
tree.remove_folder(&folder_id);
// Clear the stored ID
self.created_folder_id = None;
}
Ok(())
}
fn description(&self) -> String {
format!("Create folder '{}'", self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_folder_at_root() {
let mut document = Document::new("Test");
// Create and execute action
let mut action = CreateFolderAction::new(AssetCategory::Vector, "My Folder", None);
action.execute(&mut document).unwrap();
// Verify folder was created
let tree = document.get_folder_tree(AssetCategory::Vector);
assert_eq!(tree.folders.len(), 1);
let roots = tree.root_folders();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].name, "My Folder");
assert_eq!(roots[0].parent_id, None);
// Get the created ID
let folder_id = action.created_folder_id().unwrap();
assert_eq!(roots[0].id, folder_id);
// Rollback
action.rollback(&mut document).unwrap();
// Verify folder was removed
let tree = document.get_folder_tree(AssetCategory::Vector);
assert_eq!(tree.folders.len(), 0);
}
#[test]
fn test_create_nested_folder() {
let mut document = Document::new("Test");
// Create parent folder
let mut parent_action = CreateFolderAction::new(AssetCategory::Audio, "Parent", None);
parent_action.execute(&mut document).unwrap();
let parent_id = parent_action.created_folder_id().unwrap();
// Create child folder
let mut child_action =
CreateFolderAction::new(AssetCategory::Audio, "Child", Some(parent_id));
child_action.execute(&mut document).unwrap();
// Verify structure
let tree = document.get_folder_tree(AssetCategory::Audio);
assert_eq!(tree.folders.len(), 2);
let roots = tree.root_folders();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].name, "Parent");
let children = tree.children_of(&parent_id);
assert_eq!(children.len(), 1);
assert_eq!(children[0].name, "Child");
assert_eq!(children[0].parent_id, Some(parent_id));
// Rollback child
child_action.rollback(&mut document).unwrap();
let tree = document.get_folder_tree(AssetCategory::Audio);
assert_eq!(tree.folders.len(), 1);
assert_eq!(tree.children_of(&parent_id).len(), 0);
}
#[test]
fn test_create_folder_description() {
let action = CreateFolderAction::new(AssetCategory::Images, "Photos", None);
assert_eq!(action.description(), "Create folder 'Photos'");
}
#[test]
fn test_multiple_categories() {
let mut document = Document::new("Test");
// Create folders in different categories
let mut vector_action = CreateFolderAction::new(AssetCategory::Vector, "Shapes", None);
let mut video_action = CreateFolderAction::new(AssetCategory::Video, "Clips", None);
vector_action.execute(&mut document).unwrap();
video_action.execute(&mut document).unwrap();
// Verify each category has its own tree
let vector_tree = document.get_folder_tree(AssetCategory::Vector);
assert_eq!(vector_tree.root_folders().len(), 1);
assert_eq!(vector_tree.root_folders()[0].name, "Shapes");
let video_tree = document.get_folder_tree(AssetCategory::Video);
assert_eq!(video_tree.root_folders().len(), 1);
assert_eq!(video_tree.root_folders()[0].name, "Clips");
// Other categories should still be empty
let audio_tree = document.get_folder_tree(AssetCategory::Audio);
assert_eq!(audio_tree.folders.len(), 0);
}
}

View File

@ -0,0 +1,452 @@
//! Delete folder action
//!
//! Handles deleting a folder from the asset library with two strategies:
//! - Move contents to parent folder
//! - Delete recursively (folder and all contents)
use crate::action::Action;
use crate::asset_folder::AssetFolder;
use crate::document::{AssetCategory, Document};
use uuid::Uuid;
/// Strategy for handling folder contents during deletion
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteStrategy {
/// Move contents to parent folder before deleting
MoveToParent,
/// Delete folder and all contents recursively
DeleteRecursive,
}
/// Action that deletes a folder
pub struct DeleteFolderAction {
/// Asset category for this folder
category: AssetCategory,
/// Folder ID to delete
folder_id: Uuid,
/// Deletion strategy
strategy: DeleteStrategy,
/// Removed folders (for undo)
removed_folders: Vec<AssetFolder>,
/// Asset IDs that were moved to parent (for MoveToParent strategy)
moved_asset_ids: Vec<Uuid>,
/// Asset IDs that were deleted (for DeleteRecursive strategy)
deleted_asset_ids: Vec<Uuid>,
}
impl DeleteFolderAction {
/// Create a new delete folder action
///
/// # Arguments
///
/// * `category` - Which asset category the folder is in
/// * `folder_id` - ID of the folder to delete
/// * `strategy` - How to handle folder contents
pub fn new(category: AssetCategory, folder_id: Uuid, strategy: DeleteStrategy) -> Self {
Self {
category,
folder_id,
strategy,
removed_folders: Vec::new(),
moved_asset_ids: Vec::new(),
deleted_asset_ids: Vec::new(),
}
}
}
impl Action for DeleteFolderAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
// Get the folder tree
let tree = document.get_folder_tree_mut(self.category);
// Get the folder to check if it exists
let folder = tree
.folders
.get(&self.folder_id)
.ok_or_else(|| format!("Folder {} not found", self.folder_id))?;
let parent_id = folder.parent_id;
match self.strategy {
DeleteStrategy::MoveToParent => {
// Find all assets in this folder and move them to parent
match self.category {
AssetCategory::Vector => {
for (id, clip) in document.vector_clips.iter_mut() {
if clip.folder_id == Some(self.folder_id) {
clip.folder_id = parent_id;
self.moved_asset_ids.push(*id);
}
}
}
AssetCategory::Video => {
for (id, clip) in document.video_clips.iter_mut() {
if clip.folder_id == Some(self.folder_id) {
clip.folder_id = parent_id;
self.moved_asset_ids.push(*id);
}
}
}
AssetCategory::Audio => {
for (id, clip) in document.audio_clips.iter_mut() {
if clip.folder_id == Some(self.folder_id) {
clip.folder_id = parent_id;
self.moved_asset_ids.push(*id);
}
}
}
AssetCategory::Images => {
for (id, asset) in document.image_assets.iter_mut() {
if asset.folder_id == Some(self.folder_id) {
asset.folder_id = parent_id;
self.moved_asset_ids.push(*id);
}
}
}
AssetCategory::Effects => {
for (id, effect) in document.effect_definitions.iter_mut() {
if effect.folder_id == Some(self.folder_id) {
effect.folder_id = parent_id;
self.moved_asset_ids.push(*id);
}
}
}
}
// Find all subfolders and move them to parent
let tree = document.get_folder_tree_mut(self.category);
let subfolder_ids: Vec<Uuid> = tree
.folders
.values()
.filter(|f| f.parent_id == Some(self.folder_id))
.map(|f| f.id)
.collect();
for subfolder_id in subfolder_ids {
if let Some(subfolder) = tree.folders.get_mut(&subfolder_id) {
subfolder.parent_id = parent_id;
}
}
}
DeleteStrategy::DeleteRecursive => {
// Find all assets in this folder and its descendants, and delete them
// First, collect all descendant folder IDs
let tree = document.get_folder_tree(self.category);
let mut to_check = vec![self.folder_id];
let mut all_folder_ids = vec![self.folder_id];
let mut i = 0;
while i < to_check.len() {
let current_id = to_check[i];
for (child_id, child) in &tree.folders {
if child.parent_id == Some(current_id) {
to_check.push(*child_id);
all_folder_ids.push(*child_id);
}
}
i += 1;
}
// Delete all assets in these folders
match self.category {
AssetCategory::Vector => {
let to_delete: Vec<Uuid> = document
.vector_clips
.iter()
.filter(|(_, clip)| {
clip.folder_id
.map(|fid| all_folder_ids.contains(&fid))
.unwrap_or(false)
})
.map(|(id, _)| *id)
.collect();
for id in to_delete {
document.vector_clips.remove(&id);
self.deleted_asset_ids.push(id);
}
}
AssetCategory::Video => {
let to_delete: Vec<Uuid> = document
.video_clips
.iter()
.filter(|(_, clip)| {
clip.folder_id
.map(|fid| all_folder_ids.contains(&fid))
.unwrap_or(false)
})
.map(|(id, _)| *id)
.collect();
for id in to_delete {
document.video_clips.remove(&id);
self.deleted_asset_ids.push(id);
}
}
AssetCategory::Audio => {
let to_delete: Vec<Uuid> = document
.audio_clips
.iter()
.filter(|(_, clip)| {
clip.folder_id
.map(|fid| all_folder_ids.contains(&fid))
.unwrap_or(false)
})
.map(|(id, _)| *id)
.collect();
for id in to_delete {
document.audio_clips.remove(&id);
self.deleted_asset_ids.push(id);
}
}
AssetCategory::Images => {
let to_delete: Vec<Uuid> = document
.image_assets
.iter()
.filter(|(_, asset)| {
asset
.folder_id
.map(|fid| all_folder_ids.contains(&fid))
.unwrap_or(false)
})
.map(|(id, _)| *id)
.collect();
for id in to_delete {
document.image_assets.remove(&id);
self.deleted_asset_ids.push(id);
}
}
AssetCategory::Effects => {
let to_delete: Vec<Uuid> = document
.effect_definitions
.iter()
.filter(|(_, effect)| {
effect
.folder_id
.map(|fid| all_folder_ids.contains(&fid))
.unwrap_or(false)
})
.map(|(id, _)| *id)
.collect();
for id in to_delete {
document.effect_definitions.remove(&id);
self.deleted_asset_ids.push(id);
}
}
}
}
}
// Remove the folder and all descendants
let tree = document.get_folder_tree_mut(self.category);
self.removed_folders = tree.remove_folder(&self.folder_id);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
// Restore all removed folders
let tree = document.get_folder_tree_mut(self.category);
for folder in &self.removed_folders {
tree.add_folder(folder.clone());
}
match self.strategy {
DeleteStrategy::MoveToParent => {
// Restore folder_id for moved assets
match self.category {
AssetCategory::Vector => {
for id in &self.moved_asset_ids {
if let Some(clip) = document.vector_clips.get_mut(id) {
clip.folder_id = Some(self.folder_id);
}
}
}
AssetCategory::Video => {
for id in &self.moved_asset_ids {
if let Some(clip) = document.video_clips.get_mut(id) {
clip.folder_id = Some(self.folder_id);
}
}
}
AssetCategory::Audio => {
for id in &self.moved_asset_ids {
if let Some(clip) = document.audio_clips.get_mut(id) {
clip.folder_id = Some(self.folder_id);
}
}
}
AssetCategory::Images => {
for id in &self.moved_asset_ids {
if let Some(asset) = document.image_assets.get_mut(id) {
asset.folder_id = Some(self.folder_id);
}
}
}
AssetCategory::Effects => {
for id in &self.moved_asset_ids {
if let Some(effect) = document.effect_definitions.get_mut(id) {
effect.folder_id = Some(self.folder_id);
}
}
}
}
}
DeleteStrategy::DeleteRecursive => {
// Note: We can't restore deleted assets as we didn't store them
// In a real implementation, you might want to store the deleted assets too
// For now, this is a limitation - recursive delete is not fully undoable
// if assets are involved. We could improve this in a future iteration.
}
}
// Clear the rollback data
self.removed_folders.clear();
self.moved_asset_ids.clear();
self.deleted_asset_ids.clear();
Ok(())
}
fn description(&self) -> String {
match self.strategy {
DeleteStrategy::MoveToParent => "Delete folder (move contents to parent)".to_string(),
DeleteStrategy::DeleteRecursive => "Delete folder and all contents".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::create_folder::CreateFolderAction;
use crate::clip::VectorClip;
#[test]
fn test_delete_empty_folder() {
let mut document = Document::new("Test");
// Create a folder
let mut create_action =
CreateFolderAction::new(AssetCategory::Vector, "Empty Folder", None);
create_action.execute(&mut document).unwrap();
let folder_id = create_action.created_folder_id().unwrap();
// Delete it
let mut delete_action =
DeleteFolderAction::new(AssetCategory::Vector, folder_id, DeleteStrategy::MoveToParent);
delete_action.execute(&mut document).unwrap();
// Verify folder was deleted
let tree = document.get_folder_tree(AssetCategory::Vector);
assert_eq!(tree.folders.len(), 0);
// Rollback
delete_action.rollback(&mut document).unwrap();
// Verify folder was restored
let tree = document.get_folder_tree(AssetCategory::Vector);
assert_eq!(tree.folders.len(), 1);
assert_eq!(tree.folders[&folder_id].name, "Empty Folder");
}
#[test]
fn test_delete_folder_move_to_parent() {
let mut document = Document::new("Test");
// Create a folder
let mut create_action =
CreateFolderAction::new(AssetCategory::Vector, "Folder", None);
create_action.execute(&mut document).unwrap();
let folder_id = create_action.created_folder_id().unwrap();
// Add a clip to the folder
let mut clip = VectorClip::new("Test Clip", 100.0, 100.0, 5.0);
clip.folder_id = Some(folder_id);
let clip_id = clip.id;
document.vector_clips.insert(clip_id, clip);
// Delete folder with MoveToParent strategy
let mut delete_action =
DeleteFolderAction::new(AssetCategory::Vector, folder_id, DeleteStrategy::MoveToParent);
delete_action.execute(&mut document).unwrap();
// Verify folder was deleted
let tree = document.get_folder_tree(AssetCategory::Vector);
assert_eq!(tree.folders.len(), 0);
// Verify clip was moved to root (folder_id = None)
assert_eq!(document.vector_clips[&clip_id].folder_id, None);
// Rollback
delete_action.rollback(&mut document).unwrap();
// Verify folder was restored
let tree = document.get_folder_tree(AssetCategory::Vector);
assert_eq!(tree.folders.len(), 1);
// Verify clip is back in folder
assert_eq!(
document.vector_clips[&clip_id].folder_id,
Some(folder_id)
);
}
#[test]
fn test_delete_folder_with_subfolders() {
let mut document = Document::new("Test");
// Create parent folder
let mut parent_action =
CreateFolderAction::new(AssetCategory::Audio, "Parent", None);
parent_action.execute(&mut document).unwrap();
let parent_id = parent_action.created_folder_id().unwrap();
// Create child folder
let mut child_action =
CreateFolderAction::new(AssetCategory::Audio, "Child", Some(parent_id));
child_action.execute(&mut document).unwrap();
let child_id = child_action.created_folder_id().unwrap();
// Delete parent with MoveToParent (moves child to root)
let mut delete_action =
DeleteFolderAction::new(AssetCategory::Audio, parent_id, DeleteStrategy::MoveToParent);
delete_action.execute(&mut document).unwrap();
// Verify parent was deleted
let tree = document.get_folder_tree(AssetCategory::Audio);
assert!(!tree.folders.contains_key(&parent_id));
// Verify child was moved to root
assert_eq!(tree.folders[&child_id].parent_id, None);
// Rollback
delete_action.rollback(&mut document).unwrap();
// Verify both folders restored
let tree = document.get_folder_tree(AssetCategory::Audio);
assert_eq!(tree.folders.len(), 2);
assert_eq!(tree.folders[&child_id].parent_id, Some(parent_id));
}
#[test]
fn test_delete_nonexistent_folder() {
let mut document = Document::new("Test");
let fake_id = Uuid::new_v4();
let mut action =
DeleteFolderAction::new(AssetCategory::Images, fake_id, DeleteStrategy::MoveToParent);
let result = action.execute(&mut document);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
}

View File

@ -20,6 +20,10 @@ pub mod split_clip_instance;
pub mod transform_clip_instances;
pub mod transform_objects;
pub mod trim_clip_instances;
pub mod create_folder;
pub mod rename_folder;
pub mod delete_folder;
pub mod move_asset_to_folder;
pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
@ -38,3 +42,7 @@ pub use split_clip_instance::SplitClipInstanceAction;
pub use transform_clip_instances::TransformClipInstancesAction;
pub use transform_objects::TransformShapeInstancesAction;
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
pub use create_folder::CreateFolderAction;
pub use rename_folder::RenameFolderAction;
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
pub use move_asset_to_folder::MoveAssetToFolderAction;

View File

@ -0,0 +1,319 @@
//! Move asset to folder action
//!
//! Handles moving an asset between folders in the asset library.
use crate::action::Action;
use crate::document::{AssetCategory, Document};
use uuid::Uuid;
/// Action that moves an asset to a different folder
pub struct MoveAssetToFolderAction {
/// Asset category
category: AssetCategory,
/// Asset ID to move
asset_id: Uuid,
/// New folder ID (None = move to root)
new_folder_id: Option<Uuid>,
/// Old folder ID (stored after execution for rollback)
old_folder_id: Option<Option<Uuid>>,
}
impl MoveAssetToFolderAction {
/// Create a new move asset to folder action
///
/// # Arguments
///
/// * `category` - Which asset category the asset is in
/// * `asset_id` - ID of the asset to move
/// * `new_folder_id` - ID of the destination folder (None = root)
pub fn new(
category: AssetCategory,
asset_id: Uuid,
new_folder_id: Option<Uuid>,
) -> Self {
Self {
category,
asset_id,
new_folder_id,
old_folder_id: None,
}
}
}
impl Action for MoveAssetToFolderAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
// Validate that the destination folder exists (if specified)
if let Some(folder_id) = self.new_folder_id {
let tree = document.get_folder_tree(self.category);
if !tree.folders.contains_key(&folder_id) {
return Err(format!("Destination folder {} not found", folder_id));
}
}
// Find the asset and update its folder_id
match self.category {
AssetCategory::Vector => {
let clip = document
.vector_clips
.get_mut(&self.asset_id)
.ok_or_else(|| format!("Vector clip {} not found", self.asset_id))?;
self.old_folder_id = Some(clip.folder_id);
clip.folder_id = self.new_folder_id;
}
AssetCategory::Video => {
let clip = document
.video_clips
.get_mut(&self.asset_id)
.ok_or_else(|| format!("Video clip {} not found", self.asset_id))?;
self.old_folder_id = Some(clip.folder_id);
clip.folder_id = self.new_folder_id;
}
AssetCategory::Audio => {
let clip = document
.audio_clips
.get_mut(&self.asset_id)
.ok_or_else(|| format!("Audio clip {} not found", self.asset_id))?;
self.old_folder_id = Some(clip.folder_id);
clip.folder_id = self.new_folder_id;
}
AssetCategory::Images => {
let asset = document
.image_assets
.get_mut(&self.asset_id)
.ok_or_else(|| format!("Image asset {} not found", self.asset_id))?;
self.old_folder_id = Some(asset.folder_id);
asset.folder_id = self.new_folder_id;
}
AssetCategory::Effects => {
let effect = document
.effect_definitions
.get_mut(&self.asset_id)
.ok_or_else(|| format!("Effect definition {} not found", self.asset_id))?;
self.old_folder_id = Some(effect.folder_id);
effect.folder_id = self.new_folder_id;
}
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
// Restore the old folder_id
if let Some(old_folder_id) = self.old_folder_id {
match self.category {
AssetCategory::Vector => {
if let Some(clip) = document.vector_clips.get_mut(&self.asset_id) {
clip.folder_id = old_folder_id;
}
}
AssetCategory::Video => {
if let Some(clip) = document.video_clips.get_mut(&self.asset_id) {
clip.folder_id = old_folder_id;
}
}
AssetCategory::Audio => {
if let Some(clip) = document.audio_clips.get_mut(&self.asset_id) {
clip.folder_id = old_folder_id;
}
}
AssetCategory::Images => {
if let Some(asset) = document.image_assets.get_mut(&self.asset_id) {
asset.folder_id = old_folder_id;
}
}
AssetCategory::Effects => {
if let Some(effect) = document.effect_definitions.get_mut(&self.asset_id) {
effect.folder_id = old_folder_id;
}
}
}
}
Ok(())
}
fn description(&self) -> String {
if self.new_folder_id.is_some() {
"Move asset to folder".to_string()
} else {
"Move asset to root".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::create_folder::CreateFolderAction;
use crate::clip::VectorClip;
#[test]
fn test_move_asset_to_folder() {
let mut document = Document::new("Test");
// Create a folder
let mut create_action =
CreateFolderAction::new(AssetCategory::Vector, "My Folder", None);
create_action.execute(&mut document).unwrap();
let folder_id = create_action.created_folder_id().unwrap();
// Create a clip at root
let clip = VectorClip::new("Test Clip", 100.0, 100.0, 5.0);
let clip_id = clip.id;
document.vector_clips.insert(clip_id, clip);
// Verify clip is at root
assert_eq!(document.vector_clips[&clip_id].folder_id, None);
// Move clip to folder
let mut move_action =
MoveAssetToFolderAction::new(AssetCategory::Vector, clip_id, Some(folder_id));
move_action.execute(&mut document).unwrap();
// Verify clip moved
assert_eq!(
document.vector_clips[&clip_id].folder_id,
Some(folder_id)
);
// Rollback
move_action.rollback(&mut document).unwrap();
// Verify clip back at root
assert_eq!(document.vector_clips[&clip_id].folder_id, None);
}
#[test]
fn test_move_asset_to_root() {
let mut document = Document::new("Test");
// Create a folder
let mut create_action =
CreateFolderAction::new(AssetCategory::Video, "Videos", None);
create_action.execute(&mut document).unwrap();
let folder_id = create_action.created_folder_id().unwrap();
// Create a clip in the folder
let mut clip = crate::clip::VideoClip::new(
"Test Video",
"test.mp4",
1920.0,
1080.0,
10.0,
30.0,
);
clip.folder_id = Some(folder_id);
let clip_id = clip.id;
document.video_clips.insert(clip_id, clip);
// Move clip to root
let mut move_action = MoveAssetToFolderAction::new(AssetCategory::Video, clip_id, None);
move_action.execute(&mut document).unwrap();
// Verify clip at root
assert_eq!(document.video_clips[&clip_id].folder_id, None);
// Rollback
move_action.rollback(&mut document).unwrap();
// Verify clip back in folder
assert_eq!(
document.video_clips[&clip_id].folder_id,
Some(folder_id)
);
}
#[test]
fn test_move_asset_between_folders() {
let mut document = Document::new("Test");
// Create two folders
let mut folder1_action =
CreateFolderAction::new(AssetCategory::Audio, "Folder 1", None);
folder1_action.execute(&mut document).unwrap();
let folder1_id = folder1_action.created_folder_id().unwrap();
let mut folder2_action =
CreateFolderAction::new(AssetCategory::Audio, "Folder 2", None);
folder2_action.execute(&mut document).unwrap();
let folder2_id = folder2_action.created_folder_id().unwrap();
// Create a clip in folder 1
let mut clip = crate::clip::AudioClip::new_sampled("Test Audio", 5.0, 0);
clip.folder_id = Some(folder1_id);
let clip_id = clip.id;
document.audio_clips.insert(clip_id, clip);
// Move to folder 2
let mut move_action =
MoveAssetToFolderAction::new(AssetCategory::Audio, clip_id, Some(folder2_id));
move_action.execute(&mut document).unwrap();
// Verify in folder 2
assert_eq!(
document.audio_clips[&clip_id].folder_id,
Some(folder2_id)
);
// Rollback
move_action.rollback(&mut document).unwrap();
// Verify back in folder 1
assert_eq!(
document.audio_clips[&clip_id].folder_id,
Some(folder1_id)
);
}
#[test]
fn test_move_to_nonexistent_folder() {
let mut document = Document::new("Test");
// Create a clip
let clip = VectorClip::new("Test", 100.0, 100.0, 5.0);
let clip_id = clip.id;
document.vector_clips.insert(clip_id, clip);
// Try to move to nonexistent folder
let fake_folder_id = Uuid::new_v4();
let mut action =
MoveAssetToFolderAction::new(AssetCategory::Vector, clip_id, Some(fake_folder_id));
let result = action.execute(&mut document);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_move_nonexistent_asset() {
let mut document = Document::new("Test");
let fake_asset_id = Uuid::new_v4();
let mut action = MoveAssetToFolderAction::new(AssetCategory::Images, fake_asset_id, None);
let result = action.execute(&mut document);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_move_description() {
let asset_id = Uuid::new_v4();
let folder_id = Uuid::new_v4();
let action1 =
MoveAssetToFolderAction::new(AssetCategory::Effects, asset_id, Some(folder_id));
assert_eq!(action1.description(), "Move asset to folder");
let action2 = MoveAssetToFolderAction::new(AssetCategory::Effects, asset_id, None);
assert_eq!(action2.description(), "Move asset to root");
}
}

View File

@ -0,0 +1,173 @@
//! Rename folder action
//!
//! Handles renaming a folder in the asset library.
use crate::action::Action;
use crate::document::{AssetCategory, Document};
use uuid::Uuid;
/// Action that renames a folder
pub struct RenameFolderAction {
/// Asset category for this folder
category: AssetCategory,
/// Folder ID to rename
folder_id: Uuid,
/// New folder name
new_name: String,
/// Old folder name (stored after execution for rollback)
old_name: Option<String>,
}
impl RenameFolderAction {
/// Create a new rename folder action
///
/// # Arguments
///
/// * `category` - Which asset category the folder is in
/// * `folder_id` - ID of the folder to rename
/// * `new_name` - The new name for the folder
pub fn new(category: AssetCategory, folder_id: Uuid, new_name: impl Into<String>) -> Self {
Self {
category,
folder_id,
new_name: new_name.into(),
old_name: None,
}
}
}
impl Action for RenameFolderAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
// Get the folder tree
let tree = document.get_folder_tree_mut(self.category);
// Get the folder
let folder = tree
.folders
.get_mut(&self.folder_id)
.ok_or_else(|| format!("Folder {} not found", self.folder_id))?;
// Store old name for rollback
self.old_name = Some(folder.name.clone());
// Update the name
folder.name = self.new_name.clone();
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
// Get the folder tree
let tree = document.get_folder_tree_mut(self.category);
// Get the folder
let folder = tree
.folders
.get_mut(&self.folder_id)
.ok_or_else(|| format!("Folder {} not found", self.folder_id))?;
// Restore old name
if let Some(old_name) = &self.old_name {
folder.name = old_name.clone();
}
Ok(())
}
fn description(&self) -> String {
format!("Rename folder to '{}'", self.new_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::create_folder::CreateFolderAction;
#[test]
fn test_rename_folder() {
let mut document = Document::new("Test");
// Create a folder first
let mut create_action =
CreateFolderAction::new(AssetCategory::Vector, "Original Name", None);
create_action.execute(&mut document).unwrap();
let folder_id = create_action.created_folder_id().unwrap();
// Rename it
let mut rename_action =
RenameFolderAction::new(AssetCategory::Vector, folder_id, "New Name");
rename_action.execute(&mut document).unwrap();
// Verify name changed
let tree = document.get_folder_tree(AssetCategory::Vector);
let folder = &tree.folders[&folder_id];
assert_eq!(folder.name, "New Name");
// Rollback
rename_action.rollback(&mut document).unwrap();
// Verify name restored
let tree = document.get_folder_tree(AssetCategory::Vector);
let folder = &tree.folders[&folder_id];
assert_eq!(folder.name, "Original Name");
}
#[test]
fn test_rename_nonexistent_folder() {
let mut document = Document::new("Test");
let fake_id = Uuid::new_v4();
let mut action = RenameFolderAction::new(AssetCategory::Audio, fake_id, "New Name");
let result = action.execute(&mut document);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_rename_description() {
let folder_id = Uuid::new_v4();
let action = RenameFolderAction::new(AssetCategory::Images, folder_id, "Photos 2024");
assert_eq!(action.description(), "Rename folder to 'Photos 2024'");
}
#[test]
fn test_multiple_renames() {
let mut document = Document::new("Test");
// Create a folder
let mut create_action = CreateFolderAction::new(AssetCategory::Effects, "V1", None);
create_action.execute(&mut document).unwrap();
let folder_id = create_action.created_folder_id().unwrap();
// Rename multiple times
let mut rename1 = RenameFolderAction::new(AssetCategory::Effects, folder_id, "V2");
let mut rename2 = RenameFolderAction::new(AssetCategory::Effects, folder_id, "V3");
let mut rename3 = RenameFolderAction::new(AssetCategory::Effects, folder_id, "Final");
rename1.execute(&mut document).unwrap();
rename2.execute(&mut document).unwrap();
rename3.execute(&mut document).unwrap();
// Verify final name
let tree = document.get_folder_tree(AssetCategory::Effects);
assert_eq!(tree.folders[&folder_id].name, "Final");
// Rollback in reverse order
rename3.rollback(&mut document).unwrap();
let tree = document.get_folder_tree(AssetCategory::Effects);
assert_eq!(tree.folders[&folder_id].name, "V3");
rename2.rollback(&mut document).unwrap();
let tree = document.get_folder_tree(AssetCategory::Effects);
assert_eq!(tree.folders[&folder_id].name, "V2");
rename1.rollback(&mut document).unwrap();
let tree = document.get_folder_tree(AssetCategory::Effects);
assert_eq!(tree.folders[&folder_id].name, "V1");
}
}

View File

@ -0,0 +1,263 @@
//! Asset library folder organization
//!
//! Provides hierarchical folder structure for organizing assets in the library.
//! Each asset category (Vector, Video, Audio, Images, Effects) has its own
//! independent folder tree with unlimited nesting depth.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
/// Metadata for an asset library folder
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct AssetFolder {
/// Unique identifier
pub id: Uuid,
/// Folder name
pub name: String,
/// Parent folder ID (None for root-level folders)
pub parent_id: Option<Uuid>,
}
impl AssetFolder {
/// Create a new folder
pub fn new(name: impl Into<String>, parent_id: Option<Uuid>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
parent_id,
}
}
}
/// Folder tree for a specific asset category
///
/// Uses a flat HashMap for efficient O(1) lookup, with parent_id references
/// for hierarchy. This matches the Document's asset storage pattern.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct AssetFolderTree {
/// All folders in this category, keyed by ID
pub folders: HashMap<Uuid, AssetFolder>,
}
impl AssetFolderTree {
/// Create a new empty folder tree
pub fn new() -> Self {
Self::default()
}
/// Add a folder to the tree
///
/// Returns the folder's ID for convenience
pub fn add_folder(&mut self, folder: AssetFolder) -> Uuid {
let id = folder.id;
self.folders.insert(id, folder);
id
}
/// Remove a folder and all its children recursively
///
/// Returns the removed folder and all descendants for undo support
pub fn remove_folder(&mut self, folder_id: &Uuid) -> Vec<AssetFolder> {
let mut removed = Vec::new();
// Collect all descendant IDs using breadth-first traversal
let mut to_remove = vec![*folder_id];
let mut i = 0;
while i < to_remove.len() {
let current_id = to_remove[i];
// Find children of current folder
for (child_id, child) in &self.folders {
if child.parent_id == Some(current_id) {
to_remove.push(*child_id);
}
}
i += 1;
}
// Remove all collected folders
for id in to_remove {
if let Some(folder) = self.folders.remove(&id) {
removed.push(folder);
}
}
removed
}
/// Get all root folders (folders with no parent)
///
/// Returns folders sorted alphabetically by name (case-insensitive)
pub fn root_folders(&self) -> Vec<&AssetFolder> {
let mut roots: Vec<_> = self.folders.values()
.filter(|f| f.parent_id.is_none())
.collect();
roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
roots
}
/// Get children of a specific folder
///
/// Returns folders sorted alphabetically by name (case-insensitive)
pub fn children_of(&self, parent_id: &Uuid) -> Vec<&AssetFolder> {
let mut children: Vec<_> = self.folders.values()
.filter(|f| f.parent_id == Some(*parent_id))
.collect();
children.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
children
}
/// Get the full path from root to a folder
///
/// Returns a vector of folder IDs starting from root and ending at the specified folder
pub fn path_to_folder(&self, folder_id: &Uuid) -> Vec<Uuid> {
let mut path = Vec::new();
let mut current_id = Some(*folder_id);
// Walk up the tree from folder to root
while let Some(id) = current_id {
path.insert(0, id);
current_id = self.folders.get(&id).and_then(|f| f.parent_id);
}
path
}
/// Check if folder_a is a descendant of folder_b (or the same folder)
///
/// Used to prevent circular references when moving folders
pub fn is_descendant_of(&self, folder_a: &Uuid, folder_b: &Uuid) -> bool {
if folder_a == folder_b {
return true;
}
let mut current_id = self.folders.get(folder_a).and_then(|f| f.parent_id);
// Walk up from folder_a, looking for folder_b
while let Some(id) = current_id {
if id == *folder_b {
return true;
}
current_id = self.folders.get(&id).and_then(|f| f.parent_id);
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_folder() {
let folder = AssetFolder::new("Test Folder", None);
assert_eq!(folder.name, "Test Folder");
assert_eq!(folder.parent_id, None);
}
#[test]
fn test_add_and_get_root_folders() {
let mut tree = AssetFolderTree::new();
let folder1 = AssetFolder::new("Folder B", None);
let folder2 = AssetFolder::new("Folder A", None);
tree.add_folder(folder1);
tree.add_folder(folder2);
let roots = tree.root_folders();
assert_eq!(roots.len(), 2);
// Should be sorted alphabetically
assert_eq!(roots[0].name, "Folder A");
assert_eq!(roots[1].name, "Folder B");
}
#[test]
fn test_children_of() {
let mut tree = AssetFolderTree::new();
let parent = AssetFolder::new("Parent", None);
let parent_id = tree.add_folder(parent);
let child1 = AssetFolder::new("Child B", Some(parent_id));
let child2 = AssetFolder::new("Child A", Some(parent_id));
tree.add_folder(child1);
tree.add_folder(child2);
let children = tree.children_of(&parent_id);
assert_eq!(children.len(), 2);
// Should be sorted alphabetically
assert_eq!(children[0].name, "Child A");
assert_eq!(children[1].name, "Child B");
}
#[test]
fn test_path_to_folder() {
let mut tree = AssetFolderTree::new();
let root = AssetFolder::new("Root", None);
let root_id = tree.add_folder(root);
let child = AssetFolder::new("Child", Some(root_id));
let child_id = tree.add_folder(child);
let grandchild = AssetFolder::new("Grandchild", Some(child_id));
let grandchild_id = tree.add_folder(grandchild);
let path = tree.path_to_folder(&grandchild_id);
assert_eq!(path, vec![root_id, child_id, grandchild_id]);
}
#[test]
fn test_is_descendant_of() {
let mut tree = AssetFolderTree::new();
let root = AssetFolder::new("Root", None);
let root_id = tree.add_folder(root);
let child = AssetFolder::new("Child", Some(root_id));
let child_id = tree.add_folder(child);
let grandchild = AssetFolder::new("Grandchild", Some(child_id));
let grandchild_id = tree.add_folder(grandchild);
// Grandchild is descendant of child
assert!(tree.is_descendant_of(&grandchild_id, &child_id));
// Grandchild is descendant of root
assert!(tree.is_descendant_of(&grandchild_id, &root_id));
// Child is not descendant of grandchild
assert!(!tree.is_descendant_of(&child_id, &grandchild_id));
// Folder is descendant of itself
assert!(tree.is_descendant_of(&child_id, &child_id));
}
#[test]
fn test_remove_folder_recursive() {
let mut tree = AssetFolderTree::new();
let root = AssetFolder::new("Root", None);
let root_id = tree.add_folder(root);
let child1 = AssetFolder::new("Child1", Some(root_id));
let child1_id = tree.add_folder(child1);
let child2 = AssetFolder::new("Child2", Some(root_id));
tree.add_folder(child2);
let grandchild = AssetFolder::new("Grandchild", Some(child1_id));
tree.add_folder(grandchild);
// Remove root folder (should remove all descendants)
let removed = tree.remove_folder(&root_id);
assert_eq!(removed.len(), 4); // root + 2 children + 1 grandchild
assert_eq!(tree.folders.len(), 0);
}
}

View File

@ -43,6 +43,10 @@ pub struct VectorClip {
/// Nested layer hierarchy
pub layers: LayerTree<AnyLayer>,
/// Folder this clip belongs to (None = root of category)
#[serde(default)]
pub folder_id: Option<Uuid>,
}
impl VectorClip {
@ -55,6 +59,7 @@ impl VectorClip {
height,
duration,
layers: LayerTree::new(),
folder_id: None,
}
}
@ -73,6 +78,7 @@ impl VectorClip {
height,
duration,
layers: LayerTree::new(),
folder_id: None,
}
}
@ -185,6 +191,10 @@ pub struct ImageAsset {
/// If None, the image will be loaded from path when needed
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Vec<u8>>,
/// Folder this asset belongs to (None = root of category)
#[serde(default)]
pub folder_id: Option<Uuid>,
}
impl ImageAsset {
@ -202,6 +212,7 @@ impl ImageAsset {
width,
height,
data: None,
folder_id: None,
}
}
@ -220,6 +231,7 @@ impl ImageAsset {
width,
height,
data: Some(data),
folder_id: None,
}
}
}
@ -252,6 +264,10 @@ pub struct VideoClip {
/// When set, the audio clip should be moved/trimmed in sync with this video clip
#[serde(default, skip_serializing_if = "Option::is_none")]
pub linked_audio_clip_id: Option<Uuid>,
/// Folder this clip belongs to (None = root of category)
#[serde(default)]
pub folder_id: Option<Uuid>,
}
impl VideoClip {
@ -273,6 +289,7 @@ impl VideoClip {
duration,
frame_rate,
linked_audio_clip_id: None,
folder_id: None,
}
}
}
@ -366,6 +383,10 @@ pub struct AudioClip {
/// Audio clip type (sampled or MIDI)
pub clip_type: AudioClipType,
/// Folder this clip belongs to (None = root of category)
#[serde(default)]
pub folder_id: Option<Uuid>,
}
impl AudioClip {
@ -381,6 +402,7 @@ impl AudioClip {
name: name.into(),
duration,
clip_type: AudioClipType::Sampled { audio_pool_index },
folder_id: None,
}
}
@ -400,6 +422,7 @@ impl AudioClip {
name: name.into(),
duration,
clip_type: AudioClipType::Midi { midi_clip_id },
folder_id: None,
}
}

View File

@ -3,6 +3,7 @@
//! The Document represents a complete animation project with settings
//! and a root graphics object containing the scene graph.
use crate::asset_folder::AssetFolderTree;
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
use crate::effect::EffectDefinition;
use crate::layer::AnyLayer;
@ -68,6 +69,16 @@ impl Default for GraphicsObject {
}
}
/// Asset category for folder tree access
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetCategory {
Vector,
Video,
Audio,
Images,
Effects,
}
/// Document settings and scene
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Document {
@ -115,6 +126,26 @@ pub struct Document {
#[serde(default)]
pub effect_definitions: HashMap<Uuid, EffectDefinition>,
/// Folder organization for vector clips
#[serde(default)]
pub vector_folders: AssetFolderTree,
/// Folder organization for video clips
#[serde(default)]
pub video_folders: AssetFolderTree,
/// Folder organization for audio clips
#[serde(default)]
pub audio_folders: AssetFolderTree,
/// Folder organization for image assets
#[serde(default)]
pub image_folders: AssetFolderTree,
/// Folder organization for effect definitions
#[serde(default)]
pub effect_folders: AssetFolderTree,
/// Current UI layout state (serialized for save/load)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_layout: Option<LayoutNode>,
@ -145,6 +176,11 @@ impl Default for Document {
image_assets: HashMap::new(),
instance_groups: HashMap::new(),
effect_definitions: HashMap::new(),
vector_folders: AssetFolderTree::new(),
video_folders: AssetFolderTree::new(),
audio_folders: AssetFolderTree::new(),
image_folders: AssetFolderTree::new(),
effect_folders: AssetFolderTree::new(),
ui_layout: None,
ui_layout_base: None,
current_time: 0.0,
@ -194,6 +230,28 @@ impl Document {
self.width / self.height
}
/// Get the folder tree for a specific asset category
pub fn get_folder_tree(&self, category: AssetCategory) -> &AssetFolderTree {
match category {
AssetCategory::Vector => &self.vector_folders,
AssetCategory::Video => &self.video_folders,
AssetCategory::Audio => &self.audio_folders,
AssetCategory::Images => &self.image_folders,
AssetCategory::Effects => &self.effect_folders,
}
}
/// Get a mutable reference to the folder tree for a specific asset category
pub fn get_folder_tree_mut(&mut self, category: AssetCategory) -> &mut AssetFolderTree {
match category {
AssetCategory::Vector => &mut self.vector_folders,
AssetCategory::Video => &mut self.video_folders,
AssetCategory::Audio => &mut self.audio_folders,
AssetCategory::Images => &mut self.image_folders,
AssetCategory::Effects => &mut self.effect_folders,
}
}
/// Calculate the actual timeline endpoint based on the last clip
///
/// Returns the end time of the last clip instance across all layers,

View File

@ -284,6 +284,10 @@ pub struct EffectDefinition {
pub inputs: Vec<EffectInput>,
/// Parameter definitions
pub parameters: Vec<EffectParameterDef>,
/// Folder this effect belongs to (None = root of category)
#[serde(default)]
pub folder_id: Option<Uuid>,
}
impl EffectDefinition {
@ -302,6 +306,7 @@ impl EffectDefinition {
shader_code: shader_code.into(),
inputs: vec![EffectInput::composition("source")],
parameters,
folder_id: None,
}
}
@ -315,6 +320,7 @@ impl EffectDefinition {
shader_code: shader_code.into(),
inputs: vec![EffectInput::composition("source")],
parameters,
folder_id: None,
}
}

View File

@ -15,6 +15,7 @@ pub mod object;
pub mod layer;
pub mod layer_tree;
pub mod clip;
pub mod asset_folder;
pub mod instance_group;
pub mod effect;
pub mod effect_layer;

17
src/assets/folder.svg Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
id="svg-folder"
height="16"
width="16"
version="1.1"
viewBox="0 0 16 16">
<path
id="folder-path"
style="fill:#ffc864;stroke:none"
d="M 1,3 C 1,2.5 1.5,2 2,2 h 4 l 1.5,1.5 H 14 c 0.5,0 1,0.5 1,1 V 13 c 0,0.5 -0.5,1 -1,1 H 2 C 1.5,14 1,13.5 1,13 Z" />
<path
id="folder-tab"
style="fill:#ffb83d;stroke:none"
d="M 1,4.5 C 1,4 1.5,3.5 2,3.5 h 5.5 l 1,1 H 14 c 0.5,0 1,0.5 1,1 V 5.5 H 1 Z" />
</svg>

After

Width:  |  Height:  |  Size: 541 B