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
37 changes: 36 additions & 1 deletion apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -733,13 +733,48 @@ fn select_local_audio_source(
Ok(summary)
}

#[tauri::command]
fn save_project(payload: Value) -> Result<(), String> {
let parsed = serde_json::from_value::<RehearsalSongPayload>(payload)
.map_err(|_| "Invalid project payload".to_string())?;

let path = FileDialog::new()
.add_filter("BandScope Project", &["bscope", "json"])
.save_file()
.ok_or_else(|| "User cancelled".to_string())?;

let content = serde_json::to_string_pretty(&parsed)
.map_err(|_| "Failed to serialize project".to_string())?;
std::fs::write(path, content).map_err(|_| "Failed to write file".to_string())?;

Ok(())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[tauri::command]
fn load_project() -> Result<RehearsalSongPayload, String> {
let path = FileDialog::new()
.add_filter("BandScope Project", &["bscope", "json"])
.pick_file()
.ok_or_else(|| "User cancelled".to_string())?;

let metadata = std::fs::metadata(&path).map_err(|_| "Failed to read file".to_string())?;
if metadata.len() > 5 * 1024 * 1024 {
return Err("Project file is too large (exceeds 5MB limit)".to_string());
}

let content = std::fs::read_to_string(path).map_err(|_| "Failed to read file".to_string())?;
serde_json::from_str(&content).map_err(|_| "Invalid project file format".to_string())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn main() {
tauri::Builder::default()
.manage(AppState::default())
.invoke_handler(tauri::generate_handler![
select_local_audio_source,
start_analysis_job,
get_analysis_job_status
get_analysis_job_status,
save_project,
load_project
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
197 changes: 196 additions & 1 deletion apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

const tauriInvoke = vi.fn();
const mockLoadProject = vi.fn();
const mockSaveProject = vi.fn();

vi.mock("./lib/analysis", () => ({
createDefaultAnalysisRequest: () => ({
Expand All @@ -19,7 +21,9 @@ vi.mock("./lib/analysis", () => ({
return { ok: true, bootstrap: response };
},
startAnalysisJob: (request: unknown) => tauriInvoke("start_analysis_job", { request }),
getAnalysisJobStatus: (jobId: string) => tauriInvoke("get_analysis_job_status", { jobId })
getAnalysisJobStatus: (jobId: string) => tauriInvoke("get_analysis_job_status", { jobId }),
loadProject: () => mockLoadProject(),
saveProject: (song: unknown) => mockSaveProject(song)
}));

function succeededResult() {
Expand Down Expand Up @@ -104,6 +108,8 @@ function succeededResult() {
describe("App", () => {
beforeEach(() => {
tauriInvoke.mockReset();
mockLoadProject.mockReset();
mockSaveProject.mockReset();
});

it("selects a local audio source and starts a local-audio analysis job", async () => {
Expand Down Expand Up @@ -424,4 +430,193 @@ describe("App", () => {
});
expect(tauriInvoke).toHaveBeenCalledTimes(2); // select + start
});


it("loads a project and updates the UI", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));

await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});
expect(mockLoadProject).toHaveBeenCalledTimes(1);
});

it("handles loading a project failure safely", async () => {
mockLoadProject.mockRejectedValueOnce(new Error("Corrupt file"));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));

await waitFor(() => {
expect(screen.getByText(/Failed to load project: Corrupt file/i)).toBeTruthy();
});
});

it("ignores cancellation when loading a project", async () => {
mockLoadProject.mockRejectedValueOnce(new Error("User cancelled"));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));

// Should not show error, should remain in empty state
await waitFor(() => {
expect(screen.queryByText(/Failed to load project/i)).toBeNull();
});
});

it("handles loading a project failure with string error gracefully", async () => {
mockLoadProject.mockRejectedValueOnce("Unknown load error");
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));

await waitFor(() => {
expect(screen.getByText(/Failed to load project: Unknown load error/i)).toBeTruthy();
});
});

it("ignores cancellation when loading a project with string error", async () => {
mockLoadProject.mockRejectedValueOnce("User cancelled");
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));

await waitFor(() => {
expect(screen.queryByText(/Failed to load project/i)).toBeNull();
});
});

it("saves a project successfully", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});

mockSaveProject.mockResolvedValueOnce(undefined);

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
expect(mockSaveProject).toHaveBeenCalledWith(succeededResult().result);
});
});

it("handles saving a project failure gracefully", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});

mockSaveProject.mockRejectedValueOnce(new Error("Permission denied"));

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
expect(screen.getByText(/Failed to save project: Permission denied/i)).toBeTruthy();
});
});

it("ignores cancellation when saving a project with Error object", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});

mockSaveProject.mockRejectedValueOnce(new Error("User cancelled"));

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
expect(screen.queryByText(/Failed to save project/i)).toBeNull();
});
});

it("handles saving a project failure with string error gracefully", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});

mockSaveProject.mockRejectedValueOnce("Disk full");

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
expect(screen.getByText(/Failed to save project: Disk full/i)).toBeTruthy();
});
});

it("ignores cancellation when saving a project with string error", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});

mockSaveProject.mockRejectedValueOnce("User cancelled");

// Now click save
fireEvent.click(screen.getByRole("button", { name: /save project/i }));

await waitFor(() => {
expect(screen.queryByText(/Failed to save project/i)).toBeNull();
});
});

it("handles song update from workspace", async () => {
mockLoadProject.mockResolvedValueOnce(succeededResult().result);
render(<App />);

// Load first to get jobResult populated
fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});

// Mock prompt to simulate user entering a new chord
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7");

// Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit)
fireEvent.click(screen.getAllByText("C#m7", { selector: 'strong' })[0]);

// Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated)
await waitFor(() => {
expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0);
});

promptSpy.mockRestore();
});

it("does nothing when Save Project is clicked but there is no jobResult", () => {
render(<App />);
const saveButton = screen.getByRole("button", { name: /save project/i });
fireEvent.click(saveButton);
expect(mockSaveProject).not.toHaveBeenCalled();
});
});
74 changes: 67 additions & 7 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
createDefaultAnalysisRequest,
getAnalysisJobStatus,
selectLocalAudioSource,
startAnalysisJob
startAnalysisJob,
loadProject,
saveProject
} from "./lib/analysis";
import { createTranslator, detectPreferredLocale } from "./i18n";
import { Workspace } from "./features/workspace/Workspace";
Expand All @@ -35,7 +37,7 @@ function progressMessage(
}

export function App() {
const t = createTranslator(detectPreferredLocale());
const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
const defaultRequest = useMemo(() => createDefaultAnalysisRequest(), []);
const [jobStatus, setJobStatus] = useState<AnalysisJobStatus | null>(null);
const [jobResult, setJobResult] = useState<RehearsalSong | null>(null);
Expand Down Expand Up @@ -77,7 +79,7 @@ export function App() {
}, ANALYSIS_POLL_INTERVAL_MS);

return () => window.clearTimeout(timer);
}, [jobStatus]);
}, [jobStatus, t]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleStartAnalysis = async () => {
setJobError(null);
Expand Down Expand Up @@ -114,6 +116,39 @@ export function App() {
setJobStatus(null);
};

const handleLoadProject = async () => {
try {
const song = await loadProject();
setJobResult(song);
setJobError(null);
setSelectedBootstrap(null);
setJobStatus(null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (e) {
if (e instanceof Error && e.message !== "User cancelled") {
setJobError(`Failed to load project: ${e.message}`);
} else if (typeof e === "string" && e !== "User cancelled") {
setJobError(`Failed to load project: ${e}`);
}
}
};

const handleSaveProject = async () => {
if (!jobResult) return;
try {
await saveProject(jobResult);
} catch (e) {
if (e instanceof Error && e.message !== "User cancelled") {
setJobError(`Failed to save project: ${e.message}`);
} else if (typeof e === "string" && e !== "User cancelled") {
setJobError(`Failed to save project: ${e}`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleSongUpdate = (updatedSong: RehearsalSong) => {
setJobResult(updatedSong);
};

const renderWorkspaceState = () => {
if (jobError) {
return <ErrorState error={jobError} />;
Expand All @@ -122,16 +157,33 @@ export function App() {
return <LoadingState />;
}
if (jobResult) {
return <Workspace song={jobResult} />;
return <Workspace song={jobResult} onSongUpdate={handleSongUpdate} />;
}
return <EmptyState />;
};

return (
<main style={{ padding: "24px", maxWidth: "1200px", margin: "0 auto", fontFamily: "system-ui, sans-serif" }}>
<header style={{ marginBottom: "32px" }}>
<h1 style={{ margin: "0 0 8px 0" }}>{t("appTitle")}</h1>
<p style={{ color: "#666", margin: "0" }}>{t("appSubtitle")}</p>
<header style={{ marginBottom: "32px", display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h1 style={{ margin: "0 0 8px 0" }}>{t("appTitle")}</h1>
<p style={{ color: "#666", margin: "0" }}>{t("appSubtitle")}</p>
</div>
<button
type="button"
onClick={handleSaveProject}
aria-disabled={!jobResult}
style={{
padding: "8px 16px",
cursor: jobResult ? "pointer" : "not-allowed",
borderRadius: "4px",
backgroundColor: jobResult ? "#fff" : "#f5f5f5",
border: "1px solid #ccc",
opacity: jobResult ? 1 : 0.5
}}
>
Save Project
</button>
</header>

<div style={{ marginBottom: "24px", display: "flex", gap: "12px", alignItems: "center" }}>
Expand All @@ -143,6 +195,14 @@ export function App() {
>
{t("chooseLocalAudio")}
</button>
<button
type="button"
onClick={handleLoadProject}
disabled={analysisInFlight || isStarting}
style={{ padding: "8px 16px", cursor: "pointer", borderRadius: "4px" }}
>
Open Project
</button>
<button
type="button"
onClick={handleStartAnalysis}
Expand Down
Loading
Loading