diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 24fbc46..8c51b89 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -1,7 +1,5 @@ "use strict"; -const { contextBridge, ipcRenderer } = require("electron"); - function readAdditionalArgument(argv, name) { const prefix = `--${name}=`; const match = argv.find((arg) => arg.startsWith(prefix)); @@ -63,7 +61,11 @@ function exposeEchoformApi(electron, preloadConfig = resolvePreloadConfig()) { }); } -exposeEchoformApi({ contextBridge, ipcRenderer }); +// Only touch the Electron module inside Electron. Requiring it from plain Node +// (unit tests) resolves the binary path and can trigger a binary download. +if (process.versions.electron) { + exposeEchoformApi(require("electron")); +} module.exports = { exposeEchoformApi, diff --git a/apps/desktop/index.html b/apps/desktop/index.html index cdafd99..1cebdc2 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -1,12 +1,12 @@ - + Echoform - +
diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e0c0386..bd58fb5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,13 +37,12 @@ "wavesurfer.js": "^7.12.11" }, "devDependencies": { - "@base-ui/react": "^1.7.0", "@biomejs/biome": "2.5.7", - "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource-variable/instrument-sans": "^5.3.0", "@phosphor-icons/react": "^2.1.10", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-virtual": "^3.14.9", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.3", @@ -56,20 +55,13 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "date-fns": "^4.4.0", "electron": "^43.2.0", "electron-builder": "^26.15.3", "electron-builder-squirrel-windows": "^26.15.3", - "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", "jsdom": "^30.0.1", - "next-themes": "^0.4.6", "radix-ui": "^1.6.7", "react": "^19.2.8", - "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", - "react-resizable-panels": "^4.12.2", - "recharts": "3.10.1", "shadcn": "^4.16.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", @@ -77,7 +69,6 @@ "tw-animate-css": "^1.4.0", "typescript": "~7.0.2", "ultracite": "7.10.0", - "vaul": "^1.1.2", "vite": "^8.2.0", "vitest": "^4.1.10", "zustand": "^5.0.14" diff --git a/apps/desktop/public/vite.svg b/apps/desktop/public/vite.svg deleted file mode 100644 index c22d974..0000000 --- a/apps/desktop/public/vite.svg +++ /dev/null @@ -1,43 +0,0 @@ - diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 69583db..5836dee 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,9 +1,11 @@ import { useEffect, useRef } from "react"; +import { Logo } from "@/components/logo"; import { PreviewPlayer } from "@/components/preview-player"; import { ProjectHeader } from "@/components/project-header"; import { AppSidebar } from "@/components/sidebar"; import { Timeline } from "@/components/timeline"; import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { WelcomeOnboarding } from "@/components/welcome-onboarding"; import { useDaemonSync } from "@/hooks/use-daemon-sync"; import { usePreviewStatusToasts } from "@/hooks/use-preview-status-toasts"; @@ -16,35 +18,28 @@ import { useStore } from "@/lib/store"; function AppLoading() { return (
-
-
-
-
- Loading projects... +
+ + + Starting Echoform… +
); } -function ConnectionIndicator() { +function ConnectionBanner() { const connected = useConnectionStore((s) => s.connected); - const snapshotReceived = useStore((s) => s.snapshotReceived); - - // Don't show "reconnecting" on first load — the app loading screen handles it - if (!snapshotReceived) { - return null; - } - if (connected) { return null; } - return ( -
-
-
- Reconnecting... -
+
+ Lost connection to Echoform's background service. Reconnecting — saves + made in Ableton meanwhile are picked up once it's back.
); } @@ -78,8 +73,9 @@ function App() { }); }, [connected, onboardingStep, projects.length, snapshotReceived]); - // Show loading screen while waiting for initial connection + snapshot - if (!(connected && snapshotReceived)) { + // Only block on the very first snapshot; later disconnects show a banner + // so the user keeps seeing their history. + if (!snapshotReceived) { return ( <> @@ -88,40 +84,41 @@ function App() { ); } - // Show onboarding when not completed yet if (onboardingStep !== "done") { return ( - <> + - + ); } return ( -
-
- -
- -
- -
- + +
+
+
- {selectedProject && previewSave && ( - - )} -
- - -
+
+ + +
+ +
+ {selectedProject && previewSave && ( + + )} +
+ + +
+ ); } diff --git a/apps/desktop/src/assets/react.svg b/apps/desktop/src/assets/react.svg deleted file mode 100644 index fd4ba6b..0000000 --- a/apps/desktop/src/assets/react.svg +++ /dev/null @@ -1,16 +0,0 @@ - diff --git a/apps/desktop/src/components/arrangement-map.tsx b/apps/desktop/src/components/arrangement-map.tsx new file mode 100644 index 0000000..ed18afd --- /dev/null +++ b/apps/desktop/src/components/arrangement-map.tsx @@ -0,0 +1,278 @@ +import { useState } from "react"; +import { abletonColor } from "@/lib/ableton-colors"; +import { displayTrackName } from "@/lib/track-name"; +import type { ClipAnalysis, SaveAnalysis, TrackAnalysis } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const LABEL_WIDTH = "9.5rem"; + +export interface SelectedClip { + clip: ClipAnalysis; + track: TrackAnalysis; +} + +function pct(beats: number, length: number) { + return `${(beats / Math.max(length, 1)) * 100}%`; +} + +function barRange(clip: ClipAnalysis, perBar: number): string { + const first = Math.floor(clip.start / perBar) + 1; + const last = Math.max(first, Math.ceil(clip.end / perBar)); + return first === last ? `bar ${first}` : `bars ${first}–${last}`; +} + +const STATUS_TEXT: Record = { + added: "new", + edited: "edited", + moved: "moved", + removed: "removed", + same: "unchanged", +}; + +function ClipBlock({ + clip, + track, + length, + perBar, + selected, + onSelect, +}: { + clip: ClipAnalysis; + track: TrackAnalysis; + length: number; + perBar: number; + selected: boolean; + onSelect: () => void; +}) { + const color = abletonColor(track.color); + const width = `max(2px, ${pct(clip.end - clip.start, length)})`; + const noteCount = clip.notes + ? ` · ${clip.notes.added.length} notes added, ${clip.notes.removed.length} removed` + : ""; + const title = `${clip.name || track.name} · ${barRange(clip, perBar)} · ${STATUS_TEXT[clip.status]}${noteCount}`; + const selectable = clip.notes !== null; + + return ( + <> + {clip.movedFrom !== null && ( + + )} + + + ); +} + +/** + * The checkpoint's arrangement: one row per track, clips placed by bar, + * drawn by what happened to them since the previous checkpoint. + */ +export function ArrangementMap({ + analysis, + selected, + onSelectClip, +}: { + analysis: SaveAnalysis; + selected: SelectedClip | null; + onSelectClip: (selection: SelectedClip) => void; +}) { + const changed = analysis.tracks.filter((t) => t.status !== "same"); + const [showAll, setShowAll] = useState(changed.length === 0); + const tracks = (showAll ? analysis.tracks : changed).filter( + (t) => t.clips.length > 0 || t.status !== "same" || t.type === "group" + ); + const length = Math.max(analysis.lengthBeats, analysis.beatsPerBar * 4); + const hidden = analysis.tracks.length - changed.length; + + return ( +
+
+

+ Arrangement +

+ {changed.length > 0 && hidden > 0 && ( + + )} +
+ +
+
+ +
+ + {analysis.locators.map((locator) => ( + + ))} +
+
+ + {tracks.length === 0 ? ( +

+ No clips in the arrangement. +

+ ) : ( + tracks.map((track) => ( +
+
+ + + {displayTrackName(track.name)} + +
+
+ {track.clips.map((clip) => ( + onSelectClip({ clip, track })} + perBar={analysis.beatsPerBar} + selected={selected?.clip === clip} + track={track} + /> + ))} +
+
+ )) + )} +
+ +
+ + new + + + + + + edited + + + + removed + + + + moved from + + + {" "} + unchanged + +
+
+ ); +} + +/** Bar numbers along the top of the arrangement map. */ +export function BarRuler({ + scaleBeats, + beatsPerBar, + className, +}: { + scaleBeats: number; + beatsPerBar: number; + className?: string; +}) { + const totalBars = Math.max(1, Math.ceil(scaleBeats / beatsPerBar)); + const step = [1, 2, 4, 8, 16, 32, 64].find((s) => totalBars / s <= 8) ?? 128; + const marks: number[] = []; + for (let bar = 1; bar <= totalBars; bar += step) { + marks.push(bar); + } + return ( +
+ {marks.map((bar) => ( + + {bar} + + ))} +
+ ); +} diff --git a/apps/desktop/src/components/change-list.tsx b/apps/desktop/src/components/change-list.tsx new file mode 100644 index 0000000..2490523 --- /dev/null +++ b/apps/desktop/src/components/change-list.tsx @@ -0,0 +1,397 @@ +import type { ReactNode } from "react"; +import { abletonColor } from "@/lib/ableton-colors"; +import { displayTrackName } from "@/lib/track-name"; +import type { + ClipAnalysis, + SaveAnalysis, + SetChange, + TrackAnalysis, + TrackChange, +} from "@/lib/types"; +import { cn } from "@/lib/utils"; + +function bars(start: number, end: number, perBar: number): string { + const first = Math.floor(start / perBar) + 1; + const last = Math.max(first, Math.ceil(end / perBar)); + return first === last ? `bar ${first}` : `bars ${first}–${last}`; +} + +function db(value: number): string { + if (!Number.isFinite(value)) { + return "−∞"; + } + return `${value > 0 ? "+" : value < 0 ? "−" : ""}${Math.abs(value).toFixed(1)}`; +} + +function pan(value: number): string { + const amount = Math.round(Math.abs(value) * 50); + if (amount === 0) { + return "C"; + } + return `${amount}${value < 0 ? "L" : "R"}`; +} + +function number(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(2); +} + +const Value = ({ children }: { children: ReactNode }) => ( + {children} +); + +const Arrow = () => →; + +interface Line { + key: string; + quiet?: boolean; + text: ReactNode; + tone?: "added" | "removed" | "edited"; +} + +function clipLines(track: TrackAnalysis, perBar: number): Line[] { + const lines: Line[] = []; + const by = (status: ClipAnalysis["status"]) => + track.clips.filter((c) => c.status === status); + const span = (clips: ClipAnalysis[]) => + bars( + Math.min(...clips.map((c) => c.start)), + Math.max(...clips.map((c) => c.end)), + perBar + ); + + const added = by("added"); + if (added.length > 0 && track.status !== "added") { + const notes = added.reduce( + (sum, c) => sum + (c.notes?.added.length ?? 0), + 0 + ); + lines.push({ + key: "added", + text: ( + <> + {added.length === 1 ? "New clip" : `${added.length} new clips`} in{" "} + {span(added)} + {notes > 0 && ( + · {notes} notes + )} + + ), + tone: "added", + }); + } + const edited = by("edited"); + if (edited.length > 0) { + const plus = edited.reduce( + (sum, c) => sum + (c.notes?.added.length ?? 0), + 0 + ); + const minus = edited.reduce( + (sum, c) => sum + (c.notes?.removed.length ?? 0), + 0 + ); + lines.push({ + key: "edited", + text: ( + <> + {edited.some((c) => c.kind === "midi") + ? "Notes edited" + : "Clips edited"}{" "} + in {span(edited)} + {plus + minus > 0 && ( + + {" "} + +{plus}{" "} + −{minus} + + )} + + ), + tone: "edited", + }); + } + const removed = by("removed"); + if (removed.length > 0 && track.status !== "removed") { + lines.push({ + key: "removed", + text: <>Cleared {span(removed)}, + tone: "removed", + }); + } + const moved = by("moved"); + if (moved.length > 0) { + const first = moved[0]!; + lines.push({ + key: "moved", + text: + moved.length === 1 && first.movedFrom !== null ? ( + bars(first.movedFrom, first.movedFrom + 1, perBar) === + bars(first.start, first.start + 1, perBar) ? ( + <>Clip nudged within {bars(first.start, first.start + 1, perBar)} + ) : ( + <> + Clip moved from{" "} + {bars(first.movedFrom, first.movedFrom + 1, perBar)} to{" "} + {bars(first.start, first.start + 1, perBar)} + + ) + ) : ( + <>{moved.length} clips moved + ), + }); + } + const samples = [...new Set(added.map((c) => c.sample).filter(Boolean))]; + if (samples.length > 0) { + lines.push({ + key: "samples", + quiet: true, + text: <>Audio: {samples.slice(0, 3).join(", ")}, + }); + } + return lines; +} + +function changeLine(change: TrackChange, index: number): Line { + const key = `${change.type}-${index}`; + switch (change.type) { + case "renamed": + return { key, text: <>Renamed from “{change.from}” }; + case "device-added": + return { key, text: <>{change.device} added, tone: "added" }; + case "device-removed": + return { key, text: <>{change.device} removed, tone: "removed" }; + case "device-on": + return { key, text: <>{change.device} turned on }; + case "device-off": + return { key, text: <>{change.device} turned off }; + case "device-state": + return { + key, + quiet: true, + text: <>{change.device}: plugin state changed (details not readable), + }; + case "device-settings": + return { + key, + text: + change.params.length === 0 ? ( + <>{change.device} settings changed + ) : ( + <> + {change.device} + {change.params.slice(0, 4).map((p) => ( + + {p.name} {number(p.from)} + + {number(p.to)} + + ))} + {change.params.length > 4 && ( + + +{change.params.length - 4} more parameters + + )} + + ), + }; + case "volume": + return { + key, + text: ( + <> + Volume {db(change.from)} + + {db(change.to)} dB + + ), + }; + case "pan": + return { + key, + text: ( + <> + Pan {pan(change.from)} + + {pan(change.to)} + + ), + }; + case "send": + return { + key, + text: ( + <> + Send {String.fromCharCode(65 + change.index)}{" "} + {db(change.from)} + + {db(change.to)} dB + + ), + }; + case "muted": + case "unmuted": + case "soloed": + case "unsoloed": + return { + key, + text: change.type.charAt(0).toUpperCase() + change.type.slice(1), + }; + case "automation": + return { + key, + text: ( + <> + {change.target} automation {change.status} + + ), + tone: + change.status === "added" + ? "added" + : change.status === "removed" + ? "removed" + : undefined, + }; + case "session-clips": + return { + key, + quiet: true, + text: ( + <> + Session clips {change.from} + + {change.to} + + ), + }; + } +} + +function setLine(change: SetChange, index: number, perBar: number): Line { + const key = `set-${index}`; + switch (change.type) { + case "tempo": + return { + key, + text: ( + <> + Tempo {change.from} + + {change.to} BPM + + ), + }; + case "time-signature": + return { + key, + text: ( + <> + Time signature {change.from} + + {change.to} + + ), + }; + case "locator-added": + case "locator-removed": + return { + key, + text: ( + <> + Marker “{change.name || "untitled"}” at{" "} + {bars(change.time, change.time + 1, perBar)}{" "} + {change.type === "locator-added" ? "added" : "removed"} + + ), + tone: change.type === "locator-added" ? "added" : "removed", + }; + } +} + +const TONE_MARK: Record, string> = { + added: "bg-added", + edited: "bg-edited", + removed: "bg-removed", +}; + +function Lines({ lines }: { lines: Line[] }) { + return ( +
    + {lines.map((line) => ( +
  • + + {line.text} +
  • + ))} +
+ ); +} + +/** Every change in plain words, grouped by track. */ +export function ChangeList({ analysis }: { analysis: SaveAnalysis }) { + const perBar = analysis.beatsPerBar; + const setLines = analysis.setChanges.map((c, i) => setLine(c, i, perBar)); + const tracks = analysis.tracks + .filter((t) => t.status !== "same") + .map((track) => ({ + lines: + track.status === "added" + ? [{ key: "added", text: <>New track, tone: "added" as const }] + : track.status === "removed" + ? [ + { + key: "removed", + text: <>Track removed, + tone: "removed" as const, + }, + ] + : [...clipLines(track, perBar), ...track.changes.map(changeLine)], + track, + })) + .filter((entry) => entry.lines.length > 0); + + if (setLines.length === 0 && tracks.length === 0) { + return ( +

+ {analysis.summary.first + ? "This is the first checkpoint of this set, so there is nothing earlier to compare with." + : "Nothing in the song changed in this save."} +

+ ); + } + + return ( +
+ {setLines.length > 0 && ( +
+

+ Song +

+ +
+ )} + {tracks.map(({ track, lines }) => ( +
+

+ + {displayTrackName(track.name)} +

+ +
+ ))} +
+ ); +} diff --git a/apps/desktop/src/components/expanded-card.test.tsx b/apps/desktop/src/components/checkpoint-detail.test.tsx similarity index 91% rename from apps/desktop/src/components/expanded-card.test.tsx rename to apps/desktop/src/components/checkpoint-detail.test.tsx index 6c78684..7ce4b21 100644 --- a/apps/desktop/src/components/expanded-card.test.tsx +++ b/apps/desktop/src/components/checkpoint-detail.test.tsx @@ -1,8 +1,9 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Idea, Project, Save } from "@/lib/types"; -import { ExpandedCard } from "./expanded-card"; +import { CheckpointDetail } from "./checkpoint-detail"; const { sendDaemonCommand } = vi.hoisted(() => ({ sendDaemonCommand: vi.fn(), @@ -77,14 +78,19 @@ const project: Project = { }; function renderCard() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); return render( - + + + ); } @@ -100,6 +106,13 @@ function deferred() { describe("historical checkpoint actions", () => { beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: async () => ({ error: "No set" }), + ok: false, + }) + ); sendDaemonCommand.mockReset(); sendDaemonCommand.mockResolvedValue({ requestId: "request-1" }); }); diff --git a/apps/desktop/src/components/checkpoint-detail.tsx b/apps/desktop/src/components/checkpoint-detail.tsx new file mode 100644 index 0000000..f421d18 --- /dev/null +++ b/apps/desktop/src/components/checkpoint-detail.tsx @@ -0,0 +1,626 @@ +import { + CaretDown, + CircleNotch, + GitBranch, + MusicNote, + PushPin, + ShieldCheck, + TrashSimple, + X, +} from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { sendDaemonCommand } from "@/lib/daemon-client"; +import { posthog } from "@/lib/posthog"; +import { usePreviewStore } from "@/lib/preview-store"; +import type { Idea, Project, Save, SaveAnalysis } from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { ArrangementMap, type SelectedClip } from "./arrangement-map"; +import { ChangeList } from "./change-list"; +import { describeSave } from "./checkpoint-row"; +import { PianoRollDiff } from "./piano-roll-diff"; +import { PreviewRequestDialog } from "./preview-request-dialog"; +import { + fileTabName, + formatFullDateTime, + formatTime, + getKeepReasons, + getSaveDisplayTitle, +} from "./timeline-utils"; + +async function fetchAnalysis( + projectId: string, + saveId: string +): Promise { + const res = await fetch( + `/api/projects/${projectId}/saves/${saveId}/analysis` + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error ?? "This checkpoint couldn't be analyzed."); + } + return data.analysis as SaveAnalysis; +} + +/** Open the most interesting MIDI clip in the piano roll by default. */ +function defaultClip(analysis: SaveAnalysis): SelectedClip | null { + for (const status of ["edited", "added"] as const) { + for (const track of analysis.tracks) { + const clip = track.clips.find((c) => c.status === status && c.notes); + if (clip) { + return { clip, track }; + } + } + } + return null; +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function SongChanges({ projectId, save }: { projectId: string; save: Save }) { + const query = useQuery({ + queryFn: () => fetchAnalysis(projectId, save.id), + queryKey: [ + "analysis", + projectId, + save.id, + save.summary?.baseSaveId ?? null, + ], + staleTime: Number.POSITIVE_INFINITY, + }); + const [picked, setPicked] = useState(null); + + if (query.isPending) { + return ( +
+
+
+ Reading the Ableton set… +
+ ); + } + if (query.isError) { + return ( +

+ {query.error.message} +

+ ); + } + + const analysis = query.data; + const selection = picked ?? defaultClip(analysis); + return ( +
+ + {selection && ( + + )} +
+ +
+
+ ); +} + +interface CheckpointDetailProps { + idea: Idea | undefined; + isHead: boolean; + onClose: () => void; + project: Project; + save: Save; +} + +export function CheckpointDetail({ + save, + idea, + isHead, + project, + onClose, +}: CheckpointDetailProps) { + const openPreviewPlayer = usePreviewStore((s) => s.openPreviewPlayer); + const projectId = project.id; + const editRequestGenerationRef = useRef(0); + const savedLabel = save.customLabel ? save.label : ""; + const [labelVal, setLabelVal] = useState(savedLabel); + const [noteVal, setNoteVal] = useState(save.note); + const [edit, setEdit] = useState<{ + error: string | null; + pending: boolean; + saved: boolean; + }>({ error: null, pending: false, saved: false }); + const [pin, setPin] = useState<{ error: string | null; pending: boolean }>({ + error: null, + pending: false, + }); + const [dialog, setDialog] = useState< + "none" | "branch" | "delete" | "preview" + >("none"); + const [branch, setBranch] = useState<{ + advanced: boolean; + error: string | null; + pending: boolean; + }>({ advanced: false, error: null, pending: false }); + const [remove, setRemove] = useState<{ + error: string | null; + pending: boolean; + }>({ error: null, pending: false }); + + const keepReasons = getKeepReasons(project, save); + const isBranchStart = !save.auto && Boolean(project.continuedFrom); + + const commitEdit = async () => { + const nextLabel = labelVal.trim(); + if (nextLabel === savedLabel && noteVal === save.note) { + return; + } + const generation = editRequestGenerationRef.current + 1; + editRequestGenerationRef.current = generation; + setEdit({ error: null, pending: true, saved: false }); + try { + await sendDaemonCommand( + { + projectId, + saveId: save.id, + type: "update-save", + ...(noteVal === save.note ? {} : { note: noteVal }), + ...(nextLabel === savedLabel ? {} : { label: nextLabel }), + }, + { reportError: false } + ); + if (editRequestGenerationRef.current === generation) { + setEdit({ error: null, pending: false, saved: true }); + } + } catch (error) { + if (editRequestGenerationRef.current === generation) { + setEdit({ + error: + error instanceof Error + ? error.message + : "Checkpoint details were not saved.", + pending: false, + saved: false, + }); + } + } + }; + + const handlePin = async () => { + setPin({ error: null, pending: true }); + try { + await sendDaemonCommand( + { + pinned: !save.pinned, + projectId, + saveId: save.id, + type: "update-save", + }, + { reportError: false } + ); + setPin({ error: null, pending: false }); + } catch (error) { + setPin({ + error: + error instanceof Error + ? error.message + : "The checkpoint pin was not changed.", + pending: false, + }); + } + }; + + const handleBranch = async () => { + setBranch((b) => ({ ...b, error: null, pending: true })); + try { + await sendDaemonCommand( + { + open: true, + projectId, + saveId: save.id, + type: "recover-save", + }, + { reportError: false } + ); + posthog.capture("save_recovered"); + setBranch({ advanced: false, error: null, pending: false }); + setDialog("none"); + } catch (error) { + setBranch((b) => ({ + ...b, + error: + error instanceof Error + ? error.message + : "The copy could not be created.", + pending: false, + })); + } + }; + + const handleDelete = async () => { + setRemove({ error: null, pending: true }); + try { + await sendDaemonCommand( + { projectId, saveId: save.id, type: "delete-save" }, + { reportError: false } + ); + posthog.capture("save_deleted", { auto: save.auto }); + setRemove({ error: null, pending: false }); + setDialog("none"); + } catch (error) { + setRemove({ + error: + error instanceof Error + ? error.message + : "Checkpoint was not deleted.", + pending: false, + }); + } + }; + + return ( +
+
+
+
+
+ + {formatTime(save.createdAt)} + + {isHead && ( + + Latest + + )} + {isBranchStart && ( + + + Branch start + + )} +
+
+ {formatFullDateTime(save.createdAt)} + {idea ? ` · ${fileTabName(idea)}.als` : ""} +
+
+ +
+ + setLabelVal(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + placeholder="Name this checkpoint…" + value={labelVal} + /> +

+ {describeSave(project, save)} +

+ +
+ + + {save.previewStatus === "ready" && ( + + )} +
+

+ Continue from here opens this version in Ableton as a separate copy. + Your current project is not changed. +

+ {(save.previewStatus === "missing" || + save.previewStatus === "error") && ( +

+ {save.previewStatus === "missing" + ? "The audio preview file is missing. Attach it again." + : "The audio preview couldn't be imported. Attach it again."} +

+ )} +
+ + + +
+