diff --git a/apps/filmcraft-web/src/import.rs b/apps/filmcraft-web/src/import.rs index e837a371..6b41a2cd 100644 --- a/apps/filmcraft-web/src/import.rs +++ b/apps/filmcraft-web/src/import.rs @@ -81,12 +81,12 @@ async fn prewarm(path: &str, size: u64) { } /// `file.import` one path, retried while its bytes are loading. -async fn import_path(path: String) -> Result { +async fn import_path(path: String, bin: u64) -> Result { for _ in 0..2000 { let p = path.clone(); let (r, pending) = on_ui(move |w, _| { let _ = filmcraft_media::pending::take(); - let r = w.app.session.execute("file.import", json!({"paths": [p]})).map_err(|e| e.to_string()); + let r = w.app.session.execute("file.import", json!({"paths": [p], "bin": bin})).map_err(|e| e.to_string()); (r, filmcraft_media::pending::take()) }) .await; @@ -105,6 +105,8 @@ async fn import_path(path: String) -> Result { /// Import browser files (no copies): returns `{items, errors, paths}`. pub async fn import_files(files: Vec) -> Value { + // Capture once per batch: navigation while prewarming must not redirect pending imports. + let bin = on_ui(|w, _| w.app.import_bin().0).await; let mut items = Vec::new(); let mut errors = Vec::new(); let mut paths = Vec::new(); @@ -113,7 +115,7 @@ pub async fn import_files(files: Vec) -> Value { let size = f.size() as u64; let path = fs::register_blob(&name, f.clone().into()); prewarm(&path, size).await; - match import_path(path.clone()).await { + match import_path(path.clone(), bin).await { Ok(v) => { items.extend(v.get("items").and_then(Value::as_array).cloned().unwrap_or_default()); errors.extend(v.get("errors").and_then(Value::as_array).cloned().unwrap_or_default()); diff --git a/apps/filmcraft-web/tests/import-bins.mjs b/apps/filmcraft-web/tests/import-bins.mjs new file mode 100644 index 00000000..6e181171 --- /dev/null +++ b/apps/filmcraft-web/tests/import-bins.mjs @@ -0,0 +1,67 @@ +// Web import destinations: active bin tab, browser drop + navigation during pending reads. +// Synthetic browser drop event exercises the production handler; physical OS drag/picker UI is outside this test. +export async function checkImportBins({ evaluate, media, timeoutMs = 120000 }) { + const report = {gates: {}, cases: []}; + await evaluate('filmcraft.execute("file.newProject", {name: "Import bins"})'); + const parent = (await evaluate('filmcraft.execute("file.newBin", {name: "Footage"})')).bin; + const child = (await evaluate(`filmcraft.execute("file.newBin", {name: "Selects", parent: ${parent}})`)).bin; + await evaluate(`(async () => {window.__importBinFixture = new Uint8Array(await (await fetch(${JSON.stringify(media)})).arrayBuffer());})()`); + const setBin = bin => evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${JSON.stringify(bin)}, activeTab: null}})`); + const importExpression = name => `filmcraft.importFiles([new File([window.__importBinFixture], ${JSON.stringify(name)}, {type: "video/mp4"})])`; + const locate = async name => { + const deadline = Date.now() + timeoutMs; + const pollMs = 100; + for (;;) { + const project = await evaluate('filmcraft.execute("project.inspect")'); + const find = bin => { + for (const entry of bin.children) { + if (entry.item !== undefined && entry.name === name) return bin.id; + if (entry.children) {const found = find(entry); if (found !== undefined) return found;} + } + }; + const found = find(project.root); + if (found !== undefined) return found; + if (Date.now() >= deadline) throw new Error(`Import never appeared: ${name}`); + await new Promise(resolve => setTimeout(resolve, pollMs)); + } + }; + const check = async (key, expected, name) => { + const actual = await locate(name); + report.cases.push({key, expected, actual, name}); report.gates[key] = actual === expected; + }; + await evaluate(`filmcraft.request("ui.set", {projectPanel: {bin: ${child}, activeTab: 0, tabs: [{bin: ${parent}}]}})`); + await evaluate(importExpression("tab.mp4")); await check("activeTab", parent, "tab.mp4"); + await setBin(child); + await evaluate(`(() => { + const transfer = new DataTransfer(); + transfer.items.add(new File([window.__importBinFixture], "drop.mp4", {type: "video/mp4"})); + window.dispatchEvent(new DragEvent("drop", {dataTransfer: transfer, bubbles: true, cancelable: true})); + })()`); + await check("browserDrop", child, "drop.mp4"); + await setBin(child); + await evaluate(`(() => { + const original = Blob.prototype.arrayBuffer; + let release; + const pending = new Promise(resolve => {release = resolve;}); + const gate = window.__importBinGate = {hits: 0, release, original}; + Blob.prototype.arrayBuffer = function () {gate.hits++; return pending.then(() => original.call(this));}; + window.__pendingBinImport = ${importExpression("pending.mp4")}; + })()`); + try { + const deadline = Date.now() + timeoutMs; + const pollMs = 100; + for (;;) { + if (await evaluate("window.__importBinGate.hits > 0")) break; + if (Date.now() >= deadline) throw new Error("Read-delay probe did not intercept any Blob reads"); + await new Promise(resolve => setTimeout(resolve, pollMs)); + } + report.delayedReadHits = await evaluate("window.__importBinGate.hits"); + await setBin(parent); + await evaluate("window.__importBinGate.release(); window.__pendingBinImport"); + await check("destinationCapturedBeforeRead", child, "pending.mp4"); + } finally { + await evaluate("Blob.prototype.arrayBuffer = window.__importBinGate.original; window.__importBinGate.release(); delete window.__importBinGate; delete window.__pendingBinImport; delete window.__importBinFixture"); + } + report.ok = Object.values(report.gates).every(Boolean); + return report; +} diff --git a/apps/filmcraft-web/tests/smoke.mjs b/apps/filmcraft-web/tests/smoke.mjs index f43ceec0..0ff290e3 100644 --- a/apps/filmcraft-web/tests/smoke.mjs +++ b/apps/filmcraft-web/tests/smoke.mjs @@ -13,6 +13,7 @@ import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { checkImportBins } from "./import-bins.mjs"; const arg = (k, d) => { const i = process.argv.indexOf(`--${k}`); @@ -162,6 +163,8 @@ try { await sleep(500); await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false }); report.steps.modes = { screenshot: await shot("05-modes") }; + report.steps.importBins = await checkImportBins({evaluate: js, media}); + if (!report.steps.importBins.ok) throw new Error("import bin regression: " + JSON.stringify(report.steps.importBins.gates)); // still alive? (a panicked app never answers) await Promise.race([js("filmcraft.inspect().then(() => true)"), sleep(5000).then(() => { throw new Error("app stopped answering"); })]); const fatal = logs.filter((l) => /panicked at|RuntimeError|EXCEPTION/.test(l)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 461aa36e..3c3f65fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -938,6 +938,11 @@ impl FilmcraftApp { // ---------------------------------------------------------------- files + /// Destination of imports started from the currently shown Project panel view. + pub fn import_bin(&self) -> filmcraft_project::BinId { + panels::project::view_of(self, panels::project::shown_inst(self)).bin + } + pub fn file_dialog(&mut self, id: &str, params: &Value) -> Result { match id { "file.import" => { @@ -952,7 +957,8 @@ impl FilmcraftApp { if paths.is_empty() { return Ok(Value::Null); } - let r = self.session.execute("file.import", json!({"paths": paths})).map_err(|e| e.to_string()); + let bin = params.get("bin").cloned().unwrap_or_else(|| json!(self.import_bin().0)); + let r = self.session.execute("file.import", json!({"paths": paths, "bin": bin})).map_err(|e| e.to_string()); if let Ok(v) = &r && let Some(errs) = v.get("errors").and_then(Value::as_array) && !errs.is_empty() @@ -1022,7 +1028,7 @@ impl FilmcraftApp { } } if !paths.is_empty() { - let _ = self.session.execute("file.import", json!({"paths": paths})); + let _ = self.session.execute("file.import", json!({"paths": paths, "bin": self.import_bin().0})); } } diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index 6188afea..bb504f32 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -492,6 +492,34 @@ fn media_dir(tag: &str) -> std::path::PathBuf { dir } +#[test] +fn file_dialog_import_uses_shown_bin_unless_explicit() { + let dir = media_dir("import-bin"); + let path = dir.join("a-interview.wav").to_string_lossy().into_owned(); + let mut app = FilmcraftApp::new(Session::default()); + app.hooks.pick_files = Some(Box::new(move |_| vec![path.clone()])); + let parent = app.session.execute("file.newBin", json!({"name": "Footage"})).unwrap()["bin"].as_u64().unwrap(); + let child = app.session.execute("file.newBin", json!({"name": "Selects", "parent": parent})).unwrap()["bin"].as_u64().unwrap(); + let root = app.session.project.root.id.0; + app.ui.project_panel.tabs.push(filmcraft_ui_egui::panels::project::BinTab { bin: parent, ..Default::default() }); + for (shown, tab, params, expected) in [ + (None, None, json!({}), root), + (Some(child), None, json!({}), child), + (Some(child), Some(0), json!({}), parent), + (Some(child), None, json!({"bin": null}), root), + (Some(parent), None, json!({"bin": child}), child), + ] { + app.ui.project_panel.bin = shown; + app.ui.project_panel.active_tab = tab; + let result = app.file_dialog("file.import", ¶ms).unwrap(); + assert!(result["errors"].as_array().unwrap().is_empty(), "{result}"); + let item = ItemId(result["items"][0].as_u64().unwrap()); + let bin = app.session.project.root.find_bin(filmcraft_project::BinId(expected)).unwrap(); + assert!(bin.children.contains(&BinEntry::Item(item)), "shown={shown:?}, tab={tab:?}, params={params}: expected bin {expected}"); + } + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn media_browser_navigation_import_and_columns() { let dir = media_dir("nav");