fix(import): use the open Project bin as destination

Resolve the shown Project bin for file-dialog and dropped imports. Capture the browser destination before asynchronous media reads so navigation cannot redirect an in-flight batch.
This commit is contained in:
WavJaby 2026-10-08 16:18:12 +08:00
parent 61238bf8fc
commit aa66dd9c8d
5 changed files with 111 additions and 5 deletions

View File

@ -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<Value, String> {
async fn import_path(path: String, bin: u64) -> Result<Value, String> {
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<Value, String> {
/// Import browser files (no copies): returns `{items, errors, paths}`.
pub async fn import_files(files: Vec<web_sys::File>) -> 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<web_sys::File>) -> 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());

View File

@ -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;
}

View File

@ -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));

View File

@ -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<Value, String> {
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}));
}
}

View File

@ -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", &params).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");