diff --git a/README.md b/README.md
index 6412cf4..e5d30bf 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,22 @@
# Stracciatella Toolset
-This is a prototye desktop application with the goal of being an editor for mods for JA2 Stracciatella.
+A desktop application to create mods for JA2 Straccatella.
+
+
## Features
-This app currently allows editing json data for JA2 Stracciatella only. Some JSONs might still be missing or broken.
+This app currently allows editing json data for JA2 Stracciatella. Most json files are supported, although some might still be missing. Known missing files are:
+
+- Dealer Inventories
+- Translations
## Install
+The best way to install the app is to download the latest release from the [releases page](https://github.com/ja2-stracciatella/stracciatella-toolset/releases).
+
+## Development Setup
+
Install build dependencies:
- [NodeJS / NPM](https://nodejs.org/)
@@ -19,7 +28,7 @@ Clone the repo and install node dependencies:
npm install
```
-## Starting Development
+## Starting the App for Development
Start the app in the `dev` environment:
@@ -27,18 +36,26 @@ Start the app in the `dev` environment:
npm start
```
-## Linting & Formatting
+## Testing
-Linting:
+You can run the following commands:
+
+Typechecking:
+```bash
+npm run tsc:check
```
+
+Linting:
+
+```bash
npm run lint
```
-Auto-fix:
+Tests (in watch mode):
-```
-npm run lint -- --fix
+```bash
+npm run test:watch
```
## Packaging the App
diff --git a/assets/icon.png b/assets/icon.png
old mode 100755
new mode 100644
index 755a6e5..3a65106
Binary files a/assets/icon.png and b/assets/icon.png differ
diff --git a/assets/icon.svg b/assets/icon.svg
index b064abf..bff0ec5 100644
--- a/assets/icon.svg
+++ b/assets/icon.svg
@@ -1,23 +1,235 @@
-
+
+
+
+
diff --git a/assets/icons/1024x1024.png b/assets/icons/1024x1024.png
index 5940b65..6a3382d 100644
Binary files a/assets/icons/1024x1024.png and b/assets/icons/1024x1024.png differ
diff --git a/assets/icons/128x128.png b/assets/icons/128x128.png
index 14e578d..41b7f23 100644
Binary files a/assets/icons/128x128.png and b/assets/icons/128x128.png differ
diff --git a/assets/icons/16x16.png b/assets/icons/16x16.png
index 260a46c..dd981d1 100644
Binary files a/assets/icons/16x16.png and b/assets/icons/16x16.png differ
diff --git a/assets/icons/24x24.png b/assets/icons/24x24.png
index 5617241..b4e1c13 100644
Binary files a/assets/icons/24x24.png and b/assets/icons/24x24.png differ
diff --git a/assets/icons/256x256.png b/assets/icons/256x256.png
index 755a6e5..3a65106 100644
Binary files a/assets/icons/256x256.png and b/assets/icons/256x256.png differ
diff --git a/assets/icons/32x32.png b/assets/icons/32x32.png
index 63423df..f1e9197 100644
Binary files a/assets/icons/32x32.png and b/assets/icons/32x32.png differ
diff --git a/assets/icons/48x48.png b/assets/icons/48x48.png
index 74d87a0..bf92f13 100644
Binary files a/assets/icons/48x48.png and b/assets/icons/48x48.png differ
diff --git a/assets/icons/512x512.png b/assets/icons/512x512.png
index 313cd49..c9c9712 100644
Binary files a/assets/icons/512x512.png and b/assets/icons/512x512.png differ
diff --git a/assets/icons/64x64.png b/assets/icons/64x64.png
index 6de0ec0..2001d05 100644
Binary files a/assets/icons/64x64.png and b/assets/icons/64x64.png differ
diff --git a/assets/icons/96x96.png b/assets/icons/96x96.png
index 8255ab5..454989d 100644
Binary files a/assets/icons/96x96.png and b/assets/icons/96x96.png differ
diff --git a/docs/screenshot.jpg b/docs/screenshot.jpg
new file mode 100644
index 0000000..e07a536
Binary files /dev/null and b/docs/screenshot.jpg differ
diff --git a/package.json b/package.json
index 02884ff..b1206f0 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
{
- "description": "A foundation for scalable desktop apps",
+ "description": "A desktop application to create mods for JA2 Straccatella.",
"license": "MIT",
"version": "0.22.0",
"main": "./.erb/dll/main.bundle.dev.js",
@@ -19,7 +19,8 @@
"start:main": "concurrently -k \"cross-env NODE_ENV=development webpack --watch --config ./.erb/configs/webpack.config.main.dev.mts\" \"electronmon .\"",
"start:preload": "cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.preload.dev.mts",
"start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.mts",
- "test": "cross-env NODE_ENV=development vitest --run"
+ "test": "cross-env NODE_ENV=development vitest run",
+ "test:watch": "cross-env NODE_ENV=development vitest watch"
},
"browserslist": [],
"prettier": {
diff --git a/release/app/package-lock.json b/release/app/package-lock.json
index 351a9da..b8676f9 100644
--- a/release/app/package-lock.json
+++ b/release/app/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "electron-react-boilerplate",
- "version": "4.6.0",
+ "name": "stracciatella-toolset",
+ "version": "0.22.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
- "name": "electron-react-boilerplate",
- "version": "4.6.0",
+ "name": "stracciatella-toolset",
+ "version": "0.22.0",
"hasInstallScript": true,
"license": "MIT"
}
diff --git a/release/app/package.json b/release/app/package.json
index dfe472d..b604c9f 100644
--- a/release/app/package.json
+++ b/release/app/package.json
@@ -1,12 +1,11 @@
{
- "name": "electron-react-boilerplate",
- "version": "4.6.0",
- "description": "A foundation for scalable desktop apps",
+ "name": "stracciatella-toolset",
+ "version": "0.22.0",
+ "description": "A desktop application to create mods for JA2 Straccatella.",
"license": "MIT",
"author": {
- "name": "Electron React Boilerplate Maintainers",
- "email": "electronreactboilerplate@gmail.com",
- "url": "https://github.com/electron-react-boilerplate"
+ "name": "JA2 Stracciatella Team",
+ "url": "https://github.com/ja2-stracciatella"
},
"main": "./dist/main/main.js",
"scripts": {
diff --git a/src-rust/src/invokables/resources.rs b/src-rust/src/invokables/resources.rs
index 4b1e906..e587dd5 100644
--- a/src-rust/src/invokables/resources.rs
+++ b/src-rust/src/invokables/resources.rs
@@ -13,8 +13,10 @@ pub enum ResourceEntry {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
pub struct List {
path: String,
+ mod_only: Option,
}
impl Invokable for List {
@@ -36,26 +38,28 @@ impl Invokable for List {
let selected_mod = state.try_selected_mod()?;
let mut result = HashSet::new();
- let candidates = selected_mod.vfs.read_dir(&Nfc::caseless(&self.path)).ok();
- for candidate in candidates.iter().flatten() {
- let path = if self.path.is_empty() {
- candidate.clone()
- } else {
- Nfc::caseless(&format!("{}/{}", self.path, candidate))
- };
- // Workaround to determine whether the candidate is a file
- let is_file = selected_mod.vfs.open(&path).is_ok();
- let entry = if is_file {
- ResourceEntry::File {
- path: candidate.to_string().to_lowercase(),
- }
- } else {
- ResourceEntry::Dir {
- path: candidate.to_string().to_lowercase(),
- }
- };
+ if !self.mod_only.unwrap_or(true) {
+ let candidates = selected_mod.vfs.read_dir(&Nfc::caseless(&self.path)).ok();
+ for candidate in candidates.iter().flatten() {
+ let path = if self.path.is_empty() {
+ candidate.clone()
+ } else {
+ Nfc::caseless(&format!("{}/{}", self.path, candidate))
+ };
+ // Workaround to determine whether the candidate is a file
+ let is_file = selected_mod.vfs.open(&path).is_ok();
+ let entry = if is_file {
+ ResourceEntry::File {
+ path: candidate.to_string().to_lowercase(),
+ }
+ } else {
+ ResourceEntry::Dir {
+ path: candidate.to_string().to_lowercase(),
+ }
+ };
- result.insert(entry);
+ result.insert(entry);
+ }
}
let dir = selected_mod.data_path(&self.path);
diff --git a/src/__tests__/renderer/App.test.tsx b/src/__tests__/renderer/App.test.tsx
index c66a529..97711aa 100644
--- a/src/__tests__/renderer/App.test.tsx
+++ b/src/__tests__/renderer/App.test.tsx
@@ -4,7 +4,7 @@ import { renderWithTestProviders } from './test-utils/render';
import { it, describe, expect } from 'vitest';
describe('App', () => {
- it('should render the configuration dialog when toolset is not configured', async () => {
+ it('should render loading as the default', async () => {
const { getByLabelText } = renderWithTestProviders();
await waitFor(() => {
diff --git a/src/__tests__/renderer/state/files.test.tsx b/src/__tests__/renderer/state/files.test.tsx
new file mode 100644
index 0000000..16be0ca
--- /dev/null
+++ b/src/__tests__/renderer/state/files.test.tsx
@@ -0,0 +1,1280 @@
+import { it, describe, expect } from 'vitest';
+import { createAppStore } from '../../../renderer/state/store';
+import {
+ changeEditMode,
+ changeJson,
+ changeSaveMode,
+ changeText,
+ loadJSON,
+ persistJSON,
+} from '../../../renderer/state/files';
+import { getInvokeMock, InvokeMock } from '../test-utils/invoke';
+import { miniSerializeError } from '@reduxjs/toolkit';
+import { JsonReadInvokable } from 'src/common/invokables/jsons';
+import { InvokableOutput } from 'src/common/invokables';
+
+const TEST_FILE = 'testfile.json';
+const TEST_SCHEMA = {
+ type: 'object',
+ properties: { test: { type: 'string' } },
+ required: ['test'],
+};
+const TEST_VANILLA = { test: 'vanilla value' };
+const TEST_REPLACE = { test: 'replace value' };
+const TEST_PATCH = [
+ { op: 'replace' as const, path: '/test', value: 'patch value' },
+];
+const TEST_PATCHED = { test: 'patch value' };
+const TEST_DEFAULT_DISK_STATE = {
+ loading: false,
+ loadingError: null,
+ persisting: false,
+ persistingError: null,
+ data: {
+ title: TEST_FILE,
+ description: null,
+ schema: TEST_SCHEMA,
+ itemSchema: null,
+ vanilla: TEST_VANILLA,
+ mod: null,
+ patch: null,
+ applied: TEST_VANILLA,
+ saveMode: 'patch',
+ },
+};
+
+function resolveJsonRead(
+ invokeMock: InvokeMock,
+ data: Partial> = {},
+) {
+ invokeMock.resolve(
+ 'json/read',
+ { file: TEST_FILE },
+ {
+ schema: TEST_SCHEMA,
+ vanilla: TEST_VANILLA,
+ value: null,
+ patch: null,
+ ...data,
+ },
+ );
+}
+
+async function setupTestFile(
+ readOutput: Partial> = {},
+) {
+ const appStore = createAppStore();
+ const invokeMock = getInvokeMock();
+
+ resolveJsonRead(invokeMock, readOutput);
+ await appStore.dispatch(loadJSON(TEST_FILE));
+
+ return { appStore, invokeMock };
+}
+
+describe('files state', () => {
+ describe('loadJson', () => {
+ it('should load a file without mod values', async () => {
+ const { appStore } = await setupTestFile();
+
+ expect(appStore.getState().files.disk[TEST_FILE]).toEqual(
+ TEST_DEFAULT_DISK_STATE,
+ );
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ saveMode: 'patch',
+ editMode: 'visual',
+ modified: false,
+ value: TEST_VANILLA,
+ });
+ });
+
+ it('should load a file with a mod patch', async () => {
+ const { appStore } = await setupTestFile({
+ patch: TEST_PATCH,
+ });
+
+ expect(appStore.getState().files.disk[TEST_FILE]).toEqual({
+ ...TEST_DEFAULT_DISK_STATE,
+ data: {
+ ...TEST_DEFAULT_DISK_STATE.data,
+ patch: TEST_PATCH,
+ applied: TEST_PATCHED,
+ },
+ });
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ saveMode: 'patch',
+ editMode: 'visual',
+ modified: false,
+ value: TEST_PATCHED,
+ });
+ });
+
+ it('should load a file with a mod replacement value', async () => {
+ const { appStore } = await setupTestFile({
+ value: TEST_REPLACE,
+ });
+
+ expect(appStore.getState().files.disk[TEST_FILE]).toEqual({
+ ...TEST_DEFAULT_DISK_STATE,
+ data: {
+ ...TEST_DEFAULT_DISK_STATE.data,
+ saveMode: 'replace',
+ mod: TEST_REPLACE,
+ applied: TEST_REPLACE,
+ },
+ });
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ saveMode: 'replace',
+ editMode: 'visual',
+ modified: false,
+ value: TEST_REPLACE,
+ });
+ });
+
+ it('should set an error on invoke failures', async () => {
+ const appStore = createAppStore();
+ const invokeMock = getInvokeMock();
+ const error = new Error('test error');
+
+ invokeMock.reject('json/read', { file: TEST_FILE }, error);
+
+ await appStore.dispatch(loadJSON(TEST_FILE));
+
+ expect(appStore.getState().files.disk[TEST_FILE]).toEqual({
+ loading: false,
+ loadingError: miniSerializeError(error),
+ persisting: false,
+ persistingError: null,
+ data: null,
+ });
+ });
+ });
+
+ describe('changeJson', () => {
+ it('should update a json file and set the modified flag', async () => {
+ const { appStore } = await setupTestFile();
+
+ appStore.dispatch(
+ changeJson({
+ filename: TEST_FILE,
+ value: {
+ test: 'foo without bar',
+ },
+ }),
+ );
+
+ expect(appStore.getState().files.disk[TEST_FILE]).toEqual(
+ TEST_DEFAULT_DISK_STATE,
+ );
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ saveMode: 'patch',
+ editMode: 'visual',
+ modified: true,
+ value: { test: 'foo without bar' },
+ });
+ });
+ });
+
+ describe('changeEditMode', () => {
+ describe('visual to text', () => {
+ [
+ {
+ name: 'without preexisting data in patch save mode',
+ readOutput: {},
+ expectedNotModified: {
+ saveMode: 'patch',
+ editMode: 'text',
+ modified: false,
+ value: JSON.stringify([], null, 4),
+ },
+ expectedModified: {
+ saveMode: 'patch',
+ editMode: 'text',
+ modified: true,
+ value: JSON.stringify(
+ [{ op: 'replace', path: '/test', value: 'foo without bar' }],
+ null,
+ 4,
+ ),
+ },
+ },
+ {
+ name: 'with preexisting data in patch save mode',
+ readOutput: {
+ patch: TEST_PATCH,
+ },
+ expectedNotModified: {
+ saveMode: 'patch',
+ editMode: 'text',
+ modified: false,
+ value: JSON.stringify(TEST_PATCH, null, 4),
+ },
+ expectedModified: {
+ saveMode: 'patch',
+ editMode: 'text',
+ modified: true,
+ value: JSON.stringify(
+ [{ op: 'replace', path: '/test', value: 'foo without bar' }],
+ null,
+ 4,
+ ),
+ },
+ },
+ {
+ name: 'with preexisting data in replace save mode',
+ readOutput: {
+ value: TEST_REPLACE,
+ },
+ expectedNotModified: {
+ saveMode: 'replace',
+ editMode: 'text',
+ modified: false,
+ value: JSON.stringify(TEST_REPLACE, null, 4),
+ },
+ expectedModified: {
+ saveMode: 'replace',
+ editMode: 'text',
+ modified: true,
+ value: JSON.stringify(
+ {
+ test: 'foo without bar',
+ },
+ null,
+ 4,
+ ),
+ },
+ },
+ ].forEach(
+ ({ name, readOutput, expectedNotModified, expectedModified }) => {
+ describe(name, () => {
+ it('should switch edit mode from visual to text when file was not modified', async () => {
+ const { appStore } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual(
+ expectedNotModified,
+ );
+ });
+
+ it('should switch edit mode when file was modified', async () => {
+ const { appStore } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeJson({
+ filename: TEST_FILE,
+ value: {
+ test: 'foo without bar',
+ },
+ }),
+ );
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual(
+ expectedModified,
+ );
+ });
+ });
+ },
+ );
+ });
+
+ // Note that these tests depend on the correct behavior of the visual to text
+ describe('text to visual', () => {
+ [
+ {
+ name: 'without preexisting data in patch save mode',
+ readOutput: {},
+ modification: TEST_PATCH,
+ expectedSaveMode: 'patch',
+ expectedNotModified: {
+ editMode: 'visual',
+ modified: false,
+ saveMode: 'patch',
+ value: TEST_VANILLA,
+ },
+ expectedModified: {
+ editMode: 'visual',
+ modified: true,
+ saveMode: 'patch',
+ value: TEST_PATCHED,
+ },
+ },
+ {
+ name: 'with preexisting data in patch save mode',
+ readOutput: {
+ patch: TEST_PATCH,
+ },
+ modification: [
+ {
+ ...TEST_PATCH[0],
+ value: 'other patch',
+ },
+ ],
+ expectedSaveMode: 'patch',
+ expectedNotModified: {
+ saveMode: 'patch',
+ editMode: 'visual',
+ modified: false,
+ value: TEST_PATCHED,
+ },
+ expectedModified: {
+ saveMode: 'patch',
+ editMode: 'visual',
+ modified: true,
+ value: {
+ test: 'other patch',
+ },
+ },
+ },
+ {
+ name: 'with preexisting data in replace save mode',
+ readOutput: {
+ value: TEST_REPLACE,
+ },
+ modification: {
+ test: 'other value',
+ },
+ expectedSaveMode: 'replace',
+ expectedNotModified: {
+ saveMode: 'replace',
+ editMode: 'visual',
+ modified: false,
+ value: TEST_REPLACE,
+ },
+ expectedModified: {
+ saveMode: 'replace',
+ editMode: 'visual',
+ modified: true,
+ value: {
+ test: 'other value',
+ },
+ },
+ },
+ ].forEach(
+ ({
+ name,
+ readOutput,
+ modification,
+ expectedSaveMode,
+ expectedNotModified,
+ expectedModified,
+ }) => {
+ describe(name, () => {
+ [
+ {
+ name: 'json',
+ value: 'foobar',
+ },
+ {
+ name: 'patch',
+ value: '"foobar"',
+ },
+ ].forEach(({ name, value }) => {
+ it(`should do nothing if the text is not a valid ${name} value`, async () => {
+ const { appStore } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value,
+ }),
+ );
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'visual',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ modified: true,
+ saveMode: expectedSaveMode,
+ value,
+ });
+ });
+ });
+
+ it('should switch edit mode for non modified files', async () => {
+ const { appStore } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'visual',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual(
+ expectedNotModified,
+ );
+ });
+
+ it('should switch edit mode for modified files', async () => {
+ const appStore = createAppStore();
+ const invokeMock = getInvokeMock();
+
+ resolveJsonRead(invokeMock, readOutput);
+ await appStore.dispatch(loadJSON(TEST_FILE));
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value: JSON.stringify(modification),
+ }),
+ );
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'visual',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual(
+ expectedModified,
+ );
+ });
+ });
+ },
+ );
+ });
+ });
+
+ describe('changeSaveMode', () => {
+ describe('in visual edit mode', () => {
+ async function setup(
+ readOutput: Partial>,
+ ) {
+ const { appStore, invokeMock } = await setupTestFile(readOutput);
+
+ expect(appStore.getState().files.open[TEST_FILE]?.saveMode).toBe(
+ 'patch',
+ );
+
+ return { appStore, invokeMock };
+ }
+
+ [
+ {
+ name: 'without mod values',
+ readOutput: {},
+ expectedValue: TEST_VANILLA,
+ },
+ {
+ name: 'with patch mod value',
+ readOutput: {
+ patch: TEST_PATCH,
+ },
+ expectedValue: TEST_PATCHED,
+ },
+ ].forEach(({ name, readOutput, expectedValue }) => {
+ describe(name, () => {
+ it('should change modified state', async () => {
+ const { appStore } = await setup(readOutput);
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'replace',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'visual',
+ saveMode: 'replace',
+ modified: true,
+ value: expectedValue,
+ });
+ });
+ });
+ });
+ });
+
+ describe('in text edit mode', () => {
+ describe('patch to replace', () => {
+ async function setup(
+ readOutput: Partial>,
+ ) {
+ const { appStore, invokeMock } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.saveMode).toBe(
+ 'patch',
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.editMode).toBe(
+ 'text',
+ );
+
+ return {
+ appStore,
+ invokeMock,
+ };
+ }
+
+ [
+ { name: 'without mod data', readOutput: {}, expected: TEST_VANILLA },
+ {
+ name: 'with mod data',
+ readOutput: {
+ patch: TEST_PATCH,
+ },
+ expected: TEST_PATCHED,
+ },
+ ].forEach(({ name, readOutput, expected }) => {
+ describe(name, () => {
+ [
+ {
+ name: 'json',
+ value: 'foobar',
+ },
+ {
+ name: 'patch',
+ value: '"foobar"',
+ },
+ ].forEach(({ name, value }) => {
+ it(`should do nothing if value is not ${name}`, async () => {
+ const { appStore } = await setup(readOutput);
+
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value,
+ }),
+ );
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'replace',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'patch',
+ modified: true,
+ value,
+ });
+ });
+ });
+
+ it(`should change editor value to full value`, async () => {
+ const { appStore } = await setup(readOutput);
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'replace',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'replace',
+ modified: true,
+ value: JSON.stringify(expected, null, 4),
+ });
+ });
+
+ it(`should change editor value to full value when modified`, async () => {
+ const { appStore } = await setup(readOutput);
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value: JSON.stringify([
+ {
+ ...TEST_PATCH[0],
+ value: 'other patch',
+ },
+ ]),
+ }),
+ );
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'replace',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'replace',
+ modified: true,
+ value: JSON.stringify({ test: 'other patch' }, null, 4),
+ });
+ });
+ });
+ });
+ });
+
+ describe('replace to patch', () => {
+ async function setup(
+ readOutput: Partial>,
+ ) {
+ const { appStore, invokeMock } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'replace',
+ }),
+ );
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.saveMode).toBe(
+ 'replace',
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.editMode).toBe(
+ 'text',
+ );
+
+ return {
+ appStore,
+ invokeMock,
+ };
+ }
+
+ [
+ {
+ name: 'without mod data',
+ readOutput: {},
+ expectedModified: false,
+ expected: [],
+ },
+ {
+ name: 'with mod data',
+ readOutput: {
+ value: TEST_REPLACE,
+ },
+ expectedModified: true,
+ expected: [
+ {
+ op: 'replace',
+ path: '/test',
+ value: 'replace value',
+ },
+ ],
+ },
+ ].forEach(({ name, readOutput, expectedModified, expected }) => {
+ describe(name, () => {
+ [
+ {
+ name: 'json',
+ value: 'foobar',
+ },
+ {
+ name: 'json root',
+ value: '"foobar"',
+ },
+ ].forEach(({ name, value }) => {
+ it(`should do nothing if value is not ${name}`, async () => {
+ const { appStore } = await setup(readOutput);
+
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value,
+ }),
+ );
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'patch',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'replace',
+ modified: true,
+ value,
+ });
+ });
+ });
+
+ it(`should change editor value to patch value`, async () => {
+ const { appStore } = await setup(readOutput);
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'patch',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'patch',
+ modified: expectedModified,
+ value: JSON.stringify(expected, null, 4),
+ });
+ });
+
+ it(`should change editor value to patch value after modification`, async () => {
+ const { appStore } = await setup(readOutput);
+
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value: '{}',
+ }),
+ );
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'patch',
+ }),
+ );
+
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'patch',
+ modified: true,
+ value: JSON.stringify(
+ [{ op: 'remove', path: '/test' }],
+ null,
+ 4,
+ ),
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+
+ describe('persistJSON', () => {
+ describe('in visual edit mode', () => {
+ describe('in patch save mode', async () => {
+ async function setup(
+ readOutput: Partial>,
+ ) {
+ const { appStore, invokeMock } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'patch',
+ }),
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.saveMode).toBe(
+ 'patch',
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.editMode).toBe(
+ 'visual',
+ );
+
+ return {
+ appStore,
+ invokeMock,
+ };
+ }
+
+ [
+ {
+ name: 'without mod data',
+ readOutput: {},
+ expectedNotModified: { patch: null, applied: TEST_VANILLA },
+ },
+ {
+ name: 'with mod patch data',
+ readOutput: { patch: TEST_PATCH },
+ expectedNotModified: {
+ patch: TEST_PATCH,
+ applied: TEST_PATCHED,
+ },
+ },
+ {
+ name: 'with mod replace data',
+ readOutput: { value: TEST_REPLACE },
+ expectedNotModified: {
+ patch: [
+ {
+ op: 'replace' as const,
+ path: '/test',
+ value: TEST_REPLACE.test,
+ },
+ ],
+ applied: TEST_REPLACE,
+ },
+ },
+ ].forEach(({ name, readOutput, expectedNotModified }) => {
+ describe(name, () => {
+ it('should save the correct patch', async () => {
+ const { appStore, invokeMock } = await setup(readOutput);
+
+ invokeMock.resolve(
+ 'json/persist',
+ {
+ file: TEST_FILE,
+ value: null,
+ patch: expectedNotModified.patch,
+ },
+ {
+ schema: TEST_SCHEMA,
+ vanilla: TEST_VANILLA,
+ value: null,
+ patch: expectedNotModified.patch,
+ },
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError,
+ ).toBeNull();
+ expect(appStore.getState().files.disk[TEST_FILE]?.data).toEqual({
+ schema: TEST_SCHEMA,
+ itemSchema: null,
+ title: TEST_FILE,
+ description: null,
+ saveMode: 'patch',
+ vanilla: TEST_VANILLA,
+ mod: null,
+ ...expectedNotModified,
+ });
+ });
+
+ it('should save the correct patch after modifications', async () => {
+ const { appStore, invokeMock } = await setup(readOutput);
+ const expectedPatch = [{ op: 'remove' as const, path: '/test' }];
+
+ invokeMock.resolve(
+ 'json/persist',
+ {
+ file: TEST_FILE,
+ value: null,
+ patch: expectedPatch,
+ },
+ {
+ schema: TEST_SCHEMA,
+ vanilla: TEST_VANILLA,
+ value: null,
+ patch: expectedPatch,
+ },
+ );
+ appStore.dispatch(
+ changeJson({
+ filename: TEST_FILE,
+ value: {},
+ }),
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError,
+ ).toBeNull();
+ expect(appStore.getState().files.disk[TEST_FILE]?.data).toEqual({
+ schema: TEST_SCHEMA,
+ itemSchema: null,
+ title: TEST_FILE,
+ description: null,
+ saveMode: 'patch',
+ vanilla: TEST_VANILLA,
+ mod: null,
+ patch: expectedPatch,
+ applied: {},
+ });
+ });
+ });
+ });
+ });
+
+ describe('in replace save mode', () => {
+ async function setup(
+ readOutput: Partial>,
+ ) {
+ const { appStore, invokeMock } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'replace',
+ }),
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.saveMode).toBe(
+ 'replace',
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.editMode).toBe(
+ 'visual',
+ );
+
+ return {
+ appStore,
+ invokeMock,
+ };
+ }
+
+ [
+ {
+ name: 'without mod data',
+ readOutput: {},
+ expectedNotModified: { mod: TEST_VANILLA, applied: TEST_VANILLA },
+ },
+ {
+ name: 'with mod patch data',
+ readOutput: { patch: TEST_PATCH },
+ expectedNotModified: {
+ mod: TEST_PATCHED,
+ applied: TEST_PATCHED,
+ },
+ },
+ {
+ name: 'with mod replace data',
+ readOutput: { value: TEST_REPLACE },
+ expectedNotModified: {
+ mod: TEST_REPLACE,
+ applied: TEST_REPLACE,
+ },
+ },
+ ].forEach(({ name, readOutput, expectedNotModified }) => {
+ describe(name, () => {
+ it('should save the correct patch', async () => {
+ const { appStore, invokeMock } = await setup(readOutput);
+
+ invokeMock.resolve(
+ 'json/persist',
+ {
+ file: TEST_FILE,
+ value: expectedNotModified.mod,
+ patch: null,
+ },
+ {
+ schema: TEST_SCHEMA,
+ vanilla: TEST_VANILLA,
+ value: expectedNotModified.mod,
+ patch: null,
+ },
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError,
+ ).toBeNull();
+ expect(appStore.getState().files.disk[TEST_FILE]?.data).toEqual({
+ schema: TEST_SCHEMA,
+ itemSchema: null,
+ title: TEST_FILE,
+ description: null,
+ saveMode: 'replace',
+ vanilla: TEST_VANILLA,
+ patch: null,
+ ...expectedNotModified,
+ });
+ });
+
+ it('should save the correct patch after modifications', async () => {
+ const { appStore, invokeMock } = await setup(readOutput);
+
+ invokeMock.resolve(
+ 'json/persist',
+ {
+ file: TEST_FILE,
+ value: {},
+ patch: null,
+ },
+ {
+ schema: TEST_SCHEMA,
+ vanilla: TEST_VANILLA,
+ value: {},
+ patch: null,
+ },
+ );
+ appStore.dispatch(
+ changeJson({
+ filename: TEST_FILE,
+ value: {},
+ }),
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError,
+ ).toBeNull();
+ expect(appStore.getState().files.disk[TEST_FILE]?.data).toEqual({
+ schema: TEST_SCHEMA,
+ itemSchema: null,
+ title: TEST_FILE,
+ description: null,
+ saveMode: 'replace',
+ vanilla: TEST_VANILLA,
+ mod: {},
+ patch: null,
+ applied: {},
+ });
+ });
+ });
+ });
+ });
+ });
+
+ describe('in text edit mode', () => {
+ describe('in patch mode', () => {
+ async function setup(
+ readOutput: Partial>,
+ ) {
+ const { appStore, invokeMock } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.saveMode).toBe(
+ 'patch',
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.editMode).toBe(
+ 'text',
+ );
+
+ return {
+ appStore,
+ invokeMock,
+ };
+ }
+
+ [
+ {
+ name: 'valid json',
+ value: 'test',
+ expected: 'current editor value is not valid JSON',
+ },
+ {
+ name: 'a valid json patch',
+ value: '{}',
+ expected: 'current editor value is not a valid JSON patch',
+ },
+ ].forEach(({ name, value, expected }) => {
+ it(`should throw an error when the editor value is not ${name}`, async () => {
+ const { appStore } = await setup({});
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value,
+ }),
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError
+ ?.message,
+ ).toContain(expected);
+ expect(appStore.getState().files.open[TEST_FILE]?.modified).toBe(
+ true,
+ );
+ });
+ });
+
+ it('should persist the current editor value', async () => {
+ const { appStore, invokeMock } = await setup({});
+ const patch = [
+ {
+ op: 'add' as const,
+ path: '/test',
+ value: 'test',
+ },
+ ];
+
+ invokeMock.resolve(
+ 'json/persist',
+ {
+ file: TEST_FILE,
+ value: null,
+ patch,
+ },
+ {
+ vanilla: TEST_VANILLA,
+ schema: TEST_SCHEMA,
+ value: null,
+ patch,
+ },
+ );
+
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value: JSON.stringify(patch),
+ }),
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError,
+ ).toBeNull();
+ expect(appStore.getState().files.disk[TEST_FILE]?.data).toEqual({
+ title: TEST_FILE,
+ description: null,
+ vanilla: TEST_VANILLA,
+ schema: TEST_SCHEMA,
+ itemSchema: null,
+ saveMode: 'patch',
+ mod: null,
+ patch,
+ applied: {
+ test: 'test',
+ },
+ });
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'patch',
+ modified: false,
+ value: JSON.stringify(patch),
+ });
+ });
+ });
+ });
+
+ describe('in replace mode', () => {
+ async function setup(
+ readOutput: Partial>,
+ ) {
+ const { appStore, invokeMock } = await setupTestFile(readOutput);
+
+ appStore.dispatch(
+ changeSaveMode({
+ filename: TEST_FILE,
+ saveMode: 'replace',
+ }),
+ );
+ appStore.dispatch(
+ changeEditMode({
+ filename: TEST_FILE,
+ editMode: 'text',
+ }),
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.saveMode).toBe(
+ 'replace',
+ );
+ expect(appStore.getState().files.open[TEST_FILE]?.editMode).toBe(
+ 'text',
+ );
+
+ return {
+ appStore,
+ invokeMock,
+ };
+ }
+
+ [
+ {
+ name: 'valid json',
+ value: 'test',
+ expected: 'current editor value is not valid JSON',
+ },
+ {
+ name: 'a valid json root',
+ value: '1',
+ expected: 'current editor value is not a valid JSON root',
+ },
+ ].forEach(({ name, value, expected }) => {
+ it(`should throw an error when the editor value is not ${name}`, async () => {
+ const { appStore } = await setup({});
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value,
+ }),
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError?.message,
+ ).toContain(expected);
+ expect(appStore.getState().files.open[TEST_FILE]?.modified).toBe(
+ true,
+ );
+ });
+ });
+
+ it('should persist the current editor value', async () => {
+ const { appStore, invokeMock } = await setup({});
+ const value = {
+ test: 'test',
+ };
+
+ invokeMock.resolve(
+ 'json/persist',
+ {
+ file: TEST_FILE,
+ value,
+ patch: null,
+ },
+ {
+ vanilla: TEST_VANILLA,
+ schema: TEST_SCHEMA,
+ value,
+ patch: null,
+ },
+ );
+
+ appStore.dispatch(
+ changeText({
+ filename: TEST_FILE,
+ value: JSON.stringify(value),
+ }),
+ );
+ await appStore.dispatch(persistJSON(TEST_FILE));
+
+ expect(
+ appStore.getState().files.disk[TEST_FILE]?.persistingError,
+ ).toBeNull();
+ expect(appStore.getState().files.disk[TEST_FILE]?.data).toEqual({
+ title: TEST_FILE,
+ description: null,
+ vanilla: TEST_VANILLA,
+ schema: TEST_SCHEMA,
+ itemSchema: null,
+ saveMode: 'replace',
+ mod: value,
+ patch: null,
+ applied: {
+ test: 'test',
+ },
+ });
+ expect(appStore.getState().files.open[TEST_FILE]).toEqual({
+ editMode: 'text',
+ saveMode: 'replace',
+ modified: false,
+ value: JSON.stringify(value),
+ });
+ });
+ });
+ });
+});
diff --git a/src/__tests__/renderer/test-utils/invoke.tsx b/src/__tests__/renderer/test-utils/invoke.tsx
new file mode 100644
index 0000000..ed9128f
--- /dev/null
+++ b/src/__tests__/renderer/test-utils/invoke.tsx
@@ -0,0 +1,92 @@
+import { isDeepEqual } from 'remeda';
+import {
+ AnyInvokableName,
+ InvokableFromName,
+ InvokableInput,
+ InvokableOutput,
+} from 'src/common/invokables';
+import { MockedFunction } from 'vitest';
+
+type MockedInvoke = MockedFunction;
+
+type InvokeResult =
+ | {
+ type: 'reject';
+ error: Error;
+ }
+ | {
+ type: 'resolve';
+ output: unknown;
+ };
+
+export class InvokeMock {
+ private invoke: MockedInvoke;
+ private expectedCalls: Array<{
+ name: AnyInvokableName;
+ input: unknown;
+ result: InvokeResult;
+ }>;
+
+ constructor(invoke: typeof window.electronAPI.invoke) {
+ this.invoke = invoke as MockedInvoke;
+ this.invoke.mockImplementation(async (payload) =>
+ this.mockImplementation(payload),
+ );
+ this.expectedCalls = [];
+ }
+
+ private mockImplementation(
+ payload: Parameters[0],
+ ) {
+ for (const call of this.expectedCalls) {
+ if (
+ isDeepEqual(payload, {
+ name: call.name,
+ input: call.input,
+ })
+ ) {
+ if (call.result.type === 'resolve') {
+ return call.result.output;
+ }
+ throw call.result.error;
+ }
+ }
+ throw new Error(
+ `Unexpected invoke call to ${payload.name} with input ${JSON.stringify(payload.input)}`,
+ );
+ }
+
+ resolve(
+ name: T,
+ input: InvokableInput>,
+ output: InvokableOutput>,
+ ) {
+ this.expectedCalls.push({
+ name,
+ input,
+ result: {
+ type: 'resolve',
+ output,
+ },
+ });
+ }
+
+ reject(
+ name: T,
+ input: InvokableInput>,
+ error: Error,
+ ) {
+ this.expectedCalls.push({
+ name,
+ input,
+ result: {
+ type: 'reject',
+ error,
+ },
+ });
+ }
+}
+
+export function getInvokeMock() {
+ return new InvokeMock(window.electronAPI.invoke);
+}
diff --git a/src/__tests__/renderer/test-utils/render.tsx b/src/__tests__/renderer/test-utils/render.tsx
index 8c28d54..655be03 100644
--- a/src/__tests__/renderer/test-utils/render.tsx
+++ b/src/__tests__/renderer/test-utils/render.tsx
@@ -1,9 +1,10 @@
import { render } from '@testing-library/react';
import { ReactElement } from 'react';
-import { appStore } from '../../../renderer/state/store';
+import { createAppStore } from '../../../renderer/state/store';
import { Provider } from 'react-redux';
export function renderWithTestProviders(element: ReactElement) {
+ const appStore = createAppStore();
return {
...render({element}),
};
diff --git a/src/__tests__/vitest.setup.ts b/src/__tests__/vitest.setup.ts
index 325e9a3..b45594f 100644
--- a/src/__tests__/vitest.setup.ts
+++ b/src/__tests__/vitest.setup.ts
@@ -1,25 +1,19 @@
import '@testing-library/jest-dom/vitest';
-import { beforeAll, vi } from 'vitest';
+import { beforeEach, vi } from 'vitest';
-beforeAll(() => {
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: vi.fn().mockImplementation((query) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: vi.fn(), // Deprecated
- removeListener: vi.fn(), // Deprecated
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- });
- Object.defineProperty(window, 'electronAPI', {
- writable: false,
- value: {
- invoke: vi.fn(),
- onMainEvent: vi.fn(),
- },
- });
+beforeEach(() => {
+ window.matchMedia = vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(), // Deprecated
+ removeListener: vi.fn(), // Deprecated
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+ window.electronAPI = {
+ invoke: vi.fn(),
+ onMainEvent: vi.fn(),
+ };
});
diff --git a/src/common/invokables/resources.ts b/src/common/invokables/resources.ts
index 56160cc..7d0c285 100644
--- a/src/common/invokables/resources.ts
+++ b/src/common/invokables/resources.ts
@@ -17,7 +17,8 @@ const RESOURCE_ENTRY_SCHEMA = z.union([
export type ResourceEntry = z.infer;
const LIST_INPUT_SCHEMA = z.object({
- path: z.nullable(z.string()),
+ path: z.string(),
+ modOnly: z.optional(z.boolean()),
});
const LIST_OUTPUT_SCHEMA = z.array(RESOURCE_ENTRY_SCHEMA);
diff --git a/src/main/menu.ts b/src/main/menu.ts
index ba0fb77..4039866 100644
--- a/src/main/menu.ts
+++ b/src/main/menu.ts
@@ -11,6 +11,28 @@ interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
+const HELP_MENU: MenuItemConstructorOptions = {
+ label: 'Help',
+ submenu: [
+ {
+ label: 'Learn More',
+ click() {
+ shell.openExternal(
+ 'https://github.com/ja2-stracciatella/stracciatella-toolset',
+ );
+ },
+ },
+ {
+ label: 'Report an Issue',
+ click() {
+ shell.openExternal(
+ 'https://github.com/ja2-stracciatella/stracciatella-toolset/issues',
+ );
+ },
+ },
+ ],
+};
+
export default class MenuBuilder {
mainWindow: BrowserWindow;
@@ -23,7 +45,7 @@ export default class MenuBuilder {
process.env.NODE_ENV === 'development' ||
process.env.DEBUG_PROD === 'true'
) {
- this.setupDevelopmentEnvironment();
+ this.setupDevelopmentContextMenu();
}
const template =
@@ -37,7 +59,7 @@ export default class MenuBuilder {
return menu;
}
- setupDevelopmentEnvironment(): void {
+ setupDevelopmentContextMenu(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
@@ -54,17 +76,17 @@ export default class MenuBuilder {
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
- label: 'Electron',
+ label: 'Stracciatella Toolset',
submenu: [
{
- label: 'About ElectronReact',
+ label: 'About Stracciatella Toolset',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
- label: 'Hide ElectronReact',
+ label: 'Hide Stracciatella Toolset',
accelerator: 'Command+H',
selector: 'hide:',
},
@@ -84,22 +106,6 @@ export default class MenuBuilder {
},
],
};
- const subMenuEdit: DarwinMenuItemConstructorOptions = {
- label: 'Edit',
- submenu: [
- { label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' },
- { label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' },
- { type: 'separator' },
- { label: 'Cut', accelerator: 'Command+X', selector: 'cut:' },
- { label: 'Copy', accelerator: 'Command+C', selector: 'copy:' },
- { label: 'Paste', accelerator: 'Command+V', selector: 'paste:' },
- {
- label: 'Select All',
- accelerator: 'Command+A',
- selector: 'selectAll:',
- },
- ],
- };
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
@@ -151,37 +157,6 @@ export default class MenuBuilder {
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
- const subMenuHelp: MenuItemConstructorOptions = {
- label: 'Help',
- submenu: [
- {
- label: 'Learn More',
- click() {
- shell.openExternal('https://electronjs.org');
- },
- },
- {
- label: 'Documentation',
- click() {
- shell.openExternal(
- 'https://github.com/electron/electron/tree/main/docs#readme',
- );
- },
- },
- {
- label: 'Community Discussions',
- click() {
- shell.openExternal('https://www.electronjs.org/community');
- },
- },
- {
- label: 'Search Issues',
- click() {
- shell.openExternal('https://github.com/electron/electron/issues');
- },
- },
- ],
- };
const subMenuView =
process.env.NODE_ENV === 'development' ||
@@ -189,7 +164,7 @@ export default class MenuBuilder {
? subMenuViewDev
: subMenuViewProd;
- return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
+ return [subMenuAbout, subMenuView, subMenuWindow, HELP_MENU];
}
buildDefaultTemplate() {
@@ -197,10 +172,6 @@ export default class MenuBuilder {
{
label: '&File',
submenu: [
- {
- label: '&Open',
- accelerator: 'Ctrl+O',
- },
{
label: '&Close',
accelerator: 'Ctrl+W',
@@ -252,37 +223,7 @@ export default class MenuBuilder {
},
],
},
- {
- label: 'Help',
- submenu: [
- {
- label: 'Learn More',
- click() {
- shell.openExternal('https://electronjs.org');
- },
- },
- {
- label: 'Documentation',
- click() {
- shell.openExternal(
- 'https://github.com/electron/electron/tree/main/docs#readme',
- );
- },
- },
- {
- label: 'Community Discussions',
- click() {
- shell.openExternal('https://www.electronjs.org/community');
- },
- },
- {
- label: 'Search Issues',
- click() {
- shell.openExternal('https://github.com/electron/electron/issues');
- },
- },
- ],
- },
+ HELP_MENU,
];
return templateDefault;
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 20b6358..0688a1b 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1,4 +1,4 @@
-import { BrowserRouter, Route, Routes } from 'react-router-dom';
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import 'antd/dist/reset.css';
import '@ant-design/v5-patch-for-react-19';
import { useMemo } from 'react';
@@ -6,10 +6,11 @@ import { ROUTES } from './EditorRoutes';
import './App.css';
import { WithToolsetConfig } from './components/WithToolsetConfig';
import { WithSelectedMod } from './components/selectedMod/WithSelectedMod';
-import { EditorLayout } from './components/layout/EditorLayout';
+import { ToolsetLayout } from './components/layout/ToolsetLayout';
import { Provider } from 'react-redux';
-import { appStore } from './state/store';
+import { createAppStore } from './state/store';
import { ListenAll } from './components/events/ListenAll';
+import { ConfigProvider, ThemeConfig } from 'antd';
export function AppWithoutProviders() {
const routes = useMemo(() => {
@@ -24,9 +25,15 @@ export function AppWithoutProviders() {
-
- {routes}
-
+
+
+ {routes}
+ }
+ />
+
+
@@ -34,12 +41,21 @@ export function AppWithoutProviders() {
);
}
+const appStore = createAppStore();
+const THEME_CONFIG: ThemeConfig = {
+ token: {
+ colorPrimary: '#9d1e1c',
+ },
+};
+
export default function App() {
return (
);
}
diff --git a/src/renderer/components/Dashboard.tsx b/src/renderer/components/Dashboard.tsx
index 055afed..b8502e9 100644
--- a/src/renderer/components/Dashboard.tsx
+++ b/src/renderer/components/Dashboard.tsx
@@ -1,5 +1,25 @@
import { Typography } from 'antd';
+import { EditorContent } from './layout/EditorContent';
+import { useAppSelector } from '../hooks/state';
export function Dashboard() {
- return Dashboard;
+ const modName = useAppSelector(
+ (state) => state.mods.selected.data?.name || 'Unknown',
+ );
+
+ return (
+
+ Dashboard
+
+ Welcome to the Stracciatella Toolset!
+
+
+ You are editing the mod: "{modName}"
+
+
+ On the left you find a menu to select the different parts of the game
+ that currently can be edited. Feel free to explore!
+
+
+ );
}
diff --git a/src/renderer/components/content/ResourceSelectorModal.tsx b/src/renderer/components/content/ResourceSelectorModal.tsx
index b5ce9dc..b014e69 100644
--- a/src/renderer/components/content/ResourceSelectorModal.tsx
+++ b/src/renderer/components/content/ResourceSelectorModal.tsx
@@ -4,7 +4,15 @@ import {
FileOutlined,
ArrowUpOutlined,
} from '@ant-design/icons';
-import { Button, List, Breadcrumb, Flex, Splitter } from 'antd';
+import {
+ Button,
+ List,
+ Breadcrumb,
+ Flex,
+ Splitter,
+ Typography,
+ Checkbox,
+} from 'antd';
import Modal from 'antd/lib/modal/Modal';
import {
useCallback,
@@ -26,10 +34,14 @@ const Breadcrumbs = memo(function Breadcrumbs({
pathPrefix,
currentDir,
switchDir,
+ filterModOnly,
+ setFilterModOnly,
}: {
pathPrefix: string[];
currentDir: string[];
switchDir: (currentDir: string[]) => void;
+ filterModOnly: boolean;
+ setFilterModOnly: (filterModOnly: boolean) => unknown;
}) {
const setToParentDir = useCallback(() => {
if (currentDir.length === pathPrefix.length) {
@@ -63,15 +75,24 @@ const Breadcrumbs = memo(function Breadcrumbs({
}, [currentDir, pathPrefix, switchDir]);
return (
-
-
-
+ Show only resources from mod
+
);
});
@@ -149,6 +170,7 @@ export function ResourceSelectorModal({
onSelect: (value: string) => unknown;
onCancel: () => unknown;
}) {
+ const [filterModOnly, setFilterModOnly] = useState(false);
const [currentDir, setCurrentDir] = useState(initialDir ?? pathPrefix ?? []);
const [selectedEntry, setSelectedEntry] = useState(
null,
@@ -162,7 +184,7 @@ export function ResourceSelectorModal({
loading,
error,
refresh,
- } = useDirEntries(currentDir.join('/'), resourceType);
+ } = useDirEntries(currentDir.join('/'), resourceType, filterModOnly);
const modalOnOk = useCallback(() => {
if (selectedEntry !== null && selectedEntry.type === 'File') {
if (currentDir.length === 0) {
@@ -225,6 +247,9 @@ export function ResourceSelectorModal({
},
[loading, onItemClick, selectedEntry],
);
+ const title = useMemo(() => {
+ return Select resource;
+ }, []);
const content = useMemo(() => {
if (error) {
return ;
@@ -244,7 +269,7 @@ export function ResourceSelectorModal({
return (
diff --git a/src/renderer/components/content/SoundPreview.tsx b/src/renderer/components/content/SoundPreview.tsx
index b83defd..1327bb1 100644
--- a/src/renderer/components/content/SoundPreview.tsx
+++ b/src/renderer/components/content/SoundPreview.tsx
@@ -1,35 +1,51 @@
-import { PauseCircleOutlined, PlayCircleOutlined } from '@ant-design/icons';
+import {
+ ExclamationCircleOutlined,
+ PauseCircleOutlined,
+ PlayCircleOutlined,
+} from '@ant-design/icons';
import { Button } from 'antd';
-import { useCallback, useEffect, useState } from 'react';
-import { invoke } from '../../lib/invoke';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useSound } from '../../hooks/useSound';
export function SoundPreview({ path }: { path: string }) {
+ const { loading, error: loadingError, data, refresh } = useSound(path);
+ const [playError, setPlayError] = useState(null);
const [audio, setAudio] = useState(null);
const togglePlay = useCallback(async () => {
- if (!audio) {
- // load audio
- const src = await invoke('sound/read', {
- file: path,
- });
- const a = new Audio(src);
-
+ if (audio) {
+ audio.pause();
+ } else if (!loading && data) {
+ const a = new Audio(data);
a.onpause = () => setAudio(null);
-
setAudio(a);
- } else if (audio) {
- audio.pause();
}
- }, [audio, path]);
+ }, [audio, data, loading]);
+ const icon = useMemo(() => {
+ const error = playError || loadingError;
+ if (error) {
+ return ;
+ }
+ if (audio) {
+ return ;
+ }
+ return ;
+ }, [audio, loadingError, playError]);
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
useEffect(() => {
if (audio) {
- audio.play();
+ audio.play().catch((e) => {
+ if (e instanceof Error) {
+ setPlayError(e);
+ }
+ });
}
- }, [audio, path]);
+ return () => {
+ audio?.pause();
+ };
+ }, [audio]);
- return (
-
- {audio ? : }
-
- );
+ return {icon};
}
diff --git a/src/renderer/components/layout/EditorContent.tsx b/src/renderer/components/layout/EditorContent.tsx
index 9bedb6f..59d4ba4 100644
--- a/src/renderer/components/layout/EditorContent.tsx
+++ b/src/renderer/components/layout/EditorContent.tsx
@@ -42,11 +42,11 @@ const EDIT_MODE_SELECT_OPTIONS = [
];
interface ContentProps {
- file: string;
+ file?: string;
children: ReactNode;
}
-function EditorContentSaveButton({ file }: Pick) {
+function EditorContentSaveButton({ file }: { file: string }) {
const modified = useFileModified(file);
const saving = useFileSaving(file);
const loading = useFileLoading(file);
@@ -79,7 +79,9 @@ function EditorContentSaveButton({ file }: Pick) {
);
}
-function EditModeSelect({ file }: Pick) {
+function EditModeSelect({
+ file,
+}: Parameters[0]) {
const loading = useFileLoading(file);
const saving = useFileSaving(file);
const editMode = useFileEditMode(file);
@@ -99,7 +101,9 @@ function EditModeSelect({ file }: Pick) {
);
}
-function SaveModeSelect({ file }: Pick) {
+function SaveModeSelect({
+ file,
+}: Parameters[0]) {
const loading = useFileLoading(file);
const saving = useFileSaving(file);
const editMode = useFileSaveMode(file);
@@ -142,15 +146,6 @@ export const EditorContent = function EditorContent({
}) as const,
[],
);
- const headerStyle = useMemo(
- () =>
- ({
- paddingTop: '10px',
- paddingLeft: '10px',
- paddingRight: '10px',
- }) as const,
- [],
- );
const contentWrapperStyle = useMemo(
() =>
({
@@ -172,12 +167,24 @@ export const EditorContent = function EditorContent({
}) as const,
[],
);
+ const header = useMemo(() => {
+ if (!file) return null;
+ return (
+
+
+
+ );
+ }, [file]);
return (
-
-
-
+ {header}
diff --git a/src/renderer/components/layout/EditorLayout.css b/src/renderer/components/layout/ToolsetLayout.css
similarity index 100%
rename from src/renderer/components/layout/EditorLayout.css
rename to src/renderer/components/layout/ToolsetLayout.css
diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/ToolsetLayout.tsx
similarity index 97%
rename from src/renderer/components/layout/EditorLayout.tsx
rename to src/renderer/components/layout/ToolsetLayout.tsx
index 3a84b95..4a46084 100644
--- a/src/renderer/components/layout/EditorLayout.tsx
+++ b/src/renderer/components/layout/ToolsetLayout.tsx
@@ -2,7 +2,7 @@ import { ReactNode, useMemo } from 'react';
import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom';
import { Badge, Layout, Menu, MenuProps, Space, Typography } from 'antd';
-import './EditorLayout.css';
+import './ToolsetLayout.css';
import { Item, MENU, MenuItem } from '../../EditorRoutes';
import { useAppSelector } from '../../hooks/state';
import { useFileModified } from '../../hooks/useFileModified';
@@ -94,7 +94,7 @@ function SideMenu() {
);
}
-export function EditorLayout({ children }: LayoutProps) {
+export function ToolsetLayout({ children }: LayoutProps) {
const selectedModName = useAppSelector((s) => s.mods.selected.data?.name);
const modSuffix = useMemo(
() => (selectedModName ? ` - ${selectedModName}` : ''),
diff --git a/src/renderer/components/visual/form/StringReferenceWidget.tsx b/src/renderer/components/visual/form/StringReferenceWidget.tsx
index 2f96e47..55c005f 100644
--- a/src/renderer/components/visual/form/StringReferenceWidget.tsx
+++ b/src/renderer/components/visual/form/StringReferenceWidget.tsx
@@ -65,12 +65,10 @@ export function StringReferenceWidget({
);
useEffect(() => {
- files.forEach((file, idx) => {
- if (!values[idx] && !error) {
- loadFile(file);
- }
+ references.forEach((reference) => {
+ loadFile(reference.file);
});
- }, [error, files, loadFile, values]);
+ }, [loadFile, references]);
if (loading) {
return (
@@ -106,19 +104,15 @@ export function stringReferenceTo(
property: string,
preview?: PreviewFn,
) {
+ const references = [
+ {
+ property,
+ file,
+ preview,
+ },
+ ];
return function StringReference(props: WidgetProps) {
- return (
-
- );
+ return ;
};
}
diff --git a/src/renderer/hooks/useDirEntries.tsx b/src/renderer/hooks/useDirEntries.tsx
index 5affa38..cb4e725 100644
--- a/src/renderer/hooks/useDirEntries.tsx
+++ b/src/renderer/hooks/useDirEntries.tsx
@@ -10,7 +10,11 @@ const filterFunctions = {
[ResourceType.Graphics]: (e: ResourceEntry) => e.path.endsWith('.sti'),
};
-export function useDirEntries(dir: string, resourceType: ResourceType) {
+export function useDirEntries(
+ dir: string,
+ resourceType: ResourceType,
+ modOnly: boolean,
+) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
@@ -19,6 +23,7 @@ export function useDirEntries(dir: string, resourceType: ResourceType) {
try {
const result = await invoke('resources/list', {
path: dir,
+ modOnly,
});
const filterFn = filterFunctions[resourceType];
const entries = result.filter((e) => {
@@ -51,7 +56,7 @@ export function useDirEntries(dir: string, resourceType: ResourceType) {
} finally {
setLoading(false);
}
- }, [dir, resourceType]);
+ }, [dir, modOnly, resourceType]);
return {
loading,
diff --git a/src/renderer/hooks/useImage.tsx b/src/renderer/hooks/useImage.tsx
index 919b134..b590490 100644
--- a/src/renderer/hooks/useImage.tsx
+++ b/src/renderer/hooks/useImage.tsx
@@ -17,6 +17,7 @@ export function useImageFile(file: string | null, subimage?: number) {
});
setData(i);
} catch (e: any) {
+ setData(null);
setError(e);
} finally {
setLoading(false);
diff --git a/src/renderer/hooks/useSound.tsx b/src/renderer/hooks/useSound.tsx
new file mode 100644
index 0000000..098f583
--- /dev/null
+++ b/src/renderer/hooks/useSound.tsx
@@ -0,0 +1,24 @@
+import { useCallback, useState } from 'react';
+import { invoke } from '../lib/invoke';
+
+export function useSound(file: string) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [data, setData] = useState(null);
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const i = await invoke('sound/read', {
+ file,
+ });
+ setData(i);
+ } catch (e: any) {
+ setData(null);
+ setError(e);
+ } finally {
+ setLoading(false);
+ }
+ }, [file]);
+
+ return { loading, error, data, refresh };
+}
diff --git a/src/renderer/index.ejs b/src/renderer/index.ejs
index 886ec84..dd2e8ff 100644
--- a/src/renderer/index.ejs
+++ b/src/renderer/index.ejs
@@ -6,7 +6,7 @@
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline' 'unsafe-eval'"
/>
- Hello Electron React!
+ Stracciatella Toolset
diff --git a/src/renderer/state/files.tsx b/src/renderer/state/files.tsx
index 55fdd6b..9e9c14a 100644
--- a/src/renderer/state/files.tsx
+++ b/src/renderer/state/files.tsx
@@ -23,6 +23,7 @@ import {
} from '../../common/invokables/jsons';
import { InvokableOutput } from 'src/common/invokables';
import { isArray, omit, splice } from 'remeda';
+import { ZodError } from 'zod';
export type SaveMode = 'patch' | 'replace';
@@ -92,15 +93,46 @@ export const persistJSON = createAsyncThunk(
let patch: JsonPatch | null = null;
if (open.editMode === 'visual') {
if (open.saveMode == 'patch') {
- patch = generatePatch(disk.vanilla, open.value);
+ const p = generatePatch(disk.vanilla, open.value);
+ if (p.length !== 0) {
+ patch = p;
+ }
} else {
value = open.value;
}
} else {
if (open.saveMode === 'patch') {
- patch = JSON.parse(open.value);
+ try {
+ patch = JSON_PATCH_SCHEMA.parse(JSON.parse(open.value));
+ } catch (error) {
+ if (error instanceof ZodError) {
+ throw new Error(
+ `current editor value is not a valid JSON patch: ${error.message}`,
+ );
+ }
+ if (error instanceof Error) {
+ throw new Error(
+ `current editor value is not valid JSON: ${error.message}`,
+ );
+ }
+ throw error;
+ }
} else {
- value = JSON.parse(open.value);
+ try {
+ value = JSON_ROOT_SCHEMA.parse(JSON.parse(open.value));
+ } catch (error) {
+ if (error instanceof ZodError) {
+ throw new Error(
+ `current editor value is not a valid JSON root: ${error.message}`,
+ );
+ }
+ if (error instanceof Error) {
+ throw new Error(
+ `current editor value is not valid JSON: ${error.message}`,
+ );
+ }
+ throw error;
+ }
}
}
@@ -241,23 +273,14 @@ const filesSlice = createSlice({
if (!open || !disk || open.saveMode === saveMode) {
return;
}
- if (open.editMode === 'text') {
- try {
- if (open.saveMode === 'patch') {
- JSON_PATCH_SCHEMA.parse(JSON.parse(open.value));
- }
- } catch {
- return;
- }
- }
try {
if (open.editMode === 'text') {
- if (open.saveMode === 'patch') {
+ if (open.saveMode === 'replace') {
const value = JSON_ROOT_SCHEMA.parse(JSON.parse(open.value));
open.value = jsonToString(generatePatch(disk.vanilla, value));
} else {
const patch = JSON_PATCH_SCHEMA.parse(JSON.parse(open.value));
- open.value = jsonToString(applyPatch(disk.applied, patch));
+ open.value = jsonToString(applyPatch(disk.vanilla, patch));
}
}
} catch {
@@ -280,7 +303,7 @@ const filesSlice = createSlice({
if (open.editMode === 'text') {
try {
if (open.saveMode === 'patch') {
- const patch = JSON.parse(open.value);
+ const patch = JSON_PATCH_SCHEMA.parse(JSON.parse(open.value));
const applied = applyPatch(disk.applied, patch);
state.open[filename] = {
...open,
@@ -288,7 +311,7 @@ const filesSlice = createSlice({
value: applied,
};
} else {
- const value = JSON.parse(open.value);
+ const value = JSON_ROOT_SCHEMA.parse(JSON.parse(open.value));
state.open[filename] = {
...open,
editMode: editMode as 'visual',
@@ -301,7 +324,7 @@ const filesSlice = createSlice({
} else {
if (open.saveMode === 'patch') {
const value = open.value;
- const patch = generatePatch(disk.applied, value);
+ const patch = generatePatch(disk.vanilla, value);
state.open[filename] = {
...open,
editMode: editMode as 'text',
diff --git a/src/renderer/state/store.tsx b/src/renderer/state/store.tsx
index a53788a..2e28b77 100644
--- a/src/renderer/state/store.tsx
+++ b/src/renderer/state/store.tsx
@@ -10,9 +10,12 @@ const reducer = combineReducers({
files,
});
-export const appStore = configureStore({
- reducer,
-});
+export const createAppStore = () =>
+ configureStore({
+ reducer,
+ });
-export type AppState = ReturnType;
-export type AppDispatch = typeof appStore.dispatch;
+export type AppState = ReturnType<
+ ReturnType['getState']
+>;
+export type AppDispatch = ReturnType['dispatch'];