Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/capabilities/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"permissions": [
"core:event:default",
"core:window:default",
"core:window:allow-close"
"core:window:allow-close",
"core:window:allow-destroy"
]
}
33 changes: 32 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use rfd::{AsyncMessageDialog, MessageButtons, MessageDialogResult, MessageLevel};
use serde::Serialize;
use std::{fs, path::Path};
use tauri::{
Expand Down Expand Up @@ -85,6 +86,30 @@ async fn export_image(bytes: Vec<u8>, default_name: String) -> Result<bool, Stri
Ok(true)
}

#[tauri::command]
async fn confirm_dialog(message: String) -> Result<bool, String> {
let result = AsyncMessageDialog::new()
.set_title("Jauto")
.set_description(message)
.set_level(MessageLevel::Warning)
.set_buttons(MessageButtons::YesNo)
.show()
.await;
Ok(result == MessageDialogResult::Yes)
}

#[tauri::command]
async fn show_error_dialog(message: String) -> Result<(), String> {
AsyncMessageDialog::new()
.set_title("Jauto")
.set_description(message)
.set_level(MessageLevel::Error)
.set_buttons(MessageButtons::Ok)
.show()
.await;
Ok(())
}

fn file_name(path: &Path, fallback: &str) -> String {
path.file_name()
.and_then(|name| name.to_str())
Expand Down Expand Up @@ -187,7 +212,13 @@ pub fn run() {
build_menu(app)?;
Ok(())
})
.invoke_handler(tauri::generate_handler![open_file, save_file, export_image])
.invoke_handler(tauri::generate_handler![
open_file,
save_file,
export_image,
confirm_dialog,
show_error_dialog
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
6 changes: 4 additions & 2 deletions apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
"windows": [
{
"title": "Jauto",
"width": 1200,
"height": 800
"width": 1440,
"height": 960,
"minWidth": 1100,
"minHeight": 700
}
],
"security": {
Expand Down
13 changes: 10 additions & 3 deletions apps/desktop/src/renderer/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,21 @@ import {
import type { AutomatonKind } from '@jauto/core';
import DesktopAppHeader from './DesktopAppHeader.vue';
import { DesktopFileService } from './DesktopFileService';
import { confirmWithJauto, showJautoError } from './nativeDialogs';

const docStore = useDocumentStore();
const fileService = new DesktopFileService();
const commands = useApplicationCommands(fileService, (message) => window.alert(message));
const commands = useApplicationCommands(
fileService,
(message) => void showJautoError(message),
confirmWithJauto,
);
let unlistenMenu: (() => void) | null = null;
let unlistenClose: (() => void) | null = null;
let closeApproved = false;
docStore.restoreRecoveryDraft();
const beforeUnload = createBeforeUnloadHandler(
() => docStore.currentView === 'editor' && docStore.isDirty,
() => !closeApproved && docStore.currentView === 'editor' && docStore.isDirty,
);
window.addEventListener('beforeunload', beforeUnload);
onBeforeUnmount(() => {
Expand Down Expand Up @@ -61,9 +66,11 @@ onMounted(async () => {
await runProtectedDocumentAction({
isDirty: true,
save: saveCurrentDocument,
confirm: confirmWithJauto,
action: async () => {
closeApproved = true;
await nativeWindow.close();
window.removeEventListener('beforeunload', beforeUnload);
await nativeWindow.destroy();
},
});
});
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/renderer/src/DesktopAppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ import {
} from '@jauto/ui';
import type { AutomatonKind } from '@jauto/core';
import { DesktopFileService } from './DesktopFileService';
import { confirmWithJauto, showJautoError } from './nativeDialogs';

const docStore = useDocumentStore();
const fileService = new DesktopFileService();
const commands = useApplicationCommands(fileService);
const commands = useApplicationCommands(
fileService,
(message) => void showJautoError(message),
confirmWithJauto,
);
const openMenu = ref<string | null>(null);
const isRenaming = ref(false);
const renameCancelled = ref(false);
Expand Down Expand Up @@ -53,7 +58,7 @@ async function saveFile() {
try {
await persistToDisk(true);
} catch (err) {
window.alert(`Failed to save: ${err instanceof Error ? err.message : String(err)}`);
await showJautoError(`Failed to save: ${err instanceof Error ? err.message : String(err)}`);
}
}

Expand All @@ -63,7 +68,7 @@ async function onSaveClick() {
try {
await persistToDisk();
} catch (err) {
window.alert(`Failed to save: ${err instanceof Error ? err.message : String(err)}`);
await showJautoError(`Failed to save: ${err instanceof Error ? err.message : String(err)}`);
} finally {
savingFile.value = false;
}
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/renderer/src/nativeDialogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { invoke } from '@tauri-apps/api/core';

export function confirmWithJauto(message: string): Promise<boolean> {
return invoke<boolean>('confirm_dialog', { message });
}

export async function showJautoError(message: string): Promise<void> {
await invoke('show_error_dialog', { message });
}
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.5.0",
"eslint": "^10.4.1",
"eslint-plugin-vue": "^10.10.0",
"eslint": "^10.10.0",
"eslint-plugin-vue": "^10.11.0",
"jsdom": "^30.0.1",
"prettier": "^3.8.3",
"turbo": "^2.9.16",
"prettier": "^3.9.6",
"turbo": "^2.10.12",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vitest": "^4.1.8",
"typescript-eslint": "^8.70.0",
"vitest": "^4.1.11",
"vue-eslint-parser": "^10.4.1"
},
"packageManager": "pnpm@11.5.2",
Expand Down
43 changes: 28 additions & 15 deletions packages/ui/__tests__/document-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { describe, expect, it, vi } from 'vitest';
import { createBeforeUnloadHandler, requestDocumentLifecycleDecision, runProtectedDocumentAction } from '../src/documentLifecycle';
import {
createBeforeUnloadHandler,
requestDocumentLifecycleDecision,
runProtectedDocumentAction,
} from '../src/documentLifecycle';

describe('document lifecycle protection', () => {
it.each([
[[true], 'save'],
[[false, true], 'discard'],
[[false, false], 'cancel'],
] as const)('maps confirmation answers to %s', (answers, expected) => {
] as const)('maps confirmation answers to %s', async (answers, expected) => {
const remaining = [...answers];
const confirm = vi.fn(() => remaining.shift() ?? false);
expect(requestDocumentLifecycleDecision(confirm)).toBe(expected);
expect(await requestDocumentLifecycleDecision(confirm)).toBe(expected);
});

it('supports asynchronous native confirmation dialogs', async () => {
const confirm = vi.fn(async () => true);
expect(await requestDocumentLifecycleDecision(confirm)).toBe('save');
});

it('runs immediately for a clean document', async () => {
Expand All @@ -22,20 +31,24 @@ describe('document lifecycle protection', () => {

it('does not replace the document when save fails or is canceled', async () => {
const action = vi.fn();
expect(await runProtectedDocumentAction({
isDirty: true,
save: async () => false,
action,
confirm: () => true,
})).toBe(false);
expect(
await runProtectedDocumentAction({
isDirty: true,
save: async () => false,
action,
confirm: () => true,
}),
).toBe(false);
expect(action).not.toHaveBeenCalled();

expect(await runProtectedDocumentAction({
isDirty: true,
save: async () => true,
action,
confirm: () => false,
})).toBe(false);
expect(
await runProtectedDocumentAction({
isDirty: true,
save: async () => true,
action,
confirm: () => false,
}),
).toBe(false);
expect(action).not.toHaveBeenCalled();
});

Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/applicationCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FileService } from '@jauto/file-io';
import { openAutomaton, saveAutomaton } from '@jauto/file-io';
import { exportDiagramPng } from './diagramExport';
import { runProtectedDocumentAction } from './documentLifecycle';
import type { ConfirmFn } from './documentLifecycle';
import { useDocumentStore } from './stores/document';
import { useHistoryStore } from './stores/history';
import { useSimulationStore } from './stores/simulation';
Expand All @@ -22,6 +23,7 @@ export type ApplicationMenuCommand =
export function useApplicationCommands(
fileService: FileService,
reportError: (message: string) => void = (message) => window.alert(message),
confirm?: ConfirmFn,
) {
const document = useDocumentStore();
const history = useHistoryStore();
Expand Down Expand Up @@ -59,6 +61,7 @@ export function useApplicationCommands(
isDirty: document.isDirty,
save: () => saveDocument(false),
action,
confirm,
});
}

Expand Down Expand Up @@ -94,6 +97,7 @@ export function useApplicationCommands(
simulation.stop();
document.goHome();
},
confirm,
});
}

Expand Down
11 changes: 6 additions & 5 deletions packages/ui/src/components/SimulationControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,13 @@ function remainingText(config: Record<string, unknown>): string {
.sim-controls__branch {
display: grid;
gap: 2px;
min-width: 180px;
padding: 6px 8px;
min-width: 280px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-secondary);
font: 11px/1.35 var(--font-mono);
font: 12px/1.45 var(--font-mono);
}

.sim-controls__tape {
Expand All @@ -394,10 +394,11 @@ function remainingText(config: Record<string, unknown>): string {
}

.sim-controls__tape b {
min-width: 17px;
padding: 1px 3px;
min-width: 28px;
padding: 4px 6px;
border: 1px solid var(--color-border);
text-align: center;
font-size: 13px;
}

.sim-controls__tape .sim-controls__head {
Expand Down
12 changes: 7 additions & 5 deletions packages/ui/src/documentLifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
export type DocumentLifecycleDecision = 'save' | 'discard' | 'cancel';

export type ConfirmFn = (message: string) => boolean;
export type ConfirmFn = (message: string) => boolean | Promise<boolean>;

export function requestDocumentLifecycleDecision(confirm: ConfirmFn): DocumentLifecycleDecision {
if (confirm('Save changes before continuing?')) return 'save';
if (confirm('Discard unsaved changes?')) return 'discard';
export async function requestDocumentLifecycleDecision(
confirm: ConfirmFn,
): Promise<DocumentLifecycleDecision> {
if (await confirm('Save changes before continuing?')) return 'save';
if (await confirm('Discard unsaved changes?')) return 'discard';
return 'cancel';
}

Expand All @@ -20,7 +22,7 @@ export async function runProtectedDocumentAction(options: {
}

const confirm = options.confirm ?? ((message: string) => window.confirm(message));
const decision = requestDocumentLifecycleDecision(confirm);
const decision = await requestDocumentLifecycleDecision(confirm);
if (decision === 'cancel') return false;
if (decision === 'save' && !(await options.save())) return false;
await options.action();
Expand Down
Loading