From c95532fad4aa6cb7beff650a8ce27b914552e55b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 21:03:26 +0000 Subject: [PATCH] Never offer the Primary Rig's own machine to bring over from itself Joshua's Windows PC, the Primary Rig, showed "Bring over from the Primary Rig" for its Reaper key map and menus. Two causes: - "Add" on a suggestion from another machine saved that machine as the new version's source, so adding one plug-in from the Mac made the Mac the Primary machine. add_to_golden now keeps the Primary machine, and the store reads older "Added ... from ..." versions as their parent's, so existing rigs are right again without a migration. - On the Primary machine, files edited here after the version was saved were offered for Bring over, which would undo the newer change. They now get a "Changed here since Primary Rig vN" card with Update Primary Rig, which adds this machine's copies to a new version. Compare's machine list also puts "Primary" first so it isn't cut off. Decision #123. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013Se1kg5gSsJs5yj2HeacfG --- app/src-tauri/src/bring.rs | 114 +++++++++- app/src-tauri/src/compare.rs | 2 +- app/src-tauri/src/lib.rs | 1 + app/src/BringOver.tsx | 411 ++++++++++++++++++++++------------- app/src/api.ts | 10 + app/src/screens/Compare.tsx | 3 +- crates/local/src/backup.rs | 1 + crates/local/src/bring.rs | 70 +++++- crates/local/src/golden.rs | 16 +- crates/store/src/lib.rs | 50 ++++- crates/store/tests/store.rs | 47 ++++ docs/decisions.md | 1 + 12 files changed, 545 insertions(+), 181 deletions(-) diff --git a/app/src-tauri/src/bring.rs b/app/src-tauri/src/bring.rs index 5c5813c..4e34b38 100644 --- a/app/src-tauri/src/bring.rs +++ b/app/src-tauri/src/bring.rs @@ -33,6 +33,15 @@ pub struct BringView { pub ignored: Vec, /// The ignores that hide `ignored`, to take back. pub ignores: Vec, + /// This machine is the Primary Rig's (its latest version was made from + /// it), and nothing was asked `from` another machine. + pub primary_here: bool, + /// On the Primary machine: files changed here after the Primary Rig + /// saved them. Not brought over (that would undo the change); "Update + /// Primary Rig" adds them to it instead (decision #123). + pub changed_here: Vec, + /// The latest Primary Rig version, to name it. + pub golden_version: Option, } /// Mirrored in `src/api.ts`. @@ -72,10 +81,19 @@ pub async fn files_to_bring(from: Option) -> Result { cloud: None, ignored: Vec::new(), ignores: Vec::new(), + primary_here: false, + changed_here: Vec::new(), + golden_version: None, }); } let this = this_machine()?; - let (source, files) = list(&this, from.as_deref())?; + let Listed { + source, + files, + changed_here, + primary_here, + golden_version, + } = list(&this, from.as_deref())?; let (ignored, ignores) = match &source { Some(source) => { let store = open(&db)?; @@ -89,10 +107,13 @@ pub async fn files_to_bring(from: Option) -> Result { &target, scan.as_ref(), )?; - ( - bring::drop_same_here(source, scan.as_ref(), ignored), - ignores, - ) + let ignored = bring::drop_same_here(source, scan.as_ref(), ignored); + let ignored = if primary_here { + bring::split_changed_here(ignored).0 + } else { + ignored + }; + (ignored, ignores) } None => Default::default(), }; @@ -103,9 +124,41 @@ pub async fn files_to_bring(from: Option) -> Result { cloud: crate::transfer::cloud_name(), ignored, ignores, + primary_here, + changed_here, + golden_version, }) } +/// "Update Primary Rig" on the Primary machine: a new Primary Rig version +/// with this machine's copies of the changed files (`root/rel` keys, as +/// listed in `changed_here`). Returns the new version. +#[tauri::command] +pub async fn keep_changes_in_golden(app: AppHandle, keys: Vec) -> Result { + let this = this_machine()?; + let listed = list(&this, None)?; + let mut products: Vec = listed + .changed_here + .iter() + .filter(|f| keys.contains(&f.key())) + .filter_map(|f| f.product_id.as_ref().map(|p| p.0.clone())) + .collect(); + products.sort(); + products.dedup(); + if products.is_empty() { + return Err("Those files already match the Primary Rig.".to_owned()); + } + let mut store = open(&db_path()?)?; + let v = patchpilot_local::golden::add_to_golden( + &mut store, + &this, + &products, + patchpilot_local::now(), + )?; + crate::compare::changed_here(&app, &store); + Ok(v.version) +} + /// Bring over the chosen files (`root/rel` keys as listed) and every file /// they need, from the Primary Rig or the linked machine `from`, then rescan. #[tauri::command] @@ -118,7 +171,11 @@ pub async fn bring_files( let rig = load_rig()?.ok_or("Link this machine first, so it can fetch the files.")?; let this = this_machine()?; let machine = open(&db_path()?)?.latest_manifest(&this).map_err(err)?; - let (source, list) = list(&this, from.as_deref())?; + let Listed { + source, + files: list, + .. + } = list(&this, from.as_deref())?; let source = source.ok_or("Nothing to bring over from: make a Primary Rig first.")?; let chosen = bring::with_needs(&list, &keys); @@ -245,22 +302,51 @@ pub async fn bring_files( Ok(result) } +/// What [`list`] found. +#[derive(Default)] +struct Listed { + /// The manifest files come from. + source: Option, + /// What it has that this machine lacks or holds an older copy of. + files: Vec, + /// On the Primary machine, what changed here after the Primary Rig. + changed_here: Vec, + primary_here: bool, + golden_version: Option, +} + /// The manifest files come from (the Primary Rig through this machine's /// profile, or `from`'s latest scan) and what it has that this machine lacks. -fn list(this: &str, from: Option<&str>) -> Result<(Option, Vec), String> { +/// On the Primary Rig's own machine, files changed here since are set apart: +/// they are newer than the Primary Rig's copies, not behind them. +fn list(this: &str, from: Option<&str>) -> Result { let store = open(&db_path()?)?; let machine = store.latest_manifest(this).map_err(err)?; + let golden = store.golden(None).map_err(err)?; + let golden_version = golden.as_ref().map(|g| g.info.version); + let primary_here = from.is_none() + && golden + .as_ref() + .is_some_and(|g| g.info.source_machine_id.as_deref() == Some(this)); let source = match from { Some(id) if id == this => return Err("That's this machine.".to_owned()), Some(id) => store.latest_manifest(id).map_err(err)?, - None => store.golden(None).map_err(err)?.map(|g| g.manifest), + None => golden.map(|g| g.manifest), }; let Some(source) = source else { - return Ok((None, Vec::new())); + return Ok(Listed { + golden_version, + ..Listed::default() + }); }; let (_, profile) = profiles(&store, this, from)?; let files = bring::files_to_bring(&source, &profile, machine.as_ref()); - let mut files = bring::drop_same_here(&source, machine.as_ref(), files); + let files = bring::drop_same_here(&source, machine.as_ref(), files); + let (mut files, changed_here) = if primary_here { + bring::split_changed_here(files) + } else { + (files, Vec::new()) + }; if crate::transfer::cloud_name().is_some() { // Big files come through the cloud folder; only those too big even // for that can't arrive. @@ -274,7 +360,13 @@ fn list(this: &str, from: Option<&str>) -> Result<(Option, Vec) -> Resul /// After a change made on this machine: no alert about it here, upload it, /// and refresh the windows. -fn changed_here(app: &AppHandle, store: &Store) { +pub(crate) fn changed_here(app: &AppHandle, store: &Store) { crate::alerts::rebaseline(app, store); crate::sync::nudge(app); let _ = app.emit("data-changed", ()); diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 9248b8c..66ce257 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -156,6 +156,7 @@ pub fn run() { reaper::reaper_startup, bring::files_to_bring, bring::bring_files, + bring::keep_changes_in_golden, transfer::transfer_view, transfer::set_transfer_folder, transfer::send_big_files, diff --git a/app/src/BringOver.tsx b/app/src/BringOver.tsx index fd4f41d..452361c 100644 --- a/app/src/BringOver.tsx +++ b/app/src/BringOver.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { bringFiles, fileKey, filesToBring, ignoreItems, type BringResult, type BringView, type FileToBring } from "./api"; +import { + bringFiles, + fileKey, + filesToBring, + ignoreItems, + keepChangesInGolden, + type BringResult, + type BringView, + type FileToBring, +} from "./api"; import { bringSummary, isReaperScript, isTemplate, Needs } from "./bring"; import { IgnoreButton, IgnoredToggle, pathRules } from "./Ignore"; import { useShell } from "./shell"; @@ -12,6 +21,11 @@ import { Badge, Button, buildTree, Card, Folder, FolderTree, isConflictCopy, Til * #62); anything replaced is backed up first. Anything can be ignored here * (decision #65), and then stops being listed or counted. Loads its own list, * so any screen about this machine can show it. + * + * On the Primary Rig's own machine, files changed here after the Primary Rig + * saved them aren't offered: bringing the older copy over would undo the + * change. They get their own card, which updates the Primary Rig instead + * (decision #123). */ export function BringOver({ onError }: { onError: (e: string) => void }) { const [view, setView] = useState(null); @@ -44,7 +58,12 @@ export function BringOver({ onError }: { onError: (e: string) => void }) { const folders = useFolders("bring-over", (id) => !id.includes("/") && groups.length === 1); if (!view || !view.has_source) return null; - if (files.length === 0 && !result && !view.linked) return null; + const changed = view.changed_here; + const changedCard = changed.length > 0 && ( + + ); + // On the Primary machine with only its own changes, that card says it all. + if (files.length === 0 && !result && (changed.length > 0 || !view.linked)) return changedCard || null; // The catch-all: every Reaper script here, whatever is ticked. const scripts = files.filter(isReaperScript); @@ -100,174 +119,252 @@ export function BringOver({ onError }: { onError: (e: string) => void }) { const summary = result && bringSummary(result, "the Primary machine", leftOut); const summaryLine = groups.map(([app, fs]) => `${app}: ${summaryOf(fs)}`).join(" · "); + return ( + <> + } + title={ + upToDate ? Up to date with the Primary Rig : "Bring over from the Primary Rig" + } + sub={ + + {upToDate + ? view.ignored.length + ? "Everything from the Primary Rig you haven't ignored is on this machine." + : "Every template, script and theme from the Primary Rig is on this machine." + : summaryLine} + + } + actions={ + <> + {!upToDate && ( + + )} + {view.linked && allScripts.length > 0 && allScripts.length < files.length && ( + + )} + {view.linked && allTemplates.length > 0 && allTemplates.length < files.length && ( + + )} + {view.linked && allPresets.length > 0 && allPresets.length < files.length && ( + + )} + {view.linked && chosen.length > 0 && ( + + )} + + } + > + {!view.linked && files.length > 0 && ( +

+ Link this machine with the Primary one to bring these over. + {shell && ( + + )} +

+ )} + + {view.ignores.length > 0 && ( +
+ `${v.files} file${v.files === 1 ? "" : "s"}`} + /> +
+ )} + + {summary && ( +

+ {summary.text} + {summary.warn && {summary.warn}} +

+ )} + + {open && !upToDate && ( +
+
+ {groups.map(([app, fs]) => ( + folders.toggle(app)} + actions={ + <> + {ignoreAction(`All ${app} files`, fs, true)} + {view.linked && } + + } + > + {groupByKind(fs).map(([kind, ks]) => { + const id = `${app}/${kind}`; + const ticked = ks.filter(isChosen).length; + return ( + folders.toggle(id)} + badges={view.linked && ticked < ks.length && {ticked} ticked} + actions={ + <> + {ignoreAction(`${app} ${plural(lower(kind))}`, ks)} + {view.linked && } + + } + > + ( + <> + {ignoreAction(node.name, treeItems(node))} + {view.linked && ( + + )} + + )} + renderItems={(items, indent) => ( +
    + {items.map((f) => ( + setChosen([f], !isChosen(f))} + ignore={ignoreAction(fileName(f.rel), [f])} + /> + ))} +
+ )} + /> +
+ ); + })} +
+ ))} +
+

+ Files that work together are ticked together, so they arrive whole. Anything replaced is saved first. +

+
+ )} +
+ {changedCard} + + ); +} + +/** + * On the Primary machine: files changed here after Primary Rig v was + * saved. They're newer than the Primary Rig's copies, so the way back to + * parity is to update the Primary Rig with them, not to bring the old ones + * over (decision #123). + */ +function ChangedHere({ + files, + version, + onDone, + onError, +}: { + files: FileToBring[]; + version: number | null; + onDone: () => void; + onError: (e: string) => void; +}) { + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(null); + const summaryLine = groupByApp(files) + .map(([app, fs]) => `${app}: ${summaryOf(fs)}`) + .join(" · "); + const keep = () => { + setBusy(true); + keepChangesInGolden(files.map(fileKey)) + .then((v) => { + setSaved(v); + onDone(); + }) + .catch((e) => onError(String(e))) + .finally(() => setBusy(false)); + }; return ( } - title={ - upToDate ? Up to date with the Primary Rig : "Bring over from the Primary Rig" - } - sub={ - - {upToDate - ? view.ignored.length - ? "Everything from the Primary Rig you haven't ignored is on this machine." - : "Every template, script and theme from the Primary Rig is on this machine." - : summaryLine} - - } + icon={} + title={`Changed here since Primary Rig${version ? ` v${version}` : ""}`} + sub={{summaryLine} · newer than the Primary Rig's copies} actions={ <> - {!upToDate && ( - - )} - {view.linked && allScripts.length > 0 && allScripts.length < files.length && ( - - )} - {view.linked && allTemplates.length > 0 && allTemplates.length < files.length && ( - - )} - {view.linked && allPresets.length > 0 && allPresets.length < files.length && ( + + {files.some((f) => f.product_id) && ( - )} - {view.linked && chosen.length > 0 && ( - )} } > - {!view.linked && files.length > 0 && ( -

- Link this machine with the Primary one to bring these over. - {shell && ( - - )} -

- )} - - {view.ignores.length > 0 && ( -
- `${v.files} file${v.files === 1 ? "" : "s"}`} - /> -
- )} - - {summary && ( -

- {summary.text} - {summary.warn && {summary.warn}} -

- )} - - {open && !upToDate && ( -
-
- {groups.map(([app, fs]) => ( - folders.toggle(app)} - actions={ - <> - {ignoreAction(`All ${app} files`, fs, true)} - {view.linked && } - - } - > - {groupByKind(fs).map(([kind, ks]) => { - const id = `${app}/${kind}`; - const ticked = ks.filter(isChosen).length; - return ( - folders.toggle(id)} - badges={view.linked && ticked < ks.length && {ticked} ticked} - actions={ - <> - {ignoreAction(`${app} ${plural(lower(kind))}`, ks)} - {view.linked && } - - } - > - ( - <> - {ignoreAction(node.name, treeItems(node))} - {view.linked && ( - - )} - - )} - renderItems={(items, indent) => ( -
    - {items.map((f) => ( - setChosen([f], !isChosen(f))} - ignore={ignoreAction(fileName(f.rel), [f])} - /> - ))} -
- )} - /> -
- ); - })} -
- ))} -
-

- Files that work together are ticked together, so they arrive whole. Anything replaced is saved first. -

-
+ {saved !== null &&

Primary Rig v{saved} has this machine's copies now.

} + {open && ( +
    + {files.map((f) => ( +
  • + + {fileName(f.rel)} + + + {f.app} {lower(f.kind)} + +
  • + ))} +
)}
); diff --git a/app/src/api.ts b/app/src/api.ts index 31a1ccc..9f07edc 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -317,6 +317,8 @@ export interface FileToBring { size: number | null; /** This machine has a different copy, backed up before it is replaced. */ replaces: boolean; + /** This machine's different copy was saved after this one (or either time is unknown). */ + newer_here: boolean; too_large: boolean; /** Its product, to match it with comparison rows. */ product_id: string | null; @@ -337,6 +339,12 @@ export interface BringView { ignored: FileToBring[]; /** The ignores behind `ignored`. */ ignores: IgnoreView[]; + /** This machine is the Primary Rig's own (and nothing was asked `from` another). */ + primary_here: boolean; + /** On the Primary machine: files changed here after the Primary Rig saved them (decision #123). */ + changed_here: FileToBring[]; + /** The latest Primary Rig version. */ + golden_version: number | null; } export interface BringResult { @@ -360,6 +368,8 @@ export const filesToBring = (from?: string) => invoke("files_to_bring /** Bring these files and every file they need; from the Primary Rig unless `from` names a linked machine. */ export const bringFiles = (keys: string[], from?: string) => invoke("bring_files", { keys, from: from ?? null }); +/** On the Primary machine: put this machine's copies of these changed files into a new Primary Rig version. */ +export const keepChangesInGolden = (keys: string[]) => invoke("keep_changes_in_golden", { keys }); // Big-file hand-off (decision #64); mirrors src-tauri/src/transfer.rs and patchpilot_local::transfer. diff --git a/app/src/screens/Compare.tsx b/app/src/screens/Compare.tsx index 87a1b7f..255aeec 100644 --- a/app/src/screens/Compare.tsx +++ b/app/src/screens/Compare.tsx @@ -546,9 +546,10 @@ function MachineItem({ + {/* Primary first: the line is cut short in the narrow list. */} + {m.golden_source ? "Primary · " : ""} {m.os === "macos" ? "macOS" : "Windows"} {m.is_this_machine ? " · this machine" : ""} - {m.golden_source ? " · Primary machine" : ""} {m.profile ? ` · ${m.profile.name}` : ""} diff --git a/crates/local/src/backup.rs b/crates/local/src/backup.rs index 7442e23..afd0cd9 100644 --- a/crates/local/src/backup.rs +++ b/crates/local/src/backup.rs @@ -880,6 +880,7 @@ pub fn restore_list( hash: v.hash.clone(), size: Some(v.size), replaces: false, + newer_here: false, too_large: false, product_id: None, needs: Vec::new(), diff --git a/crates/local/src/bring.rs b/crates/local/src/bring.rs index 7b25e1a..3f2892e 100644 --- a/crates/local/src/bring.rs +++ b/crates/local/src/bring.rs @@ -206,6 +206,10 @@ pub struct FileToBring { /// True when this machine has a different file at that path (it is backed /// up before being replaced). pub replaces: bool, + /// This machine's different copy was saved after the one listed here (or + /// either time is unknown). On the Primary Rig's own machine such a file + /// is a change to keep, not one to overwrite: see [`split_changed_here`]. + pub newer_here: bool, /// Over [`MAX_FILE_BYTES`], so it can't travel over the link. pub too_large: bool, /// Its product, to find it from the comparison's rows. @@ -323,12 +327,12 @@ pub fn files_to_bring( wanted.insert(k, (item.platform, item)); } } - let here: BTreeMap<(String, String), ContentHash> = machine + let here: BTreeMap<(String, String), (ContentHash, Option)> = machine .into_iter() .flat_map(|m| &m.items) .filter_map(|i| { let (_, root, rel, _) = classify(&i.path)?; - Some((key(root, rel), copy_hash(i)?)) + Some((key(root, rel), (copy_hash(i)?, i.mtime))) }) .collect(); @@ -336,10 +340,13 @@ pub fn files_to_bring( .into_iter() .filter_map(|(k, (_, item))| { let hash = copy_hash(item)?; - let replaces = match here.get(&k) { - Some(h) if *h == hash => return None, - Some(_) => true, - None => false, + let (replaces, newer_here) = match here.get(&k) { + Some((h, _)) if *h == hash => return None, + Some((_, mtime)) => ( + true, + !matches!((mtime, item.mtime), (Some(here), Some(there)) if *here <= there), + ), + None => (false, false), }; let (shared, root, rel, kind) = classify(&item.path)?; // Presets group by their folder: "FabFilter presets". @@ -353,6 +360,7 @@ pub fn files_to_bring( size: item.size, hash, replaces, + newer_here, product_id: item.product_id.clone(), // Every file it needs, listed or not; narrowed below. needs: item @@ -368,6 +376,17 @@ pub fn files_to_bring( link(out) } +/// On the machine the Primary Rig is made from, `files` (from the Primary +/// Rig) split into what to bring over and what this machine changed after +/// the Primary Rig saved it. Those changes are newer than the Primary Rig's +/// copies: bringing the Primary Rig's over would undo them, so they go to +/// the Primary Rig instead ("Update Primary Rig"). +pub fn split_changed_here(files: Vec) -> (Vec, Vec) { + let (changed, bring): (Vec<_>, Vec<_>) = + files.into_iter().partition(|f| f.replaces && f.newer_here); + (link(bring), changed) +} + /// Narrow each file's `needs` to files in the list (the rest are here /// already), spelled as listed, and mark the files that can't arrive whole. fn link(mut files: Vec) -> Vec { @@ -833,6 +852,45 @@ mod tests { ); } + #[test] + fn the_primary_machine_keeps_what_it_changed_since() { + let dated = |rel: &str, body: &str, mtime: Option| Item { + mtime, + ..template(Os::Windows, rel, body) + }; + let dir = r"C:\Users\a\AppData\Roaming\REAPER"; + let golden = manifest( + Os::Windows, + dir, + vec![ + dated("reaper-kb.ini", "keys v1", Some(100)), + dated("reaper-menu.ini", "menus v1", None), + dated("FXChains/Bus.RfxChain", "bus from the Mac", Some(300)), + dated("FXChains/New.RfxChain", "added from the Mac", Some(300)), + ], + ); + let pc = manifest( + Os::Windows, + dir, + vec![ + dated("reaper-kb.ini", "keys v2", Some(200)), + dated("reaper-menu.ini", "menus v2", Some(200)), + dated("FXChains/Bus.RfxChain", "bus", Some(100)), + ], + ); + let list = files_to_bring(&golden, &TargetProfile::default(), Some(&pc)); + let (bring, changed) = split_changed_here(list); + let rels = |fs: &[FileToBring]| fs.iter().map(|f| f.rel.clone()).collect::>(); + // Edited here after the Primary Rig saved them (or when is unknown): + // the Primary Rig's copy is older, so it isn't offered. + assert_eq!(rels(&changed), ["reaper-kb.ini", "reaper-menu.ini"]); + // Newer in the Primary Rig (added from another machine), or missing here. + assert_eq!( + rels(&bring), + ["FXChains/Bus.RfxChain", "FXChains/New.RfxChain"] + ); + } + #[test] fn collection_names_merge_into_this_machines_library_cfg() { let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/local/src/golden.rs b/crates/local/src/golden.rs index 759107f..47a6dc3 100644 --- a/crates/local/src/golden.rs +++ b/crates/local/src/golden.rs @@ -14,7 +14,9 @@ const SEEN_FILE: &str = "alerts-seen.json"; const OFF_FILE: &str = "drift-alerts-off"; /// Add `products` (IDs) from `machine_id`'s latest scan to a new Primary Rig -/// version, noting where they came from. +/// version, noting where they came from. The Primary machine stays the one +/// the Primary Rig was made from: adding a plug-in from another machine +/// doesn't make that machine the Primary one (decision #123). pub fn add_to_golden( store: &mut Store, machine_id: &str, @@ -39,8 +41,13 @@ pub fn add_to_golden( many => format!("{} products", many.len()), }; let note = format!("Added {what} from {}", scan.machine.name); + let primary = golden + .info + .source_machine_id + .as_deref() + .unwrap_or(machine_id); store - .save_golden(&next, Some(machine_id), Some(¬e), now) + .save_golden(&next, Some(primary), Some(¬e), now) .map_err(err) } @@ -226,6 +233,11 @@ mod tests { .product(&ProductId("acme-delay".into())) .is_some() ); + // Adding from the laptop leaves the studio the Primary machine. + assert_eq!( + g.info.source_machine_id.as_deref(), + Some(studio.machine.id.as_str()) + ); // The studio now lacks Acme Delay: one drift alert, about itself. let alerts = check_alerts(&store, app, this).unwrap(); diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 9dc5b4f..6ce5f90 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -10,7 +10,7 @@ mod migrations; mod paths; mod restore_points; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use patchpilot_core::{ @@ -441,7 +441,8 @@ impl Store { |r| Ok((golden_info(r)?, r.get::<_, i64>(5)?)), ) .optional()?; - row.map(|(info, snapshot_id)| { + row.map(|(mut info, snapshot_id)| { + info.source_machine_id = self.primary_machines()?.remove(&info.version).flatten(); let mut manifest = self.snapshot_manifest(snapshot_id)?; // Versions saved before the rename (decision #60) are stored as // "Golden Rig v". @@ -458,7 +459,50 @@ impl Store { FROM golden_versions WHERE golden_id = ?1 ORDER BY version DESC", )?; let rows = stmt.query_map([GOLDEN_ID], golden_info)?; - Ok(rows.collect::>()?) + let mut primary = self.primary_machines()?; + Ok(rows + .map(|r| { + r.map(|mut info| { + info.source_machine_id = primary.remove(&info.version).flatten(); + info + }) + }) + .collect::>()?) + } + + /// Each version's Primary machine: the machine it was made or updated + /// from. A version that only added products from another machine + /// ("Added Acme Delay from Studio Mac") keeps its parent's: Patch Pilot + /// before 0.4.59 saved the adding machine there, which made that machine + /// the Primary one (decision #123). + fn primary_machines(&self) -> Result>> { + let mut stmt = self.conn.prepare( + "SELECT version, source_machine_id, note FROM golden_versions + WHERE golden_id = ?1 ORDER BY version", + )?; + let rows = stmt.query_map([GOLDEN_ID], |r| { + Ok(( + r.get::<_, u32>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, Option>(2)?, + )) + })?; + let mut out = BTreeMap::new(); + let mut last: Option = None; + for row in rows { + let (version, source, note) = row?; + let added = note + .as_deref() + .is_some_and(|n| n.starts_with("Added ") && n.contains(" from ")); + let primary = if added && last.is_some() { + last.clone() + } else { + source + }; + last = primary.clone(); + out.insert(version, primary); + } + Ok(out) } /// Bundle these machines' latest scans (and their profile assignments), diff --git a/crates/store/tests/store.rs b/crates/store/tests/store.rs index c8e198c..e3fe2be 100644 --- a/crates/store/tests/store.rs +++ b/crates/store/tests/store.rs @@ -363,6 +363,53 @@ fn golden_versions_are_kept_and_chained() { assert_eq!(store.snapshots(&studio.machine.id).unwrap().len(), 0); } +#[test] +fn adding_from_another_machine_keeps_the_primary_machine() { + // Before 0.4.59 an "Added ... from " version saved the adding + // machine as its source, which made it the Primary one (decision #123). + let studio = fixture_manifest(); + let mut store = Store::open_in_memory().unwrap(); + store.save_golden(&studio, Some("pc"), None, 100).unwrap(); + let mut added = studio.clone(); + added.items[0].version = Some("9.9.9".into()); + store + .save_golden(&added, Some("mac"), Some("Added Acme Delay from Mac"), 200) + .unwrap(); + let mut imported = added.clone(); + imported.items[0].version = Some("9.9.10".into()); + store + .save_golden( + &imported, + Some("mac"), + Some("Added MS-20 V from Mac (imported: v3 from Mac)"), + 300, + ) + .unwrap(); + + let latest = store.golden(None).unwrap().unwrap(); + assert_eq!(latest.info.version, 3); + assert_eq!(latest.info.source_machine_id.as_deref(), Some("pc")); + let sources: Vec<_> = store + .golden_history() + .unwrap() + .into_iter() + .map(|v| v.source_machine_id) + .collect(); + assert_eq!( + sources, + [Some("pc".into()), Some("pc".into()), Some("pc".into())] + ); + + // Updating from the Mac's whole scan does make it the Primary machine. + let mut whole = imported.clone(); + whole.items[0].version = Some("10.0.0".into()); + store + .save_golden(&whole, Some("mac"), Some("Updated from Mac"), 400) + .unwrap(); + let latest = store.golden(None).unwrap().unwrap(); + assert_eq!(latest.info.source_machine_id.as_deref(), Some("mac")); +} + #[test] fn migrates_a_v1_database_to_latest() { let dir = tempfile::tempdir().unwrap(); diff --git a/docs/decisions.md b/docs/decisions.md index 93c7aad..3e4f3cd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -127,3 +127,4 @@ Claude: never undo an Accepted decision without adding a new line that supersede | 120 | 2026-11-03 | **Releases also show on the main repo** (Joshua made `PatchPilotSync/patch-pilot` public and asked for releases on its page; amends #32). After the Release workflow publishes to `patch-pilot-releases`, a new job calls `.github/workflows/mirror-release.yml`, which copies the release to the main repo with the same tag, title and notes, attaching only the installers people download (`*_aarch64.dmg`, `*_x64.dmg`, `*_x64-setup.exe`). The updater endpoint, update packages, `latest.json` and `catalog.json` stay on `patch-pilot-releases`. The copy is made as a draft, then published once every installer is attached; it is marked Latest only when its tag is the releases repo's latest, so backfilling an older tag (run the workflow from the Actions tab) never displaces the newest. It uses only the built-in token (the releases repo is public). The README's badge and download link now point to the main repo's releases. | Installed apps already read `patch-pilot-releases/releases/latest/download/latest.json`, so moving the updater would strand every existing install; copying keeps them working while the public repo's page shows each release. Installers only keeps the page clean. | Accepted | | 121 | 2026-11-06 | **First run is a few setup questions, not a tour** (Joshua: a questionnaire that asks how to sync, explains each way's benefits and limits, which DAWs and what to keep in sync, and makes getting going as easy as possible; amends #36 and #91). The Welcome screen becomes five short steps before the first scan (`app/src/FirstRun.tsx`): name this machine and whether it's the main one; Reaper and/or Ableton Live; how files travel (a cloud folder, the home network, a private GitHub repo, or decide later), each with one line on what it's good at and one on its limits (`app/src/SyncMethods.tsx`, the shared wording); what stays the same, as six plain groups over Choose what syncs's kinds (only the DAWs picked); then a summary and Scan this machine. Answers set real settings: a cloud folder or GitHub repo is picked in place and keeping files in sync goes on; after the scan an unticked DAW is no longer watched and its kinds stop syncing, "main" makes the scan the Primary Rig (unless one exists), "not the main one" opens Link your machines on Enter a code, and the home network goes on (`lan_use` + keep in sync) as soon as the machine is linked. The tour is no longer shown on a first run (the questions count as seeing it; How it works still opens it); machines scanned before this still see it once. | A new tester got lost finding how sync works across Settings, This machine and the tour; asking once, up front, and doing the setup there removes the hunt. Everything stays changeable in Settings. | Accepted | | 122 | 2026-11-06 | **One "Scripts, templates and presets" guide on every screen that moves them** (Joshua: a first-time tester couldn't tell how to get Reaper scripts and templates working across machines). A `FilesGuide` Notice line (app/src/FilesGuide.tsx) sits in Get Up To Speed (this machine), Set up, This machine and Settings > Sync. Its line names the next thing to do (link the machines, then Keep Files in Sync) with that button, or turns green once both are done; How it works opens the ways in order, each ticked when done here: link your machines, Bring over what's missing (from the Primary Rig), Keep Files in Sync on every machine (same cloud folder, GitHub repo or home network), Choose what syncs, and what's never copied (plug-ins, Reaper extensions, sample libraries; reaper.ini and audio/MIDI settings stay per machine). On a checklist it starts open until syncing is on; elsewhere folded; the toggle is remembered. On This machine it hides once linked and syncing, since Sync & backup says the rest. Buttons reach the Link dialog and other tabs through a small `ShellContext` (app/src/shell.ts) instead of props through every screen. Bring over's "link first" line gets a Link a machine button, Set up's intro and tour step 5 say to link first. | The ways existed but were spread over four places with nothing saying which comes first or that linking is required; one line per screen keeps it compact (decision #68) while answering "how do my scripts get there?" where people look. | Accepted | +| 123 | 2026-11-06 | **The Primary machine never offers to bring over from itself** (Joshua: his Windows PC, the Primary Rig, showed "Bring over from the Primary Rig" for its Reaper key map and menus). Two causes, both fixed. (1) "Add" on a suggestion from another machine saved that machine as the new version's source, so one added plug-in made it the Primary machine. `add_to_golden` now keeps the previous version's source, and `Store::golden`/`golden_history` read an older "Added ... from ..." version (also imported ones) as its parent's Primary machine, so existing rigs are right again without a migration. Only a whole save (Make Primary, Update Primary Rig from this machine) moves it. (2) On the Primary machine itself, Bring over listed every file whose copy here differs from the Primary Rig's, including ones edited here (or placed by Keep Files in Sync) after the version was saved, so bringing them over would undo the newer change. `FileToBring.newer_here` says this machine's copy is newer (or a time is unknown), `bring::split_changed_here` sets those apart on the Primary machine (Bring over and Compare's Bring over lines no longer offer them), and a "Changed here since Primary Rig v" card with **Update Primary Rig** adds this machine's copies to a new version (`keep_changes_in_golden`, through `add_to_golden`, so plug-ins the Primary Rig has from elsewhere stay). A file only the Primary Rig has, or whose Primary Rig copy is newer (added from another machine), is still brought over. Compare's machine list puts "Primary" first so the narrow line doesn't cut it off. | The Primary Rig is the reference: on its own machine, a difference means the rig moved on here, so the fix is to record it, not to roll the machine back. Reading the old notes avoids a schema change and corrects machines that already synced the wrong source. | Accepted |