From 013ba519b9fb69c4a35a9e24003175f5e1333195 Mon Sep 17 00:00:00 2001 From: scottmcmaster <3137688+scottmcmaster@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:54:04 +0000 Subject: [PATCH 1/2] feat: add `homebrew_add_casks` managed-edit command and wire up direct tracking in the filesystem UI (#393) ## Summary When you choose to "track" untracked Homebrew items either individually or as a section, use direct managed edits instead of the agent, which is 1000x faster and more reliable. I should note that it _also_ routes you through the `.nixmac` directory path which the agent generally won't do. This seems like a possible area for a broader discussion @fkb032 @czxtm To enable this, I did some fairly minor refactoring of the existing Homebrew code. So extending this high-level approach to "defaults" and "startup items" will be more work as we'll need to add analogous helper code for it (where it already existed for brew). ## Test Plan Couple of new unit tests, plus manual testing. - [ ] No test plan needed ## Docs - [ ] Docs updated (companion PR in darkmatter/nixmac-web: #\___) - [x] No docs update needed --- .../src-tauri/examples/specta_gen_ts.rs | 1 + .../native/src-tauri/src/commands/homebrew.rs | 10 + apps/native/src-tauri/src/main.rs | 1 + .../src/managed_edits/homebrew_adopt.rs | 229 ++++++++++++++++-- apps/native/src-tauri/src/shared_types.rs | 3 + .../src/shared_types/managed_edits.rs | 9 + .../src/components/widget/filesystem/data.ts | 23 +- .../widget/filesystem/file-list.tsx | 16 +- .../widget/filesystem/filesystem-step.tsx | 28 ++- .../widget/filesystem/untracked-card.tsx | 39 ++- .../__snapshots__/setup-step.stories.tsx.snap | 3 + apps/native/src/ipc/api.ts | 3 + apps/native/src/ipc/types.ts | 2 + 13 files changed, 321 insertions(+), 46 deletions(-) create mode 100644 apps/native/src-tauri/src/shared_types/managed_edits.rs create mode 100644 apps/native/src/components/widget/steps/__snapshots__/setup-step.stories.tsx.snap diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index b03ce286d..59bef72c3 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -59,6 +59,7 @@ fn main() { .register::() .register::() .register::() + .register::() .register::() .register::() .register::() diff --git a/apps/native/src-tauri/src/commands/homebrew.rs b/apps/native/src-tauri/src/commands/homebrew.rs index e68dfaf07..675adbe76 100644 --- a/apps/native/src-tauri/src/commands/homebrew.rs +++ b/apps/native/src-tauri/src/commands/homebrew.rs @@ -24,3 +24,13 @@ pub async fn homebrew_get_state_diff( managed_edits::homebrew_adopt::get_homebrew_state_diff(Path::new(&dir)) .map_err(|e| capture_err("homebrew_get_state_diff", e)) } + +#[tauri::command] +pub async fn homebrew_add_casks( + app: AppHandle, + casks: Vec, +) -> Result { + crate::managed_edits::homebrew_adopt::add_homebrew_casks(&app, casks) + .await + .map_err(|e| capture_err("homebrew_add_casks", e)) +} diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 595b73273..82016651e 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -488,6 +488,7 @@ fn run_gui_mode( #[cfg(debug_assertions)] commands::debug::e2e_mark_boot_stage, // Homebrew + commands::homebrew::homebrew_add_casks, commands::homebrew::homebrew_apply_diff, commands::homebrew::homebrew_get_state_diff, // Git diff --git a/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs b/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs index c72edc46f..332061736 100644 --- a/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs +++ b/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs @@ -7,6 +7,7 @@ use crate::system::scanner::inject_module_import; use crate::{managed_edits::managed_edit, shared_types}; use anyhow::{Context, Result}; use serde_json::{Map, Value}; +use shared_types::HomebrewCaskItem; use tauri::AppHandle; const NIXMAC_HOMEBREW_DATA_PATH: &str = ".nixmac/homebrew/data.json"; @@ -371,7 +372,12 @@ fn sanitize_homebrew_diff( } } -fn ensure_nixmac_homebrew_module(config_dir: &std::path::Path) -> Result<()> { +/// Ensures the managed Nixmac Homebrew module exists and returns the path to +/// its data file. +/// +/// The returned JSON file is the canonical place for managed Homebrew taps, +/// brews, and casks used by the `.nixmac/homebrew` module. +pub fn ensure_nixmac_homebrew_module(config_dir: &std::path::Path) -> Result { let nixmac_dir = config_dir.join(".nixmac"); let module_dir = nixmac_dir.join("homebrew"); std::fs::create_dir_all(&module_dir) @@ -413,20 +419,119 @@ fn ensure_nixmac_homebrew_module(config_dir: &std::path::Path) -> Result<()> { .with_context(|| format!("failed to write '{}'", module_data.display()))?; } + Ok(module_data) +} + +fn ensure_nixmac_module_import(config_dir: &std::path::Path) -> Result<()> { + let flake_path = config_dir.join("flake.nix"); + if !flake_path.exists() { + return Err(anyhow::anyhow!( + "cannot enable Nixmac Homebrew module because '{}' does not exist", + flake_path.display() + )); + } + + let flake_content = std::fs::read_to_string(&flake_path) + .with_context(|| format!("failed to read flake file '{}'", flake_path.display()))?; + + let updated = inject_module_import(&flake_content, "./.nixmac") + .map_err(anyhow::Error::msg) + .with_context(|| { + format!( + "failed to inject Nixmac module import into '{}'", + flake_path.display() + ) + })?; + + if updated != flake_content { + std::fs::write(&flake_path, updated) + .with_context(|| format!("failed to write flake file '{}'", flake_path.display()))?; + } + Ok(()) } +fn add_homebrew_casks_to_config( + config_dir: &std::path::Path, + casks: &[HomebrewCaskItem], +) -> Result { + if casks.is_empty() { + return Err(anyhow::anyhow!("at least one Homebrew cask is required")); + } + + let cask_names = casks + .iter() + .map(|cask| cask.name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>(); + if cask_names.is_empty() { + return Err(anyhow::anyhow!( + "at least one named Homebrew cask is required" + )); + } + + ensure_nixmac_module_import(config_dir)?; + let data_path = ensure_nixmac_homebrew_module(config_dir)?; + let mut data = read_homebrew_data(&data_path)?; + merge_json_array(&mut data, "casks", &cask_names)?; + + let rendered = serde_json::to_string_pretty(&data)?; + std::fs::write(&data_path, format!("{}\n", rendered)) + .with_context(|| format!("failed to write '{}'", data_path.display()))?; + + Ok(data_path) +} + +/// Adds one or more Homebrew casks to the managed Nixmac Homebrew data file, +/// snapshots the pre-edit tree onto a rollback branch, and enters the managed +/// review flow. +pub async fn add_homebrew_casks( + app: &AppHandle, + casks: Vec, +) -> Result { + let item_count = casks + .iter() + .filter(|cask| !cask.name.trim().is_empty()) + .count(); + if casks.is_empty() { + return Err(anyhow::anyhow!("at least one Homebrew cask is required")); + } + if item_count == 0 { + return Err(anyhow::anyhow!( + "at least one named Homebrew cask is required" + )); + } + + let context = managed_edit::prepare_managed_edit(app)?; + let dir = context.dir.clone(); + + add_homebrew_casks_to_config(std::path::Path::new(&dir), &casks) + .context("Failed to add Homebrew casks")?; + + let working_tree_status = + crate::git::status(&dir).context("Failed to get working tree status for evolve state")?; + managed_edit::finalize_managed_edit( + app, + context, + working_tree_status, + item_count, + "add_homebrew_casks", + ) + .await +} + fn apply_homebrew_data_import( diff: &HomebrewState, config_dir: &std::path::Path, source_rel: &str, ) -> Result<()> { - if source_rel == NIXMAC_HOMEBREW_DATA_PATH { - ensure_nixmac_homebrew_module(config_dir)?; - } - - let source = resolve_path_in_dir_allow_create(config_dir, source_rel) - .with_context(|| format!("invalid homebrew source path '{}'", source_rel))?; + let source = if source_rel == NIXMAC_HOMEBREW_DATA_PATH { + ensure_nixmac_homebrew_module(config_dir)? + } else { + resolve_path_in_dir_allow_create(config_dir, source_rel) + .with_context(|| format!("invalid homebrew source path '{}'", source_rel))? + }; if let Some(parent) = source.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create directory '{}'", parent.display()))?; @@ -447,7 +552,6 @@ fn apply_homebrew_data_import( /// Writes missing items in the diff to the config, using the source field to determine where to write. /// If the source is empty, we'll set up the official .nixmac/homebrew module and write data.json. /// We also hook up .nixmac to flake.nix in that case. -#[allow(dead_code)] pub fn apply_homebrew_import(diff: HomebrewState, config_dir: &std::path::Path) -> Result<()> { if diff.casks.is_empty() && diff.brews.is_empty() && diff.taps.is_empty() { return Ok(()); @@ -469,24 +573,7 @@ pub fn apply_homebrew_import(diff: HomebrewState, config_dir: &std::path::Path) apply_homebrew_data_import(&diff, config_dir, &source_rel)?; if diff.source.is_none() { - let flake_path = config_dir.join("flake.nix"); - let flake_content = std::fs::read_to_string(&flake_path) - .with_context(|| format!("failed to read flake file '{}'", flake_path.display()))?; - - let updated = inject_module_import(&flake_content, "./.nixmac") - .map_err(anyhow::Error::msg) - .with_context(|| { - format!( - "failed to inject Nixmac module import into '{}'", - flake_path.display() - ) - })?; - - if updated != flake_content { - std::fs::write(&flake_path, updated).with_context(|| { - format!("failed to write flake file '{}'", flake_path.display()) - })?; - } + ensure_nixmac_module_import(config_dir)?; } return Ok(()); @@ -879,6 +966,96 @@ mod tests { assert_eq!(homebrew_item_count(&sanitized), 0); } + #[test] + fn ensure_nixmac_homebrew_module_returns_data_file_path() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + + let data_file = + ensure_nixmac_homebrew_module(temp.path()).expect("homebrew module should be created"); + + assert_eq!(data_file, temp.path().join(NIXMAC_HOMEBREW_DATA_PATH)); + assert!(temp.path().join(".nixmac/default.nix").exists()); + assert!(temp.path().join(".nixmac/homebrew/default.nix").exists()); + assert!(temp.path().join(".nixmac/homebrew/meta.json").exists()); + assert!(data_file.exists()); + } + + #[test] + fn add_homebrew_casks_writes_managed_data_and_injects_flake_import() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let flake = temp.path().join("flake.nix"); + write_file( + &flake, + r#"{ + outputs = { self }: { + darwinConfigurations.host = { + modules = [ + ./modules/darwin/system.nix + ]; + }; + }; +} +"#, + ); + write_file( + &temp.path().join(NIXMAC_HOMEBREW_DATA_PATH), + r#"{ + "taps": [], + "brews": [], + "casks": ["docker"] +} +"#, + ); + + let data_file = add_homebrew_casks_to_config( + temp.path(), + &[ + HomebrewCaskItem { + name: "docker".to_string(), + version: Some("4.32.0".to_string()), + }, + HomebrewCaskItem { + name: " obs ".to_string(), + version: Some("30.2.3".to_string()), + }, + ], + ) + .expect("casks should be added"); + + assert_eq!(data_file, temp.path().join(NIXMAC_HOMEBREW_DATA_PATH)); + + let data: Value = serde_json::from_str( + &std::fs::read_to_string(data_file).expect("homebrew data should exist"), + ) + .expect("homebrew data should parse"); + assert_eq!(json_string_array(&data, "casks"), vec!["docker", "obs"]); + assert_eq!(json_string_array(&data, "brews"), Vec::::new()); + assert_eq!(json_string_array(&data, "taps"), Vec::::new()); + + let flake_content = std::fs::read_to_string(flake).expect("flake should remain readable"); + assert!(flake_content.contains("./.nixmac")); + } + + #[test] + fn add_homebrew_casks_requires_flake_before_writing_module() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + + let err = add_homebrew_casks_to_config( + temp.path(), + &[HomebrewCaskItem { + name: "iterm2".to_string(), + version: None, + }], + ) + .expect_err("managed Homebrew casks should require flake.nix"); + + assert!(err.to_string().contains("flake.nix")); + assert!( + !temp.path().join(".nixmac").exists(), + ".nixmac should not be written when flake.nix is missing" + ); + } + #[test] fn sanitized_default_source_still_creates_nixmac_module_and_injects_flake_import() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/apps/native/src-tauri/src/shared_types.rs b/apps/native/src-tauri/src/shared_types.rs index 4f1daedee..fab61225d 100644 --- a/apps/native/src-tauri/src/shared_types.rs +++ b/apps/native/src-tauri/src/shared_types.rs @@ -12,6 +12,8 @@ mod evolve; mod feedback; #[path = "shared_types/git.rs"] mod git; +#[path = "shared_types/managed_edits.rs"] +mod managed_edits; #[path = "shared_types/prefs.rs"] mod prefs; #[path = "shared_types/settings_io.rs"] @@ -25,6 +27,7 @@ pub use events::*; pub use evolve::*; pub use feedback::*; pub use git::*; +pub use managed_edits::*; pub use prefs::*; pub use settings_io::*; pub use system::*; diff --git a/apps/native/src-tauri/src/shared_types/managed_edits.rs b/apps/native/src-tauri/src/shared_types/managed_edits.rs new file mode 100644 index 000000000..42d445efe --- /dev/null +++ b/apps/native/src-tauri/src/shared_types/managed_edits.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HomebrewCaskItem { + pub name: String, + pub version: Option, +} diff --git a/apps/native/src/components/widget/filesystem/data.ts b/apps/native/src/components/widget/filesystem/data.ts index 7c9355439..c63016d49 100644 --- a/apps/native/src/components/widget/filesystem/data.ts +++ b/apps/native/src/components/widget/filesystem/data.ts @@ -6,6 +6,7 @@ export type CandidateItem = { detail: string; installedAt: string; attr: string; + version?: string; }; export type FsFile = { @@ -280,17 +281,17 @@ creation_rules: scanCommand: "brew list --cask", scannedAt: "scanned 14 min ago", items: [ - { name: "docker", detail: "Docker Desktop · 4.32.0", installedAt: "Mar 12", attr: 'homebrew.casks = [ "docker" ];' }, - { name: "obs", detail: "OBS Studio · 30.2.3", installedAt: "Feb 28", attr: 'homebrew.casks = [ "obs" ];' }, - { name: "iterm2", detail: "iTerm2 · 3.5.1", installedAt: "Jan 09", attr: 'homebrew.casks = [ "iterm2" ];' }, - { name: "vlc", detail: "VLC media player · 3.0.20", installedAt: "Jan 02", attr: 'homebrew.casks = [ "vlc" ];' }, - { name: "figma", detail: "Figma · 124.4.0", installedAt: "2025-12-18", attr: 'homebrew.casks = [ "figma" ];' }, - { name: "spotify", detail: "Spotify · 1.2.45", installedAt: "2025-11-30", attr: 'homebrew.casks = [ "spotify" ];' }, - { name: "slack", detail: "Slack · 4.40.0", installedAt: "2025-11-21", attr: 'homebrew.casks = [ "slack" ];' }, - { name: "zoom", detail: "Zoom · 6.1.10", installedAt: "2025-11-15", attr: 'homebrew.casks = [ "zoom" ];' }, - { name: "discord", detail: "Discord · 0.0.310", installedAt: "2025-10-04", attr: 'homebrew.casks = [ "discord" ];' }, - { name: "notion", detail: "Notion · 4.1.0", installedAt: "2025-09-22", attr: 'homebrew.casks = [ "notion" ];' }, - { name: "audacity", detail: "Audacity · 3.6.4", installedAt: "2025-08-11", attr: 'homebrew.casks = [ "audacity" ];' }, + { name: "docker", detail: "Docker Desktop · 4.32.0", installedAt: "Mar 12", attr: 'homebrew.casks = [ "docker" ];', version: "4.32.0" }, + { name: "obs", detail: "OBS Studio · 30.2.3", installedAt: "Feb 28", attr: 'homebrew.casks = [ "obs" ];', version: "30.2.3" }, + { name: "iterm2", detail: "iTerm2 · 3.5.1", installedAt: "Jan 09", attr: 'homebrew.casks = [ "iterm2" ];', version: "3.5.1" }, + { name: "vlc", detail: "VLC media player · 3.0.20", installedAt: "Jan 02", attr: 'homebrew.casks = [ "vlc" ];', version: "3.0.20" }, + { name: "figma", detail: "Figma · 124.4.0", installedAt: "2025-12-18", attr: 'homebrew.casks = [ "figma" ];', version: "124.4.0" }, + { name: "spotify", detail: "Spotify · 1.2.45", installedAt: "2025-11-30", attr: 'homebrew.casks = [ "spotify" ];', version: "1.2.45" }, + { name: "slack", detail: "Slack · 4.40.0", installedAt: "2025-11-21", attr: 'homebrew.casks = [ "slack" ];', version: "4.40.0" }, + { name: "zoom", detail: "Zoom · 6.1.10", installedAt: "2025-11-15", attr: 'homebrew.casks = [ "zoom" ];', version: "6.1.10" }, + { name: "discord", detail: "Discord · 0.0.310", installedAt: "2025-10-04", attr: 'homebrew.casks = [ "discord" ];', version: "0.0.310" }, + { name: "notion", detail: "Notion · 4.1.0", installedAt: "2025-09-22", attr: 'homebrew.casks = [ "notion" ];', version: "4.1.0" }, + { name: "audacity", detail: "Audacity · 3.6.4", installedAt: "2025-08-11", attr: 'homebrew.casks = [ "audacity" ];', version: "3.6.4" }, ], }, { diff --git a/apps/native/src/components/widget/filesystem/file-list.tsx b/apps/native/src/components/widget/filesystem/file-list.tsx index 74a36ad3c..1cc6fac9f 100644 --- a/apps/native/src/components/widget/filesystem/file-list.tsx +++ b/apps/native/src/components/widget/filesystem/file-list.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Search } from "lucide-react"; -import type { FsFile } from "./data"; +import type { CandidateItem, FsFile } from "./data"; import { FileRow } from "./file-row"; import { UntrackedCard } from "./untracked-card"; @@ -11,9 +11,15 @@ interface FileListProps { onEditWithPrompt: (file: FsFile) => void; /** Untracked sections route through this — caller seeds the prompt with a tracking task. */ onTrack: (seed: string) => void; + onTrackHomebrewCasks?: (items: CandidateItem[]) => Promise | void; } -export function FileList({ files, onEditWithPrompt, onTrack }: FileListProps) { +export function FileList({ + files, + onEditWithPrompt, + onTrack, + onTrackHomebrewCasks, +}: FileListProps) { const [query, setQuery] = useState(""); const q = query.trim().toLowerCase(); @@ -50,7 +56,11 @@ export function FileList({ files, onEditWithPrompt, onTrack }: FileListProps) { {filtered.map((f) => f.status === "candidate" ? (
- +
) : ( diff --git a/apps/native/src/components/widget/filesystem/filesystem-step.tsx b/apps/native/src/components/widget/filesystem/filesystem-step.tsx index 93896cc1a..f9aa63a6a 100644 --- a/apps/native/src/components/widget/filesystem/filesystem-step.tsx +++ b/apps/native/src/components/widget/filesystem/filesystem-step.tsx @@ -2,9 +2,13 @@ import { useEffect, useState } from "react"; +import { tauriAPI } from "@/ipc/api"; import { useWidgetStore } from "@/stores/widget-store"; +import { mirrorChangeMapState } from "@/viewmodel/change-map"; +import { mirrorEvolveState } from "@/viewmodel/evolve"; +import { mirrorGitState } from "@/viewmodel/git"; -import { FILES, SECTIONS, type FsFile, type SectionId } from "./data"; +import { FILES, SECTIONS, type CandidateItem, type FsFile, type SectionId } from "./data"; import { FileList } from "./file-list"; import { SectionTabs } from "./section-tabs"; import { seedForFile } from "./seed-prompt"; @@ -57,6 +61,27 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { const onEditWithPrompt = (file: FsFile) => seed(seedForFile(file)); const onTrack = (text: string) => seed(text); + // Add future direct managed-edit trackers here (for example, system defaults) + // and pass them down alongside the fallback prompt seeding handler. + const onTrackHomebrewCasks = async (items: CandidateItem[]) => { + const store = useWidgetStore.getState(); + store.setProcessing(true, "apply"); + try { + const result = await tauriAPI.homebrew.addCasks( + items.map((item) => ({ + name: item.name, + version: item.version ?? null, + })), + ); + mirrorEvolveState(result.evolveState); + mirrorChangeMapState(result.changeMap); + mirrorGitState(result.gitStatus); + store.setRecommendedPrompt(undefined); + setShowFilesystem(false); + } finally { + store.setProcessing(false); + } + }; return (
@@ -66,6 +91,7 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { files={files} onEditWithPrompt={onEditWithPrompt} onTrack={onTrack} + onTrackHomebrewCasks={onTrackHomebrewCasks} />
Use these as starting points — every change goes through the standard plan → review → save flow. diff --git a/apps/native/src/components/widget/filesystem/untracked-card.tsx b/apps/native/src/components/widget/filesystem/untracked-card.tsx index da9da502f..4917d6955 100644 --- a/apps/native/src/components/widget/filesystem/untracked-card.tsx +++ b/apps/native/src/components/widget/filesystem/untracked-card.tsx @@ -16,6 +16,7 @@ interface UntrackedCardProps { * caller seeds the prompt and closes the Filesystem view. */ onTrack: (seed: string) => void; + onTrackHomebrewCasks?: (items: CandidateItem[]) => Promise | void; } const CARD_TONE_CLASSES: Record< @@ -54,11 +55,33 @@ const CARD_TONE_CLASSES: Record< }, }; -export function UntrackedCard({ file, onTrack }: UntrackedCardProps) { +export function UntrackedCard({ file, onTrack, onTrackHomebrewCasks }: UntrackedCardProps) { const items = useMemo(() => file.items ?? [], [file.items]); const [showSource, setShowSource] = useState(false); + const [trackingKey, setTrackingKey] = useState(null); + const [trackError, setTrackError] = useState(null); const tone = CARD_TONE_CLASSES[file.tone]; const Icon = resolveIcon(file.iconName); + // Managed-edit routes are section-scoped: Homebrew casks use the direct + // managed edit path today, while other untracked sections still seed prompts. + const canTrackHomebrew = file.id === "untracked-brew" && !!onTrackHomebrewCasks; + + const trackItems = async (selectedItems: CandidateItem[], key: string, seed: string) => { + if (!canTrackHomebrew || !onTrackHomebrewCasks) { + onTrack(seed); + return; + } + + setTrackingKey(key); + setTrackError(null); + try { + await onTrackHomebrewCasks(selectedItems); + } catch (error: unknown) { + setTrackError(String(error)); + } finally { + setTrackingKey(null); + } + }; if (file.status !== "candidate") return null; @@ -91,10 +114,12 @@ export function UntrackedCard({ file, onTrack }: UntrackedCardProps) {
+ {trackError && ( +
{trackError}
+ )}
@@ -135,10 +163,11 @@ export function UntrackedCard({ file, onTrack }: UntrackedCardProps) { size="sm" variant="ghost" className="h-6 px-2 text-[10.5px] text-teal-300 hover:bg-teal-500/10 hover:text-teal-200" - onClick={() => onTrack(seedForUntrackedItem(file, it))} + disabled={trackingKey !== null} + onClick={() => trackItems([it], it.name, seedForUntrackedItem(file, it))} data-testid={`track-item-${file.id}-${it.name}`} > - Track + {trackingKey === it.name ? "Tracking..." : "Track"} ))} diff --git a/apps/native/src/components/widget/steps/__snapshots__/setup-step.stories.tsx.snap b/apps/native/src/components/widget/steps/__snapshots__/setup-step.stories.tsx.snap new file mode 100644 index 000000000..e7fdda23e --- /dev/null +++ b/apps/native/src/components/widget/steps/__snapshots__/setup-step.stories.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Default Config Required 1`] = `"

Welcome to nixmac

Let's set up your nix-darwin configuration

Creates an empty folder in your home directory, then nixmac can generate a default flake.

Select your own, or proceed below for defaults


No nix-darwin configuration found in this directory

This will be your darwinConfiguration name

This will create a basic nix-darwin flake in the directory

"`; diff --git a/apps/native/src/ipc/api.ts b/apps/native/src/ipc/api.ts index 0430c2a8e..a9621eeb0 100644 --- a/apps/native/src/ipc/api.ts +++ b/apps/native/src/ipc/api.ts @@ -22,6 +22,7 @@ import type { FileDiffContents, FinalizeApplyResult, GitStatus, + HomebrewCaskItem, HomebrewState, HistoryItem, ImportResult, @@ -233,6 +234,8 @@ export const tauriAPI = { homebrew: { getStateDiff: () => invoke("homebrew_get_state_diff"), applyDiff: (diff: HomebrewState) => invoke("homebrew_apply_diff", { diff }), + addCasks: (casks: HomebrewCaskItem[]) => + invoke("homebrew_add_casks", { casks }), }, updater: { diff --git a/apps/native/src/ipc/types.ts b/apps/native/src/ipc/types.ts index 5adbfe198..90274b4bc 100644 --- a/apps/native/src/ipc/types.ts +++ b/apps/native/src/ipc/types.ts @@ -1053,6 +1053,8 @@ isOrphanedRestore: boolean; */ isUndone: boolean } +export type HomebrewCaskItem = { name: string; version: string | null } + /** * Current Homebrew package state detected on the machine. */ From c523a07b3d665811dd8f3e25da2f32efced51121 Mon Sep 17 00:00:00 2001 From: scottmcmaster <3137688+scottmcmaster@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:54:05 +0000 Subject: [PATCH 2/2] feat: use real homebrew untracked diff in filesystem view (#403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Start swapping out the fake data on the "Untracked" tab of file system view by integrating the same Homebrew diff that the pre-existing chip uses. This involves several changes and refactorings: - Add separate sections for brews/casks/taps simiar to how the chip popup menu works. - Add expanders since in practice some of these sections can get really long (see screenshot). - Fix pre-existing unrelated bug with the brew list command and add logging so it's not totally silent in the future. - Make the "untracked" banner on the begin step delay-loaded and dynamic with the correct count. - Remove the "Track all" button since it never worked right and would work even less right with potentially hundreds of items. We can look into bringing it back once the untracked support is "done". Also see screenshot. The data for "defaults" and "startup items" is still the fake data. I will work on defaults next. Note that there is a lot of demo/AI cruft remaining particularly in data.ts that will continue to disappear the closer we get to the goal. Another potential follow-up item is some caching or limitations on how often we automatically scan, although empirically right now it's not unreasonably slow imo. ![Screenshot 2026-06-12 at 11.25.48 AM.png](https://app.graphite.com/user-attachments/assets/635f022e-e918-4f81-9177-c300c7e34489.png) ![Screenshot 2026-06-12 at 10.45.38 AM.png](https://app.graphite.com/user-attachments/assets/dc8b351c-6bd1-4833-a402-3a3b442eb84f.png) ## Test Plan Some minor updates to unit tests, plus lots of manual testing. Also needed to update a bunch of snaps, hopefully this doesn't run into merge issues etc. - [ ] No test plan needed ## Docs - [ ] Docs updated (companion PR in darkmatter/nixmac-web: #\___) - [x] No docs update needed --- .../src-tauri/examples/specta_gen_ts.rs | 3 +- .../native/src-tauri/src/commands/homebrew.rs | 8 +- apps/native/src-tauri/src/main.rs | 2 +- .../src/managed_edits/homebrew_adopt.rs | 126 +++++++--- .../src/shared_types/managed_edits.rs | 11 +- .../evolve-flow.stories.tsx.snap | 10 +- .../__snapshots__/widget.stories.tsx.snap | 38 +-- .../__snapshots__/file-list.stories.tsx.snap | 2 +- .../filesystem-step.stories.tsx.snap | 6 +- .../section-tabs.stories.tsx.snap | 4 +- .../seed-prompt.stories.tsx.snap | 55 ++--- .../untracked-banner.stories.tsx.snap | 6 +- .../untracked-card.stories.tsx.snap | 10 +- .../components/widget/filesystem/data.test.ts | 58 +++++ .../src/components/widget/filesystem/data.ts | 225 ++++++++++++++++-- .../widget/filesystem/file-list.stories.tsx | 14 +- .../widget/filesystem/file-list.tsx | 6 +- .../widget/filesystem/filesystem-step.tsx | 73 +++++- .../widget/filesystem/section-tabs.tsx | 9 +- .../widget/filesystem/seed-prompt.stories.tsx | 18 +- .../filesystem/untracked-banner.stories.tsx | 23 +- .../widget/filesystem/untracked-banner.tsx | 28 +-- .../filesystem/untracked-card.stories.tsx | 33 ++- .../widget/filesystem/untracked-card.tsx | 182 ++++++++------ .../components/widget/steps/begin-step.tsx | 18 +- apps/native/src/ipc/api.ts | 6 +- apps/native/src/ipc/types.ts | 4 +- 27 files changed, 709 insertions(+), 269 deletions(-) create mode 100644 apps/native/src/components/widget/filesystem/data.test.ts diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index 59bef72c3..d57185259 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -59,7 +59,8 @@ fn main() { .register::() .register::() .register::() - .register::() + .register::() + .register::() .register::() .register::() .register::() diff --git a/apps/native/src-tauri/src/commands/homebrew.rs b/apps/native/src-tauri/src/commands/homebrew.rs index 675adbe76..07d8dad70 100644 --- a/apps/native/src-tauri/src/commands/homebrew.rs +++ b/apps/native/src-tauri/src/commands/homebrew.rs @@ -26,11 +26,11 @@ pub async fn homebrew_get_state_diff( } #[tauri::command] -pub async fn homebrew_add_casks( +pub async fn homebrew_add_items( app: AppHandle, - casks: Vec, + items: Vec, ) -> Result { - crate::managed_edits::homebrew_adopt::add_homebrew_casks(&app, casks) + crate::managed_edits::homebrew_adopt::add_homebrew_items(&app, items) .await - .map_err(|e| capture_err("homebrew_add_casks", e)) + .map_err(|e| capture_err("homebrew_add_items", e)) } diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 82016651e..63499eb90 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -488,7 +488,7 @@ fn run_gui_mode( #[cfg(debug_assertions)] commands::debug::e2e_mark_boot_stage, // Homebrew - commands::homebrew::homebrew_add_casks, + commands::homebrew::homebrew_add_items, commands::homebrew::homebrew_apply_diff, commands::homebrew::homebrew_get_state_diff, // Git diff --git a/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs b/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs index 332061736..5c751a1ed 100644 --- a/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs +++ b/apps/native/src-tauri/src/managed_edits/homebrew_adopt.rs @@ -1,13 +1,13 @@ use crate::evolve::file_ops::resolve_path_in_dir_allow_create; use crate::evolve::nix_file_editor::{apply_semantic_edit, nix_quote_values}; use crate::evolve::types::{FileEditAction, SemanticFileEdit}; -use crate::shared_types::HomebrewState; +use crate::shared_types::{HomebrewItemType, HomebrewState}; use crate::system::nix_ast_lists::parse_string_lists_by_attrpath; use crate::system::scanner::inject_module_import; use crate::{managed_edits::managed_edit, shared_types}; use anyhow::{Context, Result}; use serde_json::{Map, Value}; -use shared_types::HomebrewCaskItem; +use shared_types::HomebrewItem; use tauri::AppHandle; const NIXMAC_HOMEBREW_DATA_PATH: &str = ".nixmac/homebrew/data.json"; @@ -202,7 +202,9 @@ pub async fn apply_homebrew_diff( } /// Scan the system for Homebrew packages, casks, and taps. -/// Only includes explicitly installed brews and casks (via `--installed-on-request`), not their dependencies. +/// Only includes explicitly installed brews and casks not their dependencies. +/// Note that generally speaking, `brew list --casks` does *not* count dependences, and +/// the `--installed-on-request` flag is not valid for casks. /// Excludes default taps (homebrew/core, homebrew/cask). /// If homebrew is not installed, returns an empty state with is_installed set to false. pub fn scan_homebrew() -> HomebrewState { @@ -221,10 +223,12 @@ pub fn scan_homebrew() -> HomebrewState { .lines() .map(str::to_owned) .collect(); + } else { + log::warn!("failed to scan Homebrew brews: brew command returned non-zero exit code"); } } if let Ok(output) = std::process::Command::new("brew") - .args(["list", "--installed-on-request", "--cask"]) + .args(["list", "--cask"]) .env("PATH", crate::system::nix::get_nix_path()) .output() { @@ -233,6 +237,8 @@ pub fn scan_homebrew() -> HomebrewState { .lines() .map(str::to_owned) .collect(); + } else { + log::warn!("failed to scan Homebrew casks: brew command returned non-zero exit code"); } } if let Ok(output) = std::process::Command::new("brew") @@ -249,6 +255,8 @@ pub fn scan_homebrew() -> HomebrewState { }) .map(str::to_owned) .collect(); + } else { + log::warn!("failed to scan Homebrew taps: brew command returned non-zero exit code"); } } @@ -451,30 +459,63 @@ fn ensure_nixmac_module_import(config_dir: &std::path::Path) -> Result<()> { Ok(()) } -fn add_homebrew_casks_to_config( +fn add_homebrew_items_to_config( config_dir: &std::path::Path, - casks: &[HomebrewCaskItem], + items: &[HomebrewItem], ) -> Result { - if casks.is_empty() { - return Err(anyhow::anyhow!("at least one Homebrew cask is required")); + if items.is_empty() { + return Err(anyhow::anyhow!("at least one Homebrew item is required")); } - let cask_names = casks + let item_names = items .iter() - .map(|cask| cask.name.trim()) + .map(|item| item.name.trim()) .filter(|name| !name.is_empty()) .map(str::to_string) .collect::>(); - if cask_names.is_empty() { + if item_names.is_empty() { return Err(anyhow::anyhow!( - "at least one named Homebrew cask is required" + "at least one named Homebrew item is required" )); } ensure_nixmac_module_import(config_dir)?; let data_path = ensure_nixmac_homebrew_module(config_dir)?; let mut data = read_homebrew_data(&data_path)?; - merge_json_array(&mut data, "casks", &cask_names)?; + + // Separate out the casks/brews/formulae so we can do 0..3 merge_json_array calls for each. + let casks = items + .iter() + .filter(|item| item.item_type == HomebrewItemType::Cask) + .map(|item| item.name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>(); + if !casks.is_empty() { + merge_json_array(&mut data, "casks", &casks)?; + } + + let brews = items + .iter() + .filter(|item| item.item_type == HomebrewItemType::Brew) + .map(|item| item.name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>(); + if !brews.is_empty() { + merge_json_array(&mut data, "brews", &brews)?; + } + + let taps = items + .iter() + .filter(|item| item.item_type == HomebrewItemType::Tap) + .map(|item| item.name.trim()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect::>(); + if !taps.is_empty() { + merge_json_array(&mut data, "taps", &taps)?; + } let rendered = serde_json::to_string_pretty(&data)?; std::fs::write(&data_path, format!("{}\n", rendered)) @@ -483,31 +524,31 @@ fn add_homebrew_casks_to_config( Ok(data_path) } -/// Adds one or more Homebrew casks to the managed Nixmac Homebrew data file, +/// Adds one or more Homebrew items to the managed Nixmac Homebrew data file, /// snapshots the pre-edit tree onto a rollback branch, and enters the managed /// review flow. -pub async fn add_homebrew_casks( +pub async fn add_homebrew_items( app: &AppHandle, - casks: Vec, + items: Vec, ) -> Result { - let item_count = casks + let item_count = items .iter() - .filter(|cask| !cask.name.trim().is_empty()) + .filter(|item| !item.name.trim().is_empty()) .count(); - if casks.is_empty() { - return Err(anyhow::anyhow!("at least one Homebrew cask is required")); + if items.is_empty() { + return Err(anyhow::anyhow!("at least one Homebrew item is required")); } if item_count == 0 { return Err(anyhow::anyhow!( - "at least one named Homebrew cask is required" + "at least one named Homebrew item is required" )); } let context = managed_edit::prepare_managed_edit(app)?; let dir = context.dir.clone(); - add_homebrew_casks_to_config(std::path::Path::new(&dir), &casks) - .context("Failed to add Homebrew casks")?; + add_homebrew_items_to_config(std::path::Path::new(&dir), &items) + .context("Failed to add Homebrew items")?; let working_tree_status = crate::git::status(&dir).context("Failed to get working tree status for evolve state")?; @@ -516,7 +557,7 @@ pub async fn add_homebrew_casks( context, working_tree_status, item_count, - "add_homebrew_casks", + "add_homebrew_items", ) .await } @@ -665,6 +706,8 @@ pub fn apply_homebrew_import(diff: HomebrewState, config_dir: &std::path::Path) #[cfg(test)] mod tests { + use crate::shared_types::HomebrewItemType; + use super::*; fn homebrew_state( @@ -981,7 +1024,7 @@ mod tests { } #[test] - fn add_homebrew_casks_writes_managed_data_and_injects_flake_import() { + fn add_homebrew_items_writes_managed_data_and_injects_flake_import() { let temp = tempfile::tempdir().expect("tempdir should be created"); let flake = temp.path().join("flake.nix"); write_file( @@ -1007,16 +1050,29 @@ mod tests { "#, ); - let data_file = add_homebrew_casks_to_config( + // Make sure to test all three item types. + let data_file = add_homebrew_items_to_config( temp.path(), &[ - HomebrewCaskItem { + HomebrewItem { name: "docker".to_string(), version: Some("4.32.0".to_string()), + item_type: HomebrewItemType::Cask, }, - HomebrewCaskItem { + HomebrewItem { name: " obs ".to_string(), version: Some("30.2.3".to_string()), + item_type: HomebrewItemType::Cask, + }, + HomebrewItem { + name: "git".to_string(), + version: Some("2.42.0".to_string()), + item_type: HomebrewItemType::Brew, + }, + HomebrewItem { + name: "homebrew/cask-fonts".to_string(), + version: None, + item_type: HomebrewItemType::Tap, }, ], ) @@ -1029,22 +1085,26 @@ mod tests { ) .expect("homebrew data should parse"); assert_eq!(json_string_array(&data, "casks"), vec!["docker", "obs"]); - assert_eq!(json_string_array(&data, "brews"), Vec::::new()); - assert_eq!(json_string_array(&data, "taps"), Vec::::new()); + assert_eq!(json_string_array(&data, "brews"), vec!["git"]); + assert_eq!( + json_string_array(&data, "taps"), + vec!["homebrew/cask-fonts"] + ); let flake_content = std::fs::read_to_string(flake).expect("flake should remain readable"); assert!(flake_content.contains("./.nixmac")); } #[test] - fn add_homebrew_casks_requires_flake_before_writing_module() { + fn add_homebrew_items_requires_flake_before_writing_module() { let temp = tempfile::tempdir().expect("tempdir should be created"); - let err = add_homebrew_casks_to_config( + let err = add_homebrew_items_to_config( temp.path(), - &[HomebrewCaskItem { + &[HomebrewItem { name: "iterm2".to_string(), version: None, + item_type: HomebrewItemType::Cask, }], ) .expect_err("managed Homebrew casks should require flake.nix"); diff --git a/apps/native/src-tauri/src/shared_types/managed_edits.rs b/apps/native/src-tauri/src/shared_types/managed_edits.rs index 42d445efe..4cceed02c 100644 --- a/apps/native/src-tauri/src/shared_types/managed_edits.rs +++ b/apps/native/src-tauri/src/shared_types/managed_edits.rs @@ -3,7 +3,16 @@ use specta::Type; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, Type)] #[serde(rename_all = "camelCase")] -pub struct HomebrewCaskItem { +pub enum HomebrewItemType { + Tap, + Cask, + Brew, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HomebrewItem { pub name: String, pub version: Option, + pub item_type: HomebrewItemType, } diff --git a/apps/native/src/components/widget/__snapshots__/evolve-flow.stories.tsx.snap b/apps/native/src/components/widget/__snapshots__/evolve-flow.stories.tsx.snap index c04ad689a..3d17ee0fb 100644 --- a/apps/native/src/components/widget/__snapshots__/evolve-flow.stories.tsx.snap +++ b/apps/native/src/components/widget/__snapshots__/evolve-flow.stories.tsx.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`1. Begin (idle) 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Console
"`; +exports[`1. Begin (idle) 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No API key set (Evolution and Summary providers) .

Console
"`; -exports[`2. Evolving (progress) 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
7 events
Waiting for next event...
Console
"`; +exports[`2. Evolving (progress) 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
7 events
Waiting for next event...
Console
"`; -exports[`3. Review (changes generated) 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
10 events
Waiting for next event...
Console
"`; +exports[`3. Review (changes generated) 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
10 events
Waiting for next event...
Console
"`; -exports[`4. Merge (ready to commit) 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
10 events
Waiting for next event...
Console
"`; +exports[`4. Merge (ready to commit) 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
10 events
Waiting for next event...
Console
"`; -exports[`Full Flow (animated) 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
10 events
Waiting for next event...
Console
"`; +exports[`Full Flow (animated) 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
10 events
Waiting for next event...
Console
"`; diff --git a/apps/native/src/components/widget/__snapshots__/widget.stories.tsx.snap b/apps/native/src/components/widget/__snapshots__/widget.stories.tsx.snap index 3e81151db..22f3f2c64 100644 --- a/apps/native/src/components/widget/__snapshots__/widget.stories.tsx.snap +++ b/apps/native/src/components/widget/__snapshots__/widget.stories.tsx.snap @@ -1,39 +1,39 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Applying 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...

Applying changes

Updating your configuration and preparing the review step.

Console
"`; +exports[`Applying 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...

Applying changes

Updating your configuration and preparing the review step.

Console
"`; -exports[`Commit Screen 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; +exports[`Commit Screen 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; -exports[`Commit Screen With Message 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; +exports[`Commit Screen With Message 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; -exports[`Committing 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; +exports[`Committing 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; -exports[`Console With Output 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; +exports[`Console With Output 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; -exports[`Evolving 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; +exports[`Evolving 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; -exports[`Evolving Ready To Commit 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; +exports[`Evolving Ready To Commit 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; -exports[`Evolving With Unstaged Changes 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; +exports[`Evolving With Unstaged Changes 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; -exports[`Generating 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
6 events1,523 tokens
Waiting for next event...
Console
"`; +exports[`Generating 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
6 events1,523 tokens
Waiting for next event...
Console
"`; -exports[`Generating With Progress 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; +exports[`Generating With Progress 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; -exports[`Idle 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Console
"`; +exports[`Idle 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No API key set (Evolution and Summary providers) .

Console
"`; -exports[`Idle With Prompt 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Console
"`; +exports[`Idle With Prompt 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No API key set (Evolution and Summary providers) .

Console
"`; -exports[`Many Changed Files 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; +exports[`Many Changed Files 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; -exports[`Onboarding 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Console
"`; +exports[`Onboarding 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No API key set (Evolution and Summary providers) .

Console
"`; -exports[`Onboarding With Directory 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Console
"`; +exports[`Onboarding With Directory 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No API key set (Evolution and Summary providers) .

Console
"`; -exports[`Onboarding With Permissions 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; +exports[`Onboarding With Permissions 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; -exports[`Preview 1`] = `"

nixmac

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...

Applying changes

Updating your configuration and preparing the review step.

Console
"`; +exports[`Preview 1`] = `"

nixmac

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...

Applying changes

Updating your configuration and preparing the review step.

Console
"`; -exports[`Settings Open 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; +exports[`Settings Open 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Settings

General

Select your own, or proceed below for defaults

The darwin configuration to use for this machine

Send diagnostics to the nixmac team
Share redacted crash and error reports to improve stability. Restart required.
Support Nixmac
Help fund continued development.
Version
Console
"`; -exports[`With Error 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; +exports[`With Error 1`] = `"

nixmac

Failed to connect to nix daemon. Is the Nix daemon running?

Get started

No API key set (Evolution and Summary providers) .

Evolving...
8 events1,523 tokens
Waiting for next event...
Console
"`; diff --git a/apps/native/src/components/widget/filesystem/__snapshots__/file-list.stories.tsx.snap b/apps/native/src/components/widget/filesystem/__snapshots__/file-list.stories.tsx.snap index 49dba20a0..057b303d6 100644 --- a/apps/native/src/components/widget/filesystem/__snapshots__/file-list.stories.tsx.snap +++ b/apps/native/src/components/widget/filesystem/__snapshots__/file-list.stories.tsx.snap @@ -6,4 +6,4 @@ exports[`Setup Section 1`] = `"
Command-line tools
modules/darwin/packages.nix
Programs available in your terminal — git, ripgrep, jq, etc.
Apps & casks+1 cask
modules/darwin/homebrew.nix
Mac apps installed via Homebrew — Rectangle, 1Password, browsers.
Dock & Finder
modules/darwin/defaults.nix
macOS preferences — how the Dock, Finder, and screenshots behave.
Background services
modules/darwin/services.nix
Things that run automatically — yabai, skhd, sketchybar.
Security
modules/darwin/security.nix
Touch ID for sudo, login policy, firewall.
Prompt seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; -exports[`Untracked Section 1`] = `"
11 apps installed by hand
Casks already on disk via \`brew\` but not declared in your flake. On a fresh Mac they wouldn't come back.
$ brew list --cask·scanned 14 min ago·would land in modules/darwin/homebrew.nix
·Found · 11
  • docker
    Docker Desktop · 4.32.0
    Mar 12
  • obs
    OBS Studio · 30.2.3
    Feb 28
  • iterm2
    iTerm2 · 3.5.1
    Jan 09
  • vlc
    VLC media player · 3.0.20
    Jan 02
  • figma
    Figma · 124.4.0
    2025-12-18
  • spotify
    Spotify · 1.2.45
    2025-11-30
  • slack
    Slack · 4.40.0
    2025-11-21
  • zoom
    Zoom · 6.1.10
    2025-11-15
  • discord
    Discord · 0.0.310
    2025-10-04
  • notion
    Notion · 4.1.0
    2025-09-22
  • audacity
    Audacity · 3.6.4
    2025-08-11
8 untracked settings
Preferences you've changed in System Settings. Capture them as code so a fresh install matches.
$ defaults read · diff against profile·scanned 14 min ago·would land in modules/darwin/defaults.nix
·Found · 8
  • Dock — magnification on
    dock magnification = 1
    changed Mar 18
  • Finder — show path bar
    finder ShowPathbar = 1
    changed Mar 02
  • Trackpad — three-finger drag
    trackpad TrackpadThreeFingerDrag = 1
    changed Feb 14
  • Keyboard — fast key repeat
    NSGlobalDomain KeyRepeat = 2
    changed Jan 28
  • Mission Control — disable rearrange
    dock mru-spaces = 0
    changed Jan 15
  • Hot corners — bottom-right: lock screen
    dock wvous-br-corner = 13
    changed Jan 09
  • Menu bar — show date
    menuExtraClock ShowDate = 1
    changed 2025-12-22
  • Sound — feedback off
    NSGlobalDomain "com.apple.sound.beep.feedback" = 0
    changed 2025-12-04
4 apps auto-start at login
Move them into your config so new machines launch the same set.
$ osascript · System Events get login items·scanned 14 min ago·would land in modules/darwin/services.nix
·Found · 4
  • Rectangle
    /Applications/Rectangle.app
    since Dec 2024
  • Raycast
    /Applications/Raycast.app
    since Dec 2024
  • 1Password
    /Applications/1Password.app
    since Jan 2025
  • Hammerspoon
    /Applications/Hammerspoon.app
    since Feb 2025
Prompt seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`Untracked Section 1`] = `"
3 untracked Homebrew casks
Homebrew casks already on disk but not declared in your flake.
$ brew list --cask·scanned 14 mins ago·would land in .nixmac/homebrew/data.json
·Found · 3
  • docker
    Homebrew cask
    cask
  • obs
    Homebrew cask
    cask
  • iterm2
    Homebrew cask
    cask
1 untracked Homebrew tap
Homebrew taps already configured but not declared in your flake.
$ brew tap·scanned 14 mins ago·would land in .nixmac/homebrew/data.json
·Found · 1
  • homebrew/cask-fonts
    Homebrew tap
    tap
2 untracked Homebrew brews
Homebrew formulae already on disk but not declared in your flake.
$ brew list --formula·scanned 14 mins ago·would land in .nixmac/homebrew/data.json
·Found · 2
  • mas
    Homebrew formula
    formula
  • ffmpeg
    Homebrew formula
    formula
8 untracked settings
Preferences you've changed in System Settings. Capture them as code so a fresh install matches.
$ defaults read · diff against profile·scanned 14 min ago·would land in modules/darwin/defaults.nix
·Found · 8
  • Dock — magnification on
    dock magnification = 1
    changed Mar 18
  • Finder — show path bar
    finder ShowPathbar = 1
    changed Mar 02
  • Trackpad — three-finger drag
    trackpad TrackpadThreeFingerDrag = 1
    changed Feb 14
  • Keyboard — fast key repeat
    NSGlobalDomain KeyRepeat = 2
    changed Jan 28
  • Mission Control — disable rearrange
    dock mru-spaces = 0
    changed Jan 15
  • Hot corners — bottom-right: lock screen
    dock wvous-br-corner = 13
    changed Jan 09
  • Menu bar — show date
    menuExtraClock ShowDate = 1
    changed 2025-12-22
  • Sound — feedback off
    NSGlobalDomain "com.apple.sound.beep.feedback" = 0
    changed 2025-12-04
4 apps auto-start at login
Move them into your config so new machines launch the same set.
$ osascript · System Events get login items·scanned 14 min ago·would land in modules/darwin/services.nix
·Found · 4
  • Rectangle
    /Applications/Rectangle.app
    since Dec 2024
  • Raycast
    /Applications/Raycast.app
    since Dec 2024
  • 1Password
    /Applications/1Password.app
    since Jan 2025
  • Hammerspoon
    /Applications/Hammerspoon.app
    since Feb 2025
Prompt seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; diff --git a/apps/native/src/components/widget/filesystem/__snapshots__/filesystem-step.stories.tsx.snap b/apps/native/src/components/widget/filesystem/__snapshots__/filesystem-step.stories.tsx.snap index 1226f59f4..276b05fcd 100644 --- a/apps/native/src/components/widget/filesystem/__snapshots__/filesystem-step.stories.tsx.snap +++ b/apps/native/src/components/widget/filesystem/__snapshots__/filesystem-step.stories.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Default 1`] = `"
Command-line tools
modules/darwin/packages.nix
Programs available in your terminal — git, ripgrep, jq, etc.
Apps & casks+1 cask
modules/darwin/homebrew.nix
Mac apps installed via Homebrew — Rectangle, 1Password, browsers.
Dock & Finder
modules/darwin/defaults.nix
macOS preferences — how the Dock, Finder, and screenshots behave.
Background services
modules/darwin/services.nix
Things that run automatically — yabai, skhd, sketchybar.
Security
modules/darwin/security.nix
Touch ID for sudo, login policy, firewall.
Use these as starting points — every change goes through the standard plan → review → save flow.
Seed pushed to PromptInput · BeginStep would open next
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`Default 1`] = `"
Command-line tools
modules/darwin/packages.nix
Programs available in your terminal — git, ripgrep, jq, etc.
Apps & casks+1 cask
modules/darwin/homebrew.nix
Mac apps installed via Homebrew — Rectangle, 1Password, browsers.
Dock & Finder
modules/darwin/defaults.nix
macOS preferences — how the Dock, Finder, and screenshots behave.
Background services
modules/darwin/services.nix
Things that run automatically — yabai, skhd, sketchybar.
Security
modules/darwin/security.nix
Touch ID for sudo, login policy, firewall.
Use these as starting points — every change goes through the standard plan → review → save flow.
Seed pushed to PromptInput · BeginStep would open next
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; -exports[`Narrow Widget 1`] = `"
Command-line tools
modules/darwin/packages.nix
Programs available in your terminal — git, ripgrep, jq, etc.
Apps & casks+1 cask
modules/darwin/homebrew.nix
Mac apps installed via Homebrew — Rectangle, 1Password, browsers.
Dock & Finder
modules/darwin/defaults.nix
macOS preferences — how the Dock, Finder, and screenshots behave.
Background services
modules/darwin/services.nix
Things that run automatically — yabai, skhd, sketchybar.
Security
modules/darwin/security.nix
Touch ID for sudo, login policy, firewall.
Use these as starting points — every change goes through the standard plan → review → save flow.
Prompt seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`Narrow Widget 1`] = `"
Command-line tools
modules/darwin/packages.nix
Programs available in your terminal — git, ripgrep, jq, etc.
Apps & casks+1 cask
modules/darwin/homebrew.nix
Mac apps installed via Homebrew — Rectangle, 1Password, browsers.
Dock & Finder
modules/darwin/defaults.nix
macOS preferences — how the Dock, Finder, and screenshots behave.
Background services
modules/darwin/services.nix
Things that run automatically — yabai, skhd, sketchybar.
Security
modules/darwin/security.nix
Touch ID for sudo, login policy, firewall.
Use these as starting points — every change goes through the standard plan → review → save flow.
Prompt seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; -exports[`Standalone 1`] = `"
Command-line tools
modules/darwin/packages.nix
Programs available in your terminal — git, ripgrep, jq, etc.
Apps & casks+1 cask
modules/darwin/homebrew.nix
Mac apps installed via Homebrew — Rectangle, 1Password, browsers.
Dock & Finder
modules/darwin/defaults.nix
macOS preferences — how the Dock, Finder, and screenshots behave.
Background services
modules/darwin/services.nix
Things that run automatically — yabai, skhd, sketchybar.
Security
modules/darwin/security.nix
Touch ID for sudo, login policy, firewall.
Use these as starting points — every change goes through the standard plan → review → save flow.
"`; +exports[`Standalone 1`] = `"
Command-line tools
modules/darwin/packages.nix
Programs available in your terminal — git, ripgrep, jq, etc.
Apps & casks+1 cask
modules/darwin/homebrew.nix
Mac apps installed via Homebrew — Rectangle, 1Password, browsers.
Dock & Finder
modules/darwin/defaults.nix
macOS preferences — how the Dock, Finder, and screenshots behave.
Background services
modules/darwin/services.nix
Things that run automatically — yabai, skhd, sketchybar.
Security
modules/darwin/security.nix
Touch ID for sudo, login policy, firewall.
Use these as starting points — every change goes through the standard plan → review → save flow.
"`; diff --git a/apps/native/src/components/widget/filesystem/__snapshots__/section-tabs.stories.tsx.snap b/apps/native/src/components/widget/filesystem/__snapshots__/section-tabs.stories.tsx.snap index 9ea277a10..e78d00a2b 100644 --- a/apps/native/src/components/widget/filesystem/__snapshots__/section-tabs.stories.tsx.snap +++ b/apps/native/src/components/widget/filesystem/__snapshots__/section-tabs.stories.tsx.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`System Active 1`] = `"
"`; +exports[`System Active 1`] = `"
"`; -exports[`Untracked Active 1`] = `"
"`; +exports[`Untracked Active 1`] = `"
"`; diff --git a/apps/native/src/components/widget/filesystem/__snapshots__/seed-prompt.stories.tsx.snap b/apps/native/src/components/widget/filesystem/__snapshots__/seed-prompt.stories.tsx.snap index ae205222e..33a7145b7 100644 --- a/apps/native/src/components/widget/filesystem/__snapshots__/seed-prompt.stories.tsx.snap +++ b/apps/native/src/components/widget/filesystem/__snapshots__/seed-prompt.stories.tsx.snap @@ -1,18 +1,17 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Banner Seed 1`] = ` -"
Track these items by adding them to modules/darwin/homebrew.nix:
-- docker (Docker Desktop · 4.32.0)
-- obs (OBS Studio · 30.2.3)
-- iterm2 (iTerm2 · 3.5.1)
-- vlc (VLC media player · 3.0.20)
-- figma (Figma · 124.4.0)
-- spotify (Spotify · 1.2.45)
-- slack (Slack · 4.40.0)
-- zoom (Zoom · 6.1.10)
-- discord (Discord · 0.0.310)
-- notion (Notion · 4.1.0)
-- audacity (Audacity · 3.6.4)
+"
Track these items by adding them to .nixmac/homebrew/data.json:
+- docker (Homebrew cask)
+- obs (Homebrew cask)
+- iterm2 (Homebrew cask)
+
+Track these items by adding them to .nixmac/homebrew/data.json:
+- homebrew/cask-fonts (Homebrew tap)
+
+Track these items by adding them to .nixmac/homebrew/data.json:
+- mas (Homebrew formula)
+- ffmpeg (Homebrew formula)
 
 Track these items by adding them to modules/darwin/defaults.nix:
 - Dock — magnification on (dock magnification = 1)
@@ -33,19 +32,7 @@ Track these items by adding them to modules/darwin/services.nix:
 `;
 
 exports[`Per File Seeds 1`] = `
-"
seedForFile (per managed/candidate file)
FileSeed
flake.nix
Change flake.nix (e.g. add the unstable nixpkgs channel): 
flake.lock
Regenerate flake.lock.
modules/darwin/packages.nix
Change modules/darwin/packages.nix (e.g. add a CLI tool): 
modules/darwin/homebrew.nix
Change modules/darwin/homebrew.nix (e.g. install Slack): 
modules/darwin/defaults.nix
Change modules/darwin/defaults.nix (e.g. auto-hide the Dock): 
modules/darwin/services.nix
Change modules/darwin/services.nix (e.g. enable yabai): 
modules/darwin/security.nix
Change modules/darwin/security.nix (e.g. enable Touch ID for sudo): 
modules/home/dotfiles.nix
Change modules/home/dotfiles.nix (e.g. switch from neovim to helix): 
modules/home/apps.nix
Change modules/home/apps.nix (e.g. set Ghostty's theme to gruvbox): 
.sops.yaml
Change .sops.yaml (e.g. add a new secret recipient): 
nix-overlays.nix
Change nix-overlays.nix: 
Untracked Homebrew
Track these items by adding them to modules/darwin/homebrew.nix:
-- docker (Docker Desktop · 4.32.0)
-- obs (OBS Studio · 30.2.3)
-- iterm2 (iTerm2 · 3.5.1)
-- vlc (VLC media player · 3.0.20)
-- figma (Figma · 124.4.0)
-- spotify (Spotify · 1.2.45)
-- slack (Slack · 4.40.0)
-- zoom (Zoom · 6.1.10)
-- discord (Discord · 0.0.310)
-- notion (Notion · 4.1.0)
-- audacity (Audacity · 3.6.4)
-
Custom macOS defaults
Track these items by adding them to modules/darwin/defaults.nix:
+"
seedForFile (per managed/candidate file)
FileSeed
flake.nix
Change flake.nix (e.g. add the unstable nixpkgs channel): 
flake.lock
Regenerate flake.lock.
modules/darwin/packages.nix
Change modules/darwin/packages.nix (e.g. add a CLI tool): 
modules/darwin/homebrew.nix
Change modules/darwin/homebrew.nix (e.g. install Slack): 
modules/darwin/defaults.nix
Change modules/darwin/defaults.nix (e.g. auto-hide the Dock): 
modules/darwin/services.nix
Change modules/darwin/services.nix (e.g. enable yabai): 
modules/darwin/security.nix
Change modules/darwin/security.nix (e.g. enable Touch ID for sudo): 
modules/home/dotfiles.nix
Change modules/home/dotfiles.nix (e.g. switch from neovim to helix): 
modules/home/apps.nix
Change modules/home/apps.nix (e.g. set Ghostty's theme to gruvbox): 
.sops.yaml
Change .sops.yaml (e.g. add a new secret recipient): 
nix-overlays.nix
Change nix-overlays.nix: 
Untracked Homebrew casks
Track scanning homebrew casks.
Untracked Homebrew taps
Track scanning homebrew taps.
Untracked Homebrew brews
Track scanning homebrew brews.
Custom macOS defaults
Track these items by adding them to modules/darwin/defaults.nix:
 - Dock — magnification on (dock magnification = 1)
 - Finder — show path bar (finder ShowPathbar = 1)
 - Trackpad — three-finger drag (trackpad TrackpadThreeFingerDrag = 1)
@@ -62,20 +49,12 @@ exports[`Per File Seeds 1`] = `
 
" `; -exports[`Single Item Seed 1`] = `"
Track "docker" by adding it to modules/darwin/homebrew.nix. Detail: Docker Desktop · 4.32.0.
"`; +exports[`Single Item Seed 1`] = `"
Track "docker" by adding it to .nixmac/homebrew/data.json. Detail: Homebrew cask.
"`; exports[`Untracked Section Seed 1`] = ` -"
Track these items by adding them to modules/darwin/homebrew.nix:
-- docker (Docker Desktop · 4.32.0)
-- obs (OBS Studio · 30.2.3)
-- iterm2 (iTerm2 · 3.5.1)
-- vlc (VLC media player · 3.0.20)
-- figma (Figma · 124.4.0)
-- spotify (Spotify · 1.2.45)
-- slack (Slack · 4.40.0)
-- zoom (Zoom · 6.1.10)
-- discord (Discord · 0.0.310)
-- notion (Notion · 4.1.0)
-- audacity (Audacity · 3.6.4)
+"
Track these items by adding them to .nixmac/homebrew/data.json:
+- docker (Homebrew cask)
+- obs (Homebrew cask)
+- iterm2 (Homebrew cask)
 
" `; diff --git a/apps/native/src/components/widget/filesystem/__snapshots__/untracked-banner.stories.tsx.snap b/apps/native/src/components/widget/filesystem/__snapshots__/untracked-banner.stories.tsx.snap index d41dd7dea..70f668042 100644 --- a/apps/native/src/components/widget/filesystem/__snapshots__/untracked-banner.stories.tsx.snap +++ b/apps/native/src/components/widget/filesystem/__snapshots__/untracked-banner.stories.tsx.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`All Surfaces 1`] = `"
23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.
Banner action
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`All Surfaces 1`] = `"
18 items on your Mac aren't in your config
Across 5 surfaces (including Homebrew, system defaults, and startup items). On a fresh install, none of them would come back.
Banner action
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; exports[`Empty 1`] = `"
Banner returns null when there are zero items. (Nothing rendered above this note.)
"`; -exports[`Single Surface 1`] = `"
11 items on your Mac aren't in your config
On a fresh install, they wouldn't come back.
Banner action
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`Single Surface 1`] = `"
3 items on your Mac aren't in your config
On a fresh install, they wouldn't come back.
Banner action
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; -exports[`Toggle View 1`] = `"
23 items on your Mac aren't in your config
Across 3 surfaces (Homebrew, defaults, login items). On a fresh install, none of them would come back.
"`; +exports[`Toggle View 1`] = `"
18 items on your Mac aren't in your config
Across 5 surfaces (including Homebrew, system defaults, and startup items). On a fresh install, none of them would come back.
"`; diff --git a/apps/native/src/components/widget/filesystem/__snapshots__/untracked-card.stories.tsx.snap b/apps/native/src/components/widget/filesystem/__snapshots__/untracked-card.stories.tsx.snap index d60124452..7132eebf5 100644 --- a/apps/native/src/components/widget/filesystem/__snapshots__/untracked-card.stories.tsx.snap +++ b/apps/native/src/components/widget/filesystem/__snapshots__/untracked-card.stories.tsx.snap @@ -1,7 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Custom Defaults 1`] = `"
8 untracked settings
Preferences you've changed in System Settings. Capture them as code so a fresh install matches.
$ defaults read · diff against profile·scanned 14 min ago·would land in modules/darwin/defaults.nix
·Found · 8
  • Dock — magnification on
    dock magnification = 1
    changed Mar 18
  • Finder — show path bar
    finder ShowPathbar = 1
    changed Mar 02
  • Trackpad — three-finger drag
    trackpad TrackpadThreeFingerDrag = 1
    changed Feb 14
  • Keyboard — fast key repeat
    NSGlobalDomain KeyRepeat = 2
    changed Jan 28
  • Mission Control — disable rearrange
    dock mru-spaces = 0
    changed Jan 15
  • Hot corners — bottom-right: lock screen
    dock wvous-br-corner = 13
    changed Jan 09
  • Menu bar — show date
    menuExtraClock ShowDate = 1
    changed 2025-12-22
  • Sound — feedback off
    NSGlobalDomain "com.apple.sound.beep.feedback" = 0
    changed 2025-12-04
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`Custom Defaults 1`] = `"
8 untracked settings
Preferences you've changed in System Settings. Capture them as code so a fresh install matches.
$ defaults read · diff against profile·scanned 14 min ago·would land in modules/darwin/defaults.nix
·Found · 8
  • Dock — magnification on
    dock magnification = 1
    changed Mar 18
  • Finder — show path bar
    finder ShowPathbar = 1
    changed Mar 02
  • Trackpad — three-finger drag
    trackpad TrackpadThreeFingerDrag = 1
    changed Feb 14
  • Keyboard — fast key repeat
    NSGlobalDomain KeyRepeat = 2
    changed Jan 28
  • Mission Control — disable rearrange
    dock mru-spaces = 0
    changed Jan 15
  • Hot corners — bottom-right: lock screen
    dock wvous-br-corner = 13
    changed Jan 09
  • Menu bar — show date
    menuExtraClock ShowDate = 1
    changed 2025-12-22
  • Sound — feedback off
    NSGlobalDomain "com.apple.sound.beep.feedback" = 0
    changed 2025-12-04
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; -exports[`Homebrew Casks 1`] = `"
11 apps installed by hand
Casks already on disk via \`brew\` but not declared in your flake. On a fresh Mac they wouldn't come back.
$ brew list --cask·scanned 14 min ago·would land in modules/darwin/homebrew.nix
·Found · 11
  • docker
    Docker Desktop · 4.32.0
    Mar 12
  • obs
    OBS Studio · 30.2.3
    Feb 28
  • iterm2
    iTerm2 · 3.5.1
    Jan 09
  • vlc
    VLC media player · 3.0.20
    Jan 02
  • figma
    Figma · 124.4.0
    2025-12-18
  • spotify
    Spotify · 1.2.45
    2025-11-30
  • slack
    Slack · 4.40.0
    2025-11-21
  • zoom
    Zoom · 6.1.10
    2025-11-15
  • discord
    Discord · 0.0.310
    2025-10-04
  • notion
    Notion · 4.1.0
    2025-09-22
  • audacity
    Audacity · 3.6.4
    2025-08-11
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`Homebrew Brews 1`] = `"
2 untracked Homebrew brews
Homebrew formulae already on disk but not declared in your flake.
$ brew list --formula·scanned 14 mins ago·would land in .nixmac/homebrew/data.json
·Found · 2
  • mas
    Homebrew formula
    formula
  • ffmpeg
    Homebrew formula
    formula
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; -exports[`Login Items 1`] = `"
4 apps auto-start at login
Move them into your config so new machines launch the same set.
$ osascript · System Events get login items·scanned 14 min ago·would land in modules/darwin/services.nix
·Found · 4
  • Rectangle
    /Applications/Rectangle.app
    since Dec 2024
  • Raycast
    /Applications/Raycast.app
    since Dec 2024
  • 1Password
    /Applications/1Password.app
    since Jan 2025
  • Hammerspoon
    /Applications/Hammerspoon.app
    since Feb 2025
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; +exports[`Homebrew Casks 1`] = `"
3 untracked Homebrew casks
Homebrew casks already on disk but not declared in your flake.
$ brew list --cask·scanned 14 mins ago·would land in .nixmac/homebrew/data.json
·Found · 3
  • docker
    Homebrew cask
    cask
  • obs
    Homebrew cask
    cask
  • iterm2
    Homebrew cask
    cask
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; + +exports[`Homebrew Taps 1`] = `"
1 untracked Homebrew tap
Homebrew taps already configured but not declared in your flake.
$ brew tap·scanned 14 mins ago·would land in .nixmac/homebrew/data.json
·Found · 1
  • homebrew/cask-fonts
    Homebrew tap
    tap
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; + +exports[`Login Items 1`] = `"
4 apps auto-start at login
Move them into your config so new machines launch the same set.
$ osascript · System Events get login items·scanned 14 min ago·would land in modules/darwin/services.nix
·Found · 4
  • Rectangle
    /Applications/Rectangle.app
    since Dec 2024
  • Raycast
    /Applications/Raycast.app
    since Dec 2024
  • 1Password
    /Applications/1Password.app
    since Jan 2025
  • Hammerspoon
    /Applications/Hammerspoon.app
    since Feb 2025
Tracking seed
Click any "Edit with a prompt" / "Track these" button — the seed will appear here.
"`; diff --git a/apps/native/src/components/widget/filesystem/data.test.ts b/apps/native/src/components/widget/filesystem/data.test.ts new file mode 100644 index 000000000..9e99172dd --- /dev/null +++ b/apps/native/src/components/widget/filesystem/data.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { homebrewFilesFromDiff, untrackedCandidateItemCount, type FsFile } from "./data"; + +describe("untrackedCandidateItemCount", () => { + it("counts only candidate items that would render in untracked cards", () => { + const files: FsFile[] = [ + { + id: "managed", + path: "flake.nix", + title: "Managed", + description: "Tracked already", + iconName: "wiring", + tone: "muted", + status: "managed", + items: [{ name: "ignored", detail: "", installedAt: "", attr: "" }], + }, + { + id: "empty-candidate", + path: "Empty", + title: "Empty", + description: "No drift", + iconName: "warn", + tone: "amber", + status: "candidate", + items: [], + }, + { + id: "candidate", + path: "Candidate", + title: "Candidate", + description: "Real drift", + iconName: "warn", + tone: "amber", + status: "candidate", + items: [ + { name: "one", detail: "", installedAt: "", attr: "" }, + { name: "two", detail: "", installedAt: "", attr: "" }, + ], + }, + ]; + + expect(untrackedCandidateItemCount(files)).toBe(2); + }); + + it("matches the live Homebrew diff sections shown from the begin banner", () => { + const files = homebrewFilesFromDiff({ + isInstalled: true, + casks: ["docker", "obs"], + brews: ["mas"], + taps: [], + source: null, + lastChecked: 1, + }); + + expect(untrackedCandidateItemCount(files)).toBe(3); + }); +}); diff --git a/apps/native/src/components/widget/filesystem/data.ts b/apps/native/src/components/widget/filesystem/data.ts index c63016d49..d1ef1ec12 100644 --- a/apps/native/src/components/widget/filesystem/data.ts +++ b/apps/native/src/components/widget/filesystem/data.ts @@ -1,3 +1,5 @@ +import type { HomebrewItemType, HomebrewState } from "@/ipc/types"; + export type FileTone = "teal" | "amber" | "rose" | "blue" | "muted"; export type FileStatus = "managed" | "changed" | "candidate"; @@ -7,6 +9,7 @@ export type CandidateItem = { installedAt: string; attr: string; version?: string; + kind?: HomebrewItemType; }; export type FsFile = { @@ -269,30 +272,46 @@ creation_rules: ], manage: [ { - id: "untracked-brew", - path: "Untracked Homebrew", - title: "11 apps installed by hand", + id: "untracked-homebrew-casks", + path: "Untracked Homebrew casks", + title: "Scanning Homebrew casks", description: - "Casks already on disk via `brew` but not declared in your flake. On a fresh Mac they wouldn't come back.", + "Homebrew casks installed on this Mac but not declared in your flake.", iconName: "warn", tone: "amber", status: "candidate", - destination: "modules/darwin/homebrew.nix", - scanCommand: "brew list --cask", - scannedAt: "scanned 14 min ago", - items: [ - { name: "docker", detail: "Docker Desktop · 4.32.0", installedAt: "Mar 12", attr: 'homebrew.casks = [ "docker" ];', version: "4.32.0" }, - { name: "obs", detail: "OBS Studio · 30.2.3", installedAt: "Feb 28", attr: 'homebrew.casks = [ "obs" ];', version: "30.2.3" }, - { name: "iterm2", detail: "iTerm2 · 3.5.1", installedAt: "Jan 09", attr: 'homebrew.casks = [ "iterm2" ];', version: "3.5.1" }, - { name: "vlc", detail: "VLC media player · 3.0.20", installedAt: "Jan 02", attr: 'homebrew.casks = [ "vlc" ];', version: "3.0.20" }, - { name: "figma", detail: "Figma · 124.4.0", installedAt: "2025-12-18", attr: 'homebrew.casks = [ "figma" ];', version: "124.4.0" }, - { name: "spotify", detail: "Spotify · 1.2.45", installedAt: "2025-11-30", attr: 'homebrew.casks = [ "spotify" ];', version: "1.2.45" }, - { name: "slack", detail: "Slack · 4.40.0", installedAt: "2025-11-21", attr: 'homebrew.casks = [ "slack" ];', version: "4.40.0" }, - { name: "zoom", detail: "Zoom · 6.1.10", installedAt: "2025-11-15", attr: 'homebrew.casks = [ "zoom" ];', version: "6.1.10" }, - { name: "discord", detail: "Discord · 0.0.310", installedAt: "2025-10-04", attr: 'homebrew.casks = [ "discord" ];', version: "0.0.310" }, - { name: "notion", detail: "Notion · 4.1.0", installedAt: "2025-09-22", attr: 'homebrew.casks = [ "notion" ];', version: "4.1.0" }, - { name: "audacity", detail: "Audacity · 3.6.4", installedAt: "2025-08-11", attr: 'homebrew.casks = [ "audacity" ];', version: "3.6.4" }, - ], + destination: ".nixmac/homebrew/data.json", + scanCommand: "homebrew_get_state_diff", + scannedAt: "not scanned yet", + items: [], + }, + { + id: "untracked-homebrew-taps", + path: "Untracked Homebrew taps", + title: "Scanning Homebrew taps", + description: + "Homebrew taps configured on this Mac but not declared in your flake.", + iconName: "warn", + tone: "amber", + status: "candidate", + destination: ".nixmac/homebrew/data.json", + scanCommand: "homebrew_get_state_diff", + scannedAt: "not scanned yet", + items: [], + }, + { + id: "untracked-homebrew-brews", + path: "Untracked Homebrew brews", + title: "Scanning Homebrew brews", + description: + "Homebrew brews installed on this Mac but not declared in your flake.", + iconName: "warn", + tone: "amber", + status: "candidate", + destination: ".nixmac/homebrew/data.json", + scanCommand: "homebrew_get_state_diff", + scannedAt: "not scanned yet", + items: [], }, { id: "custom-defaults", @@ -338,6 +357,172 @@ creation_rules: ], }; +const HOMEBREW_FILE_DESTINATION = ".nixmac/homebrew/data.json"; + +type HomebrewSectionDefinition = { + id: string; + kind: HomebrewItemType; + stateKey: "casks" | "taps" | "brews"; + singular: string; + plural: string; + emptyTitle: string; + foundDescription: string; + emptyDescription: string; + scanCommand: string; +}; + +const HOMEBREW_SECTIONS: HomebrewSectionDefinition[] = [ + { + id: "untracked-homebrew-casks", + kind: "cask", + stateKey: "casks", + singular: "cask", + plural: "casks", + emptyTitle: "No untracked Homebrew casks", + foundDescription: "Homebrew casks already on disk but not declared in your flake.", + emptyDescription: "Every detected Homebrew cask is already declared in your config.", + scanCommand: "brew list --cask", + }, + { + id: "untracked-homebrew-taps", + kind: "tap", + stateKey: "taps", + singular: "tap", + plural: "taps", + emptyTitle: "No untracked Homebrew taps", + foundDescription: "Homebrew taps already configured but not declared in your flake.", + emptyDescription: "Every detected Homebrew tap is already declared in your config.", + scanCommand: "brew tap", + }, + { + id: "untracked-homebrew-brews", + kind: "brew", + stateKey: "brews", + singular: "brew", + plural: "brews", + emptyTitle: "No untracked Homebrew brews", + foundDescription: "Homebrew brews already on disk but not declared in your flake.", + emptyDescription: "Every detected Homebrew brew is already declared in your config.", + scanCommand: "brew list --formula --installed-on-request", + }, +]; + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} + +function scannedAt(lastChecked: number) { + const ageSecs = Math.max(0, Math.floor(Date.now() / 1000) - lastChecked); + if (ageSecs < 60) return "scanned just now"; + const minutes = Math.floor(ageSecs / 60); + if (minutes < 60) return `scanned ${minutes} ${pluralize(minutes, "min")} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `scanned ${hours} ${pluralize(hours, "hour")} ago`; + const days = Math.floor(hours / 24); + return `scanned ${days} ${pluralize(days, "day")} ago`; +} + +function homebrewItems(names: string[], kind: HomebrewItemType): CandidateItem[] { + const attrPath = kind === "brew" ? "brews" : `${kind}s`; + const label = kind === "brew" ? "formula" : kind; + return names.map((name) => ({ + name, + detail: `Homebrew ${label}`, + installedAt: label, + attr: `homebrew.${attrPath} = [ "${name}" ];`, + kind, + })); +} + +export function untrackedCandidateItemCount(files: FsFile[]) { + return files.reduce((acc, file) => { + if (file.status !== "candidate") return acc; + return acc + (file.items?.length ?? 0); + }, 0); +} + +function homebrewFallback(section: HomebrewSectionDefinition): FsFile { + const base = FILES.manage.find((file) => file.id === section.id); + return base ?? { + id: section.id, + path: `Untracked Homebrew ${section.plural}`, + title: `Untracked Homebrew ${section.plural}`, + description: `Homebrew ${section.plural} installed on this Mac but not declared in your flake.`, + iconName: "warn" as const, + tone: "amber" as const, + status: "candidate" as const, + destination: HOMEBREW_FILE_DESTINATION, + }; +} + +function homebrewFileForSection( + section: HomebrewSectionDefinition, + diff: HomebrewState | null, + error?: string | null, +): FsFile { + const fallback = homebrewFallback(section); + + if (error) { + return { + ...fallback, + title: "Homebrew scan failed", + description: error, + scanCommand: "homebrew_get_state_diff", + scannedAt: "scan failed", + items: [], + }; + } + + if (!diff) return fallback; + + if (!diff.isInstalled) { + return { + ...fallback, + title: "Homebrew not found", + description: "Homebrew is not installed or not discoverable on this Mac.", + scanCommand: "brew --version", + scannedAt: scannedAt(diff.lastChecked), + items: [], + }; + } + + const items = homebrewItems(diff[section.stateKey], section.kind); + const count = items.length; + + return { + ...fallback, + title: + count === 0 + ? section.emptyTitle + : `${count} untracked Homebrew ${pluralize(count, section.singular, section.plural)}`, + description: count === 0 ? section.emptyDescription : section.foundDescription, + destination: HOMEBREW_FILE_DESTINATION, + scanCommand: section.scanCommand, + scannedAt: scannedAt(diff.lastChecked), + items, + }; +} + +export function homebrewFilesFromDiff(diff: HomebrewState | null, error?: string | null): FsFile[] { + return HOMEBREW_SECTIONS.map((section) => homebrewFileForSection(section, diff, error)); +} + +export function isHomebrewCandidateFile(file: FsFile) { + return HOMEBREW_SECTIONS.some((section) => section.id === file.id); +} + +function isHomebrewPlaceholder(file: FsFile) { + return HOMEBREW_SECTIONS.some((section) => section.id === file.id); +} + +export function replaceHomebrewPlaceholders(files: FsFile[], replacements: FsFile[]) { + const [firstHomebrew] = HOMEBREW_SECTIONS; + return files.flatMap((file) => { + if (file.id === firstHomebrew.id) return replacements; + return isHomebrewPlaceholder(file) ? [] : [file]; + }); +} + export const TONE_CLASSES: Record = { teal: { fg: "text-teal-400", bg: "bg-teal-500/15" }, amber: { fg: "text-amber-400", bg: "bg-amber-500/15" }, diff --git a/apps/native/src/components/widget/filesystem/file-list.stories.tsx b/apps/native/src/components/widget/filesystem/file-list.stories.tsx index 5da1f676a..0156e0426 100644 --- a/apps/native/src/components/widget/filesystem/file-list.stories.tsx +++ b/apps/native/src/components/widget/filesystem/file-list.stories.tsx @@ -1,6 +1,6 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; -import { FILES } from "./data"; +import { FILES, homebrewFilesFromDiff, replaceHomebrewPlaceholders } from "./data"; import { FileList } from "./file-list"; import { SeedDisplay } from "./seed-display"; import { seedForFile } from "./seed-prompt"; @@ -14,6 +14,16 @@ const meta = preview.meta({ export default meta; +const storyHomebrew = homebrewFilesFromDiff({ + isInstalled: true, + casks: ["docker", "obs", "iterm2"], + brews: ["mas", "ffmpeg"], + taps: ["homebrew/cask-fonts"], + source: null, + lastChecked: Math.floor(Date.now() / 1000) - 14 * 60, +}); +const storyManageFiles = replaceHomebrewPlaceholders(FILES.manage, storyHomebrew); + export const SystemSection = meta.story({ render: () => ( @@ -52,7 +62,7 @@ export const UntrackedSection = meta.story({ {(push) => (
push(seedForFile(f))} onTrack={push} /> diff --git a/apps/native/src/components/widget/filesystem/file-list.tsx b/apps/native/src/components/widget/filesystem/file-list.tsx index 1cc6fac9f..20481cdfb 100644 --- a/apps/native/src/components/widget/filesystem/file-list.tsx +++ b/apps/native/src/components/widget/filesystem/file-list.tsx @@ -11,14 +11,14 @@ interface FileListProps { onEditWithPrompt: (file: FsFile) => void; /** Untracked sections route through this — caller seeds the prompt with a tracking task. */ onTrack: (seed: string) => void; - onTrackHomebrewCasks?: (items: CandidateItem[]) => Promise | void; + onTrackHomebrewItems?: (items: CandidateItem[]) => Promise | void; } export function FileList({ files, onEditWithPrompt, onTrack, - onTrackHomebrewCasks, + onTrackHomebrewItems, }: FileListProps) { const [query, setQuery] = useState(""); @@ -59,7 +59,7 @@ export function FileList({
) : ( diff --git a/apps/native/src/components/widget/filesystem/filesystem-step.tsx b/apps/native/src/components/widget/filesystem/filesystem-step.tsx index f9aa63a6a..01c957981 100644 --- a/apps/native/src/components/widget/filesystem/filesystem-step.tsx +++ b/apps/native/src/components/widget/filesystem/filesystem-step.tsx @@ -8,7 +8,17 @@ import { mirrorChangeMapState } from "@/viewmodel/change-map"; import { mirrorEvolveState } from "@/viewmodel/evolve"; import { mirrorGitState } from "@/viewmodel/git"; -import { FILES, SECTIONS, type CandidateItem, type FsFile, type SectionId } from "./data"; +import type { HomebrewItem, HomebrewState } from "@/ipc/types"; + +import { + FILES, + SECTIONS, + homebrewFilesFromDiff, + replaceHomebrewPlaceholders, + type CandidateItem, + type FsFile, + type SectionId, +} from "./data"; import { FileList } from "./file-list"; import { SectionTabs } from "./section-tabs"; import { seedForFile } from "./seed-prompt"; @@ -36,6 +46,8 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { : "darwin"; const [activeSection, setActiveSection] = useState(initialSection); + const [homebrewDiff, setHomebrewDiff] = useState(null); + const [homebrewError, setHomebrewError] = useState(null); // Clear the target on mount so a subsequent toggle from the header // (which passes no section) returns to the user's last view. @@ -43,12 +55,38 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { if (targetSection) { useWidgetStore.setState({ filesystemTargetSection: null }); } - // Run only on mount — see effect of targetSection changing handled - // by the lifecycle below (the view is unmounted between openings). - // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only + }, [targetSection]); + + useEffect(() => { + let cancelled = false; + tauriAPI.homebrew + .getStateDiff() + .then((diff) => { + if (!cancelled) { + setHomebrewDiff(diff); + setHomebrewError(null); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setHomebrewError(String(error)); + } + }); + + return () => { + cancelled = true; + }; }, []); - const files = FILES[activeSection] ?? []; + const filesBySection = { + ...FILES, + manage: replaceHomebrewPlaceholders( + FILES.manage, + homebrewFilesFromDiff(homebrewDiff, homebrewError), + ), + }; + + const files = filesBySection[activeSection] ?? []; const seed = (text: string) => { if (onSeedPrompt) { @@ -63,21 +101,29 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { const onTrack = (text: string) => seed(text); // Add future direct managed-edit trackers here (for example, system defaults) // and pass them down alongside the fallback prompt seeding handler. - const onTrackHomebrewCasks = async (items: CandidateItem[]) => { + const onTrackHomebrewItems = async (items: CandidateItem[]) => { const store = useWidgetStore.getState(); store.setProcessing(true, "apply"); try { - const result = await tauriAPI.homebrew.addCasks( - items.map((item) => ({ + const homebrewItems: HomebrewItem[] = items.map((item) => { + if (!item.kind) { + throw new Error(`Cannot track ${item.name}: missing Homebrew item type.`); + } + return { name: item.name, version: item.version ?? null, - })), + itemType: item.kind, + }; + }); + const result = await tauriAPI.homebrew.addItems( + homebrewItems, ); mirrorEvolveState(result.evolveState); mirrorChangeMapState(result.changeMap); mirrorGitState(result.gitStatus); store.setRecommendedPrompt(undefined); setShowFilesystem(false); + setHomebrewDiff(null); } finally { store.setProcessing(false); } @@ -85,13 +131,18 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { return (
- +
Use these as starting points — every change goes through the standard plan → review → save flow. diff --git a/apps/native/src/components/widget/filesystem/section-tabs.tsx b/apps/native/src/components/widget/filesystem/section-tabs.tsx index b2c19527f..1f8db90cd 100644 --- a/apps/native/src/components/widget/filesystem/section-tabs.tsx +++ b/apps/native/src/components/widget/filesystem/section-tabs.tsx @@ -3,7 +3,12 @@ import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { FsFile, Section, SectionId } from "./data"; +import { + untrackedCandidateItemCount, + type FsFile, + type Section, + type SectionId, +} from "./data"; const SECTION_ICONS: Record = { entry: Cable, @@ -28,7 +33,7 @@ export function SectionTabs({ sections, active, setActive, files }: SectionTabsP const Icon = SECTION_ICONS[s.id]; const untrackedCount = s.id === "manage" - ? files[s.id]?.reduce((acc, f) => acc + (f.items?.length ?? 0), 0) + ? untrackedCandidateItemCount(files[s.id] ?? []) : 0; return ( -
); diff --git a/apps/native/src/components/widget/filesystem/untracked-card.stories.tsx b/apps/native/src/components/widget/filesystem/untracked-card.stories.tsx index d141fb3d7..21e21a265 100644 --- a/apps/native/src/components/widget/filesystem/untracked-card.stories.tsx +++ b/apps/native/src/components/widget/filesystem/untracked-card.stories.tsx @@ -1,6 +1,6 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; -import { FILES } from "./data"; +import { FILES, homebrewFilesFromDiff } from "./data"; import { SeedDisplay } from "./seed-display"; import { UntrackedCard } from "./untracked-card"; @@ -13,7 +13,14 @@ const meta = preview.meta({ export default meta; -const brew = FILES.manage.find((f) => f.id === "untracked-brew")!; +const [casks, taps, brews] = homebrewFilesFromDiff({ + isInstalled: true, + casks: ["docker", "obs", "iterm2"], + brews: ["mas", "ffmpeg"], + taps: ["homebrew/cask-fonts"], + source: null, + lastChecked: Math.floor(Date.now() / 1000) - 14 * 60, +}); const defaults = FILES.manage.find((f) => f.id === "custom-defaults")!; const login = FILES.manage.find((f) => f.id === "login-items")!; @@ -21,7 +28,27 @@ export const HomebrewCasks = meta.story({ render: () => (
- {(push) => } + {(push) => } + +
+ ), +}); + +export const HomebrewTaps = meta.story({ + render: () => ( +
+ + {(push) => } + +
+ ), +}); + +export const HomebrewBrews = meta.story({ + render: () => ( +
+ + {(push) => }
), diff --git a/apps/native/src/components/widget/filesystem/untracked-card.tsx b/apps/native/src/components/widget/filesystem/untracked-card.tsx index 4917d6955..6379374dc 100644 --- a/apps/native/src/components/widget/filesystem/untracked-card.tsx +++ b/apps/native/src/components/widget/filesystem/untracked-card.tsx @@ -1,10 +1,15 @@ -import { useMemo, useState } from "react"; -import { Braces, MessageSquarePlus } from "lucide-react"; +import { useId, useMemo, useState } from "react"; +import { Braces, ChevronDown, MessageSquarePlus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import type { CandidateItem, FileTone, FsFile } from "./data"; +import { + isHomebrewCandidateFile, + type CandidateItem, + type FileTone, + type FsFile, +} from "./data"; import { highlightNixLine } from "./highlight"; import { resolveIcon } from "./icons"; import { seedForUntrackedItem, seedForUntrackedSection } from "./seed-prompt"; @@ -16,7 +21,7 @@ interface UntrackedCardProps { * caller seeds the prompt and closes the Filesystem view. */ onTrack: (seed: string) => void; - onTrackHomebrewCasks?: (items: CandidateItem[]) => Promise | void; + onTrackHomebrewItems?: (items: CandidateItem[]) => Promise | void; } const CARD_TONE_CLASSES: Record< @@ -55,19 +60,22 @@ const CARD_TONE_CLASSES: Record< }, }; -export function UntrackedCard({ file, onTrack, onTrackHomebrewCasks }: UntrackedCardProps) { +export function UntrackedCard({ file, onTrack, onTrackHomebrewItems }: UntrackedCardProps) { const items = useMemo(() => file.items ?? [], [file.items]); + const contentId = useId(); + const [expanded, setExpanded] = useState(true); const [showSource, setShowSource] = useState(false); const [trackingKey, setTrackingKey] = useState(null); const [trackError, setTrackError] = useState(null); const tone = CARD_TONE_CLASSES[file.tone]; const Icon = resolveIcon(file.iconName); - // Managed-edit routes are section-scoped: Homebrew casks use the direct - // managed edit path today, while other untracked sections still seed prompts. - const canTrackHomebrew = file.id === "untracked-brew" && !!onTrackHomebrewCasks; + const hasItems = items.length > 0; + // Managed-edit routes are section-scoped: Homebrew candidates use the direct + // managed edit path, while other untracked sections still seed prompts. + const canTrackHomebrew = isHomebrewCandidateFile(file) && !!onTrackHomebrewItems; const trackItems = async (selectedItems: CandidateItem[], key: string, seed: string) => { - if (!canTrackHomebrew || !onTrackHomebrewCasks) { + if (!canTrackHomebrew || !onTrackHomebrewItems) { onTrack(seed); return; } @@ -75,7 +83,7 @@ export function UntrackedCard({ file, onTrack, onTrackHomebrewCasks }: Untracked setTrackingKey(key); setTrackError(null); try { - await onTrackHomebrewCasks(selectedItems); + await onTrackHomebrewItems(selectedItems); } catch (error: unknown) { setTrackError(String(error)); } finally { @@ -96,7 +104,22 @@ export function UntrackedCard({ file, onTrack, onTrackHomebrewCasks }: Untracked
-
{file.title}
+
+
{file.title}
+ +
{file.description}
@@ -110,81 +133,90 @@ export function UntrackedCard({ file, onTrack, onTrackHomebrewCasks }: Untracked {file.destination}
-
- - -
{trackError && (
{trackError}
)}
-
-
- · - Found · {items.length} - -
-
    - {items.map((it, i) => ( -
  • 0 && "border-border/30 border-t", - )} - > -
    -
    {it.name}
    -
    - {it.detail} -
    -
    - {it.installedAt} + {expanded && ( +
    +
    +
    -
  • - ))} -
-
+ + + - {showSource && ( -
-          {items.map((it) => (
-            
-              +
-               
-              {highlightNixLine(it.attr)}
-              # {it.name}
-            
-          ))}
-        
+
+
+ · + Found · {items.length} + +
+
    + {items.map((it, i) => ( +
  • 0 && "border-border/30 border-t", + )} + > +
    +
    {it.name}
    +
    + {it.detail} +
    +
    + + {it.installedAt} + + +
  • + ))} +
+
+ + {showSource && ( +
+              {items.map((it) => (
+                
+                  +
+                   
+                  {highlightNixLine(it.attr)}
+                  # {it.name}
+                
+              ))}
+            
+ )} + )} ); diff --git a/apps/native/src/components/widget/steps/begin-step.tsx b/apps/native/src/components/widget/steps/begin-step.tsx index 74cb04779..74cee6b7e 100644 --- a/apps/native/src/components/widget/steps/begin-step.tsx +++ b/apps/native/src/components/widget/steps/begin-step.tsx @@ -1,23 +1,33 @@ "use client"; -import { FILES } from "@/components/widget/filesystem/data"; +import { + FILES, + homebrewFilesFromDiff, + replaceHomebrewPlaceholders, +} from "@/components/widget/filesystem/data"; import { UntrackedBanner } from "@/components/widget/filesystem/untracked-banner"; import { GetStartedMessage } from "@/components/widget/layout/get-started-message"; import { PromptInputSection } from "@/components/widget/promptinput/prompt-input-section"; +import { useHomebrewDiff } from "@/hooks/use-homebrew-diff"; import { filesystemViewEnabled } from "@/lib/flags"; import { useWidgetStore } from "@/stores/widget-store"; export function BeginStep() { - const setEvolvePrompt = useWidgetStore((s) => s.setEvolvePrompt); const setShowFilesystem = useWidgetStore((s) => s.setShowFilesystem); + const prefsLoaded = useWidgetStore((s) => s.prefsLoaded); + const scanHomebrewOnStartup = useWidgetStore((s) => s.scanHomebrewOnStartup); + const shouldScan = filesystemViewEnabled && prefsLoaded && scanHomebrewOnStartup; + const { diff, error } = useHomebrewDiff(shouldScan); + const untrackedCandidates = diff + ? replaceHomebrewPlaceholders(FILES.manage, homebrewFilesFromDiff(diff, error)) + : []; return ( <> {filesystemViewEnabled && ( setEvolvePrompt(seed)} + candidates={untrackedCandidates} onView={() => setShowFilesystem(true, "manage")} /> )} diff --git a/apps/native/src/ipc/api.ts b/apps/native/src/ipc/api.ts index a9621eeb0..33bbaaafc 100644 --- a/apps/native/src/ipc/api.ts +++ b/apps/native/src/ipc/api.ts @@ -22,7 +22,7 @@ import type { FileDiffContents, FinalizeApplyResult, GitStatus, - HomebrewCaskItem, + HomebrewItem, HomebrewState, HistoryItem, ImportResult, @@ -234,8 +234,8 @@ export const tauriAPI = { homebrew: { getStateDiff: () => invoke("homebrew_get_state_diff"), applyDiff: (diff: HomebrewState) => invoke("homebrew_apply_diff", { diff }), - addCasks: (casks: HomebrewCaskItem[]) => - invoke("homebrew_add_casks", { casks }), + addItems: (items: HomebrewItem[]) => + invoke("homebrew_add_items", { items }), }, updater: { diff --git a/apps/native/src/ipc/types.ts b/apps/native/src/ipc/types.ts index 90274b4bc..975ce77a3 100644 --- a/apps/native/src/ipc/types.ts +++ b/apps/native/src/ipc/types.ts @@ -1053,7 +1053,9 @@ isOrphanedRestore: boolean; */ isUndone: boolean } -export type HomebrewCaskItem = { name: string; version: string | null } +export type HomebrewItem = { name: string; version: string | null; itemType: HomebrewItemType } + +export type HomebrewItemType = "tap" | "cask" | "brew" /** * Current Homebrew package state detected on the machine.