Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 33 additions & 2 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,9 @@ fn start_analysis_job(request: Value, state: tauri::State<'_, AppState>) -> Anal
}

#[tauri::command]
fn get_analysis_job_status(job_id: String, state: tauri::State<'_, AppState>) -> AnalysisJobStatus {
fn get_analysis_job_status,
save_project,
load_project(job_id: String, state: tauri::State<'_, AppState>) -> AnalysisJobStatus {
state
.0
.jobs
Expand Down Expand Up @@ -733,13 +735,42 @@ 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())?;

if let Some(path) = FileDialog::new()
.add_filter("BandScope Project", &["bscope", "json"])
.save_file()
{
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(|| "No file selected".to_string())?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
111 changes: 110 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,107 @@ 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("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();
});

const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockSaveProject.mockRejectedValueOnce(new Error("Permission denied"));

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

await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save project", expect.any(Error));
});

consoleErrorSpy.mockRestore();
});

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();
});
});
59 changes: 53 additions & 6 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
createDefaultAnalysisRequest,
getAnalysisJobStatus,
selectLocalAudioSource,
startAnalysisJob
startAnalysisJob,
loadProject,
saveProject
} from "./lib/analysis";
import { createTranslator, detectPreferredLocale } from "./i18n";
import { Workspace } from "./features/workspace/Workspace";
Expand Down Expand Up @@ -77,7 +79,7 @@
}, 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,32 @@
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}`);
}
}
};

const handleSaveProject = async () => {
try {
await saveProject(jobResult!);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} catch (e) {
console.error("Failed to save project", e);

Check failure on line 137 in apps/desktop/src/App.tsx

View workflow job for this annotation

GitHub Actions / release-preflight

Unexpected console statement

Check failure on line 137 in apps/desktop/src/App.tsx

View workflow job for this annotation

GitHub Actions / ci / build-and-test

Unexpected console statement
}
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 +150,27 @@
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>
{jobResult && (
<button
type="button"
onClick={handleSaveProject}
style={{ padding: "8px 16px", cursor: "pointer", borderRadius: "4px", backgroundColor: "#fff", border: "1px solid #ccc" }}
>
Save Project
</button>
)}
</header>

<div style={{ marginBottom: "24px", display: "flex", gap: "12px", alignItems: "center" }}>
Expand All @@ -143,6 +182,14 @@
>
{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
37 changes: 34 additions & 3 deletions apps/desktop/src/features/workspace/SectionRoadmap.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
import type { RehearsalSong } from "@bandscope/shared-types";
import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { ConfidenceBadge } from "./ConfidenceBadge";

interface SectionRoadmapProps {
song: RehearsalSong;
activeRole: string | null; // null means all roles
onSongUpdate?: (song: RehearsalSong) => void;
}

export function SectionRoadmap({ song, activeRole }: SectionRoadmapProps) {
export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) {
const t = createTranslator(detectPreferredLocale());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const handleChordEdit = (sectionId: string, role: RehearsalRole) => {
if (!onSongUpdate) return;
const newChord = window.prompt("Enter new chord:", role.harmony.chord);
if (newChord !== null && newChord.trim() !== "" && newChord !== role.harmony.chord) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const updatedSong = structuredClone(song);
const section = updatedSong.sections.find(s => s.id === sectionId);
if (section) {
const targetRole = section.roles.find(r => r.id === role.id);
if (targetRole) {
targetRole.harmony = {
...targetRole.harmony,
chord: newChord.trim(),
source: "user"
};
targetRole.manualOverrides.push({
field: "harmony",
value: { ...targetRole.harmony, source: "user" },
source: "user"
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onSongUpdate(updatedSong);
}
}
}
};

return (
<div style={{ marginTop: "24px" }}>
<h2>{t("sectionRoadmapTitle")}</h2>
Expand Down Expand Up @@ -55,7 +81,12 @@ export function SectionRoadmap({ song, activeRole }: SectionRoadmapProps) {
)}
</div>
<div style={{ fontSize: "0.9em", marginTop: "4px" }}>
Chord: <strong>{role.harmony.chord}</strong>
Chord: <strong
style={{ cursor: onSongUpdate ? "pointer" : "default", textDecoration: onSongUpdate ? "underline" : "none", color: role.harmony.source === "user" ? "#1890ff" : "inherit" }}
onClick={() => handleChordEdit(section.id, role)}
title={onSongUpdate ? "Click to edit chord" : undefined}
>{role.harmony.chord}</strong>
{role.harmony.source === "user" && <span style={{ fontSize: "0.8em", marginLeft: "4px", color: "#1890ff" }}>(User)</span>}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
<div style={{ fontSize: "0.85em", color: "#666", marginTop: "2px" }}>
Cue: {role.cue.value}
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/features/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { SectionRoadmap } from "./SectionRoadmap";

interface WorkspaceProps {
song: RehearsalSong;
onSongUpdate?: (song: RehearsalSong) => void;
}

export function Workspace({ song }: WorkspaceProps) {
export function Workspace({ song, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState<string | null>(null);

// Extract all unique roles from the song's sections
Expand Down Expand Up @@ -39,6 +40,7 @@ export function Workspace({ song }: WorkspaceProps) {
<SectionRoadmap
song={song}
activeRole={activeRole}
onSongUpdate={onSongUpdate}
/>
</div>
);
Expand Down
Loading
Loading