From e1254cbaddeaf4ffc68e4e3b89d43780b01b21da Mon Sep 17 00:00:00 2001 From: Cooper Maruyama Date: Sun, 21 Jun 2026 00:02:18 -0700 Subject: [PATCH 01/17] onboarding UI --- README.md | 6 +- apps/native/.storybook/hooks.ts | 20 +- apps/native/.storybook/main.ts | 9 +- apps/native/.storybook/manager.ts | 6 +- apps/native/.storybook/mocks/monaco-react.tsx | 28 +- apps/native/.storybook/mocks/tauri-runtime.ts | 64 ++- apps/native/.storybook/preview.tsx | 19 +- apps/native/.storybook/theme.ts | 4 +- apps/native/.storybook/vitest.setup.ts | 61 +- apps/native/app/globals.css | 2 +- .../src/components/nix-editor/index.tsx | 4 +- .../nix-editor/use-nix-editor.test.ts | 60 +- .../components/nix-editor/use-nix-editor.ts | 25 +- .../nixmac-mascot/NixmacMascot3D.tsx | 7 +- .../src/components/nixmac-mascot/README.md | 6 +- .../nixmac-mascot/nixmac-mascot.css | 8 +- .../preview-indicator/preview-indicator.tsx | 26 +- .../src/components/styles/card-grid-style.tsx | 9 +- .../styles/stepper-wizard-style.tsx | 37 +- .../components/styles/vercel-list-style.tsx | 25 +- apps/native/src/components/tabs-content-1.tsx | 17 +- .../__snapshots__/widget.stories.tsx.snap | 36 +- .../components/widget/badges/badge-list.tsx | 6 +- .../badges/config-dir-badge.stories.tsx | 3 +- .../components/widget/badges/time-badge.tsx | 6 +- .../widget/controls/bootstrap-config.tsx | 9 +- .../widget/controls/build-head-button.tsx | 5 +- .../widget/controls/commit-message.tsx | 4 +- .../widget/controls/confirm-button.tsx | 2 +- .../widget/controls/confirmation-dialog.tsx | 4 +- .../widget/controls/directory-picker.test.tsx | 8 +- .../widget/controls/directory-picker.tsx | 16 +- .../widget/controls/model-combobox.tsx | 7 +- .../widget/controls/repo-import.test.tsx | 2 +- .../components/widget/evolve-flow.stories.tsx | 100 +++- .../widget/feedback/feedback-dialog.tsx | 3 +- .../widget/feedback/report-issue-button.tsx | 2 +- .../src/components/widget/filesystem/data.ts | 94 ++-- .../widget/filesystem/file-list.stories.tsx | 15 +- .../widget/filesystem/file-row.stories.tsx | 16 +- .../widget/filesystem/filesystem-step.tsx | 21 +- .../widget/filesystem/highlight.tsx | 4 +- .../widget/filesystem/section-tabs.tsx | 11 +- .../filesystem/seed-display.stories.tsx | 5 +- .../widget/filesystem/seed-display.tsx | 9 +- .../filesystem/untracked-banner.stories.tsx | 5 +- .../filesystem/untracked-card.stories.tsx | 12 +- .../widget/filesystem/untracked-card.tsx | 25 +- .../widget/history/analyze-history-button.tsx | 8 +- .../history/analyze-history-item-button.tsx | 6 +- .../history/discard-uncommitted-dialog.tsx | 6 +- .../history-confirm-restore-button.tsx | 16 +- .../widget/history/history-header.tsx | 4 +- .../widget/history/history-item-card.tsx | 40 +- .../history/history-item-expanded-detail.tsx | 10 +- .../history/history-restore-item-button.tsx | 5 +- .../widget/history/timeline-connector.tsx | 52 +- .../widget/layout/AppErrorBoundary.tsx | 11 +- .../widget/layout/AppFatalFallback.tsx | 3 +- .../src/components/widget/layout/console.tsx | 13 +- .../widget/layout/debug-overlay.tsx | 2 +- .../widget/layout/error-message.tsx | 2 +- .../widget/layout/git-status-debug.tsx | 2 +- .../src/components/widget/layout/header.tsx | 10 +- .../widget/layout/merge-section.tsx | 13 +- .../src/components/widget/layout/stepper.tsx | 163 +++--- .../widget/layout/update-banner.tsx | 10 +- .../external-build-detected.stories.tsx | 11 +- .../notifications/external-build-detected.tsx | 5 +- .../uncommitted-changes-detected.tsx | 2 +- .../unsummarized-changes-detected.tsx | 9 +- .../onboarding-flow.stories.tsx.snap | 15 + .../widget/onboarding/celebration-overlay.tsx | 94 ++++ .../src/components/widget/onboarding/index.ts | 8 + .../onboarding/inference/inference-setup.tsx | 520 ++++++++++++++++++ .../widget/onboarding/lib/customizations.ts | 187 +++++++ .../widget/onboarding/lib/flake-ref.ts | 202 +++++++ .../widget/onboarding/lib/inference.ts | 97 ++++ .../widget/onboarding/lib/onboarding.ts | 56 ++ .../onboarding/onboarding-flow.stories.tsx | 429 +++++++++++++++ .../widget/onboarding/onboarding-flow.tsx | 30 + .../widget/onboarding/onboarding-header.tsx | 27 + .../widget/onboarding/onboarding-sidebar.tsx | 25 + .../onboarding/onboarding-step-content.tsx | 50 ++ .../onboarding/source/create-source.tsx | 184 +++++++ .../onboarding/source/flake-ref-source.tsx | 178 ++++++ .../onboarding/source/github-source.tsx | 149 +++++ .../widget/onboarding/source/local-source.tsx | 62 +++ .../widget/onboarding/step-shell.tsx | 32 ++ .../components/widget/onboarding/stepper.tsx | 57 ++ .../widget/onboarding/steps/build-step.tsx | 285 ++++++++++ .../onboarding/steps/customizations-step.tsx | 484 ++++++++++++++++ .../onboarding/steps/inference-step.tsx | 42 ++ .../onboarding/steps/nix-setup-step.tsx | 202 +++++++ .../onboarding/steps/permissions-step.tsx | 145 +++++ .../widget/onboarding/steps/setup-step.tsx | 420 ++++++++++++++ .../widget/onboarding/use-onboarding-flow.ts | 60 ++ .../overlays/config-edit-overlay-panel.tsx | 6 +- .../widget/overlays/editor-panel.stories.tsx | 2 +- .../widget/overlays/editor-panel.tsx | 2 +- .../widget/overlays/evolve-overlay-panel.tsx | 8 +- .../widget/overlays/evolve-progress.tsx | 34 +- .../rebuild-overlay-panel.stories.tsx | 37 +- .../overlays/rebuild-overlay-panel.test.tsx | 4 +- .../widget/overlays/rebuild-overlay-panel.tsx | 19 +- .../promptinput/begin-evolve-warning.tsx | 48 +- .../promptinput/conversational-response.tsx | 2 +- .../widget/promptinput/homebrew-badge.tsx | 34 +- .../promptinput/mac-recommendation-chip.tsx | 6 +- .../promptinput/prompt-history-badge.tsx | 11 +- .../widget/promptinput/prompt-input.test.tsx | 4 +- .../widget/promptinput/prompt-input.tsx | 52 +- .../promptinput/system-defaults-cta.test.tsx | 2 +- .../promptinput/system-defaults-cta.tsx | 27 +- .../widget/settings/account-tab.tsx | 9 +- .../widget/settings/ai-models-tab.stories.tsx | 8 +- .../widget/settings/ai-models-tab.tsx | 56 +- .../widget/settings/auto-config-field.tsx | 5 +- .../settings/auto-tuning-section.stories.tsx | 4 +- .../settings/backup-restore-section.tsx | 16 +- .../widget/settings/developer-tab.stories.tsx | 18 +- .../widget/settings/developer-tab.tsx | 66 ++- .../widget/settings/general-tab.tsx | 44 +- .../widget/settings/preferences-tab.tsx | 22 +- .../widget/settings/settings-dialog.tsx | 26 +- .../components/widget/settings/tuning-tab.tsx | 4 +- .../__snapshots__/setup-step.stories.tsx.snap | 3 - .../components/widget/steps/begin-step.tsx | 4 +- .../components/widget/steps/evolve-step.tsx | 19 +- .../components/widget/steps/history-step.tsx | 19 +- .../src/components/widget/steps/index.ts | 5 +- .../widget/steps/manual-evolve-step.tsx | 27 +- .../widget/steps/nix-setup-step.test.tsx | 82 --- .../widget/steps/nix-setup-step.tsx | 159 ------ .../widget/steps/permissions-step.tsx | 209 ------- .../widget/steps/setup-step.stories.tsx | 90 --- .../widget/steps/setup-step.test.tsx | 106 ---- .../components/widget/steps/setup-step.tsx | 134 ----- .../summaries/analyze-current-button.tsx | 2 +- .../summaries/collapsible-diff.stories.tsx | 4 +- .../widget/summaries/collapsible-diff.tsx | 14 +- .../widget/summaries/diff-line-stats.test.tsx | 5 +- .../widget/summaries/diff-line-stats.tsx | 8 +- .../widget/summaries/diff-section.stories.tsx | 8 +- .../widget/summaries/diff-section.tsx | 11 +- .../components/widget/summaries/diff-view.tsx | 11 +- .../components/widget/summaries/file-view.tsx | 22 +- .../full-file-diff-editor.stories.tsx | 57 +- .../summaries/full-file-diff-editor.tsx | 19 +- .../widget/summaries/hunk-pill.stories.tsx | 38 +- .../components/widget/summaries/hunk-pill.tsx | 2 +- .../summaries/markdown-description.test.tsx | 10 +- .../widget/summaries/markdown-description.tsx | 4 +- .../widget/summaries/markdown-utils.ts | 8 +- .../widget/summaries/monaco-setup.ts | 20 +- .../widget/summaries/own-summary-item.tsx | 11 +- .../widget/summaries/summary-items.test.tsx | 24 +- .../widget/summaries/summary-items.tsx | 59 +- .../widget/summaries/summary-or-diff.tsx | 27 +- .../widget/summaries/unsummarized-change.tsx | 13 +- .../unsummarized-changes-section.stories.tsx | 9 +- .../unsummarized-changes-section.tsx | 15 +- .../src/components/widget/utils.stories.tsx | 5 +- .../src/components/widget/utils.test.ts | 24 +- apps/native/src/components/widget/utils.ts | 38 +- .../src/components/widget/widget.stories.tsx | 119 +++- .../src/components/widget/widget.test.tsx | 4 +- apps/native/src/components/widget/widget.tsx | 50 +- apps/native/src/hooks/use-apply.ts | 2 +- apps/native/src/hooks/use-current-step.ts | 4 +- apps/native/src/hooks/use-darwin-config.ts | 2 +- .../src/hooks/use-evolve-mascot.test.ts | 10 +- apps/native/src/hooks/use-evolve-mascot.ts | 4 +- apps/native/src/hooks/use-evolve.test.ts | 4 +- apps/native/src/hooks/use-evolve.ts | 13 +- .../src/hooks/use-feedback-on-recovery.ts | 5 +- apps/native/src/hooks/use-git-operations.ts | 6 +- apps/native/src/hooks/use-history-card.ts | 19 +- apps/native/src/hooks/use-history-restore.ts | 13 +- apps/native/src/hooks/use-history.ts | 4 +- apps/native/src/hooks/use-homebrew-diff.ts | 2 +- apps/native/src/hooks/use-panic-handler.ts | 2 +- apps/native/src/hooks/use-rebuild-stream.ts | 2 +- .../src/hooks/use-recommended-prompt.ts | 2 +- apps/native/src/hooks/use-rollback.test.ts | 4 +- apps/native/src/hooks/use-rollback.ts | 4 +- apps/native/src/hooks/use-summary.ts | 4 +- apps/native/src/hooks/use-tray-events.ts | 2 +- apps/native/src/hooks/use-updater.ts | 6 +- apps/native/src/index.css | 227 +++++--- apps/native/src/lib/api-key-verification.ts | 8 +- apps/native/src/lib/boot-diagnostics.ts | 4 +- apps/native/src/lib/env.ts | 3 +- apps/native/src/lib/lsp-monaco-bridge.ts | 10 +- apps/native/src/lib/telemetry/context.tsx | 4 +- .../src/lib/telemetry/forwarding-processor.ts | 9 +- apps/native/src/lib/telemetry/init.ts | 12 +- apps/native/src/lib/telemetry/provider.ts | 19 +- apps/native/src/lib/telemetry/sanitize.ts | 18 +- apps/native/src/lib/telemetry/types.ts | 30 +- apps/native/src/main.tsx | 26 +- apps/native/src/preview-indicator-window.tsx | 19 +- apps/native/src/utils/test-fixtures.ts | 6 +- apps/native/src/utils/widget-test-helpers.ts | 4 +- apps/native/src/viewmodel/change-map.ts | 2 +- apps/native/src/viewmodel/evolution.ts | 7 +- apps/native/src/viewmodel/evolve.ts | 2 +- apps/native/src/viewmodel/git.ts | 4 +- apps/native/src/viewmodel/history.ts | 2 +- apps/native/src/viewmodel/nix-install.ts | 4 +- apps/native/src/viewmodel/permissions.ts | 2 +- apps/native/src/viewmodel/preferences.ts | 2 +- apps/native/src/viewmodel/prompt-history.ts | 2 +- apps/native/src/viewmodel/rebuild.ts | 10 +- apps/native/src/viewmodel/viewmodel.test.ts | 4 +- ops/secrets/e2e.enc.yaml | 118 ++-- ops/secrets/prod.yaml | 64 +-- ops/secrets/secrets.yaml | 64 +-- package.json | 14 +- packages/state/package.json | 18 + packages/state/src/index.ts | 10 + packages/state/src/onboarding-types.ts | 6 + packages/state/src/onboarding.ts | 61 ++ .../state/src}/ui-state.test.ts | 2 +- .../stores => packages/state/src}/ui-state.ts | 22 +- .../state/src}/view-model.ts | 4 +- packages/state/tsconfig.json | 26 + packages/ui/src/components/button-glow.tsx | 81 ++- packages/ui/src/components/ui/accordion.tsx | 6 +- .../ui/src/components/ui/alert-dialog.tsx | 42 +- packages/ui/src/components/ui/alert.tsx | 14 +- .../ui/src/components/ui/animated-tabs.tsx | 16 +- .../ui/src/components/ui/aspect-ratio.tsx | 4 +- packages/ui/src/components/ui/avatar.tsx | 20 +- .../ui/src/components/ui/badge-button.tsx | 6 +- packages/ui/src/components/ui/badge.tsx | 19 +- packages/ui/src/components/ui/breadcrumb.tsx | 13 +- .../ui/src/components/ui/button-group.tsx | 16 +- packages/ui/src/components/ui/button.tsx | 8 +- packages/ui/src/components/ui/calendar.tsx | 88 +-- packages/ui/src/components/ui/card.tsx | 27 +- packages/ui/src/components/ui/carousel.tsx | 29 +- packages/ui/src/components/ui/chart.tsx | 77 ++- packages/ui/src/components/ui/checkbox.tsx | 7 +- packages/ui/src/components/ui/collapsible.tsx | 18 +- packages/ui/src/components/ui/command.tsx | 47 +- .../ui/src/components/ui/context-menu.tsx | 62 +-- packages/ui/src/components/ui/dialog.tsx | 30 +- packages/ui/src/components/ui/drawer.tsx | 27 +- .../ui/src/components/ui/dropdown-menu.tsx | 97 ++-- packages/ui/src/components/ui/empty.tsx | 22 +- packages/ui/src/components/ui/field.tsx | 69 +-- packages/ui/src/components/ui/form.tsx | 26 +- packages/ui/src/components/ui/hover-card.tsx | 14 +- packages/ui/src/components/ui/input-group.tsx | 88 ++- packages/ui/src/components/ui/input-otp.tsx | 13 +- packages/ui/src/components/ui/input.tsx | 16 +- packages/ui/src/components/ui/item.tsx | 43 +- packages/ui/src/components/ui/kbd.tsx | 2 +- packages/ui/src/components/ui/label.tsx | 7 +- packages/ui/src/components/ui/menubar.tsx | 60 +- .../ui/multi-step-loader-overlay.tsx | 12 +- .../src/components/ui/multi-step-loader.tsx | 11 +- .../ui/src/components/ui/navigation-menu.tsx | 21 +- .../ui/src/components/ui/noise-background.tsx | 22 +- packages/ui/src/components/ui/pagination.tsx | 35 +- packages/ui/src/components/ui/popover.tsx | 14 +- packages/ui/src/components/ui/progress.tsx | 5 +- packages/ui/src/components/ui/radio-group.tsx | 2 +- packages/ui/src/components/ui/resizable.tsx | 11 +- packages/ui/src/components/ui/scroll-area.tsx | 8 +- packages/ui/src/components/ui/select.tsx | 35 +- packages/ui/src/components/ui/separator.tsx | 41 +- packages/ui/src/components/ui/sheet.tsx | 21 +- packages/ui/src/components/ui/sidebar.tsx | 94 +--- packages/ui/src/components/ui/slider.tsx | 2 +- packages/ui/src/components/ui/switch.tsx | 7 +- packages/ui/src/components/ui/table.tsx | 40 +- packages/ui/src/components/ui/tabs.tsx | 28 +- packages/ui/src/components/ui/textarea.tsx | 37 +- .../ui/src/components/ui/toggle-group.tsx | 14 +- packages/ui/src/components/ui/toggle.tsx | 5 +- packages/ui/src/components/ui/tooltip.tsx | 10 +- packages/ui/src/components/ui/ui.test.tsx | 10 +- packages/ui/src/components/ui/use-mobile.tsx | 4 +- tsconfig.json | 5 +- 286 files changed, 6503 insertions(+), 3458 deletions(-) create mode 100644 apps/native/src/components/widget/onboarding/__snapshots__/onboarding-flow.stories.tsx.snap create mode 100644 apps/native/src/components/widget/onboarding/celebration-overlay.tsx create mode 100644 apps/native/src/components/widget/onboarding/index.ts create mode 100644 apps/native/src/components/widget/onboarding/inference/inference-setup.tsx create mode 100644 apps/native/src/components/widget/onboarding/lib/customizations.ts create mode 100644 apps/native/src/components/widget/onboarding/lib/flake-ref.ts create mode 100644 apps/native/src/components/widget/onboarding/lib/inference.ts create mode 100644 apps/native/src/components/widget/onboarding/lib/onboarding.ts create mode 100644 apps/native/src/components/widget/onboarding/onboarding-flow.stories.tsx create mode 100644 apps/native/src/components/widget/onboarding/onboarding-flow.tsx create mode 100644 apps/native/src/components/widget/onboarding/onboarding-header.tsx create mode 100644 apps/native/src/components/widget/onboarding/onboarding-sidebar.tsx create mode 100644 apps/native/src/components/widget/onboarding/onboarding-step-content.tsx create mode 100644 apps/native/src/components/widget/onboarding/source/create-source.tsx create mode 100644 apps/native/src/components/widget/onboarding/source/flake-ref-source.tsx create mode 100644 apps/native/src/components/widget/onboarding/source/github-source.tsx create mode 100644 apps/native/src/components/widget/onboarding/source/local-source.tsx create mode 100644 apps/native/src/components/widget/onboarding/step-shell.tsx create mode 100644 apps/native/src/components/widget/onboarding/stepper.tsx create mode 100644 apps/native/src/components/widget/onboarding/steps/build-step.tsx create mode 100644 apps/native/src/components/widget/onboarding/steps/customizations-step.tsx create mode 100644 apps/native/src/components/widget/onboarding/steps/inference-step.tsx create mode 100644 apps/native/src/components/widget/onboarding/steps/nix-setup-step.tsx create mode 100644 apps/native/src/components/widget/onboarding/steps/permissions-step.tsx create mode 100644 apps/native/src/components/widget/onboarding/steps/setup-step.tsx create mode 100644 apps/native/src/components/widget/onboarding/use-onboarding-flow.ts delete mode 100644 apps/native/src/components/widget/steps/__snapshots__/setup-step.stories.tsx.snap delete mode 100644 apps/native/src/components/widget/steps/nix-setup-step.test.tsx delete mode 100644 apps/native/src/components/widget/steps/nix-setup-step.tsx delete mode 100644 apps/native/src/components/widget/steps/permissions-step.tsx delete mode 100644 apps/native/src/components/widget/steps/setup-step.stories.tsx delete mode 100644 apps/native/src/components/widget/steps/setup-step.test.tsx delete mode 100644 apps/native/src/components/widget/steps/setup-step.tsx create mode 100644 packages/state/package.json create mode 100644 packages/state/src/index.ts create mode 100644 packages/state/src/onboarding-types.ts create mode 100644 packages/state/src/onboarding.ts rename {apps/native/src/stores => packages/state/src}/ui-state.test.ts (98%) rename {apps/native/src/stores => packages/state/src}/ui-state.ts (93%) rename {apps/native/src/stores => packages/state/src}/view-model.ts (96%) create mode 100644 packages/state/tsconfig.json diff --git a/README.md b/README.md index 16ff3ab6d..e53a4e7be 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ git init Copy one of the included templates: | Template | Description | -|----------|-------------| +| ------------------------------------------------------------------------ | -------------------------------------- | | [`nix-darwin-determinate`](apps/native/templates/nix-darwin-determinate) | Minimal nix-darwin for Determinate Nix | | [`nixos-unified`](apps/native/templates/nixos-unified) | Cross-platform (macOS + NixOS) | | [`minimal`](apps/native/templates/minimal) | Bare-bones starting point | @@ -162,7 +162,7 @@ sudo -i nix run nix-darwin/master#darwin-rebuild -- switch --flake ~/.darwin#$HO When you import a nix repository from a zip file, nixmac will perform substitution on the following placeholder strings: | Placeholder | Value | -| ----------- | ----- | +| ---------------------- | -------------------------------------------------- | | `HOSTNAME_PLACEHOLDER` | Hostname of the Mac you're running on | | `PLATFORM_PLACEHOLDER` | Platform architecture of the Mac you're running on | | `USERNAME_PLACEHOLDER` | Current username e.g. `$USER` | @@ -174,7 +174,7 @@ When you import a nix repository from a zip file, nixmac will perform substituti nixmac uses separate models for **evolution** (config changes via tool use) and **summarization** (commit messages, UI labels). | Variable | Default | Description | -|----------|---------|-------------| +| --------------------- | --------------------------- | ----------------------------------------------------------------------- | | `EVOLVE_PROVIDER` | `openrouter` | `openrouter`, `openai`, `ollama`, or `vllm` | | `EVOLVE_MODEL` | `anthropic/claude-sonnet-4` | Model for config evolution | | `SUMMARY_AI_PROVIDER` | `openrouter` | Provider for summarization | diff --git a/apps/native/.storybook/hooks.ts b/apps/native/.storybook/hooks.ts index a96e30b15..91a5eb640 100644 --- a/apps/native/.storybook/hooks.ts +++ b/apps/native/.storybook/hooks.ts @@ -1,17 +1,17 @@ -import { useState, useEffect } from 'react'; -import { addons } from 'storybook/preview-api'; -import { DARK_MODE_EVENT_NAME } from '@vueless/storybook-dark-mode'; +import { useState, useEffect } from "react"; +import { addons } from "storybook/preview-api"; +import { DARK_MODE_EVENT_NAME } from "@vueless/storybook-dark-mode"; -const channel = addons.getChannel() +const channel = addons.getChannel(); /** * Use this hook if you want to pass in your own callback, e.g. Mantine's `setColorScheme` **/ export function useOnDarkModeEvent(callback: (isDarkMode: any) => any) { useEffect(function () { - channel.on(DARK_MODE_EVENT_NAME, callback) - return () => channel.off(DARK_MODE_EVENT_NAME, callback) - }) + channel.on(DARK_MODE_EVENT_NAME, callback); + return () => channel.off(DARK_MODE_EVENT_NAME, callback); + }); } /** @@ -22,8 +22,8 @@ export function useIsDarkMode() { // toggle, not on initial mount. Without this default, DocsContainer would // render light on load (undefined ? dark : light) and stay light until the // user toggles. parameters.darkMode.current: "dark" keeps this in sync. - const [isDarkMode, setIsDarkMode] = useState() - useOnDarkModeEvent(setIsDarkMode) + const [isDarkMode, setIsDarkMode] = useState(); + useOnDarkModeEvent(setIsDarkMode); // useEffect(() => { // if (isDarkMode) { // document.documentElement.classList.add("dark"); @@ -31,5 +31,5 @@ export function useIsDarkMode() { // document.documentElement.classList.remove("dark"); // } // }, [isDarkMode]); - return isDarkMode + return isDarkMode; } diff --git a/apps/native/.storybook/main.ts b/apps/native/.storybook/main.ts index 101acda34..4687b4076 100644 --- a/apps/native/.storybook/main.ts +++ b/apps/native/.storybook/main.ts @@ -7,6 +7,7 @@ const storybookDir = fileURLToPath(new URL(".", import.meta.url)); const appRoot = path.resolve(storybookDir, ".."); const repoRoot = path.resolve(appRoot, "../.."); const uiPackageRoot = path.resolve(repoRoot, "packages/ui/src"); +const statePackageRoot = path.resolve(repoRoot, "packages/state/src"); function withoutMonacoEditorPlugin(plugins: unknown): unknown { if (!Array.isArray(plugins)) return plugins; @@ -20,9 +21,7 @@ function withoutMonacoEditorPlugin(plugins: unknown): unknown { plugin && typeof plugin === "object" && "name" in plugin && - /monaco-editor|moncao-editor/.test( - String((plugin as { name?: unknown }).name), - ) + /monaco-editor|moncao-editor/.test(String((plugin as { name?: unknown }).name)) ) { return []; } @@ -57,6 +56,10 @@ const config: StorybookConfig = { "@/ipc/api": path.resolve(storybookDir, "mocks/ipc-api.ts"), "@/components/ui": path.resolve(uiPackageRoot, "components/ui"), "@nixmac/ui": uiPackageRoot, + "@nixmac/state": statePackageRoot, + "@nixmac/native/ipc/types": path.resolve(appRoot, "src/ipc/types.ts"), + "@nixmac/native/types/feedback": path.resolve(appRoot, "src/types/feedback.ts"), + "@nixmac/native/types/rebuild": path.resolve(appRoot, "src/types/rebuild.ts"), "@tauri-apps/api/core": path.resolve(storybookDir, "mocks/tauri-core.ts"), "@tauri-apps/api/app": path.resolve(storybookDir, "mocks/tauri-app.ts"), "@tauri-apps/api/event": path.resolve(storybookDir, "mocks/tauri-event.ts"), diff --git a/apps/native/.storybook/manager.ts b/apps/native/.storybook/manager.ts index a8f45abe4..39cf225f1 100644 --- a/apps/native/.storybook/manager.ts +++ b/apps/native/.storybook/manager.ts @@ -1,5 +1,5 @@ import { addons } from "storybook/manager-api"; -import theme from "./theme" +import theme from "./theme"; addons.setConfig({ - theme -}) + theme, +}); diff --git a/apps/native/.storybook/mocks/monaco-react.tsx b/apps/native/.storybook/mocks/monaco-react.tsx index 0e28667a1..6ca17c78d 100644 --- a/apps/native/.storybook/mocks/monaco-react.tsx +++ b/apps/native/.storybook/mocks/monaco-react.tsx @@ -45,10 +45,21 @@ export function Editor({ return (
-
+
@@ -67,10 +78,19 @@ export function DiffEditor({ height, wrapperProps, beforeMount, onMount }: DiffE return (
-
+
diff --git a/apps/native/.storybook/mocks/tauri-runtime.ts b/apps/native/.storybook/mocks/tauri-runtime.ts index ef2209b7f..2276daa18 100644 --- a/apps/native/.storybook/mocks/tauri-runtime.ts +++ b/apps/native/.storybook/mocks/tauri-runtime.ts @@ -152,17 +152,19 @@ function emit(eventName: string, payload: unknown) { function addListener(eventName: string, handler: (event: { payload: T }) => void, once = false) { const wrapped = once - ? ((event: { payload: T }) => { + ? (event: { payload: T }) => { handler(event); removeListener(eventName, wrapped as (event: { payload: unknown }) => void); - }) + } : (handler as (event: { payload: unknown }) => void); const eventListeners = listeners.get(eventName) ?? new Set(); eventListeners.add(wrapped as (event: { payload: unknown }) => void); listeners.set(eventName, eventListeners); - return Promise.resolve(() => removeListener(eventName, wrapped as (event: { payload: unknown }) => void)); + return Promise.resolve(() => + removeListener(eventName, wrapped as (event: { payload: unknown }) => void), + ); } function removeListener(eventName: string, handler: (event: { payload: unknown }) => void) { @@ -234,7 +236,7 @@ export async function invoke(command: string, args?: Record) { // mounting the widget would clobber the state a story/its controls just // applied (or crash on a `null` payload the mirrors don't expect). This // mirrors the unidirectional-sync contract: hydrate = read the latest cell. - const { useViewModel } = await import("../../src/stores/view-model"); + const { useViewModel } = await import("@nixmac/state"); const vm = useViewModel.getState(); switch (command) { @@ -310,7 +312,8 @@ export async function invoke(command: string, args?: Record) { export const tauriEvent = { listen: addListener, - once: (eventName: string, handler: (event: { payload: T }) => void) => addListener(eventName, handler, true), + once: (eventName: string, handler: (event: { payload: T }) => void) => + addListener(eventName, handler, true), emit, }; @@ -324,7 +327,7 @@ export const storybookTauriAPI = { git: { status: async () => baseGitStatus(), statusAndCache: async () => { - const { useViewModel } = await import("../../src/stores/view-model"); + const { useViewModel } = await import("@nixmac/state"); return useViewModel.getState().git ?? baseGitStatus(); }, cached: async () => baseGitStatus(), @@ -344,7 +347,16 @@ export const storybookTauriAPI = { gitStatus: baseGitStatus(), evolveState: baseEvolveState(), conversationalResponse: null, - telemetry: { state: "generated" as const, iterations: 1, buildAttempts: 1, totalTokens: 500, editsCount: 1, thinkingCount: 1, toolCallsCount: 3, durationMs: 5000 }, + telemetry: { + state: "generated" as const, + iterations: 1, + buildAttempts: 1, + totalTokens: 500, + editsCount: 1, + thinkingCount: 1, + toolCallsCount: 3, + durationMs: 5000, + }, }), evolveAnswer: async () => okResult(), evolveCancel: async () => ({ ok: true, message: "Cancelled" }), @@ -393,12 +405,12 @@ export const storybookTauriAPI = { }, summarizedChanges: { findChangeMap: async () => { - const { useViewModel } = await import("../../src/stores/view-model"); + const { useViewModel } = await import("@nixmac/state"); return useViewModel.getState().changeMap ?? baseSemanticChangeMap(); }, summarizeCurrent: async () => baseSemanticChangeMap(), generateCommitMessage: async () => { - const { useUiState } = await import("../../src/stores/ui-state"); + const { useUiState } = await import("@nixmac/state"); return useUiState.getState().commitMessageSuggestion ?? "chore: mock commit message"; }, }, @@ -455,14 +467,20 @@ export const storybookTauriAPI = { scanner: { getRecommendedPrompt: async () => null, scanDefaults: async () => ({ defaults: [], totalScanned: 0 }), - applyDefaults: async () => ({ ok: true, count: 0, changeMap: baseSemanticChangeMap(), gitStatus: baseGitStatus(), evolveState: baseEvolveState() }), + applyDefaults: async () => ({ + ok: true, + count: 0, + changeMap: baseSemanticChangeMap(), + gitStatus: baseGitStatus(), + evolveState: baseEvolveState(), + }), }, evolveState: { get: async () => { // Return the store's current evolve state so init doesn't overwrite story state. // Dynamic import avoids circular dep at module-evaluation time; by the time // this async method is called the store module is fully initialized. - const { useViewModel } = await import("../../src/stores/view-model"); + const { useViewModel } = await import("@nixmac/state"); return useViewModel.getState().evolve ?? baseEvolveState(); }, clear: async () => baseEvolveState(), @@ -484,16 +502,26 @@ export const storybookTauriAPI = { permissions: { checkAll: async () => ({ permissions: permissions.map((permission) => ({ ...permission })), - allRequiredGranted: permissions.filter((permission) => permission.required).every((permission) => permission.status === "granted"), + allRequiredGranted: permissions + .filter((permission) => permission.required) + .every((permission) => permission.status === "granted"), checkedAt: Date.now(), }), request: async (permissionId: string) => { permissions = permissions.map((permission) => permission.id === permissionId ? { ...permission, status: "granted" } : permission, ); - return permissions.find((permission) => permission.id === permissionId) ?? { id: permissionId, status: "granted" }; + return ( + permissions.find((permission) => permission.id === permissionId) ?? { + id: permissionId, + status: "granted", + } + ); }, - allRequiredGranted: async () => permissions.filter((permission) => permission.required).every((permission) => permission.status === "granted"), + allRequiredGranted: async () => + permissions + .filter((permission) => permission.required) + .every((permission) => permission.status === "granted"), checkFullDiskAccess: async () => true, requestFullDiskAccess: async () => { permissions = permissions.map((permission) => @@ -515,7 +543,13 @@ export const storybookTauriAPI = { source: null, lastChecked: Date.now(), }), - applyDiff: async () => ({ ok: true, count: 0, changeMap: baseSemanticChangeMap(), gitStatus: baseGitStatus(), evolveState: baseEvolveState() }), + applyDiff: async () => ({ + ok: true, + count: 0, + changeMap: baseSemanticChangeMap(), + gitStatus: baseGitStatus(), + evolveState: baseEvolveState(), + }), }, debug: { logBreadcrumb: async () => okResult(), diff --git a/apps/native/.storybook/preview.tsx b/apps/native/.storybook/preview.tsx index c8d20fe31..93492b22c 100644 --- a/apps/native/.storybook/preview.tsx +++ b/apps/native/.storybook/preview.tsx @@ -30,17 +30,13 @@ const withViewModelBypass: Decorator = (Story) => { const withDarkTheme: Decorator = (Story) => { useEffect(() => { document.documentElement.classList.add("dark"); - const sbRoot = document.getElementsByClassName( - 'sb-show-main', - )[0] as HTMLElement; - if (sbRoot) { - sbRoot.style.backgroundColor = darkTheme.appBg; - } + const sbRoot = document.getElementsByClassName("sb-show-main")[0] as HTMLElement; + if (sbRoot) { + sbRoot.style.backgroundColor = darkTheme.appBg; + } return () => { document.documentElement.classList.remove("dark"); - const sbRoot = document.getElementsByClassName( - 'sb-show-main', - )[0] as HTMLElement; + const sbRoot = document.getElementsByClassName("sb-show-main")[0] as HTMLElement; if (sbRoot) { sbRoot.style.backgroundColor = ""; } @@ -50,14 +46,11 @@ const withDarkTheme: Decorator = (Story) => { return ; }; - // CI-only: when capturing screenshots of failed snapshot stories, this regex // (built from the failed story names by scripts/resolve-failed-stories.mjs) is // injected at build time so Creevey skips every story whose name is NOT in the // failed set. Unset in normal builds, so this is a no-op for dev/Vitest. -const creeveySkipRegex = import.meta.env.VITE_CREEVEY_SKIP_REGEX as - | string - | undefined; +const creeveySkipRegex = import.meta.env.VITE_CREEVEY_SKIP_REGEX as string | undefined; const creeveyParameters = creeveySkipRegex ? { diff --git a/apps/native/.storybook/theme.ts b/apps/native/.storybook/theme.ts index 341a55bb4..f5739e733 100644 --- a/apps/native/.storybook/theme.ts +++ b/apps/native/.storybook/theme.ts @@ -1,4 +1,4 @@ -import { create } from "storybook/theming" +import { create } from "storybook/theming"; export default create({ base: "dark", @@ -6,4 +6,4 @@ export default create({ appPreviewBg: "#0c0c0e", appContentBg: "#0c0c0e", barBg: "#0c0c0e", -}) +}); diff --git a/apps/native/.storybook/vitest.setup.ts b/apps/native/.storybook/vitest.setup.ts index 00b746e4d..8ca6713ce 100644 --- a/apps/native/.storybook/vitest.setup.ts +++ b/apps/native/.storybook/vitest.setup.ts @@ -19,9 +19,10 @@ declare global { window.MonacoEnvironment = { getWorker(_workerId, label) { - const workerUrl = label === "json" - ? new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url) - : new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url); + const workerUrl = + label === "json" + ? new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url) + : new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url); return new Worker(workerUrl, { type: "module" }); }, @@ -32,27 +33,29 @@ const preview = await import("./preview"); beforeAll(preview.default.composed.beforeAll); function normalizeAnimations(html: string): string { - return html - .replace(/transform:\s*[^;"]+/g, "transform: MOTION") - .replace(/opacity:\s*[^;"]+/g, "opacity: MOTION") - // Animated gradients (e.g. the evolve/processing shimmer) sweep their - // `circle at px px` center every frame — stabilize the coordinates - // (the swept x can go negative, hence the optional sign). - .replace(/circle at -?[\d.]+px -?[\d.]+px/g, "circle at MOTIONpx MOTIONpx") - .replace(/translateY\(([^)]+)\)/g, (_match, val) => { - const rounded = Math.round(Number.parseFloat(val)); - const stableOffset = rounded >= 9 && rounded <= 11 ? 10 : rounded; - return `translateY(${stableOffset}px)`; - }) - .replace(/translateX\(([^)]+)\)/g, (_match, val) => { - return `translateX(${Math.round(Number.parseFloat(val))}px)`; - }) - .replace(/scale\(([^)]+)\)/g, (_match, val) => { - return `scale(${Math.round(Number.parseFloat(val) * 100) / 100})`; - }) - .replace(/opacity:\s*([\d.]+)/g, (_match, val) => { - return `opacity: ${Math.round(Number.parseFloat(val) * 100) / 100}`; - }); + return ( + html + .replace(/transform:\s*[^;"]+/g, "transform: MOTION") + .replace(/opacity:\s*[^;"]+/g, "opacity: MOTION") + // Animated gradients (e.g. the evolve/processing shimmer) sweep their + // `circle at px px` center every frame — stabilize the coordinates + // (the swept x can go negative, hence the optional sign). + .replace(/circle at -?[\d.]+px -?[\d.]+px/g, "circle at MOTIONpx MOTIONpx") + .replace(/translateY\(([^)]+)\)/g, (_match, val) => { + const rounded = Math.round(Number.parseFloat(val)); + const stableOffset = rounded >= 9 && rounded <= 11 ? 10 : rounded; + return `translateY(${stableOffset}px)`; + }) + .replace(/translateX\(([^)]+)\)/g, (_match, val) => { + return `translateX(${Math.round(Number.parseFloat(val))}px)`; + }) + .replace(/scale\(([^)]+)\)/g, (_match, val) => { + return `scale(${Math.round(Number.parseFloat(val) * 100) / 100})`; + }) + .replace(/opacity:\s*([\d.]+)/g, (_match, val) => { + return `opacity: ${Math.round(Number.parseFloat(val) * 100) / 100}`; + }) + ); } function normalizeSnapshotRoot(root: Element): string { @@ -88,7 +91,7 @@ function normalizeSnapshotRoot(root: Element): string { "style", style .replace(/width:\s*[^;"]+/g, "width: MONACO") - .replace(/height:\s*[^;"]+/g, "height: MONACO") + .replace(/height:\s*[^;"]+/g, "height: MONACO"), ); } @@ -105,7 +108,7 @@ function normalizeSnapshotRoot(root: Element): string { // Monaco can emit these attributes in either order across runs. html = html.replace( /
/g, - '
' + '
', ); html = html.replace(/ style="--cmdk-list-height:[^"]*"/g, ""); return html; @@ -113,7 +116,7 @@ function normalizeSnapshotRoot(root: Element): string { function cleanupMonacoAccessibilityContainers(): void { for (const container of document.body.querySelectorAll( - ":scope > .monaco-alert, :scope > .monaco-status" + ":scope > .monaco-alert, :scope > .monaco-status", )) { container.remove(); } @@ -122,9 +125,7 @@ function cleanupMonacoAccessibilityContainers(): void { // Automatically snapshot every story after it renders afterEach(() => { try { - const containers = document.body.querySelectorAll( - ":scope > div:not(.sb-wrapper)" - ); + const containers = document.body.querySelectorAll(":scope > div:not(.sb-wrapper)"); const root = containers[containers.length - 1]; if (root?.innerHTML) { expect(normalizeSnapshotRoot(root)).toMatchSnapshot(); diff --git a/apps/native/app/globals.css b/apps/native/app/globals.css index 4cd0aac92..c6ee5648c 100644 --- a/apps/native/app/globals.css +++ b/apps/native/app/globals.css @@ -125,4 +125,4 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +} diff --git a/apps/native/src/components/nix-editor/index.tsx b/apps/native/src/components/nix-editor/index.tsx index 6e2914d50..ecfb6aa0f 100644 --- a/apps/native/src/components/nix-editor/index.tsx +++ b/apps/native/src/components/nix-editor/index.tsx @@ -37,9 +37,7 @@ export function NixEditor({ filePath, onSave, className, disableRuntime = false )}
{lspStatus === "running" && ( -
- nixd -
+
nixd
)} {lspStatus === "error" && (
diff --git a/apps/native/src/components/nix-editor/use-nix-editor.test.ts b/apps/native/src/components/nix-editor/use-nix-editor.test.ts index 1d286bb7b..a9bd58679 100644 --- a/apps/native/src/components/nix-editor/use-nix-editor.test.ts +++ b/apps/native/src/components/nix-editor/use-nix-editor.test.ts @@ -38,28 +38,30 @@ const h = vi.hoisted(() => { editorValue: "", }; - const monacoCreate = vi.fn((_container: HTMLElement, options: { value: string, language?: string }) => { - state.editorValue = options.value; - state.changeHandler = null; - state.saveCommandHandler = null; - - const editor: FakeEditor = { - getValue: vi.fn(() => state.editorValue), - onDidChangeModelContent: vi.fn((fn: () => void) => { - state.changeHandler = fn; - return { dispose: vi.fn() }; - }), - addCommand: vi.fn((_keybinding: number, fn: () => void | Promise) => { - state.saveCommandHandler = fn; - }), - dispose: vi.fn(), - getModel: vi.fn(() => ({})), - _triggerChange: () => state.changeHandler?.(), - _triggerSaveCommand: () => state.saveCommandHandler?.(), - }; - state.lastEditor = editor; - return editor; - }); + const monacoCreate = vi.fn( + (_container: HTMLElement, options: { value: string; language?: string }) => { + state.editorValue = options.value; + state.changeHandler = null; + state.saveCommandHandler = null; + + const editor: FakeEditor = { + getValue: vi.fn(() => state.editorValue), + onDidChangeModelContent: vi.fn((fn: () => void) => { + state.changeHandler = fn; + return { dispose: vi.fn() }; + }), + addCommand: vi.fn((_keybinding: number, fn: () => void | Promise) => { + state.saveCommandHandler = fn; + }), + dispose: vi.fn(), + getModel: vi.fn(() => ({})), + _triggerChange: () => state.changeHandler?.(), + _triggerSaveCommand: () => state.saveCommandHandler?.(), + }; + state.lastEditor = editor; + return editor; + }, + ); const initNixGrammar = vi.fn(async () => {}); const bridgeMonacoToLsp = vi.fn(() => vi.fn()); @@ -107,7 +109,8 @@ vi.mock("monaco-editor", () => ({ })); vi.mock("@/lib/nix-grammar", () => ({ - initNixGrammar: (...args: unknown[]) => h.initNixGrammar.apply(null, args as Parameters), + initNixGrammar: (...args: unknown[]) => + h.initNixGrammar.apply(null, args as Parameters), })); vi.mock("@/lib/lsp-client", () => ({ @@ -115,7 +118,8 @@ vi.mock("@/lib/lsp-client", () => ({ })); vi.mock("@/lib/lsp-monaco-bridge", () => ({ - bridgeMonacoToLsp: (...args: unknown[]) => h.bridgeMonacoToLsp(...(args as Parameters)), + bridgeMonacoToLsp: (...args: unknown[]) => + h.bridgeMonacoToLsp(...(args as Parameters)), })); vi.mock("@/ipc/api", () => ({ @@ -225,9 +229,7 @@ describe("useNixEditor", () => { it("skips LSP startup for non-Nix files and infers language from the extension", async () => { const { ref } = makeContainerRef(); - const { result } = renderHook(() => - useNixEditor({ filePath: "README.md", containerRef: ref }), - ); + const { result } = renderHook(() => useNixEditor({ filePath: "README.md", containerRef: ref })); await waitFor(() => { expect(result.current.isLoading).toBe(false); @@ -340,9 +342,7 @@ describe("useNixEditor", () => { const onSave = vi.fn(); const { ref } = makeContainerRef(); - renderHook(() => - useNixEditor({ filePath: "configuration.nix", containerRef: ref, onSave }), - ); + renderHook(() => useNixEditor({ filePath: "configuration.nix", containerRef: ref, onSave })); await waitFor(() => expect(h.state.lastEditor).not.toBeNull()); await waitFor(() => expect(h.state.saveCommandHandler).not.toBeNull()); diff --git a/apps/native/src/components/nix-editor/use-nix-editor.ts b/apps/native/src/components/nix-editor/use-nix-editor.ts index f232d6d48..824a11eff 100644 --- a/apps/native/src/components/nix-editor/use-nix-editor.ts +++ b/apps/native/src/components/nix-editor/use-nix-editor.ts @@ -13,7 +13,12 @@ interface UseNixEditorOptions { disabled?: boolean; } -export function useNixEditor({ filePath, containerRef, onSave, disabled = false }: UseNixEditorOptions) { +export function useNixEditor({ + filePath, + containerRef, + onSave, + disabled = false, +}: UseNixEditorOptions) { const editorRef = useRef(null); const [isLoading, setIsLoading] = useState(!disabled); const [isDirty, setIsDirty] = useState(false); @@ -51,10 +56,7 @@ export function useNixEditor({ filePath, containerRef, onSave, disabled = false // Load file content (and config dir when not disabled) const [content, config] = disabled ? [await tauriAPI.editor.readFile(filePath), null as { configDir: string } | null] - : await Promise.all([ - tauriAPI.editor.readFile(filePath), - tauriAPI.config.get(), - ]); + : await Promise.all([tauriAPI.editor.readFile(filePath), tauriAPI.config.get()]); if (disposed) return; originalContentRef.current = content; @@ -110,11 +112,14 @@ export function useNixEditor({ filePath, containerRef, onSave, disabled = false // Cmd+S to save editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { const content = editor!.getValue(); - tauriAPI.editor.writeFile(filePath, content).then(() => { - originalContentRef.current = content; - setIsDirty(false); - onSave?.(content); - }).catch((e) => setError(String(e))); + tauriAPI.editor + .writeFile(filePath, content) + .then(() => { + originalContentRef.current = content; + setIsDirty(false); + onSave?.(content); + }) + .catch((e) => setError(String(e))); }); } diff --git a/apps/native/src/components/nixmac-mascot/NixmacMascot3D.tsx b/apps/native/src/components/nixmac-mascot/NixmacMascot3D.tsx index f2d6914c2..48453e0ab 100644 --- a/apps/native/src/components/nixmac-mascot/NixmacMascot3D.tsx +++ b/apps/native/src/components/nixmac-mascot/NixmacMascot3D.tsx @@ -45,7 +45,12 @@ function sampleHop(t: number): { yPct: number; rotDeg: number; sx: number; sy: n const span = b[0] - a[0] || 1; const f = Math.min(Math.max((t - a[0]) / span, 0), 1); const lerp = (x: number, y: number) => x + (y - x) * f; - return { yPct: lerp(a[1], b[1]), rotDeg: lerp(a[2], b[2]), sx: lerp(a[3], b[3]), sy: lerp(a[4], b[4]) }; + return { + yPct: lerp(a[1], b[1]), + rotDeg: lerp(a[2], b[2]), + sx: lerp(a[3], b[3]), + sy: lerp(a[4], b[4]), + }; } /** Rasterize a raw SVG string into an sRGB CanvasTexture (alpha preserved). */ diff --git a/apps/native/src/components/nixmac-mascot/README.md b/apps/native/src/components/nixmac-mascot/README.md index 9c40c91cb..6e0689442 100644 --- a/apps/native/src/components/nixmac-mascot/README.md +++ b/apps/native/src/components/nixmac-mascot/README.md @@ -25,7 +25,7 @@ bun add lottie-react ```tsx import { NixmacMascotLottie } from "@/components/nixmac-mascot/NixmacMascotLottie"; - // speed={2.5} to surface the hop quickly +; // speed={2.5} to surface the hop quickly ``` ## Option B — SVG + CSS (lightest) @@ -35,12 +35,12 @@ Best if you only need it in this app and don't need the portable .json. ```tsx import { NixmacMascot } from "@/components/nixmac-mascot/NixmacMascot"; - +; ``` ## Tuning the motion -The animation *is* the design decision — both paths expose a "personality" block: +The animation _is_ the design decision — both paths expose a "personality" block: - **Lottie:** edit the `PERSONALITY` constants at the top of `build_lottie.py` (`LOOP_S` = hop cadence, `JUMP_HEIGHT`, `SPIN_DEG`, `SQUASH`/`STRETCH`, …), then diff --git a/apps/native/src/components/nixmac-mascot/nixmac-mascot.css b/apps/native/src/components/nixmac-mascot/nixmac-mascot.css index c8f92a73d..3766e2ca1 100644 --- a/apps/native/src/components/nixmac-mascot/nixmac-mascot.css +++ b/apps/native/src/components/nixmac-mascot/nixmac-mascot.css @@ -42,7 +42,6 @@ } @media (prefers-reduced-motion: no-preference) { - .nixmac-mascot #eye-left, .nixmac-mascot #eye-right { transform-box: fill-box; @@ -78,7 +77,6 @@ } @keyframes nixmac-blink { - 0%, 88%, 100% { @@ -91,7 +89,6 @@ } @keyframes nixmac-smile { - 0%, 100% { transform: translateY(0) scaleX(1); @@ -103,7 +100,6 @@ } @keyframes nixmac-blush { - 0%, 100% { opacity: 0.8; @@ -115,7 +111,6 @@ } @keyframes nixmac-pulse { - 0%, 100% { opacity: var(--pulse-dim); @@ -129,7 +124,6 @@ /* translateY is in % so the hop height scales with the rendered size. Ends on rotate(360deg); the loop's reset to 0deg is visually identical. */ @keyframes nixmac-hop { - 0%, 68% { transform: translateY(0) rotate(0) scale(1, 1); @@ -164,4 +158,4 @@ 100% { transform: translateY(0) rotate(360deg) scale(1, 1); } -} \ No newline at end of file +} diff --git a/apps/native/src/components/preview-indicator/preview-indicator.tsx b/apps/native/src/components/preview-indicator/preview-indicator.tsx index c4a8d494a..0207d42c9 100644 --- a/apps/native/src/components/preview-indicator/preview-indicator.tsx +++ b/apps/native/src/components/preview-indicator/preview-indicator.tsx @@ -90,9 +90,7 @@ export function PreviewIndicator({ {/* Header */}
- - Preview Mode - + Preview Mode {filesChanged} file{filesChanged !== 1 ? "s" : ""} changed @@ -101,18 +99,13 @@ export function PreviewIndicator({ {/* Summary */} {summary && (
-

- {summary} -

+

{summary}

)} {/* Commit message input */}
-

- Three new apps for communication and project management, plus - improvements to your code editor that make it easier to read and - navigate your work. + Three new apps for communication and project management, plus improvements to your code + editor that make it easier to read and navigate your work.

diff --git a/apps/native/src/components/styles/stepper-wizard-style.tsx b/apps/native/src/components/styles/stepper-wizard-style.tsx index 9d29abb72..b094aee31 100644 --- a/apps/native/src/components/styles/stepper-wizard-style.tsx +++ b/apps/native/src/components/styles/stepper-wizard-style.tsx @@ -109,25 +109,17 @@ export function StepperWizardStyle() { : "bg-muted text-muted-foreground" }`} > - {currentStep > step.id ? ( - - ) : ( - step.id - )} + {currentStep > step.id ? : step.id}

= step.id - ? "text-foreground" - : "text-muted-foreground" + currentStep >= step.id ? "text-foreground" : "text-muted-foreground" }`} > {step.name}

-

- {step.description} -

+

{step.description}

{i < steps.length - 1 && ( @@ -150,10 +142,7 @@ export function StepperWizardStyle() {
{changeCategories.map((cat) => ( -
+

{cat.title}

-

- {cat.count} changes -

+

{cat.count} changes

    {cat.items.map((item, i) => ( -
  • +
  • {item}
  • @@ -284,9 +268,7 @@ export function StepperWizardStyle() {

{action.name}

-

- {action.desc} -

+

{action.desc}

))}
@@ -312,10 +294,7 @@ export function StepperWizardStyle() { ) : ( -
@@ -215,9 +206,7 @@ export function VercelListStyle() {
- - Waiting for action... - + Waiting for action...
diff --git a/apps/native/src/components/tabs-content-1.tsx b/apps/native/src/components/tabs-content-1.tsx index 4e414bf0f..5c4f24f07 100644 --- a/apps/native/src/components/tabs-content-1.tsx +++ b/apps/native/src/components/tabs-content-1.tsx @@ -25,11 +25,7 @@ const Example = () => (
- +
@@ -56,11 +52,7 @@ const Example = () => (
- +
@@ -68,10 +60,7 @@ const Example = () => (
-

No OpenAI API key set (Evolution and Summary providers) .

Applying changes

Updating your configuration and preparing the review step.

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

nixmac

Describe

What to change

Review

2

Check & test

Save

3

Keep changes

Ready to test-drive your changes?

What's changed

What else can I change for you?

No OpenAI API key set (Evolution and Summary providers) .

Applying changes

Updating your configuration and preparing the review step.

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

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

Active Changes

System Settings (4)
Keyboard (1)

Commit Changes

Console
"`; +exports[`Commit Screen 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; -exports[`Committing 1`] = `"

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

Active Changes

Commit Changes

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

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

Active Changes

Commit Changes

Console
"`; -exports[`Console With Output 1`] = `"

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

Active Changes

System Settings (4)
Keyboard (1)

Commit Changes

Console
"`; +exports[`Console With Output 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; -exports[`Evolving 1`] = `"

nixmac

Describe

What to change

Review

2

Check & test

Save

3

Keep changes

Ready to test-drive your changes?

What's changed

System Settings (4)
Keyboard (1)

What else can I change for you?

No OpenAI API key set (Evolution and Summary providers) .

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

nixmac

Describe

What to change

Review

2

Check & test

Save

3

Keep changes

Ready to test-drive your changes?

What's changed

System Settings (4)
Keyboard (1)

What else can I change for you?

No OpenAI API key set (Evolution and Summary providers) .

Console
"`; -exports[`Evolving Ready To Commit 1`] = `"

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

Active Changes

System Settings (4)
Keyboard (1)

Commit Changes

Console
"`; +exports[`Evolving Ready To Commit 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; -exports[`Evolving With Unstaged Changes 1`] = `"

nixmac

Describe

What to change

Review

2

Check & test

Save

3

Keep changes

Ready to test-drive your changes?

What's changed

What else can I change for you?

No OpenAI API key set (Evolution and Summary providers) .

Console
"`; +exports[`Evolving With Unstaged Changes 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; -exports[`Feedback Open 1`] = `"

Give feedback

Help us make nixmac better

"`; +exports[`Feedback Open 1`] = `"

Give feedback

Help us make nixmac better

"`; -exports[`Filesystem View 1`] = `"

nixmac

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.
Console
"`; +exports[`Filesystem View 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; exports[`Generating 1`] = `"

nixmac

Get started

No OpenAI 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

No OpenAI API key set (Evolution and Summary providers) .

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

nixmac

History

0
Console
"`; +exports[`History View 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; exports[`Idle 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No OpenAI 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 OpenAI API key set (Evolution and Summary providers) .

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

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

What's changed

System Settings (4)
Keyboard (1)

Commit Changes

Console
"`; +exports[`Manual Commit 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; -exports[`Manual Evolve 1`] = `"

nixmac

Describe

What to change

Review

2

Check & test

Save

3

Keep changes

Uncommitted changes

What's changed

No OpenAI API key set (Evolution and Summary providers) .

Console
"`; +exports[`Manual Evolve 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; -exports[`Many Changed Files 1`] = `"

nixmac

Describe

What to change

Review

2

Check & test

Save

3

Keep changes

Ready to test-drive your changes?

What's changed

System Settings (4)
Keyboard (1)

What else can I change for you?

No OpenAI API key set (Evolution and Summary providers) .

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

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; -exports[`Onboarding 1`] = `"

nixmac

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.


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

Step 3 of 6

Set up your configuration

nixmac manages your Mac through a Nix flake. Do you already have one, or are you starting fresh?

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

nixmac

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


Select your nix-darwin host configuration

Console
"`; -exports[`Permissions Required 1`] = `"

nixmac

System Permissions

Grant the following permissions to continue

Full Disk Access

Required Pending

Recommended for complete system management capabilities

Go to System Settings → Privacy & Security → Full Disk Access and add nixmac.

Administrator Privileges

Required Granted

Required to install system packages and modify system configurations

You will be prompted for your password when needed

Grant all required permissions to continue

Console
"`; +exports[`Permissions Required 1`] = `"
nixmac

Step 1 of 6

System Permissions

nixmac needs a few macOS permissions before it can read your configuration and apply changes. Grant the required ones to continue — we’ll move on automatically.

  • Full Disk AccessRequired

    Recommended for complete system management capabilities

    Go to System Settings → Privacy & Security → Full Disk Access and add nixmac.

  • Administrator PrivilegesRequired

    Required to install system packages and modify system configurations

    You will be prompted for your password when needed

    Granted

Administrator privileges are requested with a password prompt only when a change needs them. Full Disk Access is optional but recommended for the smoothest experience.

Console
"`; exports[`Playground 1`] = `"

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No OpenAI API key set (Evolution and Summary providers) .

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

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

Active Changes

System Settings (4)
Keyboard (1)

Commit Changes

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

nixmac

Describe

What to change

Review

Check & test

Save

3

Keep changes

All changes active!

Active Changes

System Settings (4)
Keyboard (1)

Commit Changes

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

nixmac

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

Get started

No OpenAI API key set (Evolution and Summary providers) .

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

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
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

Describe

1

What to change

Review

2

Check & test

Save

3

Keep changes

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

Get started

No OpenAI API key set (Evolution and Summary providers) .

Console
"`; +exports[`With Error 1`] = `"
nixmac

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
Console
"`; diff --git a/apps/native/src/components/widget/badges/badge-list.tsx b/apps/native/src/components/widget/badges/badge-list.tsx index e45241d05..a1f1916f2 100644 --- a/apps/native/src/components/widget/badges/badge-list.tsx +++ b/apps/native/src/components/widget/badges/badge-list.tsx @@ -1,9 +1,5 @@ import type { ReactNode } from "react"; export function BadgeList({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); + return
{children}
; } diff --git a/apps/native/src/components/widget/badges/config-dir-badge.stories.tsx b/apps/native/src/components/widget/badges/config-dir-badge.stories.tsx index e5541ef0c..3187f0eab 100644 --- a/apps/native/src/components/widget/badges/config-dir-badge.stories.tsx +++ b/apps/native/src/components/widget/badges/config-dir-badge.stories.tsx @@ -23,7 +23,8 @@ export const CustomDir = meta.story({ export const InlineInText = meta.story({ render: () => (

- Content of may be seen by your AI provider. + Content of may be seen by your AI + provider.

), }); diff --git a/apps/native/src/components/widget/badges/time-badge.tsx b/apps/native/src/components/widget/badges/time-badge.tsx index 92d7fc2d0..882d60187 100644 --- a/apps/native/src/components/widget/badges/time-badge.tsx +++ b/apps/native/src/components/widget/badges/time-badge.tsx @@ -1,9 +1,5 @@ import { formatRelativeTime } from "@/components/widget/utils"; export function TimeBadge({ createdAt }: { createdAt: number }) { - return ( - - {formatRelativeTime(createdAt)} - - ); + return {formatRelativeTime(createdAt)}; } diff --git a/apps/native/src/components/widget/controls/bootstrap-config.tsx b/apps/native/src/components/widget/controls/bootstrap-config.tsx index 29e89d382..93663fd0b 100644 --- a/apps/native/src/components/widget/controls/bootstrap-config.tsx +++ b/apps/native/src/components/widget/controls/bootstrap-config.tsx @@ -3,8 +3,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useDarwinConfig } from "@/hooks/use-darwin-config"; -import { useViewModel } from "@/stores/view-model"; -import { useUiState } from "@/stores/ui-state"; +import { useViewModel } from "@nixmac/state"; +import { useUiState } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import { AlertCircle, GitCommit, Sparkles } from "lucide-react"; import { useEffect, useState } from "react"; @@ -63,7 +63,10 @@ export function BootstrapConfig({ setFlakeExists(false); return; } - tauriAPI.flake.existsAt(configDir).then(setFlakeExists).catch(() => setFlakeExists(false)); + tauriAPI.flake + .existsAt(configDir) + .then(setFlakeExists) + .catch(() => setFlakeExists(false)); }, [configDir]); const needsInitialCommit = diff --git a/apps/native/src/components/widget/controls/build-head-button.tsx b/apps/native/src/components/widget/controls/build-head-button.tsx index 38803a5f3..3d99a9ba8 100644 --- a/apps/native/src/components/widget/controls/build-head-button.tsx +++ b/apps/native/src/components/widget/controls/build-head-button.tsx @@ -1,7 +1,7 @@ import { Wrench } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { useApply } from "@/hooks/use-apply"; interface BuildHeadButtonProps { @@ -20,7 +20,8 @@ export function BuildHeadButton({ isRestoring = false }: BuildHeadButtonProps) { disabled={isRestoring} className={cn( "h-auto whitespace-nowrap border-white/10 bg-white/[0.06] px-[10px] py-1 text-[10px] text-neutral-400 hover:border-white/30", - uncommittedChanges && "opacity-40 cursor-default hover:border-white/10 hover:bg-white/[0.06] hover:text-neutral-400", + uncommittedChanges && + "opacity-40 cursor-default hover:border-white/10 hover:bg-white/[0.06] hover:text-neutral-400", )} onClick={(e) => { e.stopPropagation(); diff --git a/apps/native/src/components/widget/controls/commit-message.tsx b/apps/native/src/components/widget/controls/commit-message.tsx index 13552233a..c6fee2538 100644 --- a/apps/native/src/components/widget/controls/commit-message.tsx +++ b/apps/native/src/components/widget/controls/commit-message.tsx @@ -11,9 +11,7 @@ export function CommitMessage({ hash, message, originMessage }: CommitMessagePro {message ?? `Commit ${hash}`} {originMessage && ( -

- {originMessage} -

+

{originMessage}

)} ); diff --git a/apps/native/src/components/widget/controls/confirm-button.tsx b/apps/native/src/components/widget/controls/confirm-button.tsx index 5e0205141..b905b1387 100644 --- a/apps/native/src/components/widget/controls/confirm-button.tsx +++ b/apps/native/src/components/widget/controls/confirm-button.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { CheckConfirmationOff } from "@/components/widget/controls/check-confirmation-off"; import { ConfirmationDialog } from "@/components/widget/controls/confirmation-dialog"; import { usePrefs } from "@/hooks/use-prefs"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import type { ConfirmPrefKey } from "@/types/preferences"; import type { ComponentProps } from "react"; import { useState } from "react"; diff --git a/apps/native/src/components/widget/controls/confirmation-dialog.tsx b/apps/native/src/components/widget/controls/confirmation-dialog.tsx index 9ff98fec7..992a87b24 100644 --- a/apps/native/src/components/widget/controls/confirmation-dialog.tsx +++ b/apps/native/src/components/widget/controls/confirmation-dialog.tsx @@ -64,9 +64,7 @@ export function ConfirmationDialog({ Confirm Action - - {message} - + {message} {children} diff --git a/apps/native/src/components/widget/controls/directory-picker.test.tsx b/apps/native/src/components/widget/controls/directory-picker.test.tsx index f52cf61f0..0d92b4e90 100644 --- a/apps/native/src/components/widget/controls/directory-picker.test.tsx +++ b/apps/native/src/components/widget/controls/directory-picker.test.tsx @@ -2,8 +2,8 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import "@testing-library/jest-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { DirectoryPicker } from "@/components/widget/controls/directory-picker"; import { makeGlobalPreferences as makePrefs } from "@/utils/test-fixtures"; @@ -219,8 +219,6 @@ describe("", () => { useViewModel.setState({ preferences: makePrefs({ configDir: "/Users/me/empty" }) }); }); - expect( - await screen.findByText(/flake\.nix not found in this directory/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/flake\.nix not found in this directory/i)).toBeInTheDocument(); }); }); diff --git a/apps/native/src/components/widget/controls/directory-picker.tsx b/apps/native/src/components/widget/controls/directory-picker.tsx index 305d91ad8..c596a205a 100644 --- a/apps/native/src/components/widget/controls/directory-picker.tsx +++ b/apps/native/src/components/widget/controls/directory-picker.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useDarwinConfig } from "@/hooks/use-darwin-config"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { HoverClickPopoverIcon } from "@/components/ui/hover-click-popover-icon"; import { ConfigDirBadge } from "@/components/widget/badges/config-dir-badge"; import { GitignoreBadge } from "@/components/widget/badges/gitignore-badge"; @@ -21,8 +21,7 @@ type DirectoryPickerProps = { type SetupChoice = "new" | "existing" | "import"; -const INITIAL_HINT = - "Select your own, or proceed below for defaults"; +const INITIAL_HINT = "Select your own, or proceed below for defaults"; function getDirectoryName(path: string | undefined): string { if (!path) return ".darwin"; @@ -117,7 +116,9 @@ export function DirectoryPicker({ } }; - const onBlur = () => { submit(); }; + const onBlur = () => { + submit(); + }; const onKeyDown = async (e: React.KeyboardEvent) => { if (e.key !== "Enter") return; const target = e.currentTarget; @@ -247,7 +248,8 @@ export function DirectoryPicker({

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

) : ( @@ -268,7 +270,9 @@ export function DirectoryPicker({ )} {validationMessage && ( -

+

{validationMessage}

)} diff --git a/apps/native/src/components/widget/controls/model-combobox.tsx b/apps/native/src/components/widget/controls/model-combobox.tsx index 21c0e58a6..251514956 100644 --- a/apps/native/src/components/widget/controls/model-combobox.tsx +++ b/apps/native/src/components/widget/controls/model-combobox.tsx @@ -34,12 +34,7 @@ interface OllamaModel { name: string; } -const OPENAI_MODELS = [ - "gpt-4o", - "gpt-4o-mini", - "gpt-4.1", - "gpt-4.1-mini", -] as const; +const OPENAI_MODELS = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini"] as const; async function fetchOpenRouterModels(): Promise { try { diff --git a/apps/native/src/components/widget/controls/repo-import.test.tsx b/apps/native/src/components/widget/controls/repo-import.test.tsx index eb007e24c..8db408ec7 100644 --- a/apps/native/src/components/widget/controls/repo-import.test.tsx +++ b/apps/native/src/components/widget/controls/repo-import.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import "@testing-library/jest-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { RepoImport } from "@/components/widget/controls/repo-import"; // --------------------------------------------------------------------------- diff --git a/apps/native/src/components/widget/evolve-flow.stories.tsx b/apps/native/src/components/widget/evolve-flow.stories.tsx index 45945736a..aa9d0b5df 100644 --- a/apps/native/src/components/widget/evolve-flow.stories.tsx +++ b/apps/native/src/components/widget/evolve-flow.stories.tsx @@ -1,7 +1,7 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import type { EvolveEvent } from "@/ipc/types"; import type { SemanticChangeMap, EvolveState, GitStatus, Change } from "@/ipc/types"; import { useEffect, useRef } from "react"; @@ -141,16 +141,76 @@ const evolveStateMerge: EvolveState = { }; const mockEvolveEvents: EvolveEvent[] = [ - { raw: "Starting evolution...", summary: "Starting evolution", eventType: "start", iteration: null, timestampMs: 0 }, - { raw: "Iteration 1 of 25", summary: "Iteration 1", eventType: "iteration", iteration: 1, timestampMs: 1200 }, - { raw: "Analyzing current configuration to understand package structure...", summary: "Thinking about changes", eventType: "thinking", iteration: 1, timestampMs: 2400 }, - { raw: "read_file: configuration.nix", summary: "Reading configuration.nix", eventType: "reading", iteration: 1, timestampMs: 3100 }, - { raw: "edit_file: configuration.nix — adding htop and btop", summary: "Editing configuration.nix", eventType: "editing", iteration: 1, timestampMs: 4500 }, - { raw: "Creating modules/monitoring.nix with monitoring tools", summary: "Creating modules/monitoring.nix", eventType: "editing", iteration: 1, timestampMs: 5800 }, - { raw: "Running nix eval to verify syntax...", summary: "Checking build", eventType: "buildCheck", iteration: 1, timestampMs: 7200 }, - { raw: "Build check passed", summary: "Build passed", eventType: "buildPass", iteration: 1, timestampMs: 9500 }, - { raw: "Summarizing changes...", summary: "Analyzing changes", eventType: "summarizing", iteration: null, timestampMs: 10200 }, - { raw: "Evolution complete: 2 files changed, 14 additions", summary: "Evolution complete", eventType: "complete", iteration: null, timestampMs: 11800 }, + { + raw: "Starting evolution...", + summary: "Starting evolution", + eventType: "start", + iteration: null, + timestampMs: 0, + }, + { + raw: "Iteration 1 of 25", + summary: "Iteration 1", + eventType: "iteration", + iteration: 1, + timestampMs: 1200, + }, + { + raw: "Analyzing current configuration to understand package structure...", + summary: "Thinking about changes", + eventType: "thinking", + iteration: 1, + timestampMs: 2400, + }, + { + raw: "read_file: configuration.nix", + summary: "Reading configuration.nix", + eventType: "reading", + iteration: 1, + timestampMs: 3100, + }, + { + raw: "edit_file: configuration.nix — adding htop and btop", + summary: "Editing configuration.nix", + eventType: "editing", + iteration: 1, + timestampMs: 4500, + }, + { + raw: "Creating modules/monitoring.nix with monitoring tools", + summary: "Creating modules/monitoring.nix", + eventType: "editing", + iteration: 1, + timestampMs: 5800, + }, + { + raw: "Running nix eval to verify syntax...", + summary: "Checking build", + eventType: "buildCheck", + iteration: 1, + timestampMs: 7200, + }, + { + raw: "Build check passed", + summary: "Build passed", + eventType: "buildPass", + iteration: 1, + timestampMs: 9500, + }, + { + raw: "Summarizing changes...", + summary: "Analyzing changes", + eventType: "summarizing", + iteration: null, + timestampMs: 10200, + }, + { + raw: "Evolution complete: 2 files changed, 14 additions", + summary: "Evolution complete", + eventType: "complete", + iteration: null, + timestampMs: 11800, + }, ]; // ============================================================================= @@ -232,7 +292,8 @@ function AnimatedEvolveFlow() { const t3 = setTimeout(() => { useViewModel.setState({ evolve: evolveStateMerge }); useUiState.setState({ - commitMessageSuggestion: "feat: add system monitoring tools (htop, btop, bottom, bandwhich, procs)", + commitMessageSuggestion: + "feat: add system monitoring tools (htop, btop, bottom, bandwhich, procs)", }); }, completionTime + 5000); timeoutsRef.current.push(t3); @@ -275,7 +336,15 @@ export const Begin = meta.story({ ), @@ -320,7 +389,8 @@ export const Merge = meta.story({ evolveState: evolveStateMerge, gitStatus: mockGitStatus, changeMap: mockChangeMap, - commitMessageSuggestion: "feat: add system monitoring tools (htop, btop, bottom, bandwhich, procs)", + commitMessageSuggestion: + "feat: add system monitoring tools (htop, btop, bottom, bandwhich, procs)", }} /> ), diff --git a/apps/native/src/components/widget/feedback/feedback-dialog.tsx b/apps/native/src/components/widget/feedback/feedback-dialog.tsx index 2d3f817a6..b8917aa49 100644 --- a/apps/native/src/components/widget/feedback/feedback-dialog.tsx +++ b/apps/native/src/components/widget/feedback/feedback-dialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { useCurrentStep } from "@/hooks/use-current-step"; import { Dialog, @@ -945,7 +945,6 @@ export function FeedbackDialog() { )} - )} diff --git a/apps/native/src/components/widget/feedback/report-issue-button.tsx b/apps/native/src/components/widget/feedback/report-issue-button.tsx index e950042a6..91afc3b34 100644 --- a/apps/native/src/components/widget/feedback/report-issue-button.tsx +++ b/apps/native/src/components/widget/feedback/report-issue-button.tsx @@ -1,7 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { FeedbackType } from "@/types/feedback"; export function ReportIssueButton() { diff --git a/apps/native/src/components/widget/filesystem/data.ts b/apps/native/src/components/widget/filesystem/data.ts index 158be2300..f2620defc 100644 --- a/apps/native/src/components/widget/filesystem/data.ts +++ b/apps/native/src/components/widget/filesystem/data.ts @@ -317,8 +317,7 @@ creation_rules: id: "untracked-homebrew-casks", path: "Untracked Homebrew casks", title: "Scanning Homebrew casks", - description: - "Homebrew casks installed on this Mac but not declared in your flake.", + description: "Homebrew casks installed on this Mac but not declared in your flake.", iconName: "warn", tone: "amber", status: "candidate", @@ -331,8 +330,7 @@ creation_rules: 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.", + description: "Homebrew taps configured on this Mac but not declared in your flake.", iconName: "warn", tone: "amber", status: "candidate", @@ -345,8 +343,7 @@ creation_rules: 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.", + description: "Homebrew brews installed on this Mac but not declared in your flake.", iconName: "warn", tone: "amber", status: "candidate", @@ -455,34 +452,38 @@ function homebrewItems(names: string[], itemType: HomebrewItemType): CandidateIt } function systemDefaultsFallback(): FsFile { - return FILES.manage.find((file) => file.id === SYSTEM_DEFAULTS_ID) ?? { - id: SYSTEM_DEFAULTS_ID, - path: "Custom macOS defaults", - title: "Scanning macOS defaults", - description: - "Preferences you've changed in System Settings. Capture them as code so a fresh install matches.", - iconName: "settings" as const, - tone: "blue" as const, - status: "candidate" as const, - destination: SYSTEM_DEFAULTS_FILE_DESTINATION, - }; + return ( + FILES.manage.find((file) => file.id === SYSTEM_DEFAULTS_ID) ?? { + id: SYSTEM_DEFAULTS_ID, + path: "Custom macOS defaults", + title: "Scanning macOS defaults", + description: + "Preferences you've changed in System Settings. Capture them as code so a fresh install matches.", + iconName: "settings" as const, + tone: "blue" as const, + status: "candidate" as const, + destination: SYSTEM_DEFAULTS_FILE_DESTINATION, + } + ); } function launchdFallback(): FsFile { - return FILES.manage.find((file) => file.id === LAUNCHD_ID) ?? { - id: LAUNCHD_ID, - path: "Untracked launchd items", - title: "Scanning launchd items", - description: - "Started Homebrew services that launchd runs today but nix-darwin does not declare.", - iconName: "warn" as const, - tone: "amber" as const, - status: "candidate" as const, - destination: LAUNCHD_FILE_DESTINATION, - scanCommand: "scan_launchd_items", - scannedAt: "not scanned yet", - items: [], - }; + return ( + FILES.manage.find((file) => file.id === LAUNCHD_ID) ?? { + id: LAUNCHD_ID, + path: "Untracked launchd items", + title: "Scanning launchd items", + description: + "Started Homebrew services that launchd runs today but nix-darwin does not declare.", + iconName: "warn" as const, + tone: "amber" as const, + status: "candidate" as const, + destination: LAUNCHD_FILE_DESTINATION, + scanCommand: "scan_launchd_items", + scannedAt: "not scanned yet", + items: [], + } + ); } function nixValue(setting: SystemDefault) { @@ -540,9 +541,7 @@ function launchdAttr(item: LaunchdItem) { ]; if (item.programArguments.length > 0) { - lines.push( - ` ProgramArguments = [ ${item.programArguments.map(nixString).join(" ")} ];`, - ); + lines.push(` ProgramArguments = [ ${item.programArguments.map(nixString).join(" ")} ];`); } lines.push(` RunAtLoad = ${item.runAtLoad ? "true" : "false"};`); @@ -573,10 +572,7 @@ function launchdAttr(item: LaunchdItem) { function launchdItems(items: LaunchdItem[]): CandidateItem[] { return items.map((item) => ({ name: item.name, - detail: - item.programArguments.length > 0 - ? item.programArguments.join(" ") - : item.label, + detail: item.programArguments.length > 0 ? item.programArguments.join(" ") : item.label, installedAt: launchdAttrScope(item.scope), attr: launchdAttr(item), source: "launchd", @@ -675,16 +671,18 @@ export function untrackedCandidateItemCount(files: FsFile[]) { 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, - }; + 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( 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 aaa75bac8..537522640 100644 --- a/apps/native/src/components/widget/filesystem/file-list.stories.tsx +++ b/apps/native/src/components/widget/filesystem/file-list.stories.tsx @@ -81,10 +81,7 @@ export const SystemSection = meta.story({ {(push) => (
- push(seedForFile(f))} - /> + push(seedForFile(f))} />
)}
@@ -96,10 +93,7 @@ export const PersonalSection = meta.story({ {(push) => (
- push(seedForFile(f))} - /> + push(seedForFile(f))} />
)}
@@ -129,10 +123,7 @@ export const SetupSection = meta.story({ {(push) => (
- push(seedForFile(f))} - /> + push(seedForFile(f))} />
)}
diff --git a/apps/native/src/components/widget/filesystem/file-row.stories.tsx b/apps/native/src/components/widget/filesystem/file-row.stories.tsx index 2ac35cf12..89a4f6e22 100644 --- a/apps/native/src/components/widget/filesystem/file-row.stories.tsx +++ b/apps/native/src/components/widget/filesystem/file-row.stories.tsx @@ -25,7 +25,9 @@ const findFile = (id: string): FsFile => { export const Managed = meta.story({ render: () => ( - {(push) => push(`change ${f.path}`)} />} + {(push) => ( + push(`change ${f.path}`)} /> + )} ), }); @@ -33,7 +35,9 @@ export const Managed = meta.story({ export const Changed = meta.story({ render: () => ( - {(push) => push(`change ${f.path}`)} />} + {(push) => ( + push(`change ${f.path}`)} /> + )} ), }); @@ -41,7 +45,9 @@ export const Changed = meta.story({ export const Readonly = meta.story({ render: () => ( - {(push) => push(`change ${f.path}`)} />} + {(push) => ( + push(`change ${f.path}`)} /> + )} ), }); @@ -53,7 +59,9 @@ export const Readonly = meta.story({ export const PeekableNixSource = meta.story({ render: () => ( - {(push) => push(`change ${f.path}`)} />} + {(push) => ( + push(`change ${f.path}`)} /> + )} ), }); diff --git a/apps/native/src/components/widget/filesystem/filesystem-step.tsx b/apps/native/src/components/widget/filesystem/filesystem-step.tsx index 5a4c27546..c80a426e3 100644 --- a/apps/native/src/components/widget/filesystem/filesystem-step.tsx +++ b/apps/native/src/components/widget/filesystem/filesystem-step.tsx @@ -5,14 +5,9 @@ import { useEffect, useState } from "react"; import { tauriAPI } from "@/ipc/api"; import { useLaunchdItems } from "@/hooks/use-launchd-items"; import { useSystemDefaultsScan } from "@/hooks/use-system-defaults-scan"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; -import type { - HomebrewItem, - HomebrewState, - LaunchdItem, - SystemDefault, -} from "@/ipc/types"; +import type { HomebrewItem, HomebrewState, LaunchdItem, SystemDefault } from "@/ipc/types"; import { FILES, @@ -98,10 +93,7 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { const manageFiles = replaceLaunchdPlaceholder( replaceSystemDefaultsPlaceholder( - replaceHomebrewPlaceholders( - FILES.manage, - homebrewFilesFromDiff(homebrewDiff, homebrewError), - ), + replaceHomebrewPlaceholders(FILES.manage, homebrewFilesFromDiff(homebrewDiff, homebrewError)), systemDefaultsFileFromScan(systemDefaultsScan, systemDefaultsError), ), launchdItemsFileFromScan(launchdItems, launchdError), @@ -145,9 +137,7 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { itemType: item.itemType, }; }); - await tauriAPI.homebrew.addItems( - homebrewItems, - ); + await tauriAPI.homebrew.addItems(homebrewItems); invalidateRecommendation(); setShowFilesystem(false); setHomebrewDiff(null); @@ -211,7 +201,8 @@ export function FilesystemStep({ onSeedPrompt }: FilesystemStepProps = {}) { onTrackLaunchdItems={onTrackLaunchdItems} />
- Use these as starting points — every change goes through the standard plan → review → save flow. + Use these as starting points — every change goes through the standard plan → review → save + flow.
); diff --git a/apps/native/src/components/widget/filesystem/highlight.tsx b/apps/native/src/components/widget/filesystem/highlight.tsx index b3cf59dac..c20281e0e 100644 --- a/apps/native/src/components/widget/filesystem/highlight.tsx +++ b/apps/native/src/components/widget/filesystem/highlight.tsx @@ -99,8 +99,6 @@ export function highlightNixLine(line: string): ReactNode { segs = applyRegex(segs, /\b(true|false|null)\b/g, "text-amber-300"); return segs.map((s, j) => ( // biome-ignore lint/suspicious/noArrayIndexKey: stable per render - - {s.color ? {s.text} : s.text} - + {s.color ? {s.text} : s.text} )); } diff --git a/apps/native/src/components/widget/filesystem/section-tabs.tsx b/apps/native/src/components/widget/filesystem/section-tabs.tsx index 1f8db90cd..bfc467008 100644 --- a/apps/native/src/components/widget/filesystem/section-tabs.tsx +++ b/apps/native/src/components/widget/filesystem/section-tabs.tsx @@ -3,12 +3,7 @@ import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { - untrackedCandidateItemCount, - type FsFile, - type Section, - type SectionId, -} from "./data"; +import { untrackedCandidateItemCount, type FsFile, type Section, type SectionId } from "./data"; const SECTION_ICONS: Record = { entry: Cable, @@ -32,9 +27,7 @@ export function SectionTabs({ sections, active, setActive, files }: SectionTabsP const isActive = s.id === active; const Icon = SECTION_ICONS[s.id]; const untrackedCount = - s.id === "manage" - ? untrackedCandidateItemCount(files[s.id] ?? []) - : 0; + s.id === "manage" ? untrackedCandidateItemCount(files[s.id] ?? []) : 0; return ( diff --git a/apps/native/src/components/widget/filesystem/seed-display.tsx b/apps/native/src/components/widget/filesystem/seed-display.tsx index 0b4cddbce..3efd017ae 100644 --- a/apps/native/src/components/widget/filesystem/seed-display.tsx +++ b/apps/native/src/components/widget/filesystem/seed-display.tsx @@ -20,7 +20,9 @@ export function SeedDisplay({ children, title = "Prompt seed" }: SeedDisplayProp return (
-
{children(push)}
+
+ {children(push)} +
{title}
@@ -42,10 +44,7 @@ export function SeedDisplay({ children, title = "Prompt seed" }: SeedDisplayProp
    {history.map((seed, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: stable per render -
  1. +
  2. {i === 0 ? "latest" : `prev #${i}`}
    diff --git a/apps/native/src/components/widget/filesystem/untracked-banner.stories.tsx b/apps/native/src/components/widget/filesystem/untracked-banner.stories.tsx index 4d8082616..39e240ced 100644 --- a/apps/native/src/components/widget/filesystem/untracked-banner.stories.tsx +++ b/apps/native/src/components/widget/filesystem/untracked-banner.stories.tsx @@ -97,10 +97,7 @@ export const ToggleView = meta.story({ const [open, setOpen] = useState(false); return (
    - setOpen((v) => !v)} - /> + setOpen((v) => !v)} /> {open && (
    (Stand-in) Filesystem view → Untracked tab would render here. 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 d140f90a8..6f71f621f 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,10 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; -import { homebrewFilesFromDiff, launchdItemsFileFromScan, systemDefaultsFileFromScan } from "./data"; +import { + homebrewFilesFromDiff, + launchdItemsFileFromScan, + systemDefaultsFileFromScan, +} from "./data"; import { SeedDisplay } from "./seed-display"; import { UntrackedCard } from "./untracked-card"; @@ -57,7 +61,11 @@ const launchd = launchdItemsFileFromScan([ label: "homebrew.mxcl.postgresql@14", scope: "LaunchDaemon", name: "postgresql@14", - programArguments: ["/opt/homebrew/opt/postgresql@14/bin/postgres", "-D", "/opt/homebrew/var/postgresql@14"], + programArguments: [ + "/opt/homebrew/opt/postgresql@14/bin/postgres", + "-D", + "/opt/homebrew/var/postgresql@14", + ], runAtLoad: true, keepAlive: true, environmentVariables: {}, diff --git a/apps/native/src/components/widget/filesystem/untracked-card.tsx b/apps/native/src/components/widget/filesystem/untracked-card.tsx index 82c89cba6..736a4e406 100644 --- a/apps/native/src/components/widget/filesystem/untracked-card.tsx +++ b/apps/native/src/components/widget/filesystem/untracked-card.tsx @@ -74,8 +74,7 @@ export function UntrackedCard({ const Icon = resolveIcon(file.iconName); const hasItems = items.length > 0; const canTrackHomebrew = isHomebrewCandidateFile(file) && !!onTrackHomebrewItems; - const canTrackSystemDefaults = - isSystemDefaultsCandidateFile(file) && !!onTrackSystemDefaults; + const canTrackSystemDefaults = isSystemDefaultsCandidateFile(file) && !!onTrackSystemDefaults; const canTrackLaunchd = isLaunchdCandidateFile(file) && !!onTrackLaunchdItems; const canTrack = canTrackHomebrew || canTrackSystemDefaults || canTrackLaunchd; @@ -137,13 +136,10 @@ export function UntrackedCard({ {file.scannedAt} · - would land in{" "} - {file.destination} + would land in {file.destination}
    - {trackError && ( -
    {trackError}
    - )} + {trackError &&
    {trackError}
    }
@@ -159,7 +155,11 @@ export function UntrackedCard({ data-testid={`track-all-${file.id}`} > {" "} - {trackingKey === "all" ? "Tracking..." : (items.length === 1 ? "Track this one" : `Track these ${items.length}`)} + {trackingKey === "all" + ? "Tracking..." + : items.length === 1 + ? "Track this one" + : `Track these ${items.length}`}
diff --git a/apps/native/src/components/widget/history/history-confirm-restore-button.tsx b/apps/native/src/components/widget/history/history-confirm-restore-button.tsx index 27d6ab3fb..c45578c69 100644 --- a/apps/native/src/components/widget/history/history-confirm-restore-button.tsx +++ b/apps/native/src/components/widget/history/history-confirm-restore-button.tsx @@ -7,7 +7,11 @@ interface HistoryConfirmRestoreButtonProps { onCancel?: () => void; } -export function HistoryConfirmRestoreButton({ deactivateCount, onConfirm, onCancel }: HistoryConfirmRestoreButtonProps) { +export function HistoryConfirmRestoreButton({ + deactivateCount, + onConfirm, + onCancel, +}: HistoryConfirmRestoreButtonProps) { return (
@@ -18,7 +22,10 @@ export function HistoryConfirmRestoreButton({ deactivateCount, onConfirm, onCanc id="history-confirm-restore-button" data-testid="history-confirm-restore-button" className="h-auto whitespace-nowrap border-teal-400/30 bg-teal-400/10 px-[10px] py-1 text-[10px] text-teal-400 hover:border-teal-400/50 hover:bg-teal-400/15" - onClick={(e) => { e.stopPropagation(); onConfirm?.(); }} + onClick={(e) => { + e.stopPropagation(); + onConfirm?.(); + }} > Confirm Restore @@ -28,7 +35,10 @@ export function HistoryConfirmRestoreButton({ deactivateCount, onConfirm, onCanc variant="outline" size="sm" className="h-auto whitespace-nowrap border-white/10 bg-white/[0.06] px-[10px] py-1 text-[10px] text-neutral-500 hover:border-white/20 hover:text-neutral-400" - onClick={(e) => { e.stopPropagation(); onCancel?.(); }} + onClick={(e) => { + e.stopPropagation(); + onCancel?.(); + }} > Cancel diff --git a/apps/native/src/components/widget/history/history-header.tsx b/apps/native/src/components/widget/history/history-header.tsx index 56e7d1cd7..787793c51 100644 --- a/apps/native/src/components/widget/history/history-header.tsx +++ b/apps/native/src/components/widget/history/history-header.tsx @@ -10,7 +10,9 @@ export function HistoryHeader({ count }: HistoryHeaderProps) {
-

History

+

+ History +

{count} diff --git a/apps/native/src/components/widget/history/history-item-card.tsx b/apps/native/src/components/widget/history/history-item-card.tsx index ca78c79a0..a761d35fe 100644 --- a/apps/native/src/components/widget/history/history-item-card.tsx +++ b/apps/native/src/components/widget/history/history-item-card.tsx @@ -12,7 +12,11 @@ import { useHistoryCard } from "@/hooks/use-history-card"; import { cn } from "@/lib/utils"; import type { HistoryItem } from "@/ipc/types"; import type { TimelineContext } from "@/components/widget/history/timeline-connector"; -import { HistoryItemTimeline, TimeLineConnector, TimelineDot } from "@/components/widget/history/timeline-connector"; +import { + HistoryItemTimeline, + TimeLineConnector, + TimelineDot, +} from "@/components/widget/history/timeline-connector"; interface HistoryItemCardProps { item: HistoryItem; @@ -37,14 +41,21 @@ export function HistoryItemCard({ onConfirmRestore, onCancelRestore, }: HistoryItemCardProps) { - const { expanded, colorMap, cardClassName, actionType, handleCardClick, handleKeyDown } = useHistoryCard(item, isPreview); + const { expanded, colorMap, cardClassName, actionType, handleCardClick, handleKeyDown } = + useHistoryCard(item, isPreview); const { isUndone } = timeline; const isCardInteractive = !!item.changeMap && !isPreview; const getActionOrBadge = () => { switch (actionType) { case "preview": - return ; + return ( + + ); case "current": return ; case "base": @@ -68,12 +79,17 @@ export function HistoryItemCard({
- +
} + header={ + + } actions={getActionOrBadge()} > - +
diff --git a/apps/native/src/components/widget/history/history-item-expanded-detail.tsx b/apps/native/src/components/widget/history/history-item-expanded-detail.tsx index be9ff94c0..25fc8ed52 100644 --- a/apps/native/src/components/widget/history/history-item-expanded-detail.tsx +++ b/apps/native/src/components/widget/history/history-item-expanded-detail.tsx @@ -9,7 +9,11 @@ interface HistoryDetailedChangeInfoProps { expanded: boolean; } -export function HistoryDetailedChangeInfo({ item, colorMap, expanded }: HistoryDetailedChangeInfoProps) { +export function HistoryDetailedChangeInfo({ + item, + colorMap, + expanded, +}: HistoryDetailedChangeInfoProps) { if (!item.changeMap || !expanded) return null; const { groups, singles } = item.changeMap; if (groups.length === 0 && singles.length === 0) return null; @@ -19,9 +23,7 @@ export function HistoryDetailedChangeInfo({ item, colorMap, expanded }: HistoryD {groups.map((group) => ( ))} - {singles.length > 0 && ( - - )} + {singles.length > 0 && }
); } diff --git a/apps/native/src/components/widget/history/history-restore-item-button.tsx b/apps/native/src/components/widget/history/history-restore-item-button.tsx index b135030a4..ba0ecdd10 100644 --- a/apps/native/src/components/widget/history/history-restore-item-button.tsx +++ b/apps/native/src/components/widget/history/history-restore-item-button.tsx @@ -1,7 +1,7 @@ import { Loader2, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; interface HistoryRestoreItemButtonProps { hash: string; @@ -24,7 +24,8 @@ export function HistoryRestoreItemButton({ disabled={isRestoring} className={cn( "h-auto whitespace-nowrap border-white/10 bg-white/[0.06] px-[10px] py-1 text-[10px] text-neutral-400 hover:border-white/30", - uncommittedChanges && "opacity-40 cursor-default hover:border-white/10 hover:bg-white/[0.06] hover:text-neutral-400", + uncommittedChanges && + "opacity-40 cursor-default hover:border-white/10 hover:bg-white/[0.06] hover:text-neutral-400", )} onClick={(e) => { e.stopPropagation(); diff --git a/apps/native/src/components/widget/history/timeline-connector.tsx b/apps/native/src/components/widget/history/timeline-connector.tsx index a40018bf3..344f5d7af 100644 --- a/apps/native/src/components/widget/history/timeline-connector.tsx +++ b/apps/native/src/components/widget/history/timeline-connector.tsx @@ -4,28 +4,24 @@ import { cn } from "@/lib/utils"; // Vertical line style constants. LINE_LABEL is brighter — used for day-label // spans which sit between commits and need a stronger visual thread. const LINE_NORMAL = "bg-teal-400/40 shadow-[0_0_4px_1px_rgba(45,212,191,0.15)]"; -export const LINE_LABEL = "bg-teal-400/60 shadow-[0_0_4px_1px_rgba(45,212,191,0.35)]"; +export const LINE_LABEL = "bg-teal-400/60 shadow-[0_0_4px_1px_rgba(45,212,191,0.35)]"; export const LINE_UNDONE = "bg-neutral-700"; -type TimelineLineVariant = - | "normal" - | "undone" - | "fade-to-undone" - | "fade-from-undone"; +type TimelineLineVariant = "normal" | "undone" | "fade-to-undone" | "fade-from-undone"; const VARIANT_CLASSES: Record = { - normal: LINE_NORMAL, - undone: LINE_UNDONE, - "fade-to-undone": "bg-gradient-to-b from-teal-400/40 to-neutral-700", + normal: LINE_NORMAL, + undone: LINE_UNDONE, + "fade-to-undone": "bg-gradient-to-b from-teal-400/40 to-neutral-700", "fade-from-undone": "bg-gradient-to-b from-neutral-700 to-teal-400/40", }; // 29px = mt-6 (24px dot offset) + h-2.5/2 (5px half-dot) — the dot center. // Top line terminates here; bottom line originates here. const SPAN_CLASSES = { - top: "top-0 h-[29px]", + top: "top-0 h-[29px]", bottom: "top-[29px] bottom-0", - full: "top-0 bottom-0", + full: "top-0 bottom-0", } as const; function TimeLineSection({ @@ -37,11 +33,7 @@ function TimeLineSection({ }) { return (
); } @@ -49,7 +41,12 @@ function TimeLineSection({ export function TimelineDot({ isUndone }: { isUndone?: boolean }) { return (
-
+
); } @@ -82,10 +79,11 @@ export function TimeLineConnector({ > {isUndone && (
- {isPreviewActive - ? - : - } + {isPreviewActive ? ( + + ) : ( + + )}
)}
@@ -107,8 +105,16 @@ export interface TimelineContext { */ export function HistoryItemTimeline({ timeline }: { timeline: TimelineContext }) { const { isFirst, isLast, isUndone, bottomFadeToUndone, topFadeFromUndone } = timeline; - const topVariant: TimelineLineVariant = topFadeFromUndone ? "fade-from-undone" : isUndone ? "undone" : "normal"; - const bottomVariant: TimelineLineVariant = bottomFadeToUndone ? "fade-to-undone" : isUndone ? "undone" : "normal"; + const topVariant: TimelineLineVariant = topFadeFromUndone + ? "fade-from-undone" + : isUndone + ? "undone" + : "normal"; + const bottomVariant: TimelineLineVariant = bottomFadeToUndone + ? "fade-to-undone" + : isUndone + ? "undone" + : "normal"; return ( <> {!isFirst && } diff --git a/apps/native/src/components/widget/layout/AppErrorBoundary.tsx b/apps/native/src/components/widget/layout/AppErrorBoundary.tsx index 99ca2f987..eea803573 100644 --- a/apps/native/src/components/widget/layout/AppErrorBoundary.tsx +++ b/apps/native/src/components/widget/layout/AppErrorBoundary.tsx @@ -16,10 +16,7 @@ type AppErrorBoundaryState = { * Sentry.ErrorBoundary: render errors are logged and routed through the * unified telemetry pipeline (OTEL → Rust backend) via getTelemetry(). */ -export class AppErrorBoundary extends Component< - AppErrorBoundaryProps, - AppErrorBoundaryState -> { +export class AppErrorBoundary extends Component { state: AppErrorBoundaryState = { error: null }; static getDerivedStateFromError(error: Error): AppErrorBoundaryState { @@ -34,11 +31,7 @@ export class AppErrorBoundary extends Component< render() { const { error } = this.state; if (error) { - return this.props.fallback ? ( - this.props.fallback(error) - ) : ( - - ); + return this.props.fallback ? this.props.fallback(error) : ; } return this.props.children; } diff --git a/apps/native/src/components/widget/layout/AppFatalFallback.tsx b/apps/native/src/components/widget/layout/AppFatalFallback.tsx index 06a8c5f9a..4305a4875 100644 --- a/apps/native/src/components/widget/layout/AppFatalFallback.tsx +++ b/apps/native/src/components/widget/layout/AppFatalFallback.tsx @@ -18,8 +18,7 @@ function stashErrorForRecovery(error: Error | null | undefined): void { timestamp: new Date().toISOString(), }), ); - } catch { - } + } catch {} } export function AppFatalFallback({ error }: AppFatalFallbackProps) { diff --git a/apps/native/src/components/widget/layout/console.tsx b/apps/native/src/components/widget/layout/console.tsx index 8c24c3b73..9fe414e79 100644 --- a/apps/native/src/components/widget/layout/console.tsx +++ b/apps/native/src/components/widget/layout/console.tsx @@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp, GripHorizontal } from "lucide-react"; import { useState, useCallback, useRef } from "react"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { DebugOverlay } from "@/components/widget/layout/debug-overlay"; /** Minimum console height in pixels. */ @@ -87,18 +87,11 @@ export function Console() { {expanded && } Console - {expanded ? ( - - ) : ( - - )} + {expanded ? : }
{expanded && ( -
+
{/* Debug Info */}
diff --git a/apps/native/src/components/widget/layout/debug-overlay.tsx b/apps/native/src/components/widget/layout/debug-overlay.tsx index eec6f1d3b..b938e4e85 100644 --- a/apps/native/src/components/widget/layout/debug-overlay.tsx +++ b/apps/native/src/components/widget/layout/debug-overlay.tsx @@ -1,7 +1,7 @@ "use client"; import { tauriAPI } from "@/ipc/api"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { useState } from "react"; import { GitStatusDebug } from "@/components/widget/layout/git-status-debug"; diff --git a/apps/native/src/components/widget/layout/error-message.tsx b/apps/native/src/components/widget/layout/error-message.tsx index 18c8b14b1..1b4a4220b 100644 --- a/apps/native/src/components/widget/layout/error-message.tsx +++ b/apps/native/src/components/widget/layout/error-message.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { EVOLUTION_CANCELLED_MSG } from "@/lib/constants"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { useCurrentStep } from "@/hooks/use-current-step"; import { FeedbackType } from "@/types/feedback"; import { Settings } from "lucide-react"; diff --git a/apps/native/src/components/widget/layout/git-status-debug.tsx b/apps/native/src/components/widget/layout/git-status-debug.tsx index 92840403a..58dd6170a 100644 --- a/apps/native/src/components/widget/layout/git-status-debug.tsx +++ b/apps/native/src/components/widget/layout/git-status-debug.tsx @@ -9,7 +9,7 @@ // - Do not add fields that are not on the GitStatus type. import { useState } from "react"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; function Bool({ label, value }: { label: string; value: boolean | undefined }) { if (value === undefined || !value) { diff --git a/apps/native/src/components/widget/layout/header.tsx b/apps/native/src/components/widget/layout/header.tsx index e2022b0cc..c172b09d3 100644 --- a/apps/native/src/components/widget/layout/header.tsx +++ b/apps/native/src/components/widget/layout/header.tsx @@ -4,9 +4,9 @@ import { filesystemViewEnabled } from "@/lib/flags"; import { cn } from "@/lib/utils"; import { Clock, FolderTree, Settings, MessageSquarePlus } from "lucide-react"; import { APP_NAME } from "../../../../shared/constants"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { computeCurrentStep } from "@/components/widget/utils"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; export function Header() { const setSettingsOpen = useUiState((s) => s.setSettingsOpen); @@ -58,7 +58,8 @@ export function Header() {
- ); + {/* Connector line cell (after steps 1 and 2) */} + {!isLast && ( +
+ )} + + ); + })} +
+
+ ); } diff --git a/apps/native/src/components/widget/layout/update-banner.tsx b/apps/native/src/components/widget/layout/update-banner.tsx index 4a757fa65..77cfb46aa 100644 --- a/apps/native/src/components/widget/layout/update-banner.tsx +++ b/apps/native/src/components/widget/layout/update-banner.tsx @@ -53,7 +53,7 @@ export function UpdateBanner() { expanded ? "items-start" : "items-center", error ? "border-destructive/40 bg-destructive/10 text-destructive" - : "border-blue-500/30 bg-blue-500/10 text-blue-200" + : "border-blue-500/30 bg-blue-500/10 text-blue-200", )} > @@ -68,15 +68,13 @@ export function UpdateBanner() {

) : ( <> -

- Update available: v{version} -

+

Update available: v{version}

{notes && (

{notes} @@ -108,7 +106,7 @@ export function UpdateBanner() { onClick={installUpdate} className={cn( "rounded-md px-3 py-1 text-xs font-medium transition-colors", - "bg-blue-500/20 hover:bg-blue-500/30 text-blue-100" + "bg-blue-500/20 hover:bg-blue-500/30 text-blue-100", )} > Install & Restart diff --git a/apps/native/src/components/widget/notifications/external-build-detected.stories.tsx b/apps/native/src/components/widget/notifications/external-build-detected.stories.tsx index 3f3fcc381..a08ae938c 100644 --- a/apps/native/src/components/widget/notifications/external-build-detected.stories.tsx +++ b/apps/native/src/components/widget/notifications/external-build-detected.stories.tsx @@ -1,7 +1,7 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; import type { EvolveState } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { fn } from "storybook/test"; import { useEffect } from "react"; import { ExternalBuildDetected } from "@/components/widget/notifications/external-build-detected"; @@ -71,22 +71,19 @@ function setup({ * Default — banner is visible: external build detected during an active evolution. */ export const Visible = meta.story({ - render: () => - setup({ externalBuildDetected: true, evolveState: mockEvolveState }), + render: () => setup({ externalBuildDetected: true, evolveState: mockEvolveState }), }); /** * Hidden — no external build detected, component renders nothing. */ export const HiddenNoBuild = meta.story({ - render: () => - setup({ externalBuildDetected: false, evolveState: mockEvolveState }), + render: () => setup({ externalBuildDetected: false, evolveState: mockEvolveState }), }); /** * Hidden — external build detected but no active evolution, component renders nothing. */ export const HiddenNoEvolution = meta.story({ - render: () => - setup({ externalBuildDetected: true, evolveState: null }), + render: () => setup({ externalBuildDetected: true, evolveState: null }), }); diff --git a/apps/native/src/components/widget/notifications/external-build-detected.tsx b/apps/native/src/components/widget/notifications/external-build-detected.tsx index 748abb588..c46dfee25 100644 --- a/apps/native/src/components/widget/notifications/external-build-detected.tsx +++ b/apps/native/src/components/widget/notifications/external-build-detected.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Hammer } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { useApply } from "@/hooks/use-apply"; export function ExternalBuildDetected() { @@ -25,8 +25,7 @@ export function ExternalBuildDetected() { return (

- - A nix build was detected outside nixmac. + A nix build was detected outside nixmac.

Step 4 of 6

Import your customizations

Already set this Mac up by hand? nixmac can scan for tweaks that aren't in your flake yet — macOS preferences, Homebrew casks and taps, launch agents — and turn them into code.

Signature feature

Scan this Mac for untracked settings

Already set this Mac up by hand? We'll run a few read-only commands to detect what you've customized and turn it into code. Nothing changes on your system — you choose what to track afterward.

  • macOS preferences$ defaults read
  • Homebrew casks$ brew list --cask
  • Homebrew taps$ brew tap
  • Launch agents$ launchctl list
"`; + +exports[`First Build 1`] = `"
nixmac

Step 6 of 6

Run your first build

This applies your flake to this Mac with darwin-rebuild. We'll stream the logs here and help you fix anything that fails before you finish.

Build command

darwin-rebuild switch --flake /Users/demo/.darwin#macbook-pro
Build log

Logs will appear here once the build starts.

"`; + +exports[`Import Flake 1`] = `"
nixmac

Step 3 of 6

Set up your configuration

nixmac manages your Mac through a Nix flake. Do you already have one, or are you starting fresh?

"`; + +exports[`Inference 1`] = `"
nixmac

Step 5 of 6

Set up AI inference

nixmac turns plain-language requests into nix changes. Choose how those requests are processed — use our hosted models, or bring your own API key.

Sign in to nixmac

Encrypted in transit. We never see your nix configuration contents.

Not sure yet? You can finish this while your first build runs.

"`; + +exports[`Nix Setup 1`] = `"
nixmac

Step 3 of 6

Set up your configuration

nixmac manages your Mac through a Nix flake. Do you already have one, or are you starting fresh?

"`; + +exports[`Permissions 1`] = `"
nixmac

Step 1 of 6

System Permissions

nixmac needs a few macOS permissions before it can read your configuration and apply changes. Grant the required ones to continue — we’ll move on automatically.

  • Desktop Folder AccessRequired

    Lets nixmac read configs you keep on your Desktop.

  • Documents Folder AccessRequired

    Most flakes live in Documents — we need to read them.

  • Administrator PrivilegesRequired

    Required to apply system changes with darwin-rebuild.

    You'll be prompted for your password when a change needs it.

  • Full Disk AccessRecommended

    Recommended so every file in your flake can be managed.

    System Settings → Privacy & Security → Full Disk Access

Administrator privileges are requested with a password prompt only when a change needs them. Full Disk Access is optional but recommended for the smoothest experience.

"`; + +exports[`Playground 1`] = `"
nixmac

Step 1 of 6

System Permissions

nixmac needs a few macOS permissions before it can read your configuration and apply changes. Grant the required ones to continue — we’ll move on automatically.

  • Desktop Folder AccessRequired

    Lets nixmac read configs you keep on your Desktop.

  • Documents Folder AccessRequired

    Most flakes live in Documents — we need to read them.

  • Administrator PrivilegesRequired

    Required to apply system changes with darwin-rebuild.

    You'll be prompted for your password when a change needs it.

  • Full Disk AccessRecommended

    Recommended so every file in your flake can be managed.

    System Settings → Privacy & Security → Full Disk Access

Administrator privileges are requested with a password prompt only when a change needs them. Full Disk Access is optional but recommended for the smoothest experience.

"`; diff --git a/apps/native/src/components/widget/onboarding/celebration-overlay.tsx b/apps/native/src/components/widget/onboarding/celebration-overlay.tsx new file mode 100644 index 000000000..88f0d99e1 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/celebration-overlay.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Lottie from "lottie-react"; +import { ArrowRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface CelebrationOverlayProps { + host: string; + onDismiss: () => void; +} + +/** + * Full-window congratulations moment shown once the first build succeeds. + * Loads the bundled Lottie animations from /public at runtime so they stay + * out of the main JS bundle. + */ +export function CelebrationOverlay({ host, onDismiss }: CelebrationOverlayProps) { + const [trophy, setTrophy] = useState(null); + const [confetti, setConfetti] = useState(null); + const [reducedMotion, setReducedMotion] = useState(false); + + useEffect(() => { + setReducedMotion(window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false); + }, []); + + useEffect(() => { + let active = true; + Promise.all([ + fetch("/lottie/celebrate.json").then((r) => r.json()), + fetch("/lottie/confetti.json").then((r) => r.json()), + ]) + .then(([t, c]) => { + if (!active) return; + setTrophy(t); + setConfetti(c); + }) + .catch(() => { + /* Animation is decorative — fall back to the static layout silently. */ + }); + return () => { + active = false; + }; + }, []); + + // Allow Escape to dismiss. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onDismiss(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onDismiss]); + + return ( +
+ {/* Confetti layer */} + {confetti && !reducedMotion ? ( + + ) : null} + +
+ {trophy ? ( + + ) : null} + + + Setup complete + + +

Welcome to nixmac

+

+ {host} is now fully managed. Every + change from here runs through a safe build — describe what you want and nixmac writes the + Nix for you. +

+ + +
+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/index.ts b/apps/native/src/components/widget/onboarding/index.ts new file mode 100644 index 000000000..645c89a15 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/index.ts @@ -0,0 +1,8 @@ +export { OnboardingFlow } from "@/components/widget/onboarding/onboarding-flow"; +export { useOnboarding } from "@nixmac/state"; +export { + computeOnboardingStep, + STEPS, + stepEyebrow, + type StepId, +} from "@/components/widget/onboarding/lib/onboarding"; diff --git a/apps/native/src/components/widget/onboarding/inference/inference-setup.tsx b/apps/native/src/components/widget/onboarding/inference/inference-setup.tsx new file mode 100644 index 000000000..672152cec --- /dev/null +++ b/apps/native/src/components/widget/onboarding/inference/inference-setup.tsx @@ -0,0 +1,520 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Check, + CreditCard, + Globe, + KeyRound, + Loader2, + Lock, + ShieldCheck, + TriangleAlert, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + BYOK_PROVIDERS, + HOSTED_PLANS, + validateKeyFormat, + type InferenceConfig, + type InferenceMode, +} from "@/components/widget/onboarding/lib/inference"; +import { tauriAPI } from "@/ipc/api"; +import { cn } from "@/lib/utils"; +import { getTelemetry } from "@/lib/telemetry/instance"; +import posthog from "posthog-js"; + +interface InferenceSetupProps { + onConfigured: (config: InferenceConfig) => void; +} + +export function InferenceSetup({ onConfigured }: InferenceSetupProps) { + const [mode, setMode] = useState("hosted"); + + return ( +
+
+ setMode("hosted")} + /> + setMode("byok")} + /> +
+ + {mode === "hosted" ? ( + + ) : ( + + )} +
+ ); +} + +function ModeCard({ + active, + icon: Icon, + title, + blurb, + badge, + onClick, +}: { + active: boolean; + icon: typeof Globe; + title: string; + blurb: string; + badge?: string; + onClick: () => void; +}) { + return ( + + ); +} + +/* ----------------------------- Hosted account ---------------------------- */ + +type HostedStage = "account" | "payment"; + +function HostedFlow({ onConfigured }: { onConfigured: (config: InferenceConfig) => void }) { + const [stage, setStage] = useState("account"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [plan, setPlan] = useState("starter"); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + const [card, setCard] = useState(""); + const [exp, setExp] = useState(""); + const [cvc, setCvc] = useState(""); + + const emailValid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email); + const accountReady = emailValid && password.length >= 8; + const cardReady = card.replace(/\s/g, "").length >= 15 && exp.length >= 4 && cvc.length >= 3; + + async function submitAccount() { + if (!accountReady) return; + setWorking(true); + setError(null); + try { + // Real account credential exchange via the backend. + await tauriAPI.account.signIn(email, password); + posthog.identify(email, { email }); + getTelemetry().captureEvent({ name: "account_signed_in" }); + setStage("payment"); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setWorking(false); + } + } + + function submitPayment() { + if (!cardReady) return; + setWorking(true); + // TODO(billing): no payment backend yet — simulate tokenization then record + // the hosted choice. Account sign-in above is real. + setTimeout(() => { + setWorking(false); + getTelemetry().captureEvent({ name: "inference_configured", props: { mode: "hosted" } }); + onConfigured({ mode: "hosted", email, plan }); + }, 1200); + } + + if (stage === "account") { + return ( +
+

Sign in to nixmac

+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="At least 8 characters" + autoComplete="current-password" + className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ + + {error ?

{error}

: null} +

+

+
+ ); + } + + // stage === "payment" + return ( +
+
+ + + Signed in as {email} +
+ +
+ Choose a plan +
+ {HOSTED_PLANS.map((p) => { + const active = plan === p.id; + return ( + + ); + })} +
+
+ +
+

+

+
+ + setCard(formatCard(e.target.value))} + placeholder="1234 5678 9012 3456" + className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ setExp(formatExp(e.target.value))} + placeholder="MM/YY" + className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + setCvc(e.target.value.replace(/\D/g, "").slice(0, 4))} + placeholder="CVC" + className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+
+
+ + +

+

+
+ ); +} + +function formatCard(v: string): string { + return v + .replace(/\D/g, "") + .slice(0, 16) + .replace(/(.{4})/g, "$1 ") + .trim(); +} + +function formatExp(v: string): string { + const digits = v.replace(/\D/g, "").slice(0, 4); + if (digits.length <= 2) return digits; + return `${digits.slice(0, 2)}/${digits.slice(2)}`; +} + +/* ------------------------------- BYOK flow ------------------------------- */ + +type KeyState = "idle" | "checking" | "invalid" | "valid"; + +function ByokFlow({ onConfigured }: { onConfigured: (config: InferenceConfig) => void }) { + const [providerId, setProviderId] = useState(BYOK_PROVIDERS[0].id); + const provider = useMemo( + () => BYOK_PROVIDERS.find((p) => p.id === providerId) ?? BYOK_PROVIDERS[0], + [providerId], + ); + const [model, setModel] = useState(provider.defaultModel); + const [key, setKey] = useState(""); + const [keyState, setKeyState] = useState("idle"); + const [serverError, setServerError] = useState(""); + + const format = useMemo(() => validateKeyFormat(provider, key), [provider, key]); + const touched = key.trim().length > 0; + + function changeProvider(id: string) { + const next = BYOK_PROVIDERS.find((p) => p.id === id) ?? BYOK_PROVIDERS[0]; + setProviderId(next.id); + setModel(next.defaultModel); + setKey(""); + setKeyState("idle"); + setServerError(""); + } + + async function verify() { + if (!format.valid) return; + setKeyState("checking"); + setServerError(""); + try { + // Persist the key + provider/model exactly like Settings → AI Models. + await tauriAPI.ui.setPrefs({ + [provider.prefsKeyField]: key.trim(), + evolveProvider: provider.id, + evolveModel: model, + }); + setKeyState("valid"); + getTelemetry().captureEvent({ + name: "inference_configured", + props: { mode: "byok", provider: provider.id }, + }); + setTimeout( + () => + onConfigured({ + mode: "byok", + providerId: provider.id, + providerName: provider.name, + model, + }), + 500, + ); + } catch (e: unknown) { + setKeyState("invalid"); + const msg = e instanceof Error ? e.message : String(e); + setServerError(msg); + getTelemetry().captureError(e instanceof Error ? e : new Error(msg), { + provider: provider.id, + }); + } + } + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+ {keyState === "valid" ? ( + + + ) : serverError ? ( + + + ) : touched ? ( + + {!format.valid ? + ) : ( + + Find it at {provider.docsHint}. Stored locally in your app preferences. + + )} +
+
+ + +

+

+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/lib/customizations.ts b/apps/native/src/components/widget/onboarding/lib/customizations.ts new file mode 100644 index 000000000..0de46908c --- /dev/null +++ b/apps/native/src/components/widget/onboarding/lib/customizations.ts @@ -0,0 +1,187 @@ +import type { HomebrewState, LaunchdItem, SystemDefaultsScan } from "@/ipc/types"; + +export type CustomizationGroupId = + | "macos-settings" + | "homebrew-casks" + | "homebrew-taps" + | "launch-agents"; + +export type GroupSeverity = "info" | "warning"; + +export interface CustomizationItem { + id: string; + /** Human-readable label, e.g. "Dock — Group windows by application" */ + label: string; + /** The detected value in mono, e.g. "dock.expose-group-apps = true" */ + detail: string; + /** Small right-aligned hint, e.g. "default: false" or "tap" */ + meta: string; + /** The nix line this item would add to the config */ + nixLine: string; +} + +export interface CustomizationGroup { + id: CustomizationGroupId; + title: string; + description: string; + /** The shell command nixmac ran to detect these, shown in mono */ + command: string; + /** Extra context appended after the command, e.g. "57 known keys" */ + commandNote?: string; + /** Where the tracked items would be written */ + landingPath: string; + severity: GroupSeverity; + items: CustomizationItem[]; +} + +function defaultsGroup(scan: SystemDefaultsScan): CustomizationGroup | null { + if (!scan.defaults.length) return null; + return { + id: "macos-settings", + title: "untracked macOS settings", + description: + "Preferences you've changed in System Settings. Capture them as code so a fresh install matches.", + command: "defaults read", + commandNote: `${scan.totalScanned} known keys`, + landingPath: "modules/darwin/defaults.nix", + severity: "info", + items: scan.defaults.map((d) => ({ + id: `default-${d.nixKey}`, + label: d.category ? `${d.category} — ${d.label}` : d.label, + detail: `${d.nixKey} = ${d.currentValue}`, + meta: `default: ${d.defaultValue}`, + nixLine: `${d.nixKey} = ${d.currentValue};`, + })), + }; +} + +function casksGroup(state: HomebrewState): CustomizationGroup | null { + if (!state.isInstalled || !state.casks.length) return null; + return { + id: "homebrew-casks", + title: "untracked Homebrew casks", + description: "Homebrew casks already on disk but not declared in your flake.", + command: "brew list --cask", + landingPath: ".nixmac/homebrew/data.json", + severity: "warning", + items: state.casks.map((name) => ({ + id: `cask-${name}`, + label: name, + detail: "Homebrew cask", + meta: "cask", + nixLine: `homebrew.casks = [ "${name}" ];`, + })), + }; +} + +function tapsGroup(state: HomebrewState): CustomizationGroup | null { + if (!state.isInstalled || !state.taps.length) return null; + return { + id: "homebrew-taps", + title: "untracked Homebrew taps", + description: "Homebrew taps already configured but not declared in your flake.", + command: "brew tap", + landingPath: ".nixmac/homebrew/data.json", + severity: "warning", + items: state.taps.map((name) => ({ + id: `tap-${name}`, + label: name, + detail: "Homebrew tap", + meta: "tap", + nixLine: `homebrew.taps = [ "${name}" ];`, + })), + }; +} + +function launchdGroup(items: LaunchdItem[]): CustomizationGroup | null { + if (!items.length) return null; + return { + id: "launch-agents", + title: "untracked launch agents", + description: "Background services started by launchd that aren't declared in your flake yet.", + command: "launchctl list", + landingPath: "modules/darwin/launchd.nix", + severity: "warning", + items: items.map((item) => ({ + id: `launchd-${item.label}`, + label: item.name || item.label, + detail: item.label, + meta: item.scope, + nixLine: `launchd.user.agents.${item.name || "service"}.serviceConfig.ProgramArguments = [ ${item.programArguments + .map((a) => `"${a}"`) + .join(" ")} ];`, + })), + }; +} + +/** Assemble the non-empty customization groups from the real scanner results. */ +export function buildCustomizationGroups(inputs: { + defaults: SystemDefaultsScan; + homebrew: HomebrewState; + launchd: LaunchdItem[]; +}): CustomizationGroup[] { + return [ + defaultsGroup(inputs.defaults), + casksGroup(inputs.homebrew), + tapsGroup(inputs.homebrew), + launchdGroup(inputs.launchd), + ].filter((g): g is CustomizationGroup => g !== null); +} + +export function totalCustomizations(groups: CustomizationGroup[]): number { + return groups.reduce((sum, g) => sum + g.items.length, 0); +} + +/** A small static sample used by Storybook and as a graceful fallback. */ +export const MOCK_CUSTOMIZATION_GROUPS: CustomizationGroup[] = [ + { + id: "macos-settings", + title: "untracked macOS settings", + description: + "Preferences you've changed in System Settings. Capture them as code so a fresh install matches.", + command: "defaults read", + commandNote: "57 known keys", + landingPath: "modules/darwin/defaults.nix", + severity: "info", + items: [ + { + id: "default-dock", + label: "Dock — Group windows by application in Mission Control", + detail: "system.defaults.dock.expose-group-apps = true", + meta: "default: false", + nixLine: "system.defaults.dock.expose-group-apps = true;", + }, + { + id: "default-wm", + label: "Window Manager — Hide desktop items in Stage Manager", + detail: "system.defaults.WindowManager.HideDesktop = true", + meta: "default: false", + nixLine: "system.defaults.WindowManager.HideDesktop = true;", + }, + ], + }, + { + id: "homebrew-casks", + title: "untracked Homebrew casks", + description: "Homebrew casks already on disk but not declared in your flake.", + command: "brew list --cask", + landingPath: ".nixmac/homebrew/data.json", + severity: "warning", + items: [ + { + id: "cask-raycast", + label: "raycast", + detail: "Homebrew cask", + meta: "cask", + nixLine: 'homebrew.casks = [ "raycast" ];', + }, + { + id: "cask-ghostty", + label: "ghostty", + detail: "Homebrew cask", + meta: "cask", + nixLine: 'homebrew.casks = [ "ghostty" ];', + }, + ], + }, +]; diff --git a/apps/native/src/components/widget/onboarding/lib/flake-ref.ts b/apps/native/src/components/widget/onboarding/lib/flake-ref.ts new file mode 100644 index 000000000..261fef64b --- /dev/null +++ b/apps/native/src/components/widget/onboarding/lib/flake-ref.ts @@ -0,0 +1,202 @@ +export interface ParsedFlakeRef { + valid: boolean; + /** The recognized flakeref type from the Nix manual. */ + type: + | "github" + | "gitlab" + | "sourcehut" + | "git" + | "mercurial" + | "tarball" + | "path" + | "indirect" + | "unknown"; + /** Human-friendly description of what this reference points to. */ + label: string; + /** Hint shown under the input. */ + hint: string; + /** + * Whether the current native backend can import this kind of reference. + * The backend imports GitHub repos (owner/repo) and local paths directly; + * other flakeref kinds are recognized but not yet wired. + */ + importable: boolean; +} + +const ARCHIVE_RE = /\.(zip|tar|tgz|tar\.gz|tar\.xz|tar\.bz2|tar\.zst)$/i; + +/** + * Best-effort parser/validator for Nix flake references. Mirrors the types in + * the Nix manual (github, gitlab, sourcehut, git, mercurial, tarball/file, + * path, indirect). Validates shape only — it does not fetch anything. + */ +export function parseFlakeRef(raw: string): ParsedFlakeRef { + const input = raw.trim(); + + if (!input) { + return { + valid: false, + type: "unknown", + label: "", + hint: "Paste a flake reference to continue.", + importable: false, + }; + } + + // github:owner/repo(/ref-or-rev)? + const gh = input.match(/^github:([\w.-]+)\/([\w.-]+)(?:\/([\w./-]+))?/i); + if (gh) { + const [, owner, repo, refOrRev] = gh; + return { + valid: true, + type: "github", + label: `GitHub · ${owner}/${repo}${refOrRev ? ` @ ${refOrRev}` : ""}`, + hint: "Fetched from GitHub — fast and no full clone.", + importable: true, + }; + } + + // gitlab:owner/repo + const gl = input.match(/^gitlab:([\w.%-]+)\/([\w.-]+)(?:\/([\w./-]+))?/i); + if (gl) { + const [, owner, repo] = gl; + return { + valid: true, + type: "gitlab", + label: `GitLab · ${owner}/${repo}`, + hint: "GitLab imports aren't wired yet — use GitHub or a local folder.", + importable: false, + }; + } + + // sourcehut:~owner/repo + const sh = input.match(/^sourcehut:(~[\w.-]+)\/([\w.-]+)/i); + if (sh) { + const [, owner, repo] = sh; + return { + valid: true, + type: "sourcehut", + label: `SourceHut · ${owner}/${repo}`, + hint: "SourceHut imports aren't wired yet — use GitHub or a local folder.", + importable: false, + }; + } + + // git, git+https, git+ssh, git+file, git:// + if (/^git(\+(https?|ssh|file|git))?:\/\/.+/i.test(input)) { + return { + valid: true, + type: "git", + label: "Git repository", + hint: "Raw git refs aren't wired yet — use GitHub or a local folder.", + importable: false, + }; + } + + // mercurial: hg+http(s)/ssh/file + if (/^hg\+(https?|ssh|file):\/\/.+/i.test(input)) { + return { + valid: true, + type: "mercurial", + label: "Mercurial repository", + hint: "Mercurial imports aren't wired yet — use GitHub or a local folder.", + importable: false, + }; + } + + // tarball+http / file+http or any http(s) ending in an archive extension + if ( + /^(tarball|file)\+https?:\/\/.+/i.test(input) || + (/^https?:\/\/.+/i.test(input) && ARCHIVE_RE.test(input.split("?")[0])) + ) { + return { + valid: true, + type: "tarball", + label: "Tarball flake", + hint: "Remote tarballs aren't wired yet — use GitHub or a local folder.", + importable: false, + }; + } + + // plain https URL (treated as tarball/file fetcher) + if (/^https?:\/\/.+/i.test(input)) { + return { + valid: true, + type: "tarball", + label: "Remote flake (http)", + hint: "Remote URLs aren't wired yet — use GitHub or a local folder.", + importable: false, + }; + } + + // path: explicit, absolute, ~ or ./ relative + if (/^(path:|~|\/|\.\/|\.\.\/|\.$)/.test(input)) { + return { + valid: true, + type: "path", + label: "Local path", + hint: "Points to a directory on this machine that contains a flake.nix.", + importable: true, + }; + } + + // indirect registry id, e.g. nixpkgs or nixpkgs/nixos-unstable + if (/^[\w.-]+(\/[\w./-]+)?$/.test(input)) { + return { + valid: true, + type: "indirect", + label: `Registry · ${input}`, + hint: "Registry refs aren't wired yet — use GitHub or a local folder.", + importable: false, + }; + } + + return { + valid: false, + type: "unknown", + label: "", + hint: "This doesn't look like a valid flake reference.", + importable: false, + }; +} + +/** Example refs surfaced as quick-fill chips in the UI. */ +export const EXAMPLE_REFS: { ref: string; note: string }[] = [ + { ref: "github:alice/nix-darwin-config", note: "GitHub repo" }, + { ref: "~/Documents/nix-darwin", note: "Local folder" }, + { ref: "github:alice/dotfiles/main", note: "GitHub branch" }, +]; + +export interface StarterTemplate { + id: string; + name: string; + description: string; + includes: string[]; + recommended?: boolean; +} + +/** Starter configurations offered to first-time users. */ +export const STARTER_TEMPLATES: StarterTemplate[] = [ + { + id: "darwin-hm", + name: "nix-darwin + home-manager", + description: "System settings plus per-user dotfiles. The best starting point for most people.", + includes: ["nix-darwin", "home-manager", "Sensible macOS defaults"], + recommended: true, + }, + { + id: "minimal", + name: "Minimal nix-darwin", + description: "Just the system layer. Add home-manager later whenever you want it.", + includes: ["nix-darwin", "A few CLI packages"], + }, + { + id: "batteries", + name: "Batteries included", + description: "Opinionated setup with common developer tools and Homebrew casks wired in.", + includes: ["nix-darwin", "home-manager", "Homebrew casks", "Dev tooling"], + }, +]; + +/** Default directory a new starter configuration is written to. */ +export const DEFAULT_CONFIG_DIR = "~/.darwin"; diff --git a/apps/native/src/components/widget/onboarding/lib/inference.ts b/apps/native/src/components/widget/onboarding/lib/inference.ts new file mode 100644 index 000000000..2fd3da2be --- /dev/null +++ b/apps/native/src/components/widget/onboarding/lib/inference.ts @@ -0,0 +1,97 @@ +export type { InferenceConfig, InferenceMode } from "@nixmac/state"; + +export interface HostedPlan { + id: string; + name: string; + price: string; + blurb: string; + recommended?: boolean; +} + +export const HOSTED_PLANS: HostedPlan[] = [ + { + id: "starter", + name: "Starter", + price: "$0 + usage", + blurb: "Pay only for what you use. Great for occasional config edits.", + recommended: true, + }, + { + id: "pro", + name: "Pro", + price: "$20/mo", + blurb: "Higher rate limits and priority models for daily driving.", + }, +]; + +/** API-key fields on the app's UiPrefs that an onboarding provider maps to. */ +export type PrefsKeyField = "openrouterApiKey" | "openaiApiKey"; + +export interface InferenceProvider { + /** Matches the app's evolveProvider value (model-combobox ModelProvider). */ + id: "openrouter" | "openai"; + name: string; + models: string[]; + /** Default evolve model persisted with this provider. */ + defaultModel: string; + /** UiPrefs field the API key is written to. */ + prefsKeyField: PrefsKeyField; + keyPrefix?: string; + keyPlaceholder: string; + docsHint: string; +} + +/** + * Bring-your-own-key providers, aligned to what the native backend actually + * supports (OpenRouter + OpenAI direct). Keys persist to UiPrefs and the + * selection is written to evolveProvider/evolveModel — exactly like the + * Settings → AI Models tab. Local providers (Ollama/vLLM) are configured in + * Settings instead. + */ +export const BYOK_PROVIDERS: InferenceProvider[] = [ + { + id: "openrouter", + name: "OpenRouter", + models: [ + "anthropic/claude-sonnet-4", + "anthropic/claude-opus-4", + "openai/gpt-4o", + "google/gemini-2.5-pro", + ], + defaultModel: "anthropic/claude-sonnet-4", + prefsKeyField: "openrouterApiKey", + keyPrefix: "sk-or-", + keyPlaceholder: "sk-or-v1-…", + docsHint: "openrouter.ai → Keys", + }, + { + id: "openai", + name: "OpenAI", + models: ["gpt-4o", "gpt-4o-mini", "o4-mini"], + defaultModel: "gpt-4o", + prefsKeyField: "openaiApiKey", + keyPrefix: "sk-", + keyPlaceholder: "sk-…", + docsHint: "platform.openai.com → API Keys", + }, +]; + +export interface KeyValidation { + valid: boolean; + hint: string; +} + +/** Lightweight client-side sanity check before the live provider check. */ +export function validateKeyFormat(provider: InferenceProvider, key: string): KeyValidation { + const trimmed = key.trim(); + if (!trimmed) { + return { valid: false, hint: `Paste your ${provider.name} API key to continue.` }; + } + if (provider.keyPrefix && !trimmed.startsWith(provider.keyPrefix)) { + return { valid: false, hint: `${provider.name} keys start with “${provider.keyPrefix}”.` }; + } + if (trimmed.length < 20) { + return { valid: false, hint: "That key looks too short — double-check it." }; + } + return { valid: true, hint: "Looks well-formed. We'll verify it with the provider." }; +} diff --git a/apps/native/src/components/widget/onboarding/lib/onboarding.ts b/apps/native/src/components/widget/onboarding/lib/onboarding.ts new file mode 100644 index 000000000..14f43d288 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/lib/onboarding.ts @@ -0,0 +1,56 @@ +export type StepId = + | "permissions" + | "nix-setup" + | "setup" + | "customizations" + | "inference" + | "build"; + +export const STEPS: { id: StepId; label: string; description: string }[] = [ + { id: "permissions", label: "Permissions", description: "Grant macOS access" }, + { id: "nix-setup", label: "System Setup", description: "Install Nix & nix-darwin" }, + { id: "setup", label: "Import Flake", description: "Import your configuration" }, + { id: "customizations", label: "Import Customizations", description: "Capture existing tweaks" }, + { id: "inference", label: "AI Inference", description: "Hosted or your own key" }, + { id: "build", label: "First Build", description: "Apply your configuration" }, +]; + +/** Stable "Step X of N" label so step numbering stays correct as steps change. */ +export function stepEyebrow(id: StepId): string { + const index = STEPS.findIndex((s) => s.id === id); + return `Step ${index + 1} of ${STEPS.length}`; +} + +/** Inputs to the onboarding step machine — backend gates plus local progress. */ +export interface OnboardingStepInputs { + /** All required macOS permissions granted. */ + permissionsReady: boolean; + /** Nix and darwin-rebuild both detected (or test override). */ + nixReady: boolean; + /** A config dir + a valid host attribute are set. */ + flakeReady: boolean; + /** User finished (or skipped) the import-customizations step. */ + customizationsReviewed: boolean; + /** A resolved inference config exists. */ + hasInference: boolean; + /** User deferred inference to the build step. */ + inferenceSkipped: boolean; +} + +/** + * Returns the first onboarding gate that is not yet satisfied. Mirrors the + * app's computeCurrentStep for the first three gates, then continues into the + * session-local post-setup steps. Steps run strictly in order. + */ +export function computeOnboardingStep(inputs: OnboardingStepInputs): StepId { + if (!inputs.permissionsReady) return "permissions"; + if (!inputs.nixReady) return "nix-setup"; + if (!inputs.flakeReady) return "setup"; + // Importing existing customizations is optional, but the user must review + // the detected items before moving on. + if (!inputs.customizationsReviewed) return "customizations"; + // Inference is optional here — it can be configured now or deferred to the + // build step, where it becomes required. + if (!inputs.hasInference && !inputs.inferenceSkipped) return "inference"; + return "build"; +} diff --git a/apps/native/src/components/widget/onboarding/onboarding-flow.stories.tsx b/apps/native/src/components/widget/onboarding/onboarding-flow.stories.tsx new file mode 100644 index 000000000..744356961 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/onboarding-flow.stories.tsx @@ -0,0 +1,429 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import type React from "react"; +import { useEffect, useRef } from "react"; +import { OnboardingFlow } from "@/components/widget/onboarding/onboarding-flow"; +import { useOnboarding } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; +import { tauriAPI } from "@/ipc/api"; + +/** + * Interactive, fully-mocked onboarding stories. Each story is a clickable + * entry point into the real OnboardingFlow: a story-scoped backend patches the + * (Storybook) tauriAPI singleton so every action drives the real ViewModel / + * onboarding stores, letting you walk the whole flow end-to-end without a + * Tauri backend. + * + * - Permissions → click each "Request" / "Open Settings" to grant. + * - System Setup → "Check again" detects Nix on the 2nd probe. + * - Import Flake → GitHub / local / flake-ref / "Start from scratch" all work; + * they populate a config dir + hosts, then pick a host. + * - Import Customizations → "Scan this Mac" returns mocked defaults/casks/taps. + * - AI Inference → BYOK (saves a key) or hosted (sign in + mock card). + * - First Build → "Run build" streams a mocked log to success → celebration. + */ + +const meta = preview.meta({ + title: "Widget/Onboarding/OnboardingFlow", + component: OnboardingFlow, + parameters: { layout: "centered" }, + decorators: [ + (Story: React.ComponentType) => ( +
+ +
+ ), + ], +}); + +export default meta; + +const SAMPLE_HOSTS = ["macbook-pro", "mac-studio"]; +const STEP_ORDER = ["permissions", "nix-setup", "setup", "customizations", "inference", "build"]; + +const PERMISSION_DEFS = [ + { + id: "desktop", + name: "Desktop Folder Access", + description: "Lets nixmac read configs you keep on your Desktop.", + required: true, + canRequestProgrammatically: true, + instructions: null, + }, + { + id: "documents", + name: "Documents Folder Access", + description: "Most flakes live in Documents — we need to read them.", + required: true, + canRequestProgrammatically: true, + instructions: null, + }, + { + id: "admin", + name: "Administrator Privileges", + description: "Required to apply system changes with darwin-rebuild.", + required: true, + canRequestProgrammatically: false, + instructions: "You'll be prompted for your password when a change needs it.", + }, + { + id: "full-disk", + name: "Full Disk Access", + description: "Recommended so every file in your flake can be managed.", + required: false, + canRequestProgrammatically: false, + instructions: "System Settings → Privacy & Security → Full Disk Access", + }, +]; + +const MOCK_DEFAULTS = { + totalScanned: 57, + defaults: [ + { + nixKey: "system.defaults.dock.expose-group-apps", + label: "Group windows by application", + category: "Dock", + currentValue: "true", + defaultValue: "false", + }, + { + nixKey: "system.defaults.WindowManager.HideDesktop", + label: "Hide desktop items in Stage Manager", + category: "Window Manager", + currentValue: "true", + defaultValue: "false", + }, + { + nixKey: "system.defaults.NSGlobalDomain.AppleShowAllExtensions", + label: "Show all file extensions", + category: "Finder", + currentValue: "true", + defaultValue: "false", + }, + ], +}; + +const MOCK_HOMEBREW = { + isInstalled: true, + casks: ["raycast", "ghostty", "arc", "orbstack"], + brews: [], + taps: ["charmbracelet/tap", "withgraphite/tap"], + source: "brew", + lastChecked: 0, +}; + +const MOCK_LAUNCHD = [ + { + label: "com.example.redis", + scope: "LaunchAgent", + name: "redis", + programArguments: ["/opt/homebrew/bin/redis-server"], + runAtLoad: true, + keepAlive: true, + environmentVariables: {}, + standardOutPath: null, + standardErrorPath: null, + workingDirectory: null, + }, +]; + +const BUILD_LINES = [ + "building the system configuration...", + "evaluating flake...", + "these 14 derivations will be built:", + " /nix/store/…-darwin-system-25.05.drv", + "copying 132 paths from 'https://cache.nixos.org'...", + "activating system configuration...", + "setting up launchd services...", + "✓ switched to configuration 'macbook-pro'.", +]; + +/** + * Patches the Storybook tauriAPI singleton so onboarding actions drive the + * real ViewModel/onboarding stores. Returns a restore fn to undo the patches + * (so other stories in the snapshot runner aren't affected). + */ +function installBackend(startAt: string) { + const startIdx = Math.max(0, STEP_ORDER.indexOf(startAt)); + const state = { + permStatus: Object.fromEntries( + PERMISSION_DEFS.map((p) => [p.id, startIdx >= 1 ? "granted" : "pending"]), + ) as Record, + nix: startIdx >= 2 ? { installed: true, darwin: true } : { installed: null, darwin: null }, + nixProbes: 0, + configDir: startIdx >= 3 ? "/Users/demo/.darwin" : "", + hosts: startIdx >= 3 ? SAMPLE_HOSTS : [], + hostAttr: startIdx >= 3 ? SAMPLE_HOSTS[0] : "", + flakeExists: startIdx >= 3, + }; + + function permissionsState() { + const permissions = PERMISSION_DEFS.map((p) => ({ ...p, status: state.permStatus[p.id] })); + return { + permissions, + allRequiredGranted: permissions + .filter((p) => p.required) + .every((p) => p.status === "granted"), + lastChecked: 0, + }; + } + + function syncVM() { + useViewModel.setState({ + permissions: permissionsState(), + permissionsHydrated: true, + nixInstall: { + installed: state.nix.installed, + darwinRebuildAvailable: state.nix.darwin, + installing: false, + }, + preferences: { + configDir: state.configDir || null, + hostAttr: state.hostAttr || null, + repoRoot: null, + sendDiagnostics: false, + evolveProvider: null, + evolveModel: null, + summaryProvider: null, + summaryModel: null, + ollamaApiBaseUrl: null, + vllmApiBaseUrl: null, + confirmBuild: true, + confirmClear: true, + confirmRollback: true, + autoSummarizeOnFocus: false, + scanHomebrewOnStartup: false, + defaultToDiffTab: false, + experimentalSpinningMascot: false, + developerMode: false, + pinnedVersion: null, + updateChannel: "stable", + }, + hosts: state.hosts, + git: { headCommitHash: "abc1234", files: [], changes: [] } as any, + rebuildStatus: { isRunning: false, success: null, exitCode: null } as any, + rebuildLog: { lines: [], rawLines: [] }, + }); + } + + // Seed the stores for the entry point. + syncVM(); + useOnboarding.setState({ + trackedCustomizations: [], + customizationsReviewed: startIdx >= 4, + inference: + startIdx >= 5 + ? { + mode: "byok", + providerId: "openrouter", + providerName: "OpenRouter", + model: "anthropic/claude-sonnet-4", + } + : null, + inferenceSkipped: false, + buildComplete: false, + active: startIdx >= 3, + completed: false, + }); + + // ---- Patch tauriAPI methods, remembering originals for restore ---- + const saved: Array<[Record, string, unknown]> = []; + const ensure = (key: string) => { + if (!(tauriAPI as any)[key]) { + saved.push([tauriAPI as any, key, (tauriAPI as any)[key]]); + (tauriAPI as any)[key] = {}; + } + return (tauriAPI as any)[key]; + }; + const patch = (obj: Record, key: string, fn: unknown) => { + saved.push([obj, key, obj[key]]); + obj[key] = fn; + }; + + const setConfigWithHosts = (dir: string) => { + state.configDir = dir; + state.hosts = SAMPLE_HOSTS; + state.flakeExists = true; + syncVM(); + return { dir, changed: true }; + }; + + // permissions + patch(tauriAPI.permissions, "refresh", async () => { + syncVM(); + }); + patch(tauriAPI.permissions, "get", async () => permissionsState()); + patch(tauriAPI.permissions, "request", async (id: string) => { + state.permStatus[id] = "granted"; + syncVM(); + return { ...PERMISSION_DEFS.find((p) => p.id === id), status: "granted" }; + }); + patch(tauriAPI.permissions, "requestFullDiskAccess", async () => { + state.permStatus["full-disk"] = "granted"; + syncVM(); + }); + patch( + tauriAPI.permissions, + "checkFullDiskAccess", + async () => state.permStatus["full-disk"] === "granted", + ); + + // nix — first probe reports missing, subsequent probes report ready. + patch(tauriAPI.nix, "check", async () => { + state.nixProbes += 1; + state.nix = + state.nixProbes >= 2 + ? { installed: true, darwin: true } + : { installed: false, darwin: false }; + syncVM(); + return { + installed: state.nix.installed, + version: "2.20.0", + darwinRebuildAvailable: state.nix.darwin, + }; + }); + patch(tauriAPI.nix, "installState", async () => ({ + installed: state.nix.installed, + darwinRebuildAvailable: state.nix.darwin, + installing: false, + })); + + // config / flake — importing or picking a dir populates hosts; bootstrap + set-host finishes. + ensure("config"); + patch(tauriAPI.config, "getThisHostname", async () => "demo-mac"); + patch(tauriAPI.config, "pickDir", async () => + setConfigWithHosts("/Users/demo/Documents/nix-darwin"), + ); + patch(tauriAPI.config, "setDir", async (dir: string) => setConfigWithHosts(dir)); + patch(tauriAPI.config, "prepareNewDir", async (dir: string) => { + state.configDir = dir; + state.hosts = []; + state.flakeExists = false; + syncVM(); + return { dir, changed: true }; + }); + patch(tauriAPI.config, "importGithub", async () => setConfigWithHosts("/Users/demo/.darwin")); + patch(tauriAPI.config, "importZip", async () => setConfigWithHosts("/Users/demo/.darwin")); + patch(tauriAPI.config, "pickZip", async () => "/Users/demo/Downloads/nix-darwin.zip"); + patch(tauriAPI.config, "setHostAttr", async (host: string) => { + state.hostAttr = host; + syncVM(); + return { ok: true }; + }); + + ensure("flake"); + patch(tauriAPI.flake, "listHosts", async () => state.hosts); + patch(tauriAPI.flake, "exists", async () => state.flakeExists); + patch(tauriAPI.flake, "existsAt", async () => state.flakeExists); + patch(tauriAPI.flake, "bootstrapDefault", async (hostname: string) => { + state.hosts = [hostname || "demo-mac"]; + state.flakeExists = true; + syncVM(); + }); + + ensure("path"); + patch(tauriAPI.path, "normalize", async (input: string) => + input.startsWith("~/") ? `/Users/demo/${input.slice(2)}` : input, + ); + patch(tauriAPI.path, "exists", async () => true); + + // customizations scanners + ensure("scanner"); + patch(tauriAPI.scanner, "scanDefaults", async () => MOCK_DEFAULTS); + ensure("homebrew"); + patch(tauriAPI.homebrew, "getStateDiff", async () => MOCK_HOMEBREW); + ensure("launchd"); + patch(tauriAPI.launchd, "scanLaunchdItems", async () => MOCK_LAUNCHD); + + // inference + ensure("account"); + patch(tauriAPI.account, "signIn", async (email: string) => ({ + signedIn: true, + account: { email }, + credentialId: "demo", + })); + ensure("ui"); + patch(tauriAPI.ui, "setPrefs", async () => ({ ok: true })); + patch(tauriAPI.ui, "getPrefs", async () => ({})); + + // first build — stream a mocked log to success. + const timers: ReturnType[] = []; + ensure("darwin"); + patch(tauriAPI.darwin, "applyStreamStart", async () => { + useViewModel.setState({ + rebuildStatus: { isRunning: true, success: null, exitCode: null } as any, + rebuildLog: { lines: [], rawLines: [] }, + }); + BUILD_LINES.forEach((line, i) => { + timers.push( + setTimeout( + () => { + useViewModel.setState((s: any) => ({ + rebuildLog: { ...s.rebuildLog, rawLines: [...s.rebuildLog.rawLines, line] }, + })); + if (i === BUILD_LINES.length - 1) { + useViewModel.setState({ + rebuildStatus: { isRunning: false, success: true, exitCode: 0 } as any, + }); + } + }, + 300 + i * 320, + ), + ); + }); + return { ok: true }; + }); + patch(tauriAPI.darwin, "finalizeApply", async () => ({})); + patch(tauriAPI.darwin, "rebuildStatus", async () => useViewModel.getState().rebuildStatus); + + return () => { + timers.forEach(clearTimeout); + for (const [obj, key, value] of saved) obj[key] = value; + }; +} + +function OnboardingHarness({ startAt = "permissions" }: { startAt?: string }) { + // Patch synchronously on first render so child mount effects (e.g. the + // permissions probe) hit the mocked backend, then restore on unmount. + const restoreRef = useRef<(() => void) | null>(null); + if (restoreRef.current === null) { + restoreRef.current = installBackend(startAt); + } + useEffect(() => { + return () => { + restoreRef.current?.(); + restoreRef.current = null; + }; + }, []); + + return ; +} + +/** Full clickable flow from the very first step. */ +export const Playground = meta.story({ + render: () => , +}); + +export const Permissions = meta.story({ + render: () => , +}); + +export const NixSetup = meta.story({ + render: () => , +}); + +export const ImportFlake = meta.story({ + render: () => , +}); + +export const Customizations = meta.story({ + render: () => , +}); + +export const Inference = meta.story({ + render: () => , +}); + +export const FirstBuild = meta.story({ + render: () => , +}); diff --git a/apps/native/src/components/widget/onboarding/onboarding-flow.tsx b/apps/native/src/components/widget/onboarding/onboarding-flow.tsx new file mode 100644 index 000000000..5ecf448e2 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/onboarding-flow.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { OnboardingHeader } from "@/components/widget/onboarding/onboarding-header"; +import { OnboardingSidebar } from "@/components/widget/onboarding/onboarding-sidebar"; +import { OnboardingStepContent } from "@/components/widget/onboarding/onboarding-step-content"; +import { useOnboardingFlow } from "@/components/widget/onboarding/use-onboarding-flow"; + +/** + * The full onboarding experience: brand header + sidebar stepper + the active + * step. The first three gates are driven by the live ViewModel/IPC; the + * post-setup steps (customizations, inference, build) are driven by the + * session-local onboarding store. + */ +export function OnboardingFlow() { + const { currentStep, progress } = useOnboardingFlow(); + + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/onboarding-header.tsx b/apps/native/src/components/widget/onboarding/onboarding-header.tsx new file mode 100644 index 000000000..aaeecdfb8 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/onboarding-header.tsx @@ -0,0 +1,27 @@ +import { RotateCcw } from "lucide-react"; +import { useOnboarding } from "@nixmac/state"; +import { getTelemetry } from "@/lib/telemetry/instance"; + +export function OnboardingHeader() { + const reset = useOnboarding((s) => s.reset); + + return ( +
+
+ + nixmac +
+ +
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/onboarding-sidebar.tsx b/apps/native/src/components/widget/onboarding/onboarding-sidebar.tsx new file mode 100644 index 000000000..3cd704d2d --- /dev/null +++ b/apps/native/src/components/widget/onboarding/onboarding-sidebar.tsx @@ -0,0 +1,25 @@ +import { OnboardingStepper } from "@/components/widget/onboarding/stepper"; +import type { StepId } from "@/components/widget/onboarding/lib/onboarding"; + +interface OnboardingSidebarProps { + currentStep: StepId; + progress: number; +} + +export function OnboardingSidebar({ currentStep, progress }: OnboardingSidebarProps) { + return ( + + ); +} diff --git a/apps/native/src/components/widget/onboarding/onboarding-step-content.tsx b/apps/native/src/components/widget/onboarding/onboarding-step-content.tsx new file mode 100644 index 000000000..4d8aef60a --- /dev/null +++ b/apps/native/src/components/widget/onboarding/onboarding-step-content.tsx @@ -0,0 +1,50 @@ +import { BuildStep } from "@/components/widget/onboarding/steps/build-step"; +import { CustomizationsStep } from "@/components/widget/onboarding/steps/customizations-step"; +import { InferenceStep } from "@/components/widget/onboarding/steps/inference-step"; +import { NixSetupStep } from "@/components/widget/onboarding/steps/nix-setup-step"; +import { PermissionsStep } from "@/components/widget/onboarding/steps/permissions-step"; +import { SetupStep } from "@/components/widget/onboarding/steps/setup-step"; +import type { StepId } from "@/components/widget/onboarding/lib/onboarding"; +import { useOnboarding } from "@nixmac/state"; +import { getTelemetry } from "@/lib/telemetry/instance"; + +interface OnboardingStepContentProps { + currentStep: StepId; +} + +export function OnboardingStepContent({ currentStep }: OnboardingStepContentProps) { + const onboarding = useOnboarding(); + + return ( +
+
+ {currentStep === "permissions" && } + {currentStep === "nix-setup" && } + {currentStep === "setup" && } + {currentStep === "customizations" && ( + + )} + {currentStep === "inference" && ( + + )} + {currentStep === "build" && ( + { + getTelemetry().captureEvent({ name: "onboarding_completed" }); + onboarding.complete(); + }} + /> + )} +
+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/source/create-source.tsx b/apps/native/src/components/widget/onboarding/source/create-source.tsx new file mode 100644 index 000000000..f7beadcc1 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/source/create-source.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Check, Loader2, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DEFAULT_CONFIG_DIR, + STARTER_TEMPLATES, +} from "@/components/widget/onboarding/lib/flake-ref"; +import { useDarwinConfig } from "@/hooks/use-darwin-config"; +import { tauriAPI } from "@/ipc/api"; +import { useUiState } from "@nixmac/state"; +import { cn } from "@/lib/utils"; + +interface CreateSourceProps { + onCreated?: () => void; +} + +/** + * Scaffold a starter configuration: create an empty config dir, then bootstrap + * nixmac's default nix-darwin flake into it for the named host. + * + * NOTE: the backend bootstrap currently always writes the default scaffold; + * the template choice below is presentational until template-specific + * scaffolds land (tracked separately). + */ +export function CreateSource({ onCreated }: CreateSourceProps) { + const { prepareNewDir, bootstrap } = useDarwinConfig(); + const [templateId, setTemplateId] = useState("darwin-hm"); + const [hostName, setHostName] = useState(""); + const [dir, setDir] = useState(DEFAULT_CONFIG_DIR); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + // Suggest this Mac's hostname as the default config name. + useEffect(() => { + let cancelled = false; + tauriAPI.config + .getThisHostname() + .then((name) => { + if (!cancelled && name.trim()) setHostName((current) => current || name.trim()); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const host = (hostName.trim() || "this-mac").replace(/[^\w-]/g, "-"); + + async function create() { + setError(null); + setCreating(true); + try { + const normalized = await tauriAPI.path.normalize(dir.trim() || DEFAULT_CONFIG_DIR); + await prepareNewDir(normalized); + await bootstrap(host); + const storeError = useUiState.getState().error; + if (storeError) { + setError(storeError); + return; + } + onCreated?.(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setCreating(false); + } + } + + return ( +
+ {/* Template picker */} +
+ Pick a starting template +
+ {STARTER_TEMPLATES.map((t) => { + const active = templateId === t.id; + return ( + + ); + })} +
+
+ + {/* Host name */} +
+ + setHostName(e.target.value)} + placeholder="this-mac" + className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +

+ Becomes darwinConfigurations.{host} in your flake. +

+
+ + {/* Destination */} +
+ + setDir(e.target.value)} + className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +

+ We'll write a flake.nix here and initialize git. + You can push it to GitHub anytime later. +

+
+ + + + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/source/flake-ref-source.tsx b/apps/native/src/components/widget/onboarding/source/flake-ref-source.tsx new file mode 100644 index 000000000..94cef70fa --- /dev/null +++ b/apps/native/src/components/widget/onboarding/source/flake-ref-source.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Check, FileArchive, Link2, Loader2, TriangleAlert } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { EXAMPLE_REFS, parseFlakeRef } from "@/components/widget/onboarding/lib/flake-ref"; +import { useDarwinConfig } from "@/hooks/use-darwin-config"; +import { tauriAPI } from "@/ipc/api"; +import { cn } from "@/lib/utils"; + +interface FlakeRefSourceProps { + onImported?: () => void; +} + +/** + * Advanced import: accepts a GitHub ref (`github:owner/repo[/branch]`) or a + * local path, plus a `.zip` archive picker. Other flakeref kinds are + * recognized but gated until the backend wires them up. + */ +export function FlakeRefSource({ onImported }: FlakeRefSourceProps) { + const { setDir, importGithub, pickZip, importZip } = useDarwinConfig(); + const [value, setValue] = useState(""); + const [loading, setLoading] = useState(false); + const [zipLoading, setZipLoading] = useState(false); + const [error, setError] = useState(null); + + const parsed = useMemo(() => parseFlakeRef(value), [value]); + const touched = value.trim().length > 0; + const canUse = parsed.valid && parsed.importable; + + async function use() { + if (!canUse) return; + setError(null); + setLoading(true); + try { + if (parsed.type === "github") { + // github:owner/repo[/branch] -> owner/repo[#branch] for config.importGithub + const rest = value.trim().replace(/^github:/i, ""); + const [owner, repo, ...refParts] = rest.split("/"); + const branch = refParts.join("/"); + const repoRef = branch ? `${owner}/${repo}#${branch}` : `${owner}/${repo}`; + await importGithub(repoRef, ".darwin"); + } else { + const normalized = await tauriAPI.path.normalize(value.trim().replace(/^path:/i, "")); + await setDir(normalized); + } + onImported?.(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + } + + async function importArchive() { + setError(null); + const path = await pickZip(); + if (!path) return; + setZipLoading(true); + try { + await importZip(path, ".darwin"); + onImported?.(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setZipLoading(false); + } + } + + return ( +
+
+ +
+
+ +
+ {touched && parsed.valid ? ( + + {parsed.importable ? ( + + ) : touched && !parsed.valid ? ( + + + ) : ( + + Supports github:owner/repo and local paths today. + + )} +
+
+ +
+

+ Examples +

+
+ {EXAMPLE_REFS.map((ex) => ( + + ))} +
+
+ +
+ + +
+ + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/source/github-source.tsx b/apps/native/src/components/widget/onboarding/source/github-source.tsx new file mode 100644 index 000000000..4bb5f7932 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/source/github-source.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState } from "react"; +import { GitBranch, Loader2, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useDarwinConfig } from "@/hooks/use-darwin-config"; +import { cn } from "@/lib/utils"; + +interface GitHubSourceProps { + onImported?: () => void; +} + +const DEFAULT_DIR = ".darwin"; + +const EXAMPLES = ["nix-darwin/nix-darwin", "you/dotfiles", "you/nix-config#main"]; + +/** + * Import a nix-darwin flake from a public GitHub repo via the real + * `config.importGithub` command — takes `owner/repo` with an optional + * `#branch`. (OAuth + private-repo browsing is a future enhancement.) + */ +export function GitHubSource({ onImported }: GitHubSourceProps) { + const { importGithub } = useDarwinConfig(); + const [repoRef, setRepoRef] = useState(""); + const [dirName, setDirName] = useState(DEFAULT_DIR); + const [importing, setImporting] = useState(false); + const [error, setError] = useState(null); + + const targetName = dirName.trim() || DEFAULT_DIR; + const ready = repoRef.trim().length > 0; + + async function runImport() { + if (!ready) { + setError("Enter a GitHub reference like owner/repo"); + return; + } + setError(null); + setImporting(true); + try { + await importGithub(repoRef.trim(), targetName); + onImported?.(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setImporting(false); + } + } + + return ( +
+
+ +
+

Import from a GitHub repo

+

+ Pull your flake straight from a public repository — no local git required. +

+
+
+ +
+ +
+ github: + { + setRepoRef(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") void runImport(); + }} + spellCheck={false} + autoCapitalize="off" + autoComplete="off" + placeholder="owner/repo" + className="w-full bg-transparent font-mono text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ {EXAMPLES.map((ex) => ( + + ))} +
+
+ +
+ + setDirName(e.target.value)} + spellCheck={false} + autoCapitalize="off" + autoComplete="off" + className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +

+ Imported into ~/{targetName}. Add{" "} + #branch to the repo to clone a specific branch. +

+
+ + + + {error ?

{error}

: null} + +

+

+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/source/local-source.tsx b/apps/native/src/components/widget/onboarding/source/local-source.tsx new file mode 100644 index 000000000..eaae7f5d5 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/source/local-source.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { FolderOpen, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useDarwinConfig } from "@/hooks/use-darwin-config"; + +interface LocalSourceProps { + onImported?: () => void; +} + +/** Pick a local folder that already contains a flake.nix (real folder picker). */ +export function LocalSource({ onImported }: LocalSourceProps) { + const { pickDir } = useDarwinConfig(); + const [browsing, setBrowsing] = useState(false); + const [error, setError] = useState(null); + + async function browse() { + setError(null); + setBrowsing(true); + try { + const result = await pickDir(); + if (result) onImported?.(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBrowsing(false); + } + } + + return ( +
+
+ +

Choose a local folder

+

+ Select the folder that contains your flake.nix. Already + cloned your dotfiles? This is the quickest option. +

+ + {error ?

{error}

: null} +
+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/step-shell.tsx b/apps/native/src/components/widget/onboarding/step-shell.tsx new file mode 100644 index 000000000..a83008d05 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/step-shell.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +interface StepShellProps { + eyebrow: string; + title: string; + description: string; + children: ReactNode; + footer?: ReactNode; +} + +/** Shared header/body/footer scaffold for every onboarding step. */ +export function StepShell({ eyebrow, title, description, children, footer }: StepShellProps) { + return ( +
+
+

{eyebrow}

+

{title}

+

+ {description} +

+
+ +
{children}
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/stepper.tsx b/apps/native/src/components/widget/onboarding/stepper.tsx new file mode 100644 index 000000000..ffcd6653e --- /dev/null +++ b/apps/native/src/components/widget/onboarding/stepper.tsx @@ -0,0 +1,57 @@ +import { Check } from "lucide-react"; +import { STEPS, type StepId } from "@/components/widget/onboarding/lib/onboarding"; +import { cn } from "@/lib/utils"; + +interface StepperProps { + currentStep: StepId; +} + +/** Vertical sidebar stepper listing every onboarding step with its status. */ +export function OnboardingStepper({ currentStep }: StepperProps) { + const currentIndex = STEPS.findIndex((s) => s.id === currentStep); + + return ( +
    + {STEPS.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + + return ( +
  1. +
    + + {isComplete ? +
    + + {step.label} + + + {step.description} + +
    +
    +
  2. + ); + })} +
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/steps/build-step.tsx b/apps/native/src/components/widget/onboarding/steps/build-step.tsx new file mode 100644 index 000000000..4ae7d4f27 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/steps/build-step.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { + ArrowRight, + CheckCircle2, + CircleAlert, + Loader2, + Play, + RotateCcw, + Sparkles, + Terminal, + Wrench, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { StepShell } from "@/components/widget/onboarding/step-shell"; +import { InferenceSetup } from "@/components/widget/onboarding/inference/inference-setup"; +import { stepEyebrow } from "@/components/widget/onboarding/lib/onboarding"; + +// Lazy so lottie-web (and its canvas usage) stays out of the main bundle and +// the jsdom test graph — it only loads when the celebration actually shows. +const CelebrationOverlay = lazy(() => + import("@/components/widget/onboarding/celebration-overlay").then((m) => ({ + default: m.CelebrationOverlay, + })), +); +import type { InferenceConfig } from "@/components/widget/onboarding/lib/inference"; +import { useApply } from "@/hooks/use-apply"; +import { useViewModel } from "@nixmac/state"; +import { cn } from "@/lib/utils"; +import { getTelemetry } from "@/lib/telemetry/instance"; + +interface BuildStepProps { + /** Whether AI inference is already configured. */ + hasInference: boolean; + onConfigureInference: (config: InferenceConfig) => void; + /** Called when the user finishes onboarding from the success screen. */ + onComplete: () => void; +} + +type BuildStatus = "idle" | "running" | "error" | "success"; + +/** Common first-build failures surfaced as quick fixes. */ +const FIXES = [ + { + title: "Typo in a package or option name", + detail: + "An unknown attribute (like a misspelled package) is the most common first-build failure. Nix points to the file and line — open it and fix the highlighted spot.", + }, + { + title: "Stale flake inputs", + detail: "Run nix flake update to refresh pinned inputs, then rebuild.", + }, + { + title: "Uncommitted changes", + detail: "Nix only sees committed files in a flake. Commit your changes, then retry.", + }, +]; + +export function BuildStep({ hasInference, onConfigureInference, onComplete }: BuildStepProps) { + const { handleApply } = useApply(); + const rebuildStatus = useViewModel((s) => s.rebuildStatus); + const rawLines = useViewModel((s) => s.rebuildLog.rawLines); + const configDir = useViewModel((s) => s.preferences?.configDir ?? ""); + const host = useViewModel((s) => s.preferences?.hostAttr ?? "this-mac"); + + const [started, setStarted] = useState(false); + const [celebrate, setCelebrate] = useState(false); + const [dismissedCelebration, setDismissedCelebration] = useState(false); + const [trackedOutcome, setTrackedOutcome] = useState<"success" | "error" | null>(null); + const logRef = useRef(null); + + const command = `darwin-rebuild switch --flake ${configDir || "."}#${host}`; + + const status: BuildStatus = rebuildStatus?.isRunning + ? "running" + : rebuildStatus?.success === true + ? "success" + : rebuildStatus?.success === false + ? "error" + : "idle"; + + const buildStarted = started || status !== "idle"; + + // Auto-scroll the log panel as lines stream in. + useEffect(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; + }, [rawLines]); + + // Track first build outcome once per run. + useEffect(() => { + if (trackedOutcome !== null) return; + if (status === "success") { + getTelemetry().captureEvent({ name: "first_build_completed" }); + setTrackedOutcome("success"); + } else if (status === "error") { + getTelemetry().captureEvent({ name: "first_build_failed" }); + setTrackedOutcome("error"); + } + }, [status, trackedOutcome]); + + // Fire the celebration once the build succeeds AND inference is configured. + useEffect(() => { + if (status === "success" && hasInference && !dismissedCelebration) { + setCelebrate(true); + } + }, [status, hasInference, dismissedCelebration]); + + function runBuild() { + setStarted(true); + setTrackedOutcome(null); + getTelemetry().captureEvent({ name: "first_build_started" }); + void handleApply(); + } + + return ( + + {/* Command + run control */} +
+
+

+ Build command +

+ {command} +
+ {status === "idle" ? ( + + ) : status === "running" ? ( + + ) : status === "error" ? ( + + ) : ( + + + )} +
+ + {/* Terminal log panel */} +
+
+
+
+ {rawLines.length === 0 ? ( +

Logs will appear here once the build starts.

+ ) : ( + rawLines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+
+ + {/* Help panel on failure */} + {status === "error" ? ( +
+
+ + +
+

Build failed — let's fix it

+

+ Your Mac was not changed. Try the most likely fixes, then retry. +

+
+
+
    + {FIXES.map((fix) => ( +
  • +
  • + ))} +
+
+ ) : null} + + {/* Inference requirement — surfaces while the build runs if it was skipped + earlier. Must be completed before finishing. */} + {buildStarted && !hasInference ? ( +
+
+ + +
+

+ {status === "running" + ? "While this builds: set up AI inference" + : "One more step: set up AI inference"} +

+

+ nixmac needs an inference backend before you can start making changes. Finish this + to complete setup. +

+
+
+ +
+ ) : null} + + {/* Success summary */} + {status === "success" ? ( +
+
+ + +
+

{host} is now managed by nixmac

+

+ {hasInference + ? "Your first build is live. From here, every change runs through a build just like this one." + : "Your first build is live. Finish setting up AI inference above to open nixmac."} +

+
+
+ +
+ ) : null} + + {celebrate ? ( + + { + setCelebrate(false); + setDismissedCelebration(true); + onComplete(); + }} + /> + + ) : null} +
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/steps/customizations-step.tsx b/apps/native/src/components/widget/onboarding/steps/customizations-step.tsx new file mode 100644 index 000000000..bc6807300 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/steps/customizations-step.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + ArrowRight, + Braces, + Check, + ChevronDown, + Loader2, + Plus, + Radar, + SkipForward, + SlidersHorizontal, + Sparkles, + TriangleAlert, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { StepShell } from "@/components/widget/onboarding/step-shell"; +import { stepEyebrow } from "@/components/widget/onboarding/lib/onboarding"; +import { + buildCustomizationGroups, + MOCK_CUSTOMIZATION_GROUPS, + totalCustomizations, + type CustomizationGroup, +} from "@/components/widget/onboarding/lib/customizations"; +import { tauriAPI } from "@/ipc/api"; +import { cn } from "@/lib/utils"; +import { getTelemetry } from "@/lib/telemetry/instance"; + +type ScanState = "idle" | "scanning" | "done"; + +/** What the scanner inspects, surfaced as live progress lines. */ +const SCAN_TARGETS = [ + { label: "macOS preferences", command: "defaults read" }, + { label: "Homebrew casks", command: "brew list --cask" }, + { label: "Homebrew taps", command: "brew tap" }, + { label: "Launch agents", command: "launchctl list" }, +]; + +interface CustomizationsStepProps { + tracked: string[]; + onSetTracked: (ids: string[]) => void; + onContinue: () => void; +} + +/** Run the real read-only scanners and assemble customization groups. */ +async function runScan(): Promise { + const [defaults, homebrew, launchd] = await Promise.all([ + tauriAPI.scanner.scanDefaults().catch(() => ({ defaults: [], totalScanned: 0 })), + tauriAPI.homebrew.getStateDiff().catch(() => ({ + isInstalled: false, + casks: [], + brews: [], + taps: [], + source: null, + lastChecked: 0, + })), + tauriAPI.launchd.scanLaunchdItems().catch(() => []), + ]); + return buildCustomizationGroups({ defaults, homebrew, launchd }); +} + +export function CustomizationsStep({ tracked, onSetTracked, onContinue }: CustomizationsStepProps) { + const [scanState, setScanState] = useState("idle"); + const [groups, setGroups] = useState(null); + const [animationDone, setAnimationDone] = useState(false); + const trackedSet = new Set(tracked); + + function track(ids: string[]) { + onSetTracked([...new Set([...tracked, ...ids])]); + } + + function untrack(ids: string[]) { + const remove = new Set(ids); + onSetTracked(tracked.filter((id) => !remove.has(id))); + } + + const trackedCount = tracked.length; + + // Kick off the real scan when entering the scanning state. + useEffect(() => { + if (scanState !== "scanning") return; + let active = true; + runScan() + .then((result) => { + if (active) setGroups(result); + }) + .catch(() => { + if (active) setGroups([]); + }); + return () => { + active = false; + }; + }, [scanState]); + + // Show results once both the animation and the real scan have finished. + useEffect(() => { + if (scanState === "scanning" && animationDone && groups !== null) { + setScanState("done"); + } + }, [scanState, animationDone, groups]); + + function startScan() { + setGroups(null); + setAnimationDone(false); + getTelemetry().captureEvent({ name: "customizations_scanned" }); + setScanState("scanning"); + } + + // ---- Pre-scan empty state ---- + if (scanState === "idle") { + return ( + +
+ + + + + +

+ Scan this Mac for untracked settings +

+

+ Already set this Mac up by hand? We'll run a few read-only commands to detect what + you've customized and turn it into code. Nothing changes on your system — you + choose what to track afterward. +

+ +
    + {SCAN_TARGETS.map((t) => ( +
  • + {t.label} + $ {t.command} +
  • + ))} +
+ + +
+ +
+ +
+
+ ); + } + + // ---- In-progress scan ---- + if (scanState === "scanning") { + return ( + + setAnimationDone(true)} /> + + ); + } + + // ---- Results ---- + const resolved = groups ?? []; + const total = totalCustomizations(resolved); + + if (resolved.length === 0) { + return ( + +
+ + +

No untracked customizations detected

+

+ You can always import tweaks later from the Untracked tab. +

+
+
+ +
+
+ ); + } + + return ( + +
+

+ {total} customizations detected + across {resolved.length} categories +

+ 0 ? "bg-success/15 text-success" : "bg-muted text-muted-foreground", + )} + > + {trackedCount} tracked + +
+ +
+ {resolved.map((group) => ( + + ))} +
+ +

+ Use these as starting points — every change still goes through the standard plan → review → + save flow. You can also skip and import them later from the Untracked tab. +

+ +
+ + +
+
+ ); +} + +function ScanProgress({ onDone }: { onDone: () => void }) { + const [current, setCurrent] = useState(0); + const onDoneRef = useRef(onDone); + onDoneRef.current = onDone; + + useEffect(() => { + const timers: ReturnType[] = []; + SCAN_TARGETS.forEach((_, i) => { + timers.push(setTimeout(() => setCurrent(i + 1), (i + 1) * 700)); + }); + timers.push(setTimeout(() => onDoneRef.current(), SCAN_TARGETS.length * 700 + 500)); + return () => timers.forEach(clearTimeout); + }, []); + + return ( +
+
    + {SCAN_TARGETS.map((t, i) => { + const done = i < current; + const active = i === current; + return ( +
  • +
  • + ); + })} +
+
+ ); +} + +function GroupCard({ + group, + trackedSet, + onTrack, + onUntrack, +}: { + group: CustomizationGroup; + trackedSet: Set; + onTrack: (ids: string[]) => void; + onUntrack: (ids: string[]) => void; +}) { + const [expanded, setExpanded] = useState(group.severity === "info"); + const [showPreview, setShowPreview] = useState(false); + + const ids = group.items.map((i) => i.id); + const trackedIds = ids.filter((id) => trackedSet.has(id)); + const allTracked = trackedIds.length === ids.length; + const someTracked = trackedIds.length > 0; + const isWarning = group.severity === "warning"; + + return ( +
+ + + {expanded ? ( + <> +
+ {allTracked ? ( + + ) : ( + + )} + +
+ +

+ · Found · {group.items.length} +

+
    + {group.items.map((item) => { + const isTracked = trackedSet.has(item.id); + return ( +
  • +
    +

    {item.label}

    +

    + {item.detail} +

    +
    +
    + + {item.meta} + + {isTracked ? ( + + ) : ( + + )} +
    +
  • + ); + })} +
+ + {showPreview ? ( +
+
+                {group.items.map((item) => (
+                  
+ + + {item.nixLine} +
+ ))} +
+
+ ) : null} + + ) : null} +
+ ); +} + +// Exposed for Storybook/fallback wiring. +export { MOCK_CUSTOMIZATION_GROUPS }; diff --git a/apps/native/src/components/widget/onboarding/steps/inference-step.tsx b/apps/native/src/components/widget/onboarding/steps/inference-step.tsx new file mode 100644 index 000000000..fa33c976a --- /dev/null +++ b/apps/native/src/components/widget/onboarding/steps/inference-step.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Clock } from "lucide-react"; +import { StepShell } from "@/components/widget/onboarding/step-shell"; +import { InferenceSetup } from "@/components/widget/onboarding/inference/inference-setup"; +import { stepEyebrow } from "@/components/widget/onboarding/lib/onboarding"; +import type { InferenceConfig } from "@/components/widget/onboarding/lib/inference"; +import { getTelemetry } from "@/lib/telemetry/instance"; + +interface InferenceStepProps { + onConfigured: (config: InferenceConfig) => void; + onSkip: () => void; +} + +export function InferenceStep({ onConfigured, onSkip }: InferenceStepProps) { + return ( + + + +
+

+

+ +
+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/steps/nix-setup-step.tsx b/apps/native/src/components/widget/onboarding/steps/nix-setup-step.tsx new file mode 100644 index 000000000..738b80e5e --- /dev/null +++ b/apps/native/src/components/widget/onboarding/steps/nix-setup-step.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { open } from "@tauri-apps/plugin-shell"; +import { ArrowUpRight, Check, CircleAlert, Loader2, Terminal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { StepShell } from "@/components/widget/onboarding/step-shell"; +import { stepEyebrow } from "@/components/widget/onboarding/lib/onboarding"; +import { useNixInstall } from "@/hooks/use-nix-install"; +import { useViewModel } from "@nixmac/state"; +import { cn } from "@/lib/utils"; + +const NIX_INSTALLERS = [ + { + href: "https://determinate.systems/nix-installer/", + title: "Install Nix", + subtitle: "Determinate Systems installer (recommended)", + }, +] as const; + +const NIX_DARWIN_URL = "https://github.com/nix-darwin/nix-darwin"; + +async function openExternalUrl(url: string) { + try { + await open(url); + } catch (error) { + console.warn( + "Failed to open external URL with Tauri shell; falling back to window.open.", + error, + ); + window.open(url, "_blank"); + } +} + +type CheckStatus = "ok" | "missing" | "unknown" | "checking"; + +export function NixSetupStep() { + const nixInstalled = useViewModel((s) => s.nixInstall?.installed ?? null); + const darwinRebuildAvailable = useViewModel((s) => s.nixInstall?.darwinRebuildAvailable ?? null); + const { checkNix } = useNixInstall(); + const [isChecking, setIsChecking] = useState(false); + + const probing = nixInstalled === null; + + useEffect(() => { + if (nixInstalled === null) checkNix(); + }, [nixInstalled, checkNix]); + + async function recheck() { + setIsChecking(true); + try { + await checkNix(); + } finally { + setIsChecking(false); + } + } + + function statusFor(key: "nix" | "darwin"): CheckStatus { + if (isChecking || probing) return "checking"; + if (key === "nix") return nixInstalled === true ? "ok" : "missing"; + if (nixInstalled !== true) return "unknown"; + return darwinRebuildAvailable === true ? "ok" : "missing"; + } + + const CHECKS: { key: "nix" | "darwin"; label: string; hint: string }[] = [ + { key: "nix", label: "Nix package manager", hint: "The nix binary on your PATH" }, + { + key: "darwin", + label: "nix-darwin (darwin-rebuild)", + hint: "Applies your system configuration", + }, + ]; + + return ( + +
+
+
+
+ +
+ +
    + {CHECKS.map((check) => { + const status = statusFor(check.key); + return ( +
  • +
  • + ); + })} +
+
+ +
+ {NIX_INSTALLERS.map((installer, i) => ( + + ))} + +
+ +

+ Already installed everything? Hit “Check again” and nixmac will continue as soon as both + tools are detected. +

+
+ ); +} + +function InstallLink({ + step, + title, + subtitle, + href, +}: { + step: string; + title: string; + subtitle: string; + href: string; +}) { + return ( + { + event.preventDefault(); + void openExternalUrl(href); + }} + className="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent" + > + + {step} + + + + {title} + + {subtitle} + + + ); +} diff --git a/apps/native/src/components/widget/onboarding/steps/permissions-step.tsx b/apps/native/src/components/widget/onboarding/steps/permissions-step.tsx new file mode 100644 index 000000000..4d2fd3860 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/steps/permissions-step.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Check, ExternalLink, Loader2, Lock, ShieldCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { StepShell } from "@/components/widget/onboarding/step-shell"; +import { stepEyebrow } from "@/components/widget/onboarding/lib/onboarding"; +import { tauriAPI } from "@/ipc/api"; +import type { Permission } from "@/ipc/types"; +import { useViewModel } from "@nixmac/state"; +import { cn } from "@/lib/utils"; + +/** + * Permissions step. Real macOS permission state is mirrored from the backend + * cell into the ViewModel; this only triggers probes/requests and the + * `permissions_changed` round-trip updates the display. When every required + * permission is granted the onboarding machine advances on its own. + */ +export function PermissionsStep() { + const permissionsState = useViewModel((s) => s.permissions); + const [requesting, setRequesting] = useState(null); + + // Refresh permissions when the step mounts. + useEffect(() => { + tauriAPI.permissions.refresh().catch((error) => { + console.error("Failed to check permissions:", error); + }); + }, []); + + async function handleGrant(permission: Permission) { + setRequesting(permission.id); + try { + if (permission.id === "full-disk") { + await tauriAPI.permissions.requestFullDiskAccess(); + // Give the user a beat to grant access in System Settings, then re-probe. + await new Promise((resolve) => setTimeout(resolve, 1000)); + } else { + await tauriAPI.permissions.request(permission.id); + } + await tauriAPI.permissions.refresh(); + } catch (error) { + console.error("Failed to request permission:", error); + } finally { + setRequesting(null); + } + } + + const permissions = permissionsState?.permissions ?? []; + + return ( + +
    + {permissions.map((perm) => { + const isGranted = perm.status === "granted"; + const isRequesting = requesting === perm.id; + const canRequest = perm.canRequestProgrammatically; + + return ( +
  • +
    + +
    +
    + {perm.name} + + {perm.required ? "Required" : "Recommended"} + +
    +

    + {perm.description} +

    + {perm.instructions ? ( +

    + {perm.instructions} +

    + ) : null} +
    +
    + +
    + {isGranted ? ( + + + ) : ( + + )} +
    +
  • + ); + })} +
+ +

+ Administrator privileges are requested with a password prompt only when a change needs them. + Full Disk Access is optional but recommended for the smoothest experience. +

+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/steps/setup-step.tsx b/apps/native/src/components/widget/onboarding/steps/setup-step.tsx new file mode 100644 index 000000000..835481285 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/steps/setup-step.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + ArrowLeft, + Check, + ChevronDown, + ChevronRight, + GitBranch, + GitCommit, + HardDrive, + Link2, + Loader2, + Rocket, + Server, + Sparkles, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { StepShell } from "@/components/widget/onboarding/step-shell"; +import { stepEyebrow } from "@/components/widget/onboarding/lib/onboarding"; +import { GitHubSource } from "@/components/widget/onboarding/source/github-source"; +import { LocalSource } from "@/components/widget/onboarding/source/local-source"; +import { FlakeRefSource } from "@/components/widget/onboarding/source/flake-ref-source"; +import { CreateSource } from "@/components/widget/onboarding/source/create-source"; +import { useDarwinConfig } from "@/hooks/use-darwin-config"; +import { tauriAPI } from "@/ipc/api"; +import { useViewModel } from "@nixmac/state"; + +type Mode = "choose" | "import" | "create"; +type Method = "github" | "local" | "ref"; + +const METHODS: { id: Method; label: string; blurb: string; icon: typeof GitBranch }[] = [ + { + id: "github", + label: "GitHub", + blurb: "Pull your flake straight from a public repository. No local git required.", + icon: GitBranch, + }, + { + id: "local", + label: "Local folder", + blurb: "Point to a folder that already contains a flake.nix.", + icon: HardDrive, + }, + { + id: "ref", + label: "Flake reference", + blurb: "Paste a github: ref or a local path, or import a .zip.", + icon: Link2, + }, +]; + +function PathCard({ + icon: Icon, + title, + description, + badge, + onClick, + testId, +}: { + icon: typeof GitBranch; + title: string; + description: string; + badge?: string; + onClick: () => void; + testId?: string; +}) { + return ( + + ); +} + +function BackLink({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +export function SetupStep() { + const configDir = useViewModel((s) => s.preferences?.configDir ?? ""); + const hosts = useViewModel((s) => s.hosts); + const savedHost = useViewModel((s) => s.preferences?.hostAttr ?? ""); + const gitStatus = useViewModel((s) => s.git); + const { saveHost, bootstrap, isBootstrapping } = useDarwinConfig(); + + const [changing, setChanging] = useState(false); + const [mode, setMode] = useState("choose"); + const [method, setMethod] = useState("github"); + const [selectedHost, setSelectedHost] = useState(""); + const [flakeExists, setFlakeExists] = useState(null); + const [thisHostname, setThisHostname] = useState("this-mac"); + + const hasConfigDir = Boolean(configDir); + const showSources = !hasConfigDir || changing; + + // Once a config dir lands, leave the "change source" view. + useEffect(() => { + if (configDir) setChanging(false); + }, [configDir]); + + // Does the chosen directory already contain a flake.nix? + useEffect(() => { + let cancelled = false; + if (!configDir) { + setFlakeExists(false); + return; + } + setFlakeExists(null); + tauriAPI.flake + .existsAt(configDir) + .then((exists) => { + if (!cancelled) setFlakeExists(exists); + }) + .catch(() => { + if (!cancelled) setFlakeExists(false); + }); + return () => { + cancelled = true; + }; + }, [configDir]); + + useEffect(() => { + tauriAPI.config + .getThisHostname() + .then((name) => { + if (name.trim()) setThisHostname(name.trim()); + }) + .catch(() => {}); + }, []); + + // ---- No config dir yet: fork between existing vs. new ---- + if (showSources) { + if (mode === "choose") { + return ( + + {changing ? setChanging(false)} /> : null} +
+ setMode("import")} + /> + setMode("create")} + testId="onboarding-start-from-scratch" + /> +
+
+ ); + } + + if (mode === "create") { + return ( + + setMode("choose")} /> + setChanging(false)} /> + + ); + } + + // mode === "import" + return ( + + setMode("choose")} /> + + {method === "github" ? ( +
+ setChanging(false)} /> +
+

+ Or import another way +

+
+ {METHODS.filter((m) => m.id !== "github").map((m) => { + const Icon = m.icon; + return ( + + ); + })} +
+
+
+ ) : ( +
+ + {method === "local" ? setChanging(false)} /> : null} + {method === "ref" ? setChanging(false)} /> : null} +
+ )} +
+ ); + } + + // ---- Config dir chosen: confirm host ---- + const hasHosts = hosts.length > 0; + const effectiveHost = selectedHost || savedHost; + const needsInitialCommit = + flakeExists === true && (gitStatus === null || gitStatus.headCommitHash === ""); + const checkingFlake = flakeExists === null; + + return ( + + {/* Imported source summary */} +
+ +
+

+

+

{configDir}

+
+ +
+ + {/* Host selection */} +
+
+ +
+

Machine configuration

+

+ {hasHosts ? "Pick the host that matches this Mac" : "No hosts found in this flake"} +

+
+
+ + {checkingFlake ? ( +
+
+ ) : needsInitialCommit ? ( +
+

+ + flake.nix found but not committed. + {" "} + Nix needs a git commit to evaluate your flake. +

+ +
+ ) : hasHosts ? ( +
+
+ + +
+ +
+ ) : ( +
+

+ This flake has no darwinConfigurations yet. nixmac + can scaffold a starter config for this Mac. +

+ +
+ )} +
+
+ ); +} diff --git a/apps/native/src/components/widget/onboarding/use-onboarding-flow.ts b/apps/native/src/components/widget/onboarding/use-onboarding-flow.ts new file mode 100644 index 000000000..e260f8ab4 --- /dev/null +++ b/apps/native/src/components/widget/onboarding/use-onboarding-flow.ts @@ -0,0 +1,60 @@ +import { useEffect, useMemo } from "react"; +import { + computeOnboardingStep, + STEPS, + type StepId, +} from "@/components/widget/onboarding/lib/onboarding"; +import { useOnboarding } from "@nixmac/state"; +import { settings } from "@/lib/env"; +import { useViewModel } from "@nixmac/state"; + +export function useOnboardingFlow(): { currentStep: StepId; progress: number } { + const permissions = useViewModel((s) => s.permissions); + const permissionsHydrated = useViewModel((s) => s.permissionsHydrated); + const nixInstalled = useViewModel((s) => s.nixInstall?.installed ?? null); + const darwinRebuildAvailable = useViewModel((s) => s.nixInstall?.darwinRebuildAvailable ?? null); + const configDir = useViewModel((s) => s.preferences?.configDir ?? ""); + const host = useViewModel((s) => s.preferences?.hostAttr ?? ""); + const hosts = useViewModel((s) => s.hosts); + + const onboarding = useOnboarding(); + + const permissionsReady = !(permissionsHydrated && permissions && !permissions.allRequiredGranted); + const nixReady = + (nixInstalled === true && darwinRebuildAvailable === true) || + settings.NIX_INSTALLED_OVERRIDE === true; + const flakeReady = Boolean(configDir) && Boolean(host) && hosts.includes(host); + + const currentStep = useMemo( + () => + computeOnboardingStep({ + permissionsReady, + nixReady, + flakeReady, + customizationsReviewed: onboarding.customizationsReviewed, + hasInference: Boolean(onboarding.inference), + inferenceSkipped: onboarding.inferenceSkipped, + }), + [ + permissionsReady, + nixReady, + flakeReady, + onboarding.customizationsReviewed, + onboarding.inference, + onboarding.inferenceSkipped, + ], + ); + + useEffect(() => { + if (flakeReady && !onboarding.active && !onboarding.completed) { + onboarding.beginPostSetup(); + } + }, [flakeReady, onboarding]); + + const progress = useMemo(() => { + const currentIndex = STEPS.findIndex((s) => s.id === currentStep); + return (currentIndex / (STEPS.length - 1)) * 100; + }, [currentStep]); + + return { currentStep, progress }; +} diff --git a/apps/native/src/components/widget/overlays/config-edit-overlay-panel.tsx b/apps/native/src/components/widget/overlays/config-edit-overlay-panel.tsx index 822a32d6c..d21d06290 100644 --- a/apps/native/src/components/widget/overlays/config-edit-overlay-panel.tsx +++ b/apps/native/src/components/widget/overlays/config-edit-overlay-panel.tsx @@ -1,7 +1,7 @@ "use client"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { Loader2 } from "lucide-react"; /** @@ -31,4 +31,4 @@ export function ConfigEditOverlayPanel() {
); -} \ No newline at end of file +} diff --git a/apps/native/src/components/widget/overlays/editor-panel.stories.tsx b/apps/native/src/components/widget/overlays/editor-panel.stories.tsx index 9f7db8ebb..4936d5604 100644 --- a/apps/native/src/components/widget/overlays/editor-panel.stories.tsx +++ b/apps/native/src/components/widget/overlays/editor-panel.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useEffect } from "react"; import { waitFor } from "storybook/test"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { EditorPanel } from "@/components/widget/overlays/editor-panel"; function EditorPanelWithState({ filePath }: { filePath: string }) { diff --git a/apps/native/src/components/widget/overlays/editor-panel.tsx b/apps/native/src/components/widget/overlays/editor-panel.tsx index e0dd54f21..5f491cf8d 100644 --- a/apps/native/src/components/widget/overlays/editor-panel.tsx +++ b/apps/native/src/components/widget/overlays/editor-panel.tsx @@ -1,7 +1,7 @@ import { X } from "lucide-react"; import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react"; import { Button } from "@/components/ui/button"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; class EditorErrorBoundary extends Component< { children: ReactNode; onError: () => void }, diff --git a/apps/native/src/components/widget/overlays/evolve-overlay-panel.tsx b/apps/native/src/components/widget/overlays/evolve-overlay-panel.tsx index 4fe979727..85b805094 100644 --- a/apps/native/src/components/widget/overlays/evolve-overlay-panel.tsx +++ b/apps/native/src/components/widget/overlays/evolve-overlay-panel.tsx @@ -1,8 +1,8 @@ "use client"; import { EvolveProgress } from "@/components/widget/overlays/evolve-progress"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { clearEvolveEvents } from "@/viewmodel/evolution"; import { tauriAPI } from "@/ipc/api"; @@ -32,9 +32,7 @@ export function EvolveOverlayPanel() { return (
-
+
{/* Progress */}
0; + const hasRawContent = event.raw && event.raw !== event.summary && event.raw.length > 0; const formatTime = (ms: number): string => { const seconds = Math.floor(ms / 1000); @@ -201,9 +197,7 @@ function EventItem({ event, isLatest }: EventItemProps) { {event.summary} @@ -280,10 +274,7 @@ function parseQuestionChoices(raw: string): string[] | null { if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[1]); - if ( - Array.isArray(parsed) && - parsed.every((choice) => typeof choice === "string") - ) { + if (Array.isArray(parsed) && parsed.every((choice) => typeof choice === "string")) { return parsed; } } catch { @@ -325,9 +316,7 @@ function QuestionPrompt({

{event.summary}

-

- Answered: {input} -

+

Answered: {input}

@@ -394,12 +383,7 @@ function QuestionPrompt({ // Main Component // ============================================================================= -export function EvolveProgress({ - events, - isGenerating, - className, - onStop, -}: EvolveProgressProps) { +export function EvolveProgress({ events, isGenerating, className, onStop }: EvolveProgressProps) { const scrollRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); const prevEventsLengthRef = useRef(events.length); @@ -448,7 +432,11 @@ export function EvolveProgress({ )} - {isAnalyzing ? "Analyzing changes..." : isGenerating ? "Evolving..." : "Evolution Complete"} + {isAnalyzing + ? "Analyzing changes..." + : isGenerating + ? "Evolving..." + : "Evolution Complete"}
diff --git a/apps/native/src/components/widget/overlays/rebuild-overlay-panel.stories.tsx b/apps/native/src/components/widget/overlays/rebuild-overlay-panel.stories.tsx index 672eb77fe..191dcd8f5 100644 --- a/apps/native/src/components/widget/overlays/rebuild-overlay-panel.stories.tsx +++ b/apps/native/src/components/widget/overlays/rebuild-overlay-panel.stories.tsx @@ -2,8 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import React, { useEffect } from "react"; import { RebuildOverlayPanel } from "@/components/widget/overlays/rebuild-overlay-panel"; import type { RebuildStatus } from "@/ipc/types"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import type { RebuildContext, RebuildLine } from "@/types/rebuild"; import { makeRebuildStatus } from "@/utils/test-fixtures"; @@ -17,7 +17,12 @@ type RebuildScenario = Partial & { * Decorator that seeds the ViewModel rebuild slices (and the UiState rebuild * context) before rendering. */ -const withRebuildState = ({ context = "apply", lines = [], rawLines = [], ...status }: RebuildScenario) => { +const withRebuildState = ({ + context = "apply", + lines = [], + rawLines = [], + ...status +}: RebuildScenario) => { return (Story: () => React.ReactNode) => { useEffect(() => { useViewModel.setState({ @@ -59,9 +64,7 @@ export default meta; type Story = StoryObj; // Sample rebuild lines for different states -const startingLines: RebuildLine[] = [ - { id: 1, text: "🚀 Starting rebuild...", type: "info" }, -]; +const startingLines: RebuildLine[] = [{ id: 1, text: "🚀 Starting rebuild...", type: "info" }]; const buildingLines: RebuildLine[] = [ { id: 1, text: "🚀 Starting rebuild...", type: "info" }, @@ -97,36 +100,28 @@ const errorLines: RebuildLine[] = [ * Initial state when rebuild just started */ export const Starting: Story = { - decorators: [ - withRebuildState({ isRunning: true, lines: startingLines }), - ], + decorators: [withRebuildState({ isRunning: true, lines: startingLines })], }; /** * Building state with a few progress lines */ export const Building: Story = { - decorators: [ - withRebuildState({ isRunning: true, lines: buildingLines }), - ], + decorators: [withRebuildState({ isRunning: true, lines: buildingLines })], }; /** * Mid-build state with more progress */ export const MidBuild: Story = { - decorators: [ - withRebuildState({ isRunning: true, lines: midBuildLines }), - ], + decorators: [withRebuildState({ isRunning: true, lines: midBuildLines })], }; /** * Successfully completed rebuild */ export const Success: Story = { - decorators: [ - withRebuildState({ isRunning: false, lines: completedLines, success: true }), - ], + decorators: [withRebuildState({ isRunning: false, lines: completedLines, success: true })], }; /** @@ -139,8 +134,7 @@ export const InfiniteRecursionError: Story = { lines: errorLines, success: false, errorType: "infinite_recursion", - errorMessage: - "error: infinite recursion encountered at /nix/store/...-source/flake.nix:42", + errorMessage: "error: infinite recursion encountered at /nix/store/...-source/flake.nix:42", systemUntouched: true, }), ], @@ -178,8 +172,7 @@ export const BuildError: Story = { ], success: false, errorType: "build_error", - errorMessage: - "builder for '/nix/store/abc123-some-package.drv' failed with exit code 1", + errorMessage: "builder for '/nix/store/abc123-some-package.drv' failed with exit code 1", }), ], }; diff --git a/apps/native/src/components/widget/overlays/rebuild-overlay-panel.test.tsx b/apps/native/src/components/widget/overlays/rebuild-overlay-panel.test.tsx index 7feddaa5e..71a994cf2 100644 --- a/apps/native/src/components/widget/overlays/rebuild-overlay-panel.test.tsx +++ b/apps/native/src/components/widget/overlays/rebuild-overlay-panel.test.tsx @@ -4,8 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RebuildOverlayPanel } from "@/components/widget/overlays/rebuild-overlay-panel"; import type { RebuildStatus } from "@/ipc/types"; -import { initialUiState, useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { initialUiState, useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import type { RebuildContext } from "@/types/rebuild"; import { makeRebuildStatus } from "@/utils/test-fixtures"; diff --git a/apps/native/src/components/widget/overlays/rebuild-overlay-panel.tsx b/apps/native/src/components/widget/overlays/rebuild-overlay-panel.tsx index 1ac831222..3930d0e79 100644 --- a/apps/native/src/components/widget/overlays/rebuild-overlay-panel.tsx +++ b/apps/native/src/components/widget/overlays/rebuild-overlay-panel.tsx @@ -2,8 +2,8 @@ import { Button } from "@/components/ui/button"; import { useRebuildStream } from "@/hooks/use-rebuild-stream"; import { useRollback } from "@/hooks/use-rollback"; import { cn } from "@/lib/utils"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import type { RebuildErrorType, RebuildLine } from "@/types/rebuild"; import { AlertTriangle, @@ -155,16 +155,7 @@ function LoaderCore({ pendingCount?: number; children?: React.ReactNode; }) { - const skeletonWidths = [ - "w-24", - "w-32", - "w-28", - "w-20", - "w-36", - "w-30", - "w-22", - "w-40", - ]; + const skeletonWidths = ["w-24", "w-32", "w-28", "w-20", "w-36", "w-30", "w-22", "w-40"]; const itemHeight = 36; // Find the index of the most recently completed step (one before current) @@ -289,7 +280,7 @@ function LoaderCore({ return ( +

{errorMessage}

)} diff --git a/apps/native/src/components/widget/promptinput/begin-evolve-warning.tsx b/apps/native/src/components/widget/promptinput/begin-evolve-warning.tsx index d95ccbc31..ca8d49cb1 100644 --- a/apps/native/src/components/widget/promptinput/begin-evolve-warning.tsx +++ b/apps/native/src/components/widget/promptinput/begin-evolve-warning.tsx @@ -14,8 +14,8 @@ import { ConfigDirBadge } from "@/components/widget/badges/config-dir-badge"; import { useEvolve } from "@/hooks/use-evolve"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useRollback } from "@/hooks/use-rollback"; -import { useViewModel } from "@/stores/view-model"; -import { useUiState } from "@/stores/ui-state"; +import { useViewModel } from "@nixmac/state"; +import { useUiState } from "@nixmac/state"; import { Loader2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -111,16 +111,16 @@ export function BeginEvolveWarning({ open, onOpenChange, handleEvolve }: BeginEv ({f.changeType}) ))} - {files.length > 5 && ( -
  • …and {files.length - 5} more
  • - )} + {files.length > 5 &&
  • …and {files.length - 5} more
  • } )}
    -

    First, decide how to handle uncommitted changes.

    +

    + First, decide how to handle uncommitted changes. +

    {/* Option 1: Discard */}
    @@ -131,7 +131,9 @@ export function BeginEvolveWarning({ open, onOpenChange, handleEvolve }: BeginEv

    Discard changes

    -

    Permanently remove all uncommitted changes.

    +

    + Permanently remove all uncommitted changes. +

    {evolvePrompt}

    )} -
    diff --git a/apps/native/src/components/widget/promptinput/conversational-response.tsx b/apps/native/src/components/widget/promptinput/conversational-response.tsx index c0c5e7ab7..4582508b3 100644 --- a/apps/native/src/components/widget/promptinput/conversational-response.tsx +++ b/apps/native/src/components/widget/promptinput/conversational-response.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { Bot, X } from "lucide-react"; export function ConversationalResponse() { diff --git a/apps/native/src/components/widget/promptinput/homebrew-badge.tsx b/apps/native/src/components/widget/promptinput/homebrew-badge.tsx index e3faffa9e..e4356a77a 100644 --- a/apps/native/src/components/widget/promptinput/homebrew-badge.tsx +++ b/apps/native/src/components/widget/promptinput/homebrew-badge.tsx @@ -2,14 +2,10 @@ import { BadgeButton } from "@/components/ui/badge-button"; import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { countDiffItems, useHomebrewDiff } from "@/hooks/use-homebrew-diff"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { Package } from "lucide-react"; /** @@ -41,31 +37,17 @@ export function HomebrewBadge() { return ( - + {total} untracked Homebrew {total === 1 ? "item" : "items"} - +
    - {diff.taps.length > 0 && ( - - )} - {diff.brews.length > 0 && ( - - )} - {diff.casks.length > 0 && ( - - )} + {diff.taps.length > 0 && } + {diff.brews.length > 0 && } + {diff.casks.length > 0 && }
    diff --git a/apps/native/src/components/widget/settings/auto-config-field.tsx b/apps/native/src/components/widget/settings/auto-config-field.tsx index df923fe8d..297407983 100644 --- a/apps/native/src/components/widget/settings/auto-config-field.tsx +++ b/apps/native/src/components/widget/settings/auto-config-field.tsx @@ -52,7 +52,10 @@ export function AutoConfigField({ structName, field, current, onCommit }: Props) const labelRow = (
    -

    - Export the contents of settings.json{" "} - (per-device prefs like provider, model, and confirmations) to a file you can keep or share. - Import replaces all current settings with the contents of a previously exported file. - Repo-synced tuning values live in .nixmac/settings.json{" "} - inside your config directory and are versioned by git — not included in this export. + Export the contents of{" "} + settings.json (per-device prefs + like provider, model, and confirmations) to a file you can keep or share. Import replaces + all current settings with the contents of a previously exported file. Repo-synced tuning + values live in{" "} + .nixmac/settings.json inside your + config directory and are versioned by git — not included in this export.

    diff --git a/apps/native/src/components/widget/settings/developer-tab.stories.tsx b/apps/native/src/components/widget/settings/developer-tab.stories.tsx index 2e89b3113..95e07fa22 100644 --- a/apps/native/src/components/widget/settings/developer-tab.stories.tsx +++ b/apps/native/src/components/widget/settings/developer-tab.stories.tsx @@ -1,7 +1,7 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { makeGlobalPreferences } from "@/utils/test-fixtures"; import type React from "react"; import { useEffect } from "react"; @@ -30,7 +30,13 @@ export const Unpinned = meta.story({ decorators: [ (Story: React.ComponentType) => { useEffect(() => { - useViewModel.setState({ preferences: makeGlobalPreferences({ developerMode: true, pinnedVersion: null, updateChannel: "stable" }) }); + useViewModel.setState({ + preferences: makeGlobalPreferences({ + developerMode: true, + pinnedVersion: null, + updateChannel: "stable", + }), + }); }, []); return ; }, @@ -42,7 +48,13 @@ export const PinnedToPastVersion = meta.story({ decorators: [ (Story: React.ComponentType) => { useEffect(() => { - useViewModel.setState({ preferences: makeGlobalPreferences({ developerMode: true, pinnedVersion: "0.21.0", updateChannel: "develop" }) }); + useViewModel.setState({ + preferences: makeGlobalPreferences({ + developerMode: true, + pinnedVersion: "0.21.0", + updateChannel: "develop", + }), + }); }, []); return ; }, diff --git a/apps/native/src/components/widget/settings/developer-tab.tsx b/apps/native/src/components/widget/settings/developer-tab.tsx index d0f683590..aed4ae838 100644 --- a/apps/native/src/components/widget/settings/developer-tab.tsx +++ b/apps/native/src/components/widget/settings/developer-tab.tsx @@ -1,8 +1,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; -import { useViewModel } from "@/stores/view-model"; -import { useUiState } from "@/stores/ui-state"; +import { useViewModel } from "@nixmac/state"; +import { useUiState } from "@nixmac/state"; import { clearChangeMap } from "@/viewmodel/change-map"; import { clearEvolveEvents } from "@/viewmodel/evolution"; import { clearRebuildLog } from "@/viewmodel/rebuild"; @@ -43,7 +43,9 @@ export function DeveloperTab() { const [errorMessage, setErrorMessage] = useState(null); useEffect(() => { - getVersion().then(setCurrentVersion).catch(() => setCurrentVersion("unknown")); + getVersion() + .then(setCurrentVersion) + .catch(() => setCurrentVersion("unknown")); }, []); const handleInstall = async () => { @@ -77,7 +79,9 @@ export function DeveloperTab() { setErrorMessage(null); try { await clearPinnedVersion(); - setStatusMessage("Cleared pinned version. The auto-updater will check for the latest on next launch."); + setStatusMessage( + "Cleared pinned version. The auto-updater will check for the latest on next launch.", + ); } catch (err) { setErrorMessage(err instanceof Error ? err.message : String(err)); } @@ -91,7 +95,7 @@ export function DeveloperTab() { setStatusMessage( channel === "stable" ? "Using stable updates from main." - : "Using develop updates. The next auto-update check will read the develop channel." + : "Using develop updates. The next auto-update check will read the develop channel.", ); } catch (err) { setErrorMessage(err instanceof Error ? err.message : String(err)); @@ -100,7 +104,9 @@ export function DeveloperTab() { const handleClearTauriState = async () => { if ( - !window.confirm("Clear Tauri stores? This resets saved settings, routing state, build state, and caches.") + !window.confirm( + "Clear Tauri stores? This resets saved settings, routing state, build state, and caches.", + ) ) { return; } @@ -146,7 +152,6 @@ export function DeveloperTab() { } }; - const handleDisableDeveloper = async () => { try { await tauriAPI.ui.setPrefs({ developerMode: false }); @@ -160,7 +165,8 @@ export function DeveloperTab() {

    Developer

    - Hidden tools for debugging and bisecting regressions. Don't use these unless you know what you're doing. + Hidden tools for debugging and bisecting regressions. Don't use these unless you know what + you're doing.

    @@ -170,10 +176,11 @@ export function DeveloperTab() {
    Heads up
    - Installing a past version replaces your current .app{" "} - bundle on disk. The version number you enter must match an existing release at{" "} - releases.nixmac.com. Bisecting only works in release - builds — the dev binary doesn't ship the updater plugin. + Installing a past version replaces your current{" "} + .app bundle on disk. The version number + you enter must match an existing release at{" "} + releases.nixmac.com. Bisecting only + works in release builds — the dev binary doesn't ship the updater plugin.
    @@ -208,8 +215,9 @@ export function DeveloperTab() {

    - Stable follows releases from main. Develop follows signed - release-mode builds from develop. Version pins override the + Stable follows releases from main. + Develop follows signed release-mode builds from{" "} + develop. Version pins override the selected channel until you resume auto-update.

    @@ -229,7 +237,8 @@ export function DeveloperTab() { })}
    - Current channel: {updateChannel} + Current channel:{" "} + {updateChannel}
    @@ -243,8 +252,8 @@ export function DeveloperTab() { {pinnedVersion ? (
    - Pinned to v{pinnedVersion}. The silent - update check on launch is suppressed while pinned. + Pinned to v{pinnedVersion}. + The silent update check on launch is suppressed while pinned.

    - Enter a version that exists in the release bucket (look at git tag{" "} - for valid values). The signed bundle is downloaded from{" "} - releases.nixmac.com/<version>/, verified, and - installed. + Enter a version that exists in the release bucket (look at{" "} + git tag for valid values). The signed + bundle is downloaded from{" "} + + releases.nixmac.com/<version>/ + + , verified, and installed.

    - Reset saved Tauri plugin-store data when the widget gets stuck in the wrong step or cached data looks stale. - This clears saved settings, routing state, build state, prompt history, and model caches. + Reset saved Tauri plugin-store data when the widget gets stuck in the wrong step or + cached data looks stale. This clears saved settings, routing state, build state, prompt + history, and model caches.

    - diff --git a/apps/native/src/components/widget/settings/general-tab.tsx b/apps/native/src/components/widget/settings/general-tab.tsx index 6ec81f72f..147450d4c 100644 --- a/apps/native/src/components/widget/settings/general-tab.tsx +++ b/apps/native/src/components/widget/settings/general-tab.tsx @@ -10,7 +10,7 @@ import { import { BootstrapConfig } from "@/components/widget/controls/bootstrap-config"; import { DirectoryPicker } from "@/components/widget/controls/directory-picker"; import { getWebSiteUrl } from "@/lib/env"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import { useTelemetry } from "@/lib/telemetry/context"; import { getVersion } from "@tauri-apps/api/app"; @@ -38,7 +38,10 @@ async function openExternalUrl(url: string) { try { await open(url); } catch (error) { - console.warn("Failed to open external URL with Tauri shell; falling back to browser window.", error); + console.warn( + "Failed to open external URL with Tauri shell; falling back to browser window.", + error, + ); window.open(url, "_blank"); } } @@ -67,7 +70,16 @@ export function GeneralTab({
    - { + saveHost(value); + telemetry.captureEvent({ + name: "settings_changed", + props: { setting: "host" }, + }); + }} + value={host || undefined} + > @@ -121,7 +133,9 @@ export function GeneralTab({ try { await tauriAPI.ui.setPrefs({ sendDiagnostics: checked }); telemetry.setEnabled(checked); - telemetry.captureEvent({ name: checked ? "diagnostics_opt_in" : "diagnostics_opt_out" }); + telemetry.captureEvent({ + name: checked ? "diagnostics_opt_in" : "diagnostics_opt_out", + }); } catch (error) { // Revert the field value if persisting the preference fails sendDiagnosticsField.handleChange(previousValue); @@ -134,9 +148,7 @@ export function GeneralTab({
    Support Nixmac
    -
    - Help fund continued development. -
    +
    Help fund continued development.
    - {tapHint && !developerMode && ( -
    {tapHint}
    - )} + {tapHint && !developerMode &&
    {tapHint}
    } {developerMode && (
    Developer settings panel is enabled. -
    diff --git a/apps/native/src/components/widget/settings/preferences-tab.tsx b/apps/native/src/components/widget/settings/preferences-tab.tsx index 8a204e4d5..01d8098c0 100644 --- a/apps/native/src/components/widget/settings/preferences-tab.tsx +++ b/apps/native/src/components/widget/settings/preferences-tab.tsx @@ -1,6 +1,6 @@ import { Switch } from "@/components/ui/switch"; import { usePrefs } from "@/hooks/use-prefs"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; export function PreferencesTab() { const { setPref } = usePrefs(); @@ -21,7 +21,9 @@ export function PreferencesTab() {
    Build
    -
    Ask before rebuilding with changes
    +
    + Ask before rebuilding with changes +
    Rollback
    -
    Ask before rolling back to a previous commit
    +
    + Ask before rolling back to a previous commit +
    Auto-summarize on focus
    -
    Summarize unsummarized changes when the window is focused
    +
    + Summarize unsummarized changes when the window is focused +
    Diff Tab
    -
    Prefer Diff tab when reviewing changes
    +
    + Prefer Diff tab when reviewing changes +
    Scan Homebrew
    -
    Detect Homebrew drift and offer to resolve
    +
    + Detect Homebrew drift and offer to resolve +
    s.preferences?.configDir ?? ""); const hosts = useViewModel((s) => s.hosts); const host = useViewModel((s) => s.preferences?.hostAttr ?? ""); @@ -151,12 +155,14 @@ export function SettingsDialog() { form.setFieldValue("summaryProvider", summaryProvider); form.setFieldValue( "summaryModel", - prefs.summaryModel ?? (summaryProvider === "openai" ? "gpt-4o-mini" : "openai/gpt-4o-mini"), + prefs.summaryModel ?? + (summaryProvider === "openai" ? "gpt-4o-mini" : "openai/gpt-4o-mini"), ); form.setFieldValue("evolveProvider", evolveProvider); form.setFieldValue( "evolveModel", - prefs.evolveModel ?? (evolveProvider === "openai" ? "gpt-4o" : "anthropic/claude-sonnet-4"), + prefs.evolveModel ?? + (evolveProvider === "openai" ? "gpt-4o" : "anthropic/claude-sonnet-4"), ); form.setFieldValue("sendDiagnostics", prefs.sendDiagnostics ?? false); diff --git a/apps/native/src/components/widget/settings/tuning-tab.tsx b/apps/native/src/components/widget/settings/tuning-tab.tsx index 06be6c0be..b64e3264b 100644 --- a/apps/native/src/components/widget/settings/tuning-tab.tsx +++ b/apps/native/src/components/widget/settings/tuning-tab.tsx @@ -55,7 +55,9 @@ export function TuningTab() { Evolution settings

    - Saved to .nixmac/settings.json in + Saved to + .nixmac/settings.json + in your config repo so they sync across machines.

    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 deleted file mode 100644 index e7fdda23e..000000000 --- a/apps/native/src/components/widget/steps/__snapshots__/setup-step.stories.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// 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/components/widget/steps/begin-step.tsx b/apps/native/src/components/widget/steps/begin-step.tsx index a97a09099..383c38311 100644 --- a/apps/native/src/components/widget/steps/begin-step.tsx +++ b/apps/native/src/components/widget/steps/begin-step.tsx @@ -16,8 +16,8 @@ import { useHomebrewDiff } from "@/hooks/use-homebrew-diff"; import { useLaunchdItems } from "@/hooks/use-launchd-items"; import { useSystemDefaultsScan } from "@/hooks/use-system-defaults-scan"; import { filesystemViewEnabled } from "@/lib/flags"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; export function BeginStep() { const setShowFilesystem = useUiState((s) => s.setShowFilesystem); diff --git a/apps/native/src/components/widget/steps/evolve-step.tsx b/apps/native/src/components/widget/steps/evolve-step.tsx index ebd6930d0..30659fbd0 100644 --- a/apps/native/src/components/widget/steps/evolve-step.tsx +++ b/apps/native/src/components/widget/steps/evolve-step.tsx @@ -11,18 +11,9 @@ import { Eraser, Loader2, Wrench } from "lucide-react"; import { NoiseBackground } from "@/components/ui/noise-background"; import { cn } from "@/lib/utils"; +const ACTIVE_GRADIENT = ["rgb(45, 212, 191)", "rgb(20, 184, 166)", "rgb(13, 148, 136)"] as const; -const ACTIVE_GRADIENT = [ - "rgb(45, 212, 191)", - "rgb(20, 184, 166)", - "rgb(13, 148, 136)", -] as const; - -const INACTIVE_GRADIENT = [ - "rgb(115, 115, 115)", - "rgb(82, 82, 82)", - "rgb(64, 64, 64)", -] as const; +const INACTIVE_GRADIENT = ["rgb(115, 115, 115)", "rgb(82, 82, 82)", "rgb(64, 64, 64)"] as const; /** * Evolve Review Step: AI session active, not yet built. @@ -52,7 +43,7 @@ export function EvolveStep() { Discard - { - const viewport = scrollAreaRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ); + const viewport = scrollAreaRef.current?.querySelector("[data-radix-scroll-area-viewport]"); viewport?.scrollTo({ top: 0, behavior: "smooth" }); setIsFlashing(true); }; @@ -48,9 +46,7 @@ export function HistoryStep() { // Scroll to top when preview activates so the synthetic commit is visible. useEffect(() => { if (!previewTargetHash) return; - const viewport = scrollAreaRef.current?.querySelector( - "[data-radix-scroll-area-viewport]", - ); + const viewport = scrollAreaRef.current?.querySelector("[data-radix-scroll-area-viewport]"); viewport?.scrollTo({ top: 0, behavior: "smooth" }); }, [previewTargetHash]); @@ -83,7 +79,9 @@ export function HistoryStep() { isRestoring={fi.item.hash === restoringHash} isPreview={fi.item.hash === PREVIEW_ITEM_HASH} isPreviewActive={!!previewTargetHash} - deactivateCount={fi.item.hash === PREVIEW_ITEM_HASH ? previewDeactivateCount : undefined} + deactivateCount={ + fi.item.hash === PREVIEW_ITEM_HASH ? previewDeactivateCount : undefined + } timeline={{ isFirst: fi.item.hash === firstCommitHash, isLast: fi.item.hash === lastCommitHash, @@ -101,10 +99,7 @@ export function HistoryStep() { ))}
    - + ); } diff --git a/apps/native/src/components/widget/steps/index.ts b/apps/native/src/components/widget/steps/index.ts index d062eb47f..4ef1118fd 100644 --- a/apps/native/src/components/widget/steps/index.ts +++ b/apps/native/src/components/widget/steps/index.ts @@ -5,6 +5,5 @@ export { ManualEvolveStep } from "./manual-evolve-step"; export { ManualCommitStep } from "./manual-commit-step"; export { HistoryStep } from "./history-step"; export { FilesystemStep } from "../filesystem/filesystem-step"; -export { NixSetupStep } from "./nix-setup-step"; -export { PermissionsStep } from "./permissions-step"; -export { SetupStep } from "./setup-step"; +// Onboarding steps (permissions, nix-setup, setup) now live under +// components/widget/onboarding and render via OnboardingFlow. diff --git a/apps/native/src/components/widget/steps/manual-evolve-step.tsx b/apps/native/src/components/widget/steps/manual-evolve-step.tsx index f7eb90649..a22b6d728 100644 --- a/apps/native/src/components/widget/steps/manual-evolve-step.tsx +++ b/apps/native/src/components/widget/steps/manual-evolve-step.tsx @@ -9,22 +9,14 @@ import { SummaryOrDiff } from "@/components/widget/summaries/summary-or-diff"; import { useApply } from "@/hooks/use-apply"; import { useEvolve } from "@/hooks/use-evolve"; import { cn } from "@/lib/utils"; -import { useViewModel } from "@/stores/view-model"; -import { useUiState } from "@/stores/ui-state"; +import { useViewModel } from "@nixmac/state"; +import { useUiState } from "@nixmac/state"; import { Loader2, Wrench } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; -const ACTIVE_GRADIENT = [ - "rgb(45, 212, 191)", - "rgb(20, 184, 166)", - "rgb(13, 148, 136)", -] as const; +const ACTIVE_GRADIENT = ["rgb(45, 212, 191)", "rgb(20, 184, 166)", "rgb(13, 148, 136)"] as const; -const INACTIVE_GRADIENT = [ - "rgb(115, 115, 115)", - "rgb(82, 82, 82)", - "rgb(64, 64, 64)", -] as const; +const INACTIVE_GRADIENT = ["rgb(115, 115, 115)", "rgb(82, 82, 82)", "rgb(64, 64, 64)"] as const; type BuildCheckStatus = "checking" | "passed" | "failed"; @@ -36,9 +28,7 @@ export function ManualEvolveStep() { const { handleApply } = useApply(); const { buildCheck } = useEvolve(); const gitStatus = useViewModel((s) => s.git); - const isApplyBusy = useUiState( - (s) => s.isProcessing && s.processingAction === "apply", - ); + const isApplyBusy = useUiState((s) => s.isProcessing && s.processingAction === "apply"); const rebuildRunning = useViewModel((s) => s.rebuildStatus?.isRunning ?? false); const [buildStatus, setBuildStatus] = useState("checking"); @@ -68,8 +58,7 @@ export function ManualEvolveStep() { }; }, [buildCheck, changeFingerprint]); - const buildReady = - buildStatus === "passed" && !isApplyBusy && !rebuildRunning; + const buildReady = buildStatus === "passed" && !isApplyBusy && !rebuildRunning; const buildChecking = buildStatus === "checking"; return ( @@ -84,9 +73,7 @@ export function ManualEvolveStep() { "w-fit rounded-full p-0.5 transition-opacity duration-300", !buildReady && "opacity-70 saturate-50", )} - gradientColors={ - buildReady ? [...ACTIVE_GRADIENT] : [...INACTIVE_GRADIENT] - } + gradientColors={buildReady ? [...ACTIVE_GRADIENT] : [...INACTIVE_GRADIENT]} noiseIntensity={buildReady ? 0.2 : 0.08} > ({ - mockCheckNix: vi.fn<() => Promise>(), - vmState: { - nixInstall: { - installed: false as boolean | null, - darwinRebuildAvailable: null as boolean | null, - } as { installed: boolean | null; darwinRebuildAvailable: boolean | null } | null, - }, -})); - -vi.mock("@/stores/view-model", () => ({ - useViewModel: (selector: (state: typeof vmState) => T) => selector(vmState), -})); - -vi.mock("@/hooks/use-nix-install", () => ({ - useNixInstall: () => ({ - checkNix: mockCheckNix, - }), -})); - -vi.mock("@tauri-apps/plugin-shell", () => ({ - open: vi.fn<(url: string) => Promise>(), -})); - -import { NixSetupStep } from "./nix-setup-step"; - -describe("", () => { - beforeEach(() => { - vmState.nixInstall = { installed: false, darwinRebuildAvailable: null }; - mockCheckNix.mockReset(); - mockCheckNix.mockResolvedValue(); - }); - - it("guides users to external Nix installers without running the installer", () => { - render(); - - expect(screen.getByText(/Nix is the package manager nixmac uses/i)).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /Determinate Systems installer/i })).toHaveAttribute( - "href", - "https://determinate.systems/nix-installer/", - ); - expect(screen.getByRole("link", { name: /Official NixOS installer/i })).toHaveAttribute( - "href", - "https://nixos.org/download/", - ); - expect(screen.queryByRole("button", { name: /Install Nix/i })).not.toBeInTheDocument(); - }); - - it("explains missing nix-darwin without auto-installing when Nix is installed", () => { - vmState.nixInstall = { installed: true, darwinRebuildAvailable: false }; - - render(); - - expect(screen.getByText(/Nix is installed, but nixmac cannot find darwin-rebuild/i)).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /nix-darwin instructions/i })).toHaveAttribute( - "href", - "https://github.com/LnL7/nix-darwin", - ); - expect(screen.getByRole("button", { name: /I've installed nix-darwin - check again/i })).toBeInTheDocument(); - }); - - it("checks detection again when the user clicks the recheck button", () => { - render(); - - fireEvent.click(screen.getByRole("button", { name: /I've installed Nix - check again/i })); - - expect(mockCheckNix).toHaveBeenCalledTimes(1); - }); - - it("checks the system when setup status is pending", async () => { - vmState.nixInstall = { installed: null, darwinRebuildAvailable: null }; - - render(); - - expect(screen.getByText(/Checking system/i)).toBeInTheDocument(); - await waitFor(() => expect(mockCheckNix).toHaveBeenCalledTimes(1)); - }); -}); diff --git a/apps/native/src/components/widget/steps/nix-setup-step.tsx b/apps/native/src/components/widget/steps/nix-setup-step.tsx deleted file mode 100644 index c57c900b9..000000000 --- a/apps/native/src/components/widget/steps/nix-setup-step.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { useNixInstall } from "@/hooks/use-nix-install"; -import { useViewModel } from "@/stores/view-model"; -import { open } from "@tauri-apps/plugin-shell"; -import { CheckCircle2, ExternalLink, Loader2, Package, RefreshCw } from "lucide-react"; -import { useEffect } from "react"; - -type NixSetupState = "checking" | "missing-nix" | "missing-darwin-rebuild" | "success"; - -function getNixSetupState(store: { - nixInstalled: boolean | null; - darwinRebuildAvailable: boolean | null; -}): NixSetupState { - if (store.nixInstalled === true && store.darwinRebuildAvailable === true) return "success"; - if (store.nixInstalled === null) return "checking"; - if (store.nixInstalled !== true) return "missing-nix"; - return "missing-darwin-rebuild"; -} - -const NIX_INSTALLERS = [ - { - href: "https://determinate.systems/nix-installer/", - title: "Determinate Systems installer", - description: "Recommended for most Macs: a polished installer with a clear uninstall path.", - }, - { - href: "https://nixos.org/download/", - title: "Official NixOS installer", - description: "Best if you want the upstream Nix project path and are comfortable following terminal setup steps.", - }, -] as const; - -const NIX_DARWIN_URL = "https://github.com/LnL7/nix-darwin"; - -async function openExternalUrl(url: string) { - try { - await open(url); - } catch (error) { - console.warn("Failed to open external URL with Tauri shell; falling back to browser window.", error); - window.open(url, "_blank"); - } -} - -function ExternalSetupLink({ - href, - title, - description, -}: { - href: string; - title: string; - description: string; -}) { - return ( - { - event.preventDefault(); - void openExternalUrl(href); - }} - className="flex items-start justify-between gap-3 rounded-md border border-border p-3 text-left transition-colors hover:bg-muted/50" - > - - {title} - {description} - - - - ); -} - -export function NixSetupStep() { - const nixInstalled = useViewModel((s) => s.nixInstall?.installed ?? null); - const darwinRebuildAvailable = useViewModel( - (s) => s.nixInstall?.darwinRebuildAvailable ?? null, - ); - const { checkNix } = useNixInstall(); - - const state = getNixSetupState({ nixInstalled, darwinRebuildAvailable }); - - useEffect(() => { - if (nixInstalled === null) { - checkNix(); - } - }, [nixInstalled, checkNix]); - - return ( -
    -
    -
    -
    - -
    -

    System Setup

    -

    - nixmac needs Nix and nix-darwin before it can manage this Mac. -

    -
    - - - {state === "checking" && ( -
    - - Checking system... -
    - )} - - {state === "missing-nix" && ( -
    -

    - Nix is the package manager nixmac uses to build and apply repeatable Mac - configuration changes. Install Nix first, then return here so nixmac can check - your system again. -

    -
    - {NIX_INSTALLERS.map((installer) => ( - - ))} -
    - -
    - )} - - {state === "missing-darwin-rebuild" && ( -
    -

    - Nix is installed, but nixmac cannot find darwin-rebuild. Install nix-darwin so - nixmac can build and apply macOS configuration changes, then check again. -

    - - -
    - )} - - {state === "success" && ( -
    - - Nix & nix-darwin are installed -
    - )} -
    -
    -
    - ); -} diff --git a/apps/native/src/components/widget/steps/permissions-step.tsx b/apps/native/src/components/widget/steps/permissions-step.tsx deleted file mode 100644 index dd962f280..000000000 --- a/apps/native/src/components/widget/steps/permissions-step.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useViewModel } from "@/stores/view-model"; -import { tauriAPI } from "@/ipc/api"; -import type { Permission, PermissionStatus } from "@/ipc/types"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Shield, Check, X, AlertCircle, Loader2 } from "lucide-react"; -import { useState, useEffect } from "react"; - -/** - * Permissions step component - checks and requests macOS permissions - * required for proper operation of nix-darwin. - * - * The permissions state is mirrored from the backend cell into the - * ViewModel; this component only triggers probes/requests and the - * `permissions_changed` round-trip updates the display. - */ -export function PermissionsStep() { - const permissionsState = useViewModel((state) => state.permissions); - const [isLoading, setIsLoading] = useState(null); - - // Refresh permissions when the component mounts - useEffect(() => { - tauriAPI.permissions.refresh().catch((error) => { - console.error("Failed to check permissions:", error); - }); - }, []); - - const handleRequestPermission = async (permissionId: string) => { - setIsLoading(permissionId); - try { - if (permissionId === "full-disk") { - // For FDA, use the native plugin to request - await tauriAPI.permissions.requestFullDiskAccess(); - // Wait a bit for user to potentially grant access, then re-check - await new Promise((resolve) => setTimeout(resolve, 1000)); - } else { - // For other permissions, use the backend - await tauriAPI.permissions.request(permissionId); - } - // Re-probe; the cell write emits `permissions_changed`. - await tauriAPI.permissions.refresh(); - } catch (error) { - console.error("Failed to request permission:", error); - } finally { - setIsLoading(null); - } - }; - - const handleRefreshAll = async () => { - setIsLoading("all"); - try { - await tauriAPI.permissions.refresh(); - } catch (error) { - console.error("Failed to refresh permissions:", error); - } finally { - setIsLoading(null); - } - }; - - const permissions = permissionsState?.permissions ?? []; - const allRequiredGranted = permissionsState?.allRequiredGranted ?? false; - - return ( -
    -
    - {/* Header */} -
    -
    - -
    -

    System Permissions

    -

    - Grant the following permissions to continue -

    -
    - - {/* Permissions List */} - -
    - {permissions.map((permission) => ( - handleRequestPermission(permission.id)} - /> - ))} -
    -
    - - {/* Actions */} -
    - -

    - {allRequiredGranted - ? "All required permissions granted!" - : "Grant all required permissions to continue"} -

    -
    -
    -
    - ); -} - -interface PermissionCardProps { - permission: Permission; - isLoading: boolean; - onRequest: () => void; -} - -function PermissionCard({ permission, isLoading, onRequest }: PermissionCardProps) { - const actionLabel = getActionLabel(permission.status); - const isGranted = permission.status === "granted"; - - return ( -
    -
    -
    -
    -

    {permission.name}

    - {permission.required && ( - - Required - - )} - -
    -

    {permission.description}

    - {permission.instructions && ( -
    -

    {permission.instructions}

    -
    - )} -
    -
    - -
    -
    -
    - ); -} - -function PermissionStatusBadge({ status }: { status: PermissionStatus }) { - const styles: Record = { - granted: "bg-console-success/10 text-console-success border-console-success/20", - denied: "bg-console-error/10 text-console-error border-console-error/20", - pending: "bg-secondary text-muted-foreground border-border", - unknown: "bg-secondary text-muted-foreground border-border", - }; - - const icons: Record = { - granted: , - denied: , - pending: , - unknown: , - }; - - const labels: Record = { - granted: "Granted", - denied: "Denied", - pending: "Pending", - unknown: "Unknown", - }; - - return ( - - {icons[status]} {labels[status]} - - ); -} - -function getActionLabel(status: PermissionStatus): string { - switch (status) { - case "granted": - return "Granted"; - case "denied": - return "Retry"; - case "pending": - case "unknown": - default: - return "Request"; - } -} diff --git a/apps/native/src/components/widget/steps/setup-step.stories.tsx b/apps/native/src/components/widget/steps/setup-step.stories.tsx deleted file mode 100644 index dffd5a5f7..000000000 --- a/apps/native/src/components/widget/steps/setup-step.stories.tsx +++ /dev/null @@ -1,90 +0,0 @@ -// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) -import preview from "#storybook/preview"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; -import { makeGlobalPreferences } from "@/utils/test-fixtures"; -import type React from "react"; -import { useEffect } from "react"; -import { SetupStep } from "./setup-step"; - -const meta = preview.meta({ - title: "Widget/Steps/SetupStep", - component: SetupStep, - parameters: { - layout: "fullscreen", - }, - decorators: [ - (Story: React.ComponentType) => ( -
    - -
    - ), - ], -}); - -export default meta; - -function installSetupMocks() { - if (typeof window === "undefined") return; - - (window as any).__TAURI_INTERNALS__ = { - invoke: async (cmd: string, args?: Record) => { - if (cmd === "path_normalize") { - const input = String(args?.input ?? ""); - return input.startsWith("~/") ? `/Users/demo/${input.slice(2)}` : input; - } - - if (cmd === "config_prepare_new_dir") { - return { - dir: String(args?.dir ?? "/Users/demo/.darwin"), - evolveState: null, - hosts: [], - }; - } - - if (cmd === "flake_exists_at" || cmd === "flake_exists") { - return false; - } - - if (cmd === "path_exists") { - return true; - } - - if (cmd === "config_set_host_attr" || cmd === "bootstrap_default_config") { - return { ok: true }; - } - - return null; - }, - }; -} - -type SetupStoryProps = { - configDir: string; - hosts: string[]; - host?: string; -}; - -function SetupStory({ configDir, hosts, host = "" }: SetupStoryProps) { - installSetupMocks(); - - useEffect(() => { - useViewModel.setState({ - preferences: makeGlobalPreferences({ configDir, hostAttr: host }), - hosts, - }); - useUiState.getState().setBootstrapping(false); - useUiState.getState().setError(null); - }, [configDir, host, hosts]); - - return ; -} - -export const DefaultConfigRequired = meta.story({ - render: () => ( - - ), -}); diff --git a/apps/native/src/components/widget/steps/setup-step.test.tsx b/apps/native/src/components/widget/steps/setup-step.test.tsx deleted file mode 100644 index 1e8dbcc9a..000000000 --- a/apps/native/src/components/widget/steps/setup-step.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type { GitStatus } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; -import { makeGlobalPreferences } from "@/utils/test-fixtures"; - -const { mockFlakeExistsAt, mockSaveHost } = vi.hoisted(() => ({ - mockFlakeExistsAt: vi.fn<(dir: string) => Promise>(), - mockSaveHost: vi.fn<(host: string) => Promise>(), -})); - -vi.mock("@/ipc/api", () => ({ - tauriAPI: { - flake: { - existsAt: mockFlakeExistsAt, - }, - }, -})); - -vi.mock("@/hooks/use-darwin-config", () => ({ - useDarwinConfig: () => ({ - saveHost: mockSaveHost, - }), -})); - -vi.mock("@/components/widget/controls/directory-picker", () => ({ - DirectoryPicker: ({ onConfigured }: { onConfigured?: () => void }) => ( - - ), -})); - -vi.mock("@/components/widget/controls/bootstrap-config", () => ({ - BootstrapConfig: ({ showLabel = true }: { showLabel?: boolean }) => ( -
    - Make initial commit -
    - ), -})); - -import { SetupStep } from "./setup-step"; - -function seedConfig(configDir: string | null, hosts: string[], host: string | null) { - useViewModel.setState({ - preferences: makeGlobalPreferences({ configDir, hostAttr: host }), - hosts, - }); -} - -describe("", () => { - beforeEach(() => { - seedConfig(null, [], null); - useViewModel.setState({ git: { headCommitHash: "abc123" } as GitStatus }); - mockFlakeExistsAt.mockReset(); - mockFlakeExistsAt.mockResolvedValue(true); - mockSaveHost.mockReset(); - mockSaveHost.mockResolvedValue(); - }); - - it("persists the displayed host when Next is clicked without changing the dropdown", async () => { - seedConfig("/Users/me/.nixmac", ["mbp"], "mbp"); - - render(); - - const next = await screen.findByRole("button", { name: "Next" }); - fireEvent.click(next); - - await waitFor(() => expect(mockSaveHost).toHaveBeenCalledWith("mbp")); - expect(mockSaveHost).not.toHaveBeenCalledWith(""); - }); - - it("does not show Next while waiting to create a default configuration", async () => { - seedConfig("/Users/me/.nixmac", [], null); - - render(); - fireEvent.click(screen.getByTestId("directory-picker")); - - expect(await screen.findByTestId("bootstrap-config")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); - }); - - it("does not show Next or initial commit before a host is filled", () => { - seedConfig("/Users/me/.nixmac", ["mbp", "mini"], ""); - - render(); - - expect(screen.queryByText("Make initial commit")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); - expect(mockFlakeExistsAt).not.toHaveBeenCalled(); - }); - - it("shows the initial commit UI instead of Next when a prefilled host has no initial commit", async () => { - seedConfig("/Users/me/.nixmac", ["mbp", "mini"], "mbp"); - useViewModel.setState({ git: { headCommitHash: "" } as GitStatus }); - mockFlakeExistsAt.mockResolvedValue(true); - - render(); - - expect(await screen.findByText("Make initial commit")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); - expect(mockFlakeExistsAt).toHaveBeenCalledWith("/Users/me/.nixmac"); - }); -}); diff --git a/apps/native/src/components/widget/steps/setup-step.tsx b/apps/native/src/components/widget/steps/setup-step.tsx deleted file mode 100644 index 2556b0edf..000000000 --- a/apps/native/src/components/widget/steps/setup-step.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { BootstrapConfig } from "@/components/widget/controls/bootstrap-config"; -import { DirectoryPicker } from "@/components/widget/controls/directory-picker"; -import { useDarwinConfig } from "@/hooks/use-darwin-config"; -import { tauriAPI } from "@/ipc/api"; -import { useViewModel } from "@/stores/view-model"; -import { Monitor } from "lucide-react"; -import { useEffect, useState } from "react"; - -export function SetupStep() { - const configDir = useViewModel((state) => state.preferences?.configDir ?? ""); - const hosts = useViewModel((state) => state.hosts); - const host = useViewModel((state) => state.preferences?.hostAttr ?? ""); - const gitStatus = useViewModel((state) => state.git); - const [configDirConfirmed, setConfigDirConfirmed] = useState(() => Boolean(configDir)); - const [selectedHost, setSelectedHost] = useState(""); - const [flakeExists, setFlakeExists] = useState(null); - - const { saveHost } = useDarwinConfig(); - - useEffect(() => { - setConfigDirConfirmed(Boolean(configDir)); - }, [configDir]); - - const hasConfigDir = Boolean(configDir) && configDirConfirmed; - const hasHosts = hasConfigDir && hosts.length > 0; - const effectiveHost = selectedHost || host; - const hasEffectiveHost = effectiveHost.trim().length > 0; - const needsInitialCommit = - hasEffectiveHost && - flakeExists === true && - (gitStatus === null || gitStatus.headCommitHash === ""); - const checkingInitialCommit = hasHosts && hasEffectiveHost && flakeExists === null; - - useEffect(() => { - let cancelled = false; - - if (!hasConfigDir || (hasHosts && !hasEffectiveHost)) { - setFlakeExists(false); - return; - } - - setFlakeExists(null); - tauriAPI.flake - .existsAt(configDir) - .then((exists) => { - if (!cancelled) setFlakeExists(exists); - }) - .catch(() => { - if (!cancelled) setFlakeExists(false); - }); - - return () => { - cancelled = true; - }; - }, [configDir, effectiveHost, hasConfigDir, hasEffectiveHost, hasHosts]); - - return ( -
    - -
    -

    - Welcome to nixmac -

    -

    - Let's set up your nix-darwin configuration -

    -
    - -
    - setConfigDirConfirmed(true)} - /> -
    - -
    - - {(hasConfigDir && configDirConfirmed) && ( -
    - {hasHosts ? ( - <> - - -

    - Select your nix-darwin host configuration -

    - - ) : ( - - )} - {hasHosts && needsInitialCommit && ( - - )} - {hasHosts && hasEffectiveHost && !needsInitialCommit && !checkingInitialCommit && ( - - )} -
    - )} -
    - ); -} diff --git a/apps/native/src/components/widget/summaries/analyze-current-button.tsx b/apps/native/src/components/widget/summaries/analyze-current-button.tsx index 9b9bebc8c..e1309de5c 100644 --- a/apps/native/src/components/widget/summaries/analyze-current-button.tsx +++ b/apps/native/src/components/widget/summaries/analyze-current-button.tsx @@ -2,7 +2,7 @@ import { AnalyzeButton } from "@/components/widget/summaries/analyze-button"; import { useSummary } from "@/hooks/use-summary"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { Dna, Loader2 } from "lucide-react"; export function AnalyzeCurrentButton() { diff --git a/apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx b/apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx index fb1947850..e5001c51c 100644 --- a/apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx +++ b/apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx @@ -81,7 +81,9 @@ export const WithHeaderExtra = meta.story({ change={makeChange("edited", "modules/darwin/packages.nix")} defaultOpen headerExtra={ - +3 -1 + + +3 -1 + } >
    Diff content here
    diff --git a/apps/native/src/components/widget/summaries/collapsible-diff.tsx b/apps/native/src/components/widget/summaries/collapsible-diff.tsx index d60a05e2c..fe954f245 100644 --- a/apps/native/src/components/widget/summaries/collapsible-diff.tsx +++ b/apps/native/src/components/widget/summaries/collapsible-diff.tsx @@ -1,7 +1,4 @@ -import { - Collapsible, - CollapsibleContent, -} from "@/components/ui/collapsible"; +import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { CHANGE_TYPE_STYLES, getDirectory, @@ -47,7 +44,9 @@ export function CollapsibleDiff({ className="group inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-muted" onClick={onToggle} > - +
    @@ -55,11 +54,8 @@ export function CollapsibleDiff({ {dir && {dir}/} {name} - {headerExtra && ( -
    {headerExtra}
    - )} + {headerExtra &&
    {headerExtra}
    }
    -
    {children}
    diff --git a/apps/native/src/components/widget/summaries/diff-line-stats.test.tsx b/apps/native/src/components/widget/summaries/diff-line-stats.test.tsx index 617d4204d..58f68d96d 100644 --- a/apps/native/src/components/widget/summaries/diff-line-stats.test.tsx +++ b/apps/native/src/components/widget/summaries/diff-line-stats.test.tsx @@ -46,10 +46,7 @@ describe("diff line stats", () => { }); it("sums stats across hunks", () => { - const changes = [ - { diff: "@@ -1 +1 @@\n-old\n+new" }, - { diff: "@@ -5,0 +6,2 @@\n+one\n+two" }, - ]; + const changes = [{ diff: "@@ -1 +1 @@\n-old\n+new" }, { diff: "@@ -5,0 +6,2 @@\n+one\n+two" }]; expect(sumDiffLineStats(changes)).toEqual({ added: 3, removed: 1 }); }); diff --git a/apps/native/src/components/widget/summaries/diff-line-stats.tsx b/apps/native/src/components/widget/summaries/diff-line-stats.tsx index 6b4ce9005..ea272b2f8 100644 --- a/apps/native/src/components/widget/summaries/diff-line-stats.tsx +++ b/apps/native/src/components/widget/summaries/diff-line-stats.tsx @@ -58,12 +58,8 @@ export function DiffLineStatsBadge({ stats, className }: DiffLineStatsBadgeProps )} title={`${stats.added} additions, ${stats.removed} deletions`} > - {stats.added > 0 && ( - +{stats.added} - )} - {stats.removed > 0 && ( - -{stats.removed} - )} + {stats.added > 0 && +{stats.added}} + {stats.removed > 0 && -{stats.removed}} ); } diff --git a/apps/native/src/components/widget/summaries/diff-section.stories.tsx b/apps/native/src/components/widget/summaries/diff-section.stories.tsx index 5217b252c..7fa9ebe20 100644 --- a/apps/native/src/components/widget/summaries/diff-section.stories.tsx +++ b/apps/native/src/components/widget/summaries/diff-section.stories.tsx @@ -7,7 +7,9 @@ import { DiffSection } from "./diff-section"; function ControlledDiffSection({ changes }: { changes: Change[] }) { const [openFiles, setOpenFiles] = useState>({}); const [includedFiles, setIncludedFiles] = useState>(() => - Object.fromEntries([...new Set(changes.map((c) => c.filename))].map((filename) => [filename, true])), + Object.fromEntries( + [...new Set(changes.map((c) => c.filename))].map((filename) => [filename, true]), + ), ); return ( (
    - +
    ), }); diff --git a/apps/native/src/components/widget/summaries/diff-section.tsx b/apps/native/src/components/widget/summaries/diff-section.tsx index 05ff3af3c..a1b44707a 100644 --- a/apps/native/src/components/widget/summaries/diff-section.tsx +++ b/apps/native/src/components/widget/summaries/diff-section.tsx @@ -1,11 +1,8 @@ "use client"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { - enrichChanges, - type ChangeWithRichType, -} from "@/components/widget/utils"; -import { useUiState } from "@/stores/ui-state"; +import { enrichChanges, type ChangeWithRichType } from "@/components/widget/utils"; +import { useUiState } from "@nixmac/state"; import type { Change } from "@/ipc/types"; import { useMemo } from "react"; import { FullFileDiffEditor } from "./full-file-diff-editor"; @@ -55,9 +52,7 @@ export function DiffSection({ changes={fileChanges} contents={fileContents[filename]} isOpen={openFiles[filename] ?? false} - onOpenChange={(open) => - onOpenFilesChange({ ...openFiles, [filename]: open }) - } + onOpenChange={(open) => onOpenFilesChange({ ...openFiles, [filename]: open })} included={includedFiles[filename] ?? true} onIncludedChange={(included) => onIncludedFilesChange({ ...includedFiles, [filename]: included }) diff --git a/apps/native/src/components/widget/summaries/diff-view.tsx b/apps/native/src/components/widget/summaries/diff-view.tsx index 9fb66cedf..9824c3cff 100644 --- a/apps/native/src/components/widget/summaries/diff-view.tsx +++ b/apps/native/src/components/widget/summaries/diff-view.tsx @@ -62,14 +62,21 @@ export function DiffView({ contents, filename, onMount, disableRuntime = false } diffs .filter((d: editor.ILineChange) => d.modifiedEndLineNumber > 0) .map((d: editor.ILineChange) => ({ - range: new monaco.Range(d.modifiedStartLineNumber, 1, d.modifiedEndLineNumber, 10000), + range: new monaco.Range( + d.modifiedStartLineNumber, + 1, + d.modifiedEndLineNumber, + 10000, + ), options: { inlineClassName: "nixmac-line-added", linesDecorationsClassName: "nixmac-gutter-added", }, })), ); - } catch { /* editor disposed */ } + } catch { + /* editor disposed */ + } }; disposableRef.current = ed.onDidUpdateDiff(decorate); diff --git a/apps/native/src/components/widget/summaries/file-view.tsx b/apps/native/src/components/widget/summaries/file-view.tsx index 060f19059..7ec676bf6 100644 --- a/apps/native/src/components/widget/summaries/file-view.tsx +++ b/apps/native/src/components/widget/summaries/file-view.tsx @@ -1,6 +1,11 @@ import type { FileDiffContents } from "@/ipc/types"; import { Editor } from "@monaco-editor/react"; -import { languageFromFilename, NIXMAC_THEME, NIXMAC_THEME_DATA, FILE_VIEW_OPTIONS } from "./monaco-setup"; +import { + languageFromFilename, + NIXMAC_THEME, + NIXMAC_THEME_DATA, + FILE_VIEW_OPTIONS, +} from "./monaco-setup"; interface FileViewProps { contents: FileDiffContents; @@ -18,10 +23,21 @@ export function FileView({ contents, filename, disableRuntime = false }: FileVie return (
    -
    +
    diff --git a/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx b/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx index 47088c12b..5656755ec 100644 --- a/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx +++ b/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx @@ -1,6 +1,6 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import type { ChangeWithRichType } from "@/components/widget/utils"; import type { FileDiffContents } from "@/ipc/types"; import { useEffect, useState } from "react"; @@ -10,7 +10,10 @@ function ControlledFullFileDiffEditor({ initialOpen = false, initialIncluded = true, ...props -}: Omit, "isOpen" | "onOpenChange" | "included" | "onIncludedChange"> & { +}: Omit< + React.ComponentProps, + "isOpen" | "onOpenChange" | "included" | "onIncludedChange" +> & { initialOpen?: boolean; initialIncluded?: boolean; }) { @@ -108,11 +111,37 @@ const mockContents: FileDiffContents = { }; const changeMap = { - groups: [{ - summary: { id: 1, title: "Add CLI tools", description: "", status: "DONE", createdAt: 0 }, - changes: [{ hash: "hash1", title: "Add ripgrep, fd, jq", description: "", id: 1, filename: "configuration.nix", diff: "", lineCount: 0, createdAt: 0, ownSummaryId: null }], - }], - singles: [{ hash: "hash2", title: "Enable flakes", description: "", id: 2, filename: "configuration.nix", diff: "", lineCount: 0, createdAt: 0, ownSummaryId: null }], + groups: [ + { + summary: { id: 1, title: "Add CLI tools", description: "", status: "DONE", createdAt: 0 }, + changes: [ + { + hash: "hash1", + title: "Add ripgrep, fd, jq", + description: "", + id: 1, + filename: "configuration.nix", + diff: "", + lineCount: 0, + createdAt: 0, + ownSummaryId: null, + }, + ], + }, + ], + singles: [ + { + hash: "hash2", + title: "Enable flakes", + description: "", + id: 2, + filename: "configuration.nix", + diff: "", + lineCount: 0, + createdAt: 0, + ownSummaryId: null, + }, + ], unsummarizedHashes: [], }; @@ -187,12 +216,14 @@ export const Removed = meta.story({ change.diff)) - : null; + const fallbackNewFileContents = + changeType === "new" ? newFileContentFromDiffs(changes.map((change) => change.diff)) : null; const displayContents = - changeType === "new" && fallbackNewFileContents !== null && (!contents || contents.modified === "") + changeType === "new" && + fallbackNewFileContents !== null && + (!contents || contents.modified === "") ? { original: "", modified: fallbackNewFileContents } : contents; @@ -104,7 +107,11 @@ export function FullFileDiffEditor({ {displayContents ? (
    {changeType === "new" ? ( - + ) : ( & { diff: string }): } const changeMap: SemanticChangeMap = { - groups: [{ - summary: { id: 1, title: "Add system packages", description: "", status: "DONE", createdAt: 0 }, - changes: [{ hash: "with-summary", title: "Add vim and git", description: "", id: 1, filename: "", diff: "", lineCount: 0, createdAt: 0, ownSummaryId: null }], - }], + groups: [ + { + summary: { + id: 1, + title: "Add system packages", + description: "", + status: "DONE", + createdAt: 0, + }, + changes: [ + { + hash: "with-summary", + title: "Add vim and git", + description: "", + id: 1, + filename: "", + diff: "", + lineCount: 0, + createdAt: 0, + ownSummaryId: null, + }, + ], + }, + ], singles: [], unsummarizedHashes: [], }; @@ -47,13 +67,17 @@ function WithStore({ change, map }: { change: ChangeWithRichType; map?: Semantic export const AdditionsOnly = meta.story({ render: () => ( - + ), }); export const DeletionsOnly = meta.story({ render: () => ( - + ), }); diff --git a/apps/native/src/components/widget/summaries/hunk-pill.tsx b/apps/native/src/components/widget/summaries/hunk-pill.tsx index 059cd00eb..04571c09a 100644 --- a/apps/native/src/components/widget/summaries/hunk-pill.tsx +++ b/apps/native/src/components/widget/summaries/hunk-pill.tsx @@ -1,6 +1,6 @@ import { Badge } from "@/components/ui/badge"; import type { ChangeWithRichType } from "@/components/widget/utils"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { countDiffLineStats, DiffLineStatsBadge } from "./diff-line-stats"; interface HunkPillProps { diff --git a/apps/native/src/components/widget/summaries/markdown-description.test.tsx b/apps/native/src/components/widget/summaries/markdown-description.test.tsx index 00a208a66..7e08768b8 100644 --- a/apps/native/src/components/widget/summaries/markdown-description.test.tsx +++ b/apps/native/src/components/widget/summaries/markdown-description.test.tsx @@ -1,11 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { MarkdownDescription } from "./markdown-description"; -import { - commitMessageBody, - hasMarkdownSyntax, - shouldExpandDescription, -} from "./markdown-utils"; +import { commitMessageBody, hasMarkdownSyntax, shouldExpandDescription } from "./markdown-utils"; vi.mock("@/components/widget/summaries/markdown-content", () => ({ MarkdownContent: ({ children }: { children: string }) => ( @@ -31,9 +27,7 @@ describe("markdown-utils", () => { it("expands when text exceeds line limit or contains markdown", () => { expect(shouldExpandDescription("one line", 2)).toBe(false); - expect(shouldExpandDescription("line one\nline two\nline three", 2)).toBe( - true, - ); + expect(shouldExpandDescription("line one\nline two\nline three", 2)).toBe(true); expect(shouldExpandDescription("- one bullet", 2)).toBe(true); }); }); diff --git a/apps/native/src/components/widget/summaries/markdown-description.tsx b/apps/native/src/components/widget/summaries/markdown-description.tsx index 003b1c91f..fa203b01f 100644 --- a/apps/native/src/components/widget/summaries/markdown-description.tsx +++ b/apps/native/src/components/widget/summaries/markdown-description.tsx @@ -65,9 +65,7 @@ export function MarkdownDescription({ {modalTitle ?? "Commit message"} - - Full commit message body - + Full commit message body {trimmed} diff --git a/apps/native/src/components/widget/summaries/markdown-utils.ts b/apps/native/src/components/widget/summaries/markdown-utils.ts index 656dc4646..4dd08e634 100644 --- a/apps/native/src/components/widget/summaries/markdown-utils.ts +++ b/apps/native/src/components/widget/summaries/markdown-utils.ts @@ -19,16 +19,12 @@ export function exceedsLineLimit(text: string, maxLines: number): boolean { return lineCount(text) > maxLines; } -const MARKDOWN_PATTERN = - /(\*\*|__|\*|_|`|\[[^\]]+\]\([^)]+\)|^#{1,6}\s|^(?:[-*]|\d+\.)\s)/m; +const MARKDOWN_PATTERN = /(\*\*|__|\*|_|`|\[[^\]]+\]\([^)]+\)|^#{1,6}\s|^(?:[-*]|\d+\.)\s)/m; export function hasMarkdownSyntax(text: string): boolean { return MARKDOWN_PATTERN.test(text); } -export function shouldExpandDescription( - text: string, - maxLines: number, -): boolean { +export function shouldExpandDescription(text: string, maxLines: number): boolean { return exceedsLineLimit(text, maxLines) || hasMarkdownSyntax(text); } diff --git a/apps/native/src/components/widget/summaries/monaco-setup.ts b/apps/native/src/components/widget/summaries/monaco-setup.ts index d85c6b71b..1a386a98b 100644 --- a/apps/native/src/components/widget/summaries/monaco-setup.ts +++ b/apps/native/src/components/widget/summaries/monaco-setup.ts @@ -28,7 +28,8 @@ export const FILE_VIEW_OPTIONS: editor.IStandaloneEditorConstructionOptions = { }, wordWrap: "off", fontSize: 12, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', smoothScrolling: true, scrollBeyondLastLine: false, folding: false, @@ -71,9 +72,20 @@ export const DIFF_EDITOR_OPTIONS = { }; const EXT_TO_LANGUAGE: Record = { - nix: "nix", json: "json", yaml: "yaml", yml: "yaml", toml: "toml", - md: "markdown", sh: "shell", ts: "typescript", js: "javascript", - tsx: "typescript", jsx: "javascript", css: "css", html: "html", xml: "xml", + nix: "nix", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + md: "markdown", + sh: "shell", + ts: "typescript", + js: "javascript", + tsx: "typescript", + jsx: "javascript", + css: "css", + html: "html", + xml: "xml", }; export function languageFromFilename(filename: string): string { diff --git a/apps/native/src/components/widget/summaries/own-summary-item.tsx b/apps/native/src/components/widget/summaries/own-summary-item.tsx index 15b3f914c..8a9fd0887 100644 --- a/apps/native/src/components/widget/summaries/own-summary-item.tsx +++ b/apps/native/src/components/widget/summaries/own-summary-item.tsx @@ -10,11 +10,14 @@ interface OwnSummaryItemProps { export function OwnSummaryItem({ change, style }: OwnSummaryItemProps) { return ( -
    - {change.title || getShortFilename(change.filename)} - {change.description && ( - — {change.description} +
    + {change.title || getShortFilename(change.filename)} + {change.description && — {change.description}}
    ); } diff --git a/apps/native/src/components/widget/summaries/summary-items.test.tsx b/apps/native/src/components/widget/summaries/summary-items.test.tsx index 046154908..e0f552625 100644 --- a/apps/native/src/components/widget/summaries/summary-items.test.tsx +++ b/apps/native/src/components/widget/summaries/summary-items.test.tsx @@ -5,7 +5,9 @@ import { describe, expect, it, vi } from "vitest"; import { SummaryItems } from "./summary-items"; vi.mock("@/components/ui/collapsible", () => ({ - Collapsible: ({ children }: { children: React.ReactNode }) =>
    {children}
    , + Collapsible: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), CollapsibleContent: ({ children }: { children: React.ReactNode }) =>
    {children}
    , CollapsibleTrigger: ({ children }: { children: React.ReactNode }) => , })); @@ -41,7 +43,13 @@ function makeSingle(id: number): ChangeWithSummary { function makeGroup(id: number, changeCount: number): SemanticChangeGroup { const changes = Array.from({ length: changeCount }, (_, i) => makeSingle(id * 100 + i)); return { - summary: { id, title: `Group ${id}`, description: `Group desc ${id}`, status: "DONE", createdAt: 0 }, + summary: { + id, + title: `Group ${id}`, + description: `Group desc ${id}`, + status: "DONE", + createdAt: 0, + }, changes, }; } @@ -100,7 +108,17 @@ describe("SummaryItems", () => { unsummarizedHashes: [], }; const unsummarized: ChangeWithRichType[] = [ - { id: 99, hash: "h99", filename: "u.nix", diff: "", lineCount: 1, createdAt: 0, ownSummaryId: null, changeType: "edited", shortFilename: "u.nix" }, + { + id: 99, + hash: "h99", + filename: "u.nix", + diff: "", + lineCount: 1, + createdAt: 0, + ownSummaryId: null, + changeType: "edited", + shortFilename: "u.nix", + }, ]; render(); expect(screen.getByTestId("unsummarized")).toBeInTheDocument(); diff --git a/apps/native/src/components/widget/summaries/summary-items.tsx b/apps/native/src/components/widget/summaries/summary-items.tsx index d8bf62e08..a3fa62d9f 100644 --- a/apps/native/src/components/widget/summaries/summary-items.tsx +++ b/apps/native/src/components/widget/summaries/summary-items.tsx @@ -1,24 +1,13 @@ "use client"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Separator } from "@/components/ui/separator"; import { UnsummarizedChangesSection } from "@/components/widget/summaries/unsummarized-changes-section"; import { MarkdownDescription } from "@/components/widget/summaries/markdown-description"; import { commitMessageBody } from "@/components/widget/summaries/markdown-utils"; -import { - ChangeWithRichType, - getShortFilename, -} from "@/components/widget/utils"; +import { ChangeWithRichType, getShortFilename } from "@/components/widget/utils"; import { cn } from "@/lib/utils"; -import type { - ChangeWithSummary, - SemanticChangeGroup, - SemanticChangeMap, -} from "@/ipc/types"; +import type { ChangeWithSummary, SemanticChangeGroup, SemanticChangeMap } from "@/ipc/types"; import { Layers } from "lucide-react"; function ShimmerBar({ className }: { className?: string }) { @@ -48,17 +37,8 @@ function SkeletonItem({ index = 0 }: { index?: number }) { ); } -function GroupItem({ - group, - index, -}: { - group: SemanticChangeGroup; - index: number; -}) { - if ( - group.summary.status === "QUEUED" || - (!group.summary.title && !group.summary.description) - ) { +function GroupItem({ group, index }: { group: SemanticChangeGroup; index: number }) { + if (group.summary.status === "QUEUED" || (!group.summary.title && !group.summary.description)) { return ; } @@ -72,15 +52,11 @@ function GroupItem({
    - + {group.summary.title} - - {group.changes.length} - + {group.changes.length}
    @@ -94,18 +70,12 @@ function GroupItem({ {group.changes.map((change) => (
    {change.title || getShortFilename(change.filename)} {change.description && ( - - {" "} - — {change.description} - + — {change.description} )}
    @@ -116,13 +86,7 @@ function GroupItem({ ); } -function SingleItem({ - change, - index, -}: { - change: ChangeWithSummary; - index: number; -}) { +function SingleItem({ change, index }: { change: ChangeWithSummary; index: number }) { if (!change.title && !change.description) { return ; } @@ -151,8 +115,7 @@ const MAX_ITEMS = 5; export function SummaryItems({ map, unsummarized }: SummaryItemsProps) { const partiallySummarized = - unsummarized.length > 0 && - (map.singles.length > 0 || map.groups.length > 0); + unsummarized.length > 0 && (map.singles.length > 0 || map.groups.length > 0); return (
    {map.groups.map((group, i) => ( diff --git a/apps/native/src/components/widget/summaries/summary-or-diff.tsx b/apps/native/src/components/widget/summaries/summary-or-diff.tsx index 0d2178ab6..c5b96f54b 100644 --- a/apps/native/src/components/widget/summaries/summary-or-diff.tsx +++ b/apps/native/src/components/widget/summaries/summary-or-diff.tsx @@ -1,15 +1,12 @@ "use client"; -import { - AnimatedTabsList, - AnimatedTabsTrigger, -} from "@/components/ui/animated-tabs"; +import { AnimatedTabsList, AnimatedTabsTrigger } from "@/components/ui/animated-tabs"; import { Tabs } from "@/components/ui/tabs"; import { DiffSection } from "@/components/widget/summaries/diff-section"; import { SummaryItems } from "@/components/widget/summaries/summary-items"; import { prefetchFileDiffContents } from "@/hooks/use-git-operations"; import { cn } from "@/lib/utils"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import type { Change } from "@/ipc/types"; import { Dna, Wrench } from "lucide-react"; import { Activity, useEffect, useMemo, useState } from "react"; @@ -28,10 +25,12 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { const [openFiles, setOpenFiles] = useState>({}); const [includedFiles, setIncludedFiles] = useState>({}); - const fileDiffKey = useMemo( () => - gitStatus?.changes.map((c) => `${c.filename}:${c.hash}`).sort().join("\n") ?? "", + gitStatus?.changes + .map((c) => `${c.filename}:${c.hash}`) + .sort() + .join("\n") ?? "", [gitStatus], ); @@ -56,8 +55,7 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { } const hashSet = new Set(changeMap?.unsummarizedHashes); - const unsummarized = (gitStatus?.changes.filter((c) => hashSet.has(c.hash)) || - []) as Change[]; + const unsummarized = (gitStatus?.changes.filter((c) => hashSet.has(c.hash)) || []) as Change[]; const enrichedUnsummarizedChanges = enrichChanges(unsummarized); return ( @@ -77,9 +75,7 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { )}

    - {evolveState.step === "commit" - ? "Active Changes" - : "What's changed"} + {evolveState.step === "commit" ? "Active Changes" : "What's changed"}

    @@ -89,12 +85,7 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) {
    <> - {changeMap && ( - - )} + {changeMap && } {activeTab === "diff" && ( {dir && ( - + {dir}/ )} diff --git a/apps/native/src/components/widget/summaries/unsummarized-changes-section.stories.tsx b/apps/native/src/components/widget/summaries/unsummarized-changes-section.stories.tsx index cee0a5a19..9b16b0112 100644 --- a/apps/native/src/components/widget/summaries/unsummarized-changes-section.stories.tsx +++ b/apps/native/src/components/widget/summaries/unsummarized-changes-section.stories.tsx @@ -1,6 +1,6 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { makeGlobalPreferences } from "@/utils/test-fixtures"; import type { ChangeWithRichType } from "@/components/widget/utils"; import type { SemanticChangeMap } from "@/ipc/types"; @@ -80,7 +80,12 @@ const emptyChangeMap: SemanticChangeMap = { }; const partialChangeMap: SemanticChangeMap = { - groups: [{ summary: { id: 1, title: "Add fonts", description: "", status: "DONE", createdAt: 0 }, changes: [] as any }], + groups: [ + { + summary: { id: 1, title: "Add fonts", description: "", status: "DONE", createdAt: 0 }, + changes: [] as any, + }, + ], singles: [], unsummarizedHashes: ["hash1"], }; diff --git a/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx b/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx index 9f1172f0d..dc4062f46 100644 --- a/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx +++ b/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx @@ -10,11 +10,7 @@ import { const MAX_ITEMS = 6; -export function UnsummarizedChangesSection({ - changes, -}: { - changes: ChangeWithRichType[]; -}) { +export function UnsummarizedChangesSection({ changes }: { changes: ChangeWithRichType[] }) { if (!changes.length) return null; const changesWithRenamed = summarizeChangesByFile(categorizeRenamed(changes)); @@ -29,12 +25,11 @@ export function UnsummarizedChangesSection({ {changesWithRenamed.length > 0 && (
    {displayedChanges.map((item) => ( - + ))} - {showMore && +{remaining} more} + {showMore && ( + +{remaining} more + )}
    )} diff --git a/apps/native/src/components/widget/utils.stories.tsx b/apps/native/src/components/widget/utils.stories.tsx index 1282a1d9c..78218463b 100644 --- a/apps/native/src/components/widget/utils.stories.tsx +++ b/apps/native/src/components/widget/utils.stories.tsx @@ -29,7 +29,10 @@ function UtilsDemo() { {categories.map((label) => { const style = getCategoryStyle(label); return ( - + {label} ); diff --git a/apps/native/src/components/widget/utils.test.ts b/apps/native/src/components/widget/utils.test.ts index a0872e827..bebbd9e16 100644 --- a/apps/native/src/components/widget/utils.test.ts +++ b/apps/native/src/components/widget/utils.test.ts @@ -123,9 +123,7 @@ describe("categorizeRenamed", () => { }); it("does not categorize an in-place rename (no remove + new pair) as renamed", () => { - const result = categorizeRenamed([ - richChange("modules/darwin/networking.nix", "edited"), - ]); + const result = categorizeRenamed([richChange("modules/darwin/networking.nix", "edited")]); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ @@ -166,20 +164,22 @@ describe("getModStartLine", () => { describe("newFileContentFromDiffs", () => { it("reconstructs added content from a hunk-only new-file diff", () => { - expect(newFileContentFromDiffs([ - "@@ -0,0 +1,4 @@\n+{ config, pkgs, ... }:\n+\n+{\n+ programs.zsh.enable = true;\n+}", - ])).toBe("{ config, pkgs, ... }:\n\n{\n programs.zsh.enable = true;\n}"); + expect( + newFileContentFromDiffs([ + "@@ -0,0 +1,4 @@\n+{ config, pkgs, ... }:\n+\n+{\n+ programs.zsh.enable = true;\n+}", + ]), + ).toBe("{ config, pkgs, ... }:\n\n{\n programs.zsh.enable = true;\n}"); }); it("ignores diff metadata when reconstructing full new-file diffs", () => { - expect(newFileContentFromDiffs([ - "diff --git a/modules/home/shell.nix b/modules/home/shell.nix\nnew file mode 100644\n--- /dev/null\n+++ b/modules/home/shell.nix\n@@ -0,0 +1,2 @@\n+line one\n+line two", - ])).toBe("line one\nline two"); + expect( + newFileContentFromDiffs([ + "diff --git a/modules/home/shell.nix b/modules/home/shell.nix\nnew file mode 100644\n--- /dev/null\n+++ b/modules/home/shell.nix\n@@ -0,0 +1,2 @@\n+line one\n+line two", + ]), + ).toBe("line one\nline two"); }); it("returns null for edited-file diffs", () => { - expect(newFileContentFromDiffs([ - "@@ -3,2 +3,2 @@\n-old\n+new", - ])).toBeNull(); + expect(newFileContentFromDiffs(["@@ -3,2 +3,2 @@\n-old\n+new"])).toBeNull(); }); }); diff --git a/apps/native/src/components/widget/utils.ts b/apps/native/src/components/widget/utils.ts index c6fde0e6b..453a47cb5 100644 --- a/apps/native/src/components/widget/utils.ts +++ b/apps/native/src/components/widget/utils.ts @@ -30,8 +30,8 @@ export function computeCurrentStep(state: CurrentStepState): WidgetStep { } if ( - (state.nixInstalled !== true || state.darwinRebuildAvailable !== true) - && settings.NIX_INSTALLED_OVERRIDE !== true // bypass used for testing + (state.nixInstalled !== true || state.darwinRebuildAvailable !== true) && + settings.NIX_INSTALLED_OVERRIDE !== true // bypass used for testing ) { return "nix-setup"; } @@ -67,7 +67,6 @@ export function getDirectory(path: string): string { return parts.slice(0, -1).join("/"); } - // ============================================================================= // SUMMARY CATEGORY COLORS // ============================================================================= @@ -114,15 +113,7 @@ const CATEGORY_PALETTE: CategoryStyle[] = [EMERALD, BLUE, AMBER, VIOLET, GRAY]; const KEYWORD_STYLES: Array<{ keywords: string[]; style: CategoryStyle }> = [ { - keywords: [ - "config", - "settings", - "option", - "nix", - "darwin", - "home", - "profile", - ], + keywords: ["config", "settings", "option", "nix", "darwin", "home", "profile"], style: EMERALD, }, { @@ -160,9 +151,7 @@ export function buildColorMap(changeMap: SemanticChangeMap): ColorMap { const assign = (key: string, title: string, forceColor: boolean) => { const lower = title.toLowerCase(); const preferred = - KEYWORD_STYLES.find(({ keywords }) => - keywords.some((k) => lower.includes(k)), - )?.style ?? null; + KEYWORD_STYLES.find(({ keywords }) => keywords.some((k) => lower.includes(k)))?.style ?? null; if (preferred && !used.has(preferred)) { map.set(key, preferred); @@ -178,8 +167,7 @@ export function buildColorMap(changeMap: SemanticChangeMap): ColorMap { } }; - for (const g of changeMap.groups) - assign(String(g.summary.id), g.summary.title, true); + for (const g of changeMap.groups) assign(String(g.summary.id), g.summary.title, true); for (const s of changeMap.singles) assign(s.hash, s.title, false); return map; @@ -237,8 +225,7 @@ function findRenamePairs(changes: ChangeWithRichType[]): RenamePair[] { const newFiles = changes.filter((c) => c.changeType === "new"); for (const newFile of newFiles) { const removedFiles = changes.filter( - (c) => - c.shortFilename === newFile.shortFilename && c.changeType === "removed", + (c) => c.shortFilename === newFile.shortFilename && c.changeType === "removed", ); if (removedFiles.length === 1) { pairs.push({ oldChange: removedFiles[0], newChange: newFile }); @@ -247,9 +234,7 @@ function findRenamePairs(changes: ChangeWithRichType[]): RenamePair[] { return pairs; } -export function categorizeRenamed( - changes: ChangeWithRichType[], -): ChangeWithRichType[] { +export function categorizeRenamed(changes: ChangeWithRichType[]): ChangeWithRichType[] { const pairs = findRenamePairs(changes); const consumedRemovals = new Set(); const renamedChanges: ChangeWithRichType[] = []; @@ -277,9 +262,7 @@ function combineChangeTypes(a: ChangeType, b: ChangeType): ChangeType { return "edited"; } -export function summarizeChangesByFile( - changes: ChangeWithRichType[], -): ChangeFileSummary[] { +export function summarizeChangesByFile(changes: ChangeWithRichType[]): ChangeFileSummary[] { const byFile = new Map(); for (const change of changes) { @@ -293,10 +276,7 @@ export function summarizeChangesByFile( existing.hunkCount += 1; existing.lineCount += change.lineCount; - existing.changeType = combineChangeTypes( - existing.changeType, - change.changeType, - ); + existing.changeType = combineChangeTypes(existing.changeType, change.changeType); } return Array.from(byFile.values()); diff --git a/apps/native/src/components/widget/widget.stories.tsx b/apps/native/src/components/widget/widget.stories.tsx index 2db2a58df..21836c3af 100644 --- a/apps/native/src/components/widget/widget.stories.tsx +++ b/apps/native/src/components/widget/widget.stories.tsx @@ -7,8 +7,8 @@ import type { PermissionsState, SemanticChangeMap, } from "@/ipc/types"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { makeGlobalPreferences, makeGrantedPermissions, @@ -201,18 +201,66 @@ const CHANGE_MAP_PRESETS: Record = { }; const BASIC_EVOLVE_EVENTS: EvolveEvent[] = [ - { eventType: "start", summary: "Starting AI evolution...", raw: "Starting evolution with model gpt-5.1", iteration: null, timestampMs: 0 }, - { eventType: "iteration", summary: "Processing iteration 1...", raw: "Iteration 1 | messages=2", iteration: 1, timestampMs: 500 }, - { eventType: "apiRequest", summary: "Querying AI model...", raw: "Sending request to AI provider", iteration: 1, timestampMs: 550 }, - { eventType: "apiResponse", summary: "Received AI response", raw: "Received response | tokens used: 1523", iteration: 1, timestampMs: 2300 }, - { eventType: "thinking", summary: "Planning approach...", raw: "[planning] Analyzing configuration structure...", iteration: 1, timestampMs: 2400 }, - { eventType: "reading", summary: "Reading default.nix", raw: "Reading file: modules/darwin/default.nix", iteration: 2, timestampMs: 4600 }, + { + eventType: "start", + summary: "Starting AI evolution...", + raw: "Starting evolution with model gpt-5.1", + iteration: null, + timestampMs: 0, + }, + { + eventType: "iteration", + summary: "Processing iteration 1...", + raw: "Iteration 1 | messages=2", + iteration: 1, + timestampMs: 500, + }, + { + eventType: "apiRequest", + summary: "Querying AI model...", + raw: "Sending request to AI provider", + iteration: 1, + timestampMs: 550, + }, + { + eventType: "apiResponse", + summary: "Received AI response", + raw: "Received response | tokens used: 1523", + iteration: 1, + timestampMs: 2300, + }, + { + eventType: "thinking", + summary: "Planning approach...", + raw: "[planning] Analyzing configuration structure...", + iteration: 1, + timestampMs: 2400, + }, + { + eventType: "reading", + summary: "Reading default.nix", + raw: "Reading file: modules/darwin/default.nix", + iteration: 2, + timestampMs: 4600, + }, ]; const DETAILED_EVOLVE_EVENTS: EvolveEvent[] = [ ...BASIC_EVOLVE_EVENTS, - { eventType: "editing", summary: "Editing default.nix", raw: "Editing file: modules/darwin/default.nix", iteration: 3, timestampMs: 6000 }, - { eventType: "buildCheck", summary: "Running build check...", raw: "Running build check for host: Demo-MacBook-Pro", iteration: 3, timestampMs: 6500 }, + { + eventType: "editing", + summary: "Editing default.nix", + raw: "Editing file: modules/darwin/default.nix", + iteration: 3, + timestampMs: 6000, + }, + { + eventType: "buildCheck", + summary: "Running build check...", + raw: "Running build check for host: Demo-MacBook-Pro", + iteration: 3, + timestampMs: 6500, + }, ]; const EVOLVE_EVENT_PRESETS: Record = { @@ -392,36 +440,69 @@ const meta = preview.meta({ options: ["begin", "evolve", "commit", "manualEvolve", "manualCommit"], ...cat("Routing / Gating", "Evolve sub-step (used when no earlier gate wins)"), }, - configDir: { control: "text", ...cat("Routing / Gating", "Selected config dir; empty → Setup step") }, + configDir: { + control: "text", + ...cat("Routing / Gating", "Selected config dir; empty → Setup step"), + }, host: { control: "select", options: ["", "Demo-MacBook-Pro", "Work-MacBook"], ...cat("Routing / Gating", "Selected host; must be in the hosts list to pass Setup"), }, - hostsListed: { control: "boolean", ...cat("Routing / Gating", "Hosts discovered from the flake (false = fresh onboarding)") }, - permissionsGranted: { control: "boolean", ...cat("Routing / Gating", "false → Permissions step") }, - isBootstrapping: { control: "boolean", ...cat("Routing / Gating", "Creating a default config → Setup step") }, + hostsListed: { + control: "boolean", + ...cat("Routing / Gating", "Hosts discovered from the flake (false = fresh onboarding)"), + }, + permissionsGranted: { + control: "boolean", + ...cat("Routing / Gating", "false → Permissions step"), + }, + isBootstrapping: { + control: "boolean", + ...cat("Routing / Gating", "Creating a default config → Setup step"), + }, showHistory: { control: "boolean", ...cat("Routing / Gating", "Open the History panel") }, showFilesystem: { control: "boolean", ...cat("Routing / Gating", "Open the Filesystem view") }, // --- Evolve session - committable: { control: "boolean", ...cat("Evolve session", "evolve.committable — build succeeded, ready to commit") }, + committable: { + control: "boolean", + ...cat("Evolve session", "evolve.committable — build succeeded, ready to commit"), + }, // --- Processing & UI flags (transient `useUiState`) - isProcessing: { control: "boolean", ...cat("Processing & UI", "Global processing flag (spinner / disabled inputs)") }, + isProcessing: { + control: "boolean", + ...cat("Processing & UI", "Global processing flag (spinner / disabled inputs)"), + }, processingAction: { control: "select", options: ["none", "evolve", "apply", "merge", "cancel"], ...cat("Processing & UI", "Which long-running action is in flight"), }, - isGenerating: { control: "boolean", ...cat("Processing & UI", "AI is streaming an evolution (shows progress overlay)") }, - isSummarizing: { control: "boolean", ...cat("Processing & UI", "Change summaries are being generated") }, + isGenerating: { + control: "boolean", + ...cat("Processing & UI", "AI is streaming an evolution (shows progress overlay)"), + }, + isSummarizing: { + control: "boolean", + ...cat("Processing & UI", "Change summaries are being generated"), + }, evolvePrompt: { control: "text", ...cat("Processing & UI", "Text in the prompt input") }, error: { control: "text", ...cat("Processing & UI", "Error banner text (empty = no error)") }, settingsOpen: { control: "boolean", ...cat("Processing & UI", "Settings dialog open") }, settingsTab: { control: "select", - options: ["none", "general", "account", "api-keys", "ai-models", "preferences", "tuning", "developer"], + options: [ + "none", + "general", + "account", + "api-keys", + "ai-models", + "preferences", + "tuning", + "developer", + ], ...cat("Processing & UI", "Active settings tab"), }, feedbackOpen: { control: "boolean", ...cat("Processing & UI", "Feedback dialog open") }, diff --git a/apps/native/src/components/widget/widget.test.tsx b/apps/native/src/components/widget/widget.test.tsx index 381107152..ac14afb52 100644 --- a/apps/native/src/components/widget/widget.test.tsx +++ b/apps/native/src/components/widget/widget.test.tsx @@ -1,5 +1,5 @@ -import { initialUiState, useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { initialUiState, useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { render } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DarwinWidget } from "./widget"; diff --git a/apps/native/src/components/widget/widget.tsx b/apps/native/src/components/widget/widget.tsx index 36c5d9ee7..7275e633e 100644 --- a/apps/native/src/components/widget/widget.tsx +++ b/apps/native/src/components/widget/widget.tsx @@ -12,17 +12,16 @@ import { ReportIssueButton } from "@/components/widget/feedback/report-issue-but import { SettingsDialog } from "@/components/widget/settings/settings-dialog"; import { StepContentWrapper } from "@/components/widget/layout/step-content-wrapper"; import { Stepper } from "@/components/widget/layout/stepper"; +import { OnboardingFlow } from "@/components/widget/onboarding/onboarding-flow"; +import { useOnboarding } from "@nixmac/state"; import { - BeginStep, - CommitStep, - EvolveStep, - FilesystemStep, - HistoryStep, - ManualCommitStep, - ManualEvolveStep, - NixSetupStep, - PermissionsStep, - SetupStep, + BeginStep, + CommitStep, + EvolveStep, + FilesystemStep, + HistoryStep, + ManualCommitStep, + ManualEvolveStep, } from "@/components/widget/steps"; import { surfaceRecoveryReport } from "@/hooks/use-feedback-on-recovery"; import { useGitOperations } from "@/hooks/use-git-operations"; @@ -32,7 +31,7 @@ import { usePermissions } from "@/hooks/use-permissions"; import { useTrayEvents } from "@/hooks/use-tray-events"; import { markBootRenderStage, markBootStage } from "@/lib/boot-diagnostics"; import { useEvolveMascot } from "@/hooks/use-evolve-mascot"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { useCurrentStep } from "@/hooks/use-current-step"; import { UpdateBanner } from "@/components/widget/layout/update-banner"; import { startViewModelSync } from "@/viewmodel"; @@ -141,18 +140,18 @@ export function DarwinWidget() { }; }, []); + // Onboarding (permissions → nix → flake import → customizations → inference → + // first build) takes over the whole window via OnboardingFlow. The first + // three gates come from the live backend; the post-setup steps are tracked + // by the onboarding store, which keeps the flow on screen until completed. + const onboardingActivePost = useOnboarding((s) => s.active); + const onboardingCompleted = useOnboarding((s) => s.completed); + const isOnboardingGate = step === "permissions" || step === "nix-setup" || step === "setup"; + const showOnboarding = !onboardingCompleted && (isOnboardingGate || onboardingActivePost); + // Routing mechanism const getActiveStepComponent = () => { switch (step) { - case "permissions": - return ; - - case "nix-setup": - return ; - - case "setup": - return ; - case "begin": return ; @@ -180,6 +179,17 @@ export function DarwinWidget() { // the StepContentWrapper's padding & overflow handling. const isEdgeToEdgeStep = step === "filesystem"; + if (showOnboarding) { + return ( +
    + + + + +
    + ); + } + return (
    diff --git a/apps/native/src/hooks/use-apply.ts b/apps/native/src/hooks/use-apply.ts index d738f20ea..8270bbe79 100644 --- a/apps/native/src/hooks/use-apply.ts +++ b/apps/native/src/hooks/use-apply.ts @@ -1,4 +1,4 @@ -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import { useRebuildStream } from "@/hooks/use-rebuild-stream"; diff --git a/apps/native/src/hooks/use-current-step.ts b/apps/native/src/hooks/use-current-step.ts index 50059266d..8c63d839b 100644 --- a/apps/native/src/hooks/use-current-step.ts +++ b/apps/native/src/hooks/use-current-step.ts @@ -1,6 +1,6 @@ import { computeCurrentStep } from "@/components/widget/utils"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import type { WidgetStep } from "@/types/widget"; /** diff --git a/apps/native/src/hooks/use-darwin-config.ts b/apps/native/src/hooks/use-darwin-config.ts index 3f3cf2538..f5dc00fd6 100644 --- a/apps/native/src/hooks/use-darwin-config.ts +++ b/apps/native/src/hooks/use-darwin-config.ts @@ -1,4 +1,4 @@ -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import type { SetDirResult } from "@/ipc/types"; diff --git a/apps/native/src/hooks/use-evolve-mascot.test.ts b/apps/native/src/hooks/use-evolve-mascot.test.ts index 7f08843f5..7b417d9d1 100644 --- a/apps/native/src/hooks/use-evolve-mascot.test.ts +++ b/apps/native/src/hooks/use-evolve-mascot.test.ts @@ -1,13 +1,9 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { useEvolveMascot } from "./use-evolve-mascot"; -import { - makeGlobalPreferences as makePrefs, - makeRebuildStatus, -} from "@/utils/test-fixtures"; - +import { makeGlobalPreferences as makePrefs, makeRebuildStatus } from "@/utils/test-fixtures"; function setSpinningMascot(enabled: boolean) { useViewModel.setState({ diff --git a/apps/native/src/hooks/use-evolve-mascot.ts b/apps/native/src/hooks/use-evolve-mascot.ts index 6d9f1af00..6aec09b24 100644 --- a/apps/native/src/hooks/use-evolve-mascot.ts +++ b/apps/native/src/hooks/use-evolve-mascot.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { tauriAPI } from "@/ipc/api"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; /** * Experimental: drives the spinning-mascot corner-indicator window. diff --git a/apps/native/src/hooks/use-evolve.test.ts b/apps/native/src/hooks/use-evolve.test.ts index faa4ac096..814996387 100644 --- a/apps/native/src/hooks/use-evolve.test.ts +++ b/apps/native/src/hooks/use-evolve.test.ts @@ -1,6 +1,6 @@ import type { SemanticChangeMap } from "@/ipc/types"; -import { initialUiState, useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { initialUiState, useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useEvolve } from "./use-evolve"; diff --git a/apps/native/src/hooks/use-evolve.ts b/apps/native/src/hooks/use-evolve.ts index 0511a25e4..ead0d4509 100644 --- a/apps/native/src/hooks/use-evolve.ts +++ b/apps/native/src/hooks/use-evolve.ts @@ -1,5 +1,5 @@ import { EVOLUTION_CANCELLED_MSG } from "@/lib/constants"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import { getTelemetry } from "@/lib/telemetry/instance"; @@ -76,8 +76,15 @@ const handleEvolve = async () => { useUiState.getState().appendLog(`✗ Error: ${msg}\n`); // Track evolution failure - const stage = msg.toLowerCase().includes("build") ? "build" : msg.toLowerCase().includes("apply") ? "apply" : "agent"; - getTelemetry().captureEvent({ name: "evolve_failed", props: { stage: stage as "build" | "agent" | "apply" } }); + const stage = msg.toLowerCase().includes("build") + ? "build" + : msg.toLowerCase().includes("apply") + ? "apply" + : "agent"; + getTelemetry().captureEvent({ + name: "evolve_failed", + props: { stage: stage as "build" | "agent" | "apply" }, + }); } } finally { useUiState.getState().setGenerating(false); diff --git a/apps/native/src/hooks/use-feedback-on-recovery.ts b/apps/native/src/hooks/use-feedback-on-recovery.ts index f3d060583..29d79eb13 100644 --- a/apps/native/src/hooks/use-feedback-on-recovery.ts +++ b/apps/native/src/hooks/use-feedback-on-recovery.ts @@ -1,4 +1,4 @@ -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; const RECOVERY_STORAGE_KEY = "nixmac:pending-error-report"; @@ -24,8 +24,7 @@ function readStoredReport(): StoredErrorReport | null { name: typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : "Error", message: parsed.message, stack: typeof parsed.stack === "string" ? parsed.stack : "", - timestamp: - typeof parsed.timestamp === "string" ? parsed.timestamp : new Date().toISOString(), + timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : new Date().toISOString(), }; } catch { return null; diff --git a/apps/native/src/hooks/use-git-operations.ts b/apps/native/src/hooks/use-git-operations.ts index 30986a5ef..00b526b8a 100644 --- a/apps/native/src/hooks/use-git-operations.ts +++ b/apps/native/src/hooks/use-git-operations.ts @@ -1,4 +1,4 @@ -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import { refreshGitSnapshot } from "@/viewmodel/git"; import { refreshHostsSnapshot } from "@/viewmodel/preferences"; @@ -8,7 +8,9 @@ import { toast } from "sonner"; * Hook for git operations. * Provides functions for refreshing git status changes. */ -export const prefetchFileDiffContents = async (status: { changes: { filename: string }[] } | null) => { +export const prefetchFileDiffContents = async ( + status: { changes: { filename: string }[] } | null, +) => { const setFileDiffContents = useUiState.getState().setFileDiffContents; if (!status) { setFileDiffContents({}); diff --git a/apps/native/src/hooks/use-history-card.ts b/apps/native/src/hooks/use-history-card.ts index 9b21520be..ea4ebaf82 100644 --- a/apps/native/src/hooks/use-history-card.ts +++ b/apps/native/src/hooks/use-history-card.ts @@ -4,7 +4,7 @@ import type { HistoryItem } from "@/ipc/types"; import { cn } from "@/lib/utils"; import { buildColorMap } from "@/components/widget/utils"; import type { ColorMap } from "@/components/widget/utils"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; type ActionType = "current" | "base" | "build" | "restore" | "preview"; @@ -35,14 +35,15 @@ export function useHistoryCard(item: HistoryItem, isPreview = false): UseHistory }; const borderColor = item.isBuilt ? "border-teal-400/40" : "border-white/[0.12]"; - const interactivity = item.changeMap && !isPreview - ? cn( - "cursor-pointer", - item.isBuilt - ? "group-hover:border-teal-400/50" - : "group-hover:border-white/35 group-hover:bg-[#151515]", - ) - : "cursor-default"; + const interactivity = + item.changeMap && !isPreview + ? cn( + "cursor-pointer", + item.isBuilt + ? "group-hover:border-teal-400/50" + : "group-hover:border-white/35 group-hover:bg-[#151515]", + ) + : "cursor-default"; const expandedStyles = expanded ? cn("bg-[#151515]", item.isBuilt ? "border-teal-400/50" : "border-white/35") : undefined; diff --git a/apps/native/src/hooks/use-history-restore.ts b/apps/native/src/hooks/use-history-restore.ts index bb3ba2f12..dc6c0f102 100644 --- a/apps/native/src/hooks/use-history-restore.ts +++ b/apps/native/src/hooks/use-history-restore.ts @@ -1,10 +1,10 @@ import { useState } from "react"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { useRebuildStream } from "@/hooks/use-rebuild-stream"; import { useHistory } from "@/hooks/use-history"; import { tauriAPI } from "@/ipc/api"; import type { HistoryItem } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { getTelemetry } from "@/lib/telemetry/instance"; // Sentinel hash used to identify the frontend-only preview item. @@ -33,9 +33,7 @@ function getDayLabel(unixSeconds: number): string { return date.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }); } -type FlatItem = - | { type: "commit"; item: HistoryItem } - | { type: "day-label"; label: string }; +type FlatItem = { type: "commit"; item: HistoryItem } | { type: "day-label"; label: string }; function buildFlatList(items: HistoryItem[]): FlatItem[] { const result: FlatItem[] = []; @@ -92,10 +90,7 @@ function buildUndoneSet(items: HistoryItem[], previewHash: string): Set * segments. Day labels look ahead to the next commit to decide which segment they * belong to — so labels introducing an undone day land inside the undone wrapper. */ -function groupConsecutiveUndone( - flatItems: FlatItem[], - undoneSet: Set, -): HistorySegment[] { +function groupConsecutiveUndone(flatItems: FlatItem[], undoneSet: Set): HistorySegment[] { const segments: HistorySegment[] = []; const kindOf = (fi: FlatItem): "normal" | "undone" => diff --git a/apps/native/src/hooks/use-history.ts b/apps/native/src/hooks/use-history.ts index d92ed157f..4c1653e15 100644 --- a/apps/native/src/hooks/use-history.ts +++ b/apps/native/src/hooks/use-history.ts @@ -1,6 +1,6 @@ import { tauriAPI } from "@/ipc/api"; -import { useViewModel } from "@/stores/view-model"; -import { useUiState } from "@/stores/ui-state"; +import { useViewModel } from "@nixmac/state"; +import { useUiState } from "@nixmac/state"; import { refreshHistorySnapshot } from "@/viewmodel/history"; const loadHistory = () => refreshHistorySnapshot(); diff --git a/apps/native/src/hooks/use-homebrew-diff.ts b/apps/native/src/hooks/use-homebrew-diff.ts index 806eb60c4..b7d14fa64 100644 --- a/apps/native/src/hooks/use-homebrew-diff.ts +++ b/apps/native/src/hooks/use-homebrew-diff.ts @@ -1,7 +1,7 @@ "use client"; import { tauriAPI } from "@/ipc/api"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import type { HomebrewState } from "@/ipc/types"; import { useCallback, useEffect, useState } from "react"; diff --git a/apps/native/src/hooks/use-panic-handler.ts b/apps/native/src/hooks/use-panic-handler.ts index 3fb865e05..e6aad6959 100644 --- a/apps/native/src/hooks/use-panic-handler.ts +++ b/apps/native/src/hooks/use-panic-handler.ts @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import type { RustPanicEvent } from "@/ipc/types"; import { FeedbackType } from "@/types/feedback"; import { getTelemetry } from "@/lib/telemetry/instance"; diff --git a/apps/native/src/hooks/use-rebuild-stream.ts b/apps/native/src/hooks/use-rebuild-stream.ts index b6f229f47..73775319e 100644 --- a/apps/native/src/hooks/use-rebuild-stream.ts +++ b/apps/native/src/hooks/use-rebuild-stream.ts @@ -1,4 +1,4 @@ -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import type { RebuildContext } from "@/types/rebuild"; import { tauriAPI, ipcRenderer } from "@/ipc/api"; import type { DarwinApplyEndEvent } from "@/ipc/types"; diff --git a/apps/native/src/hooks/use-recommended-prompt.ts b/apps/native/src/hooks/use-recommended-prompt.ts index 1638fc37c..68fad329c 100644 --- a/apps/native/src/hooks/use-recommended-prompt.ts +++ b/apps/native/src/hooks/use-recommended-prompt.ts @@ -1,4 +1,4 @@ -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import { useEffect } from "react"; diff --git a/apps/native/src/hooks/use-rollback.test.ts b/apps/native/src/hooks/use-rollback.test.ts index be299f2f1..1f28fe5c4 100644 --- a/apps/native/src/hooks/use-rollback.test.ts +++ b/apps/native/src/hooks/use-rollback.test.ts @@ -1,6 +1,6 @@ import type { EvolveState, GitStatus } from "@/ipc/types"; -import { initialUiState, useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { initialUiState, useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useRollback } from "./use-rollback"; diff --git a/apps/native/src/hooks/use-rollback.ts b/apps/native/src/hooks/use-rollback.ts index ceeb0f5ac..a41e2e560 100644 --- a/apps/native/src/hooks/use-rollback.ts +++ b/apps/native/src/hooks/use-rollback.ts @@ -1,7 +1,7 @@ -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; import { useRebuildStream } from "@/hooks/use-rebuild-stream"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { getTelemetry } from "@/lib/telemetry/instance"; /** * Hook for discarding changes and restoring the working tree to its diff --git a/apps/native/src/hooks/use-summary.ts b/apps/native/src/hooks/use-summary.ts index af90ec40b..afbc9c865 100644 --- a/apps/native/src/hooks/use-summary.ts +++ b/apps/native/src/hooks/use-summary.ts @@ -1,5 +1,5 @@ -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { tauriAPI } from "@/ipc/api"; /** diff --git a/apps/native/src/hooks/use-tray-events.ts b/apps/native/src/hooks/use-tray-events.ts index f014dbf15..23df39fbc 100644 --- a/apps/native/src/hooks/use-tray-events.ts +++ b/apps/native/src/hooks/use-tray-events.ts @@ -1,6 +1,6 @@ import { listen } from "@tauri-apps/api/event"; import { useEffect } from "react"; -import { useUiState } from "@/stores/ui-state"; +import { useUiState } from "@nixmac/state"; export function useTrayEvents() { useEffect(() => { diff --git a/apps/native/src/hooks/use-updater.ts b/apps/native/src/hooks/use-updater.ts index 0950161ed..1636896e9 100644 --- a/apps/native/src/hooks/use-updater.ts +++ b/apps/native/src/hooks/use-updater.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { tauriAPI } from "@/ipc/api"; import type { UpdateInfo } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; interface UpdateState { /** Whether we're currently checking for updates */ @@ -57,8 +57,8 @@ export function useUpdater() { } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); - const isPluginMissing = errMsg.includes("plugin updater not found") || - errMsg.includes("plugin not found"); + const isPluginMissing = + errMsg.includes("plugin updater not found") || errMsg.includes("plugin not found"); if (isDevMode || isPluginMissing) { // Suppress errors when the updater plugin isn't registered (NIXMAC_DISABLE_UPDATER=1) diff --git a/apps/native/src/index.css b/apps/native/src/index.css index 4481206f3..063f219c3 100644 --- a/apps/native/src/index.css +++ b/apps/native/src/index.css @@ -4,74 +4,97 @@ @layer base { :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; + /* OKLCH channels (L C H); wrapped as oklch(var(--token)) in tailwind.config.js. + Neutrals are exact conversions of the previous HSL theme; accents are + authored in OKLCH for perceptually balanced, P3-capable color. */ + --background: 1 0 0; + --foreground: 0.1445 0 0; + --card: 1 0 0; + --card-foreground: 0.1445 0 0; + --popover: 1 0 0; + --popover-foreground: 0.1445 0 0; + --primary: 0.2044 0 0; + --primary-foreground: 0.9848 0 0; + --secondary: 0.9703 0 0; + --secondary-foreground: 0.2044 0 0; + --muted: 0.9703 0 0; + --muted-foreground: 0.5555 0 0; + --accent: 0.9703 0 0; + --accent-foreground: 0.2044 0 0; + --destructive: 0.6368 0.2078 25.33; + --destructive-foreground: 0.9848 0 0; + --border: 0.9219 0 0; + --input: 0.9219 0 0; + --ring: 0.1445 0 0; + --chart-1: 0.6772 0.1571 35.19; + --chart-2: 0.6309 0.1013 183.49; + --chart-3: 0.3787 0.044 225.54; + --chart-4: 0.8336 0.1186 88.15; + --chart-5: 0.7834 0.1261 58.75; --radius: 0.5rem; - --highlight: 230 10% 80%; - /* Onboarding accent tokens (added for the ported setup flow) */ - --success: 152 56% 40%; - --success-foreground: 0 0% 100%; - --warning: 38 92% 45%; - --warning-foreground: 0 0% 100%; - --brand: 184 80% 38%; - --brand-foreground: 0 0% 100%; + --highlight: 0.8359 0.0119 277.06; + /* Onboarding accent tokens */ + --success: 0.55 0.14 150; + --success-foreground: 0.985 0 0; + --warning: 0.62 0.13 75; + --warning-foreground: 0.985 0 0; + --brand: 0.55 0.12 185; + --brand-foreground: 0.985 0 0; + /* Sidebar tokens (used by the onboarding stepper + shared sidebar). */ + --sidebar: 0.9848 0 0; + --sidebar-foreground: 0.1445 0 0; + --sidebar-primary: 0.2044 0 0; + --sidebar-primary-foreground: 0.9848 0 0; + --sidebar-accent: 0.9703 0 0; + --sidebar-accent-foreground: 0.2044 0 0; + --sidebar-border: 0.9219 0 0; + --sidebar-ring: 0.7081 0 0; } .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --highlight: 235 40% 90%; - /* Onboarding accent tokens (added for the ported setup flow) */ - --success: 152 58% 52%; - --success-foreground: 153 30% 10%; - --warning: 38 92% 58%; - --warning-foreground: 38 50% 10%; - --brand: 184 70% 55%; - --brand-foreground: 186 50% 9%; + --background: 0.1445 0 0; + --foreground: 0.9848 0 0; + /* Elevated above background for subtle card/popover depth (was a corrupt + value that rendered transparent). */ + --card: 0.205 0 0; + --card-foreground: 0.9848 0 0; + --popover: 0.205 0 0; + --popover-foreground: 0.9848 0 0; + --primary: 0.9848 0 0; + --primary-foreground: 0.2044 0 0; + --secondary: 0.2686 0 0; + --secondary-foreground: 0.9848 0 0; + --muted: 0.2686 0 0; + --muted-foreground: 0.7153 0 0; + --accent: 0.2686 0 0; + --accent-foreground: 0.9848 0 0; + --destructive: 0.3959 0.1331 25.72; + --destructive-foreground: 0.9848 0 0; + --border: 0.2686 0 0; + --input: 0.2686 0 0; + --ring: 0.8697 0 0; + --chart-1: 0.5292 0.1931 262.13; + --chart-2: 0.6983 0.1337 165.46; + --chart-3: 0.7232 0.15 60.63; + --chart-4: 0.6192 0.2037 312.73; + --chart-5: 0.6123 0.2093 6.39; + --highlight: 0.9019 0.0255 281.73; + /* Onboarding accent tokens */ + --success: 0.74 0.15 150; + --success-foreground: 0.2 0.03 150; + --warning: 0.81 0.14 75; + --warning-foreground: 0.24 0.04 75; + --brand: 0.74 0.13 185; + --brand-foreground: 0.21 0.03 185; + /* Sidebar tokens (used by the onboarding stepper + shared sidebar). */ + --sidebar: 0.205 0 0; + --sidebar-foreground: 0.9848 0 0; + --sidebar-primary: 0.9848 0 0; + --sidebar-primary-foreground: 0.2044 0 0; + --sidebar-accent: 0.2686 0 0; + --sidebar-accent-foreground: 0.9848 0 0; + --sidebar-border: 0.2686 0 0; + --sidebar-ring: 0.8697 0 0; } } @@ -131,14 +154,14 @@ } /* Monaco scrollbar matching Minted style */ - .monaco-scrollable-element>.scrollbar.vertical>.slider { - background: hsl(var(--highlight) / 0.05) !important; + .monaco-scrollable-element > .scrollbar.vertical > .slider { + background: oklch(var(--highlight) / 0.05) !important; border-radius: var(--radius) !important; } - .monaco-scrollable-element>.scrollbar.vertical>.slider:hover, - .monaco-scrollable-element>.scrollbar.vertical.active>.slider { - background: hsl(var(--highlight) / 0.9) !important; + .monaco-scrollable-element > .scrollbar.vertical > .slider:hover, + .monaco-scrollable-element > .scrollbar.vertical.active > .slider { + background: oklch(var(--highlight) / 0.9) !important; } /* Scrollbar styling */ @@ -147,21 +170,77 @@ } ::-webkit-scrollbar-track { - background: hsl(var(--highlight) / 0.02); + background: oklch(var(--highlight) / 0.02); border-radius: var(--radius); width: 8px; } ::-webkit-scrollbar-thumb { - background-color: hsl(var(--highlight) / 0.01); + background-color: oklch(var(--highlight) / 0.01); border-radius: var(--radius); } ::-webkit-scrollbar-thumb:hover { - background-color: hsl(var(--highlight) / 0.05); + background-color: oklch(var(--highlight) / 0.05); } ::-webkit-scrollbar-thumb:active { - background-color: hsl(var(--highlight) / 0.9); + background-color: oklch(var(--highlight) / 0.9); } -} \ No newline at end of file +} + +@layer utilities { + /* Soft radial halo used behind the customizations "scan" hero. */ + .brand-halo { + position: relative; + isolation: isolate; + } + + .brand-halo::before { + content: ""; + position: absolute; + inset: -1px; + z-index: -1; + border-radius: inherit; + background: radial-gradient( + 120% 120% at 50% 0%, + oklch(var(--brand) / 0.22) 0%, + transparent 60% + ); + } + + /* Glowing ring around the scan icon / primary scan button. */ + .brand-glow { + box-shadow: + 0 0 0 1px oklch(var(--brand) / 0.4), + 0 0 24px 2px oklch(var(--brand) / 0.35), + 0 0 60px 8px oklch(var(--brand) / 0.18); + } + + @keyframes brand-pulse { + 0%, + 100% { + box-shadow: + 0 0 0 1px oklch(var(--brand) / 0.4), + 0 0 20px 2px oklch(var(--brand) / 0.3), + 0 0 48px 6px oklch(var(--brand) / 0.14); + } + + 50% { + box-shadow: + 0 0 0 1px oklch(var(--brand) / 0.55), + 0 0 30px 4px oklch(var(--brand) / 0.45), + 0 0 72px 12px oklch(var(--brand) / 0.24); + } + } + + .brand-pulse { + animation: brand-pulse 3s ease-in-out infinite; + } + + @media (prefers-reduced-motion: reduce) { + .brand-pulse { + animation: none; + } + } +} diff --git a/apps/native/src/lib/api-key-verification.ts b/apps/native/src/lib/api-key-verification.ts index e814a806f..bad1dad5b 100644 --- a/apps/native/src/lib/api-key-verification.ts +++ b/apps/native/src/lib/api-key-verification.ts @@ -68,13 +68,9 @@ export function createVerifiedApiKeyHandler({ try { await saveKey(key); } catch { - return currentRequestId === requestId - ? "failed" - : ("stale" satisfies SaveResult); + return currentRequestId === requestId ? "failed" : ("stale" satisfies SaveResult); } - return currentRequestId === requestId - ? "saved" - : ("stale" satisfies SaveResult); + return currentRequestId === requestId ? "saved" : ("stale" satisfies SaveResult); }); saveQueue = queuedSave.then( () => undefined, diff --git a/apps/native/src/lib/boot-diagnostics.ts b/apps/native/src/lib/boot-diagnostics.ts index 2e9baba6e..1af8f1f11 100644 --- a/apps/native/src/lib/boot-diagnostics.ts +++ b/apps/native/src/lib/boot-diagnostics.ts @@ -24,7 +24,7 @@ function setBootStageDomMarker(stage: string) { } function markNativeBootStage(stage: string) { - void tauriAPI.debug.markBootStage(stage, Date.now()).catch(() => { }); + void tauriAPI.debug.markBootStage(stage, Date.now()).catch(() => {}); } /** E2E-only render-body marker: DOM/title only, no IPC or localStorage. */ @@ -80,5 +80,5 @@ export function bootBreadcrumb(label: string, detail?: unknown) { const clientTimestampUnixMs = Date.now(); const summarized = summarizeDetail(detail); console.info(`[nixmac boot] ${label}`, summarized ?? ""); - void tauriAPI.debug.logBreadcrumb(label, summarized, clientTimestampUnixMs).catch(() => { }); + void tauriAPI.debug.logBreadcrumb(label, summarized, clientTimestampUnixMs).catch(() => {}); } diff --git a/apps/native/src/lib/env.ts b/apps/native/src/lib/env.ts index 768a7d40e..6c1f51915 100644 --- a/apps/native/src/lib/env.ts +++ b/apps/native/src/lib/env.ts @@ -29,8 +29,7 @@ const raw = Schema.decodeUnknownSync(Settings)(import.meta.env); export const settings: SettingsType = { VITE_SERVER_URL: raw.VITE_SERVER_URL, - NIX_INSTALLED_OVERRIDE: - raw.NIX_INSTALLED_OVERRIDE === "true" ? true : undefined, + NIX_INSTALLED_OVERRIDE: raw.NIX_INSTALLED_OVERRIDE === "true" ? true : undefined, }; // Helper to resolve the public website URL used by the native/web apps. diff --git a/apps/native/src/lib/lsp-monaco-bridge.ts b/apps/native/src/lib/lsp-monaco-bridge.ts index 33db65c6e..5102e98fd 100644 --- a/apps/native/src/lib/lsp-monaco-bridge.ts +++ b/apps/native/src/lib/lsp-monaco-bridge.ts @@ -150,9 +150,7 @@ export function bridgeMonacoToLsp( if (!result) return null; const contents = - typeof result.contents === "string" - ? result.contents - : result.contents.value; + typeof result.contents === "string" ? result.contents : result.contents.value; return { range: result.range ? lspToMonacoRange(result.range) : undefined, @@ -178,14 +176,12 @@ export function bridgeMonacoToLsp( if (!result) return { suggestions: [] }; - const items = Array.isArray(result) ? result : result.items ?? []; + const items = Array.isArray(result) ? result : (result.items ?? []); const word = _model.getWordUntilPosition(position); const suggestions: monacoNs.languages.CompletionItem[] = items.map((item) => { const doc = - typeof item.documentation === "string" - ? item.documentation - : item.documentation?.value; + typeof item.documentation === "string" ? item.documentation : item.documentation?.value; return { label: item.label, diff --git a/apps/native/src/lib/telemetry/context.tsx b/apps/native/src/lib/telemetry/context.tsx index dfb67853e..15b0d3632 100644 --- a/apps/native/src/lib/telemetry/context.tsx +++ b/apps/native/src/lib/telemetry/context.tsx @@ -18,9 +18,7 @@ export function TelemetryContextProvider({ value: TelemetryProvider; children: React.ReactNode; }) { - return ( - {children} - ); + return {children}; } /** diff --git a/apps/native/src/lib/telemetry/forwarding-processor.ts b/apps/native/src/lib/telemetry/forwarding-processor.ts index 7d4bd1577..a20b6ec64 100644 --- a/apps/native/src/lib/telemetry/forwarding-processor.ts +++ b/apps/native/src/lib/telemetry/forwarding-processor.ts @@ -1,9 +1,5 @@ import type { Context } from "@opentelemetry/api"; -import type { - ReadableSpan, - Span, - SpanProcessor, -} from "@opentelemetry/sdk-trace-web"; +import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-web"; import { invoke } from "@tauri-apps/api/core"; interface SerializedSpan { @@ -19,8 +15,7 @@ interface SerializedSpan { } // ReadableSpan.startTime/endTime are HrTime — a [seconds, nanoseconds] tuple. -const hrTimeToUnixNano = (time: [number, number]): number => - time[0] * 1e9 + time[1]; +const hrTimeToUnixNano = (time: [number, number]): number => time[0] * 1e9 + time[1]; function serializeSpan(span: ReadableSpan): SerializedSpan { return { diff --git a/apps/native/src/lib/telemetry/init.ts b/apps/native/src/lib/telemetry/init.ts index fa7b79e9f..76a8d514c 100644 --- a/apps/native/src/lib/telemetry/init.ts +++ b/apps/native/src/lib/telemetry/init.ts @@ -26,11 +26,7 @@ export async function initTelemetry(): Promise { } const key = (import.meta.env.VITE_POSTHOG_KEY || "").toString().trim(); - const host = ( - import.meta.env.VITE_POSTHOG_HOST || "https://us.i.posthog.com" - ) - .toString() - .trim(); + const host = (import.meta.env.VITE_POSTHOG_HOST || "https://us.i.posthog.com").toString().trim(); if (key.length === 0) { setTelemetryProvider(noopProvider); @@ -50,11 +46,7 @@ export async function initTelemetry(): Promise { key, host, release: (import.meta.env.VITE_NIXMAC_VERSION || "unknown").toString(), - environment: ( - import.meta.env.VITE_NIXMAC_ENV || - import.meta.env.MODE || - "prod" - ).toString(), + environment: (import.meta.env.VITE_NIXMAC_ENV || import.meta.env.MODE || "prod").toString(), }, sendDiagnostics, ); diff --git a/apps/native/src/lib/telemetry/provider.ts b/apps/native/src/lib/telemetry/provider.ts index 128f21bac..cf0fd6a52 100644 --- a/apps/native/src/lib/telemetry/provider.ts +++ b/apps/native/src/lib/telemetry/provider.ts @@ -1,10 +1,7 @@ import { SpanStatusCode, type Tracer } from "@opentelemetry/api"; import { WebTracerProvider } from "@opentelemetry/sdk-trace-web"; import { resourceFromAttributes } from "@opentelemetry/resources"; -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, -} from "@opentelemetry/semantic-conventions"; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; import posthog from "posthog-js"; import { ForwardingSpanProcessor } from "./forwarding-processor"; import { sanitizeProps, sanitizeTelemetryAttributes } from "./sanitize"; @@ -21,14 +18,8 @@ const SERVICE_NAME = "nixmac"; // OTEL span attributes accept primitives only; coerce everything else to a // JSON string after sanitization so structured props survive the boundary. -const toAttributeValue = ( - value: unknown, -): string | number | boolean | undefined => { - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { +const toAttributeValue = (value: unknown): string | number | boolean | undefined => { + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { return value; } if (value === null || value === undefined) { @@ -120,9 +111,7 @@ export function createTelemetryProvider( if (context) { const sanitized = sanitizeTelemetryAttributes(context); if (sanitized && typeof sanitized === "object") { - span.setAttributes( - toSpanAttributes(sanitized as Record), - ); + span.setAttributes(toSpanAttributes(sanitized as Record)); } } span.recordException(error); diff --git a/apps/native/src/lib/telemetry/sanitize.ts b/apps/native/src/lib/telemetry/sanitize.ts index 29d01c9c0..d397ab609 100644 --- a/apps/native/src/lib/telemetry/sanitize.ts +++ b/apps/native/src/lib/telemetry/sanitize.ts @@ -62,16 +62,10 @@ const sanitizeString = (value: string): string => { sanitized = sanitized.replace(OPENAI_TOKEN_PATTERN, REDACTED); sanitized = sanitized.replace(ANTHROPIC_TOKEN_PATTERN, REDACTED); sanitized = sanitized.replace(PRIVATE_KEY_BLOCK_PATTERN, REDACTED); - sanitized = sanitized.replace( - HOME_DIR_PATH_PATTERN, - "/Users/[REDACTED_USER]", - ); - sanitized = sanitized.replace( - NIX_SECRET_ASSIGNMENT_PATTERN, - (_, key: string) => { - return `${key} = ${REDACTED}`; - }, - ); + sanitized = sanitized.replace(HOME_DIR_PATH_PATTERN, "/Users/[REDACTED_USER]"); + sanitized = sanitized.replace(NIX_SECRET_ASSIGNMENT_PATTERN, (_, key: string) => { + return `${key} = ${REDACTED}`; + }); return sanitizeUrl(sanitized); }; @@ -135,9 +129,7 @@ export function sanitizeDiagnosticText(value: string): string { * Sanitize a flat property bag before sending to PostHog. * String values are scrubbed; non-string values pass through unchanged. */ -export function sanitizeProps( - props: Record, -): Record { +export function sanitizeProps(props: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(props)) { out[k] = typeof v === "string" ? sanitizeDiagnosticText(v) : v; diff --git a/apps/native/src/lib/telemetry/types.ts b/apps/native/src/lib/telemetry/types.ts index 46de752b9..0a71d4362 100644 --- a/apps/native/src/lib/telemetry/types.ts +++ b/apps/native/src/lib/telemetry/types.ts @@ -9,17 +9,17 @@ export type TelemetryEvent = | { name: "app_launched"; props?: { environment: string } } | { name: "app_ready"; props?: { boot_ms?: number } } | { - name: "evolve_started"; - props?: { provider: string; has_custom_model: boolean }; - } + name: "evolve_started"; + props?: { provider: string; has_custom_model: boolean }; + } | { - name: "evolve_completed"; - props: { step: string }; - } + name: "evolve_completed"; + props: { step: string }; + } | { - name: "evolve_failed"; - props?: { stage: "build" | "agent" | "apply" }; - } + name: "evolve_failed"; + props?: { stage: "build" | "agent" | "apply" }; + } | { name: "rollback_performed" } | { name: "settings_changed"; props: { setting: string } } | { name: "diagnostics_opt_in" } @@ -27,9 +27,9 @@ export type TelemetryEvent = | { name: "onboarding_completed" } | { name: "onboarding_restarted" } | { - name: "inference_configured"; - props: { mode: "hosted" | "byok"; provider?: string }; - } + name: "inference_configured"; + props: { mode: "hosted" | "byok"; provider?: string }; + } | { name: "inference_skipped" } | { name: "account_signed_in" } | { name: "first_build_started" } @@ -39,9 +39,9 @@ export type TelemetryEvent = | { name: "apply_failed" } | { name: "customizations_scanned" } | { - name: "customizations_tracked"; - props: { count: number }; - } + name: "customizations_tracked"; + props: { count: number }; + } | { name: "history_restored" } | { name: "feedback_submitted"; props: { type: string } }; diff --git a/apps/native/src/main.tsx b/apps/native/src/main.tsx index c989b5dc7..50e4cf32c 100644 --- a/apps/native/src/main.tsx +++ b/apps/native/src/main.tsx @@ -20,9 +20,7 @@ markBootStage("root-found"); // Dropped from production, e2e harness if (import.meta.env.VITE_NIXMAC_E2E_MODE === "true") { - void import("@/e2e/boot-harness").then((m) => - m.attachBootHarness({ rootElement }), - ); + void import("@/e2e/boot-harness").then((m) => m.attachBootHarness({ rootElement })); } const root = ReactDOM.createRoot(rootElement); @@ -31,9 +29,7 @@ const renderApp = (telemetry: TelemetryProvider) => { markBootStage("react-render-start"); root.render( - } - > + }> @@ -47,19 +43,21 @@ const bootstrap = async () => { // In E2E_MODE, initTelemetry returns a noop provider synchronously. const telemetry = await initTelemetry(); setTelemetryProvider(telemetry); - telemetry.captureEvent({ name: "app_launched", props: { environment: (import.meta.env.VITE_NIXMAC_ENV || import.meta.env.MODE || "prod").toString() } }); + telemetry.captureEvent({ + name: "app_launched", + props: { + environment: (import.meta.env.VITE_NIXMAC_ENV || import.meta.env.MODE || "prod").toString(), + }, + }); try { renderApp(telemetry); } catch (error) { markBootStage("react-render-fatal"); - getTelemetry().captureError( - error instanceof Error ? error : new Error(String(error)), - { name: "render-fatal" }, - ); - root.render( - , - ); + getTelemetry().captureError(error instanceof Error ? error : new Error(String(error)), { + name: "render-fatal", + }); + root.render(); } }; diff --git a/apps/native/src/preview-indicator-window.tsx b/apps/native/src/preview-indicator-window.tsx index 6d1f21028..93c1199d8 100644 --- a/apps/native/src/preview-indicator-window.tsx +++ b/apps/native/src/preview-indicator-window.tsx @@ -30,12 +30,9 @@ function PreviewIndicatorWindow() { setError(String(err)); }); - const unsubscribe = listen( - "preview-indicator:update", - (event) => { - setState(event.payload); - } - ); + const unsubscribe = listen("preview-indicator:update", (event) => { + setState(event.payload); + }); return () => { unsubscribe.then((unlisten) => unlisten()); @@ -55,9 +52,7 @@ function PreviewIndicatorWindow() { // DEBUG: Show error or loading state if (error) { return ( -
    +
    Error: {error}
    ); @@ -65,9 +60,7 @@ function PreviewIndicatorWindow() { if (!mounted) { return ( -
    +
    Mounting...
    ); @@ -92,6 +85,6 @@ if (rootElement) { ReactDOM.createRoot(rootElement).render( - + , ); } diff --git a/apps/native/src/utils/test-fixtures.ts b/apps/native/src/utils/test-fixtures.ts index d8165ab98..d073e6fe2 100644 --- a/apps/native/src/utils/test-fixtures.ts +++ b/apps/native/src/utils/test-fixtures.ts @@ -1,4 +1,4 @@ -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import type { GlobalPreferences, NixInstallState, @@ -48,9 +48,7 @@ export function makeGrantedPermissions(): PermissionsState { } /** Fully installed nix/darwin-rebuild snapshot for tests and stories. */ -export function makeNixInstallState( - overrides: Partial = {}, -): NixInstallState { +export function makeNixInstallState(overrides: Partial = {}): NixInstallState { return { installed: true, darwinRebuildAvailable: true, diff --git a/apps/native/src/utils/widget-test-helpers.ts b/apps/native/src/utils/widget-test-helpers.ts index 9b02a7a03..739d55156 100644 --- a/apps/native/src/utils/widget-test-helpers.ts +++ b/apps/native/src/utils/widget-test-helpers.ts @@ -3,8 +3,8 @@ * Exposed on window.__testWidget so WDIO tests can call them via browser.execute. */ -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { clearChangeMap } from "@/viewmodel/change-map"; import { clearEvolveEvents } from "@/viewmodel/evolution"; import { refreshEvolveSnapshot } from "@/viewmodel/evolve"; diff --git a/apps/native/src/viewmodel/change-map.ts b/apps/native/src/viewmodel/change-map.ts index 8b2aa1609..c4586dece 100644 --- a/apps/native/src/viewmodel/change-map.ts +++ b/apps/native/src/viewmodel/change-map.ts @@ -1,6 +1,6 @@ import { tauriAPI } from "@/ipc/api"; import type { SemanticChangeMap } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { bindBackendSlice } from "./_helpers"; import { refreshHistorySnapshot } from "./history"; diff --git a/apps/native/src/viewmodel/evolution.ts b/apps/native/src/viewmodel/evolution.ts index 759888978..1bec125cd 100644 --- a/apps/native/src/viewmodel/evolution.ts +++ b/apps/native/src/viewmodel/evolution.ts @@ -3,8 +3,8 @@ import type { EvolveEvent } from "@/ipc/types"; import { EVOLVE_EVENT_CHANNEL } from "@/lib/constants"; import { getTelemetry } from "@/lib/telemetry/instance"; import { formatDurationMs } from "@/lib/utils"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { toast } from "sonner"; /** Reset the evolve event stream (debug tooling / e2e reset / cancel). */ @@ -57,8 +57,7 @@ export function startEvolutionSync(): Promise<() => void> { if (!payload) return; useViewModel.setState((state) => ({ - evolveEvents: - payload.eventType === "start" ? [payload] : [...state.evolveEvents, payload], + evolveEvents: payload.eventType === "start" ? [payload] : [...state.evolveEvents, payload], })); if (payload.raw) { diff --git a/apps/native/src/viewmodel/evolve.ts b/apps/native/src/viewmodel/evolve.ts index 5e14e8479..b56c369f6 100644 --- a/apps/native/src/viewmodel/evolve.ts +++ b/apps/native/src/viewmodel/evolve.ts @@ -1,6 +1,6 @@ import { tauriAPI } from "@/ipc/api"; import type { EvolveState } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { bindBackendSlice } from "./_helpers"; function mirrorEvolveState(evolve: EvolveState | null): void { diff --git a/apps/native/src/viewmodel/git.ts b/apps/native/src/viewmodel/git.ts index aea8b8535..f9073bade 100644 --- a/apps/native/src/viewmodel/git.ts +++ b/apps/native/src/viewmodel/git.ts @@ -1,7 +1,7 @@ import { tauriAPI, ipcRenderer } from "@/ipc/api"; import type { GitState, GitStatus } from "@/ipc/types"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { bindBackendSlice } from "./_helpers"; import { refreshHistorySnapshot } from "./history"; diff --git a/apps/native/src/viewmodel/history.ts b/apps/native/src/viewmodel/history.ts index 1304398e1..84316971b 100644 --- a/apps/native/src/viewmodel/history.ts +++ b/apps/native/src/viewmodel/history.ts @@ -1,5 +1,5 @@ import { tauriAPI } from "@/ipc/api"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; export async function refreshHistorySnapshot(): Promise { try { diff --git a/apps/native/src/viewmodel/nix-install.ts b/apps/native/src/viewmodel/nix-install.ts index 410fffaec..c24488f79 100644 --- a/apps/native/src/viewmodel/nix-install.ts +++ b/apps/native/src/viewmodel/nix-install.ts @@ -1,7 +1,7 @@ import { tauriAPI } from "@/ipc/api"; import type { NixInstallState } from "@/ipc/types"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { bindBackendSlice } from "./_helpers"; function mirrorNixInstallState(next: NixInstallState): void { diff --git a/apps/native/src/viewmodel/permissions.ts b/apps/native/src/viewmodel/permissions.ts index 659897e0b..f4036926c 100644 --- a/apps/native/src/viewmodel/permissions.ts +++ b/apps/native/src/viewmodel/permissions.ts @@ -1,6 +1,6 @@ import { tauriAPI } from "@/ipc/api"; import type { PermissionsState } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { bindBackendSlice } from "./_helpers"; export function mirrorPermissions(permissions: PermissionsState | null): void { diff --git a/apps/native/src/viewmodel/preferences.ts b/apps/native/src/viewmodel/preferences.ts index affcceb48..e82fa7974 100644 --- a/apps/native/src/viewmodel/preferences.ts +++ b/apps/native/src/viewmodel/preferences.ts @@ -1,6 +1,6 @@ import { tauriAPI } from "@/ipc/api"; import type { GlobalPreferences } from "@/ipc/types"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { bindBackendSlice } from "./_helpers"; export function mirrorPreferences(preferences: GlobalPreferences): void { diff --git a/apps/native/src/viewmodel/prompt-history.ts b/apps/native/src/viewmodel/prompt-history.ts index acadcc363..e0a92f8b6 100644 --- a/apps/native/src/viewmodel/prompt-history.ts +++ b/apps/native/src/viewmodel/prompt-history.ts @@ -1,5 +1,5 @@ import { tauriAPI } from "@/ipc/api"; -import { useViewModel } from "@/stores/view-model"; +import { useViewModel } from "@nixmac/state"; import { bindBackendSlice } from "./_helpers"; export function mirrorPromptHistory(promptHistory: string[]): void { diff --git a/apps/native/src/viewmodel/rebuild.ts b/apps/native/src/viewmodel/rebuild.ts index fdec30062..5b10cbae3 100644 --- a/apps/native/src/viewmodel/rebuild.ts +++ b/apps/native/src/viewmodel/rebuild.ts @@ -1,11 +1,7 @@ import { tauriAPI, ipcRenderer } from "@/ipc/api"; -import type { - DarwinApplyDataEvent, - DarwinApplySummaryEvent, - RebuildStatus, -} from "@/ipc/types"; -import { useUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import type { DarwinApplyDataEvent, DarwinApplySummaryEvent, RebuildStatus } from "@/ipc/types"; +import { useUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import type { RebuildLine } from "@/types/rebuild"; import { bindBackendSlice } from "./_helpers"; diff --git a/apps/native/src/viewmodel/viewmodel.test.ts b/apps/native/src/viewmodel/viewmodel.test.ts index 911eddcc6..af6aa642d 100644 --- a/apps/native/src/viewmodel/viewmodel.test.ts +++ b/apps/native/src/viewmodel/viewmodel.test.ts @@ -9,8 +9,8 @@ import type { RebuildStatus, SemanticChangeMap, } from "@/ipc/types"; -import { useUiState, initialUiState } from "@/stores/ui-state"; -import { useViewModel } from "@/stores/view-model"; +import { useUiState, initialUiState } from "@nixmac/state"; +import { useViewModel } from "@nixmac/state"; import { makeGlobalPreferences, makeNixInstallState, diff --git a/ops/secrets/e2e.enc.yaml b/ops/secrets/e2e.enc.yaml index 9cbd8182f..0e5c72ef0 100644 --- a/ops/secrets/e2e.enc.yaml +++ b/ops/secrets/e2e.enc.yaml @@ -3,62 +3,62 @@ mac_user: ENC[AES256_GCM,data:7aVuki4=,iv:NXSlRNOx09jShFSINqivX9syxyfKtExFWR6p/B mac_ssh_key: ENC[AES256_GCM,data:7bgMNqzFH/qx5mxI7IBCazzOoQA7C35u7euVn6NRfUHf2CRg5Hci2/ooWnB/+byokVflERBVWu9NJj4e33GSf9/6Q/Me3It/c5WPqR1/MAtWkzySM83lhQO3meqpNFSOqQWYhszcweJM7TZ7ALaPZqup4qA7VSV3T60+j9MPu8uo+5saWe1trRHTuTdD6AYiObrc+/TijSWUcZtXXdOMjPTH0QZHajvCfxWqlz5V0oryG5BLXLnhh9pyRBP8khRu/m62Yp1ljCEaWgjINlF27jgfVYrYeBNCos64Bhb4jq3msHMYLsN2Vkh3qeDGk7VZg/6OeuUjz0gFEkahV0mJIhadK5LPRNbNeiXcm5Dqywt9YwZ8eXyUeshUB3ZN5giMxbtjBQ+f5BJDvlloXLqbAVWBjEyRNY2KZuVYUdSDKvXZA6Eoxw1OsiC3MobAPV1zq0YIqeKMLq0g8wx6dmzqBcGYHLU8kRwRBMWjXSkNAoQYXnqht1GtfBZ0NB+xN0dWuSZdYHQBNNdAtI0D5SOENiD2FHvPmHhxpqXH0ic6jRWtZlQ=,iv:MWyycHWaRD3W+OASFeZUkRVPijbAW91Vkx0DnRVyv94=,tag:08GimVCsqUgJrFNeu9FcTQ==,type:str] mac_admin_pw: ENC[AES256_GCM,data:41fJl0+tUE/xrWA=,iv:zSHAuBls1HGwCJwmZCnTFXN0cZ1B6vhQtVfuHWdhVGQ=,tag:VFC/ODtdpdlJZtoigcLZKw==,type:str] sops: - age: - - recipient: age1ua3n0xa25z5tnrhhkndmkpz9elwsxw5jzq89fwldwlcm6wxg9ddsaxksm4 - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsVndaVCtHVnQvNmVzME82 - RExCVytoZ2hwNDd0R25sVHVPME9YSHR2REhNCmJRT3ZmVGRCekgwUzU3bjRxbnFB - d29ZaXlEdCtycWZkeFdBSlMvZnMrR0EKLS0tIDlwRFRiNkhyaDhQeExSQUtrYXd4 - U0dERjUxT2F0TW5oNmRTVXdHOGhqZEEKDAM/AJJ948307/nlX/5ST8SdvuAaKh4H - uUjOvHpXDNUsVkzJDbN7vJNIAFB9AWPj1xyqzK6h6PvKWx1C0IABBA== - -----END AGE ENCRYPTED FILE----- - - recipient: age1unp2wxu3h5t5up5zsnqurwac69v84vtuy8lllpdwf03gddc6xfws00nu2a - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQ3ZmbEZVQ3JqUXFQeFY2 - OU5vKy9HT3M3bmtneWhNREpiOU5VQS9qa1ZrCnVUOVRnamlUNDByU1gwdllvODM4 - Y1BBZ3VZNGRxYnRxZGM3amtTeWFhUEEKLS0tIC9tbWhwWVJIcG9JR054TTRqN2Fr - eEIwOTVLQnFqSENSdTV4bW5lKzhqa0EKBDMxPeeSWbALJ0jeym8UcqNeACW06zud - 2gOiAn7Tlxx2VmvCJDT7n59XLAHuSL8cubl8GicDWnLmYETFCP9XGQ== - -----END AGE ENCRYPTED FILE----- - - recipient: age164al9lamrv4ufza9wvg5g5kh863yenq0gdggyxujugxqlv00894spd6jj9 - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURytrNzRKOFU4VCszaGQ3 - Q0JKMGFnaW13M0RmSFNOU1crTTJoQXlvaG1BCnlxaGdXNDVuNk9VNVppNUd5cXlR - WFNDS0I3TXBIWlo4eVhXR0JTcDV4ZzAKLS0tIGZYakh4RjVXTUp3aWhvdytHcW9O - UHJSM1FZVi91UUpldEhTQmFGN21zMDAKLo46FdZA9oOABhxWpzpQQJ7hJ96CPyUg - TIDA5nKkRMaeJGNB9KyyShaNNaqwxOIWv6YtJo6TrEgC9OxCwRGOTQ== - -----END AGE ENCRYPTED FILE----- - - recipient: age1vapyvpjzqg8wdfay055s574qt0u6avtzv4ch7kv0epvtycpyjqzqk3m4yr - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnNGVDdFBjMmgwZHRweUxF - VDRTc0pOMUsrOWs4c3FQay9mSkliUFFzR2hjCk1WQ0RjTjl5RTcwQ0kwWS8zZHdy - Mml2QU0rZ1FPUkF1U3I2SEpPRWp3dEEKLS0tIGxPd1JlZkxKVnR5dGRkSk9Cd2J1 - aFFFZUdLM2hON0Z6OG1seUVNVzd3bm8K/zjtr3jWQffyOxdWI7YjW1bC4cJv9AjY - FfYREyBjEWdb+6hNuHawtNx7DZVZx132xE75TDAQePtPeTciocYHHw== - -----END AGE ENCRYPTED FILE----- - - recipient: age16wuzuxnkcgfuxzvzgk5e5a5f6hhs386adjewyv54m9esr4yj6uuslpn6tp - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNaDJ4Y2IzOS95aml6TnNO - WXVtaUxnQ25yT250NmdMNEtiVzFPK29wM0FnCnNodGJ3WFJtVEdwMmcwSWNWR2RD - V0ZrbWFUc2JCa0llUHR1QjZqRkRlbUEKLS0tIDBwaHpiWkFIS1ljMlMwd1RCWWtw - aVFhOTU5aDlKNmY0Qk16TnF0ek5OMWMKjLmSQaSIN8tPz07MKd5q40wOx/9+9tNu - 6W6kVRhCbZ6EGcnS7jMZyAcOcQzT+I6fCW1ZcU8kS6gRyWo0VyrwZA== - -----END AGE ENCRYPTED FILE----- - - recipient: age1eqcj2g0fdekj2wpqp4y0fg9c5myydjdt9zlr5scr0grk6fxszymqkpw5jf - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuWktWM2twWEdJOUd3VjJO - cGRnUklQeWFITHRGVW01TXk4WnNVdHlsaWxRCm81NXJZWUl5WG5UY3NjUzVJZTVL - M2dTUTFrck9uaG5keFJSZCtlQ0ZCQjAKLS0tIFpKaGJZQlY3K3lOajVxRUNIdzk0 - SXBnOXM0ZzNXU0pDTEJVQWFrWTBCVTQKaAUmoAdavvb5ANTfdX30Pf8ASSBnMwCD - 5mikTN9pDEj3QPEAVQkUhnWd+iyoK4V35RTlTt/HCvgVEe0/PRdNrw== - -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-03-19T06:03:57Z" - mac: ENC[AES256_GCM,data:eiLohRfIjE2Cs2S39VvNUXNxMKTas/UdByFsIGznwhXeDx/ypP12ACrhtWPqhZwQ8sVIR9WFuj9A6+u3va2s+LUTSvxrVCzjoOI5mw7vJOCoEzKGkwi9U3lJBE1uOsU4aIf8URAIPpXv+FhRO2DS/B0FgoXr5WcDEeoQ33W4/6c=,iv:Jz2sNW/HJuAhKB1Y4AOILPSpRF2Rx6rIpryTc+SNPSU=,tag:X3ER1D1TnaWQdx22PtraCg==,type:str] - unencrypted_comment_regex: ^\s?(safe|plaintext|unencrypted)$ - version: 3.12.1 + age: + - recipient: age1ua3n0xa25z5tnrhhkndmkpz9elwsxw5jzq89fwldwlcm6wxg9ddsaxksm4 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsVndaVCtHVnQvNmVzME82 + RExCVytoZ2hwNDd0R25sVHVPME9YSHR2REhNCmJRT3ZmVGRCekgwUzU3bjRxbnFB + d29ZaXlEdCtycWZkeFdBSlMvZnMrR0EKLS0tIDlwRFRiNkhyaDhQeExSQUtrYXd4 + U0dERjUxT2F0TW5oNmRTVXdHOGhqZEEKDAM/AJJ948307/nlX/5ST8SdvuAaKh4H + uUjOvHpXDNUsVkzJDbN7vJNIAFB9AWPj1xyqzK6h6PvKWx1C0IABBA== + -----END AGE ENCRYPTED FILE----- + - recipient: age1unp2wxu3h5t5up5zsnqurwac69v84vtuy8lllpdwf03gddc6xfws00nu2a + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQ3ZmbEZVQ3JqUXFQeFY2 + OU5vKy9HT3M3bmtneWhNREpiOU5VQS9qa1ZrCnVUOVRnamlUNDByU1gwdllvODM4 + Y1BBZ3VZNGRxYnRxZGM3amtTeWFhUEEKLS0tIC9tbWhwWVJIcG9JR054TTRqN2Fr + eEIwOTVLQnFqSENSdTV4bW5lKzhqa0EKBDMxPeeSWbALJ0jeym8UcqNeACW06zud + 2gOiAn7Tlxx2VmvCJDT7n59XLAHuSL8cubl8GicDWnLmYETFCP9XGQ== + -----END AGE ENCRYPTED FILE----- + - recipient: age164al9lamrv4ufza9wvg5g5kh863yenq0gdggyxujugxqlv00894spd6jj9 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBURytrNzRKOFU4VCszaGQ3 + Q0JKMGFnaW13M0RmSFNOU1crTTJoQXlvaG1BCnlxaGdXNDVuNk9VNVppNUd5cXlR + WFNDS0I3TXBIWlo4eVhXR0JTcDV4ZzAKLS0tIGZYakh4RjVXTUp3aWhvdytHcW9O + UHJSM1FZVi91UUpldEhTQmFGN21zMDAKLo46FdZA9oOABhxWpzpQQJ7hJ96CPyUg + TIDA5nKkRMaeJGNB9KyyShaNNaqwxOIWv6YtJo6TrEgC9OxCwRGOTQ== + -----END AGE ENCRYPTED FILE----- + - recipient: age1vapyvpjzqg8wdfay055s574qt0u6avtzv4ch7kv0epvtycpyjqzqk3m4yr + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnNGVDdFBjMmgwZHRweUxF + VDRTc0pOMUsrOWs4c3FQay9mSkliUFFzR2hjCk1WQ0RjTjl5RTcwQ0kwWS8zZHdy + Mml2QU0rZ1FPUkF1U3I2SEpPRWp3dEEKLS0tIGxPd1JlZkxKVnR5dGRkSk9Cd2J1 + aFFFZUdLM2hON0Z6OG1seUVNVzd3bm8K/zjtr3jWQffyOxdWI7YjW1bC4cJv9AjY + FfYREyBjEWdb+6hNuHawtNx7DZVZx132xE75TDAQePtPeTciocYHHw== + -----END AGE ENCRYPTED FILE----- + - recipient: age16wuzuxnkcgfuxzvzgk5e5a5f6hhs386adjewyv54m9esr4yj6uuslpn6tp + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNaDJ4Y2IzOS95aml6TnNO + WXVtaUxnQ25yT250NmdMNEtiVzFPK29wM0FnCnNodGJ3WFJtVEdwMmcwSWNWR2RD + V0ZrbWFUc2JCa0llUHR1QjZqRkRlbUEKLS0tIDBwaHpiWkFIS1ljMlMwd1RCWWtw + aVFhOTU5aDlKNmY0Qk16TnF0ek5OMWMKjLmSQaSIN8tPz07MKd5q40wOx/9+9tNu + 6W6kVRhCbZ6EGcnS7jMZyAcOcQzT+I6fCW1ZcU8kS6gRyWo0VyrwZA== + -----END AGE ENCRYPTED FILE----- + - recipient: age1eqcj2g0fdekj2wpqp4y0fg9c5myydjdt9zlr5scr0grk6fxszymqkpw5jf + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuWktWM2twWEdJOUd3VjJO + cGRnUklQeWFITHRGVW01TXk4WnNVdHlsaWxRCm81NXJZWUl5WG5UY3NjUzVJZTVL + M2dTUTFrck9uaG5keFJSZCtlQ0ZCQjAKLS0tIFpKaGJZQlY3K3lOajVxRUNIdzk0 + SXBnOXM0ZzNXU0pDTEJVQWFrWTBCVTQKaAUmoAdavvb5ANTfdX30Pf8ASSBnMwCD + 5mikTN9pDEj3QPEAVQkUhnWd+iyoK4V35RTlTt/HCvgVEe0/PRdNrw== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-03-19T06:03:57Z" + mac: ENC[AES256_GCM,data:eiLohRfIjE2Cs2S39VvNUXNxMKTas/UdByFsIGznwhXeDx/ypP12ACrhtWPqhZwQ8sVIR9WFuj9A6+u3va2s+LUTSvxrVCzjoOI5mw7vJOCoEzKGkwi9U3lJBE1uOsU4aIf8URAIPpXv+FhRO2DS/B0FgoXr5WcDEeoQ33W4/6c=,iv:Jz2sNW/HJuAhKB1Y4AOILPSpRF2Rx6rIpryTc+SNPSU=,tag:X3ER1D1TnaWQdx22PtraCg==,type:str] + unencrypted_comment_regex: ^\s?(safe|plaintext|unencrypted)$ + version: 3.12.1 diff --git a/ops/secrets/prod.yaml b/ops/secrets/prod.yaml index 95b33c18f..7c13338f0 100644 --- a/ops/secrets/prod.yaml +++ b/ops/secrets/prod.yaml @@ -3,35 +3,35 @@ WEB_URL: nixmac.com # safe API_URL: api.nixmac.com sops: - age: - - recipient: age1c2q5fm3l3gn2tgcs35xstu44mu2zyrft993ksljw5r3z4dzcqcfq6vtakn - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByNnoxbitGZHEzM1JJazZL - WGlYcmlDMU9KZXpLN3A2MVZPMlZEd2FaTFVBCkhFY09VWk1QanpUOVBVM1lBVExY - WXhnaWVRL0orNXNUN2NDZXEzU3ZUcWcKLS0tIFhmVzF5RDBZVTgyVnFtdTdkeGx4 - aEM0MUdUMGZSTVB1cXFCZXpBbStnemMK84Y+AKVGcTezjVb5z8U4H+iDKeDwPjRo - xtKSS8cBQYK7A6/vm59Dm47okAVe56Tmnsl05aNvQHW6BrICFFbqbA== - -----END AGE ENCRYPTED FILE----- - - recipient: age1zl7nsteyj8lzu0spgd9qav3fhkmekkjd30uzt69mpmxgrev3nq7s3jnreh - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlRW8vSDEvTmxZK0VHdEw5 - Z3RWV3lOaGpaRUNMSGRwdm95M0RsNmZSVHlVCjZ2YzdvOG1WRzB6WEthRFhUMmVr - cmtHSmxML0o0bDBtNlhmYUUzcWljaW8KLS0tIHVkUzBla3RpdDNTTDE0MVZJZUlr - aE82SWk0MEdVeURpeE1qVTlvNlV0ZkEKhPdK/SbRtrVeVSmkhWZlyzBn9Ie2D2I6 - qSMfmZ7fLWFwQifzXBGTzFmOA3/M/9UoQjhUDvd4KQgluhwiFVaLfw== - -----END AGE ENCRYPTED FILE----- - - recipient: age16wuzuxnkcgfuxzvzgk5e5a5f6hhs386adjewyv54m9esr4yj6uuslpn6tp - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXdi8zTG5XUHlNNzJZaVli - Wmd0UFl4eXNyTmFHUnVGTlRienovanVJNUZzCkFHZ3c5bDJ0SVJoU3pIUFlSZVRY - TkdRVk9IWVEycGRjWlVibVRPQis2bkEKLS0tIGoyK1g0MWNMTTEzR0VzQU5rMkpP - Y05EVjdqdmlHTHRHN0hFMklRNzRDbEUKeE7C4+QOyxqXtwSg0crBluxmGES2FvPD - ThwwaoodtzLGP9gUWH+v3b4Js2Bo5oeJPVjttF3gt8PcZjcVnxClNA== - -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-04-16T01:48:08Z" - mac: ENC[AES256_GCM,data:0SWFdQ/BvVdbZPQseeOU5HRuenOS4mhwcxYgg1nSMAA9haJ9zUWecjQ3UpzZQTv1fIyAoABbZDc6SBr7tXvHL567FFjCxTtVigg8pw9ZDlGgIUf7t0Kdo/yEzJT/M/gNyOBHO02tTnMGx3XFarZ/Cj8kU/IByz/93+j2UXXTrNw=,iv:rap4vuyishmZAc26eyz1/ZXNO+//F4qHdI8P77J1u8o=,tag:WvfaPfWLYOqxz4heUpCMBw==,type:str] - unencrypted_comment_regex: ^\s?(safe|unencrypted)$ - version: 3.12.2 + age: + - recipient: age1c2q5fm3l3gn2tgcs35xstu44mu2zyrft993ksljw5r3z4dzcqcfq6vtakn + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByNnoxbitGZHEzM1JJazZL + WGlYcmlDMU9KZXpLN3A2MVZPMlZEd2FaTFVBCkhFY09VWk1QanpUOVBVM1lBVExY + WXhnaWVRL0orNXNUN2NDZXEzU3ZUcWcKLS0tIFhmVzF5RDBZVTgyVnFtdTdkeGx4 + aEM0MUdUMGZSTVB1cXFCZXpBbStnemMK84Y+AKVGcTezjVb5z8U4H+iDKeDwPjRo + xtKSS8cBQYK7A6/vm59Dm47okAVe56Tmnsl05aNvQHW6BrICFFbqbA== + -----END AGE ENCRYPTED FILE----- + - recipient: age1zl7nsteyj8lzu0spgd9qav3fhkmekkjd30uzt69mpmxgrev3nq7s3jnreh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlRW8vSDEvTmxZK0VHdEw5 + Z3RWV3lOaGpaRUNMSGRwdm95M0RsNmZSVHlVCjZ2YzdvOG1WRzB6WEthRFhUMmVr + cmtHSmxML0o0bDBtNlhmYUUzcWljaW8KLS0tIHVkUzBla3RpdDNTTDE0MVZJZUlr + aE82SWk0MEdVeURpeE1qVTlvNlV0ZkEKhPdK/SbRtrVeVSmkhWZlyzBn9Ie2D2I6 + qSMfmZ7fLWFwQifzXBGTzFmOA3/M/9UoQjhUDvd4KQgluhwiFVaLfw== + -----END AGE ENCRYPTED FILE----- + - recipient: age16wuzuxnkcgfuxzvzgk5e5a5f6hhs386adjewyv54m9esr4yj6uuslpn6tp + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBXdi8zTG5XUHlNNzJZaVli + Wmd0UFl4eXNyTmFHUnVGTlRienovanVJNUZzCkFHZ3c5bDJ0SVJoU3pIUFlSZVRY + TkdRVk9IWVEycGRjWlVibVRPQis2bkEKLS0tIGoyK1g0MWNMTTEzR0VzQU5rMkpP + Y05EVjdqdmlHTHRHN0hFMklRNzRDbEUKeE7C4+QOyxqXtwSg0crBluxmGES2FvPD + ThwwaoodtzLGP9gUWH+v3b4Js2Bo5oeJPVjttF3gt8PcZjcVnxClNA== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-04-16T01:48:08Z" + mac: ENC[AES256_GCM,data:0SWFdQ/BvVdbZPQseeOU5HRuenOS4mhwcxYgg1nSMAA9haJ9zUWecjQ3UpzZQTv1fIyAoABbZDc6SBr7tXvHL567FFjCxTtVigg8pw9ZDlGgIUf7t0Kdo/yEzJT/M/gNyOBHO02tTnMGx3XFarZ/Cj8kU/IByz/93+j2UXXTrNw=,iv:rap4vuyishmZAc26eyz1/ZXNO+//F4qHdI8P77J1u8o=,tag:WvfaPfWLYOqxz4heUpCMBw==,type:str] + unencrypted_comment_regex: ^\s?(safe|unencrypted)$ + version: 3.12.2 diff --git a/ops/secrets/secrets.yaml b/ops/secrets/secrets.yaml index b57175357..01d21081a 100644 --- a/ops/secrets/secrets.yaml +++ b/ops/secrets/secrets.yaml @@ -30,35 +30,35 @@ TAURI_SIGNING_PRIVATE_KEY: ENC[AES256_GCM,data:aBye4CgJH7WDi6QHbHr+pOf+3r0iZagDT TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ENC[AES256_GCM,data:8vwr7Pd1uDcv5pyyTLXr0jHh/w==,iv:IdUGpDnwfeMNvKyPY43dWUTD3xXFC+PB0pm9KoGPIdg=,tag:Lspa1dosvCvA1HvBWnruLQ==,type:str] SENTRY_DSN: ENC[AES256_GCM,data:FdpKqvUIlH8g2O03pYg5/7uhC7yjsR9FekwXuhKeobxhA0Shz9dwfjnxl+3UWc6eKOATvWuF+KI/1hwVBg==,iv:I2rVGDezMjsvrap89AFuyky0nCt+IHyVhUCJdBimrRU=,tag:x5xR8EcBENy4Oxc0DAyMcw==,type:str] sops: - age: - - recipient: age1c2q5fm3l3gn2tgcs35xstu44mu2zyrft993ksljw5r3z4dzcqcfq6vtakn - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJL2c5L3ZWZjVGMTg0S01O - SS9sSllGeTBiZHIwZWJqejkyRy9zSG1Na0dnCjRkOE03VnM3Z1crSU9mTmNyZnJ5 - clBKV2psdHIrN3pibzJLd2ZZOVF3djAKLS0tIG04OU5mdXR3LytNaG9PT0FESkIz - K2ZGM29GYUEycEJ3dXJ6VzJiU3EvTWcKqdHtSg7Z94BDUzlC2LSpOKEAkghJeKKX - Mbhy0dCiL9ZjHVUohR+gjRdMHCscaV5HBJKijYz8KZXhjvyVr5eIlw== - -----END AGE ENCRYPTED FILE----- - - recipient: age1zl7nsteyj8lzu0spgd9qav3fhkmekkjd30uzt69mpmxgrev3nq7s3jnreh - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPMkNEN1pHdm4zMUd0RWZr - K2VtSm94QjFJcWJ5RE1yTTJrZDhtZEE1Q0JvCldERXRiS2lZWE83dTBlSlVlaWdP - R3gvT0NMUk5oeTNGOThHTGVvUDhwY1kKLS0tIGRCU3ZKRXFxZ0IwaGtULzM0d0xi - clNWM3dtUkppdUNyVktzZndLWk9oSVUKN0211cdO8QpZRfR7zZopXO1FnJY/DO50 - g0vzbLoek6x9RTudYNxZqkyWcaaHivrDMvCllnYDTRl+TJv2xJXb/w== - -----END AGE ENCRYPTED FILE----- - - recipient: age16wuzuxnkcgfuxzvzgk5e5a5f6hhs386adjewyv54m9esr4yj6uuslpn6tp - enc: | - -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1dXloVFB4YWNwV1NLaFJC - UDM1U2FIRWk4Q2hUNWxNVEQvT0dCOFBLWHpNCjIxeGxOQURpTnRGR0VBNEdhODBB - dXp6Yy9UK0dVY3NTd0hYZm5qcVdwNzAKLS0tIDBUSWRvTDdmQkxRZmZPWWxUdkxB - WUJ3YVZ1eXMvcytqeDBCeW8xS0lQUncKrWER5llAB4yHpUwHXOJlJkt2awGnln+L - 9ZGN6Lf/Yc4rIzGbx4fzSJ8FTWmfgTLlHtRdp5/D+XpTgCZOPbT7xQ== - -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-06-02T11:43:40Z" - mac: ENC[AES256_GCM,data:nUu5Pxwwd1jXXVkVT94G/9NmiqK8Ys6eZbg47PzOPSs+J7N+crDRhUhkHTnyLi2vc/DVpOFz4L1Dox9YznGsNXYqcZC5QMBEa7aRFyJgbuoa84F2LmPy9k669YKVrsfeefv+BtwNHFlYV604BmVSMR5sp7m68LtNbXogCebvryc=,iv:Zdrww4igwJkfZJ1+Iuc4rlUM/JyOqMwd/kucLI9JUWg=,tag:ew241qPLLbIck3RGvoVJJA==,type:str] - unencrypted_comment_regex: ^\s?(safe|plaintext|unencrypted)$ - version: 3.12.2 + age: + - recipient: age1c2q5fm3l3gn2tgcs35xstu44mu2zyrft993ksljw5r3z4dzcqcfq6vtakn + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJL2c5L3ZWZjVGMTg0S01O + SS9sSllGeTBiZHIwZWJqejkyRy9zSG1Na0dnCjRkOE03VnM3Z1crSU9mTmNyZnJ5 + clBKV2psdHIrN3pibzJLd2ZZOVF3djAKLS0tIG04OU5mdXR3LytNaG9PT0FESkIz + K2ZGM29GYUEycEJ3dXJ6VzJiU3EvTWcKqdHtSg7Z94BDUzlC2LSpOKEAkghJeKKX + Mbhy0dCiL9ZjHVUohR+gjRdMHCscaV5HBJKijYz8KZXhjvyVr5eIlw== + -----END AGE ENCRYPTED FILE----- + - recipient: age1zl7nsteyj8lzu0spgd9qav3fhkmekkjd30uzt69mpmxgrev3nq7s3jnreh + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPMkNEN1pHdm4zMUd0RWZr + K2VtSm94QjFJcWJ5RE1yTTJrZDhtZEE1Q0JvCldERXRiS2lZWE83dTBlSlVlaWdP + R3gvT0NMUk5oeTNGOThHTGVvUDhwY1kKLS0tIGRCU3ZKRXFxZ0IwaGtULzM0d0xi + clNWM3dtUkppdUNyVktzZndLWk9oSVUKN0211cdO8QpZRfR7zZopXO1FnJY/DO50 + g0vzbLoek6x9RTudYNxZqkyWcaaHivrDMvCllnYDTRl+TJv2xJXb/w== + -----END AGE ENCRYPTED FILE----- + - recipient: age16wuzuxnkcgfuxzvzgk5e5a5f6hhs386adjewyv54m9esr4yj6uuslpn6tp + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1dXloVFB4YWNwV1NLaFJC + UDM1U2FIRWk4Q2hUNWxNVEQvT0dCOFBLWHpNCjIxeGxOQURpTnRGR0VBNEdhODBB + dXp6Yy9UK0dVY3NTd0hYZm5qcVdwNzAKLS0tIDBUSWRvTDdmQkxRZmZPWWxUdkxB + WUJ3YVZ1eXMvcytqeDBCeW8xS0lQUncKrWER5llAB4yHpUwHXOJlJkt2awGnln+L + 9ZGN6Lf/Yc4rIzGbx4fzSJ8FTWmfgTLlHtRdp5/D+XpTgCZOPbT7xQ== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-06-02T11:43:40Z" + mac: ENC[AES256_GCM,data:nUu5Pxwwd1jXXVkVT94G/9NmiqK8Ys6eZbg47PzOPSs+J7N+crDRhUhkHTnyLi2vc/DVpOFz4L1Dox9YznGsNXYqcZC5QMBEa7aRFyJgbuoa84F2LmPy9k669YKVrsfeefv+BtwNHFlYV604BmVSMR5sp7m68LtNbXogCebvryc=,iv:Zdrww4igwJkfZJ1+Iuc4rlUM/JyOqMwd/kucLI9JUWg=,tag:ew241qPLLbIck3RGvoVJJA==,type:str] + unencrypted_comment_regex: ^\s?(safe|plaintext|unencrypted)$ + version: 3.12.2 diff --git a/package.json b/package.json index 4e576d26e..86091c2ae 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "nixmac", + "version": "0.22.0", "private": true, "workspaces": [ "apps/native", @@ -20,6 +21,9 @@ "test:e2e:install": "bun -F native test:e2e:install", "knip": "knip" }, + "dependencies": { + "effect": "^4.0.0-beta.62" + }, "devDependencies": { "@types/bun": "^1.3.14", "@types/node": "^25.6.0", @@ -32,12 +36,8 @@ "typescript": "^6.0.3", "ultracite": "6.4.0" }, - "packageManager": "bun@1.3.2", - "version": "0.22.0", - "dependencies": { - "effect": "^4.0.0-beta.62" - }, "overrides": { "cpu-features": "npm:noop2@^2.0.0" - } -} \ No newline at end of file + }, + "packageManager": "bun@1.3.2" +} diff --git a/packages/state/package.json b/packages/state/package.json new file mode 100644 index 000000000..7e910bd70 --- /dev/null +++ b/packages/state/package.json @@ -0,0 +1,18 @@ +{ + "name": "@nixmac/state", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./view-model": "./src/view-model.ts", + "./ui-state": "./src/ui-state.ts", + "./onboarding": "./src/onboarding.ts" + }, + "dependencies": { + "zustand": "^5.0.8" + }, + "devDependencies": { + "@types/react": "^19" + } +} diff --git a/packages/state/src/index.ts b/packages/state/src/index.ts new file mode 100644 index 000000000..94d4b9817 --- /dev/null +++ b/packages/state/src/index.ts @@ -0,0 +1,10 @@ +export { useViewModel, type RebuildLog, type ViewModel } from "./view-model"; +export { + useUiState, + initialUiState, + type SettingsTab, + type UiState, + type UiStateValues, +} from "./ui-state"; +export { useOnboarding } from "./onboarding"; +export type { InferenceConfig, InferenceMode } from "./onboarding-types"; diff --git a/packages/state/src/onboarding-types.ts b/packages/state/src/onboarding-types.ts new file mode 100644 index 000000000..5ca63db66 --- /dev/null +++ b/packages/state/src/onboarding-types.ts @@ -0,0 +1,6 @@ +export type InferenceMode = "hosted" | "byok"; + +/** Resolved inference choice persisted on the onboarding state. */ +export type InferenceConfig = + | { mode: "hosted"; email: string; plan: string } + | { mode: "byok"; providerId: string; providerName: string; model: string }; diff --git a/packages/state/src/onboarding.ts b/packages/state/src/onboarding.ts new file mode 100644 index 000000000..134adc1cc --- /dev/null +++ b/packages/state/src/onboarding.ts @@ -0,0 +1,61 @@ +import { create } from "zustand"; +import type { InferenceConfig } from "./onboarding-types"; + +/** + * Local onboarding state for the steps that have no backend-mirrored cell of + * their own. The first three gates (permissions, nix, flake import) are driven + * entirely by the ViewModel/IPC; the post-setup steps below are session-local. + * + * `active` flips on once the user finishes the flake-import step inside the + * onboarding flow, which keeps the new post-setup steps on screen even though + * the backend already considers the Mac "set up". `completed` ends onboarding + * and lets the widget route into the normal app. + */ +interface OnboardingState { + /** IDs of detected customizations the user chose to track into their config. */ + trackedCustomizations: string[]; + /** User has finished (or skipped) the import-customizations step. */ + customizationsReviewed: boolean; + /** Resolved AI inference choice — hosted account or bring-your-own-key. */ + inference: InferenceConfig | null; + /** User chose to defer inference setup until the build runs. */ + inferenceSkipped: boolean; + /** First build finished successfully. */ + buildComplete: boolean; + /** Post-setup onboarding is in progress this session. */ + active: boolean; + /** User finished onboarding — the widget may route into the app. */ + completed: boolean; + + setTrackedCustomizations: (ids: string[]) => void; + reviewCustomizations: () => void; + configureInference: (inference: InferenceConfig) => void; + skipInference: () => void; + setBuildComplete: (complete: boolean) => void; + /** Called when the flake-import step is satisfied, opening the new steps. */ + beginPostSetup: () => void; + complete: () => void; + reset: () => void; +} + +const INITIAL = { + trackedCustomizations: [] as string[], + customizationsReviewed: false, + inference: null as InferenceConfig | null, + inferenceSkipped: false, + buildComplete: false, + active: false, + completed: false, +}; + +export const useOnboarding = create((set) => ({ + ...INITIAL, + setTrackedCustomizations: (trackedCustomizations) => set({ trackedCustomizations }), + reviewCustomizations: () => set({ customizationsReviewed: true }), + configureInference: (inference) => set({ inference, inferenceSkipped: false }), + skipInference: () => set({ inferenceSkipped: true }), + setBuildComplete: (buildComplete) => set({ buildComplete }), + beginPostSetup: () => set({ active: true }), + complete: () => set({ completed: true, active: false }), + reset: () => set({ ...INITIAL }), +})); diff --git a/apps/native/src/stores/ui-state.test.ts b/packages/state/src/ui-state.test.ts similarity index 98% rename from apps/native/src/stores/ui-state.test.ts rename to packages/state/src/ui-state.test.ts index 3527f7ec9..e4025c6e9 100644 --- a/apps/native/src/stores/ui-state.test.ts +++ b/packages/state/src/ui-state.test.ts @@ -1,4 +1,4 @@ -import { FeedbackType } from "@/types/feedback"; +import { FeedbackType } from "@nixmac/native/types/feedback"; import { beforeEach, describe, expect, it } from "vitest"; import { initialUiState, useUiState } from "./ui-state"; diff --git a/apps/native/src/stores/ui-state.ts b/packages/state/src/ui-state.ts similarity index 93% rename from apps/native/src/stores/ui-state.ts rename to packages/state/src/ui-state.ts index 9422a242a..6931ef73a 100644 --- a/apps/native/src/stores/ui-state.ts +++ b/packages/state/src/ui-state.ts @@ -8,12 +8,23 @@ // The split prevents Rust-driven state updates from clobbering local UI // concerns (e.g. closing a settings panel just because git status changed). -import type { EvolutionTelemetry, FileDiffContents, RecommendedPrompt } from "@/ipc/types"; -import { FeedbackType } from "@/types/feedback"; -import type { RebuildContext } from "@/types/rebuild"; +import type { + EvolutionTelemetry, + FileDiffContents, + RecommendedPrompt, +} from "@nixmac/native/ipc/types"; +import { FeedbackType } from "@nixmac/native/types/feedback"; +import type { RebuildContext } from "@nixmac/native/types/rebuild"; import { create } from "zustand"; -export type SettingsTab = "general" | "account" | "api-keys" | "ai-models" | "preferences" | "tuning" | "developer"; +export type SettingsTab = + | "general" + | "account" + | "api-keys" + | "ai-models" + | "preferences" + | "tuning" + | "developer"; type ProcessingAction = "evolve" | "apply" | "merge" | "cancel" | null; /** @@ -126,8 +137,7 @@ export const initialUiState: UiStateValues = { */ export const useUiState = create()((set) => ({ ...initialUiState, - setSettingsOpen: (settingsOpen, tab) => - set({ settingsOpen, settingsActiveTab: tab ?? null }), + setSettingsOpen: (settingsOpen, tab) => set({ settingsOpen, settingsActiveTab: tab ?? null }), setShowHistory: (showHistory) => set({ showHistory }), setShowFilesystem: (showFilesystem, section = null) => set({ showFilesystem, filesystemTargetSection: showFilesystem ? section : null }), diff --git a/apps/native/src/stores/view-model.ts b/packages/state/src/view-model.ts similarity index 96% rename from apps/native/src/stores/view-model.ts rename to packages/state/src/view-model.ts index afdfb5722..94216e7ca 100644 --- a/apps/native/src/stores/view-model.ts +++ b/packages/state/src/view-model.ts @@ -22,8 +22,8 @@ import type { PermissionsState, RebuildStatus, SemanticChangeMap, -} from "@/ipc/types"; -import type { RebuildLine } from "@/types/rebuild"; +} from "@nixmac/native/ipc/types"; +import type { RebuildLine } from "@nixmac/native/types/rebuild"; import { create } from "zustand"; type BuildView = { diff --git a/packages/state/tsconfig.json b/packages/state/tsconfig.json new file mode 100644 index 000000000..592262cc3 --- /dev/null +++ b/packages/state/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "paths": { + "@nixmac/native/ipc/types": ["../../apps/native/src/ipc/types.ts"], + "@nixmac/native/types/feedback": ["../../apps/native/src/types/feedback.ts"], + "@nixmac/native/types/rebuild": ["../../apps/native/src/types/rebuild.ts"] + }, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "strictNullChecks": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/ui/src/components/button-glow.tsx b/packages/ui/src/components/button-glow.tsx index 98ca624af..abf3033b0 100644 --- a/packages/ui/src/components/button-glow.tsx +++ b/packages/ui/src/components/button-glow.tsx @@ -5,60 +5,45 @@ import { Button } from "./ui/button"; import { Loader2, Wrench } from "lucide-react"; import { ComponentProps } from "react"; -const ACTIVE_GRADIENT = [ - "rgb(45, 212, 191)", - "rgb(20, 184, 166)", - "rgb(13, 148, 136)", -] as const; - -const INACTIVE_GRADIENT = [ - "rgb(115, 115, 115)", - "rgb(82, 82, 82)", - "rgb(64, 64, 64)", -] as const; - +const ACTIVE_GRADIENT = ["rgb(45, 212, 191)", "rgb(20, 184, 166)", "rgb(13, 148, 136)"] as const; +const INACTIVE_GRADIENT = ["rgb(115, 115, 115)", "rgb(82, 82, 82)", "rgb(64, 64, 64)"] as const; interface Props extends ComponentProps { active?: boolean; } -export function ButtonGlow({ - active, - ...props -}: Props) { +export function ButtonGlow({ active, ...props }: Props) { return ( - - + animating={active} + shimmer={active} + speed={active ? 0.35 : 0.1} + containerClassName={cn( + "w-fit rounded-full p-0.5 transition-opacity duration-300", + !active && "opacity-70 saturate-50", + )} + gradientColors={active ? [...ACTIVE_GRADIENT] : [...INACTIVE_GRADIENT]} + noiseIntensity={active ? 0.2 : 0.08} + > + + ); -} \ No newline at end of file +} diff --git a/packages/ui/src/components/ui/accordion.tsx b/packages/ui/src/components/ui/accordion.tsx index a130c48f5..d2bef6dba 100644 --- a/packages/ui/src/components/ui/accordion.tsx +++ b/packages/ui/src/components/ui/accordion.tsx @@ -7,9 +7,7 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; -function Accordion({ - ...props -}: React.ComponentProps) { +function Accordion({ ...props }: React.ComponentProps) { return ; } @@ -36,7 +34,7 @@ function AccordionTrigger({ svg]:rotate-180", - className + className, )} data-slot="accordion-trigger" {...props} diff --git a/packages/ui/src/components/ui/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx index ac756c7d7..06ca89a63 100644 --- a/packages/ui/src/components/ui/alert-dialog.tsx +++ b/packages/ui/src/components/ui/alert-dialog.tsx @@ -6,26 +6,18 @@ import type * as React from "react"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -function AlertDialog({ - ...props -}: React.ComponentProps) { +function AlertDialog({ ...props }: React.ComponentProps) { return ; } function AlertDialogTrigger({ ...props }: React.ComponentProps) { - return ( - - ); + return ; } -function AlertDialogPortal({ - ...props -}: React.ComponentProps) { - return ( - - ); +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; } function AlertDialogOverlay({ @@ -36,7 +28,7 @@ function AlertDialogOverlay({ ) { +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
    ) { +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
    @@ -122,12 +105,7 @@ function AlertDialogAction({ className, ...props }: React.ComponentProps) { - return ( - - ); + return ; } function AlertDialogCancel({ diff --git a/packages/ui/src/components/ui/alert.tsx b/packages/ui/src/components/ui/alert.tsx index 51df0c905..a1a60d851 100644 --- a/packages/ui/src/components/ui/alert.tsx +++ b/packages/ui/src/components/ui/alert.tsx @@ -16,7 +16,7 @@ const alertVariants = cva( defaultVariants: { variant: "default", }, - } + }, ); function Alert({ @@ -37,25 +37,19 @@ function Alert({ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { return (
    ); } -function AlertDescription({ - className, - ...props -}: React.ComponentProps<"div">) { +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { return (
    - - {children} - + + {children} ); } @@ -51,11 +51,7 @@ interface AnimatedTabsTriggerProps { className?: string; } -export function AnimatedTabsTrigger({ - value, - children, - className, -}: AnimatedTabsTriggerProps) { +export function AnimatedTabsTrigger({ value, children, className }: AnimatedTabsTriggerProps) { const ctx = useContext(AnimatedTabsContext); const isActive = ctx?.activeValue === value; @@ -65,7 +61,7 @@ export function AnimatedTabsTrigger({ onClick={() => ctx?.setActiveValue(value)} className={cn( "relative px-3 py-1 text-xs data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=inactive]:hover:text-foreground", - className + className, )} > {isActive && ( diff --git a/packages/ui/src/components/ui/aspect-ratio.tsx b/packages/ui/src/components/ui/aspect-ratio.tsx index ef2bcebfd..c762e38d3 100644 --- a/packages/ui/src/components/ui/aspect-ratio.tsx +++ b/packages/ui/src/components/ui/aspect-ratio.tsx @@ -3,9 +3,7 @@ // biome-ignore lint/performance/noNamespaceImport: Radix namespace import keeps API names grouped import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; -function AspectRatio({ - ...props -}: React.ComponentProps) { +function AspectRatio({ ...props }: React.ComponentProps) { return ; } diff --git a/packages/ui/src/components/ui/avatar.tsx b/packages/ui/src/components/ui/avatar.tsx index af66607a2..f05aca0a3 100644 --- a/packages/ui/src/components/ui/avatar.tsx +++ b/packages/ui/src/components/ui/avatar.tsx @@ -6,26 +6,17 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; -function Avatar({ - className, - ...props -}: React.ComponentProps) { +function Avatar({ className, ...props }: React.ComponentProps) { return ( ); } -function AvatarImage({ - className, - ...props -}: React.ComponentProps) { +function AvatarImage({ className, ...props }: React.ComponentProps) { return ( ) { return ( diff --git a/packages/ui/src/components/ui/badge-button.tsx b/packages/ui/src/components/ui/badge-button.tsx index 6265f122d..40a6c5ff7 100644 --- a/packages/ui/src/components/ui/badge-button.tsx +++ b/packages/ui/src/components/ui/badge-button.tsx @@ -23,12 +23,10 @@ function BadgeButton({ size="sm" className={cn( "h-auto rounded-full border px-2 py-1 text-xs font-medium hover:text-foreground", - badgeVariant === "default" && - "border-border text-muted-foreground hover:bg-muted ", + badgeVariant === "default" && "border-border text-muted-foreground hover:bg-muted ", badgeVariant === "muted" && "border-border/50 bg-background text-muted-foreground hover:bg-muted/50 ", - badgeVariant === "teal" && - "border-teal-500/20 text-muted-foreground ", + badgeVariant === "teal" && "border-teal-500/20 text-muted-foreground ", className, )} {...props} diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx index b0357a254..c7b0967ad 100644 --- a/packages/ui/src/components/ui/badge.tsx +++ b/packages/ui/src/components/ui/badge.tsx @@ -9,20 +9,18 @@ const badgeVariants = cva( { variants: { variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", - outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, }, defaultVariants: { variant: "default", }, - } + }, ); function Badge({ @@ -30,17 +28,12 @@ function Badge({ variant, asChild = false, ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot : "span"; return ( - + ); } -export { Badge, }; +export { Badge }; diff --git a/packages/ui/src/components/ui/breadcrumb.tsx b/packages/ui/src/components/ui/breadcrumb.tsx index 0c3bba2c0..00cdada89 100644 --- a/packages/ui/src/components/ui/breadcrumb.tsx +++ b/packages/ui/src/components/ui/breadcrumb.tsx @@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
      ) { ); } -function BreadcrumbSeparator({ - children, - className, - ...props -}: React.ComponentProps<"li">) { +function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) { return (