From 693e2df5eb4c35382ac1dfe40ca65732874f2cd0 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 16 Aug 2026 15:05:01 +0800 Subject: [PATCH] feat(app): project browser on real project data + drag-drop import (M12 P3) - ProjectDataSource reads the real bin tree (roots/ children via the facade folder/footage enumeration) - AppEngine::import_footage implemented (facade project_import_footage, last_error surfaced); double-click opens in the source viewer - dragging files onto the browser imports them (also fixes a real gpui bug: ExternalPaths arrives as the bare type, not Arc-wrapped, so drops never fired) - tests: real-engine import->browser listing, widget drop routing, facade folder enumeration failure matrix --- crates/oakengine/src/test_support/node.rs | 65 +++++++++++++++++--- src/app.rs | 51 ++++++++++++++- src/oakui/ffi.rs | 4 ++ src/oakui/real.rs | 75 +++++++++++++++++++++++ src/panels/project_explorer.rs | 19 +++++- 5 files changed, 204 insertions(+), 10 deletions(-) diff --git a/crates/oakengine/src/test_support/node.rs b/crates/oakengine/src/test_support/node.rs index 9e0a52c6f..320a21573 100644 --- a/crates/oakengine/src/test_support/node.rs +++ b/crates/oakengine/src/test_support/node.rs @@ -29,18 +29,20 @@ use std::ffi::{c_char, c_int}; use crate::node::{ oakengine_footage_borrow, oakengine_footage_last_error, oakengine_footage_probe, + oakengine_folder_add_child, oakengine_folder_item_child, oakengine_folder_item_child_count, oakengine_node_connect, oakengine_node_disconnect, oakengine_node_factory_create_from_id, oakengine_node_factory_id_count, oakengine_node_factory_name_from_id, oakengine_node_factory_node_at, oakengine_node_get_input, oakengine_node_get_input_at_time, oakengine_node_get_label, oakengine_node_get_name, oakengine_node_get_type_id, - oakengine_node_input_get_type, oakengine_node_input_id, oakengine_node_input_is_connected, - oakengine_node_is_clip, oakengine_node_is_folder, oakengine_node_is_track, - oakengine_node_is_viewer_output, oakengine_node_keyframe_count, oakengine_node_set_input, - oakengine_node_set_input_at_time, oakengine_node_set_label, oakengine_project_add_node, - oakengine_project_create, oakengine_project_filename, oakengine_project_free, - oakengine_project_import_footage, oakengine_project_load, oakengine_project_name, - oakengine_project_new, oakengine_project_node_at, oakengine_project_node_count, - oakengine_project_save, oakengine_project_set_filename, OakNodeValue, + oakengine_node_identity, oakengine_node_input_get_type, oakengine_node_input_id, + oakengine_node_input_is_connected, oakengine_node_is_clip, oakengine_node_is_folder, + oakengine_node_is_track, oakengine_node_is_viewer_output, oakengine_node_keyframe_count, + oakengine_node_free, oakengine_node_set_input, oakengine_node_set_input_at_time, oakengine_node_set_label, + oakengine_project_add_node, oakengine_project_create, oakengine_project_filename, + oakengine_project_free, oakengine_project_import_footage, oakengine_project_load, + oakengine_project_name, oakengine_project_new, oakengine_project_node_at, + oakengine_project_node_count, oakengine_project_root, oakengine_project_save, + oakengine_project_set_filename, OakNodeValue, }; /// Registered generator node ids used by the tests. @@ -214,6 +216,53 @@ fn project_node_keyframe_lifecycle() { assert_eq!(unsafe { oakengine_node_is_folder(solid) }, 0); assert_eq!(unsafe { oakengine_node_is_viewer_output(solid) }, 0); + // ---- the project browser's folder-tree walk (M12 P3) ----------------- + // The traversal exports the material bin walks: `project_root` resolves + // the root folder and `folder_item_child_count` / `folder_item_child` + // enumerate its children (folders and media alike), all resolved by the + // nodes' stable identities. The added nodes sit in the graph only until + // an explicit graft (the same FolderAddChild the footage import uses). + let root = unsafe { oakengine_project_root(project) }; + assert!(!root.is_null(), "a new project has a root folder"); + assert_eq!(unsafe { oakengine_node_is_folder(root) }, 1); + assert_eq!(unsafe { oakengine_folder_item_child_count(root) }, 0); + + // Graft two graph nodes under the root folder. + assert_eq!(unsafe { oakengine_folder_add_child(root, solid) }, 0); + assert_eq!(unsafe { oakengine_folder_add_child(root, transform) }, 0); + assert_eq!( + unsafe { oakengine_folder_item_child_count(root) }, + 2, + "the grafted nodes are listed under the root folder" + ); + let solid_id = unsafe { oakengine_node_identity(solid) }; + let mut seen_solid = false; + for i in 0..2 { + let child = unsafe { oakengine_folder_item_child(root, i) }; + assert!(!child.is_null(), "child {i} resolves"); + assert!(unsafe { oakengine_node_identity(child) } != 0); + if unsafe { oakengine_node_identity(child) } == solid_id { + seen_solid = true; + // The child carries the node's factory type id. + let len = unsafe { oakengine_node_get_type_id(child, buf.as_mut_ptr(), 256) }; + assert!(len > 0); + assert_eq!(unsafe { read_buf(&mut buf) }, TYPE_ID_SOLID); + assert_eq!(unsafe { oakengine_node_is_folder(child) }, 0); + } + unsafe { oakengine_node_free(child) }; + } + assert!(seen_solid, "the solid generator is a root child"); + + // Failure paths: NULL project/folder, out-of-range index, non-folder + // handles (the app never hands those to the browser). + assert!(unsafe { oakengine_project_root(std::ptr::null_mut()) }.is_null()); + assert_eq!(unsafe { oakengine_folder_item_child_count(std::ptr::null()) }, 0); + assert!(unsafe { oakengine_folder_item_child(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_folder_item_child(root, 99) }.is_null()); + assert_eq!(unsafe { oakengine_folder_item_child_count(solid) }, 0); + assert!(unsafe { oakengine_folder_item_child(solid, 0) }.is_null()); + unsafe { oakengine_node_free(root) }; + // ---- undoable label + readback -------------------------------------- assert_eq!( unsafe { oakengine_node_set_label(solid, c"My Solid".as_ptr()) }, diff --git a/src/app.rs b/src/app.rs index 13e14c09d..c09148509 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1553,7 +1553,7 @@ fn run_with(args: AppArgs) { mod tests { use super::*; use crate::oakui::EngineGateway as _; - use gpui::{px, size, TestAppContext}; + use gpui::{px, size, ExternalPaths, FileDropEvent, TestAppContext, VisualTestContext}; /// The 视图/View menu carries a 语言/Language submenu whose items are /// labeled in their own language and whose checkmark follows the active @@ -1779,6 +1779,55 @@ mod tests { assert_eq!(imported, 1, "a cancelled picker imports nothing"); } + /// Dragging files onto the project explorer routes them to the engine's + /// import: the explorer emits `FileDropRequested` on a real drop and the + /// panel's subscription calls `AppEngine::import_footage` per path (the + /// mock engine records them). The drop is simulated as the platform + /// delivers it — the OS drag entering the window, then the release. + #[gpui::test] + async fn dropping_files_onto_the_project_browser_imports_them(cx: &mut TestAppContext) { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + let mut cx = VisualTestContext::from_window(window.into(), cx); + + // The mock shell's parked frame is live: dispatch the drop against it + // (an extra draw would repaint the cached element tree and consume the + // one-shot interactive listeners). + let bounds = cx + .debug_bounds("gpui-widgets-explorer-entry-1") + .expect("the explorer's first row is rendered"); + let dropped = PathBuf::from("/media/raw/drag-drop.mov"); + let paths = ExternalPaths(std::iter::once(dropped.clone()).collect()); + // Deliver the whole drag sequence against the same rendered frame: + // gpui's interactive listeners are registered per render, so a repaint + // between the events would consume them before the drop lands. + cx.update(|window, cx| { + window.dispatch_event( + gpui::PlatformInput::FileDrop(FileDropEvent::Entered { + position: bounds.center(), + paths, + }), + cx, + ); + window.dispatch_event( + gpui::PlatformInput::FileDrop(FileDropEvent::Pending { + position: bounds.center(), + }), + cx, + ); + window.dispatch_event( + gpui::PlatformInput::FileDrop(FileDropEvent::Submit { + position: bounds.center(), + }), + cx, + ); + }); + cx.run_until_parked(); + + let imported = cx.read(|app| root.read(app).engine.read(app).imported_footage().to_vec()); + assert_eq!(imported, vec![dropped]); + } + /// The command-line parser understands the project path and the mock /// flag, and the `OAK_ENGINE` env var forces the mock. #[test] diff --git a/src/oakui/ffi.rs b/src/oakui/ffi.rs index 02e450c0f..b5510d503 100644 --- a/src/oakui/ffi.rs +++ b/src/oakui/ffi.rs @@ -385,6 +385,10 @@ unsafe extern "C" { ) -> *mut OakEngineFootage; /// `oakengine_footage_free` — release a footage handle. pub fn oakengine_footage_free(self_: *mut OakEngineFootage); + /// `oakengine_footage_last_error` — last probe/import error on this + /// thread (two-stage buf/size getter; empty when the last call + /// succeeded). + pub fn oakengine_footage_last_error(buf: *mut c_char, buf_size: c_int) -> c_int; /// `oakengine_sequence_add_footage_clip_ex` — place a clip of /// `footage` on the track, skipping the unenforceable same-project /// check (sequences live in their own scratch project — documented diff --git a/src/oakui/real.rs b/src/oakui/real.rs index ad87ab62a..1081a9483 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -2212,6 +2212,34 @@ impl AppEngine for RealEngine { cx.notify(); } + fn import_footage(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String> { + let Some(project) = self.project_ptr() else { + return Err("no project open".into()); + }; + let Some(path_c) = cstr_path(&path) else { + return Err("invalid import path".into()); + }; + // SAFETY: `project` is the live facade handle the engine owns; the + // returned footage box is freed below. + let footage = unsafe { oakengine_project_import_footage(project, path_c.as_ptr()) }; + if footage.is_null() { + let error = read_string(|buf, size| unsafe { + oakengine_footage_last_error(buf, size) + }); + return Err(if error.is_empty() { + format!("failed to import \"{}\"", path.display()) + } else { + error + }); + } + // SAFETY: `footage` is an owned facade box (`oakengine_footage_free`). + unsafe { oakengine_footage_free(footage) }; + // The material bin reads the folder tree live from the facade, so a + // notify is enough for the explorer to list the new entry. + cx.notify(); + Ok(()) + } + // --- project library (M13 D4) -------------------------------------- fn storage_bound(&self) -> bool { @@ -3115,6 +3143,53 @@ mod tests { let _ = std::fs::remove_file(&media); } + /// M12 P3 acceptance through the app seam: a `RealEngine` built exactly + /// as the app builds it imports a generated media file through the same + /// [`AppEngine::import_footage`] method the explorer's drag-drop handler + /// calls, and the project browser's `roots()` lists the entry under the + /// root folder. Selecting the entry (the double-click path) resolves back + /// to the footage node. + #[gpui::test] + async fn real_engine_import_footage_lists_in_the_project_browser( + cx: &mut gpui::TestAppContext, + ) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); + + let media = std::env::temp_dir().join(format!( + "oakapp_engine_import_{}.mp4", + std::process::id() + )); + let cpath = CString::new(media.to_string_lossy().into_owned()).unwrap(); + assert_eq!( + unsafe { oakengine_testmedia_write_clip(cpath.as_ptr(), 64, 64, 10, 10) }, + 0, + "generate e2e test media" + ); + let imported = cx + .update(|app| engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx))); + assert!(imported.is_ok(), "import through the seam succeeds: {imported:?}"); + + // The project browser (ProjectDataSource) lists the file at the root. + let name = media.file_name().unwrap().to_string_lossy().into_owned(); + let entry = cx.read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }); + let entry = entry.expect("the imported footage is listed by its file name"); + assert!(!entry.is_dir, "footage entries are files"); + assert!(entry.id != 0, "entry id is the node identity"); + + // The double-click path: selecting the entry must resolve to a node. + cx.update(|app| engine.update(app, |engine, cx| engine.select_item(entry.id, cx))); + + let _ = std::fs::remove_file(&media); + } + /// M12 P2 acceptance: a real project with a sequence + footage clip /// builds a NON-EMPTY node graph with the wires the node editor shows: /// the footage feeds the clip's `tex_in` (a real edge), and every clip diff --git a/src/panels/project_explorer.rs b/src/panels/project_explorer.rs index c36a2e81c..12b3c5435 100644 --- a/src/panels/project_explorer.rs +++ b/src/panels/project_explorer.rs @@ -40,10 +40,27 @@ impl ProjectExplorerPanel { &explorer, |this, _explorer, event: &ProjectExplorerEvent, cx| match event { ProjectExplorerEvent::OpenRequested { id, .. } => { - // Demo "open": select the item in the engine's model. + // Open the item in the engine's model (double-click on a + // footage entry selects it for the source viewer). this.engine .update(cx, |engine, cx| engine.select_item(*id, cx)); } + ProjectExplorerEvent::FileDropRequested { paths, .. } => { + // Drag-and-drop import: probe and add each dropped file + // (the first failure is logged after the rest run). + let mut first_error = None; + for path in paths { + if let Err(err) = + this.engine + .update(cx, |engine, cx| engine.import_footage(path.clone(), cx)) + { + first_error.get_or_insert(err); + } + } + if let Some(err) = first_error { + println!("[project explorer] import failed: {err}"); + } + } other => println!("[project explorer] request: {other:?}"), }, )