diff --git a/scripts/stage-scroll-ui/main.go b/scripts/stage-scroll-ui/main.go index b872d6d7..1a649158 100644 --- a/scripts/stage-scroll-ui/main.go +++ b/scripts/stage-scroll-ui/main.go @@ -120,7 +120,7 @@ func arkManifest(version string) manifest { return manifest{Version: 1, Server: serverSchema{Family: "ark", DisplayName: "ARK: Survival Evolved", AppVersion: version}, Files: []fileSchema{ { - Path: "data/serverfiles/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini", Format: "unreal-ini", Label: "GameUserSettings.ini", + Path: "serverfiles/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini", Format: "unreal-ini", Label: "GameUserSettings.ini", Description: "ARK server access, administration, and transfer settings. Every additional option remains editable in Raw mode.", Documentation: docs, Sections: []sectionSchema{ {ID: "server", Label: "Server and administration", Fields: server}, @@ -132,7 +132,7 @@ func arkManifest(version string) manifest { }, }, { - Path: "data/serverfiles/ShooterGame/Saved/Config/LinuxServer/Game.ini", Format: "unreal-ini", Label: "Game.ini", + Path: "serverfiles/ShooterGame/Saved/Config/LinuxServer/Game.ini", Format: "unreal-ini", Label: "Game.ini", Description: "ARK progression, breeding, harvesting, and balance settings. Advanced arrays and overrides remain editable in Raw mode.", Documentation: docs, Sections: []sectionSchema{{ID: "gameplay", Label: "World and progression", Fields: gameplay}}, }, @@ -215,7 +215,7 @@ func minecraftManifest(version string) manifest { admin[9].Min, admin[9].Max = number(1), number(4) return manifest{Version: 1, Server: serverSchema{Family: "minecraft", DisplayName: "Minecraft Server", AppVersion: version}, Files: []fileSchema{{ - Path: "data/server.properties", Format: "java-properties", Label: "server.properties", + Path: "server.properties", Format: "java-properties", Label: "server.properties", Description: "Minecraft dedicated-server settings. Version-specific and unknown keys remain editable in Raw mode.", Documentation: "https://minecraft.wiki/w/Server.properties", Sections: []sectionSchema{ @@ -243,17 +243,17 @@ func familyManifest(source string, appVersion string) (manifest, error) { if strings.Contains(source, "/cuberite/") { const docs = "https://book.cuberite.org/" return manifest{1, serverSchema{"minecraft-cuberite", "Cuberite Server", appVersion}, []fileSchema{ - managedFile("data/settings.ini", "settings.ini", "ini", "Cuberite server-wide settings.", docs), - rawFile("data/webadmin.ini", "webadmin.ini", "ini", "Cuberite WebAdmin settings.", docs), - rawFile("data/world/world.ini", "world/world.ini", "ini", "Cuberite overworld settings.", docs), - rawFile("data/world_nether/world.ini", "world_nether/world.ini", "ini", "Cuberite Nether world settings.", docs), - rawFile("data/world_the_end/world.ini", "world_the_end/world.ini", "ini", "Cuberite End world settings.", docs), - rawFile("data/monsters.ini", "monsters.ini", "ini", "Cuberite monster behaviour settings.", docs), - rawFile("data/motd.txt", "motd.txt", "raw", "Cuberite message of the day.", docs), - rawFile("data/crafting.txt", "crafting.txt", "raw", "Cuberite crafting recipes.", docs), - rawFile("data/brewing.txt", "brewing.txt", "raw", "Cuberite brewing recipes.", docs), - rawFile("data/furnace.txt", "furnace.txt", "raw", "Cuberite furnace recipes.", docs), - rawFile("data/items.ini", "items.ini", "ini", "Cuberite item identifiers.", docs), + managedFile("settings.ini", "settings.ini", "ini", "Cuberite server-wide settings.", docs), + rawFile("webadmin.ini", "webadmin.ini", "ini", "Cuberite WebAdmin settings.", docs), + rawFile("world/world.ini", "world/world.ini", "ini", "Cuberite overworld settings.", docs), + rawFile("world_nether/world.ini", "world_nether/world.ini", "ini", "Cuberite Nether world settings.", docs), + rawFile("world_the_end/world.ini", "world_the_end/world.ini", "ini", "Cuberite End world settings.", docs), + rawFile("monsters.ini", "monsters.ini", "ini", "Cuberite monster behaviour settings.", docs), + rawFile("motd.txt", "motd.txt", "raw", "Cuberite message of the day.", docs), + rawFile("crafting.txt", "crafting.txt", "raw", "Cuberite crafting recipes.", docs), + rawFile("brewing.txt", "brewing.txt", "raw", "Cuberite brewing recipes.", docs), + rawFile("furnace.txt", "furnace.txt", "raw", "Cuberite furnace recipes.", docs), + rawFile("items.ini", "items.ini", "ini", "Cuberite item identifiers.", docs), }}, nil } return minecraftManifest(appVersion), nil @@ -267,47 +267,47 @@ func familyManifest(source string, appVersion string) (manifest, error) { families := map[string]family{ "arkserver": {"ARK: Survival Evolved", "https://ark.wiki.gg/wiki/Server_configuration", nil}, "cs2server": {"Counter-Strike 2", "https://docs.linuxgsm.com/configuration/game-server-config", []fileSchema{ - managedFile("data/lgsm/config-lgsm/cs2server/cs2server.cfg", "cs2server.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/serverfiles/game/csgo/cfg/server.cfg", "server.cfg", "key-value", "Counter-Strike 2 game-server configuration.", "https://developer.valvesoftware.com/wiki/Counter-Strike_2/Dedicated_Servers"), + managedFile("lgsm/config-lgsm/cs2server/cs2server.cfg", "cs2server.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("serverfiles/game/csgo/cfg/server.cfg", "server.cfg", "key-value", "Counter-Strike 2 game-server configuration.", "https://developer.valvesoftware.com/wiki/Counter-Strike_2/Dedicated_Servers"), }}, "csgoserver": {"Counter-Strike: Global Offensive", "https://docs.linuxgsm.com/configuration/game-server-config", []fileSchema{ - managedFile("data/lgsm/config-lgsm/csgoserver/csgoserver.cfg", "csgoserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/serverfiles/csgo/cfg/csgoserver.cfg", "csgoserver.cfg", "key-value", "Counter-Strike: Global Offensive game-server configuration.", "https://developer.valvesoftware.com/wiki/Counter-Strike:_Global_Offensive/Dedicated_Servers"), + managedFile("lgsm/config-lgsm/csgoserver/csgoserver.cfg", "csgoserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("serverfiles/csgo/cfg/csgoserver.cfg", "csgoserver.cfg", "key-value", "Counter-Strike: Global Offensive game-server configuration.", "https://developer.valvesoftware.com/wiki/Counter-Strike:_Global_Offensive/Dedicated_Servers"), }}, "dayzserver": {"DayZ", "https://community.bistudio.com/wiki/DayZ:Server_Configuration", []fileSchema{ - managedFile("data/lgsm/config-lgsm/dayzserver/common.cfg", "common.cfg", "key-value", "LinuxGSM settings shared by DayZ instances.", lgsmDocs), - managedFile("data/lgsm/config-lgsm/dayzserver/dayzserver.cfg", "dayzserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/serverfiles/cfg/dayzserver.server.cfg", "dayzserver.server.cfg", "key-value", "DayZ game-server configuration.", "https://community.bistudio.com/wiki/DayZ:Server_Configuration"), + managedFile("lgsm/config-lgsm/dayzserver/common.cfg", "common.cfg", "key-value", "LinuxGSM settings shared by DayZ instances.", lgsmDocs), + managedFile("lgsm/config-lgsm/dayzserver/dayzserver.cfg", "dayzserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("serverfiles/cfg/dayzserver.server.cfg", "dayzserver.server.cfg", "key-value", "DayZ game-server configuration.", "https://community.bistudio.com/wiki/DayZ:Server_Configuration"), }}, "gmodserver": {"Garry's Mod", "https://docs.linuxgsm.com/configuration/game-server-config", []fileSchema{ - managedFile("data/lgsm/config-lgsm/gmodserver/gmodserver.cfg", "gmodserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/serverfiles/garrysmod/cfg/gmodserver.cfg", "gmodserver.cfg", "key-value", "Garry's Mod game-server configuration.", "https://wiki.facepunch.com/gmod/Downloading_a_Dedicated_Server"), + managedFile("lgsm/config-lgsm/gmodserver/gmodserver.cfg", "gmodserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("serverfiles/garrysmod/cfg/gmodserver.cfg", "gmodserver.cfg", "key-value", "Garry's Mod game-server configuration.", "https://wiki.facepunch.com/gmod/Downloading_a_Dedicated_Server"), }}, "pwserver": {"Palworld", "https://tech.palworldgame.com/settings-and-operation/configuration/", []fileSchema{ - managedFile("data/lgsm/config-lgsm/pwserver/common.cfg", "common.cfg", "key-value", "LinuxGSM settings shared by Palworld instances.", lgsmDocs), - managedFile("data/lgsm/config-lgsm/pwserver/pwserver.cfg", "pwserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/serverfiles/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini", "PalWorldSettings.ini", "unreal-ini", "Palworld game-server configuration.", "https://tech.palworldgame.com/settings-and-operation/configuration/"), + managedFile("lgsm/config-lgsm/pwserver/common.cfg", "common.cfg", "key-value", "LinuxGSM settings shared by Palworld instances.", lgsmDocs), + managedFile("lgsm/config-lgsm/pwserver/pwserver.cfg", "pwserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("serverfiles/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini", "PalWorldSettings.ini", "unreal-ini", "Palworld game-server configuration.", "https://tech.palworldgame.com/settings-and-operation/configuration/"), }}, "pzserver": {"Project Zomboid", "https://pzwiki.net/wiki/Server_settings", []fileSchema{ - managedFile("data/lgsm/config-lgsm/pzserver/pzserver.cfg", "pzserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/Zomboid/Server/pzserver.ini", "pzserver.ini", "ini", "Project Zomboid game-server configuration.", "https://pzwiki.net/wiki/Server_settings"), + managedFile("lgsm/config-lgsm/pzserver/pzserver.cfg", "pzserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("Zomboid/Server/pzserver.ini", "pzserver.ini", "ini", "Project Zomboid game-server configuration.", "https://pzwiki.net/wiki/Server_settings"), }}, "sdtdserver": {"7 Days to Die", "https://developer.valvesoftware.com/wiki/7_Days_to_Die_Dedicated_Server", []fileSchema{ - managedFile("data/lgsm/config-lgsm/sdtdserver/sdtdserver.cfg", "sdtdserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/serverfiles/sdtdserver.xml", "sdtdserver.xml", "xml-properties", "7 Days to Die game-server configuration.", "https://developer.valvesoftware.com/wiki/7_Days_to_Die_Dedicated_Server"), + managedFile("lgsm/config-lgsm/sdtdserver/sdtdserver.cfg", "sdtdserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("serverfiles/sdtdserver.xml", "sdtdserver.xml", "xml-properties", "7 Days to Die game-server configuration.", "https://developer.valvesoftware.com/wiki/7_Days_to_Die_Dedicated_Server"), }}, "untserver": {"Unturned", "https://docs.smartlydressedgames.com/en/stable/servers/server-hosting.html", []fileSchema{ - managedFile("data/lgsm/config-lgsm/untserver/untserver.cfg", "untserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), - rawFile("data/serverfiles/Servers/untserver/Config.json", "Config.json", "json", "Unturned game-server configuration.", "https://docs.smartlydressedgames.com/en/stable/servers/server-hosting.html"), - rawFile("data/serverfiles/Servers/untserver/Commands.dat", "Commands.dat", "key-value", "Unturned startup commands and server identity.", "https://docs.smartlydressedgames.com/en/stable/servers/server-hosting.html"), - rawFile("data/serverfiles/Servers/untserver/WorkshopDownloadConfig.json", "WorkshopDownloadConfig.json", "json", "Unturned workshop download configuration.", "https://docs.smartlydressedgames.com/en/stable/servers/server-hosting.html"), + managedFile("lgsm/config-lgsm/untserver/untserver.cfg", "untserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs), + rawFile("serverfiles/Servers/untserver/Config.json", "Config.json", "json", "Unturned game-server configuration.", "https://docs.smartlydressedgames.com/en/stable/servers/server-hosting.html"), + rawFile("serverfiles/Servers/untserver/Commands.dat", "Commands.dat", "key-value", "Unturned startup commands and server identity.", "https://docs.smartlydressedgames.com/en/stable/servers/server-hosting.html"), + rawFile("serverfiles/Servers/untserver/WorkshopDownloadConfig.json", "WorkshopDownloadConfig.json", "json", "Unturned workshop download configuration.", "https://docs.smartlydressedgames.com/en/stable/servers/server-hosting.html"), }}, } for id, spec := range families { if strings.Contains(source, "/lgsm/"+id) { if id == "arkserver" { ark := arkManifest(appVersion) - ark.Files = append([]fileSchema{managedFile("data/lgsm/config-lgsm/arkserver/arkserver.cfg", "arkserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs)}, ark.Files...) + ark.Files = append([]fileSchema{managedFile("lgsm/config-lgsm/arkserver/arkserver.cfg", "arkserver.cfg", "key-value", "LinuxGSM instance overrides.", lgsmDocs)}, ark.Files...) return ark, nil } return manifest{1, serverSchema{"lgsm-" + id, spec.name, appVersion}, spec.files}, nil @@ -315,15 +315,15 @@ func familyManifest(source string, appVersion string) (manifest, error) { } if strings.Contains(source, "/rust/rust-") { return manifest{1, serverSchema{"rust", "Rust Server", appVersion}, []fileSchema{ - managedFile("data/server/druid/cfg/server.cfg", "server.cfg", "key-value", "Rust server convars. Every convar remains editable in Raw mode.", "https://wiki.facepunch.com/rust/Creating-a-server"), + managedFile("server/druid/cfg/server.cfg", "server.cfg", "key-value", "Rust server convars. Every convar remains editable in Raw mode.", "https://wiki.facepunch.com/rust/Creating-a-server"), }}, nil } if strings.Contains(source, "/hytale/") { return manifest{1, serverSchema{"hytale", "Hytale Server", appVersion}, []fileSchema{ - rawFile("data/Server/config.json", "config.json", "json", "Hytale server configuration.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), - rawFile("data/Server/permissions.json", "permissions.json", "json", "Hytale permissions configuration.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), - rawFile("data/Server/whitelist.json", "whitelist.json", "json", "Hytale player allowlist.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), - rawFile("data/Server/bans.json", "bans.json", "json", "Hytale player ban list.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), + rawFile("Server/config.json", "config.json", "json", "Hytale server configuration.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), + rawFile("Server/permissions.json", "permissions.json", "json", "Hytale permissions configuration.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), + rawFile("Server/whitelist.json", "whitelist.json", "json", "Hytale player allowlist.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), + rawFile("Server/bans.json", "bans.json", "json", "Hytale player ban list.", "https://support.hytale.com/hc/en-us/articles/45326769420827-Hytale-Server-Manual"), }}, nil } return manifest{}, fmt.Errorf("no configuration UI catalog entry for %s", source) @@ -355,6 +355,15 @@ func stage(source, destination, bundle string) error { return err } scroll["ui"] = map[string]any{"private": map[string]any{"path": "private/dist/app.wasm"}} + if err := ensureChunk(scroll, "config-admin-ui", "private"); err != nil { + return err + } + for index, file := range configManifest.Files { + rootPath := strings.Split(filepath.ToSlash(file.Path), "/")[0] + if err := ensureChunk(scroll, fmt.Sprintf("config-file-%d", index+1), rootPath); err != nil { + return err + } + } updatedYAML, err := yaml.Marshal(scroll) if err != nil { return err @@ -363,27 +372,57 @@ func stage(source, destination, bundle string) error { return err } - privateDir := filepath.Join(destination, "private") - if err := os.MkdirAll(filepath.Join(privateDir, "dist"), 0755); err != nil { - return err - } - if err := copyFile(bundle, filepath.Join(privateDir, "dist", "app.wasm")); err != nil { - return err + privateDirs := []string{ + filepath.Join(destination, "private"), + filepath.Join(destination, "data", "private"), } manifestBytes, err := json.MarshalIndent(configManifest, "", " ") if err != nil { return err } manifestBytes = append(manifestBytes, '\n') - if err := os.WriteFile(filepath.Join(privateDir, "config-editor.manifest.json"), manifestBytes, 0644); err != nil { - return err + for _, privateDir := range privateDirs { + if err := os.MkdirAll(filepath.Join(privateDir, "dist"), 0755); err != nil { + return err + } + if err := copyFile(bundle, filepath.Join(privateDir, "dist", "app.wasm")); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(privateDir, "config-editor.manifest.json"), manifestBytes, 0644); err != nil { + return err + } } return ensureConfigFiles(destination, configManifest) } +func ensureChunk(scroll map[string]any, name string, path string) error { + rawChunks, exists := scroll["chunks"] + if !exists { + return nil + } + chunks, ok := rawChunks.([]any) + if !ok { + return fmt.Errorf("scroll chunks must be a list, got %T", rawChunks) + } + if len(chunks) == 0 { + return nil + } + for _, value := range chunks { + chunk, ok := value.(map[string]any) + if ok { + chunkPath, _ := chunk["path"].(string) + if chunkPath == "." || chunkPath == path { + return nil + } + } + } + scroll["chunks"] = append(chunks, map[string]any{"name": name, "path": path}) + return nil +} + func ensureConfigFiles(destination string, configManifest manifest) error { for _, file := range configManifest.Files { - target := filepath.Join(destination, filepath.FromSlash(file.Path)) + target := filepath.Join(destination, "data", filepath.FromSlash(file.Path)) if _, err := os.Stat(target); err == nil { continue } else if !os.IsNotExist(err) { @@ -399,7 +438,7 @@ func ensureConfigFiles(destination string, configManifest manifest) error { } else if !os.IsNotExist(err) { return err } - content := "" + content := "\n" if file.Format == "json" { content = "{}\n" } @@ -410,7 +449,7 @@ func ensureConfigFiles(destination string, configManifest manifest) error { content = "[Server]\nDescription=Druid Cuberite Server\nPorts=25565\nMaxPlayers=100\n\n[Worlds]\nDefaultWorld=world\n" } if !file.CreateIfMissing { - continue + target += ".default" } if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err diff --git a/scripts/stage-scroll-ui/main_test.go b/scripts/stage-scroll-ui/main_test.go index 56a7df14..6626d0b3 100644 --- a/scripts/stage-scroll-ui/main_test.go +++ b/scripts/stage-scroll-ui/main_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { @@ -40,6 +42,13 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if string(stagedYAML) == "" || !contains(string(stagedYAML), "path: private/dist/app.wasm") { t.Fatalf("staged yaml = %s", stagedYAML) } + var stagedScroll map[string]any + if err := yaml.Unmarshal(stagedYAML, &stagedScroll); err != nil { + t.Fatal(err) + } + if chunks, exists := stagedScroll["chunks"]; exists { + t.Fatalf("automatic data chunking was replaced by explicit chunks: %#v", chunks) + } manifestBytes, err := os.ReadFile(filepath.Join(destination, "private", "config-editor.manifest.json")) if err != nil { t.Fatal(err) @@ -54,14 +63,70 @@ func TestStageAddsPrivateUIAndMinecraftManifest(t *testing.T) { if len(got.Files) != 1 || len(got.Files[0].Sections) < 4 { t.Fatalf("manifest files = %#v", got.Files) } + if got.Files[0].Path != "server.properties" { + t.Fatalf("runtime-relative manifest path = %q, want server.properties", got.Files[0].Path) + } if _, err := os.Stat(filepath.Join(destination, "private", "dist", "app.wasm")); err != nil { t.Fatal(err) } + if _, err := os.Stat(filepath.Join(destination, "data", "private", "config-editor.manifest.json")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, "data", "private", "dist", "app.wasm")); err != nil { + t.Fatal(err) + } if _, err := os.Stat(filepath.Join(destination, "data", "server.properties")); !os.IsNotExist(err) { t.Fatalf("active config was unexpectedly created beside packaged default: %v", err) } } +func TestStageAppendsPrivateDataChunkToExistingExplicitChunks(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "scrolls", "lgsm", "pzserver") + if err := os.MkdirAll(filepath.Join(source, "data", "lgsm"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "scroll.yaml"), []byte("name: test\ndesc: test\nversion: 0.0.1\ncommands: {}\nchunks:\n - name: lgsm\n path: lgsm\n"), 0644); err != nil { + t.Fatal(err) + } + bundle := filepath.Join(root, "app.wasm") + if err := os.WriteFile(bundle, []byte("wasm"), 0644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(root, "staged") + if err := stage(source, destination, bundle); err != nil { + t.Fatal(err) + } + stagedYAML, err := os.ReadFile(filepath.Join(destination, "scroll.yaml")) + if err != nil { + t.Fatal(err) + } + var stagedScroll map[string]any + if err := yaml.Unmarshal(stagedYAML, &stagedScroll); err != nil { + t.Fatal(err) + } + chunks, ok := stagedScroll["chunks"].([]any) + if !ok { + t.Fatalf("staged chunks = %#v", stagedScroll["chunks"]) + } + wantPaths := map[string]bool{"lgsm": false, "Zomboid": false, "private": false} + for _, value := range chunks { + chunk, ok := value.(map[string]any) + if ok { + if path, wanted := chunk["path"].(string); wanted { + if _, exists := wantPaths[path]; exists { + wantPaths[path] = true + } + } + } + } + for path, found := range wantPaths { + if !found { + t.Fatalf("explicit data chunk %q missing from %#v", path, chunks) + } + } +} + func TestCatalogCoversEveryReleasedFamily(t *testing.T) { sources := []string{ "scrolls/minecraft/papermc/1.21.7", "scrolls/minecraft/minecraft-vanilla/1.21.7", @@ -80,15 +145,15 @@ func TestCatalogCoversEveryReleasedFamily(t *testing.T) { func TestLinuxGSMCatalogsExposeManagementAndGameConfiguration(t *testing.T) { want := map[string][]string{ - "arkserver": {"data/lgsm/config-lgsm/arkserver/arkserver.cfg", "data/serverfiles/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini", "data/serverfiles/ShooterGame/Saved/Config/LinuxServer/Game.ini"}, - "cs2server": {"data/lgsm/config-lgsm/cs2server/cs2server.cfg", "data/serverfiles/game/csgo/cfg/server.cfg"}, - "csgoserver": {"data/lgsm/config-lgsm/csgoserver/csgoserver.cfg", "data/serverfiles/csgo/cfg/csgoserver.cfg"}, - "dayzserver": {"data/lgsm/config-lgsm/dayzserver/common.cfg", "data/lgsm/config-lgsm/dayzserver/dayzserver.cfg", "data/serverfiles/cfg/dayzserver.server.cfg"}, - "gmodserver": {"data/lgsm/config-lgsm/gmodserver/gmodserver.cfg", "data/serverfiles/garrysmod/cfg/gmodserver.cfg"}, - "pwserver": {"data/lgsm/config-lgsm/pwserver/common.cfg", "data/lgsm/config-lgsm/pwserver/pwserver.cfg", "data/serverfiles/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini"}, - "pzserver": {"data/lgsm/config-lgsm/pzserver/pzserver.cfg", "data/Zomboid/Server/pzserver.ini"}, - "sdtdserver": {"data/lgsm/config-lgsm/sdtdserver/sdtdserver.cfg", "data/serverfiles/sdtdserver.xml"}, - "untserver": {"data/lgsm/config-lgsm/untserver/untserver.cfg", "data/serverfiles/Servers/untserver/Config.json", "data/serverfiles/Servers/untserver/Commands.dat", "data/serverfiles/Servers/untserver/WorkshopDownloadConfig.json"}, + "arkserver": {"lgsm/config-lgsm/arkserver/arkserver.cfg", "serverfiles/ShooterGame/Saved/Config/LinuxServer/GameUserSettings.ini", "serverfiles/ShooterGame/Saved/Config/LinuxServer/Game.ini"}, + "cs2server": {"lgsm/config-lgsm/cs2server/cs2server.cfg", "serverfiles/game/csgo/cfg/server.cfg"}, + "csgoserver": {"lgsm/config-lgsm/csgoserver/csgoserver.cfg", "serverfiles/csgo/cfg/csgoserver.cfg"}, + "dayzserver": {"lgsm/config-lgsm/dayzserver/common.cfg", "lgsm/config-lgsm/dayzserver/dayzserver.cfg", "serverfiles/cfg/dayzserver.server.cfg"}, + "gmodserver": {"lgsm/config-lgsm/gmodserver/gmodserver.cfg", "serverfiles/garrysmod/cfg/gmodserver.cfg"}, + "pwserver": {"lgsm/config-lgsm/pwserver/common.cfg", "lgsm/config-lgsm/pwserver/pwserver.cfg", "serverfiles/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini"}, + "pzserver": {"lgsm/config-lgsm/pzserver/pzserver.cfg", "Zomboid/Server/pzserver.ini"}, + "sdtdserver": {"lgsm/config-lgsm/sdtdserver/sdtdserver.cfg", "serverfiles/sdtdserver.xml"}, + "untserver": {"lgsm/config-lgsm/untserver/untserver.cfg", "serverfiles/Servers/untserver/Config.json", "serverfiles/Servers/untserver/Commands.dat", "serverfiles/Servers/untserver/WorkshopDownloadConfig.json"}, } for id, paths := range want { got, err := familyManifest("scrolls/lgsm/"+id, "latest") @@ -110,7 +175,7 @@ func TestHytaleCatalogIncludesEveryDocumentedTopLevelConfiguration(t *testing.T) if err != nil { t.Fatal(err) } - want := []string{"data/Server/config.json", "data/Server/permissions.json", "data/Server/whitelist.json", "data/Server/bans.json"} + want := []string{"Server/config.json", "Server/permissions.json", "Server/whitelist.json", "Server/bans.json"} gotPaths := make([]string, 0, len(got.Files)) for _, file := range got.Files { gotPaths = append(gotPaths, file.Path) @@ -136,10 +201,10 @@ func TestCuberiteCatalogIncludesDocumentedServerAndDefaultWorldConfiguration(t * t.Fatal(err) } want := []string{ - "data/settings.ini", "data/webadmin.ini", - "data/world/world.ini", "data/world_nether/world.ini", "data/world_the_end/world.ini", - "data/monsters.ini", "data/motd.txt", "data/crafting.txt", "data/brewing.txt", - "data/furnace.txt", "data/items.ini", + "settings.ini", "webadmin.ini", + "world/world.ini", "world_nether/world.ini", "world_the_end/world.ini", + "monsters.ini", "motd.txt", "crafting.txt", "brewing.txt", + "furnace.txt", "items.ini", } gotPaths := make([]string, 0, len(got.Files)) for _, file := range got.Files { @@ -177,11 +242,12 @@ func TestEnsureConfigFilesCreatesRuntimeDefaultsButPreservesTemplates(t *testing t.Fatal(err) } config := manifest{Files: []fileSchema{ - {Path: "data/templated.cfg", Format: "key-value"}, - {Path: "data/packaged.cfg", Format: "key-value"}, - {Path: "data/config.json", Format: "json", CreateIfMissing: true}, - {Path: "data/server/druid/cfg/server.cfg", Format: "key-value", CreateIfMissing: true}, - {Path: "data/settings.ini", Format: "ini", CreateIfMissing: true}, + {Path: "templated.cfg", Format: "key-value"}, + {Path: "packaged.cfg", Format: "key-value"}, + {Path: "generated.cfg", Format: "key-value"}, + {Path: "config.json", Format: "json", CreateIfMissing: true}, + {Path: "server/druid/cfg/server.cfg", Format: "key-value", CreateIfMissing: true}, + {Path: "settings.ini", Format: "ini", CreateIfMissing: true}, }} if err := ensureConfigFiles(root, config); err != nil { @@ -193,6 +259,10 @@ func TestEnsureConfigFilesCreatesRuntimeDefaultsButPreservesTemplates(t *testing if _, err := os.Stat(filepath.Join(root, "data", "packaged.cfg")); !os.IsNotExist(err) { t.Fatalf("packaged-default output was unexpectedly created: %v", err) } + generatedDefault, err := os.ReadFile(filepath.Join(root, "data", "generated.cfg.default")) + if err != nil || len(generatedDefault) == 0 { + t.Fatalf("generated fallback must be non-empty so OCI packaging retains it: %q, %v", generatedDefault, err) + } jsonBytes, err := os.ReadFile(filepath.Join(root, "data", "config.json")) if err != nil || string(jsonBytes) != "{}\n" { t.Fatalf("JSON default = %q, %v", jsonBytes, err) @@ -287,17 +357,15 @@ func TestEveryCheckedInGameServerScrollStagesAsACompleteUIPackage(t *testing.T) t.Errorf("parse manifest %s: %v", path, err) return nil } - expected, manifestErr := familyManifest(filepath.ToSlash(path), "test") - if manifestErr != nil { - t.Errorf("catalog %s: %v", path, manifestErr) - return nil - } - for index, file := range got.Files { - target := filepath.Join(destination, filepath.FromSlash(file.Path)) + for _, file := range got.Files { + if strings.HasPrefix(file.Path, "data/") { + t.Errorf("%s exposes artifact path %q instead of a runtime-relative path", path, file.Path) + } + target := filepath.Join(destination, "data", filepath.FromSlash(file.Path)) if _, err := os.Stat(target); err != nil { _, templateErr := os.Stat(target + ".scroll_template") _, defaultErr := os.Stat(target + ".default") - if templateErr != nil && defaultErr != nil && expected.Files[index].CreateIfMissing { + if templateErr != nil && defaultErr != nil { t.Errorf("%s has neither config nor template for %s", path, file.Path) } } diff --git a/ui/dist/app.wasm b/ui/dist/app.wasm index a98d6323..c33443a4 100644 Binary files a/ui/dist/app.wasm and b/ui/dist/app.wasm differ diff --git a/ui/src/config-editor/components/ActionBar.tsx b/ui/src/config-editor/components/ActionBar.tsx index efce4113..eb6bcb2c 100644 --- a/ui/src/config-editor/components/ActionBar.tsx +++ b/ui/src/config-editor/components/ActionBar.tsx @@ -3,14 +3,27 @@ import { copy } from "../copy.js"; export interface ActionBarProps { dirty: boolean; invalid: boolean; + restartRequired: boolean; saving: boolean; status: string; onSave(): Promise | void; } -export const ActionBar = ({ dirty, invalid, saving, status, onSave }: ActionBarProps) => ( +export const ActionBar = ({ + dirty, + invalid, + restartRequired, + saving, + status, + onSave, +}: ActionBarProps) => (
- {status} + + {status} + {restartRequired ? ( + {copy.restartAfterSave} + ) : false} +

{manifest.server.displayName}

-
- {manifest.server.appVersion ? `Version ${manifest.server.appVersion}` : copy.appTitle} +
+
+ {manifest.server.appVersion ? `Version ${manifest.server.appVersion}` : copy.appTitle} +
+ issue.severity === "error")} + restartRequired={snapshot.restartRequired} + saving={saving} + status={status} + onSave={onSave} + />
@@ -87,15 +96,7 @@ export const EditorApp = ({ ) : ( )} - issue.severity === "error")} - saving={saving} - status={status} - onSave={onSave} - /> -
); diff --git a/ui/src/config-editor/components/Inspector.tsx b/ui/src/config-editor/components/Inspector.tsx deleted file mode 100644 index 93fb3edf..00000000 --- a/ui/src/config-editor/components/Inspector.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { copy } from "../copy.js"; -import type { EditorSnapshot } from "../store.js"; - -export interface InspectorProps { - snapshot: EditorSnapshot; -} - -const printable = (value: unknown): string => - value === undefined ? "Not set" : value === null ? "Protected" : String(value); - -export const Inspector = ({ snapshot }: InspectorProps) => ( - -); diff --git a/ui/src/config-editor/copy.ts b/ui/src/config-editor/copy.ts index f4ebbc19..5e6e132d 100644 --- a/ui/src/config-editor/copy.ts +++ b/ui/src/config-editor/copy.ts @@ -4,20 +4,12 @@ export const copy = { formTab: "Form", rawTab: "Raw", filesHeading: "Configuration files", - inspectorHeading: "Changes & validation", - noChanges: "No pending changes", - rawChanged: "Raw configuration changed", - unknownKeys: "Unknown keys: Preserved", restartRequired: "Restart required", - stopBeforeSave: "Stop the server before saving configuration files that it may rewrite.", - noRestartRequired: "No restart required", + restartAfterSave: "Restart required after saving", save: "Save changes", saving: "Saving…", saved: "Saved and verified", conflict: "Remote file changed. Reload before saving.", rawLabel: "Raw configuration", - validationHeading: "Validation", - valid: "Configuration is valid", - secretChanged: "Secret changed", fileFormat: "Format", } as const; diff --git a/ui/src/config-editor/index.ts b/ui/src/config-editor/index.ts index bf9d2f6f..fa1c2098 100644 --- a/ui/src/config-editor/index.ts +++ b/ui/src/config-editor/index.ts @@ -18,7 +18,6 @@ export { FileRail } from "./components/FileRail.js"; export { FieldControl } from "./components/FieldControl.js"; export { FormEditor } from "./components/FormEditor.js"; export { RawEditor } from "./components/RawEditor.js"; -export { Inspector } from "./components/Inspector.js"; export { ActionBar } from "./components/ActionBar.js"; export { EDITOR_STYLES } from "./styles.js"; export { copy } from "./copy.js"; diff --git a/ui/src/config-editor/store.test.ts b/ui/src/config-editor/store.test.ts index 15ee25a2..39989063 100644 --- a/ui/src/config-editor/store.test.ts +++ b/ui/src/config-editor/store.test.ts @@ -335,4 +335,30 @@ describe("ConfigEditorStore", () => { expect(store.serializeSelectedFile()).toBe('{\n "port": 5520\n}\n'); }); + + it("shows a declared default for a missing setting without changing the file", () => { + const store = loadedStore( + "motd=Druid\n", + fileSchema([ + baseField({ + key: "online-mode", + label: "Online mode", + type: "boolean", + min: undefined, + max: undefined, + defaultValue: true, + }), + ]), + ); + + expect(store.snapshot().fields["online-mode"]).toEqual( + expect.objectContaining({ + displayValue: "true", + value: true, + dirty: false, + issues: [], + }), + ); + expect(store.serializeSelectedFile()).toBe("motd=Druid\n"); + }); }); diff --git a/ui/src/config-editor/store.ts b/ui/src/config-editor/store.ts index c30a4211..1ec2458a 100644 --- a/ui/src/config-editor/store.ts +++ b/ui/src/config-editor/store.ts @@ -192,9 +192,10 @@ export class ConfigEditorStore { field: FieldSchema, ): { value: ConfigValue | undefined; issues: ValidationIssue[] } { const raw = this.adapter.get(document, field.key); - if (raw === undefined) return { value: undefined, issues: [] }; + const input = raw === undefined ? field.defaultValue : raw; + if (input === undefined) return { value: undefined, issues: [] }; try { - const value = coerceFieldValue(field, raw); + const value = coerceFieldValue(field, input); return { value, issues: validateField(field, value) }; } catch (error) { return { diff --git a/ui/src/config-editor/styles.test.ts b/ui/src/config-editor/styles.test.ts new file mode 100644 index 00000000..11d5a91a --- /dev/null +++ b/ui/src/config-editor/styles.test.ts @@ -0,0 +1,65 @@ +import { JSDOM } from "jsdom"; +import { describe, expect, it } from "vitest"; + +import { EDITOR_STYLES } from "./styles.js"; + +function parsedStyleRules(): CSSStyleRule[] { + const dom = new JSDOM(``); + const sheet = dom.window.document.styleSheets.item(0); + + if (!sheet) { + throw new Error("Expected the config editor stylesheet to be parsed"); + } + + return Array.from(sheet.cssRules).filter( + (rule): rule is CSSStyleRule => "selectorText" in rule, + ); +} + +function findRule(rules: CSSStyleRule[], selectorFragment: string): CSSStyleRule | undefined { + return rules.find((rule) => rule.selectorText.includes(selectorFragment)); +} + +describe("config editor scrollbar theme", () => { + it("themes every scrollable editor surface with the shared Druid scrollbar", () => { + const rules = parsedStyleRules(); + const sharedRule = findRule(rules, ".editor-main, .file-rail, .raw-textarea"); + const webkitScrollbar = findRule(rules, "::-webkit-scrollbar"); + const track = findRule(rules, "::-webkit-scrollbar-track"); + const thumb = findRule(rules, "::-webkit-scrollbar-thumb"); + const button = findRule(rules, "::-webkit-scrollbar-button"); + const corner = findRule(rules, "::-webkit-scrollbar-corner"); + + expect(sharedRule).toBeDefined(); + expect(sharedRule?.style.getPropertyValue("scrollbar-width")).toBe("thin"); + expect(sharedRule?.style.getPropertyValue("scrollbar-color")).toBe( + "var(--druid-border-strong) transparent", + ); + + expect(webkitScrollbar?.style.width).toBe("10px"); + expect(webkitScrollbar?.style.height).toBe("10px"); + expect(track?.style.background).toBe("transparent"); + expect(thumb?.style.getPropertyValue("background-color")).toBe( + "var(--druid-border-strong)", + ); + expect(thumb?.style.getPropertyValue("border-radius")).toBe("999px"); + expect(thumb?.style.getPropertyValue("background-clip")).toBe("content-box"); + expect(button?.style.display).toBe("none"); + expect(button?.style.width).toBe("0"); + expect(button?.style.height).toBe("0"); + expect(corner?.style.background).toBe("transparent"); + }); +}); + +describe("config editor rendering performance", () => { + it("defers rendering off-screen field sections", () => { + const rules = parsedStyleRules(); + const fieldSection = findRule(rules, ".field-section"); + + expect(fieldSection).toBeDefined(); + expect(fieldSection?.style.getPropertyValue("content-visibility")).toBe("auto"); + expect(fieldSection?.style.getPropertyValue("contain-intrinsic-size")).toBe( + "auto 360px", + ); + }); +}); diff --git a/ui/src/config-editor/styles.ts b/ui/src/config-editor/styles.ts index 653dc0d7..549e27ba 100644 --- a/ui/src/config-editor/styles.ts +++ b/ui/src/config-editor/styles.ts @@ -2,6 +2,9 @@ export const EDITOR_STYLES = ` :host { display: block; width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; --druid-bg: #07100d; --druid-panel: #101b17; --druid-panel-raised: #15231d; @@ -17,6 +20,7 @@ export const EDITOR_STYLES = ` font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color-scheme: dark; } +.druid-wrapper { width: 100%; height: 100%; min-height: 0; overflow: hidden; } * { box-sizing: border-box; } button, input, select, textarea { font: inherit; } button, input, select, textarea, a { outline: none; } @@ -24,27 +28,56 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- box-shadow: 0 0 0 3px rgba(168, 239, 156, .28); border-color: var(--druid-accent); } -.config-editor { min-height: 640px; background: radial-gradient(circle at 75% 0%, #173326 0, transparent 32%), var(--druid-bg); color: var(--druid-text); } +.config-editor { display: grid; grid-template-rows: auto minmax(0, 1fr); width: 100%; height: 100%; min-height: 0; overflow: hidden; background: radial-gradient(circle at 75% 0%, #173326 0, transparent 32%), var(--druid-bg); color: var(--druid-text); } .editor-header { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-bottom: 1px solid var(--druid-border); } .eyebrow { color: var(--druid-accent); font-size: 11px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } .editor-title { margin: 3px 0 0; font-size: clamp(20px, 2.4vw, 30px); line-height: 1.1; } +.header-controls { display: flex; align-items: center; justify-content: flex-end; gap: 18px; } .server-version { color: var(--druid-muted); font-size: 13px; } -.editor-grid { display: grid; grid-template-columns: minmax(210px, 250px) minmax(420px, 1fr) minmax(260px, 320px); min-height: 564px; } -.file-rail, .inspector { background: rgba(11, 21, 17, .8); } -.file-rail { padding: 18px 14px; border-right: 1px solid var(--druid-border); } -.rail-heading, .inspector h2 { margin: 0 0 12px; font-size: 12px; color: var(--druid-muted); letter-spacing: .08em; text-transform: uppercase; } +.editor-grid { display: grid; grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); width: 100%; height: 100%; min-height: 0; overflow: hidden; } +.file-rail { min-height: 0; overflow-y: auto; padding: 18px 14px; border-right: 1px solid var(--druid-border); background: rgba(11, 21, 17, .8); } +.rail-heading { margin: 0 0 12px; font-size: 12px; color: var(--druid-muted); letter-spacing: .08em; text-transform: uppercase; } .file-list { display: grid; gap: 8px; } .file-button { width: 100%; min-height: 52px; display: grid; gap: 3px; padding: 10px 12px; text-align: left; border: 1px solid transparent; border-radius: 10px; background: transparent; color: var(--druid-text); cursor: pointer; } .file-button:hover { background: var(--druid-panel); } .file-button[aria-current="true"] { border-color: var(--druid-border-strong); background: var(--druid-panel-raised); } .file-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; } .file-format { color: var(--druid-muted); font-size: 12px; text-transform: uppercase; } -.editor-main { min-width: 0; padding: 18px 22px 92px; } +.editor-main { min-width: 0; min-height: 0; overflow-y: auto; padding: 18px 22px 28px; } +:is(.editor-main, .file-rail, .raw-textarea) { + scrollbar-width: thin; + scrollbar-color: var(--druid-border-strong) transparent; +} +:is(.editor-main, .file-rail, .raw-textarea)::-webkit-scrollbar { + width: 10px; + height: 10px; +} +:is(.editor-main, .file-rail, .raw-textarea)::-webkit-scrollbar-track { + background: transparent; +} +:is(.editor-main, .file-rail, .raw-textarea)::-webkit-scrollbar-thumb { + min-height: 40px; + border: 3px solid transparent; + border-radius: 999px; + background-color: var(--druid-border-strong); + background-clip: content-box; +} +:is(.editor-main, .file-rail, .raw-textarea)::-webkit-scrollbar-thumb:hover { + background-color: var(--druid-muted); +} +:is(.editor-main, .file-rail, .raw-textarea)::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} +:is(.editor-main, .file-rail, .raw-textarea)::-webkit-scrollbar-corner { + background: transparent; +} .tabs { display: inline-flex; gap: 4px; padding: 4px; margin-bottom: 18px; border: 1px solid var(--druid-border); border-radius: 10px; background: #0b1511; } .tab { min-height: 40px; min-width: 92px; border: 0; border-radius: 7px; background: transparent; color: var(--druid-muted); cursor: pointer; } .tab[aria-selected="true"] { background: var(--druid-panel-raised); color: var(--druid-text); } .form-editor { display: grid; gap: 18px; } -.field-section { min-width: 0; margin: 0; padding: 18px; border: 1px solid var(--druid-border); border-radius: 14px; background: rgba(16, 27, 23, .84); } +.field-section { min-width: 0; margin: 0; padding: 18px; border: 1px solid var(--druid-border); border-radius: 14px; background: rgba(16, 27, 23, .84); content-visibility: auto; contain-intrinsic-size: auto 360px; } .field-section legend { padding: 0 8px; color: var(--druid-accent); font-weight: 800; } .section-description { margin: 0 0 16px; color: var(--druid-muted); font-size: 13px; } .field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } @@ -55,31 +88,28 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .boolean-control { min-height: 44px; display: flex; align-items: center; gap: 10px; padding: 8px 11px; border: 1px solid var(--druid-border); border-radius: 9px; background: #09130f; } .boolean-control input { width: 20px; height: 20px; accent-color: var(--druid-accent); } .field-meta { display: flex; flex-wrap: wrap; gap: 8px; color: var(--druid-muted); font-size: 11px; } +.field-meta > span:last-child { min-width: 0; max-width: 100%; overflow-wrap: anywhere; word-break: break-word; } .restart-chip { color: var(--druid-warning); } .field-error { margin: 0; color: var(--druid-error); font-size: 12px; } .raw-editor { display: grid; gap: 10px; } .raw-textarea { width: 100%; min-height: 440px; resize: vertical; padding: 16px; border: 1px solid var(--druid-border); border-radius: 12px; background: #050b08; color: #dce8e1; font: 13px/1.55 "Cascadia Code", "SFMono-Regular", Consolas, monospace; tab-size: 2; } -.inspector { display: flex; flex-direction: column; gap: 18px; padding: 18px; border-left: 1px solid var(--druid-border); } .status-card { padding: 14px; border: 1px solid var(--druid-border); border-radius: 12px; background: var(--druid-panel); } -.status-card p { margin: 4px 0; color: var(--druid-muted); font-size: 13px; } -.change-list, .issue-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } -.change-item { padding: 10px; border: 1px solid var(--druid-border); border-radius: 9px; background: #0b1511; font-size: 13px; } -.change-value { display: block; margin-top: 4px; color: var(--druid-muted); word-break: break-word; } -.issue-item { color: var(--druid-error); font-size: 12px; } -.action-bar { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: auto 0 0; padding-top: 14px; background: linear-gradient(transparent, rgba(11, 21, 17, .98) 32%); } +.action-bar { display: flex; align-items: center; justify-content: flex-end; gap: 14px; } +.action-feedback { display: grid; gap: 3px; text-align: right; } .save-button { min-height: 46px; padding: 0 18px; border: 0; border-radius: 9px; background: var(--druid-accent); color: var(--druid-accent-ink); font-weight: 850; cursor: pointer; } .save-button:disabled { opacity: .45; cursor: not-allowed; } .action-status { color: var(--druid-muted); font-size: 12px; } +.restart-notice { color: var(--druid-warning); font-size: 12px; } .error-shell { margin: 20px; padding: 18px; border: 1px solid var(--druid-error); border-radius: 12px; background: #28120f; color: #ffd8d2; } @media (max-width: 900px) { .editor-grid { grid-template-columns: 190px minmax(0, 1fr); } - .inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--druid-border); } + .header-controls { gap: 12px; } } @media (max-width: 560px) { - .config-editor { min-height: 100%; } - .editor-header { align-items: flex-start; padding: 14px 16px; } - .editor-grid { display: block; } - .file-rail { padding: 12px 16px; border-right: 0; border-bottom: 1px solid var(--druid-border); overflow-x: auto; } + .editor-header { align-items: flex-start; flex-wrap: wrap; gap: 12px; padding: 14px 16px; } + .header-controls { width: 100%; justify-content: space-between; } + .editor-grid { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); } + .file-rail { overflow-x: auto; overflow-y: hidden; padding: 12px 16px; border-right: 0; border-bottom: 1px solid var(--druid-border); } .file-list { display: flex; width: max-content; } .file-button { width: 190px; } .editor-main { padding: 16px 16px 28px; } @@ -88,9 +118,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .field-section { padding: 14px; } .field-grid { grid-template-columns: 1fr; } .raw-textarea { min-height: 360px; } - .inspector { padding: 16px; } - .action-bar { position: static; } - .save-button { width: 100%; } + .action-bar { flex: 1; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } diff --git a/ui/src/config-editor/validation.test.ts b/ui/src/config-editor/validation.test.ts index 7a63e2de..35829c76 100644 --- a/ui/src/config-editor/validation.test.ts +++ b/ui/src/config-editor/validation.test.ts @@ -22,7 +22,20 @@ describe("coerceFieldValue", () => { expect(coerceFieldValue(field({ type: "string", min: undefined, max: undefined }), "")).toBe(""); }); - it.each(["TRUE", "yes", "1", ""])("rejects non-canonical boolean %j", (input) => { + it.each([ + ["True", true], + ["FALSE", false], + [" true ", true], + ] as const)("coerces common configuration boolean %j", (input, expected) => { + expect( + coerceFieldValue( + field({ type: "boolean", min: undefined, max: undefined }), + input, + ), + ).toBe(expected); + }); + + it.each(["yes", "1", ""])("rejects non-boolean value %j", (input) => { expect(() => coerceFieldValue( field({ type: "boolean", min: undefined, max: undefined }), @@ -31,7 +44,13 @@ describe("coerceFieldValue", () => { ).toThrow(/true or false/); }); - it.each(["1.5", "NaN", "Infinity", "", " "])( + it.each([ + ["5000.000000", 5000], + ] as const)("coerces numerically integral configuration value %j", (input, expected) => { + expect(coerceFieldValue(field(), input)).toBe(expected); + }); + + it.each(["1.5", "5e3", "NaN", "Infinity", "", " "])( "rejects invalid integer %j", (input) => expect(() => coerceFieldValue(field(), input)).toThrow(/integer/), ); diff --git a/ui/src/config-editor/validation.ts b/ui/src/config-editor/validation.ts index 809b6408..47d17954 100644 --- a/ui/src/config-editor/validation.ts +++ b/ui/src/config-editor/validation.ts @@ -27,8 +27,9 @@ export const coerceFieldValue = ( } if (field.type === "boolean") { if (input === true || input === false) return input; - if (input === "true") return true; - if (input === "false") return false; + const value = scalarText(input).trim().toLowerCase(); + if (value === "true") return true; + if (value === "false") return false; throw new TypeError("Value must be true or false."); } @@ -42,7 +43,9 @@ export const coerceFieldValue = ( const text = scalarText(input).trim(); if (field.type === "integer") { - if (!/^[+-]?\d+$/.test(text)) throw new TypeError("Value must be an integer."); + if (!/^[+-]?\d+(?:\.0+)?$/.test(text)) { + throw new TypeError("Value must be an integer."); + } const value = Number(text); if (!Number.isSafeInteger(value)) throw new TypeError("Value must be an integer."); return value;