Skip to content

Commit ca4538d

Browse files
gajopclaude
andcommitted
Fix project open/save: list projects, reload on save, capture map
Three fixes to the project lifecycle, all e2e-covered: - Load listed no projects. The engine's NativeListDir only ever returns files (it never populates its directory result), so a project -- an .sdd folder -- was invisible and Open project came back empty. list_assets now gets directories from SubDirs (which works), as the asset picker already does. - First Save now reloads into the project, as Lua's Project:Save does for a new project. Save As emits the reload; ReloadIntoProjectCommand builds the start script from the in-memory ProjectManager (from_memory) rather than the script.txt the save is still writing asynchronously, so it does not race. - A save from a booted/loaded editor wrote a mapless start script ("No map selected in startscript") because a full reload resets ProjectManager to default. SaveProjectInfoCommand now captures the engine's current map and game (via Game::get_game_map_info_owned) when the project has none, fixing both the on-disk script and the reload. E2E: project_round_trip (create then Load it back), project_save_as (Save As reloads into the saved project, verified past the reload), plus large_map_create and new_project_create assert LoadProjectCommand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d26d120 commit ca4538d

7 files changed

Lines changed: 122 additions & 56 deletions

File tree

‎native/src/sbc/actions/project.rs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,12 @@ pub fn commit_new_project(
5454
project_name.to_string(),
5555
path.clone(),
5656
true,
57-
Some(project.clone()),
57+
Some(project),
5858
)),
59-
Box::new(ReloadIntoProjectCommand::for_new_project(
59+
Box::new(ReloadIntoProjectCommand::after_save(
6060
path,
6161
game_name,
6262
game_version,
63-
project,
6463
)),
6564
]
6665
}

‎native/src/sbc/actions/run.rs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,18 +216,27 @@ fn open_save_as() -> ActionResult {
216216
};
217217
ActionResult::OpenFileDialog {
218218
config,
219-
on_accept: Box::new(|result, _iface| {
219+
on_accept: Box::new(|result, iface| {
220220
let name = result
221221
.path
222222
.strip_prefix(PROJECTS_DIR)
223223
.unwrap_or(&result.path)
224224
.trim_end_matches(".sdd")
225225
.to_string();
226226
let path = result.path.clone();
227+
let (game_name, game_version) = game_id(iface);
228+
// Saving under a new name is a new project: reload into it, as Lua's
229+
// Project:Save does for isNewProject. The save writes script.txt
230+
// asynchronously, so the reload builds from the in-memory project.
227231
vec![
228232
Box::new(SetProjectNamePathCommand::new(name.clone(), path.clone())),
229233
Box::new(SaveProjectInfoCommand::new(name, path.clone(), true, None)),
230-
Box::new(SaveCommand::new(path, true)),
234+
Box::new(SaveCommand::new(path.clone(), true)),
235+
Box::new(ReloadIntoProjectCommand::after_save(
236+
path,
237+
game_name,
238+
game_version,
239+
)),
231240
]
232241
}),
233242
}

‎native/src/sbc/panels/controls/grid.rs‎

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -394,31 +394,34 @@ pub(crate) fn list_assets(
394394
extensions: &[&str],
395395
) -> Vec<GridItem> {
396396
let extensions = normalize_extensions(extensions);
397-
// Non-recursive listing across every VFS mode, as Lua's Path.DirList does.
398-
let Ok(entries) = interface.vfs().list_entries(dir, "*", "", false) else {
399-
return Vec::new();
400-
};
401-
402-
let mut dirs = Vec::new();
403-
let mut files = Vec::new();
404-
for entry in entries {
405-
let name = entry.name;
406-
if name.is_empty() {
407-
continue;
408-
}
409-
let path = join_entry(dir, &name);
410-
let caption = path.rsplit('/').next().unwrap_or(&path).to_string();
411-
412-
if entry.is_directory {
413-
dirs.push(GridItem {
397+
// Directories must come from SubDirs, not the entry listing: the engine's
398+
// ListDir only ever returns files (it never fills its directory list), so a
399+
// project -- an `.sdd` folder in the write dir -- is invisible to a plain
400+
// entry listing and Load comes back empty. SubDirs defaults to VFS.RAW_FIRST,
401+
// which sees the write dir, exactly as Lua's `Path.SubDirs` does.
402+
let mut dirs: Vec<GridItem> = vfs_sub_dirs(interface, dir.trim_end_matches('/'))
403+
.into_iter()
404+
.map(|name| {
405+
let path = join_entry(dir, &name);
406+
GridItem {
414407
id: path,
415-
caption,
408+
caption: name,
416409
image: None,
417410
is_directory: true,
418411
tooltip: None,
419412
tooltip_markup: None,
420-
});
421-
} else {
413+
}
414+
})
415+
.collect();
416+
417+
let mut files = Vec::new();
418+
if let Ok(entries) = interface.vfs().list_entries(dir, "*", "", false) {
419+
for entry in entries {
420+
if entry.is_directory || entry.name.is_empty() {
421+
continue;
422+
}
423+
let path = join_entry(dir, &entry.name);
424+
let caption = path.rsplit('/').next().unwrap_or(&path).to_string();
422425
let matches = extensions.is_empty()
423426
|| extensions.iter().any(|ext| {
424427
caption

‎native/src/sbc/project/commands/reload_into_project_command.rs‎

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::sbc::command_system::context::Context;
99
use crate::sbc::command_system::registry::register_command;
1010
use crate::sbc::project::ops::{project_info, reload};
1111
use crate::sbc::project::paths::ProjectPaths;
12-
use crate::sbc::project::ProjectData;
12+
use crate::sbc::project::ProjectManager;
1313
use crate::sbc::teams::TeamManager;
1414

1515
#[derive(Deserialize, Debug)]
@@ -21,12 +21,13 @@ pub struct ReloadIntoProjectCommand {
2121
game_name: String,
2222
#[serde(rename = "gameVersion")]
2323
game_version: String,
24-
/// A freshly created project whose `script.txt` write is still in flight on
25-
/// the IO worker. When set, the reload builds the start script from this in
26-
/// memory instead of reading the not-yet-written file. Loading an existing
27-
/// project leaves it `None` and reads the on-disk script.
24+
/// A project just saved this tick, whose `script.txt` write is still in
25+
/// flight on the IO worker. Build the start script from the in-memory
26+
/// `ProjectManager` (which the save populated) instead of reading the
27+
/// not-yet-written file. Loading an existing project leaves it `false` and
28+
/// reads the on-disk script.
2829
#[serde(skip)]
29-
project: Option<ProjectData>,
30+
from_memory: bool,
3031
}
3132

3233
/// Accept a JSON object as a map; treat anything else as empty. Lua serializes
@@ -47,24 +48,20 @@ impl ReloadIntoProjectCommand {
4748
mod_options: Map::new(),
4849
game_name,
4950
game_version,
50-
project: None,
51+
from_memory: false,
5152
}
5253
}
5354

54-
/// Reload into a just-created project, building the start script from `project`
55-
/// rather than the `script.txt` the save is still writing asynchronously.
56-
pub(crate) fn for_new_project(
57-
path: String,
58-
game_name: String,
59-
game_version: String,
60-
project: ProjectData,
61-
) -> Self {
55+
/// Reload into a project just saved this tick (New Project, or the first
56+
/// Save of an unsaved project), building the start script from the live
57+
/// `ProjectManager` rather than the `script.txt` the save is still writing.
58+
pub(crate) fn after_save(path: String, game_name: String, game_version: String) -> Self {
6259
Self {
6360
path,
6461
mod_options: Map::new(),
6562
game_name,
6663
game_version,
67-
project: Some(project),
64+
from_memory: true,
6865
}
6966
}
7067
}
@@ -83,17 +80,16 @@ impl Command for ReloadIntoProjectCommand {
8380

8481
impl ReloadIntoProjectCommand {
8582
fn run(&self, ctx: &mut Context) -> Result<(), String> {
86-
// A new project's script.txt is still being written by the IO worker, so
87-
// build it from the project in memory; an existing project reads disk.
88-
let saved = match &self.project {
89-
Some(project) => {
90-
project_info::start_script(project, &ctx.model::<TeamManager>().all_teams())
91-
}
92-
None => {
93-
let path = ProjectPaths::new(Path::new(&self.path)).file(SCRIPT_FILE);
94-
std::fs::read_to_string(&path)
95-
.map_err(|err| format!("read {}: {err}", path.display()))?
96-
}
83+
// A just-saved project's script.txt is still being written by the IO
84+
// worker, so build it from the project in memory; an existing project
85+
// reads disk.
86+
let saved = if self.from_memory {
87+
let project = ctx.model::<ProjectManager>().serialize().clone();
88+
project_info::start_script(&project, &ctx.model::<TeamManager>().all_teams())
89+
} else {
90+
let path = ProjectPaths::new(Path::new(&self.path)).file(SCRIPT_FILE);
91+
std::fs::read_to_string(&path)
92+
.map_err(|err| format!("read {}: {err}", path.display()))?
9793
};
9894
let script = reload::start_script(
9995
&saved,

‎native/src/sbc/project/commands/save_project_info_command.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::path::PathBuf;
22

33
use serde::Deserialize;
4+
use spring_native::prelude::NativeInterfaceRef;
45

56
use crate::sbc::command_system::command::Command;
67
use crate::sbc::command_system::context::Context;
@@ -49,6 +50,22 @@ impl Command for SaveProjectInfoCommand {
4950
.unwrap_or_else(|| ctx.model::<ProjectManager>().serialize().clone());
5051
project.name = Some(self.name.clone());
5152
project.path = Some(self.path.clone());
53+
// A project saved from a booted or loaded editor carries no map or game:
54+
// a full reload resets ProjectManager to default, so the fields the New
55+
// Project dialog sets are gone. Capture the engine's current map and game
56+
// when they are missing, or the saved start script has no map and the
57+
// reload aborts with "No map selected in startscript".
58+
if project.map_name.is_none() {
59+
project.map_name = current_map_name(ctx.interface);
60+
}
61+
if project.game.is_none() {
62+
if let Ok(info) = ctx.interface.game().get_game_mod_info_owned() {
63+
project.game = Some(serde_json::json!({
64+
"name": info.game_name,
65+
"version": info.game_version,
66+
}));
67+
}
68+
}
5269
if project.mutators.is_empty() {
5370
project.mutators = vec![format!("{} 1.0", self.name)];
5471
}
@@ -86,4 +103,10 @@ impl Command for SaveProjectInfoCommand {
86103
}
87104
}
88105

106+
/// The name of the map the engine currently has loaded (Lua's `Game.mapName`).
107+
fn current_map_name(interface: &NativeInterfaceRef) -> Option<String> {
108+
let name = interface.game().get_game_map_info_owned().ok()?.map_name;
109+
(!name.is_empty()).then_some(name)
110+
}
111+
89112
register_command!(SaveProjectInfoCommand, "SaveProjectInfoCommand");

‎tools/e2e/scenarios/gallery.py‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,12 +371,42 @@ def new_project_create(run_state: E2ERun) -> None:
371371
run_state.assert_command("LoadProjectCommand")
372372

373373

374+
@scenario()
375+
def project_round_trip(run_state: E2ERun) -> None:
376+
"""Create a project, then reopen it through Load -- proving it is on disk
377+
and openable.
378+
379+
Create writes the project and reloads into it; the Load dialog must then
380+
list the `.sdd` folder, and selecting it reloads back into it. Two reloads,
381+
so this asserts on the command log rather than goldens. Guards the engine
382+
ListDir directory-listing gap: a regression there empties Load.
383+
"""
384+
left = panel_left(run_state)
385+
run_state.focus()
386+
387+
run_state.click(*panel_point(left, TOOLBAR["new_project"]), delay=1.0)
388+
run_state.click(*dialog_point(run_state, DIALOG["new_project_name"]), delay=0.2)
389+
run_state.type_text("RoundTrip")
390+
run_state.key("Return", delay=0.3)
391+
run_state.click(*dialog_point(run_state, DIALOG["new_project_create"]), delay=4.0)
392+
393+
# Reloaded into the new project; Load must now list it.
394+
run_state.click(*panel_point(left, TOOLBAR["load"]), delay=1.5)
395+
run_state.screenshot("load-lists-project")
396+
run_state.click(*dialog_point(run_state, DIALOG["file_first_cell"]), delay=0.5)
397+
run_state.click(*dialog_point(run_state, DIALOG["file_ok"]), delay=4.0)
398+
# Create + Load each reload; both entries survive in the append-only log.
399+
run_state.assert_command_at_least("ReloadIntoProjectCommand", 2)
400+
run_state.screenshot("after-load")
401+
402+
374403
@scenario()
375404
def project_save_as(run_state: E2ERun) -> None:
376-
"""Save As writes the current project under a new name (no reload).
405+
"""Save As writes the current project under a new name and reloads into it.
377406
378-
Emits SetProjectNamePath + SaveProjectInfo + Save. The name-input row pushes
379-
the dialog footer down, so this uses the taller dialog's OK position.
407+
Emits SetProjectNamePath + SaveProjectInfo + Save + Reload, as Lua's
408+
Project:Save does for a new project. The name-input row pushes the dialog
409+
footer down, so this uses the taller dialog's OK position.
380410
"""
381411
left = panel_left(run_state)
382412
run_state.focus()
@@ -385,10 +415,15 @@ def project_save_as(run_state: E2ERun) -> None:
385415
run_state.click(*dialog_point(run_state, DIALOG["file_name"]), delay=0.3)
386416
run_state.type_text("SavedProj")
387417
run_state.key("Return", delay=0.3)
388-
run_state.click(*dialog_point(run_state, DIALOG["file_ok_name"]), delay=1.5)
418+
run_state.click(*dialog_point(run_state, DIALOG["file_ok_name"]), delay=4.0)
389419
run_state.assert_command("SetProjectNamePathCommand")
390420
run_state.assert_command("SaveProjectInfoCommand")
391421
run_state.assert_command("SaveCommand")
422+
run_state.assert_command("ReloadIntoProjectCommand")
423+
# The reload must actually complete: a screenshot here fails the run if the
424+
# engine died reloading (a crash would exit it before this xwd).
425+
run_state.move(1280, 700, delay=6.0)
426+
run_state.screenshot("after-reload")
392427

393428

394429
@scenario()

‎tools/e2e/scenarios/geometry.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@
246246
"new_project_create": (347, 407),
247247
"file_name": (240, 590),
248248
"file_type": (240, 629),
249+
"file_ok": (347, 602),
249250
# A name-input dialog (Save As) has its footer one row below the plain one.
250251
"file_ok_name": (347, 641),
251252
"asset_core_cell": (104, 360),

0 commit comments

Comments
 (0)