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. + +![Screenshow](docs/screenshot.jpg) ## 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 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + +image/svg+xml + + 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 ( - - + + + setFilterModOnly(ev.target.checked)} > - - - + 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 ( - - ); + return ; } 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}
{children}
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'];