# The SwiftUI App: AppModel, Views, and Window Commands

> The app shell that drives the engine: the @main scene and menu-bar command groups, the AppModel state object that owns indexing and search, the main content layout (sidebar, results list, Quick Look, settings), and how onboarding and updater hooks are wired into launch.

- Repository: hanxiao/omni-macos
- GitHub: https://github.com/hanxiao/omni-macos
- Human wiki: https://grok-wiki.com/public/wiki/hanxiao-omni-macos-7817a5cffe05
- Complete Markdown: https://grok-wiki.com/public/wiki/hanxiao-omni-macos-7817a5cffe05/llms-full.txt

## Source Files

- `App/OmniApp.swift`
- `App/AppModel.swift`
- `App/ContentView.swift`
- `App/Sidebar.swift`
- `App/ResultsList.swift`
- `App/SettingsView.swift`
- `App/OnboardingView.swift`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [App/OmniApp.swift](App/OmniApp.swift)
- [App/AppModel.swift](App/AppModel.swift)
- [App/ContentView.swift](App/ContentView.swift)
- [App/Sidebar.swift](App/Sidebar.swift)
- [App/ResultsList.swift](App/ResultsList.swift)
- [App/SettingsView.swift](App/SettingsView.swift)
- [App/OnboardingView.swift](App/OnboardingView.swift)
- [App/Updater.swift](App/Updater.swift)
- [App/QuickLook.swift](App/QuickLook.swift)
</details>

# The SwiftUI App: AppModel, Views, and Window Commands

`OmniKit` is the engine (encoders, vector store, indexer). The `App` target is the thin SwiftUI shell that drives it: one window, one observable state object, and a set of views that render whatever that state currently says. If you are new to the repo, this is the layer to read first — it shows *what the app does* before you dig into *how embeddings are computed*. Everything user-visible flows through one type, `AppModel`, which owns the engine, the vector store, the indexer, search, history, settings, and the in-process serving layer. The views are deliberately dumb: they read `AppModel` and call its methods.

Two ideas anchor the whole shell. First, **the menu bar is the source of truth for actions** — keyboard shortcuts live on `CommandGroup` buttons, and the toolbar/context menus are click targets that name the same shortcuts (so a shortcut works even when its toolbar button is hidden). Second, **`AppModel.phase` drives the entire detail pane** — a four-state machine (`loadingModel`, `noModel`, `ready`, `failed`) decides whether you see a spinner, onboarding, the search UI, or an error.

## Ownership map: who holds what

The app has exactly one long-lived state object. `OmniApp` constructs it once with `@State`, injects it into both scenes via `.environment(model)`, and every view reaches it with `@Environment(AppModel.self)`. `AppModel` in turn privately owns the `OmniKit` types — views never touch the engine directly.

```text
            App target (SwiftUI shell)                     OmniKit (engine)
   ┌────────────────────────────────────────┐        ┌──────────────────────┐
   │ OmniApp  @main                          │        │                      │
   │  ├─ Window "main"  → ContentView        │        │                      │
   │  ├─ .commands {CommandGroups}           │        │                      │
   │  ├─ Settings → SettingsView             │        │                      │
   │  └─ .task → Updater.checkOnLaunchIfDue  │        │                      │
   │            │ .environment(model)        │        │                      │
   │            ▼                            │  owns   │                      │
   │   AppModel  @MainActor @Observable ─────┼────────▶│ OmniEngine           │
   │     phase / query / fileQuery           │  (private)│ VectorStore        │
   │     results / selection / previewURL    │◀────────┤ Indexer / FSWatcher │
   │     roots / settings / searchHistory    │         │ ServingController    │
   │            ▲ read via @Environment      │        └──────────────────────┘
   │   ┌────────┴───────────────────────┐    │
   │   ContentView (NavigationSplitView)│    │
   │     ├─ Sidebar (folders + history) │    │
   │     ├─ detail: switch on phase     │    │
   │     │    ├─ OnboardingView         │    │
   │     │    ├─ EngineFailedView       │    │
   │     │    └─ ResultsList / empty    │    │
   │     └─ .searchable toolbar field   │    │
   └────────────────────────────────────────┘
```

Sources: [App/OmniApp.swift:4-16](App/OmniApp.swift), [App/AppModel.swift:93-95](App/AppModel.swift), [App/ContentView.swift:5-13](App/ContentView.swift)

## The `@main` scene and launch hooks

`OmniApp` is the entry point. Its `body` declares two scenes: a single `Window` (not a `WindowGroup` — Omni is a one-window app) hosting `ContentView`, and a `Settings` scene hosting `SettingsView`. Both receive the shared model through `.environment(model)`. The window enforces a minimum content size and a default 1000×660.

The launch-time wiring is compact:
- `.task { Updater.checkOnLaunchIfDue() }` fires the silent, once-per-day update check when the window appears.
- `AppModel()` runs its `init`, which loads persisted state from `UserDefaults` and then kicks `Task { await bootstrap() }` to load the model and index.

```swift
// App/OmniApp.swift
@main
struct OmniApp: App {
    @State private var model = AppModel()
    var body: some Scene {
        Window("Omni", id: "main") {
            ContentView()
                .environment(model)
                .frame(minWidth: 820, minHeight: 520)
                .task { Updater.checkOnLaunchIfDue() }   // silent once-a-day check
        }
        .defaultSize(width: 1000, height: 660)
        .windowResizability(.contentMinSize)
        .commands { /* command groups */ }
        Settings { SettingsView().environment(model) }
    }
}
```

Sources: [App/OmniApp.swift:4-94](App/OmniApp.swift), [App/AppModel.swift:357-370](App/AppModel.swift)

## Menu-bar command groups

The `.commands { ... }` block is where Omni declares its keyboard interactions and menu-bar entries. Each `CommandGroup` is positioned relative to a system anchor, and most buttons gate themselves on `AppModel` computed properties (`hasSelection`, `hasActiveSearch`, `canIndex`, `isIndexing`). Because the menu bar *owns* the shortcuts, an action like Bookmark (`⌘D`) works even when its toolbar star is hidden — the comments in the file call this out explicitly.

| Command group anchor | Entries | Shortcut | Gated on |
|---|---|---|---|
| `replacing: .appInfo` | About Omni, Check for Updates…, Run Profiling… | — | profiling disabled while running / `!canIndex` |
| `after: .newItem` | Open / Quick Look / Reveal in Finder | `⌘O` / `⌘Y` / `⇧⌘R` | `hasSelection` |
| `after: .newItem` | Bookmark / Remove Bookmark Search | `⌘D` | `hasActiveSearch` |
| `after: .sidebar` | View as Gallery / List; Sort By | `⌘1` / `⌘2` | — |
| `after: .toolbar` | Index / Update / Resume Indexing | `⇧⌘I` | `isIndexing || !canIndex` |
| `after: .toolbar` | Pause Indexing | — | `!isIndexing` |
| `after: .textEditing` | Find (focus search field) | `⌘F` | — |
| `replacing: .help` | Omni Keyboard Shortcuts | `⌘/` | — |

Two details worth noting. The View options are added `after: .sidebar` — i.e. they extend the *system* View menu that `NavigationSplitView` already provides (Show Sidebar / Full Screen), rather than creating a second "View" menu. And `Find` (`⌘F`) is implemented by hand: `.searchable` does not bind `⌘F`, so the command walks `NSApp.keyWindow` to find the `NSSearchToolbarItem` and makes its field first responder. The Help command opens a retained, reused `NSWindow` (`ShortcutsView`) so reopening is instant.

Sources: [App/OmniApp.swift:17-111](App/OmniApp.swift)

## `AppModel`: the state object

`AppModel` is a `@MainActor @Observable final class`. It is large because it is the single coordination point for the whole app; the table below groups its responsibilities. SwiftUI re-renders automatically when observed properties change, and almost all heavy work (embedding, store search, stats) is dispatched off the main actor with `Task.detached`, hopping back only to assign small results.

| Area | Representative state / methods |
|---|---|
| Lifecycle | `phase` (`loadingModel`/`noModel`/`ready`/`failed`), `bootstrap()`, `retryBootstrap()` |
| Query | `rawQuery` (literal box text), `query` (semantic remainder), `fileQuery`, `applyParsedQuery()`, `literalQuery` |
| Results | `rawResults` (filtered) → `results` (thresholded + sorted), `recomputeResults()`, `selection`, `previewURL` |
| Search | `search()`, `setFileQuery()`, query-vector LRU cache (`queryEmbedCache`) |
| Indexing | `indexState`, `progress`, `startIndexing()`, `pauseIndexing()`, FSEvents `watcher`, `pausedRoots` |
| Roots | `roots`, `addRoot()`, `removeRoot()`, `canonicalizeRoots()` |
| History | `searchHistory`, `historyGroups`, `recordCurrentSearchToHistory()`, `toggleBookmarkCurrentSearch()` |
| Settings | `settings`, perf knobs (`maxImageDimension`, `maxMemoryGB`, …), persisted via `UserDefaults` |
| Serving | `serving` (`ServingController`), attached during `bootstrap()` |

A small but important design choice: the search box binds to `rawQuery` (exactly what the user typed, qualifiers and all), and `query` is the *derived* semantic text after `applyParsedQuery` strips `key:value` qualifiers (`type:`, `ext:`, `in:`, `date:`, `score:`, `sort:`). The "box owns only what it mentions" rule means a filter the box previously set but no longer names is cleared, while a filter set from the toolbar menu is left alone (`stringOwnedFilters`).

Sources: [App/AppModel.swift:93-355](App/AppModel.swift), [App/AppModel.swift:779-847](App/AppModel.swift), [App/AppModel.swift:589-619](App/AppModel.swift)

### The phase state machine

`AppModel.Phase` is what `ContentView.detail` switches on, so it effectively chooses the whole right-hand screen. `bootstrap()` loads the store and engine concurrently (`async let`), and the path through the machine depends on whether a model is found and whether loading succeeds.

```mermaid
stateDiagram-v2
    [*] --> loadingModel: AppModel.init → bootstrap()
    loadingModel --> noModel: resolvedModelDir() == nil
    loadingModel --> ready: store + engine loaded
    loadingModel --> failed: VectorStore / OmniEngine throws
    noModel --> loadingModel: downloadModel() / setModelDir()
    failed --> loadingModel: retryBootstrap() / Choose Model Folder
    ready --> loadingModel: switchVariant() / setDatabaseDir()
    note right of ready
        detail shows ResultsList or an
        empty-state CenteredStatus;
        canIndex → startIndexing()
    end note
    note right of noModel
        detail shows OnboardingView
    end note
```

On entering `ready`, `bootstrap()` hands the live engine and store to the serving layer (`serving.attach(...)`), restarts the FSEvents watcher, optionally compacts a sparse index, and — if `canIndex` — kicks a background index pass so the index silently catches up on every launch.

Sources: [App/AppModel.swift:96-105](App/AppModel.swift), [App/AppModel.swift:993-1044](App/AppModel.swift), [App/ContentView.swift:80-91](App/ContentView.swift)

### How a search runs

Typing is debounced in `ContentView` (180 ms), then `AppModel.search()` does the work. It is token-guarded (`searchToken`) so stale results are discarded, and it embeds off the main actor. A query-side embedding cache lets repeated / history / bookmarked queries skip the GPU entirely — instant, and crucially GPU-free while indexing runs.

```mermaid
sequenceDiagram
    participant U as User
    participant CV as ContentView
    participant AM as AppModel
    participant E as OmniEngine
    participant S as VectorStore
    U->>CV: type in .searchable box
    CV->>AM: applyParsedQuery(raw) → set query/filters
    CV->>CV: scheduleSearch() debounce 180ms
    CV->>AM: search()
    alt query vector cached
        AM->>S: store.search(cached, filter, topK: 60)
    else embed needed
        AM->>E: embedQuery(q) (off-actor, high priority)
        E-->>AM: vector
        AM->>S: store.search(vec, filter, topK: 60)
    end
    S-->>AM: [SearchHit] → rawResults (didSet recomputeResults)
    AM-->>CV: results (thresholded + sorted) re-render
```

File-as-query (`setFileQuery`, drag-and-drop, or "Find Similar") follows the same shape but calls `engine.embedFileQuery` first — any modality works because the embedding space is shared. Results feed `rawResults`, whose `didSet` runs `recomputeResults()` to apply the relevance threshold (`minScore`) and `sortOrder`, producing the `results` the views render.

Sources: [App/ContentView.swift:18-37](App/ContentView.swift), [App/ContentView.swift:320-326](App/ContentView.swift), [App/AppModel.swift:1254-1319](App/AppModel.swift), [App/AppModel.swift:1230-1247](App/AppModel.swift)

## The content layout

`ContentView` renders a `NavigationSplitView`: `Sidebar` on the left, and a `detail` that switches on `phase`. The whole split is wrapped in `.searchable` only when `showsSearch` is true (`phase == .ready && !roots.isEmpty`) — progressive disclosure, so the search field is *hidden* (not dimmed) during loading and onboarding. The toolbar is also progressive: search-by-file and the filter/sort/view controls appear only once there are results to act on, with the filter menu kept reachable whenever a filter is active so a filter that hides everything can still be cleared.

The `ready` content is a `VStack` of: an optional `FileQueryChip` or `QualifierBar`, then either `ResultsList` (when there are results) or an `emptyState`. The empty state is itself a small decision tree of `CenteredStatus` cards — "Add a folder", "couldn't search by that file", obsolete-index ("Switch to … or reindex"), the calm idle/searching prompt, threshold-hidden matches, and active-filter dead ends. The content view is also a Finder drop target: dropping a supported file anywhere starts a search by that file.

Sources: [App/ContentView.swift:25-164](App/ContentView.swift), [App/ContentView.swift:187-276](App/ContentView.swift)

### Sidebar: folders and history

`Sidebar` is a single `List` driven by a `SidebarSelection` enum (`.folder(URL)` or `.history(String)`) so folders and saved searches share native source-list behavior (focus highlight, arrow keys, Delete). The Folders section shows a per-folder file count, a pause glyph for paused folders, or a `CloudSyncPie` — an iCloud-Drive-style pie that fills with real indexing progress (or an indeterminate spinner for a brief reconcile). History rows are grouped (`historyGroups`: Bookmarks, then Today / Yesterday / Previous 7 Days / Earlier); selecting one re-runs that search with its saved filters via `runHistoryQuery`. Right-click menus handle pause/resume, reveal, bookmark, and remove; folders also accept dragged-in directories from Finder.

Sources: [App/Sidebar.swift:7-142](App/Sidebar.swift), [App/Sidebar.swift:195-243](App/Sidebar.swift)

### Results list, gallery, and Quick Look

`ResultsList` renders either a list (`LazyVStack` of `ResultRow`) or a gallery (`LazyVGrid` of `ResultGridItem`), chosen by `model.viewMode`. It deliberately does *not* use `List(selection:)` — a plain scroll lets click, double-click, right-click, and arrow-key navigation behave identically in both modes, all driven through `AppModel.selection` and `moveSelection(rowDelta:)`. Text rows are expandable: toggling one loads ranked matching passages (`model.passages(for:)`, computed off-actor) into a `PassagesView`.

Quick Look is wired two ways: `.quickLookPreview(...)` binds to `model.previewURL`, and an invisible `QuickLookKeyMonitor` installs an `NSEvent` local monitor so the space bar toggles the preview regardless of which subview holds focus (a focus-based `.onKeyPress` is swallowed by the list). While the panel is open, the monitor also routes arrow keys to `moveSelection`, and `selection.didSet` keeps `previewURL` on the newly selected row — so arrowing updates the live preview, Finder-style. Space is left untouched while editing the search field.

Sources: [App/ResultsList.swift:6-93](App/ResultsList.swift), [App/ResultsList.swift:149-171](App/ResultsList.swift), [App/QuickLook.swift:9-56](App/QuickLook.swift), [App/AppModel.swift:124-163](App/AppModel.swift)

### Settings

The `Settings` scene is a six-tab `TabView`: Indexing, Content, Performance, Storage, History, and Serving. The pane sizes to the selected tab (`.fixedSize` vertically) rather than forcing one height. The Indexing tab is the live home for index status and the manual Index / Reindex / Pause controls, reading `model.indexState`, `model.progress`, and the smoothed `filesPerSec` / `tokensPerSec` throughput.

Sources: [App/SettingsView.swift:5-90](App/SettingsView.swift)

## Onboarding and the updater

**Onboarding** is not a separate flow — it is the `noModel` phase. When `resolvedModelDir()` finds no complete model, `detail` renders `OnboardingView`, which offers two on-device model downloads (Nano ~1.9 GB, Small ~3.1 GB) plus a "Choose Model Folder…" escape hatch. `downloadModel(_:)` streams progress into `downloadFraction`/`downloadLabel`, and on completion calls `setModelDir`, which flips `phase` back to `loadingModel` and re-runs `bootstrap()`. The same view emphasizes the privacy stance: everything runs on-device.

**The updater** is a dependency-free in-app updater (no Sparkle). The launch hook `Updater.checkOnLaunchIfDue()` checks a JSON manifest at most once per 24 hours and stays silent unless a newer build exists; the menu's "Check for Updates…" calls `Updater.check(userInitiated: true)`, which also reports "up to date" and errors. When an update is accepted, it downloads the versioned DMG with progress, verifies its MD5, mounts and stages the app (`ditto` + a `codesign --verify --deep --strict` gate), then a detached shell helper waits for the app to quit, re-verifies the signature, replaces the bundle in place, and relaunches. If anything fails, it falls back to saving the DMG to Downloads and opening it for a manual drag-install.

```text
launch ─▶ .task → checkOnLaunchIfDue()         menu ─▶ check(userInitiated: true)
              │ (>24h since last)                          │
              ▼                                             ▼
        fetch latest.json ──▶ isNewer? ──no──▶ (silent) / "up to date"
              │ yes
              ▼
   download DMG ─▶ verify MD5 ─▶ mount+stage (ditto, codesign --verify)
              │                                   │ failure
              ▼                                   ▼
   detached helper: wait → re-verify →        fallback(): save DMG to
   rm old → ditto new → relaunch              Downloads, open installer
```

Sources: [App/OnboardingView.swift:5-79](App/OnboardingView.swift), [App/AppModel.swift:960-991](App/AppModel.swift), [App/Updater.swift:9-119](App/Updater.swift), [App/Updater.swift:170-216](App/Updater.swift)

## Summary

The SwiftUI shell is intentionally small and centralized. `OmniApp` (`@main`) declares one window plus a settings scene, registers the menu-bar command groups that own every keyboard shortcut, and wires the once-a-day update check into launch. `AppModel` is the one `@Observable` object that owns the engine, store, indexer, serving layer, search, history, and settings — and its `phase` state machine decides whether the user sees loading, onboarding, an error, or the live search UI. The views (`ContentView`, `Sidebar`, `ResultsList`, `SettingsView`, `OnboardingView`) are thin readers of that state, with heavy work pushed off the main actor and shortcuts/toolbar/context-menus kept consistent because they all route back through the same `AppModel` methods. To extend the app, the reliable pattern is: add state and a method to `AppModel`, then expose it from a view and (if it deserves a shortcut) a `CommandGroup` button.

This page describes the `App` target only; the embedding, vector store, and indexer it drives live in `OmniKit` and are documented separately.
