Skip to content
Merged
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
1 change: 1 addition & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"Bash(npx c8 report:*)",
"Bash(rg:*)",
"Bash(npx tsc:*)",
"Bash(git add:*)"
"mcp__playwright__browser_take_screenshot"
]
},
Expand Down
55 changes: 55 additions & 0 deletions frontend/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,63 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use tauri::{Manager, State};
use std::sync::Mutex;
use std::path::PathBuf;

// State to hold the current project path
struct ProjectState {
path: Mutex<Option<String>>,
}

// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn get_commands_path(state: State<ProjectState>) -> Result<String, String> {
let project_path = state.path.lock().unwrap();

match &*project_path {
Some(path) => {
// Return the commands.yaml path within the project directory
let commands_path = PathBuf::from(path).join("commands.yaml");
Ok(commands_path.to_string_lossy().to_string())
},
None => {
// No project path set, return error
Err("No project path set".to_string())
}
}
}

#[tauri::command]
fn set_project_path(state: State<ProjectState>, path: String) -> Result<(), String> {
let mut project_path = state.path.lock().unwrap();
*project_path = Some(path);
Ok(())
}

#[tauri::command]
fn get_project_path(state: State<ProjectState>) -> Result<Option<String>, String> {
let project_path = state.path.lock().unwrap();
Ok(project_path.clone())
}

fn main() {
tauri::Builder::default()
.setup(|app| {
#[cfg(debug_assertions)] // only in development
{
app.get_window("main").unwrap().open_devtools();
}
Ok(())
})
.manage(ProjectState {
path: Mutex::new(None),
})
.invoke_handler(tauri::generate_handler![
get_commands_path,
set_project_path,
get_project_path
]) // Register all commands
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
9 changes: 7 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,17 @@ import { I18nextProvider } from 'react-i18next';
import i18n from './i18n/config';

function App() {
const { loadCommandsYaml, loadSkits, projectPath } = useSkitStore();
const { loadCommandsYaml, loadSkits, projectPath, setProjectPath } = useSkitStore();
useKeyboardShortcuts();

useEffect(() => {
const loadInitialData = async () => {
try {
// Sync project path with Tauri on startup if it exists
if (projectPath && window.__TAURI__) {
await setProjectPath(projectPath);
}

const commandsYamlContent = await loadCommandsYamlFile(projectPath);
loadCommandsYaml(commandsYamlContent);

Expand Down Expand Up @@ -60,7 +65,7 @@ function App() {
};

loadInitialData();
}, [loadCommandsYaml, loadSkits, projectPath]);
}, [loadCommandsYaml, loadSkits, projectPath, setProjectPath]);

return (
<I18nextProvider i18n={i18n}>
Expand Down
64 changes: 46 additions & 18 deletions frontend/src/i18n/translationLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loadTranslations, generateCommandTranslationKeys, getAvailableLanguages, getTranslationWithFallback } from './translationLoader';
import i18n from 'i18next';
import * as tauriApi from '@tauri-apps/api';
import * as tauriFs from '@tauri-apps/api/fs';
import * as tauriPath from '@tauri-apps/api/path';

Expand Down Expand Up @@ -36,6 +35,15 @@ vi.mock('@tauri-apps/api/path', () => ({
dirname: vi.fn(),
}));

// Mock the store
vi.mock('../store/skitStore', () => ({
useSkitStore: {
getState: vi.fn(() => ({
projectPath: null,
})),
},
}));

// Mock fetch for development mode
global.fetch = vi.fn();

Expand Down Expand Up @@ -97,8 +105,12 @@ describe('translationLoader', () => {
(window as any).__TAURI__ = true;
(import.meta as any).env = { MODE: 'production' };

vi.mocked(tauriApi.invoke).mockResolvedValue('/path/to/commands.yaml');
vi.mocked(tauriPath.dirname).mockResolvedValue('/path/to');
// Mock store to return a project path
const { useSkitStore } = await import('../store/skitStore');
vi.mocked(useSkitStore.getState).mockReturnValue({
projectPath: '/path/to/project',
} as any);

vi.mocked(tauriPath.join).mockImplementation(async (...args) => args.join('/'));
vi.mocked(tauriFs.exists).mockResolvedValue(true);
vi.mocked(tauriFs.readTextFile).mockResolvedValue(JSON.stringify({
Expand All @@ -109,15 +121,19 @@ describe('translationLoader', () => {

await loadTranslations();

expect(tauriApi.invoke).toHaveBeenCalledWith('get_commands_path');
expect(tauriFs.exists).toHaveBeenCalledWith('/path/to/i18n');
expect(tauriFs.exists).toHaveBeenCalledWith('/path/to/project/i18n');
expect(tauriFs.readTextFile).toHaveBeenCalled();
expect(i18n.addResourceBundle).toHaveBeenCalled();
});

it('should fallback to development mode when commands path not found', async () => {
it('should fallback to development mode when project path not found', async () => {
(window as any).__TAURI__ = true;
vi.mocked(tauriApi.invoke).mockRejectedValue(new Error('Not found'));

// Mock store to return no project path
const { useSkitStore } = await import('../store/skitStore');
vi.mocked(useSkitStore.getState).mockReturnValue({
projectPath: null,
} as any);

const mockFetch = vi.mocked(global.fetch);
mockFetch.mockResolvedValue({
Expand All @@ -128,7 +144,7 @@ describe('translationLoader', () => {

await loadTranslations();

expect(consoleWarnSpy).toHaveBeenCalledWith('Commands path not found, using development translations');
expect(consoleWarnSpy).toHaveBeenCalledWith('No project path set, using development translations');

consoleWarnSpy.mockRestore();
});
Expand All @@ -137,8 +153,12 @@ describe('translationLoader', () => {
(window as any).__TAURI__ = true;
(import.meta as any).env = { MODE: 'production' };

vi.mocked(tauriApi.invoke).mockResolvedValue('/path/to/commands.yaml');
vi.mocked(tauriPath.dirname).mockResolvedValue('/path/to');
// Mock store to return a project path
const { useSkitStore } = await import('../store/skitStore');
vi.mocked(useSkitStore.getState).mockReturnValue({
projectPath: '/path/to/project',
} as any);

vi.mocked(tauriPath.join).mockImplementation(async (...args) => args.join('/'));
vi.mocked(tauriFs.exists).mockResolvedValue(false);

Expand All @@ -155,8 +175,12 @@ describe('translationLoader', () => {
(window as any).__TAURI__ = true;
(import.meta as any).env = { MODE: 'production' };

vi.mocked(tauriApi.invoke).mockResolvedValue('/path/to/commands.yaml');
vi.mocked(tauriPath.dirname).mockResolvedValue('/path/to');
// Mock store to return a project path
const { useSkitStore } = await import('../store/skitStore');
vi.mocked(useSkitStore.getState).mockReturnValue({
projectPath: '/path/to/project',
} as any);

vi.mocked(tauriPath.join).mockImplementation(async (...args) => args.join('/'));
vi.mocked(tauriFs.exists).mockResolvedValue(true);
vi.mocked(tauriFs.readTextFile).mockResolvedValue('invalid json');
Expand All @@ -174,8 +198,12 @@ describe('translationLoader', () => {
(window as any).__TAURI__ = true;
(import.meta as any).env = { MODE: 'production' };

vi.mocked(tauriApi.invoke).mockResolvedValue('/path/to/commands.yaml');
vi.mocked(tauriPath.dirname).mockResolvedValue('/path/to');
// Mock store to return a project path
const { useSkitStore } = await import('../store/skitStore');
vi.mocked(useSkitStore.getState).mockReturnValue({
projectPath: '/path/to/project',
} as any);

vi.mocked(tauriPath.join).mockImplementation(async (...args) => args.join('/'));

// Only i18n directory exists, but no language files
Expand Down Expand Up @@ -384,9 +412,9 @@ describe('translationLoader', () => {

const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

// Make invoke throw an error
vi.mocked(tauriApi.invoke).mockImplementation(() => {
throw new Error('Tauri error');
// Make the store import throw an error
vi.doMock('../store/skitStore', () => {
throw new Error('Store import error');
});

const mockFetch = vi.mocked(global.fetch);
Expand All @@ -397,7 +425,7 @@ describe('translationLoader', () => {

await loadTranslations();

expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load translations:', expect.any(Error));
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load translations in Tauri mode:', expect.any(Error));
// Should fallback to development translations
expect(mockFetch).toHaveBeenCalled();

Expand Down
23 changes: 13 additions & 10 deletions frontend/src/i18n/translationLoader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import i18n from 'i18next';
import { invoke } from '@tauri-apps/api';
import { exists, readTextFile } from '@tauri-apps/api/fs';
import { join, dirname } from '@tauri-apps/api/path';
import { join } from '@tauri-apps/api/path';

interface TranslationFile {
locale: string;
Expand Down Expand Up @@ -46,24 +45,28 @@ const loadDevelopmentTranslations = async (): Promise<void> => {
// Load translations from i18n folder
export async function loadTranslations(): Promise<void> {
// Check if we're in Tauri environment
if (!window.__TAURI__ || import.meta.env.MODE === 'development') {
if (!window.__TAURI__) {
await loadDevelopmentTranslations();
return;
}

try {
// Get the commands.yaml path from store or use default
const commandsPath = await invoke<string>('get_commands_path').catch(() => null);
if (!commandsPath) {
console.warn('Commands path not found, using development translations');
// Get the project path from the store
const { useSkitStore } = await import('../store/skitStore');
const projectPath = useSkitStore.getState().projectPath;

if (!projectPath) {
console.warn('No project path set, using development translations');
await loadDevelopmentTranslations();
return;
}

const i18nPath = await join(await dirname(commandsPath), 'i18n');
const i18nPath = await join(projectPath, 'i18n');

// Check if i18n directory exists
if (!(await exists(i18nPath))) {
const i18nDirExists = await exists(i18nPath);

if (!i18nDirExists) {
console.warn('i18n directory not found, using development translations');
await loadDevelopmentTranslations();
return;
Expand Down Expand Up @@ -94,7 +97,7 @@ export async function loadTranslations(): Promise<void> {
}
}
} catch (error) {
console.error('Failed to load translations:', error);
console.error('Failed to load translations in Tauri mode:', error);
await loadDevelopmentTranslations();
}
}
Expand Down
20 changes: 14 additions & 6 deletions frontend/src/store/skitStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface SkitState {
redo: () => void;
setValidationErrors: (errors: string[]) => void;
loadCommandsYaml: (yaml: string) => void;
setProjectPath: (path: string | null) => void; // Add project path setter
setProjectPath: (path: string | null) => Promise<void>; // Add project path setter
createGroup: () => void;
ungroupCommands: (groupStartId: number) => void;
toggleGroupCollapse: (groupStartId: number) => void;
Expand Down Expand Up @@ -594,10 +594,20 @@ export const useSkitStore = create<SkitState>()(
});
},

setProjectPath: (path) => {
setProjectPath: async (path) => {
set((state) => {
state.projectPath = path;
});

// Sync with Tauri backend if available
if (window.__TAURI__ && path) {
try {
const { invoke } = await import('@tauri-apps/api');
await invoke('set_project_path', { path });
} catch (error) {
console.error('Failed to sync project path with Tauri:', error);
}
}
},

createGroup: () => {
Expand Down Expand Up @@ -829,10 +839,8 @@ export const useSkitStore = create<SkitState>()(
const selectedPath = await selectProjectFolder();

if (selectedPath) {
// Update project path
set((state) => {
state.projectPath = selectedPath;
});
// Update project path and sync with Tauri
await get().setProjectPath(selectedPath);

// Load commands.yaml from the new path
try {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types/window.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
interface Window {
__TAURI__?: {
[key: string]: unknown;
};
}
2 changes: 1 addition & 1 deletion frontend/src/utils/commandFormatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function formatCommandPreview(command: SkitCommand, commandsMap: Map<stri
const placeholder = `{${key}}`;
if (formatted.includes(placeholder)) {
// Check if this property has a masterKey (references master data)
const propertyDef = commandDef.properties[key];
const propertyDef = commandDef.properties ? commandDef.properties[key] : undefined;
let displayValue = String(value);

if (propertyDef && propertyDef.masterKey && propertyDef.type === 'enum') {
Expand Down