diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index d713492..21950de 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -3,9 +3,9 @@
version: 2
updates:
- # Swift Package Manager
- - package-ecosystem: "swift"
- directory: "/Fig"
+ # Cargo (Rust)
+ - package-ecosystem: "cargo"
+ directory: "/"
schedule:
interval: "weekly"
day: "monday"
@@ -14,6 +14,6 @@ updates:
prefix: "build(deps):"
labels:
- "dependencies"
- - "swift"
+ - "rust"
reviewers:
- "doomspork"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3f0e3f0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,50 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ check:
+ name: Check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo check --workspace
+
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo test --workspace
+
+ fmt:
+ name: Format
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt
+ - run: cargo fmt --all --check
+
+ clippy:
+ name: Clippy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo clippy --workspace -- -D warnings
diff --git a/.gitignore b/.gitignore
index a770630..e820e55 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,55 +1,6 @@
-# Xcode
-## User settings
-xcuserdata/
-*.xcuserstate
-
-## Build generated
-build/
-DerivedData/
-*.build/
-*.swiftpm/
-
-## Various settings
-*.pbxuser
-!default.pbxuser
-*.mode1v3
-!default.mode1v3
-*.mode2v3
-!default.mode2v3
-*.perspectivev3
-!default.perspectivev3
-*.moved-aside
-*.xccheckout
-*.xcscmblueprint
-
-## Obj-C/Swift specific
-*.hmap
-*.ipa
-*.dSYM.zip
-*.dSYM
-
-## Playgrounds
-timeline.xctimeline
-playground.xcworkspace
-
-# Swift Package Manager
-.build/
-Packages/
-Package.resolved
-
-# CocoaPods
-Pods/
-*.xcworkspace
-
-# Carthage
-Carthage/Build/
-Carthage/Checkouts/
-
-# fastlane
-fastlane/report.xml
-fastlane/Preview.html
-fastlane/screenshots/**/*.png
-fastlane/test_output
+# Rust
+target/
+Cargo.lock
# macOS
.DS_Store
diff --git a/CLAUDE.md b/CLAUDE.md
index 6c597aa..2fb59ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,29 +4,30 @@ This file provides guidance for Claude Code and other AI agents working on the F
## Project Overview
-Fig is a native macOS application for managing Claude Code configuration files. It provides a visual interface for editing `~/.claude.json`, `~/.claude/settings.json`, project-level settings, MCP server configs, and hooks. See `README.md` for full feature details.
+Fig is a cross-platform desktop application for managing Claude Code configuration files. It provides a visual interface for editing `~/.claude.json`, `~/.claude/settings.json`, project-level settings, MCP server configs, and hooks. See `README.md` for full feature details.
## Development Setup
```bash
-brew bundle # Installs SwiftLint, SwiftFormat, Lefthook
-lefthook install # Sets up pre-commit/pre-push git hooks
-swift build # Build the project
-swift test # Run the test suite
+cargo build # Build all crates
+cargo test # Run the test suite
+cargo clippy -- -D warnings # Lint (must pass clean)
+cargo fmt --check # Format check
```
-Minimum deployment target: **macOS 14.0 (Sonoma)**. Swift 6.0 with strict concurrency enabled.
-
## Architecture
-Fig uses **MVVM** with Swift 6 strict concurrency throughout:
+Fig is a Cargo workspace with two crates using the Iced Elm architecture:
+
+- **fig-core** (`fig-core/src/`) — Pure library crate. Models, services, and error types. No GUI dependency.
+ - `models/` — Data structures with `serde`, `Clone`, `PartialEq`. Unknown JSON fields preserved via `#[serde(flatten)]` with `HashMap`.
+ - `services/` — Business logic: config file I/O (`ConfigFileManager`), file watching (`FileWatcher`), settings merging (`SettingsMergeService`), health checks, MCP operations, project discovery.
+ - `error.rs` — `FigError` and `ConfigFileError` types using `thiserror`.
-- **Models** (`Sources/Models/`) — `Sendable`, `Codable`, `Equatable`, `Hashable`. All models preserve unknown JSON keys via `AnyCodable` and `DynamicCodingKey` for safe round-tripping.
-- **ViewModels** (`Sources/ViewModels/`) — `@MainActor @Observable final class`. Manage UI state, loading/saving, undo/redo, and file watching.
-- **Views** (`Sources/Views/`) — SwiftUI views. Onboarding views live in `Views/Onboarding/`.
-- **Services** (`Sources/Services/`) — Actor-based services for all file I/O and business logic. Thread-safe by design.
-- **Utilities** (`Sources/Utilities/`) — Helpers like `Logger.swift`.
-- **App** (`Sources/App/`) — Entry point (`FigApp.swift`), keyboard commands, and focused values.
+- **fig-ui** (`fig-ui/src/`) — Binary crate using [Iced](https://iced.rs) 0.13.
+ - `main.rs` — `App` struct (state), `Message` enum (events), `update()` (state transitions), `view()` (render).
+ - `views/` — View functions returning `Element<'a, Message>`. Each view is a standalone function, not a struct.
+ - `styles.rs` — Theme color constants (`TEXT_PRIMARY`, `ACCENT`, `SELECTED_BG`, etc.).
### Configuration Hierarchy
@@ -34,25 +35,25 @@ Settings merge from three tiers with clear precedence: **projectLocal > projectS
## Code Conventions
-- **Linting**: SwiftLint (`.swiftlint.yml`) + SwiftFormat (`.swiftformat`) enforced via pre-commit hooks.
-- **Line length**: 120-char soft limit (warning), 150-char hard limit (error).
-- **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) required. Pattern: `feat|fix|docs|style|refactor|perf|test|chore|build|ci` with optional scope. Enforced by lefthook commit-msg hook.
-- **No force unwraps** — prefer `guard`/`if let`.
-- **Logging**: Use the `Log` utility (`Log.general`, `Log.ui`, `Log.fileIO`, `Log.network`) — never use `print`.
-- **Concurrency**: All models must be `Sendable`. All I/O services must be `actor`. All view models must be `@MainActor`.
+- **Linting**: `cargo clippy -- -D warnings` must pass clean. `cargo fmt` for formatting.
+- **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) required. Pattern: `feat|fix|docs|style|refactor|perf|test|chore|build|ci` with optional scope. Lowercase messages.
+- **No `.unwrap()` in library code** — use `?` or proper error handling. `.unwrap()` is acceptable in tests.
+- **Logging**: Use `eprintln!` sparingly for debugging. No println in library code.
## Testing
-- Uses **Swift Testing** framework (`@Suite`, `@Test`, `#expect`) — not XCTest.
-- Tests live in `Tests/`.
-- Run with `swift test`.
-- Focus areas: model serialization/round-tripping, service logic, view model behavior.
-- Test fixtures use enums with static properties for shared test data.
+- Inline `#[cfg(test)] mod tests` in each module.
+- Use `#[test]` for sync tests, `#[tokio::test]` for async.
+- Run with `cargo test`.
+- Focus areas: model serialization round-tripping, service logic, validation.
+- Currently 166 tests across the workspace.
## Common Pitfalls
-- **Preserve unknown JSON fields**: Models use custom `init(from:)`/`encode(to:)` with `AnyCodable` to round-trip unknown keys. Never drop `additionalProperties` during serialization.
+- **Preserve unknown JSON fields**: Models use `#[serde(flatten)] pub extra: HashMap` to round-trip unknown keys. Never drop extra fields during serialization.
- **Backups are automatic**: `ConfigFileManager` creates timestamped backups before every write. Do not bypass this.
-- **External change detection**: File watching uses `DispatchSource` on file modification dates. Respect this pattern when modifying file I/O.
+- **External change detection**: File watching uses polling on file modification times. Respect this pattern when modifying file I/O.
- **Config merge semantics**: Permissions union across tiers, environment variables override, hooks concatenate. Check `SettingsMergeService` before changing merge behavior.
-- **AnyCodable is `@unchecked Sendable`**: It stores `Any` internally and sanitizes values recursively. Take care when modifying it.
+- **Iced lifetime patterns**: View functions return `Element<'a, Message>` — borrowed data must outlive the returned element. Use `.to_string()` or `.clone()` for local values used in text widgets.
+- **Iced Padding**: Use `Padding::new(f32).left(f32).right(f32)` builder for asymmetric padding.
+- **Clippy too-many-arguments**: Use `#[allow(clippy::too_many_arguments)]` on view dispatch functions that pass state to sub-views.
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..d747fea
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,3 @@
+[workspace]
+members = ["fig-core", "fig-ui"]
+resolver = "2"
diff --git a/Fig/.swift-version b/Fig/.swift-version
deleted file mode 100644
index e0ea36f..0000000
--- a/Fig/.swift-version
+++ /dev/null
@@ -1 +0,0 @@
-6.0
diff --git a/Fig/.swiftformat b/Fig/.swiftformat
deleted file mode 100644
index 6c3bbfb..0000000
--- a/Fig/.swiftformat
+++ /dev/null
@@ -1,64 +0,0 @@
-# .swiftformat
-# SwiftFormat configuration for Fig
-
-# File options
---exclude .build,DerivedData,Packages
-
-# Format options
---allman false
---binarygrouping 4,8
---closingparen balanced
---commas always
---decimalgrouping 3,6
---elseposition same-line
---empty void
---exponentcase lowercase
---exponentgrouping disabled
---fractiongrouping disabled
---header ignore
---hexgrouping 4,8
---hexliteralcase uppercase
---ifdef indent
---importgrouping alpha
---indent 4
---indentcase false
---linebreaks lf
---maxwidth 120
---modifierorder
---nospaceoperators
---octalgrouping 4,8
---operatorfunc spaced
---patternlet hoist
---ranges spaced
---self insert
---semicolons inline
---smarttabs enabled
---stripunusedargs closure-only
---tabwidth 4
---trailingclosures
---trimwhitespace always
---typeattributes preserve
---varattributes preserve
---voidtype void
---wraparguments preserve
---wrapcollections preserve
---wrapconditions preserve
---wrapparameters preserve
---wrapreturntype preserve
---xcodeindentation disabled
---yodaswap always
-
-# Rules
---enable blankLinesBetweenImports
---enable blockComments
---enable docComments
---enable isEmpty
---enable markTypes
---enable organizeDeclarations
---enable sortImports
---enable wrapConditionalBodies
---enable wrapEnumCases
---enable wrapSwitchCases
-
---disable acronyms
---disable redundantProperty
diff --git a/Fig/.swiftlint.yml b/Fig/.swiftlint.yml
deleted file mode 100644
index 472a642..0000000
--- a/Fig/.swiftlint.yml
+++ /dev/null
@@ -1,99 +0,0 @@
-# .swiftlint.yml
-# SwiftLint configuration for Fig
-
-included:
- - Sources
- - Tests
-
-excluded:
- - .build
- - DerivedData
- - Packages
-
-disabled_rules:
- - trailing_whitespace
-
-opt_in_rules:
- - array_init
- - attributes
- - closure_end_indentation
- - closure_spacing
- - collection_alignment
- - contains_over_filter_count
- - contains_over_filter_is_empty
- - contains_over_first_not_nil
- - empty_collection_literal
- - empty_count
- - empty_string
- - explicit_init
- - extension_access_modifier
- - fallthrough
- - fatal_error_message
- - first_where
- - flatmap_over_map_reduce
- - identical_operands
- - joined_default_parameter
- - last_where
- - legacy_multiple
- - literal_expression_end_indentation
- - lower_acl_than_parent
- - modifier_order
- - number_separator
- - operator_usage_whitespace
- - overridden_super_call
- - override_in_extension
- - pattern_matching_keywords
- - prefer_self_type_over_type_of_self
- - private_action
- - private_outlet
- - prohibited_super_call
- - reduce_into
- - redundant_nil_coalescing
- - redundant_type_annotation
- - single_test_class
- - sorted_first_last
- - static_operator
- - toggle_bool
- - unavailable_function
- - unneeded_parentheses_in_closure_argument
- - unowned_variable_capture
- - untyped_error_in_catch
- - vertical_parameter_alignment_on_call
- - vertical_whitespace_closing_braces
- - vertical_whitespace_opening_braces
- - yoda_condition
-
-line_length:
- warning: 120
- error: 150
- ignores_comments: true
- ignores_urls: true
-
-file_length:
- warning: 500
- error: 1000
-
-type_body_length:
- warning: 300
- error: 500
-
-function_body_length:
- warning: 50
- error: 100
-
-identifier_name:
- min_length: 2
- max_length: 50
- excluded:
- - id
- - x
- - y
- - i
- - j
- - k
-
-nesting:
- type_level: 2
- function_level: 3
-
-reporter: xcode
diff --git a/Fig/Brewfile b/Fig/Brewfile
deleted file mode 100644
index cec75d7..0000000
--- a/Fig/Brewfile
+++ /dev/null
@@ -1,9 +0,0 @@
-# Brewfile
-# Development dependencies for Fig
-
-# Code quality tools
-brew "swiftlint"
-brew "swiftformat"
-
-# Git hooks manager
-brew "lefthook"
diff --git a/Fig/Fig.entitlements b/Fig/Fig.entitlements
deleted file mode 100644
index 12e11f3..0000000
--- a/Fig/Fig.entitlements
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
- com.apple.security.app-sandbox
-
- com.apple.security.files.user-selected.read-write
-
- com.apple.security.network.client
-
-
-
diff --git a/Fig/Package.swift b/Fig/Package.swift
deleted file mode 100644
index e8cca54..0000000
--- a/Fig/Package.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-// swift-tools-version: 6.0
-// The swift-tools-version declares the minimum version of Swift required to build this package.
-
-import PackageDescription
-
-let package = Package(
- name: "Fig",
- platforms: [
- .macOS(.v14),
- ],
- products: [
- .executable(
- name: "Fig",
- targets: ["Fig"]
- ),
- ],
- dependencies: [
- .package(url: "https://github.com/SimplyDanny/SwiftLintPlugins", from: "0.58.0"),
- .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.55.0"),
- .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.0"),
- ],
- targets: [
- .executableTarget(
- name: "Fig",
- dependencies: [
- .product(name: "MarkdownUI", package: "swift-markdown-ui"),
- ],
- path: "Sources",
- swiftSettings: [
- .enableExperimentalFeature("StrictConcurrency"),
- ],
- plugins: [
- .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins"),
- ]
- ),
- .testTarget(
- name: "FigTests",
- dependencies: ["Fig"],
- path: "Tests",
- plugins: [
- .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLintPlugins"),
- ]
- ),
- ]
-)
diff --git a/Fig/Sources/App/AppCommands.swift b/Fig/Sources/App/AppCommands.swift
deleted file mode 100644
index 387c30b..0000000
--- a/Fig/Sources/App/AppCommands.swift
+++ /dev/null
@@ -1,69 +0,0 @@
-import SwiftUI
-
-/// App-level menu commands with keyboard shortcuts.
-struct AppCommands: Commands {
- // MARK: Internal
-
- var body: some Commands {
- // Replace Preferences menu item with Global Settings navigation
- CommandGroup(replacing: .appSettings) {
- Button("Global Settings...") {
- self.selection = .globalSettings
- }
- .keyboardShortcut(",", modifiers: .command)
- }
-
- // File menu: New MCP Server + Import from JSON
- CommandGroup(after: .newItem) {
- Button("New MCP Server") {
- self.addMCPServer?()
- }
- .keyboardShortcut("n", modifiers: [.command, .shift])
- .disabled(self.addMCPServer == nil)
-
- Divider()
-
- Button("Import MCP Servers from JSON...") {
- self.pasteMCPServers?()
- }
- .keyboardShortcut("v", modifiers: [.command, .shift])
- .disabled(self.pasteMCPServers == nil)
- }
-
- // Tab menu for switching detail tabs
- CommandMenu("Tab") {
- if self.projectTab != nil {
- ForEach(Array(ProjectDetailTab.allCases.prefix(9).enumerated()), id: \.element) { index, tab in
- Button(tab.title) {
- self.projectTab = tab
- }
- .keyboardShortcut(
- KeyEquivalent(Character("\(index + 1)")),
- modifiers: .command
- )
- }
- } else if self.globalTab != nil {
- ForEach(Array(GlobalSettingsTab.allCases.prefix(9).enumerated()), id: \.element) { index, tab in
- Button(tab.title) {
- self.globalTab = tab
- }
- .keyboardShortcut(
- KeyEquivalent(Character("\(index + 1)")),
- modifiers: .command
- )
- }
- } else {
- Text("No tabs available")
- .foregroundStyle(.secondary)
- }
- }
- }
-
- // MARK: Private
-
- @FocusedBinding(\.navigationSelection) private var selection
- @FocusedBinding(\.projectDetailTab) private var projectTab
- @FocusedBinding(\.globalSettingsTab) private var globalTab
- @FocusedValue(\.addMCPServerAction) private var addMCPServer
- @FocusedValue(\.pasteMCPServersAction) private var pasteMCPServers
-}
diff --git a/Fig/Sources/App/FigApp.swift b/Fig/Sources/App/FigApp.swift
deleted file mode 100644
index 0a02964..0000000
--- a/Fig/Sources/App/FigApp.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-import AppKit
-import SwiftUI
-
-// MARK: - AppDelegate
-
-/// App delegate to configure the application before UI loads.
-final class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable {
- func applicationDidFinishLaunching(_: Notification) {
- // Ensure the app activates and shows its window when running from Xcode/SPM
- DispatchQueue.main.async {
- NSApp.setActivationPolicy(.regular)
- NSApp.activate(ignoringOtherApps: true)
- NSApp.windows.first?.makeKeyAndOrderFront(nil)
- }
- }
-}
-
-// MARK: - FigApp
-
-/// The main entry point for the Fig application.
-@main
-struct FigApp: App {
- // MARK: Internal
-
- @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
-
- var body: some Scene {
- WindowGroup {
- if self.hasCompletedOnboarding {
- ContentView()
- } else {
- OnboardingView {
- withAnimation(.easeInOut(duration: 0.3)) {
- self.hasCompletedOnboarding = true
- }
- }
- }
- }
- .windowStyle(.automatic)
- .defaultSize(width: 1200, height: 800)
- .commands {
- AppCommands()
- }
- }
-
- // MARK: Private
-
- @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
-}
diff --git a/Fig/Sources/App/FocusedValues.swift b/Fig/Sources/App/FocusedValues.swift
deleted file mode 100644
index 9df0895..0000000
--- a/Fig/Sources/App/FocusedValues.swift
+++ /dev/null
@@ -1,70 +0,0 @@
-import SwiftUI
-
-// MARK: - NavigationSelectionKey
-
-/// Key for the navigation selection binding.
-private struct NavigationSelectionKey: FocusedValueKey {
- typealias Value = Binding
-}
-
-// MARK: - ProjectDetailTabKey
-
-/// Key for the project detail tab binding.
-private struct ProjectDetailTabKey: FocusedValueKey {
- typealias Value = Binding
-}
-
-// MARK: - GlobalSettingsTabKey
-
-/// Key for the global settings tab binding.
-private struct GlobalSettingsTabKey: FocusedValueKey {
- typealias Value = Binding
-}
-
-// MARK: - AddMCPServerActionKey
-
-/// Key for the add MCP server action.
-private struct AddMCPServerActionKey: FocusedValueKey {
- typealias Value = () -> Void
-}
-
-// MARK: - PasteMCPServersActionKey
-
-/// Key for the paste/import MCP servers action.
-private struct PasteMCPServersActionKey: FocusedValueKey {
- typealias Value = () -> Void
-}
-
-// MARK: - FocusedValues Extension
-
-extension FocusedValues {
- /// Binding to the sidebar navigation selection.
- var navigationSelection: Binding? {
- get { self[NavigationSelectionKey.self] }
- set { self[NavigationSelectionKey.self] = newValue }
- }
-
- /// Binding to the project detail tab.
- var projectDetailTab: Binding? {
- get { self[ProjectDetailTabKey.self] }
- set { self[ProjectDetailTabKey.self] = newValue }
- }
-
- /// Binding to the global settings tab.
- var globalSettingsTab: Binding? {
- get { self[GlobalSettingsTabKey.self] }
- set { self[GlobalSettingsTabKey.self] = newValue }
- }
-
- /// Action to add a new MCP server.
- var addMCPServerAction: (() -> Void)? {
- get { self[AddMCPServerActionKey.self] }
- set { self[AddMCPServerActionKey.self] = newValue }
- }
-
- /// Action to paste/import MCP servers from JSON.
- var pasteMCPServersAction: (() -> Void)? {
- get { self[PasteMCPServersActionKey.self] }
- set { self[PasteMCPServersActionKey.self] = newValue }
- }
-}
diff --git a/Fig/Sources/Models/AnyCodable.swift b/Fig/Sources/Models/AnyCodable.swift
deleted file mode 100644
index 711a028..0000000
--- a/Fig/Sources/Models/AnyCodable.swift
+++ /dev/null
@@ -1,230 +0,0 @@
-import Foundation
-
-// MARK: - AnyCodable
-
-/// A type-erased `Codable` value that preserves arbitrary JSON during round-trip encoding/decoding.
-///
-/// This is essential for preserving unknown keys in Claude Code's configuration files,
-/// ensuring Fig doesn't lose fields it doesn't understand.
-///
-/// - Note: This type is marked `@unchecked Sendable` because it stores `Any` internally.
-/// In practice, values are only created from JSON decoding (which produces Sendable primitives,
-/// arrays, and dictionaries) or from the ExpressibleBy literal protocols. Avoid storing
-/// non-Sendable types directly.
-public struct AnyCodable: Codable, Equatable, Hashable, @unchecked Sendable {
- // MARK: Lifecycle
-
- public init(_ value: Any) {
- self.value = Self.sanitize(value)
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.singleValueContainer()
-
- if container.decodeNil() {
- self.value = NSNull()
- } else if let bool = try? container.decode(Bool.self) {
- self.value = bool
- } else if let double = try? container.decode(Double.self) {
- self.value = double
- } else if let int = try? container.decode(Int.self) {
- self.value = int
- } else if let string = try? container.decode(String.self) {
- self.value = string
- } else if let array = try? container.decode([AnyCodable].self) {
- self.value = array.map(\.value)
- } else if let dictionary = try? container.decode([String: AnyCodable].self) {
- self.value = dictionary.mapValues(\.value)
- } else {
- throw DecodingError.dataCorruptedError(
- in: container,
- debugDescription: "AnyCodable cannot decode value"
- )
- }
- }
-
- // MARK: Public
-
- public let value: Any
-
- // MARK: - Equatable
-
- public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
- self.areEqual(lhs.value, rhs.value)
- }
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.singleValueContainer()
-
- switch self.value {
- case is NSNull:
- try container.encodeNil()
- case let bool as Bool:
- try container.encode(bool)
- case let int as Int:
- try container.encode(int)
- case let double as Double:
- try container.encode(double)
- case let string as String:
- try container.encode(string)
- case let array as [Any]:
- try container.encode(array.map { AnyCodable($0) })
- case let dictionary as [String: Any]:
- try container.encode(dictionary.mapValues { AnyCodable($0) })
- default:
- throw EncodingError.invalidValue(
- self.value,
- EncodingError.Context(
- codingPath: container.codingPath,
- debugDescription: "AnyCodable cannot encode value of type \(type(of: self.value))"
- )
- )
- }
- }
-
- // MARK: - Hashable
-
- public func hash(into hasher: inout Hasher) {
- Self.hashValue(self.value, into: &hasher)
- }
-
- // MARK: Private
-
- /// Compares two `Any` values for equality without allocating wrapper objects.
- private static func areEqual(_ lhs: Any, _ rhs: Any) -> Bool {
- switch (lhs, rhs) {
- case is (NSNull, NSNull):
- return true
- case let (lhs as Bool, rhs as Bool):
- return lhs == rhs
- case let (lhs as Int, rhs as Int):
- return lhs == rhs
- case let (lhs as Double, rhs as Double):
- return lhs == rhs
- case let (lhs as String, rhs as String):
- return lhs == rhs
- case let (lhs as [Any], rhs as [Any]):
- guard lhs.count == rhs.count else {
- return false
- }
- return zip(lhs, rhs).allSatisfy { self.areEqual($0, $1) }
- case let (lhs as [String: Any], rhs as [String: Any]):
- guard lhs.count == rhs.count else {
- return false
- }
- return lhs.allSatisfy { key, value in
- guard let rhsValue = rhs[key] else {
- return false
- }
- return self.areEqual(value, rhsValue)
- }
- default:
- return false
- }
- }
-
- /// Hashes an `Any` value without allocating wrapper objects.
- private static func hashValue(_ value: Any, into hasher: inout Hasher) {
- switch value {
- case is NSNull:
- hasher.combine(0)
- case let bool as Bool:
- hasher.combine(1)
- hasher.combine(bool)
- case let int as Int:
- hasher.combine(2)
- hasher.combine(int)
- case let double as Double:
- hasher.combine(3)
- hasher.combine(double)
- case let string as String:
- hasher.combine(4)
- hasher.combine(string)
- case let array as [Any]:
- hasher.combine(5)
- hasher.combine(array.count)
- for element in array {
- self.hashValue(element, into: &hasher)
- }
- case let dictionary as [String: Any]:
- hasher.combine(6)
- hasher.combine(dictionary.count)
- for key in dictionary.keys.sorted() {
- hasher.combine(key)
- self.hashValue(dictionary[key]!, into: &hasher)
- }
- default:
- // Hash the type to ensure different unsupported types don't collide
- hasher.combine(7)
- hasher.combine(ObjectIdentifier(type(of: value)))
- }
- }
-
- /// Recursively sanitize values to ensure Sendable compliance.
- private static func sanitize(_ value: Any) -> Any {
- switch value {
- case let array as [Any]:
- array.map { self.sanitize($0) }
- case let dictionary as [String: Any]:
- dictionary.mapValues { self.sanitize($0) }
- default:
- value
- }
- }
-}
-
-// MARK: ExpressibleByNilLiteral
-
-extension AnyCodable: ExpressibleByNilLiteral {
- public init(nilLiteral: ()) {
- self.init(NSNull())
- }
-}
-
-// MARK: ExpressibleByBooleanLiteral
-
-extension AnyCodable: ExpressibleByBooleanLiteral {
- public init(booleanLiteral value: Bool) {
- self.init(value)
- }
-}
-
-// MARK: ExpressibleByIntegerLiteral
-
-extension AnyCodable: ExpressibleByIntegerLiteral {
- public init(integerLiteral value: Int) {
- self.init(value)
- }
-}
-
-// MARK: ExpressibleByFloatLiteral
-
-extension AnyCodable: ExpressibleByFloatLiteral {
- public init(floatLiteral value: Double) {
- self.init(value)
- }
-}
-
-// MARK: ExpressibleByStringLiteral
-
-extension AnyCodable: ExpressibleByStringLiteral {
- public init(stringLiteral value: String) {
- self.init(value)
- }
-}
-
-// MARK: ExpressibleByArrayLiteral
-
-extension AnyCodable: ExpressibleByArrayLiteral {
- public init(arrayLiteral elements: Any...) {
- self.init(elements)
- }
-}
-
-// MARK: ExpressibleByDictionaryLiteral
-
-extension AnyCodable: ExpressibleByDictionaryLiteral {
- public init(dictionaryLiteral elements: (String, Any)...) {
- self.init(Dictionary(uniqueKeysWithValues: elements))
- }
-}
diff --git a/Fig/Sources/Models/Attribution.swift b/Fig/Sources/Models/Attribution.swift
deleted file mode 100644
index 05e8c8f..0000000
--- a/Fig/Sources/Models/Attribution.swift
+++ /dev/null
@@ -1,78 +0,0 @@
-import Foundation
-
-/// Controls how Claude Code attributes its contributions in version control.
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "commits": true,
-/// "pullRequests": true
-/// }
-/// ```
-public struct Attribution: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- commits: Bool? = nil,
- pullRequests: Bool? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.commits = commits
- self.pullRequests = pullRequests
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.commits = try container.decodeIfPresent(Bool.self, forKey: .commits)
- self.pullRequests = try container.decodeIfPresent(Bool.self, forKey: .pullRequests)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// Whether to include attribution in commit messages.
- public var commits: Bool?
-
- /// Whether to include attribution in pull request descriptions.
- public var pullRequests: Bool?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.commits, forKey: .commits)
- try container.encodeIfPresent(self.pullRequests, forKey: .pullRequests)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case commits
- case pullRequests
- }
-
- private static let knownKeys: Set = ["commits", "pullRequests"]
-}
diff --git a/Fig/Sources/Models/ClaudeSettings.swift b/Fig/Sources/Models/ClaudeSettings.swift
deleted file mode 100644
index a079c5b..0000000
--- a/Fig/Sources/Models/ClaudeSettings.swift
+++ /dev/null
@@ -1,130 +0,0 @@
-import Foundation
-
-/// Top-level Claude Code settings object.
-///
-/// This represents the structure of `settings.json` files (global or project-level).
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "permissions": {
-/// "allow": ["Bash(npm run *)", "Read(src/**)"],
-/// "deny": ["Read(.env)", "Bash(curl *)"]
-/// },
-/// "env": {
-/// "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "16384"
-/// },
-/// "hooks": {
-/// "PreToolUse": [...],
-/// "PostToolUse": [...]
-/// },
-/// "disallowedTools": ["..."],
-/// "attribution": {
-/// "commits": true,
-/// "pullRequests": true
-/// }
-/// }
-/// ```
-public struct ClaudeSettings: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- permissions: Permissions? = nil,
- env: [String: String]? = nil,
- hooks: [String: [HookGroup]]? = nil,
- disallowedTools: [String]? = nil,
- attribution: Attribution? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.permissions = permissions
- self.env = env
- self.hooks = hooks
- self.disallowedTools = disallowedTools
- self.attribution = attribution
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.permissions = try container.decodeIfPresent(Permissions.self, forKey: .permissions)
- self.env = try container.decodeIfPresent([String: String].self, forKey: .env)
- self.hooks = try container.decodeIfPresent([String: [HookGroup]].self, forKey: .hooks)
- self.disallowedTools = try container.decodeIfPresent([String].self, forKey: .disallowedTools)
- self.attribution = try container.decodeIfPresent(Attribution.self, forKey: .attribution)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// Permission rules for Claude Code operations.
- public var permissions: Permissions?
-
- /// Environment variables to set for Claude Code.
- public var env: [String: String]?
-
- /// Hook configurations keyed by event name (e.g., "PreToolUse", "PostToolUse").
- public var hooks: [String: [HookGroup]]?
-
- /// Array of tool names that are not allowed.
- public var disallowedTools: [String]?
-
- /// Attribution settings for commits and pull requests.
- public var attribution: Attribution?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- /// Returns hooks for the specified event.
- public func hooks(for event: String) -> [HookGroup]? {
- self.hooks?[event]
- }
-
- /// Checks if a specific tool is disallowed.
- public func isToolDisallowed(_ toolName: String) -> Bool {
- self.disallowedTools?.contains(toolName) ?? false
- }
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.permissions, forKey: .permissions)
- try container.encodeIfPresent(self.env, forKey: .env)
- try container.encodeIfPresent(self.hooks, forKey: .hooks)
- try container.encodeIfPresent(self.disallowedTools, forKey: .disallowedTools)
- try container.encodeIfPresent(self.attribution, forKey: .attribution)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case permissions
- case env
- case hooks
- case disallowedTools
- case attribution
- }
-
- private static let knownKeys: Set = [
- "permissions", "env", "hooks", "disallowedTools", "attribution",
- ]
-}
diff --git a/Fig/Sources/Models/ConfigBundle.swift b/Fig/Sources/Models/ConfigBundle.swift
deleted file mode 100644
index 8f7b9ea..0000000
--- a/Fig/Sources/Models/ConfigBundle.swift
+++ /dev/null
@@ -1,231 +0,0 @@
-import Foundation
-
-// MARK: - ConfigBundle
-
-/// A bundle containing exported project configuration.
-///
-/// This format allows exporting and importing complete project configurations,
-/// including settings, local settings, and MCP servers.
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "version": 1,
-/// "exportedAt": "2024-01-15T10:30:00Z",
-/// "projectName": "my-project",
-/// "settings": { ... },
-/// "localSettings": { ... },
-/// "mcpServers": { ... }
-/// }
-/// ```
-struct ConfigBundle: Codable, Equatable {
- // MARK: Lifecycle
-
- init(
- version: Int = Self.currentVersion,
- exportedAt: Date = Date(),
- projectName: String,
- settings: ClaudeSettings? = nil,
- localSettings: ClaudeSettings? = nil,
- mcpServers: MCPConfig? = nil
- ) {
- self.version = version
- self.exportedAt = exportedAt
- self.projectName = projectName
- self.settings = settings
- self.localSettings = localSettings
- self.mcpServers = mcpServers
- }
-
- // MARK: Internal
-
- /// Current bundle format version.
- static let currentVersion = 1
-
- /// File extension for config bundles.
- static let fileExtension = "claudeconfig"
-
- /// Bundle format version.
- let version: Int
-
- /// When the bundle was exported.
- let exportedAt: Date
-
- /// Name of the project this bundle was exported from.
- let projectName: String
-
- /// Project settings (settings.json).
- var settings: ClaudeSettings?
-
- /// Local project settings (settings.local.json).
- var localSettings: ClaudeSettings?
-
- /// MCP server configurations (.mcp.json).
- var mcpServers: MCPConfig?
-
- /// Whether the bundle has any content.
- var isEmpty: Bool {
- self.settings == nil && self.localSettings == nil && self.mcpServers == nil
- }
-
- /// Whether the bundle contains potentially sensitive data.
- var containsSensitiveData: Bool {
- // Check local settings (usually contains sensitive data)
- if self.localSettings != nil {
- return true
- }
-
- // Check for env vars in MCP servers
- if let servers = mcpServers?.mcpServers {
- for server in servers.values {
- if let env = server.env, !env.isEmpty {
- return true
- }
- }
- }
-
- return false
- }
-
- /// Summary of bundle contents.
- var contentSummary: [String] {
- var summary: [String] = []
-
- if let settings {
- var items: [String] = []
- if let permissions = settings.permissions {
- let allowCount = permissions.allow?.count ?? 0
- let denyCount = permissions.deny?.count ?? 0
- if allowCount > 0 || denyCount > 0 {
- items.append("\(allowCount) allow, \(denyCount) deny rules")
- }
- }
- if let env = settings.env, !env.isEmpty {
- items.append("\(env.count) env vars")
- }
- if settings.hooks != nil {
- items.append("hooks")
- }
- if !items.isEmpty {
- summary.append("Settings: \(items.joined(separator: ", "))")
- } else {
- summary.append("Settings (empty)")
- }
- }
-
- if let localSettings {
- var items: [String] = []
- if let permissions = localSettings.permissions {
- let allowCount = permissions.allow?.count ?? 0
- let denyCount = permissions.deny?.count ?? 0
- if allowCount > 0 || denyCount > 0 {
- items.append("\(allowCount) allow, \(denyCount) deny rules")
- }
- }
- if let env = localSettings.env, !env.isEmpty {
- items.append("\(env.count) env vars")
- }
- if localSettings.hooks != nil {
- items.append("hooks")
- }
- if !items.isEmpty {
- summary.append("Local Settings: \(items.joined(separator: ", "))")
- } else {
- summary.append("Local Settings (empty)")
- }
- }
-
- if let mcpServers = mcpServers?.mcpServers, !mcpServers.isEmpty {
- summary.append("MCP Servers: \(mcpServers.count)")
- }
-
- return summary
- }
-}
-
-// MARK: - ConfigBundleComponent
-
-/// Components that can be included in a config bundle.
-enum ConfigBundleComponent: String, CaseIterable, Identifiable {
- case settings
- case localSettings
- case mcpServers
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var displayName: String {
- switch self {
- case .settings: "Settings (settings.json)"
- case .localSettings: "Local Settings (settings.local.json)"
- case .mcpServers: "MCP Servers (.mcp.json)"
- }
- }
-
- var icon: String {
- switch self {
- case .settings: "gearshape"
- case .localSettings: "gearshape.2"
- case .mcpServers: "server.rack"
- }
- }
-
- var isSensitive: Bool {
- self == .localSettings
- }
-
- var sensitiveWarning: String? {
- switch self {
- case .localSettings:
- "May contain API keys, tokens, or other sensitive data"
- case .mcpServers:
- "May contain environment variables with sensitive data"
- default:
- nil
- }
- }
-}
-
-// MARK: - ImportConflict
-
-/// Represents a conflict found during import.
-struct ImportConflict: Identifiable {
- enum ImportResolution: String, CaseIterable, Identifiable {
- case merge // Combine with existing (for arrays/dicts)
- case replace // Replace existing completely
- case skip // Keep existing, skip import
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var displayName: String {
- switch self {
- case .merge: "Merge with existing"
- case .replace: "Replace existing"
- case .skip: "Skip (keep existing)"
- }
- }
- }
-
- let id = UUID()
- let component: ConfigBundleComponent
- let description: String
- var resolution: ImportResolution = .merge
-}
-
-// MARK: - ImportResult
-
-/// Result of an import operation.
-struct ImportResult {
- let success: Bool
- let message: String
- let componentsImported: [ConfigBundleComponent]
- let componentsSkipped: [ConfigBundleComponent]
- let errors: [String]
-}
diff --git a/Fig/Sources/Models/ConfigSource.swift b/Fig/Sources/Models/ConfigSource.swift
deleted file mode 100644
index d908a1b..0000000
--- a/Fig/Sources/Models/ConfigSource.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-import Foundation
-
-/// Represents the source of a configuration value in the merge hierarchy.
-///
-/// Values are listed in order of precedence (lowest to highest):
-/// - `global`: User's global settings (`~/.claude/settings.json`)
-/// - `projectShared`: Project-level shared settings (`.claude/settings.json`)
-/// - `projectLocal`: Project-level local settings (`.claude/settings.local.json`)
-public enum ConfigSource: String, Sendable, Equatable, Hashable, CaseIterable, Comparable {
- /// User's global settings (~/.claude/settings.json).
- case global
-
- /// Project-level shared settings (.claude/settings.json).
- case projectShared
-
- /// Project-level local settings (.claude/settings.local.json, gitignored).
- case projectLocal
-
- // MARK: Public
-
- /// Display name for the configuration source.
- public var displayName: String {
- switch self {
- case .global:
- "Global"
- case .projectShared:
- "Project"
- case .projectLocal:
- "Local"
- }
- }
-
- /// Short label for the configuration source.
- public var label: String {
- switch self {
- case .global:
- "Global"
- case .projectShared:
- "Shared"
- case .projectLocal:
- "Local"
- }
- }
-
- /// SF Symbol icon name for the configuration source.
- public var icon: String {
- switch self {
- case .global:
- "globe"
- case .projectShared:
- "person.2"
- case .projectLocal:
- "person"
- }
- }
-
- /// File name associated with this source.
- public var fileName: String {
- switch self {
- case .global:
- "~/.claude/settings.json"
- case .projectShared:
- ".claude/settings.json"
- case .projectLocal:
- ".claude/settings.local.json"
- }
- }
-
- /// Precedence level (higher wins in merges).
- public var precedence: Int {
- switch self {
- case .global:
- 0
- case .projectShared:
- 1
- case .projectLocal:
- 2
- }
- }
-
- public static func < (lhs: ConfigSource, rhs: ConfigSource) -> Bool {
- lhs.precedence < rhs.precedence
- }
-}
diff --git a/Fig/Sources/Models/DiscoveredProject.swift b/Fig/Sources/Models/DiscoveredProject.swift
deleted file mode 100644
index e30298f..0000000
--- a/Fig/Sources/Models/DiscoveredProject.swift
+++ /dev/null
@@ -1,78 +0,0 @@
-import Foundation
-
-/// Represents a project discovered from the legacy config or filesystem scan.
-///
-/// Contains metadata about the project's location, existence status,
-/// available configuration files, and last modification time.
-///
-/// Example:
-/// ```swift
-/// let project = DiscoveredProject(
-/// path: "/Users/sean/code/relay",
-/// displayName: "relay",
-/// exists: true,
-/// hasSettings: true,
-/// hasLocalSettings: false,
-/// hasMCPConfig: true,
-/// lastModified: Date()
-/// )
-/// ```
-public struct DiscoveredProject: Sendable, Identifiable, Equatable, Hashable {
- // MARK: Lifecycle
-
- public init(
- path: String,
- displayName: String,
- exists: Bool,
- hasSettings: Bool,
- hasLocalSettings: Bool,
- hasMCPConfig: Bool,
- lastModified: Date?
- ) {
- self.path = path
- self.displayName = displayName
- self.exists = exists
- self.hasSettings = hasSettings
- self.hasLocalSettings = hasLocalSettings
- self.hasMCPConfig = hasMCPConfig
- self.lastModified = lastModified
- }
-
- // MARK: Public
-
- /// The absolute path to the project directory.
- public let path: String
-
- /// Human-readable name derived from the directory name.
- public let displayName: String
-
- /// Whether the project directory still exists on disk.
- public let exists: Bool
-
- /// Whether the project has a `.claude/settings.json` file.
- public let hasSettings: Bool
-
- /// Whether the project has a `.claude/settings.local.json` file.
- public let hasLocalSettings: Bool
-
- /// Whether the project has a `.mcp.json` file.
- public let hasMCPConfig: Bool
-
- /// Last modification time of the project directory or config files.
- public let lastModified: Date?
-
- /// Unique identifier based on the path.
- public var id: String {
- self.path
- }
-
- /// URL representation of the project path.
- public var url: URL {
- URL(fileURLWithPath: self.path)
- }
-
- /// Returns true if the project has any configuration files.
- public var hasAnyConfig: Bool {
- self.hasSettings || self.hasLocalSettings || self.hasMCPConfig
- }
-}
diff --git a/Fig/Sources/Models/DynamicCodingKey.swift b/Fig/Sources/Models/DynamicCodingKey.swift
deleted file mode 100644
index 2f4e0d4..0000000
--- a/Fig/Sources/Models/DynamicCodingKey.swift
+++ /dev/null
@@ -1,23 +0,0 @@
-import Foundation
-
-/// A dynamic coding key that can represent any string key.
-///
-/// Used for encoding/decoding unknown keys during round-trip JSON serialization.
-struct DynamicCodingKey: CodingKey {
- // MARK: Lifecycle
-
- init(stringValue: String) {
- self.stringValue = stringValue
- self.intValue = nil
- }
-
- init?(intValue: Int) {
- self.intValue = intValue
- self.stringValue = String(intValue)
- }
-
- // MARK: Internal
-
- var stringValue: String
- var intValue: Int?
-}
diff --git a/Fig/Sources/Models/FigError.swift b/Fig/Sources/Models/FigError.swift
deleted file mode 100644
index 4d999c0..0000000
--- a/Fig/Sources/Models/FigError.swift
+++ /dev/null
@@ -1,199 +0,0 @@
-import Foundation
-
-// MARK: - FigError
-
-/// Centralized error type for the Fig application.
-///
-/// All errors in the application should be converted to `FigError` before
-/// being presented to the user, ensuring consistent error messages and
-/// recovery suggestions.
-enum FigError: Error, LocalizedError, Sendable {
- // MARK: - File Errors
-
- /// A required file was not found.
- case fileNotFound(path: String)
-
- /// Permission was denied when accessing a file.
- case permissionDenied(path: String)
-
- /// The file contains invalid JSON.
- case invalidJSON(path: String, line: Int?)
-
- /// Failed to write to a file.
- case writeFailed(path: String, reason: String)
-
- /// Failed to create a backup.
- case backupFailed(path: String)
-
- // MARK: - Configuration Errors
-
- /// The configuration is invalid.
- case invalidConfiguration(message: String)
-
- /// A required configuration key is missing.
- case missingConfigKey(key: String)
-
- /// A configuration value has an invalid type.
- case invalidConfigValue(key: String, expected: String, got: String)
-
- /// A general configuration error.
- case configurationError(message: String)
-
- // MARK: - Project Errors
-
- /// The specified project was not found.
- case projectNotFound(path: String)
-
- /// The project configuration is corrupt.
- case corruptProjectConfig(path: String)
-
- // MARK: - Network Errors
-
- /// A network request failed.
- case networkError(message: String)
-
- /// The server returned an unexpected response.
- case invalidResponse(statusCode: Int)
-
- // MARK: - General Errors
-
- /// An unknown error occurred.
- case unknown(message: String)
-
- /// An operation was cancelled.
- case cancelled
-
- // MARK: Internal
-
- // MARK: - LocalizedError
-
- var errorDescription: String? {
- switch self {
- case let .fileNotFound(path):
- return "File not found: \(path)"
- case let .permissionDenied(path):
- return "Permission denied: \(path)"
- case let .invalidJSON(path, line):
- if let line {
- return "Invalid JSON at line \(line) in \(path)"
- }
- return "Invalid JSON in \(path)"
- case let .writeFailed(path, reason):
- return "Failed to write to \(path): \(reason)"
- case let .backupFailed(path):
- return "Failed to create backup for \(path)"
- case let .invalidConfiguration(message):
- return "Invalid configuration: \(message)"
- case let .missingConfigKey(key):
- return "Missing required configuration key: \(key)"
- case let .invalidConfigValue(key, expected, got):
- return "Invalid value for '\(key)': expected \(expected), got \(got)"
- case let .configurationError(message):
- return "Configuration error: \(message)"
- case let .projectNotFound(path):
- return "Project not found: \(path)"
- case let .corruptProjectConfig(path):
- return "Corrupt project configuration: \(path)"
- case let .networkError(message):
- return "Network error: \(message)"
- case let .invalidResponse(statusCode):
- return "Invalid response (status code: \(statusCode))"
- case let .unknown(message):
- return message
- case .cancelled:
- return "Operation cancelled"
- }
- }
-
- var failureReason: String? {
- switch self {
- case .fileNotFound:
- "The file does not exist at the specified path."
- case .permissionDenied:
- "The application does not have permission to access this file."
- case .invalidJSON:
- "The file contains malformed JSON that cannot be parsed."
- case .writeFailed:
- "The file system operation could not be completed."
- case .backupFailed:
- "Could not create a backup before modifying the file."
- case .invalidConfiguration:
- "The configuration contains invalid or incompatible settings."
- case .missingConfigKey:
- "A required configuration key was not found."
- case .invalidConfigValue:
- "A configuration value has the wrong type."
- case .configurationError:
- "An error occurred with the configuration."
- case .projectNotFound:
- "The project directory does not exist or is not accessible."
- case .corruptProjectConfig:
- "The project's configuration files are corrupt or unreadable."
- case .networkError:
- "A network communication error occurred."
- case .invalidResponse:
- "The server returned an unexpected or invalid response."
- case .unknown:
- nil
- case .cancelled:
- "The operation was cancelled by the user."
- }
- }
-
- var recoverySuggestion: String? {
- switch self {
- case .fileNotFound:
- "Check that the file path is correct."
- case .permissionDenied:
- "Check file permissions or grant the application access in System Settings > Privacy & Security."
- case .invalidJSON:
- "Open the file in a text editor and fix the JSON syntax, or restore from a backup."
- case .writeFailed:
- "Check that the disk is not full and you have write permissions."
- case .backupFailed:
- "Check that there is enough disk space and the directory is writable."
- case .invalidConfiguration:
- "Review the configuration values and correct any errors."
- case .missingConfigKey:
- "Add the required key to your configuration file."
- case .invalidConfigValue:
- "Update the configuration value to use the correct type."
- case .configurationError:
- "Review the configuration and correct any errors."
- case .projectNotFound:
- "Verify the project path exists and try again."
- case .corruptProjectConfig:
- "Try restoring the configuration from a backup or recreate it."
- case .networkError:
- "Check your network connection and try again."
- case .invalidResponse:
- "The service may be experiencing issues. Try again later."
- case .unknown:
- "Try the operation again or restart the application."
- case .cancelled:
- nil
- }
- }
-}
-
-// MARK: - Conversion from ConfigFileError
-
-extension FigError {
- /// Creates a FigError from a ConfigFileError.
- init(from configError: ConfigFileError) {
- switch configError {
- case let .fileNotFound(url):
- self = .fileNotFound(path: url.path)
- case let .permissionDenied(url):
- self = .permissionDenied(path: url.path)
- case let .invalidJSON(url, _):
- self = .invalidJSON(path: url.path, line: nil)
- case let .writeError(url, underlying):
- self = .writeFailed(path: url.path, reason: underlying.localizedDescription)
- case let .backupFailed(url, _):
- self = .backupFailed(path: url.path)
- case let .circularSymlink(url):
- self = .invalidConfiguration(message: "Circular symlink at \(url.path)")
- }
- }
-}
diff --git a/Fig/Sources/Models/Finding.swift b/Fig/Sources/Models/Finding.swift
deleted file mode 100644
index e5c7ac6..0000000
--- a/Fig/Sources/Models/Finding.swift
+++ /dev/null
@@ -1,205 +0,0 @@
-import Foundation
-import SwiftUI
-
-// MARK: - Severity
-
-/// Severity level for a health check finding.
-enum Severity: Int, Comparable, CaseIterable, Sendable {
- /// Security risk that should be addressed immediately.
- case security = 0
-
- /// Potential issue that may cause problems.
- case warning = 1
-
- /// Suggestion for improvement.
- case suggestion = 2
-
- /// Good practice already in place.
- case good = 3
-
- // MARK: Internal
-
- var label: String {
- switch self {
- case .security:
- "Security"
- case .warning:
- "Warning"
- case .suggestion:
- "Suggestion"
- case .good:
- "Good"
- }
- }
-
- var icon: String {
- switch self {
- case .security:
- "exclamationmark.shield.fill"
- case .warning:
- "exclamationmark.triangle.fill"
- case .suggestion:
- "lightbulb.fill"
- case .good:
- "checkmark.circle.fill"
- }
- }
-
- var color: Color {
- switch self {
- case .security:
- .red
- case .warning:
- .yellow
- case .suggestion:
- .blue
- case .good:
- .green
- }
- }
-
- static func < (lhs: Severity, rhs: Severity) -> Bool {
- lhs.rawValue < rhs.rawValue
- }
-}
-
-// MARK: - AutoFix
-
-/// Describes a fixable action for a health check finding.
-///
-/// Using an enum instead of closures to maintain `Sendable` conformance.
-enum AutoFix: Sendable, Equatable {
- /// Add a pattern to the project's deny list.
- case addToDenyList(pattern: String)
-
- /// Create an empty `settings.local.json` file.
- case createLocalSettings
-
- // MARK: Internal
-
- var label: String {
- switch self {
- case let .addToDenyList(pattern):
- "Add \(pattern) to deny list"
- case .createLocalSettings:
- "Create settings.local.json"
- }
- }
-}
-
-// MARK: - Finding
-
-/// A single finding from a health check.
-struct Finding: Identifiable, Sendable {
- // MARK: Lifecycle
-
- init(
- severity: Severity,
- title: String,
- description: String,
- autoFix: AutoFix? = nil
- ) {
- self.id = UUID()
- self.severity = severity
- self.title = title
- self.description = description
- self.autoFix = autoFix
- }
-
- // MARK: Internal
-
- /// Unique identifier for this finding.
- let id: UUID
-
- /// Severity level of the finding.
- let severity: Severity
-
- /// Short title describing the finding.
- let title: String
-
- /// Detailed description with context.
- let description: String
-
- /// Optional auto-fix action for this finding.
- let autoFix: AutoFix?
-}
-
-// MARK: - HealthCheckContext
-
-/// All data needed by health checks to analyze a project's configuration.
-struct HealthCheckContext: Sendable {
- /// Path to the project directory.
- let projectPath: URL
-
- /// Global settings from `~/.claude/settings.json`.
- let globalSettings: ClaudeSettings?
-
- /// Project shared settings from `.claude/settings.json`.
- let projectSettings: ClaudeSettings?
-
- /// Project local settings from `.claude/settings.local.json`.
- let projectLocalSettings: ClaudeSettings?
-
- /// MCP config from `.mcp.json`.
- let mcpConfig: MCPConfig?
-
- /// Global legacy config from `~/.claude.json`.
- let legacyConfig: LegacyConfig?
-
- /// Whether `settings.local.json` exists.
- let localSettingsExists: Bool
-
- /// Whether `.mcp.json` exists.
- let mcpConfigExists: Bool
-
- /// File size of `~/.claude.json` in bytes, if available.
- let globalConfigFileSize: Int64?
-
- /// All deny rules across all config sources.
- var allDenyRules: [String] {
- var rules: [String] = []
- if let deny = globalSettings?.permissions?.deny {
- rules.append(contentsOf: deny)
- }
- if let deny = projectSettings?.permissions?.deny {
- rules.append(contentsOf: deny)
- }
- if let deny = projectLocalSettings?.permissions?.deny {
- rules.append(contentsOf: deny)
- }
- return rules
- }
-
- /// All allow rules across all config sources.
- var allAllowRules: [String] {
- var rules: [String] = []
- if let allow = globalSettings?.permissions?.allow {
- rules.append(contentsOf: allow)
- }
- if let allow = projectSettings?.permissions?.allow {
- rules.append(contentsOf: allow)
- }
- if let allow = projectLocalSettings?.permissions?.allow {
- rules.append(contentsOf: allow)
- }
- return rules
- }
-
- /// All MCP servers from all sources.
- var allMCPServers: [(name: String, server: MCPServer)] {
- var servers: [(name: String, server: MCPServer)] = []
- if let mcpServers = mcpConfig?.mcpServers {
- for (name, server) in mcpServers {
- servers.append((name, server))
- }
- }
- if let globalServers = legacyConfig?.mcpServers {
- for (name, server) in globalServers {
- if !servers.contains(where: { $0.name == name }) {
- servers.append((name, server))
- }
- }
- }
- return servers
- }
-}
diff --git a/Fig/Sources/Models/HookDefinition.swift b/Fig/Sources/Models/HookDefinition.swift
deleted file mode 100644
index 753558f..0000000
--- a/Fig/Sources/Models/HookDefinition.swift
+++ /dev/null
@@ -1,80 +0,0 @@
-import Foundation
-
-/// Defines a single hook that executes during Claude Code's tool lifecycle.
-///
-/// Hooks can run shell commands or other actions in response to tool events.
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "type": "command",
-/// "command": "npm run lint"
-/// }
-/// ```
-public struct HookDefinition: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- type: String? = nil,
- command: String? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.type = type
- self.command = command
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.type = try container.decodeIfPresent(String.self, forKey: .type)
- self.command = try container.decodeIfPresent(String.self, forKey: .command)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// The type of hook (e.g., "command").
- public var type: String?
-
- /// The command to execute when the hook fires.
- public var command: String?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.type, forKey: .type)
- try container.encodeIfPresent(self.command, forKey: .command)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case type
- case command
- }
-
- private static let knownKeys: Set = ["type", "command"]
-}
diff --git a/Fig/Sources/Models/HookGroup.swift b/Fig/Sources/Models/HookGroup.swift
deleted file mode 100644
index 1a1b3f5..0000000
--- a/Fig/Sources/Models/HookGroup.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-import Foundation
-
-/// Groups hook definitions with an optional matcher pattern.
-///
-/// Hook groups allow filtering which hooks apply based on patterns.
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "matcher": "Bash(*)",
-/// "hooks": [
-/// { "type": "command", "command": "npm run lint" }
-/// ]
-/// }
-/// ```
-public struct HookGroup: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- matcher: String? = nil,
- hooks: [HookDefinition]? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.matcher = matcher
- self.hooks = hooks
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.matcher = try container.decodeIfPresent(String.self, forKey: .matcher)
- self.hooks = try container.decodeIfPresent([HookDefinition].self, forKey: .hooks)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// Optional pattern to match against tool names or operations.
- public var matcher: String?
-
- /// Array of hook definitions in this group.
- public var hooks: [HookDefinition]?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.matcher, forKey: .matcher)
- try container.encodeIfPresent(self.hooks, forKey: .hooks)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case matcher
- case hooks
- }
-
- private static let knownKeys: Set = ["matcher", "hooks"]
-}
diff --git a/Fig/Sources/Models/LegacyConfig.swift b/Fig/Sources/Models/LegacyConfig.swift
deleted file mode 100644
index 472ce9d..0000000
--- a/Fig/Sources/Models/LegacyConfig.swift
+++ /dev/null
@@ -1,140 +0,0 @@
-import Foundation
-
-/// Represents the structure of `~/.claude.json`, the global Claude Code configuration file.
-///
-/// This file contains user preferences, project history, and global MCP server configurations.
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "projects": {
-/// "/path/to/project": {
-/// "allowedTools": ["Bash", "Read"],
-/// "hasTrustDialogAccepted": true
-/// }
-/// },
-/// "customApiKeyResponses": { ... },
-/// "preferences": { ... },
-/// "mcpServers": { ... }
-/// }
-/// ```
-public struct LegacyConfig: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- projects: [String: ProjectEntry]? = nil,
- customApiKeyResponses: [String: AnyCodable]? = nil,
- preferences: [String: AnyCodable]? = nil,
- mcpServers: [String: MCPServer]? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.projects = projects
- self.customApiKeyResponses = customApiKeyResponses
- self.preferences = preferences
- self.mcpServers = mcpServers
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.projects = try container.decodeIfPresent([String: ProjectEntry].self, forKey: .projects)
- self.customApiKeyResponses = try container.decodeIfPresent(
- [String: AnyCodable].self,
- forKey: .customApiKeyResponses
- )
- self.preferences = try container.decodeIfPresent([String: AnyCodable].self, forKey: .preferences)
- self.mcpServers = try container.decodeIfPresent([String: MCPServer].self, forKey: .mcpServers)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// Dictionary of projects keyed by their file path.
- public var projects: [String: ProjectEntry]?
-
- /// Custom API key responses stored by Claude Code.
- public var customApiKeyResponses: [String: AnyCodable]?
-
- /// User preferences.
- public var preferences: [String: AnyCodable]?
-
- /// Global MCP server configurations.
- public var mcpServers: [String: MCPServer]?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- /// Returns all project paths.
- public var projectPaths: [String] {
- self.projects?.keys.sorted() ?? []
- }
-
- /// Returns all projects as an array with their paths set.
- public var allProjects: [ProjectEntry] {
- guard let projects else {
- return []
- }
- return projects.map { path, entry in
- var project = entry
- project.path = path
- return project
- }.sorted { ($0.path ?? "") < ($1.path ?? "") }
- }
-
- /// Returns all global MCP server names.
- public var globalServerNames: [String] {
- self.mcpServers?.keys.sorted() ?? []
- }
-
- /// Returns the project entry for the given path.
- public func project(at path: String) -> ProjectEntry? {
- self.projects?[path]
- }
-
- /// Returns the global MCP server configuration for the given name.
- public func globalServer(named name: String) -> MCPServer? {
- self.mcpServers?[name]
- }
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.projects, forKey: .projects)
- try container.encodeIfPresent(self.customApiKeyResponses, forKey: .customApiKeyResponses)
- try container.encodeIfPresent(self.preferences, forKey: .preferences)
- try container.encodeIfPresent(self.mcpServers, forKey: .mcpServers)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case projects
- case customApiKeyResponses
- case preferences
- case mcpServers
- }
-
- private static let knownKeys: Set = [
- "projects", "customApiKeyResponses", "preferences", "mcpServers",
- ]
-}
diff --git a/Fig/Sources/Models/MCPConfig.swift b/Fig/Sources/Models/MCPConfig.swift
deleted file mode 100644
index 0b91f0c..0000000
--- a/Fig/Sources/Models/MCPConfig.swift
+++ /dev/null
@@ -1,90 +0,0 @@
-import Foundation
-
-/// Top-level configuration for MCP servers, typically stored in `.mcp.json`.
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "mcpServers": {
-/// "github": {
-/// "command": "npx",
-/// "args": ["-y", "@modelcontextprotocol/server-github"],
-/// "env": { "GITHUB_TOKEN": "..." }
-/// },
-/// "remote-api": {
-/// "type": "http",
-/// "url": "https://mcp.example.com/api",
-/// "headers": { "Authorization": "Bearer ..." }
-/// }
-/// }
-/// }
-/// ```
-public struct MCPConfig: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- mcpServers: [String: MCPServer]? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.mcpServers = mcpServers
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.mcpServers = try container.decodeIfPresent([String: MCPServer].self, forKey: .mcpServers)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// Dictionary of MCP server configurations keyed by server name.
- public var mcpServers: [String: MCPServer]?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- /// Returns an array of all server names.
- public var serverNames: [String] {
- self.mcpServers?.keys.sorted() ?? []
- }
-
- /// Returns the server configuration for the given name.
- public func server(named name: String) -> MCPServer? {
- self.mcpServers?[name]
- }
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.mcpServers, forKey: .mcpServers)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case mcpServers
- }
-
- private static let knownKeys: Set = ["mcpServers"]
-}
diff --git a/Fig/Sources/Models/MCPServer.swift b/Fig/Sources/Models/MCPServer.swift
deleted file mode 100644
index c7f9e73..0000000
--- a/Fig/Sources/Models/MCPServer.swift
+++ /dev/null
@@ -1,155 +0,0 @@
-import Foundation
-
-/// Configuration for a Model Context Protocol (MCP) server.
-///
-/// MCP servers can be either stdio-based (using command/args) or HTTP-based (using url).
-///
-/// Stdio server example:
-/// ```json
-/// {
-/// "command": "npx",
-/// "args": ["-y", "@modelcontextprotocol/server-github"],
-/// "env": { "GITHUB_TOKEN": "..." }
-/// }
-/// ```
-///
-/// HTTP server example:
-/// ```json
-/// {
-/// "type": "http",
-/// "url": "https://mcp.example.com/api",
-/// "headers": { "Authorization": "Bearer ..." }
-/// }
-/// ```
-public struct MCPServer: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- command: String? = nil,
- args: [String]? = nil,
- env: [String: String]? = nil,
- type: String? = nil,
- url: String? = nil,
- headers: [String: String]? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.command = command
- self.args = args
- self.env = env
- self.type = type
- self.url = url
- self.headers = headers
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.command = try container.decodeIfPresent(String.self, forKey: .command)
- self.args = try container.decodeIfPresent([String].self, forKey: .args)
- self.env = try container.decodeIfPresent([String: String].self, forKey: .env)
- self.type = try container.decodeIfPresent(String.self, forKey: .type)
- self.url = try container.decodeIfPresent(String.self, forKey: .url)
- self.headers = try container.decodeIfPresent([String: String].self, forKey: .headers)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- // MARK: - Stdio Server Properties
-
- /// The command to execute for stdio servers.
- public var command: String?
-
- /// Arguments to pass to the command.
- public var args: [String]?
-
- /// Environment variables for the server process.
- public var env: [String: String]?
-
- // MARK: - HTTP Server Properties
-
- /// Server type (e.g., "http" for HTTP servers, nil for stdio).
- public var type: String?
-
- /// URL for HTTP-based MCP servers.
- public var url: String?
-
- /// HTTP headers for authentication or other purposes.
- public var headers: [String: String]?
-
- // MARK: - Additional Properties
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- /// Whether this server uses stdio transport.
- public var isStdio: Bool {
- self.command != nil && self.type != "http"
- }
-
- /// Whether this server uses HTTP transport.
- public var isHTTP: Bool {
- self.type == "http" && self.url != nil
- }
-
- /// Creates a stdio-based MCP server configuration.
- public static func stdio(
- command: String,
- args: [String]? = nil,
- env: [String: String]? = nil
- ) -> MCPServer {
- MCPServer(command: command, args: args, env: env)
- }
-
- /// Creates an HTTP-based MCP server configuration.
- public static func http(
- url: String,
- headers: [String: String]? = nil
- ) -> MCPServer {
- MCPServer(type: "http", url: url, headers: headers)
- }
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.command, forKey: .command)
- try container.encodeIfPresent(self.args, forKey: .args)
- try container.encodeIfPresent(self.env, forKey: .env)
- try container.encodeIfPresent(self.type, forKey: .type)
- try container.encodeIfPresent(self.url, forKey: .url)
- try container.encodeIfPresent(self.headers, forKey: .headers)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case command
- case args
- case env
- case type
- case url
- case headers
- }
-
- private static let knownKeys: Set = ["command", "args", "env", "type", "url", "headers"]
-}
diff --git a/Fig/Sources/Models/MCPServerFormData.swift b/Fig/Sources/Models/MCPServerFormData.swift
deleted file mode 100644
index 3315316..0000000
--- a/Fig/Sources/Models/MCPServerFormData.swift
+++ /dev/null
@@ -1,410 +0,0 @@
-import Foundation
-
-// MARK: - MCPServerType
-
-/// Type of MCP server transport.
-enum MCPServerType: String, CaseIterable, Identifiable, Sendable {
- case stdio
- case http
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var displayName: String {
- switch self {
- case .stdio: "Stdio (Command)"
- case .http: "HTTP (URL)"
- }
- }
-
- var icon: String {
- switch self {
- case .stdio: "terminal"
- case .http: "globe"
- }
- }
-}
-
-// MARK: - MCPServerScope
-
-/// Target scope for saving an MCP server configuration.
-enum MCPServerScope: String, CaseIterable, Identifiable, Sendable {
- case project
- case global
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var displayName: String {
- switch self {
- case .project: "Project (.mcp.json)"
- case .global: "Global (~/.claude.json)"
- }
- }
-
- var icon: String {
- switch self {
- case .project: "folder"
- case .global: "globe"
- }
- }
-}
-
-// MARK: - KeyValuePair
-
-/// A key-value pair for environment variables or headers.
-struct KeyValuePair: Identifiable, Equatable, Sendable {
- // MARK: Lifecycle
-
- init(id: UUID = UUID(), key: String = "", value: String = "") {
- self.id = id
- self.key = key
- self.value = value
- }
-
- // MARK: Internal
-
- let id: UUID
- var key: String
- var value: String
-
- /// Whether this pair has valid content.
- var isValid: Bool {
- !self.key.trimmingCharacters(in: .whitespaces).isEmpty
- }
-}
-
-// MARK: - MCPValidationError
-
-/// Validation error for MCP server form.
-struct MCPValidationError: Identifiable, Equatable, Sendable {
- // MARK: Lifecycle
-
- init(field: String, message: String) {
- self.id = UUID()
- self.field = field
- self.message = message
- }
-
- // MARK: Internal
-
- let id: UUID
- let field: String
- let message: String
-}
-
-// MARK: - MCPServerFormData
-
-/// Observable form state for creating or editing an MCP server.
-@MainActor
-@Observable
-final class MCPServerFormData {
- // MARK: Lifecycle
-
- init(
- name: String = "",
- serverType: MCPServerType = .stdio,
- scope: MCPServerScope = .project,
- command: String = "",
- args: [String] = [],
- envVars: [KeyValuePair] = [],
- url: String = "",
- headers: [KeyValuePair] = [],
- isEditing: Bool = false,
- originalName: String? = nil
- ) {
- self.name = name
- self.serverType = serverType
- self.scope = scope
- self.command = command
- self.args = args
- self.envVars = envVars
- self.url = url
- self.headers = headers
- self.isEditing = isEditing
- self.originalName = originalName
- }
-
- // MARK: Internal
-
- /// Server name (key in the dictionary).
- var name: String
-
- /// Type of server (stdio or http).
- var serverType: MCPServerType
-
- /// Target scope for saving.
- var scope: MCPServerScope
-
- /// Command to execute for stdio servers.
- var command: String
-
- /// Arguments for the command.
- var args: [String]
-
- /// Environment variables.
- var envVars: [KeyValuePair]
-
- /// URL for HTTP servers.
- var url: String
-
- /// HTTP headers.
- var headers: [KeyValuePair]
-
- // MARK: - Editing State
-
- /// Whether we're editing an existing server.
- var isEditing: Bool
-
- /// Original name when editing (for rename detection).
- var originalName: String?
-
- /// Input for adding a new argument.
- var newArgInput: String = ""
-
- /// Whether the form has valid data to save.
- var isValid: Bool {
- self.validate(existingNames: []).isEmpty
- }
-
- // MARK: - Factory Methods
-
- /// Creates form data from an existing MCP server for editing.
- static func from(
- name: String,
- server: MCPServer,
- scope: MCPServerScope
- ) -> MCPServerFormData {
- let formData = MCPServerFormData(
- name: name,
- serverType: server.isHTTP ? .http : .stdio,
- scope: scope,
- command: server.command ?? "",
- args: server.args ?? [],
- envVars: (server.env ?? [:]).map { KeyValuePair(key: $0.key, value: $0.value) }
- .sorted { $0.key < $1.key },
- url: server.url ?? "",
- headers: (server.headers ?? [:]).map { KeyValuePair(key: $0.key, value: $0.value) }
- .sorted { $0.key < $1.key },
- isEditing: true,
- originalName: name
- )
- return formData
- }
-
- // MARK: - Validation
-
- /// Validates the form data and returns any errors.
- func validate(existingNames: Set) -> [MCPValidationError] {
- var errors: [MCPValidationError] = []
-
- // Name validation
- let trimmedName = self.name.trimmingCharacters(in: .whitespaces)
- if trimmedName.isEmpty {
- errors.append(MCPValidationError(field: "name", message: "Server name is required"))
- } else if !self.isValidServerName(trimmedName) {
- errors.append(MCPValidationError(
- field: "name",
- message: "Name can only contain letters, numbers, hyphens, and underscores"
- ))
- } else if existingNames.contains(trimmedName) {
- // Only check for duplicates if not editing or if name changed
- if !self.isEditing || (self.originalName != trimmedName) {
- errors.append(MCPValidationError(field: "name", message: "A server with this name already exists"))
- }
- }
-
- // Type-specific validation
- switch self.serverType {
- case .stdio:
- if self.command.trimmingCharacters(in: .whitespaces).isEmpty {
- errors.append(MCPValidationError(field: "command", message: "Command is required"))
- }
- case .http:
- let trimmedURL = self.url.trimmingCharacters(in: .whitespaces)
- if trimmedURL.isEmpty {
- errors.append(MCPValidationError(field: "url", message: "URL is required"))
- } else if !self.isValidURL(trimmedURL) {
- errors.append(MCPValidationError(field: "url", message: "URL must start with http:// or https://"))
- }
- }
-
- return errors
- }
-
- /// Converts form data to an MCPServer model.
- func toMCPServer() -> MCPServer {
- switch self.serverType {
- case .stdio:
- let envDict = self.envVars
- .filter(\.isValid)
- .reduce(into: [String: String]()) { dict, pair in
- dict[pair.key] = pair.value
- }
- return MCPServer.stdio(
- command: self.command.trimmingCharacters(in: .whitespaces),
- args: self.args.isEmpty ? nil : self.args,
- env: envDict.isEmpty ? nil : envDict
- )
- case .http:
- let headersDict = self.headers
- .filter(\.isValid)
- .reduce(into: [String: String]()) { dict, pair in
- dict[pair.key] = pair.value
- }
- return MCPServer.http(
- url: self.url.trimmingCharacters(in: .whitespaces),
- headers: headersDict.isEmpty ? nil : headersDict
- )
- }
- }
-
- // MARK: - Argument Management
-
- /// Adds a new argument.
- func addArg(_ arg: String) {
- let trimmed = arg.trimmingCharacters(in: .whitespaces)
- guard !trimmed.isEmpty else {
- return
- }
- self.args.append(trimmed)
- }
-
- /// Removes an argument at the specified index.
- func removeArg(at index: Int) {
- guard self.args.indices.contains(index) else {
- return
- }
- self.args.remove(at: index)
- }
-
- // MARK: - Environment Variable Management
-
- /// Adds an empty environment variable row.
- func addEnvVar() {
- self.envVars.append(KeyValuePair())
- }
-
- /// Removes an environment variable at the specified index.
- func removeEnvVar(at index: Int) {
- guard self.envVars.indices.contains(index) else {
- return
- }
- self.envVars.remove(at: index)
- }
-
- // MARK: - Header Management
-
- /// Adds an empty header row.
- func addHeader() {
- self.headers.append(KeyValuePair())
- }
-
- /// Removes a header at the specified index.
- func removeHeader(at index: Int) {
- guard self.headers.indices.contains(index) else {
- return
- }
- self.headers.remove(at: index)
- }
-
- // MARK: - Import Methods
-
- /// Parses JSON and populates form data.
- func parseFromJSON(_ json: String) throws {
- let data = Data(json.utf8)
- let decoder = JSONDecoder()
-
- // Try parsing as MCPServer directly
- if let server = try? decoder.decode(MCPServer.self, from: data) {
- self.populateFrom(server: server)
- return
- }
-
- // Try parsing as a named server { "name": { ...config } }
- if let dict = try? decoder.decode([String: MCPServer].self, from: data),
- let (serverName, server) = dict.first
- {
- self.name = serverName
- self.populateFrom(server: server)
- return
- }
-
- throw MCPParseError.invalidJSON("Could not parse JSON as MCP server configuration")
- }
-
- /// Parses a CLI command and populates form data.
- /// Supports: claude mcp add-json 'name' '{"command": ...}'
- func parseFromCLICommand(_ command: String) throws {
- // Pattern: claude mcp add-json 'name' '{...json...}'
- // or: claude mcp add-json "name" "{...json...}"
- let pattern = #"claude\s+mcp\s+add-json\s+['\"]?(\w[\w-]*)['\"]?\s+['\"]?(\{.+\})['\"]?"#
-
- guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]),
- let match = regex.firstMatch(
- in: command,
- options: [],
- range: NSRange(command.startIndex..., in: command)
- ),
- let nameRange = Range(match.range(at: 1), in: command),
- let jsonRange = Range(match.range(at: 2), in: command)
- else {
- throw MCPParseError
- .invalidCLICommand("Could not parse CLI command. Expected: claude mcp add-json 'name' '{...}'")
- }
-
- self.name = String(command[nameRange])
- let jsonString = String(command[jsonRange])
-
- try parseFromJSON(jsonString)
- }
-
- // MARK: Private
-
- private func isValidServerName(_ name: String) -> Bool {
- let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
- return name.unicodeScalars.allSatisfy { allowedCharacters.contains($0) }
- }
-
- private func isValidURL(_ urlString: String) -> Bool {
- urlString.hasPrefix("http://") || urlString.hasPrefix("https://")
- }
-
- private func populateFrom(server: MCPServer) {
- if server.isHTTP {
- self.serverType = .http
- self.url = server.url ?? ""
- self.headers = (server.headers ?? [:]).map { KeyValuePair(key: $0.key, value: $0.value) }
- .sorted { $0.key < $1.key }
- } else {
- self.serverType = .stdio
- self.command = server.command ?? ""
- self.args = server.args ?? []
- self.envVars = (server.env ?? [:]).map { KeyValuePair(key: $0.key, value: $0.value) }
- .sorted { $0.key < $1.key }
- }
- }
-}
-
-// MARK: - MCPParseError
-
-/// Errors that can occur when parsing MCP server configurations.
-enum MCPParseError: Error, LocalizedError {
- case invalidJSON(String)
- case invalidCLICommand(String)
-
- // MARK: Internal
-
- var errorDescription: String? {
- switch self {
- case let .invalidJSON(message): message
- case let .invalidCLICommand(message): message
- }
- }
-}
diff --git a/Fig/Sources/Models/MergedSettings.swift b/Fig/Sources/Models/MergedSettings.swift
deleted file mode 100644
index f527c4f..0000000
--- a/Fig/Sources/Models/MergedSettings.swift
+++ /dev/null
@@ -1,174 +0,0 @@
-import Foundation
-
-// MARK: - MergedValue
-
-/// Wraps a value with its source configuration file.
-///
-/// Used to track where each merged configuration value originated from.
-///
-/// Example:
-/// ```swift
-/// let attribution = MergedValue(
-/// value: Attribution(commits: true),
-/// source: .projectLocal
-/// )
-/// print(attribution.source.displayName) // "Local"
-/// ```
-public struct MergedValue: Sendable {
- // MARK: Lifecycle
-
- public init(value: T, source: ConfigSource) {
- self.value = value
- self.source = source
- }
-
- // MARK: Public
-
- /// The actual configuration value.
- public let value: T
-
- /// The source file where this value originated.
- public let source: ConfigSource
-}
-
-// MARK: Equatable
-
-extension MergedValue: Equatable where T: Equatable {}
-
-// MARK: Hashable
-
-extension MergedValue: Hashable where T: Hashable {}
-
-// MARK: - MergedPermissions
-
-/// Represents merged permission rules with source tracking for each entry.
-///
-/// Permission arrays are unioned across all sources, with each entry
-/// tracking which configuration file it came from.
-public struct MergedPermissions: Sendable, Equatable, Hashable {
- // MARK: Lifecycle
-
- public init(
- allow: [MergedValue] = [],
- deny: [MergedValue] = []
- ) {
- self.allow = allow
- self.deny = deny
- }
-
- // MARK: Public
-
- /// Allowed permission patterns with their sources.
- public let allow: [MergedValue]
-
- /// Denied permission patterns with their sources.
- public let deny: [MergedValue]
-
- /// Returns all unique allow patterns (without source tracking).
- public var allowPatterns: [String] {
- self.allow.map(\.value)
- }
-
- /// Returns all unique deny patterns (without source tracking).
- public var denyPatterns: [String] {
- self.deny.map(\.value)
- }
-}
-
-// MARK: - MergedHooks
-
-/// Represents merged hook configurations with source tracking.
-///
-/// Hooks are merged by event type, with hook arrays concatenated
-/// from all sources (lower precedence first).
-public struct MergedHooks: Sendable, Equatable, Hashable {
- // MARK: Lifecycle
-
- public init(hooks: [String: [MergedValue]] = [:]) {
- self.hooks = hooks
- }
-
- // MARK: Public
-
- /// Hook groups keyed by event name, with source tracking.
- public let hooks: [String: [MergedValue]]
-
- /// Returns all event names that have hooks configured.
- public var eventNames: [String] {
- self.hooks.keys.sorted()
- }
-
- /// Returns hook groups for a specific event.
- public func groups(for event: String) -> [MergedValue]? {
- self.hooks[event]
- }
-}
-
-// MARK: - MergedSettings
-
-/// The effective configuration for a project after merging all applicable settings files.
-///
-/// Merge precedence (highest wins):
-/// 1. Project local (`.claude/settings.local.json`)
-/// 2. Project shared (`.claude/settings.json`)
-/// 3. User global (`~/.claude/settings.json`)
-///
-/// Merge semantics:
-/// - `permissions.allow` and `permissions.deny`: union of all arrays
-/// - `env`: higher-precedence keys override lower
-/// - `hooks`: merge by hook type, concatenate hook arrays
-/// - Scalar values (attribution, etc.): higher precedence wins
-public struct MergedSettings: Sendable, Equatable, Hashable {
- // MARK: Lifecycle
-
- public init(
- permissions: MergedPermissions = MergedPermissions(),
- env: [String: MergedValue] = [:],
- hooks: MergedHooks = MergedHooks(),
- disallowedTools: [MergedValue] = [],
- attribution: MergedValue? = nil
- ) {
- self.permissions = permissions
- self.env = env
- self.hooks = hooks
- self.disallowedTools = disallowedTools
- self.attribution = attribution
- }
-
- // MARK: Public
-
- /// Merged permission rules (unioned from all sources).
- public let permissions: MergedPermissions
-
- /// Merged environment variables (higher precedence overrides).
- public let env: [String: MergedValue]
-
- /// Merged hook configurations (concatenated by event type).
- public let hooks: MergedHooks
-
- /// Merged disallowed tools (unioned from all sources).
- public let disallowedTools: [MergedValue]
-
- /// Merged attribution settings (highest precedence wins).
- public let attribution: MergedValue?
-
- /// Returns the effective environment variables without source tracking.
- public var effectiveEnv: [String: String] {
- self.env.mapValues(\.value)
- }
-
- /// Returns the effective disallowed tools without source tracking.
- public var effectiveDisallowedTools: [String] {
- self.disallowedTools.map(\.value)
- }
-
- /// Checks if a specific tool is disallowed.
- public func isToolDisallowed(_ toolName: String) -> Bool {
- self.effectiveDisallowedTools.contains(toolName)
- }
-
- /// Returns the source for a specific environment variable.
- public func envSource(for key: String) -> ConfigSource? {
- self.env[key]?.source
- }
-}
diff --git a/Fig/Sources/Models/NavigationSelection.swift b/Fig/Sources/Models/NavigationSelection.swift
deleted file mode 100644
index 0b19a93..0000000
--- a/Fig/Sources/Models/NavigationSelection.swift
+++ /dev/null
@@ -1,28 +0,0 @@
-import Foundation
-
-/// Represents the current selection in the sidebar navigation.
-enum NavigationSelection: Hashable, Sendable {
- /// Global settings view (~/.claude/settings.json).
- case globalSettings
-
- /// A specific project selected by its path.
- case project(String)
-
- // MARK: Internal
-
- /// Returns the project path if this is a project selection.
- var projectPath: String? {
- if case let .project(path) = self {
- return path
- }
- return nil
- }
-
- /// Whether this selection is for global settings.
- var isGlobalSettings: Bool {
- if case .globalSettings = self {
- return true
- }
- return false
- }
-}
diff --git a/Fig/Sources/Models/Permissions.swift b/Fig/Sources/Models/Permissions.swift
deleted file mode 100644
index 4cc9cbb..0000000
--- a/Fig/Sources/Models/Permissions.swift
+++ /dev/null
@@ -1,81 +0,0 @@
-import Foundation
-
-/// Represents Claude Code's permission configuration with allow/deny rule arrays.
-///
-/// Permissions control which tools and operations Claude Code can perform.
-/// Rules use patterns like `"Bash(npm run *)"` or `"Read(src/**)"`.
-///
-/// Example JSON:
-/// ```json
-/// {
-/// "allow": ["Bash(npm run *)", "Read(src/**)"],
-/// "deny": ["Read(.env)", "Bash(curl *)"]
-/// }
-/// ```
-public struct Permissions: Codable, Equatable, Hashable, Sendable {
- // MARK: Lifecycle
-
- public init(
- allow: [String]? = nil,
- deny: [String]? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.allow = allow
- self.deny = deny
- self.additionalProperties = additionalProperties
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.allow = try container.decodeIfPresent([String].self, forKey: .allow)
- self.deny = try container.decodeIfPresent([String].self, forKey: .deny)
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// Array of allowed permission patterns.
- public var allow: [String]?
-
- /// Array of denied permission patterns.
- public var deny: [String]?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.allow, forKey: .allow)
- try container.encodeIfPresent(self.deny, forKey: .deny)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case allow
- case deny
- }
-
- private static let knownKeys: Set = ["allow", "deny"]
-}
diff --git a/Fig/Sources/Models/ProjectEntry.swift b/Fig/Sources/Models/ProjectEntry.swift
deleted file mode 100644
index b29b5b1..0000000
--- a/Fig/Sources/Models/ProjectEntry.swift
+++ /dev/null
@@ -1,165 +0,0 @@
-import Foundation
-
-// MARK: - ProjectEntry
-
-/// Represents a project discovered from LegacyConfig or filesystem scan.
-///
-/// Projects are directories that have been used with Claude Code,
-/// typically tracked in `~/.claude.json`.
-///
-/// Example JSON (from projects dictionary):
-/// ```json
-/// {
-/// "allowedTools": ["Bash", "Read", "Write"],
-/// "hasTrustDialogAccepted": true,
-/// "history": ["conversation-1", "conversation-2"],
-/// "mcpServers": { ... }
-/// }
-/// ```
-public struct ProjectEntry: Codable, Sendable, Identifiable {
- // MARK: Lifecycle
-
- public init(
- path: String? = nil,
- allowedTools: [String]? = nil,
- hasTrustDialogAccepted: Bool? = nil,
- history: [String]? = nil,
- mcpServers: [String: MCPServer]? = nil,
- additionalProperties: [String: AnyCodable]? = nil
- ) {
- self.path = path
- self.allowedTools = allowedTools
- self.hasTrustDialogAccepted = hasTrustDialogAccepted
- self.history = history
- self.mcpServers = mcpServers
- self.additionalProperties = additionalProperties
- self.fallbackID = UUID().uuidString
- }
-
- public init(from decoder: Decoder) throws {
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.path = try container.decodeIfPresent(String.self, forKey: .path)
- self.allowedTools = try container.decodeIfPresent([String].self, forKey: .allowedTools)
- self.hasTrustDialogAccepted = try container.decodeIfPresent(Bool.self, forKey: .hasTrustDialogAccepted)
- self.history = try container.decodeIfPresent([String].self, forKey: .history)
- self.mcpServers = try container.decodeIfPresent([String: MCPServer].self, forKey: .mcpServers)
- self.fallbackID = UUID().uuidString
-
- // Capture unknown keys
- let allKeysContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
- var additional: [String: AnyCodable] = [:]
-
- for key in allKeysContainer.allKeys {
- if !Self.knownKeys.contains(key.stringValue) {
- additional[key.stringValue] = try allKeysContainer.decode(AnyCodable.self, forKey: key)
- }
- }
-
- self.additionalProperties = additional.isEmpty ? nil : additional
- }
-
- // MARK: Public
-
- /// The file path to the project directory.
- /// This is typically the dictionary key in LegacyConfig, stored here for convenience.
- public var path: String?
-
- /// List of tools that are allowed for this project.
- public var allowedTools: [String]?
-
- /// Whether the user has accepted the trust dialog for this project.
- public var hasTrustDialogAccepted: Bool?
-
- /// Conversation history identifiers.
- public var history: [String]?
-
- /// Project-specific MCP server configurations.
- public var mcpServers: [String: MCPServer]?
-
- /// Additional properties not explicitly modeled, preserved during round-trip.
- public var additionalProperties: [String: AnyCodable]?
-
- public var id: String {
- self.path ?? self.fallbackID
- }
-
- /// The project name derived from the path.
- public var name: String? {
- guard let path else {
- return nil
- }
- return URL(fileURLWithPath: path).lastPathComponent
- }
-
- /// Whether this project has any MCP servers configured.
- public var hasMCPServers: Bool {
- guard let servers = mcpServers else {
- return false
- }
- return !servers.isEmpty
- }
-
- public func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encodeIfPresent(self.path, forKey: .path)
- try container.encodeIfPresent(self.allowedTools, forKey: .allowedTools)
- try container.encodeIfPresent(self.hasTrustDialogAccepted, forKey: .hasTrustDialogAccepted)
- try container.encodeIfPresent(self.history, forKey: .history)
- try container.encodeIfPresent(self.mcpServers, forKey: .mcpServers)
-
- // Encode additional properties
- if let additionalProperties {
- var additionalContainer = encoder.container(keyedBy: DynamicCodingKey.self)
- for (key, value) in additionalProperties {
- try additionalContainer.encode(value, forKey: DynamicCodingKey(stringValue: key))
- }
- }
- }
-
- // MARK: Private
-
- // MARK: - Codable
-
- private enum CodingKeys: String, CodingKey {
- case path
- case allowedTools
- case hasTrustDialogAccepted
- case history
- case mcpServers
- }
-
- private static let knownKeys: Set = [
- "path", "allowedTools", "hasTrustDialogAccepted", "history", "mcpServers",
- ]
-
- /// Fallback identifier used when `path` is nil, ensuring stable identity for SwiftUI.
- private let fallbackID: String
-}
-
-// MARK: Equatable
-
-extension ProjectEntry: Equatable {
- public static func == (lhs: ProjectEntry, rhs: ProjectEntry) -> Bool {
- // Exclude fallbackID from equality comparison
- lhs.path == rhs.path &&
- lhs.allowedTools == rhs.allowedTools &&
- lhs.hasTrustDialogAccepted == rhs.hasTrustDialogAccepted &&
- lhs.history == rhs.history &&
- lhs.mcpServers == rhs.mcpServers &&
- lhs.additionalProperties == rhs.additionalProperties
- }
-}
-
-// MARK: Hashable
-
-extension ProjectEntry: Hashable {
- public func hash(into hasher: inout Hasher) {
- // Exclude fallbackID from hash computation
- hasher.combine(self.path)
- hasher.combine(self.allowedTools)
- hasher.combine(self.hasTrustDialogAccepted)
- hasher.combine(self.history)
- hasher.combine(self.mcpServers)
- hasher.combine(self.additionalProperties)
- }
-}
diff --git a/Fig/Sources/Models/ProjectGroup.swift b/Fig/Sources/Models/ProjectGroup.swift
deleted file mode 100644
index 87fab68..0000000
--- a/Fig/Sources/Models/ProjectGroup.swift
+++ /dev/null
@@ -1,21 +0,0 @@
-import Foundation
-
-// MARK: - ProjectGroup
-
-/// A group of projects sharing the same parent directory.
-///
-/// Used to visually organize projects in the sidebar when grouping
-/// by parent directory is enabled. This reduces noise from git worktrees
-/// and similar directory structures where many projects share a common parent.
-struct ProjectGroup: Identifiable, Sendable {
- /// The full path to the parent directory.
- let parentPath: String
-
- /// Display-friendly name (abbreviated with ~).
- let displayName: String
-
- /// Projects in this group, sorted by name.
- let projects: [ProjectEntry]
-
- var id: String { self.parentPath }
-}
diff --git a/Fig/Sources/Models/SettingsEditorTypes.swift b/Fig/Sources/Models/SettingsEditorTypes.swift
deleted file mode 100644
index 73b7de6..0000000
--- a/Fig/Sources/Models/SettingsEditorTypes.swift
+++ /dev/null
@@ -1,540 +0,0 @@
-import Foundation
-import SwiftUI
-
-// MARK: - EditablePermissionRule
-
-/// A permission rule with editing metadata.
-struct EditablePermissionRule: Identifiable, Equatable, Hashable {
- // MARK: Lifecycle
-
- init(id: UUID = UUID(), rule: String, type: PermissionType) {
- self.id = id
- self.rule = rule
- self.type = type
- }
-
- // MARK: Internal
-
- let id: UUID
- var rule: String
- var type: PermissionType
-}
-
-// MARK: - EditableEnvironmentVariable
-
-/// An environment variable with editing metadata.
-struct EditableEnvironmentVariable: Identifiable, Equatable, Hashable {
- // MARK: Lifecycle
-
- init(id: UUID = UUID(), key: String, value: String) {
- self.id = id
- self.key = key
- self.value = value
- }
-
- // MARK: Internal
-
- let id: UUID
- var key: String
- var value: String
-}
-
-// MARK: - KnownEnvironmentVariable
-
-/// Known Claude Code environment variables with descriptions.
-struct KnownEnvironmentVariable: Identifiable {
- static let allVariables: [KnownEnvironmentVariable] = [
- KnownEnvironmentVariable(
- id: "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
- name: "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
- description: "Maximum tokens in Claude's response",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "BASH_DEFAULT_TIMEOUT_MS",
- name: "BASH_DEFAULT_TIMEOUT_MS",
- description: "Default timeout for bash commands in milliseconds",
- defaultValue: "120000"
- ),
- KnownEnvironmentVariable(
- id: "CLAUDE_CODE_ENABLE_TELEMETRY",
- name: "CLAUDE_CODE_ENABLE_TELEMETRY",
- description: "Enable/disable telemetry (0 or 1)",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "OTEL_METRICS_EXPORTER",
- name: "OTEL_METRICS_EXPORTER",
- description: "OpenTelemetry metrics exporter configuration",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "DISABLE_TELEMETRY",
- name: "DISABLE_TELEMETRY",
- description: "Disable all telemetry (0 or 1)",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
- name: "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
- description: "Reduce network calls by disabling non-essential traffic",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "ANTHROPIC_MODEL",
- name: "ANTHROPIC_MODEL",
- description: "Override the default model used by Claude Code",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "ANTHROPIC_DEFAULT_SONNET_MODEL",
- name: "ANTHROPIC_DEFAULT_SONNET_MODEL",
- description: "Default Sonnet model to use",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "ANTHROPIC_DEFAULT_OPUS_MODEL",
- name: "ANTHROPIC_DEFAULT_OPUS_MODEL",
- description: "Default Opus model to use",
- defaultValue: nil
- ),
- KnownEnvironmentVariable(
- id: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
- name: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
- description: "Default Haiku model to use",
- defaultValue: nil
- ),
- ]
-
- let id: String
- let name: String
- let description: String
- let defaultValue: String?
-
- static func description(for key: String) -> String? {
- self.allVariables.first { $0.name == key }?.description
- }
-}
-
-// MARK: - PermissionPreset
-
-/// Quick-add presets for common permission patterns.
-struct PermissionPreset: Identifiable {
- static let allPresets: [PermissionPreset] = [
- PermissionPreset(
- id: "protect-env",
- name: "Protect .env files",
- description: "Prevent reading environment files",
- rules: [
- ("Read(.env)", .deny),
- ("Read(.env.*)", .deny),
- ]
- ),
- PermissionPreset(
- id: "allow-npm",
- name: "Allow npm scripts",
- description: "Allow running npm scripts",
- rules: [
- ("Bash(npm run *)", .allow),
- ]
- ),
- PermissionPreset(
- id: "allow-git",
- name: "Allow git operations",
- description: "Allow running git commands",
- rules: [
- ("Bash(git *)", .allow),
- ]
- ),
- PermissionPreset(
- id: "read-only",
- name: "Read-only mode",
- description: "Deny all write and edit operations",
- rules: [
- ("Write", .deny),
- ("Edit", .deny),
- ]
- ),
- PermissionPreset(
- id: "allow-read-src",
- name: "Allow reading source",
- description: "Allow reading all files in src directory",
- rules: [
- ("Read(src/**)", .allow),
- ]
- ),
- PermissionPreset(
- id: "deny-curl",
- name: "Block curl commands",
- description: "Prevent curl network requests",
- rules: [
- ("Bash(curl *)", .deny),
- ]
- ),
- ]
-
- let id: String
- let name: String
- let description: String
- let rules: [(rule: String, type: PermissionType)]
-}
-
-// MARK: - ToolType
-
-/// Known tool types for permission rules.
-enum ToolType: String, CaseIterable, Identifiable {
- case bash = "Bash"
- case read = "Read"
- case write = "Write"
- case edit = "Edit"
- case grep = "Grep"
- case glob = "Glob"
- case webFetch = "WebFetch"
- case notebook = "Notebook"
- case custom = "Custom"
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var placeholder: String {
- switch self {
- case .bash:
- "npm run *, git *, etc."
- case .read:
- "src/**, .env, config/*.json"
- case .write:
- "*.log, temp/*, dist/**"
- case .edit:
- "src/**/*.ts, package.json"
- case .grep:
- "*.ts, src/**"
- case .glob:
- "**/*.test.ts"
- case .webFetch:
- "https://api.example.com/*"
- case .notebook:
- "*.ipynb"
- case .custom:
- "Enter tool name..."
- }
- }
-
- var supportsPattern: Bool {
- true // All tools support patterns
- }
-}
-
-// MARK: - EditingTarget
-
-/// Target file for saving edited settings.
-enum EditingTarget: String, CaseIterable, Identifiable {
- case global
- case projectShared
- case projectLocal
-
- // MARK: Internal
-
- /// Targets available when editing project settings.
- static var projectTargets: [EditingTarget] {
- [.projectShared, .projectLocal]
- }
-
- var id: String {
- rawValue
- }
-
- var label: String {
- switch self {
- case .global:
- "Global (settings.json)"
- case .projectShared:
- "Shared (settings.json)"
- case .projectLocal:
- "Local (settings.local.json)"
- }
- }
-
- var description: String {
- switch self {
- case .global:
- "Applies to all projects"
- case .projectShared:
- "Committed to git, shared with team"
- case .projectLocal:
- "Git-ignored, local overrides"
- }
- }
-
- var source: ConfigSource {
- switch self {
- case .global:
- .global
- case .projectShared:
- .projectShared
- case .projectLocal:
- .projectLocal
- }
- }
-}
-
-// MARK: - EditableHookDefinition
-
-/// A hook definition with editing metadata.
-struct EditableHookDefinition: Identifiable, Equatable, Hashable {
- // MARK: Lifecycle
-
- init(id: UUID = UUID(), type: String = "command", command: String = "") {
- self.id = id
- self.type = type
- self.command = command
- self.additionalProperties = nil
- }
-
- init(from definition: HookDefinition) {
- self.id = UUID()
- self.type = definition.type ?? "command"
- self.command = definition.command ?? ""
- self.additionalProperties = definition.additionalProperties
- }
-
- // MARK: Internal
-
- let id: UUID
- var type: String
- var command: String
- var additionalProperties: [String: AnyCodable]?
-
- func toHookDefinition() -> HookDefinition {
- HookDefinition(
- type: self.type,
- command: self.command.isEmpty ? nil : self.command,
- additionalProperties: self.additionalProperties
- )
- }
-}
-
-// MARK: - EditableHookGroup
-
-/// A hook group with editing metadata.
-struct EditableHookGroup: Identifiable, Equatable, Hashable {
- // MARK: Lifecycle
-
- init(id: UUID = UUID(), matcher: String = "", hooks: [EditableHookDefinition] = []) {
- self.id = id
- self.matcher = matcher
- self.hooks = hooks
- self.additionalProperties = nil
- }
-
- init(from group: HookGroup) {
- self.id = UUID()
- self.matcher = group.matcher ?? ""
- self.hooks = (group.hooks ?? []).map { EditableHookDefinition(from: $0) }
- self.additionalProperties = group.additionalProperties
- }
-
- // MARK: Internal
-
- let id: UUID
- var matcher: String
- var hooks: [EditableHookDefinition]
- var additionalProperties: [String: AnyCodable]?
-
- func toHookGroup() -> HookGroup {
- HookGroup(
- matcher: self.matcher.isEmpty ? nil : self.matcher,
- hooks: self.hooks.isEmpty ? nil : self.hooks.map { $0.toHookDefinition() },
- additionalProperties: self.additionalProperties
- )
- }
-}
-
-// MARK: - HookEvent
-
-/// Hook lifecycle event types.
-enum HookEvent: String, CaseIterable, Identifiable {
- case preToolUse = "PreToolUse"
- case postToolUse = "PostToolUse"
- case notification = "Notification"
- case stop = "Stop"
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var displayName: String {
- switch self {
- case .preToolUse: "Pre Tool Use"
- case .postToolUse: "Post Tool Use"
- case .notification: "Notification"
- case .stop: "Stop"
- }
- }
-
- var description: String {
- switch self {
- case .preToolUse: "Runs before a tool is executed"
- case .postToolUse: "Runs after a tool finishes executing"
- case .notification: "Runs when Claude sends notifications"
- case .stop: "Runs when Claude finishes responding"
- }
- }
-
- var icon: String {
- switch self {
- case .preToolUse: "arrow.right.circle"
- case .postToolUse: "arrow.left.circle"
- case .notification: "bell"
- case .stop: "stop.circle"
- }
- }
-
- var matcherPlaceholder: String {
- switch self {
- case .preToolUse: "Bash(*), Read(src/**), Write, etc."
- case .postToolUse: "Bash(*), Edit(*.py), etc."
- case .notification: "Optional pattern..."
- case .stop: "Optional pattern..."
- }
- }
-
- var supportsMatcher: Bool {
- switch self {
- case .preToolUse,
- .postToolUse: true
- case .notification,
- .stop: false
- }
- }
-
- var color: Color {
- switch self {
- case .preToolUse: .blue
- case .postToolUse: .green
- case .notification: .orange
- case .stop: .red
- }
- }
-}
-
-// MARK: - HookVariable
-
-/// Describes an environment variable available in hook scripts.
-struct HookVariable: Identifiable, Sendable {
- let name: String
- let description: String
- let events: [HookEvent]
-
- var id: String { name }
-}
-
-// MARK: - HookVariable + Catalog
-
-extension HookVariable {
- /// All available hook variables.
- static let all: [HookVariable] = [
- HookVariable(
- name: "$CLAUDE_TOOL_NAME",
- description: "Name of the tool being used",
- events: [.preToolUse, .postToolUse]
- ),
- HookVariable(
- name: "$CLAUDE_TOOL_INPUT",
- description: "JSON input to the tool",
- events: [.preToolUse, .postToolUse]
- ),
- HookVariable(
- name: "$CLAUDE_FILE_PATH",
- description: "File path affected (if applicable)",
- events: [.preToolUse, .postToolUse]
- ),
- HookVariable(
- name: "$CLAUDE_TOOL_OUTPUT",
- description: "Output from the tool",
- events: [.postToolUse]
- ),
- HookVariable(
- name: "$CLAUDE_NOTIFICATION",
- description: "Notification message",
- events: [.notification]
- ),
- ]
-}
-
-// MARK: - HookTemplate
-
-/// Quick-add templates for common hook configurations.
-struct HookTemplate: Identifiable {
- static let allTemplates: [HookTemplate] = [
- HookTemplate(
- id: "format-python",
- name: "Format Python on save",
- description: "Run black formatter after writing Python files",
- event: .postToolUse,
- matcher: "Write(*.py)",
- commands: ["black $CLAUDE_FILE_PATH"]
- ),
- HookTemplate(
- id: "lint-after-edit",
- name: "Run linter after edit",
- description: "Run ESLint after editing TypeScript files",
- event: .postToolUse,
- matcher: "Edit(*.ts)",
- commands: ["npx eslint --fix $CLAUDE_FILE_PATH"]
- ),
- HookTemplate(
- id: "notify-completion",
- name: "Notify on completion",
- description: "Send a system notification when Claude stops",
- event: .stop,
- matcher: nil,
- commands: [
- "osascript -e 'display notification \"Claude Code finished\" with title \"Fig\"'",
- ]
- ),
- HookTemplate(
- id: "pre-bash-guard",
- name: "Guard dangerous commands",
- description: "Log all bash commands before execution",
- event: .preToolUse,
- matcher: "Bash(*)",
- commands: ["echo \"Running: $CLAUDE_TOOL_INPUT\" >> ~/.claude/hook.log"]
- ),
- HookTemplate(
- id: "post-write-test",
- name: "Run tests after write",
- description: "Run tests after writing test files",
- event: .postToolUse,
- matcher: "Write(*test*)",
- commands: ["npm test"]
- ),
- HookTemplate(
- id: "format-swift",
- name: "Format Swift on save",
- description: "Run swift-format after writing Swift files",
- event: .postToolUse,
- matcher: "Write(*.swift)",
- commands: ["swift-format format -i $CLAUDE_FILE_PATH"]
- ),
- ]
-
- let id: String
- let name: String
- let description: String
- let event: HookEvent
- let matcher: String?
- let commands: [String]
-}
-
-// MARK: - ConflictResolution
-
-/// Options for resolving external file change conflicts.
-enum ConflictResolution {
- case keepLocal
- case useExternal
-}
diff --git a/Fig/Sources/Models/SidebarItem.swift b/Fig/Sources/Models/SidebarItem.swift
deleted file mode 100644
index 898b41d..0000000
--- a/Fig/Sources/Models/SidebarItem.swift
+++ /dev/null
@@ -1,47 +0,0 @@
-import Foundation
-
-/// Represents items displayed in the sidebar navigation.
-enum SidebarItem: String, CaseIterable, Identifiable, Sendable {
- case home
- case projects
- case settings
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var title: String {
- switch self {
- case .home:
- "Home"
- case .projects:
- "Projects"
- case .settings:
- "Settings"
- }
- }
-
- var icon: String {
- switch self {
- case .home:
- "house"
- case .projects:
- "folder"
- case .settings:
- "gear"
- }
- }
-
- var description: String {
- switch self {
- case .home:
- "Welcome to Fig. This is your home screen where you can see an overview of your activity."
- case .projects:
- "Manage and organize your projects. Create new projects or open existing ones."
- case .settings:
- "Configure application settings and preferences."
- }
- }
-}
diff --git a/Fig/Sources/Services/ConfigBundleService.swift b/Fig/Sources/Services/ConfigBundleService.swift
deleted file mode 100644
index 964c49d..0000000
--- a/Fig/Sources/Services/ConfigBundleService.swift
+++ /dev/null
@@ -1,377 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - ConfigBundleError
-
-/// Errors that can occur during bundle operations.
-enum ConfigBundleError: Error, LocalizedError {
- case invalidBundleFormat
- case unsupportedVersion(Int)
- case exportFailed(underlying: Error)
- case importFailed(underlying: Error)
- case noComponentsSelected
- case projectNotFound(String)
-
- // MARK: Internal
-
- var errorDescription: String? {
- switch self {
- case .invalidBundleFormat:
- "Invalid bundle format. The file may be corrupted or not a valid config bundle."
- case let .unsupportedVersion(version):
- "Unsupported bundle version (\(version)). Please update Fig to import this bundle."
- case let .exportFailed(error):
- "Export failed: \(error.localizedDescription)"
- case let .importFailed(error):
- "Import failed: \(error.localizedDescription)"
- case .noComponentsSelected:
- "No components selected for export."
- case let .projectNotFound(path):
- "Project not found at \(path)."
- }
- }
-}
-
-// MARK: - ConfigBundleService
-
-/// Service for exporting and importing project configuration bundles.
-actor ConfigBundleService {
- // MARK: Lifecycle
-
- private init() {}
-
- // MARK: Internal
-
- static let shared = ConfigBundleService()
-
- // MARK: - Export
-
- /// Exports a project's configuration to a bundle.
- func exportBundle(
- projectPath: URL,
- projectName: String,
- components: Set,
- configManager: ConfigFileManager = .shared
- ) async throws -> ConfigBundle {
- guard !components.isEmpty else {
- throw ConfigBundleError.noComponentsSelected
- }
-
- var bundle = ConfigBundle(projectName: projectName)
-
- // Export settings
- if components.contains(.settings) {
- bundle.settings = try await configManager.readProjectSettings(for: projectPath)
- }
-
- // Export local settings
- if components.contains(.localSettings) {
- bundle.localSettings = try await configManager.readProjectLocalSettings(for: projectPath)
- }
-
- // Export MCP servers
- if components.contains(.mcpServers) {
- bundle.mcpServers = try await configManager.readMCPConfig(for: projectPath)
- }
-
- Log.general.info("Exported bundle for '\(projectName)' with \(components.count) components")
-
- return bundle
- }
-
- /// Writes a bundle to a file.
- nonisolated func writeBundle(_ bundle: ConfigBundle, to url: URL) throws {
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- encoder.dateEncodingStrategy = .iso8601
-
- let data = try encoder.encode(bundle)
- try data.write(to: url)
-
- Log.general.info("Wrote bundle to \(url.path)")
- }
-
- // MARK: - Import
-
- /// Reads a bundle from a file.
- nonisolated func readBundle(from url: URL) throws -> ConfigBundle {
- let data = try Data(contentsOf: url)
-
- let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
-
- let bundle = try decoder.decode(ConfigBundle.self, from: data)
-
- // Validate version
- if bundle.version > ConfigBundle.currentVersion {
- throw ConfigBundleError.unsupportedVersion(bundle.version)
- }
-
- Log.general.info("Read bundle '\(bundle.projectName)' from \(url.path)")
-
- return bundle
- }
-
- /// Detects conflicts when importing a bundle to a project.
- func detectConflicts(
- bundle: ConfigBundle,
- projectPath: URL,
- components: Set,
- configManager: ConfigFileManager = .shared
- ) async -> [ImportConflict] {
- var conflicts: [ImportConflict] = []
-
- // Check settings conflict
- if components.contains(.settings), bundle.settings != nil {
- if let existing = try? await configManager.readProjectSettings(for: projectPath),
- existing != nil
- {
- conflicts.append(ImportConflict(
- component: .settings,
- description: "Project already has settings.json"
- ))
- }
- }
-
- // Check local settings conflict
- if components.contains(.localSettings), bundle.localSettings != nil {
- if let existing = try? await configManager.readProjectLocalSettings(for: projectPath),
- existing != nil
- {
- conflicts.append(ImportConflict(
- component: .localSettings,
- description: "Project already has settings.local.json"
- ))
- }
- }
-
- // Check MCP servers conflict
- if components.contains(.mcpServers), bundle.mcpServers != nil {
- if let existing = try? await configManager.readMCPConfig(for: projectPath),
- existing.mcpServers?.isEmpty == false
- {
- let existingCount = existing.mcpServers?.count ?? 0
- let importCount = bundle.mcpServers?.mcpServers?.count ?? 0
- conflicts.append(ImportConflict(
- component: .mcpServers,
- description: "Project has \(existingCount) servers, import has \(importCount)"
- ))
- }
- }
-
- return conflicts
- }
-
- /// Imports a bundle into a project.
- func importBundle(
- _ bundle: ConfigBundle,
- to projectPath: URL,
- components: Set,
- resolutions: [ConfigBundleComponent: ImportConflict.ImportResolution],
- configManager: ConfigFileManager = .shared
- ) async throws -> ImportResult {
- var imported: [ConfigBundleComponent] = []
- var skipped: [ConfigBundleComponent] = []
- var errors: [String] = []
-
- // Import settings
- if components.contains(.settings), let settings = bundle.settings {
- let resolution = resolutions[.settings] ?? .merge
- if resolution == .skip {
- skipped.append(.settings)
- } else {
- do {
- try await self.importSettings(
- settings,
- to: projectPath,
- resolution: resolution,
- configManager: configManager
- )
- imported.append(.settings)
- } catch {
- errors.append("Settings: \(error.localizedDescription)")
- }
- }
- }
-
- // Import local settings
- if components.contains(.localSettings), let localSettings = bundle.localSettings {
- let resolution = resolutions[.localSettings] ?? .merge
- if resolution == .skip {
- skipped.append(.localSettings)
- } else {
- do {
- try await self.importLocalSettings(
- localSettings,
- to: projectPath,
- resolution: resolution,
- configManager: configManager
- )
- imported.append(.localSettings)
- } catch {
- errors.append("Local Settings: \(error.localizedDescription)")
- }
- }
- }
-
- // Import MCP servers
- if components.contains(.mcpServers), let mcpConfig = bundle.mcpServers {
- let resolution = resolutions[.mcpServers] ?? .merge
- if resolution == .skip {
- skipped.append(.mcpServers)
- } else {
- do {
- try await self.importMCPServers(
- mcpConfig,
- to: projectPath,
- resolution: resolution,
- configManager: configManager
- )
- imported.append(.mcpServers)
- } catch {
- errors.append("MCP Servers: \(error.localizedDescription)")
- }
- }
- }
-
- let success = errors.isEmpty && !imported.isEmpty
- let message = if success {
- "Successfully imported \(imported.count) component(s)"
- } else if !errors.isEmpty {
- "Import completed with errors"
- } else {
- "No components were imported"
- }
-
- Log.general.info("Import: \(imported.count) imported, \(skipped.count) skipped, \(errors.count) errors")
-
- return ImportResult(
- success: success,
- message: message,
- componentsImported: imported,
- componentsSkipped: skipped,
- errors: errors
- )
- }
-
- // MARK: Private
-
- // MARK: - Import Helpers
-
- private func importSettings(
- _ settings: ClaudeSettings,
- to projectPath: URL,
- resolution: ImportConflict.ImportResolution,
- configManager: ConfigFileManager
- ) async throws {
- let existing = try await configManager.readProjectSettings(for: projectPath)
-
- let finalSettings: ClaudeSettings = if resolution == .merge, let existing {
- self.mergeSettings(existing: existing, incoming: settings)
- } else {
- settings
- }
-
- try await configManager.writeProjectSettings(finalSettings, for: projectPath)
- }
-
- private func importLocalSettings(
- _ settings: ClaudeSettings,
- to projectPath: URL,
- resolution: ImportConflict.ImportResolution,
- configManager: ConfigFileManager
- ) async throws {
- let existing = try await configManager.readProjectLocalSettings(for: projectPath)
-
- let finalSettings: ClaudeSettings = if resolution == .merge, let existing {
- self.mergeSettings(existing: existing, incoming: settings)
- } else {
- settings
- }
-
- try await configManager.writeProjectLocalSettings(finalSettings, for: projectPath)
- }
-
- private func importMCPServers(
- _ config: MCPConfig,
- to projectPath: URL,
- resolution: ImportConflict.ImportResolution,
- configManager: ConfigFileManager
- ) async throws {
- let existing = try await configManager.readMCPConfig(for: projectPath)
-
- let finalConfig: MCPConfig
- if resolution == .merge, let existing {
- var merged = existing
- if merged.mcpServers == nil {
- merged.mcpServers = [:]
- }
- // Merge servers - incoming overwrites by name
- if let incomingServers = config.mcpServers {
- for (name, server) in incomingServers {
- merged.mcpServers?[name] = server
- }
- }
- finalConfig = merged
- } else {
- finalConfig = config
- }
-
- try await configManager.writeMCPConfig(finalConfig, for: projectPath)
- }
-
- /// Merges two ClaudeSettings instances.
- private func mergeSettings(existing: ClaudeSettings, incoming: ClaudeSettings) -> ClaudeSettings {
- var merged = existing
-
- // Merge permissions (union of allow/deny)
- if let incomingPermissions = incoming.permissions {
- if merged.permissions == nil {
- merged.permissions = Permissions()
- }
-
- // Merge allow rules
- if let incomingAllow = incomingPermissions.allow {
- let existingAllow = merged.permissions?.allow ?? []
- let combined = Set(existingAllow).union(Set(incomingAllow))
- merged.permissions?.allow = Array(combined)
- }
-
- // Merge deny rules
- if let incomingDeny = incomingPermissions.deny {
- let existingDeny = merged.permissions?.deny ?? []
- let combined = Set(existingDeny).union(Set(incomingDeny))
- merged.permissions?.deny = Array(combined)
- }
- }
-
- // Merge env vars (incoming overwrites)
- if let incomingEnv = incoming.env {
- if merged.env == nil {
- merged.env = [:]
- }
- for (key, value) in incomingEnv {
- merged.env?[key] = value
- }
- }
-
- // Replace hooks entirely (hooks are complex, safer to replace)
- if incoming.hooks != nil {
- merged.hooks = incoming.hooks
- }
-
- // Merge disallowed tools
- if let incomingDisallowed = incoming.disallowedTools {
- let existingDisallowed = merged.disallowedTools ?? []
- let combined = Set(existingDisallowed).union(Set(incomingDisallowed))
- merged.disallowedTools = Array(combined)
- }
-
- // Take incoming attribution if present
- if incoming.attribution != nil {
- merged.attribution = incoming.attribution
- }
-
- return merged
- }
-}
diff --git a/Fig/Sources/Services/ConfigFileManager.swift b/Fig/Sources/Services/ConfigFileManager.swift
deleted file mode 100644
index 675261f..0000000
--- a/Fig/Sources/Services/ConfigFileManager.swift
+++ /dev/null
@@ -1,371 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - ConfigFileError
-
-/// Errors that can occur during configuration file operations.
-enum ConfigFileError: Error, LocalizedError {
- case fileNotFound(URL)
- case permissionDenied(URL)
- case invalidJSON(URL, underlying: Error)
- case writeError(URL, underlying: Error)
- case backupFailed(URL, underlying: Error)
- case circularSymlink(URL)
-
- // MARK: Internal
-
- var errorDescription: String? {
- switch self {
- case let .fileNotFound(url):
- "File not found: \(url.path)"
- case let .permissionDenied(url):
- "Permission denied: \(url.path). Check file permissions."
- case let .invalidJSON(url, underlying):
- "Invalid JSON in \(url.lastPathComponent): \(underlying.localizedDescription)"
- case let .writeError(url, underlying):
- "Failed to write \(url.lastPathComponent): \(underlying.localizedDescription)"
- case let .backupFailed(url, underlying):
- "Failed to create backup for \(url.lastPathComponent): \(underlying.localizedDescription)"
- case let .circularSymlink(url):
- "Circular symlink detected at \(url.path)"
- }
- }
-}
-
-// MARK: - ConfigFileManager
-
-/// Actor responsible for reading and writing Claude Code configuration files.
-///
-/// This service provides thread-safe file I/O with automatic backups before writes,
-/// file change monitoring, and graceful handling of missing or malformed files.
-actor ConfigFileManager {
- // MARK: Lifecycle
-
- private init() {
- self.encoder = JSONEncoder()
- self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
-
- self.decoder = JSONDecoder()
- }
-
- // MARK: Internal
-
- /// Shared instance for app-wide configuration management.
- static let shared = ConfigFileManager()
-
- // MARK: - Standard Paths
-
- /// Path to the global Claude config file (~/.claude.json).
- var globalConfigURL: URL {
- self.fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".claude.json")
- }
-
- /// Path to the global settings directory (~/.claude).
- var globalSettingsDirectory: URL {
- self.fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".claude")
- }
-
- /// Path to the global settings file (~/.claude/settings.json).
- var globalSettingsURL: URL {
- self.globalSettingsDirectory.appendingPathComponent("settings.json")
- }
-
- /// Returns the project settings directory for a given project path.
- func projectSettingsDirectory(for projectPath: URL) -> URL {
- projectPath.appendingPathComponent(".claude")
- }
-
- /// Returns the project settings file for a given project path.
- func projectSettingsURL(for projectPath: URL) -> URL {
- self.projectSettingsDirectory(for: projectPath).appendingPathComponent("settings.json")
- }
-
- /// Returns the project local settings file for a given project path.
- func projectLocalSettingsURL(for projectPath: URL) -> URL {
- self.projectSettingsDirectory(for: projectPath).appendingPathComponent("settings.local.json")
- }
-
- /// Returns the MCP config file for a given project path.
- func mcpConfigURL(for projectPath: URL) -> URL {
- projectPath.appendingPathComponent(".mcp.json")
- }
-
- // MARK: - Reading Files
-
- /// Reads and decodes a JSON configuration file.
- ///
- /// - Parameters:
- /// - type: The type to decode into.
- /// - url: The URL of the file to read.
- /// - Returns: The decoded value, or nil if the file doesn't exist.
- /// - Throws: `ConfigFileError` if the file exists but cannot be read or parsed.
- func read(_ type: T.Type, from url: URL) async throws -> T? {
- let resolvedURL = try resolveSymlinks(url)
-
- guard self.fileManager.fileExists(atPath: resolvedURL.path) else {
- Log.fileIO.debug("File not found (expected): \(url.path)")
- return nil
- }
-
- guard self.fileManager.isReadableFile(atPath: resolvedURL.path) else {
- Log.fileIO.error("Permission denied: \(url.path)")
- throw ConfigFileError.permissionDenied(url)
- }
-
- do {
- let data = try Data(contentsOf: resolvedURL)
- let decoded = try decoder.decode(type, from: data)
- Log.fileIO.debug("Successfully read: \(url.path)")
- return decoded
- } catch let error as DecodingError {
- Log.fileIO.error("JSON decode error in \(url.path): \(error)")
- throw ConfigFileError.invalidJSON(url, underlying: error)
- } catch {
- Log.fileIO.error("Read error for \(url.path): \(error)")
- throw ConfigFileError.invalidJSON(url, underlying: error)
- }
- }
-
- /// Reads the global legacy config file (~/.claude.json).
- func readGlobalConfig() async throws -> LegacyConfig? {
- try await self.read(LegacyConfig.self, from: self.globalConfigURL)
- }
-
- /// Reads the global settings file (~/.claude/settings.json).
- func readGlobalSettings() async throws -> ClaudeSettings? {
- try await self.read(ClaudeSettings.self, from: self.globalSettingsURL)
- }
-
- /// Reads project-specific settings.
- func readProjectSettings(for projectPath: URL) async throws -> ClaudeSettings? {
- try await self.read(ClaudeSettings.self, from: self.projectSettingsURL(for: projectPath))
- }
-
- /// Reads project local settings (gitignored overrides).
- func readProjectLocalSettings(for projectPath: URL) async throws -> ClaudeSettings? {
- try await self.read(ClaudeSettings.self, from: self.projectLocalSettingsURL(for: projectPath))
- }
-
- /// Reads the MCP configuration for a project.
- func readMCPConfig(for projectPath: URL) async throws -> MCPConfig? {
- try await self.read(MCPConfig.self, from: self.mcpConfigURL(for: projectPath))
- }
-
- // MARK: - Writing Files
-
- /// Writes a value to a JSON configuration file with automatic backup.
- ///
- /// - Parameters:
- /// - value: The value to encode and write.
- /// - url: The destination URL.
- /// - Throws: `ConfigFileError` if the write fails.
- func write(_ value: some Encodable, to url: URL) async throws {
- // Create backup if file exists
- if self.fileManager.fileExists(atPath: url.path) {
- try await self.createBackup(of: url)
- }
-
- // Ensure parent directory exists
- let parentDirectory = url.deletingLastPathComponent()
- if !self.fileManager.fileExists(atPath: parentDirectory.path) {
- do {
- try self.fileManager.createDirectory(
- at: parentDirectory,
- withIntermediateDirectories: true
- )
- Log.fileIO.debug("Created directory: \(parentDirectory.path)")
- } catch {
- Log.fileIO.error("Failed to create directory \(parentDirectory.path): \(error)")
- throw ConfigFileError.writeError(url, underlying: error)
- }
- }
-
- // Encode and write
- do {
- let data = try encoder.encode(value)
- try data.write(to: url, options: .atomic)
- Log.fileIO.info("Successfully wrote: \(url.path)")
- } catch let error as EncodingError {
- Log.fileIO.error("JSON encode error for \(url.path): \(error)")
- throw ConfigFileError.writeError(url, underlying: error)
- } catch {
- Log.fileIO.error("Write error for \(url.path): \(error)")
- throw ConfigFileError.writeError(url, underlying: error)
- }
- }
-
- /// Writes global settings to ~/.claude/settings.json.
- func writeGlobalSettings(_ settings: ClaudeSettings) async throws {
- try await self.write(settings, to: self.globalSettingsURL)
- }
-
- /// Writes project settings.
- func writeProjectSettings(_ settings: ClaudeSettings, for projectPath: URL) async throws {
- try await self.write(settings, to: self.projectSettingsURL(for: projectPath))
- }
-
- /// Writes project local settings.
- func writeProjectLocalSettings(_ settings: ClaudeSettings, for projectPath: URL) async throws {
- try await self.write(settings, to: self.projectLocalSettingsURL(for: projectPath))
- }
-
- /// Writes MCP configuration for a project.
- func writeMCPConfig(_ config: MCPConfig, for projectPath: URL) async throws {
- try await self.write(config, to: self.mcpConfigURL(for: projectPath))
- }
-
- /// Writes the global legacy config to ~/.claude.json.
- func writeGlobalConfig(_ config: LegacyConfig) async throws {
- try await self.write(config, to: self.globalConfigURL)
- }
-
- // MARK: - File Watching
-
- /// Starts watching a file for external changes.
- ///
- /// - Parameters:
- /// - url: The URL of the file to watch.
- /// - handler: Callback invoked when the file changes.
- func startWatching(_ url: URL, handler: @escaping @Sendable (URL) -> Void) {
- // Store handler for this specific URL
- self.changeHandlers[url] = handler
-
- guard self.fileManager.fileExists(atPath: url.path) else {
- Log.fileIO.warning("Cannot watch non-existent file: \(url.path)")
- return
- }
-
- let fd = open(url.path, O_EVTONLY)
- guard fd >= 0 else {
- Log.fileIO.warning("Cannot open file for watching: \(url.path)")
- return
- }
-
- let source = DispatchSource.makeFileSystemObjectSource(
- fileDescriptor: fd,
- eventMask: [.write, .delete, .rename],
- queue: .global(qos: .utility)
- )
-
- let watchedURL = url
- source.setEventHandler { [weak self] in
- guard let self else {
- return
- }
- Task { @Sendable in
- await self.handleFileChange(url: watchedURL)
- }
- }
-
- source.setCancelHandler {
- close(fd)
- }
-
- // Cancel existing watcher if any
- self.watchers[url]?.cancel()
- self.watchers[url] = source
-
- source.resume()
- Log.fileIO.debug("Started watching: \(url.path)")
- }
-
- /// Stops watching a file.
- func stopWatching(_ url: URL) {
- self.watchers[url]?.cancel()
- self.watchers.removeValue(forKey: url)
- self.changeHandlers.removeValue(forKey: url)
- Log.fileIO.debug("Stopped watching: \(url.path)")
- }
-
- /// Stops all file watchers.
- func stopAllWatchers() {
- for (url, source) in self.watchers {
- source.cancel()
- Log.fileIO.debug("Stopped watching: \(url.path)")
- }
- self.watchers.removeAll()
- self.changeHandlers.removeAll()
- }
-
- // MARK: - Utilities
-
- /// Checks if a file exists at the given URL.
- func fileExists(at url: URL) -> Bool {
- self.fileManager.fileExists(atPath: url.path)
- }
-
- /// Deletes a file at the given URL.
- func delete(at url: URL) async throws {
- guard self.fileManager.fileExists(atPath: url.path) else {
- return
- }
-
- // Create backup before deletion
- try await self.createBackup(of: url)
-
- try self.fileManager.removeItem(at: url)
- Log.fileIO.info("Deleted file: \(url.path)")
- }
-
- // MARK: Private
-
- private let fileManager = FileManager.default
- private let encoder: JSONEncoder
- private let decoder: JSONDecoder
-
- /// Active file watchers keyed by URL.
- private var watchers: [URL: DispatchSourceFileSystemObject] = [:]
-
- /// Callback for file change notifications.
- private var changeHandlers: [URL: (URL) -> Void] = [:]
-
- /// Maximum symlink depth to prevent infinite loops.
- private let maxSymlinkDepth = 10
-
- // MARK: - Backups
-
- /// Creates a timestamped backup of a file.
- private func createBackup(of url: URL) async throws {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withFullDate, .withTime, .withColonSeparatorInTime]
- let timestamp = formatter.string(from: Date())
- .replacingOccurrences(of: ":", with: "-")
-
- let backupName = "\(url.lastPathComponent).backup.\(timestamp)"
- let backupURL = url.deletingLastPathComponent().appendingPathComponent(backupName)
-
- do {
- try self.fileManager.copyItem(at: url, to: backupURL)
- Log.fileIO.debug("Created backup: \(backupURL.path)")
- } catch {
- Log.fileIO.error("Failed to create backup of \(url.path): \(error)")
- throw ConfigFileError.backupFailed(url, underlying: error)
- }
- }
-
- private func handleFileChange(url: URL) {
- Log.fileIO.info("File changed externally: \(url.path)")
- self.changeHandlers[url]?(url)
- }
-
- // MARK: - Symlink Resolution
-
- /// Resolves symlinks while detecting circular references.
- private func resolveSymlinks(_ url: URL, depth: Int = 0) throws -> URL {
- guard depth < self.maxSymlinkDepth else {
- throw ConfigFileError.circularSymlink(url)
- }
-
- let resourceValues = try? url.resourceValues(forKeys: [.isSymbolicLinkKey])
- guard resourceValues?.isSymbolicLink == true else {
- return url
- }
-
- let resolved = url.resolvingSymlinksInPath()
- if resolved == url {
- throw ConfigFileError.circularSymlink(url)
- }
-
- return try self.resolveSymlinks(resolved, depth: depth + 1)
- }
-}
diff --git a/Fig/Sources/Services/ConfigHealthCheckService.swift b/Fig/Sources/Services/ConfigHealthCheckService.swift
deleted file mode 100644
index 68fd325..0000000
--- a/Fig/Sources/Services/ConfigHealthCheckService.swift
+++ /dev/null
@@ -1,374 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - HealthCheck
-
-/// Protocol for individual configuration health checks.
-protocol HealthCheck: Sendable {
- /// Display name of this check.
- var name: String { get }
-
- /// Runs the check against the provided context and returns any findings.
- func check(context: HealthCheckContext) -> [Finding]
-}
-
-// MARK: - DenyListSecurityCheck
-
-/// Checks that sensitive files like `.env` and `secrets/` are in the deny list.
-struct DenyListSecurityCheck: HealthCheck {
- let name = "Deny List Security"
-
- func check(context: HealthCheckContext) -> [Finding] {
- var findings: [Finding] = []
- let denyRules = context.allDenyRules
-
- // Check for .env files
- let hasEnvDeny = denyRules.contains { rule in
- rule.contains(".env")
- }
- if !hasEnvDeny {
- findings.append(Finding(
- severity: .security,
- title: ".env files not in deny list",
- description: "Environment files often contain secrets like API keys and passwords. " +
- "Add a deny rule to prevent Claude from reading them.",
- autoFix: .addToDenyList(pattern: "Read(.env)")
- ))
- }
-
- // Check for secrets/ directory
- let hasSecretsDeny = denyRules.contains { rule in
- rule.contains("secrets")
- }
- if !hasSecretsDeny {
- findings.append(Finding(
- severity: .security,
- title: "secrets/ directory not in deny list",
- description: "The secrets/ directory may contain sensitive credentials. " +
- "Add a deny rule to prevent Claude from accessing it.",
- autoFix: .addToDenyList(pattern: "Read(secrets/**)")
- ))
- }
-
- return findings
- }
-}
-
-// MARK: - BroadAllowRulesCheck
-
-/// Checks for overly broad allow rules that may pose security risks.
-struct BroadAllowRulesCheck: HealthCheck {
- // MARK: Internal
-
- let name = "Broad Allow Rules"
-
- func check(context: HealthCheckContext) -> [Finding] {
- let allowRules = context.allAllowRules
-
- return Self.broadPatterns.compactMap { broad in
- if allowRules.contains(broad.pattern) {
- return Finding(
- severity: .warning,
- title: "Overly broad allow rule: \(broad.pattern)",
- description: "\(broad.description). Consider using more specific patterns " +
- "to limit what Claude can access."
- )
- }
- return nil
- }
- }
-
- // MARK: Private
-
- /// Patterns considered overly broad.
- private static let broadPatterns: [(pattern: String, description: String)] = [
- ("Bash(*)", "Allows any Bash command without restriction"),
- ("Read(*)", "Allows reading any file without restriction"),
- ("Write(*)", "Allows writing to any file without restriction"),
- ("Edit(*)", "Allows editing any file without restriction"),
- ]
-}
-
-// MARK: - GlobalConfigSizeCheck
-
-/// Checks if the global config file is too large (performance issue).
-struct GlobalConfigSizeCheck: HealthCheck {
- // MARK: Internal
-
- let name = "Global Config Size"
-
- func check(context: HealthCheckContext) -> [Finding] {
- guard let size = context.globalConfigFileSize, size > Self.sizeThreshold else {
- return []
- }
-
- let megabytes = Double(size) / (1024 * 1024)
- return [Finding(
- severity: .warning,
- title: "~/.claude.json is large (\(String(format: "%.1f", megabytes)) MB)",
- description: "A large global config file can slow down Claude Code startup. " +
- "Consider cleaning up old project entries or conversation history."
- )]
- }
-
- // MARK: Private
-
- /// Threshold in bytes (5 MB).
- private static let sizeThreshold: Int64 = 5 * 1024 * 1024
-}
-
-// MARK: - MCPHardcodedSecretsCheck
-
-/// Checks for MCP servers with hardcoded secrets in their configuration.
-struct MCPHardcodedSecretsCheck: HealthCheck {
- // MARK: Internal
-
- let name = "MCP Hardcoded Secrets"
-
- func check(context: HealthCheckContext) -> [Finding] {
- var findings: [Finding] = []
-
- for (name, server) in context.allMCPServers {
- // Check env vars for hardcoded secrets
- if let env = server.env {
- for (key, value) in env {
- if self.looksLikeSecret(key: key, value: value) {
- findings.append(Finding(
- severity: .warning,
- title: "Hardcoded secret in MCP server '\(name)'",
- description: "The environment variable '\(key)' appears to contain a " +
- "hardcoded secret. Consider using environment variable references instead."
- ))
- }
- }
- }
-
- // Check HTTP headers for hardcoded secrets
- if let headers = server.headers {
- for (key, value) in headers {
- if self.looksLikeSecret(key: key, value: value) {
- findings.append(Finding(
- severity: .warning,
- title: "Hardcoded secret in MCP server '\(name)' headers",
- description: "The header '\(key)' appears to contain a hardcoded secret. " +
- "Consider using environment variable references instead."
- ))
- }
- }
- }
- }
-
- return findings
- }
-
- // MARK: Private
-
- /// Patterns that suggest a value is a secret.
- private static let secretPatterns: [String] = [
- "sk-", "sk_", "ghp_", "gho_", "ghu_", "ghs_",
- "xoxb-", "xoxp-", "xoxs-",
- "AKIA", "Bearer ",
- "-----BEGIN",
- ]
-
- /// Environment variable names that commonly hold secrets.
- private static let secretKeyNames: [String] = [
- "TOKEN", "SECRET", "KEY", "PASSWORD", "CREDENTIAL",
- "AUTH", "API_KEY", "APIKEY", "PRIVATE",
- ]
-
- private func looksLikeSecret(key: String, value: String) -> Bool {
- let upperKey = key.uppercased()
-
- // Check if the key name suggests it's a secret
- let keyIsSecret = Self.secretKeyNames.contains { upperKey.contains($0) }
-
- // Check if the value looks like a known secret format
- let valueIsSecret = Self.secretPatterns.contains { value.hasPrefix($0) }
-
- // A short value is unlikely to be a secret
- let isLongEnough = value.count >= 8
-
- return (keyIsSecret && isLongEnough) || valueIsSecret
- }
-}
-
-// MARK: - LocalSettingsCheck
-
-/// Suggests creating `settings.local.json` for personal overrides.
-struct LocalSettingsCheck: HealthCheck {
- let name = "Local Settings"
-
- func check(context: HealthCheckContext) -> [Finding] {
- if context.localSettingsExists {
- return []
- }
-
- return [Finding(
- severity: .suggestion,
- title: "No settings.local.json",
- description: "Create a local settings file for personal overrides that won't be " +
- "committed to version control. This is useful for developer-specific " +
- "environment variables or permission tweaks.",
- autoFix: .createLocalSettings
- )]
- }
-}
-
-// MARK: - MCPScopingCheck
-
-/// Suggests creating a project `.mcp.json` when global MCP servers exist.
-struct MCPScopingCheck: HealthCheck {
- let name = "MCP Scoping"
-
- func check(context: HealthCheckContext) -> [Finding] {
- let hasGlobalServers = !(context.legacyConfig?.mcpServers?.isEmpty ?? true)
- let hasProjectMCP = context.mcpConfigExists
-
- if hasGlobalServers, !hasProjectMCP {
- return [Finding(
- severity: .suggestion,
- title: "Global MCP servers without project scoping",
- description: "You have global MCP servers configured but no project-level .mcp.json. " +
- "Consider creating a project-scoped MCP configuration for better isolation."
- )]
- }
-
- return []
- }
-}
-
-// MARK: - HookSuggestionsCheck
-
-/// Suggests hooks for common development patterns.
-struct HookSuggestionsCheck: HealthCheck {
- let name = "Hook Suggestions"
-
- func check(context: HealthCheckContext) -> [Finding] {
- // Only suggest if there are no hooks configured at all
- let hasAnyHooks = (context.globalSettings?.hooks?.isEmpty == false)
- || (context.projectSettings?.hooks?.isEmpty == false)
- || (context.projectLocalSettings?.hooks?.isEmpty == false)
-
- if hasAnyHooks {
- return []
- }
-
- return [Finding(
- severity: .suggestion,
- title: "No hooks configured",
- description: "Hooks let you run commands before or after Claude uses tools. " +
- "Common uses include running formatters after file edits " +
- "or linters before committing code."
- )]
- }
-}
-
-// MARK: - GoodPracticesCheck
-
-/// Reports good practices already in place (positive reinforcement).
-struct GoodPracticesCheck: HealthCheck {
- let name = "Good Practices"
-
- func check(context: HealthCheckContext) -> [Finding] {
- var findings: [Finding] = []
-
- let denyRules = context.allDenyRules
-
- // Check for .env in deny list
- if denyRules.contains(where: { $0.contains(".env") }) {
- findings.append(Finding(
- severity: .good,
- title: "Sensitive files protected",
- description: "Your deny list includes rules to protect .env files from being read."
- ))
- }
-
- // Check for secrets in deny list
- if denyRules.contains(where: { $0.contains("secrets") }) {
- findings.append(Finding(
- severity: .good,
- title: "Secrets directory protected",
- description: "Your deny list includes rules to protect the secrets directory."
- ))
- }
-
- // Check for local settings
- if context.localSettingsExists {
- findings.append(Finding(
- severity: .good,
- title: "Local settings configured",
- description: "You have a settings.local.json for personal overrides."
- ))
- }
-
- // Check for project MCP config
- if context.mcpConfigExists {
- findings.append(Finding(
- severity: .good,
- title: "Project-scoped MCP servers",
- description: "MCP servers are configured at the project level for better isolation."
- ))
- }
-
- // Check for hooks
- let hasHooks = (context.projectSettings?.hooks?.isEmpty == false)
- || (context.projectLocalSettings?.hooks?.isEmpty == false)
- if hasHooks {
- findings.append(Finding(
- severity: .good,
- title: "Hooks configured",
- description: "Lifecycle hooks are set up for automated workflows."
- ))
- }
-
- // Check for scoped permissions
- let allowRules = context.allAllowRules
- let hasScopedRules = allowRules.contains { rule in
- // Rules with specific paths or patterns are considered scoped
- rule.contains("/") || rule.contains("**")
- }
- if hasScopedRules {
- findings.append(Finding(
- severity: .good,
- title: "Scoped permission rules",
- description: "Permission rules use specific path patterns for fine-grained access control."
- ))
- }
-
- return findings
- }
-}
-
-// MARK: - ConfigHealthCheckService
-
-/// Service that runs all health checks and returns aggregated findings.
-enum ConfigHealthCheckService {
- /// All registered health checks, run in order.
- static let checks: [any HealthCheck] = [
- DenyListSecurityCheck(),
- BroadAllowRulesCheck(),
- GlobalConfigSizeCheck(),
- MCPHardcodedSecretsCheck(),
- LocalSettingsCheck(),
- MCPScopingCheck(),
- HookSuggestionsCheck(),
- GoodPracticesCheck(),
- ]
-
- /// Runs all health checks and returns findings sorted by severity.
- static func runAllChecks(context: HealthCheckContext) -> [Finding] {
- var findings: [Finding] = []
-
- for check in self.checks {
- let results = check.check(context: context)
- findings.append(contentsOf: results)
- }
-
- // Sort by severity (security first, then warning, suggestion, good)
- findings.sort { $0.severity < $1.severity }
-
- Log.general.info("Health check completed: \(findings.count) findings")
- return findings
- }
-}
diff --git a/Fig/Sources/Services/FileService.swift b/Fig/Sources/Services/FileService.swift
deleted file mode 100644
index 6e6dc27..0000000
--- a/Fig/Sources/Services/FileService.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-import Foundation
-
-/// Actor responsible for file I/O operations, ensuring thread-safe file access.
-///
-/// - Note: File operations use synchronous Foundation APIs internally. While the actor
-/// ensures thread-safe access, these operations may block the actor's executor during I/O.
-actor FileService {
- // MARK: Lifecycle
-
- private init() {}
-
- // MARK: Internal
-
- static let shared = FileService()
-
- /// Reads the contents of a file at the specified URL.
- /// - Parameter url: The URL of the file to read.
- /// - Returns: The contents of the file as Data.
- func readFile(at url: URL) async throws -> Data {
- try Data(contentsOf: url)
- }
-
- /// Writes data to a file at the specified URL.
- /// - Parameters:
- /// - data: The data to write.
- /// - url: The URL of the file to write to.
- func writeFile(_ data: Data, to url: URL) async throws {
- try data.write(to: url, options: .atomic)
- }
-
- /// Checks if a file exists at the specified URL.
- /// - Parameter url: The URL to check.
- /// - Returns: True if the file exists, false otherwise.
- func fileExists(at url: URL) -> Bool {
- FileManager.default.fileExists(atPath: url.path)
- }
-
- /// Creates a directory at the specified URL.
- /// - Parameter url: The URL where the directory should be created.
- func createDirectory(at url: URL) async throws {
- try FileManager.default.createDirectory(
- at: url,
- withIntermediateDirectories: true
- )
- }
-
- /// Deletes the file or directory at the specified URL.
- /// - Parameter url: The URL of the item to delete.
- func delete(at url: URL) async throws {
- try FileManager.default.removeItem(at: url)
- }
-}
diff --git a/Fig/Sources/Services/MCPHealthCheckService.swift b/Fig/Sources/Services/MCPHealthCheckService.swift
deleted file mode 100644
index 6de6b7c..0000000
--- a/Fig/Sources/Services/MCPHealthCheckService.swift
+++ /dev/null
@@ -1,389 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - MCPHealthCheckResult
-
-/// Result of an MCP server health check.
-struct MCPHealthCheckResult: Sendable {
- /// Status of the health check.
- enum Status: Sendable {
- case success(serverInfo: MCPServerInfo?)
- case failure(error: MCPHealthCheckError)
- case timeout
- }
-
- /// The server name that was tested.
- let serverName: String
-
- /// The result status.
- let status: Status
-
- /// How long the check took.
- let duration: TimeInterval
-
- /// Whether the check was successful.
- var isSuccess: Bool {
- if case .success = self.status {
- return true
- }
- return false
- }
-}
-
-// MARK: - MCPServerInfo
-
-/// Information returned from a successful MCP handshake.
-struct MCPServerInfo: Sendable {
- let protocolVersion: String?
- let serverName: String?
- let serverVersion: String?
-}
-
-// MARK: - MCPHealthCheckError
-
-/// Errors that can occur during MCP health checks.
-enum MCPHealthCheckError: Error, LocalizedError, Sendable {
- case processSpawnFailed(underlying: String)
- case processExitedEarly(code: Int32, stderr: String)
- case invalidHandshakeResponse(details: String)
- case httpRequestFailed(statusCode: Int?, message: String)
- case networkError(message: String)
- case timeout
- case noCommandOrURL
-
- // MARK: Internal
-
- var errorDescription: String? {
- switch self {
- case let .processSpawnFailed(msg):
- "Failed to start process: \(msg)"
- case let .processExitedEarly(code, stderr):
- "Process exited with code \(code): \(stderr.prefix(100))"
- case let .invalidHandshakeResponse(details):
- "Invalid MCP response: \(details)"
- case let .httpRequestFailed(code, msg):
- if let code {
- "HTTP \(code): \(msg)"
- } else {
- "HTTP error: \(msg)"
- }
- case let .networkError(msg):
- "Network error: \(msg)"
- case .timeout:
- "Connection timed out (10s)"
- case .noCommandOrURL:
- "Server has no command or URL configured"
- }
- }
-
- var recoverySuggestion: String? {
- switch self {
- case .processSpawnFailed:
- "Check that the command exists and is executable"
- case .processExitedEarly:
- "Check server logs or environment variables"
- case .invalidHandshakeResponse:
- "The server may not support MCP protocol"
- case .httpRequestFailed:
- "Check the URL and any required authentication"
- case .networkError:
- "Check your network connection"
- case .timeout:
- "The server may be slow or unresponsive"
- case .noCommandOrURL:
- "Configure a command (stdio) or URL (HTTP)"
- }
- }
-}
-
-// MARK: - MCPHealthCheckService
-
-/// Service for testing MCP server connectivity.
-actor MCPHealthCheckService {
- // MARK: Internal
-
- /// Shared instance for app-wide health checks.
- static let shared = MCPHealthCheckService()
-
- /// Timeout for health checks in seconds.
- let timeout: TimeInterval = 10.0
-
- /// Tests connection to an MCP server.
- func checkHealth(name: String, server: MCPServer) async -> MCPHealthCheckResult {
- let startTime = Date()
-
- let status: MCPHealthCheckResult.Status = if server.isHTTP {
- await self.checkHTTPServer(server)
- } else if server.isStdio {
- await self.checkStdioServer(server)
- } else {
- .failure(error: .noCommandOrURL)
- }
-
- let duration = Date().timeIntervalSince(startTime)
-
- return MCPHealthCheckResult(
- serverName: name,
- status: status,
- duration: duration
- )
- }
-
- // MARK: Private
-
- // MARK: - Stdio Server Check
-
- private func checkStdioServer(_ server: MCPServer) async -> MCPHealthCheckResult.Status {
- guard let command = server.command else {
- return .failure(error: .noCommandOrURL)
- }
-
- return await withTaskGroup(of: MCPHealthCheckResult.Status.self) { group in
- // Timeout task
- group.addTask {
- try? await Task.sleep(for: .seconds(self.timeout))
- return .timeout
- }
-
- // Health check task
- group.addTask {
- await self.performStdioCheck(
- command: command,
- args: server.args,
- env: server.env
- )
- }
-
- // Return first completed result
- let result = await group.next()!
- group.cancelAll()
- return result
- }
- }
-
- private func performStdioCheck(
- command: String,
- args: [String]?,
- env: [String: String]?
- ) async -> MCPHealthCheckResult.Status {
- let process = Process()
-
- // Use /usr/bin/env to resolve PATH for commands
- if command.hasPrefix("/") || command.hasPrefix("./") {
- process.executableURL = URL(fileURLWithPath: command)
- if let args {
- process.arguments = args
- }
- } else {
- process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
- process.arguments = [command] + (args ?? [])
- }
-
- // Set environment
- var environment = ProcessInfo.processInfo.environment
- if let env {
- for (key, value) in env {
- environment[key] = value
- }
- }
- process.environment = environment
-
- // Set up pipes
- let stdinPipe = Pipe()
- let stdoutPipe = Pipe()
- let stderrPipe = Pipe()
-
- process.standardInput = stdinPipe
- process.standardOutput = stdoutPipe
- process.standardError = stderrPipe
-
- do {
- try process.run()
- } catch {
- return .failure(error: .processSpawnFailed(underlying: error.localizedDescription))
- }
-
- // Give the process a moment to start
- try? await Task.sleep(for: .milliseconds(100))
-
- // Check if process exited immediately
- if !process.isRunning {
- let exitCode = process.terminationStatus
- let stderrData = try? stderrPipe.fileHandleForReading.readToEnd()
- let stderrString = stderrData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
- return .failure(error: .processExitedEarly(code: exitCode, stderr: stderrString))
- }
-
- // Send MCP initialize request
- let initRequest = #"{"jsonrpc":"2.0","id":1,"method":"initialize","params":"# +
- #"{"protocolVersion":"2024-11-05","capabilities":{},"# +
- #""clientInfo":{"name":"Fig Health Check","version":"1.0"}}}"#
- let requestData = Data(initRequest.utf8)
-
- // MCP uses Content-Length header for stdio transport
- let header = "Content-Length: \(requestData.count)\r\n\r\n"
- stdinPipe.fileHandleForWriting.write(Data(header.utf8))
- stdinPipe.fileHandleForWriting.write(requestData)
-
- // Read response with timeout
- // Note: Do NOT close stdin before reading — MCP servers treat EOF on stdin
- // as a signal to exit, which would cause them to terminate before responding.
- let responseResult = await readMCPResponse(from: stdoutPipe.fileHandleForReading)
-
- // Clean up process
- try? stdinPipe.fileHandleForWriting.close()
- process.terminate()
- try? await Task.sleep(for: .milliseconds(100))
-
- switch responseResult {
- case let .success(data):
- return self.parseInitializeResponse(data)
- case let .failure(error):
- return .failure(error: error)
- }
- }
-
- private func readMCPResponse(from handle: FileHandle) async -> Result {
- // Try to read available data
- guard let data = try? handle.availableData, !data.isEmpty else {
- return .failure(.invalidHandshakeResponse(details: "No response received"))
- }
-
- // Look for Content-Length header in the response
- guard let responseString = String(data: data, encoding: .utf8) else {
- return .failure(.invalidHandshakeResponse(details: "Invalid response encoding"))
- }
-
- // Find the JSON body (after headers)
- if let headerEnd = responseString.range(of: "\r\n\r\n") {
- let body = String(responseString[headerEnd.upperBound...])
- return .success(Data(body.utf8))
- } else if responseString.hasPrefix("{") {
- // Some servers may not send Content-Length header
- return .success(data)
- }
-
- return .failure(.invalidHandshakeResponse(details: "Could not parse response"))
- }
-
- private func parseInitializeResponse(_ data: Data) -> MCPHealthCheckResult.Status {
- struct InitializeResponse: Decodable {
- let jsonrpc: String?
- let id: Int?
- let result: ResultPayload?
- let error: ErrorPayload?
-
- struct ResultPayload: Decodable {
- let protocolVersion: String?
- let serverInfo: ServerInfo?
-
- struct ServerInfo: Decodable {
- let name: String?
- let version: String?
- }
- }
-
- struct ErrorPayload: Decodable {
- let code: Int
- let message: String
- }
- }
-
- do {
- let response = try JSONDecoder().decode(InitializeResponse.self, from: data)
-
- if let error = response.error {
- return .failure(error: .invalidHandshakeResponse(
- details: "Error \(error.code): \(error.message)"
- ))
- }
-
- if let result = response.result {
- return .success(serverInfo: MCPServerInfo(
- protocolVersion: result.protocolVersion,
- serverName: result.serverInfo?.name,
- serverVersion: result.serverInfo?.version
- ))
- }
-
- // Got a response but couldn't parse result - still consider it a success
- return .success(serverInfo: nil)
- } catch {
- Log.general.debug("Failed to parse MCP response: \(error)")
- // If we got any JSON response, consider the server responsive
- if (try? JSONSerialization.jsonObject(with: data)) != nil {
- return .success(serverInfo: nil)
- }
- return .failure(error: .invalidHandshakeResponse(details: "Invalid JSON response"))
- }
- }
-
- // MARK: - HTTP Server Check
-
- private func checkHTTPServer(_ server: MCPServer) async -> MCPHealthCheckResult.Status {
- guard let urlString = server.url,
- let url = URL(string: urlString)
- else {
- return .failure(error: .noCommandOrURL)
- }
-
- return await withTaskGroup(of: MCPHealthCheckResult.Status.self) { group in
- group.addTask {
- try? await Task.sleep(for: .seconds(self.timeout))
- return .timeout
- }
-
- group.addTask {
- await self.performHTTPCheck(url: url, headers: server.headers)
- }
-
- let result = await group.next()!
- group.cancelAll()
- return result
- }
- }
-
- private func performHTTPCheck(
- url: URL,
- headers: [String: String]?
- ) async -> MCPHealthCheckResult.Status {
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
-
- // Add custom headers
- if let headers {
- for (key, value) in headers {
- request.setValue(value, forHTTPHeaderField: key)
- }
- }
-
- // Create initialize request body
- let initRequest = #"{"jsonrpc":"2.0","id":1,"method":"initialize","params":"# +
- #"{"protocolVersion":"2024-11-05","capabilities":{},"# +
- #""clientInfo":{"name":"Fig Health Check","version":"1.0"}}}"#
- request.httpBody = Data(initRequest.utf8)
-
- do {
- let (data, response) = try await URLSession.shared.data(for: request)
-
- guard let httpResponse = response as? HTTPURLResponse else {
- return .failure(error: .httpRequestFailed(statusCode: nil, message: "Invalid response"))
- }
-
- guard (200 ... 299).contains(httpResponse.statusCode) else {
- return .failure(error: .httpRequestFailed(
- statusCode: httpResponse.statusCode,
- message: HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
- ))
- }
-
- // Try to parse MCP response
- return self.parseInitializeResponse(data)
- } catch let error as URLError {
- return .failure(error: .networkError(message: error.localizedDescription))
- } catch {
- return .failure(error: .httpRequestFailed(statusCode: nil, message: error.localizedDescription))
- }
- }
-}
diff --git a/Fig/Sources/Services/MCPServerCopyService.swift b/Fig/Sources/Services/MCPServerCopyService.swift
deleted file mode 100644
index 37e40fd..0000000
--- a/Fig/Sources/Services/MCPServerCopyService.swift
+++ /dev/null
@@ -1,411 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - ConflictStrategy
-
-/// Strategy for handling server name conflicts during copy.
-enum ConflictStrategy: String, CaseIterable, Identifiable {
- case prompt // Ask for each conflict
- case overwrite // Replace existing
- case rename // Auto-suffix with -copy
- case skip // Skip conflicts
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var displayName: String {
- switch self {
- case .prompt: "Ask for each"
- case .overwrite: "Overwrite existing"
- case .rename: "Rename (add -copy suffix)"
- case .skip: "Skip conflicts"
- }
- }
-}
-
-// MARK: - CopyConflict
-
-/// Represents a conflict found during copy operation.
-struct CopyConflict: Identifiable {
- enum ConflictResolution {
- case overwrite
- case rename(String)
- case skip
- }
-
- let id = UUID()
- let serverName: String
- let existingServer: MCPServer
- let newServer: MCPServer
- var resolution: ConflictResolution = .skip
-}
-
-// MARK: - CopyDestination
-
-/// Destination for copying an MCP server.
-enum CopyDestination: Identifiable, Hashable {
- case global
- case project(path: String, name: String)
-
- // MARK: Internal
-
- var id: String {
- switch self {
- case .global:
- "global"
- case let .project(path, _):
- path
- }
- }
-
- var displayName: String {
- switch self {
- case .global:
- "Global Configuration"
- case let .project(_, name):
- name
- }
- }
-
- var icon: String {
- switch self {
- case .global:
- "globe"
- case .project:
- "folder"
- }
- }
-}
-
-// MARK: - SensitiveEnvWarning
-
-/// Warning about potentially sensitive environment variables.
-struct SensitiveEnvWarning: Identifiable {
- let id = UUID()
- let key: String
- let reason: String
-}
-
-// MARK: - CopyResult
-
-/// Result of a copy operation.
-struct CopyResult {
- let serverName: String
- let destination: CopyDestination
- let success: Bool
- let message: String
- let renamed: Bool
- let newName: String?
-}
-
-// MARK: - MCPServerCopyService
-
-/// Service for copying MCP server configurations between projects and global config.
-actor MCPServerCopyService {
- // MARK: Lifecycle
-
- private init() {}
-
- // MARK: Internal
-
- static let shared = MCPServerCopyService()
-
- /// Checks for conflicts when copying a server to a destination.
- func checkConflict(
- serverName: String,
- server: MCPServer,
- destination: CopyDestination,
- configManager: ConfigFileManager = .shared
- ) async -> CopyConflict? {
- let existingServers = await getExistingServers(
- at: destination,
- configManager: configManager
- )
-
- if let existingServer = existingServers[serverName] {
- return CopyConflict(
- serverName: serverName,
- existingServer: existingServer,
- newServer: server
- )
- }
- return nil
- }
-
- /// Detects sensitive environment variables in a server config.
- func detectSensitiveEnvVars(server: MCPServer) -> [SensitiveEnvWarning] {
- guard let env = server.env else {
- return []
- }
-
- var warnings: [SensitiveEnvWarning] = []
-
- for key in env.keys {
- let lowerKey = key.lowercased()
-
- if lowerKey.contains("token") {
- warnings.append(SensitiveEnvWarning(
- key: key,
- reason: "May contain authentication token"
- ))
- } else if lowerKey.contains("key") || lowerKey.contains("api") {
- warnings.append(SensitiveEnvWarning(
- key: key,
- reason: "May contain API key"
- ))
- } else if lowerKey.contains("secret") {
- warnings.append(SensitiveEnvWarning(
- key: key,
- reason: "May contain secret value"
- ))
- } else if lowerKey.contains("password") || lowerKey.contains("passwd") {
- warnings.append(SensitiveEnvWarning(
- key: key,
- reason: "May contain password"
- ))
- } else if lowerKey.contains("credential") || lowerKey.contains("auth") {
- warnings.append(SensitiveEnvWarning(
- key: key,
- reason: "May contain credentials"
- ))
- }
- }
-
- return warnings
- }
-
- /// Copies a server to a destination.
- func copyServer(
- name: String,
- server: MCPServer,
- to destination: CopyDestination,
- strategy: ConflictStrategy = .prompt,
- configManager: ConfigFileManager = .shared
- ) async throws -> CopyResult {
- // Check for conflict
- if let conflict = await checkConflict(
- serverName: name,
- server: server,
- destination: destination,
- configManager: configManager
- ) {
- switch strategy {
- case .skip:
- return CopyResult(
- serverName: name,
- destination: destination,
- success: false,
- message: "Skipped - server already exists",
- renamed: false,
- newName: nil
- )
-
- case .overwrite:
- return try await self.performCopy(
- name: name,
- server: server,
- to: destination,
- configManager: configManager
- )
-
- case .rename:
- let newName = await self.generateUniqueName(
- baseName: name,
- existingNames: Set(self.getExistingServers(
- at: destination,
- configManager: configManager
- ).keys)
- )
- return try await self.performCopy(
- name: newName,
- server: server,
- to: destination,
- configManager: configManager,
- renamed: true,
- originalName: name
- )
-
- case .prompt:
- // Return a result indicating prompt is needed
- return CopyResult(
- serverName: name,
- destination: destination,
- success: false,
- message: "Conflict detected - server '\(conflict.serverName)' already exists",
- renamed: false,
- newName: nil
- )
- }
- }
-
- // No conflict, perform copy directly
- return try await self.performCopy(
- name: name,
- server: server,
- to: destination,
- configManager: configManager
- )
- }
-
- /// Copies a server with a specific conflict resolution.
- func copyServerWithResolution(
- name: String,
- server: MCPServer,
- to destination: CopyDestination,
- resolution: CopyConflict.ConflictResolution,
- configManager: ConfigFileManager = .shared
- ) async throws -> CopyResult {
- switch resolution {
- case .skip:
- CopyResult(
- serverName: name,
- destination: destination,
- success: false,
- message: "Skipped by user",
- renamed: false,
- newName: nil
- )
-
- case .overwrite:
- try await self.performCopy(
- name: name,
- server: server,
- to: destination,
- configManager: configManager
- )
-
- case let .rename(newName):
- try await self.performCopy(
- name: newName,
- server: server,
- to: destination,
- configManager: configManager,
- renamed: true,
- originalName: name
- )
- }
- }
-
- // MARK: Private
-
- /// Generates a unique name by appending -copy, -copy-2, etc.
- private func generateUniqueName(baseName: String, existingNames: Set) -> String {
- var candidate = "\(baseName)-copy"
- var counter = 2
-
- while existingNames.contains(candidate) {
- candidate = "\(baseName)-copy-\(counter)"
- counter += 1
- }
-
- return candidate
- }
-
- /// Gets existing servers at a destination.
- private func getExistingServers(
- at destination: CopyDestination,
- configManager: ConfigFileManager
- ) async -> [String: MCPServer] {
- do {
- switch destination {
- case .global:
- let config = try await configManager.readGlobalConfig()
- return config?.mcpServers ?? [:]
-
- case let .project(path, _):
- let url = URL(fileURLWithPath: path)
- let mcpConfig = try await configManager.readMCPConfig(for: url)
- return mcpConfig?.mcpServers ?? [:]
- }
- } catch {
- Log.general.error("Failed to read servers at destination: \(error)")
- return [:]
- }
- }
-
- /// Performs the actual copy operation.
- private func performCopy(
- name: String,
- server: MCPServer,
- to destination: CopyDestination,
- configManager: ConfigFileManager,
- renamed: Bool = false,
- originalName: String? = nil
- ) async throws -> CopyResult {
- // Deep copy the server (create new instance)
- let copiedServer = self.deepCopy(server: server)
-
- switch destination {
- case .global:
- var config = try await configManager.readGlobalConfig() ?? LegacyConfig()
- if config.mcpServers == nil {
- config.mcpServers = [:]
- }
- config.mcpServers?[name] = copiedServer
- try await configManager.writeGlobalConfig(config)
-
- Log.general.info("Copied server '\(name)' to global config")
-
- case let .project(path, _):
- let url = URL(fileURLWithPath: path)
- var mcpConfig = try await configManager.readMCPConfig(for: url)
- ?? MCPConfig(mcpServers: [:])
- if mcpConfig.mcpServers == nil {
- mcpConfig.mcpServers = [:]
- }
- mcpConfig.mcpServers?[name] = copiedServer
- try await configManager.writeMCPConfig(mcpConfig, for: url)
-
- Log.general.info("Copied server '\(name)' to project at \(path)")
- }
-
- let message = renamed
- ? "Copied '\(originalName ?? name)' as '\(name)'"
- : "Successfully copied '\(name)'"
-
- return CopyResult(
- serverName: name,
- destination: destination,
- success: true,
- message: message,
- renamed: renamed,
- newName: renamed ? name : nil
- )
- }
-
- /// Creates a deep copy of an MCPServer.
- private func deepCopy(server: MCPServer) -> MCPServer {
- // Create a new instance with all the same properties
- // Since MCPServer is a struct, this is a value copy
- // We just need to ensure nested collections are also copied
- var copy = MCPServer()
-
- // Copy stdio properties
- copy.command = server.command
- if let args = server.args {
- copy.args = Array(args)
- }
- if let env = server.env {
- copy.env = Dictionary(uniqueKeysWithValues: env.map { ($0.key, $0.value) })
- }
-
- // Copy HTTP properties
- copy.type = server.type
- copy.url = server.url
- if let headers = server.headers {
- copy.headers = Dictionary(uniqueKeysWithValues: headers.map { ($0.key, $0.value) })
- }
-
- // Copy additional properties
- if let additionalProperties = server.additionalProperties {
- copy.additionalProperties = Dictionary(
- uniqueKeysWithValues: additionalProperties.map { ($0.key, $0.value) }
- )
- }
-
- return copy
- }
-}
diff --git a/Fig/Sources/Services/MCPSharingService.swift b/Fig/Sources/Services/MCPSharingService.swift
deleted file mode 100644
index a7c30a2..0000000
--- a/Fig/Sources/Services/MCPSharingService.swift
+++ /dev/null
@@ -1,430 +0,0 @@
-import AppKit
-import Foundation
-import OSLog
-
-// MARK: - MCPSharingError
-
-/// Errors that can occur during MCP sharing operations.
-enum MCPSharingError: Error, LocalizedError {
- case invalidJSON(String)
- case noServersFound
- case serializationFailed(underlying: Error)
- case importFailed(underlying: Error)
-
- // MARK: Internal
-
- var errorDescription: String? {
- switch self {
- case let .invalidJSON(detail):
- "Invalid JSON: \(detail)"
- case .noServersFound:
- "No MCP servers found in the provided JSON."
- case let .serializationFailed(error):
- "Serialization failed: \(error.localizedDescription)"
- case let .importFailed(error):
- "Import failed: \(error.localizedDescription)"
- }
- }
-}
-
-// MARK: - BulkImportResult
-
-/// Result of a bulk import operation.
-struct BulkImportResult: Sendable, Equatable {
- let imported: [String]
- let skipped: [String]
- let renamed: [String: String]
- let errors: [String]
-
- var totalImported: Int {
- self.imported.count + self.renamed.count
- }
-
- var summary: String {
- var parts: [String] = []
- if !self.imported.isEmpty {
- parts.append("\(self.imported.count) imported")
- }
- if !self.renamed.isEmpty {
- parts.append("\(self.renamed.count) renamed")
- }
- if !self.skipped.isEmpty {
- parts.append("\(self.skipped.count) skipped")
- }
- if !self.errors.isEmpty {
- parts.append("\(self.errors.count) errors")
- }
- return parts.joined(separator: ", ")
- }
-}
-
-// MARK: - MCPSharingService
-
-/// Service for sharing MCP server configurations via clipboard and bulk import/export.
-actor MCPSharingService {
- // MARK: Lifecycle
-
- private init() {}
-
- // MARK: Internal
-
- static let shared = MCPSharingService()
-
- // MARK: - Serialization
-
- /// Serializes servers to MCPConfig JSON format matching `.mcp.json`.
- ///
- /// When `redactSensitive` is true, replaces sensitive env var values with
- /// `` placeholders and sensitive header values with ``.
- nonisolated func serializeToJSON(
- servers: [String: MCPServer],
- redactSensitive: Bool = false
- ) throws -> String {
- let finalServers = redactSensitive ? self.redactServers(servers) : servers
- let config = MCPConfig(mcpServers: finalServers)
-
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
-
- do {
- let data = try encoder.encode(config)
- guard let json = String(data: data, encoding: .utf8) else {
- throw MCPSharingError.serializationFailed(
- underlying: NSError(
- domain: "MCPSharingService",
- code: 0,
- userInfo: [NSLocalizedDescriptionKey: "Failed to convert data to UTF-8 string"]
- )
- )
- }
- return json
- } catch let error as MCPSharingError {
- throw error
- } catch {
- throw MCPSharingError.serializationFailed(underlying: error)
- }
- }
-
- // MARK: - Parsing
-
- /// Parses JSON text into a dictionary of MCP servers.
- ///
- /// Accepts multiple formats:
- /// - MCPConfig: `{"mcpServers": {"name": {...}, ...}}`
- /// - Flat dict: `{"name": {"command": "..."}, "name2": {...}}`
- /// - Single named: `{"name": {"command": "..."}}`
- /// - Single unnamed: `{"command": "...", "args": [...]}`
- func parseServersFromJSON(_ json: String) throws -> [String: MCPServer] {
- guard let data = json.data(using: .utf8) else {
- throw MCPSharingError.invalidJSON("Input is not valid UTF-8")
- }
-
- // Try MCPConfig format first
- if let config = try? JSONDecoder().decode(MCPConfig.self, from: data),
- let servers = config.mcpServers, !servers.isEmpty
- {
- return servers
- }
-
- // Try flat dictionary of servers
- if let dict = try? JSONDecoder().decode([String: MCPServer].self, from: data) {
- // Filter to entries that look like actual servers (have command or url)
- let validServers = dict.filter { $0.value.command != nil || $0.value.url != nil }
- if !validServers.isEmpty {
- return validServers
- }
- }
-
- // Try single unnamed server
- if let server = try? JSONDecoder().decode(MCPServer.self, from: data),
- server.command != nil || server.url != nil
- {
- return ["server": server]
- }
-
- throw MCPSharingError.noServersFound
- }
-
- // MARK: - Sensitive Data Detection
-
- /// Detects sensitive environment variables and headers across multiple servers.
- func detectSensitiveData(servers: [String: MCPServer]) -> [SensitiveEnvWarning] {
- var warnings: [SensitiveEnvWarning] = []
-
- for (serverName, server) in servers.sorted(by: { $0.key < $1.key }) {
- // Check env vars
- if let env = server.env {
- for key in env.keys.sorted() {
- if self.isSensitiveKey(key) {
- warnings.append(SensitiveEnvWarning(
- key: "\(serverName).\(key)",
- reason: self.sensitiveReason(for: key)
- ))
- }
- }
- }
-
- // Check headers
- if let headers = server.headers {
- for key in headers.keys.sorted() {
- if self.isSensitiveKey(key) {
- warnings.append(SensitiveEnvWarning(
- key: "\(serverName).\(key)",
- reason: self.sensitiveReason(for: key)
- ))
- }
- }
- }
- }
-
- return warnings
- }
-
- /// Checks if any server has sensitive data.
- func containsSensitiveData(servers: [String: MCPServer]) -> Bool {
- !self.detectSensitiveData(servers: servers).isEmpty
- }
-
- // MARK: - Clipboard Operations
-
- /// Writes MCP config JSON to the system clipboard.
- @MainActor
- func writeToClipboard(
- servers: [String: MCPServer],
- redactSensitive: Bool = false
- ) throws {
- let json = try serializeToJSON(servers: servers, redactSensitive: redactSensitive)
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(json, forType: .string)
- }
-
- /// Reads text content from the system clipboard.
- @MainActor
- func readFromClipboard() -> String? {
- NSPasteboard.general.string(forType: .string)
- }
-
- // MARK: - Bulk Import
-
- /// Imports multiple servers into a destination, handling conflicts by strategy.
- func importServers(
- _ servers: [String: MCPServer],
- to destination: CopyDestination,
- strategy: ConflictStrategy,
- configManager: ConfigFileManager = .shared
- ) async throws -> BulkImportResult {
- var imported: [String] = []
- var skipped: [String] = []
- var renamed: [String: String] = [:]
- var errors: [String] = []
-
- let existingServers = await getExistingServers(
- at: destination,
- configManager: configManager
- )
-
- // Build a set of all names (existing + already imported) to avoid collisions
- var allNames = Set(existingServers.keys)
-
- for (name, server) in servers.sorted(by: { $0.key < $1.key }) {
- let hasConflict = allNames.contains(name)
-
- if hasConflict {
- switch strategy {
- case .skip,
- .prompt:
- skipped.append(name)
- continue
-
- case .overwrite:
- do {
- try await self.writeServer(
- name: name,
- server: server,
- to: destination,
- configManager: configManager
- )
- imported.append(name)
- } catch {
- errors.append("\(name): \(error.localizedDescription)")
- }
-
- case .rename:
- let newName = self.generateUniqueName(baseName: name, existingNames: allNames)
- do {
- try await self.writeServer(
- name: newName,
- server: server,
- to: destination,
- configManager: configManager
- )
- renamed[name] = newName
- allNames.insert(newName)
- } catch {
- errors.append("\(name): \(error.localizedDescription)")
- }
- }
- } else {
- do {
- try await self.writeServer(
- name: name,
- server: server,
- to: destination,
- configManager: configManager
- )
- imported.append(name)
- allNames.insert(name)
- } catch {
- errors.append("\(name): \(error.localizedDescription)")
- }
- }
- }
-
- Log.general.info(
- "Bulk import: \(imported.count) imported, \(renamed.count) renamed, \(skipped.count) skipped, \(errors.count) errors"
- )
-
- return BulkImportResult(
- imported: imported,
- skipped: skipped,
- renamed: renamed,
- errors: errors
- )
- }
-
- // MARK: Private
-
- // MARK: - Sensitive Data Helpers
-
- private static let sensitivePatterns = [
- "token", "key", "secret", "password", "passwd",
- "credential", "auth", "api",
- ]
-
- private nonisolated func isSensitiveKey(_ key: String) -> Bool {
- let lowerKey = key.lowercased()
- return Self.sensitivePatterns.contains { lowerKey.contains($0) }
- }
-
- private func sensitiveReason(for key: String) -> String {
- let lowerKey = key.lowercased()
- if lowerKey.contains("token") {
- return "May contain authentication token"
- } else if lowerKey.contains("key") || lowerKey.contains("api") {
- return "May contain API key"
- } else if lowerKey.contains("secret") {
- return "May contain secret value"
- } else if lowerKey.contains("password") || lowerKey.contains("passwd") {
- return "May contain password"
- } else if lowerKey.contains("credential") || lowerKey.contains("auth") {
- return "May contain credentials"
- }
- return "May contain sensitive data"
- }
-
- // MARK: - Redaction
-
- private nonisolated func redactServers(_ servers: [String: MCPServer]) -> [String: MCPServer] {
- var result: [String: MCPServer] = [:]
- for (name, server) in servers {
- result[name] = self.redactServer(server)
- }
- return result
- }
-
- private nonisolated func redactServer(_ server: MCPServer) -> MCPServer {
- var redacted = server
-
- // Redact sensitive env vars
- if let env = server.env {
- var redactedEnv: [String: String] = [:]
- for (key, value) in env {
- if self.isSensitiveKey(key) {
- redactedEnv[key] = ""
- } else {
- redactedEnv[key] = value
- }
- }
- redacted.env = redactedEnv
- }
-
- // Redact sensitive headers
- if let headers = server.headers {
- var redactedHeaders: [String: String] = [:]
- for (key, value) in headers {
- if self.isSensitiveKey(key) {
- redactedHeaders[key] = ""
- } else {
- redactedHeaders[key] = value
- }
- }
- redacted.headers = redactedHeaders
- }
-
- return redacted
- }
-
- // MARK: - Name Generation
-
- private func generateUniqueName(baseName: String, existingNames: Set) -> String {
- var candidate = "\(baseName)-copy"
- var counter = 2
-
- while existingNames.contains(candidate) {
- candidate = "\(baseName)-copy-\(counter)"
- counter += 1
- }
-
- return candidate
- }
-
- // MARK: - Destination Helpers
-
- private func getExistingServers(
- at destination: CopyDestination,
- configManager: ConfigFileManager
- ) async -> [String: MCPServer] {
- do {
- switch destination {
- case .global:
- let config = try await configManager.readGlobalConfig()
- return config?.mcpServers ?? [:]
-
- case let .project(path, _):
- let url = URL(fileURLWithPath: path)
- let mcpConfig = try await configManager.readMCPConfig(for: url)
- return mcpConfig?.mcpServers ?? [:]
- }
- } catch {
- Log.general.error("Failed to read servers at destination: \(error)")
- return [:]
- }
- }
-
- private func writeServer(
- name: String,
- server: MCPServer,
- to destination: CopyDestination,
- configManager: ConfigFileManager
- ) async throws {
- switch destination {
- case .global:
- var config = try await configManager.readGlobalConfig() ?? LegacyConfig()
- if config.mcpServers == nil {
- config.mcpServers = [:]
- }
- config.mcpServers?[name] = server
- try await configManager.writeGlobalConfig(config)
-
- case let .project(path, _):
- let url = URL(fileURLWithPath: path)
- var mcpConfig = try await configManager.readMCPConfig(for: url)
- ?? MCPConfig(mcpServers: [:])
- if mcpConfig.mcpServers == nil {
- mcpConfig.mcpServers = [:]
- }
- mcpConfig.mcpServers?[name] = server
- try await configManager.writeMCPConfig(mcpConfig, for: url)
- }
- }
-}
diff --git a/Fig/Sources/Services/NotificationManager.swift b/Fig/Sources/Services/NotificationManager.swift
deleted file mode 100644
index a7c44a9..0000000
--- a/Fig/Sources/Services/NotificationManager.swift
+++ /dev/null
@@ -1,308 +0,0 @@
-import Foundation
-import OSLog
-import SwiftUI
-
-// MARK: - AppNotification
-
-/// A notification to display to the user.
-struct AppNotification: Identifiable, Sendable {
- // MARK: Lifecycle
-
- init(
- id: UUID = UUID(),
- type: NotificationType,
- title: String,
- message: String? = nil,
- dismissAfter: TimeInterval? = nil
- ) {
- self.id = id
- self.type = type
- self.title = title
- self.message = message
- self.dismissAfter = dismissAfter
- }
-
- // MARK: Internal
-
- /// Notification severity levels.
- enum NotificationType: Sendable {
- case success
- case info
- case warning
- case error
-
- // MARK: Internal
-
- var icon: String {
- switch self {
- case .success: "checkmark.circle.fill"
- case .info: "info.circle.fill"
- case .warning: "exclamationmark.triangle.fill"
- case .error: "xmark.circle.fill"
- }
- }
-
- var color: Color {
- switch self {
- case .success: .green
- case .info: .blue
- case .warning: .orange
- case .error: .red
- }
- }
- }
-
- let id: UUID
- let type: NotificationType
- let title: String
- let message: String?
- let dismissAfter: TimeInterval?
-}
-
-// MARK: - NotificationManager
-
-/// Manages application notifications and alerts.
-///
-/// Use this manager to display toast notifications and alerts to the user.
-/// All UI updates are performed on the main actor.
-@MainActor
-@Observable
-final class NotificationManager {
- // MARK: Lifecycle
-
- private init() {}
-
- // MARK: Internal
-
- // MARK: - Alerts
-
- /// Information for displaying an alert dialog.
- struct AlertInfo: Identifiable {
- struct AlertButton {
- // MARK: Lifecycle
-
- init(
- title: String,
- role: ButtonRole? = nil,
- action: (@Sendable () -> Void)? = nil
- ) {
- self.title = title
- self.role = role
- self.action = action
- }
-
- // MARK: Internal
-
- let title: String
- let role: ButtonRole?
- let action: (@Sendable () -> Void)?
-
- static func ok(action: (@Sendable () -> Void)? = nil) -> AlertButton {
- AlertButton(title: "OK", action: action)
- }
-
- static func cancel(action: (@Sendable () -> Void)? = nil) -> AlertButton {
- AlertButton(title: "Cancel", role: .cancel, action: action)
- }
-
- static func destructive(_ title: String, action: (@Sendable () -> Void)? = nil) -> AlertButton {
- AlertButton(title: title, role: .destructive, action: action)
- }
- }
-
- let id = UUID()
- let title: String
- let message: String?
- let primaryButton: AlertButton
- let secondaryButton: AlertButton?
- }
-
- /// Shared instance for app-wide notification management.
- static let shared = NotificationManager()
-
- /// Currently displayed toast notifications.
- private(set) var toasts: [AppNotification] = []
-
- /// Currently displayed alert, if any.
- private(set) var currentAlert: AlertInfo?
-
- // MARK: - Toast Notifications
-
- /// Shows a success toast notification.
- func showSuccess(_ title: String, message: String? = nil) {
- self.showToast(type: .success, title: title, message: message, dismissAfter: 3.0)
- }
-
- /// Shows an info toast notification.
- func showInfo(_ title: String, message: String? = nil) {
- self.showToast(type: .info, title: title, message: message, dismissAfter: 4.0)
- }
-
- /// Shows a warning toast notification.
- func showWarning(_ title: String, message: String? = nil) {
- self.showToast(type: .warning, title: title, message: message, dismissAfter: 5.0)
- }
-
- /// Shows an error toast notification.
- func showError(_ title: String, message: String? = nil) {
- self.showToast(type: .error, title: title, message: message, dismissAfter: 6.0)
- }
-
- /// Shows an error from a FigError.
- func showError(_ error: FigError) {
- Log.general.error("Error: \(error.localizedDescription)")
- self.showToast(
- type: .error,
- title: error.localizedDescription,
- message: error.recoverySuggestion,
- dismissAfter: 6.0
- )
- }
-
- /// Shows an error from any Error type.
- func showError(_ error: Error) {
- if let figError = error as? FigError {
- self.showError(figError)
- } else if let configError = error as? ConfigFileError {
- self.showError(FigError(from: configError))
- } else {
- Log.general.error("Error: \(error.localizedDescription)")
- self.showToast(
- type: .error,
- title: "Error",
- message: error.localizedDescription,
- dismissAfter: 6.0
- )
- }
- }
-
- /// Dismisses a specific toast notification.
- func dismissToast(id: UUID) {
- self.dismissTimers[id]?.cancel()
- self.dismissTimers.removeValue(forKey: id)
-
- withAnimation(.easeInOut(duration: 0.2)) {
- self.toasts.removeAll { $0.id == id }
- }
- }
-
- /// Dismisses all toast notifications.
- func dismissAllToasts() {
- for timer in self.dismissTimers.values {
- timer.cancel()
- }
- self.dismissTimers.removeAll()
-
- withAnimation(.easeInOut(duration: 0.2)) {
- self.toasts.removeAll()
- }
- }
-
- /// Shows an alert dialog.
- func showAlert(
- title: String,
- message: String? = nil,
- primaryButton: AlertInfo.AlertButton = .ok(),
- secondaryButton: AlertInfo.AlertButton? = nil
- ) {
- Log.general.info("Alert shown: \(title)")
- self.currentAlert = AlertInfo(
- title: title,
- message: message,
- primaryButton: primaryButton,
- secondaryButton: secondaryButton
- )
- }
-
- /// Shows an error alert dialog.
- func showErrorAlert(_ error: FigError) {
- Log.general.error("Error alert: \(error.localizedDescription)")
- self.showAlert(
- title: "Error",
- message: [error.localizedDescription, error.recoverySuggestion]
- .compactMap(\.self)
- .joined(separator: "\n\n")
- )
- }
-
- /// Shows an error alert for any Error type.
- func showErrorAlert(_ error: Error) {
- if let figError = error as? FigError {
- self.showErrorAlert(figError)
- } else if let configError = error as? ConfigFileError {
- self.showErrorAlert(FigError(from: configError))
- } else {
- Log.general.error("Error alert: \(error.localizedDescription)")
- self.showAlert(title: "Error", message: error.localizedDescription)
- }
- }
-
- /// Shows a confirmation alert.
- func showConfirmation(
- title: String,
- message: String? = nil,
- confirmTitle: String = "Confirm",
- confirmRole: ButtonRole? = nil,
- onConfirm: @escaping @Sendable () -> Void,
- onCancel: (@Sendable () -> Void)? = nil
- ) {
- self.showAlert(
- title: title,
- message: message,
- primaryButton: AlertInfo.AlertButton(title: confirmTitle, role: confirmRole, action: onConfirm),
- secondaryButton: .cancel(action: onCancel)
- )
- }
-
- /// Dismisses the current alert.
- func dismissAlert() {
- self.currentAlert = nil
- }
-
- // MARK: Private
-
- /// Auto-dismiss timers for toasts.
- private var dismissTimers: [UUID: Task] = [:]
-
- /// Shows a toast notification.
- private func showToast(
- type: AppNotification.NotificationType,
- title: String,
- message: String?,
- dismissAfter: TimeInterval?
- ) {
- let notification = AppNotification(
- type: type,
- title: title,
- message: message,
- dismissAfter: dismissAfter
- )
-
- // Log based on type
- switch type {
- case .success:
- Log.general.info("Success: \(title)")
- case .info:
- Log.general.info("Info: \(title)")
- case .warning:
- Log.general.warning("Warning: \(title)")
- case .error:
- Log.general.error("Error: \(title)")
- }
-
- withAnimation(.easeInOut(duration: 0.2)) {
- self.toasts.append(notification)
- }
-
- // Set up auto-dismiss if specified
- if let dismissAfter {
- let id = notification.id
- self.dismissTimers[id] = Task {
- try? await Task.sleep(for: .seconds(dismissAfter))
- if !Task.isCancelled {
- self.dismissToast(id: id)
- }
- }
- }
- }
-}
diff --git a/Fig/Sources/Services/PermissionRuleCopyService.swift b/Fig/Sources/Services/PermissionRuleCopyService.swift
deleted file mode 100644
index 0ee2f70..0000000
--- a/Fig/Sources/Services/PermissionRuleCopyService.swift
+++ /dev/null
@@ -1,192 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - PermissionRuleCopyService
-
-/// Service for copying permission rules between configuration scopes.
-actor PermissionRuleCopyService {
- // MARK: Lifecycle
-
- private init() {}
-
- // MARK: Internal
-
- static let shared = PermissionRuleCopyService()
-
- /// Copies a permission rule to the specified destination scope.
- ///
- /// - Parameters:
- /// - rule: The rule pattern string (e.g., "Bash(npm run *)").
- /// - type: Whether this is an allow or deny rule.
- /// - destination: The target config scope.
- /// - projectPath: The project URL (required for project-level destinations).
- /// - Returns: Whether the rule was actually added (false if duplicate).
- @discardableResult
- func copyRule(
- rule: String,
- type: PermissionType,
- to destination: ConfigSource,
- projectPath: URL? = nil,
- configManager: ConfigFileManager = .shared
- ) async throws -> Bool {
- var settings = try await loadSettings(
- for: destination,
- projectPath: projectPath,
- configManager: configManager
- ) ?? ClaudeSettings()
-
- var permissions = settings.permissions ?? Permissions()
-
- switch type {
- case .allow:
- var allow = permissions.allow ?? []
- guard !allow.contains(rule) else {
- Log.general.info("Rule already exists at destination: \(rule)")
- return false
- }
- allow.append(rule)
- permissions.allow = allow
-
- case .deny:
- var deny = permissions.deny ?? []
- guard !deny.contains(rule) else {
- Log.general.info("Rule already exists at destination: \(rule)")
- return false
- }
- deny.append(rule)
- permissions.deny = deny
- }
-
- settings.permissions = permissions
-
- try await self.writeSettings(
- settings,
- for: destination,
- projectPath: projectPath,
- configManager: configManager
- )
-
- Log.general.info("Copied rule '\(rule)' to \(destination.displayName)")
- return true
- }
-
- /// Removes a permission rule from the specified scope.
- ///
- /// - Parameters:
- /// - rule: The rule pattern string.
- /// - type: Whether this is an allow or deny rule.
- /// - source: The config scope to remove from.
- /// - projectPath: The project URL (required for project-level sources).
- func removeRule(
- rule: String,
- type: PermissionType,
- from source: ConfigSource,
- projectPath: URL? = nil,
- configManager: ConfigFileManager = .shared
- ) async throws {
- var settings = try await loadSettings(
- for: source,
- projectPath: projectPath,
- configManager: configManager
- ) ?? ClaudeSettings()
-
- var permissions = settings.permissions ?? Permissions()
-
- switch type {
- case .allow:
- permissions.allow?.removeAll { $0 == rule }
- if permissions.allow?.isEmpty == true {
- permissions.allow = nil
- }
- case .deny:
- permissions.deny?.removeAll { $0 == rule }
- if permissions.deny?.isEmpty == true {
- permissions.deny = nil
- }
- }
-
- if permissions.allow == nil, permissions.deny == nil,
- permissions.additionalProperties == nil
- {
- settings.permissions = nil
- } else {
- settings.permissions = permissions
- }
-
- try await self.writeSettings(
- settings,
- for: source,
- projectPath: projectPath,
- configManager: configManager
- )
-
- Log.general.info("Removed rule '\(rule)' from \(source.displayName)")
- }
-
- /// Checks if a rule already exists at the destination.
- func isDuplicate(
- rule: String,
- type: PermissionType,
- at destination: ConfigSource,
- projectPath: URL? = nil,
- configManager: ConfigFileManager = .shared
- ) async throws -> Bool {
- let settings = try await loadSettings(
- for: destination,
- projectPath: projectPath,
- configManager: configManager
- )
- let existingRules: [String] = switch type {
- case .allow:
- settings?.permissions?.allow ?? []
- case .deny:
- settings?.permissions?.deny ?? []
- }
- return existingRules.contains(rule)
- }
-
- // MARK: Private
-
- private func loadSettings(
- for source: ConfigSource,
- projectPath: URL?,
- configManager: ConfigFileManager
- ) async throws -> ClaudeSettings? {
- switch source {
- case .global:
- return try await configManager.readGlobalSettings()
- case .projectShared:
- guard let projectPath else {
- throw FigError.invalidConfiguration(message: "Project path is required for project-level settings")
- }
- return try await configManager.readProjectSettings(for: projectPath)
- case .projectLocal:
- guard let projectPath else {
- throw FigError.invalidConfiguration(message: "Project path is required for project-level settings")
- }
- return try await configManager.readProjectLocalSettings(for: projectPath)
- }
- }
-
- private func writeSettings(
- _ settings: ClaudeSettings,
- for source: ConfigSource,
- projectPath: URL?,
- configManager: ConfigFileManager
- ) async throws {
- switch source {
- case .global:
- try await configManager.writeGlobalSettings(settings)
- case .projectShared:
- guard let projectPath else {
- throw FigError.invalidConfiguration(message: "Project path is required for project-level settings")
- }
- try await configManager.writeProjectSettings(settings, for: projectPath)
- case .projectLocal:
- guard let projectPath else {
- throw FigError.invalidConfiguration(message: "Project path is required for project-level settings")
- }
- try await configManager.writeProjectLocalSettings(settings, for: projectPath)
- }
- }
-}
diff --git a/Fig/Sources/Services/ProjectDiscoveryService.swift b/Fig/Sources/Services/ProjectDiscoveryService.swift
deleted file mode 100644
index 655fa8c..0000000
--- a/Fig/Sources/Services/ProjectDiscoveryService.swift
+++ /dev/null
@@ -1,319 +0,0 @@
-import Foundation
-
-// MARK: - ProjectDiscoveryService
-
-/// Service for discovering Claude Code projects on the user's system.
-///
-/// Discovers projects from two sources:
-/// 1. The `~/.claude.json` legacy config file (projects dictionary)
-/// 2. Optional filesystem scanning of common directories
-///
-/// Example usage:
-/// ```swift
-/// let service = ProjectDiscoveryService(configManager: ConfigFileManager.shared)
-/// let projects = await service.discoverProjects(scanDirectories: true)
-/// for project in projects {
-/// print("\(project.displayName): \(project.path)")
-/// }
-/// ```
-actor ProjectDiscoveryService {
- // MARK: Lifecycle
-
- init(
- configManager: ConfigFileManager = .shared,
- fileManager: FileManager = .default
- ) {
- self.configManager = configManager
- self.fileManager = fileManager
- }
-
- // MARK: Internal
-
- /// Default directories to scan for Claude projects.
- ///
- /// Includes the user's home directory (`"~"`) as well as common development folders
- /// like `~/code`, `~/projects`, etc. Scanning the home directory can be expensive on
- /// systems with many files or nested directories, even when the scan depth is limited.
- /// Callers that enable directory scanning via `discoverProjects(scanDirectories:directories:)`
- /// should be aware of this potential performance cost and may wish to provide a more
- /// targeted set of directories instead of relying on these defaults.
- static let defaultScanDirectories: [String] = [
- "~",
- "~/code",
- "~/Code",
- "~/projects",
- "~/Projects",
- "~/Developer",
- "~/dev",
- "~/src",
- "~/repos",
- "~/github",
- "~/workspace",
- ]
-
- /// Discovers all Claude Code projects.
- ///
- /// - Parameters:
- /// - scanDirectories: Whether to scan common directories for `.claude/` folders.
- /// - directories: Custom directories to scan (defaults to common locations).
- /// - Returns: Array of discovered projects, sorted by last modified (most recent first).
- func discoverProjects(
- scanDirectories: Bool = false,
- directories: [String]? = nil
- ) async throws -> [DiscoveredProject] {
- var allPaths = Set()
-
- // 1. Discover from legacy config (fault-tolerant: continue if config is corrupted)
- do {
- let legacyPaths = try await discoverFromLegacyConfig()
- allPaths.formUnion(legacyPaths)
- } catch {
- // Log error but continue with other discovery sources
- // Legacy config may be missing or corrupted, but we can still scan directories
- }
-
- // 2. Optionally scan directories
- if scanDirectories {
- let dirs = directories ?? Self.defaultScanDirectories
- let scannedPaths = await scanForProjects(in: dirs)
- allPaths.formUnion(scannedPaths)
- }
-
- // 3. Build discovered project entries
- var projects: [DiscoveredProject] = []
- for path in allPaths {
- if let project = await buildDiscoveredProject(from: path) {
- projects.append(project)
- }
- }
-
- // 4. Sort by last modified (most recent first)
- return projects.sorted { first, second in
- switch (first.lastModified, second.lastModified) {
- case let (date1?, date2?):
- date1 > date2
- case (nil, _?):
- false
- case (_?, nil):
- true
- case (nil, nil):
- first.displayName.localizedCaseInsensitiveCompare(second.displayName) == .orderedAscending
- }
- }
- }
-
- /// Discovers projects from the legacy config file only.
- ///
- /// - Returns: Array of project paths from `~/.claude.json`.
- func discoverFromLegacyConfig() async throws -> [String] {
- guard let config = try await configManager.readGlobalConfig() else {
- return []
- }
-
- return config.projectPaths.compactMap { path in
- self.canonicalizePath(path)
- }
- }
-
- /// Scans directories for Claude projects (directories containing `.claude/`).
- ///
- /// - Parameter directories: Directories to scan.
- /// - Returns: Array of discovered project paths.
- func scanForProjects(in directories: [String]) async -> [String] {
- var discoveredPaths = Set()
-
- for directory in directories {
- let expandedPath = self.expandPath(directory)
- guard let canonicalPath = canonicalizePath(expandedPath) else {
- continue
- }
-
- let paths = await scanDirectory(canonicalPath, maxDepth: 3)
- discoveredPaths.formUnion(paths)
- }
-
- return Array(discoveredPaths)
- }
-
- /// Refreshes project information for a specific path.
- ///
- /// - Parameter path: The project path to refresh.
- /// - Returns: Updated discovered project, or nil if the path is invalid.
- func refreshProject(at path: String) async -> DiscoveredProject? {
- await self.buildDiscoveredProject(from: path)
- }
-
- // MARK: Private
-
- /// Directories to skip during scanning.
- private static let skipDirectories: Set = [
- "node_modules",
- ".git",
- ".svn",
- ".hg",
- "vendor",
- "Pods",
- ".build",
- "build",
- "dist",
- "target",
- "__pycache__",
- ".venv",
- "venv",
- ".cache",
- "Library",
- "Applications",
- ]
-
- private let configManager: ConfigFileManager
- private let fileManager: FileManager
-
- /// Builds a DiscoveredProject from a path.
- private func buildDiscoveredProject(from path: String) async -> DiscoveredProject? {
- guard let canonicalPath = canonicalizePath(path) else {
- return nil
- }
-
- let url = URL(fileURLWithPath: canonicalPath)
- let exists = self.fileManager.fileExists(atPath: canonicalPath)
- let displayName = url.lastPathComponent
-
- // Check for config files
- let claudeDir = url.appendingPathComponent(".claude")
- let settingsPath = claudeDir.appendingPathComponent("settings.json").path
- let localSettingsPath = claudeDir.appendingPathComponent("settings.local.json").path
- let mcpConfigPath = url.appendingPathComponent(".mcp.json").path
-
- let hasSettings = self.isFile(atPath: settingsPath)
- let hasLocalSettings = self.isFile(atPath: localSettingsPath)
- let hasMCPConfig = self.isFile(atPath: mcpConfigPath)
-
- // Get last modified date
- let lastModified = self.getLastModifiedDate(for: url)
-
- return DiscoveredProject(
- path: canonicalPath,
- displayName: displayName,
- exists: exists,
- hasSettings: hasSettings,
- hasLocalSettings: hasLocalSettings,
- hasMCPConfig: hasMCPConfig,
- lastModified: lastModified
- )
- }
-
- /// Scans a directory for Claude projects.
- private func scanDirectory(_ path: String, maxDepth: Int) async -> [String] {
- guard maxDepth > 0 else {
- return []
- }
-
- var discoveredPaths: [String] = []
- let url = URL(fileURLWithPath: path)
-
- // Check if this directory itself is a Claude project
- let claudeDir = url.appendingPathComponent(".claude")
- if self.isDirectory(atPath: claudeDir.path) {
- discoveredPaths.append(path)
- }
-
- // Scan subdirectories
- guard let contents = try? fileManager.contentsOfDirectory(
- at: url,
- includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
- options: [.skipsHiddenFiles, .skipsPackageDescendants]
- ) else {
- return discoveredPaths
- }
-
- for item in contents {
- guard let resourceValues = try? item.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]),
- resourceValues.isDirectory == true,
- resourceValues.isSymbolicLink != true
- else {
- continue
- }
-
- // Skip common non-project directories
- let name = item.lastPathComponent
- guard !Self.skipDirectories.contains(name) else {
- continue
- }
-
- let subPaths = await scanDirectory(item.path, maxDepth: maxDepth - 1)
- discoveredPaths.append(contentsOf: subPaths)
- }
-
- return discoveredPaths
- }
-
- /// Checks if a path exists and is a regular file (not a directory).
- private func isFile(atPath path: String) -> Bool {
- var isDirectory: ObjCBool = false
- let exists = self.fileManager.fileExists(atPath: path, isDirectory: &isDirectory)
- return exists && !isDirectory.boolValue
- }
-
- /// Checks if a path exists and is a directory.
- private func isDirectory(atPath path: String) -> Bool {
- var isDirectory: ObjCBool = false
- let exists = self.fileManager.fileExists(atPath: path, isDirectory: &isDirectory)
- return exists && isDirectory.boolValue
- }
-
- /// Expands tilde in path.
- private func expandPath(_ path: String) -> String {
- if path.hasPrefix("~") {
- let homeDir = self.fileManager.homeDirectoryForCurrentUser.path
- return path.replacingOccurrences(of: "~", with: homeDir, options: .anchored)
- }
- return path
- }
-
- /// Canonicalizes a path by expanding tilde and standardizing.
- private func canonicalizePath(_ path: String) -> String? {
- let expandedPath = self.expandPath(path)
- let url = URL(fileURLWithPath: expandedPath)
- let standardized = url.standardized
-
- // Only return if it's a valid absolute path
- guard standardized.path.hasPrefix("/") else {
- return nil
- }
-
- return standardized.path
- }
-
- /// Gets the last modified date for a project.
- private func getLastModifiedDate(for url: URL) -> Date? {
- // Try to get the most recent modification date from config files first
- let configPaths = [
- url.appendingPathComponent(".claude/settings.local.json"),
- url.appendingPathComponent(".claude/settings.json"),
- url.appendingPathComponent(".mcp.json"),
- ]
-
- var mostRecent: Date?
-
- for configPath in configPaths {
- if let attrs = try? fileManager.attributesOfItem(atPath: configPath.path),
- let modDate = attrs[.modificationDate] as? Date
- {
- if mostRecent.map({ modDate > $0 }) ?? true {
- mostRecent = modDate
- }
- }
- }
-
- // Fall back to directory modification date
- if mostRecent == nil {
- if let attrs = try? fileManager.attributesOfItem(atPath: url.path),
- let modDate = attrs[.modificationDate] as? Date
- {
- mostRecent = modDate
- }
- }
-
- return mostRecent
- }
-}
diff --git a/Fig/Sources/Services/SettingsMergeService.swift b/Fig/Sources/Services/SettingsMergeService.swift
deleted file mode 100644
index 0039f5f..0000000
--- a/Fig/Sources/Services/SettingsMergeService.swift
+++ /dev/null
@@ -1,216 +0,0 @@
-import Foundation
-
-// MARK: - SettingsMergeService
-
-/// Service for merging Claude Code settings from multiple configuration levels.
-///
-/// Merge precedence (highest wins):
-/// 1. Project local (`.claude/settings.local.json`)
-/// 2. Project shared (`.claude/settings.json`)
-/// 3. User global (`~/.claude/settings.json`)
-///
-/// Merge semantics:
-/// - `permissions.allow` and `permissions.deny`: union of all arrays
-/// - `env`: higher-precedence keys override lower
-/// - `hooks`: merge by hook type, concatenate hook arrays
-/// - `disallowedTools`: union of all arrays
-/// - Scalar values (attribution, etc.): higher precedence wins
-///
-/// Example usage:
-/// ```swift
-/// let service = SettingsMergeService(configManager: ConfigFileManager.shared)
-/// let merged = try await service.mergeSettings(for: projectURL)
-/// print("Commit attribution: \(merged.attribution?.value.commits ?? false)")
-/// print("Attribution source: \(merged.attribution?.source.displayName ?? "none")")
-/// ```
-actor SettingsMergeService {
- // MARK: Lifecycle
-
- init(configManager: ConfigFileManager = .shared) {
- self.configManager = configManager
- }
-
- // MARK: Internal
-
- /// Merges settings for a specific project from all configuration levels.
- ///
- /// - Parameter projectPath: The project directory path.
- /// - Returns: The merged settings with source tracking for each value.
- func mergeSettings(for projectPath: URL) async throws -> MergedSettings {
- // Load settings from all levels
- let global = try await configManager.readGlobalSettings()
- let projectShared = try await configManager.readProjectSettings(for: projectPath)
- let projectLocal = try await configManager.readProjectLocalSettings(for: projectPath)
-
- // Build source-value pairs for merging (order: lowest to highest precedence)
- let settingsWithSources: [(ClaudeSettings?, ConfigSource)] = [
- (global, .global),
- (projectShared, .projectShared),
- (projectLocal, .projectLocal),
- ]
-
- return self.merge(settingsWithSources)
- }
-
- /// Merges settings from pre-loaded settings objects.
- ///
- /// Useful for testing or when settings are already loaded.
- ///
- /// - Parameters:
- /// - global: Global settings (lowest precedence).
- /// - projectShared: Project shared settings.
- /// - projectLocal: Project local settings (highest precedence).
- /// - Returns: The merged settings with source tracking.
- func mergeSettings(
- global: ClaudeSettings?,
- projectShared: ClaudeSettings?,
- projectLocal: ClaudeSettings?
- ) -> MergedSettings {
- let settingsWithSources: [(ClaudeSettings?, ConfigSource)] = [
- (global, .global),
- (projectShared, .projectShared),
- (projectLocal, .projectLocal),
- ]
-
- return self.merge(settingsWithSources)
- }
-
- // MARK: Private
-
- private let configManager: ConfigFileManager
-
- /// Core merge logic.
- private func merge(_ settingsWithSources: [(ClaudeSettings?, ConfigSource)]) -> MergedSettings {
- let permissions = self.mergePermissions(from: settingsWithSources)
- let env = self.mergeEnv(from: settingsWithSources)
- let hooks = self.mergeHooks(from: settingsWithSources)
- let disallowedTools = self.mergeDisallowedTools(from: settingsWithSources)
- let attribution = self.mergeAttribution(from: settingsWithSources)
-
- return MergedSettings(
- permissions: permissions,
- env: env,
- hooks: hooks,
- disallowedTools: disallowedTools,
- attribution: attribution
- )
- }
-
- /// Merges permissions by unioning allow and deny arrays.
- private func mergePermissions(
- from sources: [(ClaudeSettings?, ConfigSource)]
- ) -> MergedPermissions {
- var allowEntries: [MergedValue] = []
- var denyEntries: [MergedValue] = []
- var seenAllow = Set()
- var seenDeny = Set()
-
- for (settings, source) in sources {
- guard let permissions = settings?.permissions else {
- continue
- }
-
- // Union allow patterns (deduplicated)
- for pattern in permissions.allow ?? [] {
- if !seenAllow.contains(pattern) {
- seenAllow.insert(pattern)
- allowEntries.append(MergedValue(value: pattern, source: source))
- }
- }
-
- // Union deny patterns (deduplicated)
- for pattern in permissions.deny ?? [] {
- if !seenDeny.contains(pattern) {
- seenDeny.insert(pattern)
- denyEntries.append(MergedValue(value: pattern, source: source))
- }
- }
- }
-
- return MergedPermissions(allow: allowEntries, deny: denyEntries)
- }
-
- /// Merges environment variables with higher precedence overriding.
- private func mergeEnv(
- from sources: [(ClaudeSettings?, ConfigSource)]
- ) -> [String: MergedValue] {
- var result: [String: MergedValue] = [:]
-
- // Process in order (lowest to highest precedence)
- // Higher precedence overwrites lower
- for (settings, source) in sources {
- guard let env = settings?.env else {
- continue
- }
-
- for (key, value) in env {
- result[key] = MergedValue(value: value, source: source)
- }
- }
-
- return result
- }
-
- /// Merges hooks by event type, concatenating hook arrays.
- private func mergeHooks(
- from sources: [(ClaudeSettings?, ConfigSource)]
- ) -> MergedHooks {
- var result: [String: [MergedValue]] = [:]
-
- // Process in order (lowest to highest precedence)
- // Concatenate arrays for each event type
- for (settings, source) in sources {
- guard let hooks = settings?.hooks else {
- continue
- }
-
- for (eventName, hookGroups) in hooks {
- var existing = result[eventName] ?? []
- for group in hookGroups {
- existing.append(MergedValue(value: group, source: source))
- }
- result[eventName] = existing
- }
- }
-
- return MergedHooks(hooks: result)
- }
-
- /// Merges disallowed tools by unioning arrays.
- private func mergeDisallowedTools(
- from sources: [(ClaudeSettings?, ConfigSource)]
- ) -> [MergedValue] {
- var result: [MergedValue] = []
- var seen = Set()
-
- for (settings, source) in sources {
- guard let tools = settings?.disallowedTools else {
- continue
- }
-
- for tool in tools {
- if !seen.contains(tool) {
- seen.insert(tool)
- result.append(MergedValue(value: tool, source: source))
- }
- }
- }
-
- return result
- }
-
- /// Merges attribution with higher precedence winning.
- private func mergeAttribution(
- from sources: [(ClaudeSettings?, ConfigSource)]
- ) -> MergedValue? {
- // Find the highest precedence source that has attribution
- // Process in reverse order (highest to lowest precedence)
- for (settings, source) in sources.reversed() {
- if let attribution = settings?.attribution {
- return MergedValue(value: attribution, source: source)
- }
- }
-
- return nil
- }
-}
diff --git a/Fig/Sources/Utilities/Logger.swift b/Fig/Sources/Utilities/Logger.swift
deleted file mode 100644
index f44f1a0..0000000
--- a/Fig/Sources/Utilities/Logger.swift
+++ /dev/null
@@ -1,15 +0,0 @@
-import OSLog
-
-/// Application-wide logging utility using unified logging.
-enum Log {
- // MARK: Internal
-
- static let general = Logger(subsystem: subsystem, category: "general")
- static let ui = Logger(subsystem: subsystem, category: "ui")
- static let fileIO = Logger(subsystem: subsystem, category: "fileIO")
- static let network = Logger(subsystem: subsystem, category: "network")
-
- // MARK: Private
-
- private static let subsystem = Bundle.main.bundleIdentifier ?? "com.fig.app"
-}
diff --git a/Fig/Sources/ViewModels/AppViewModel.swift b/Fig/Sources/ViewModels/AppViewModel.swift
deleted file mode 100644
index 7f3f443..0000000
--- a/Fig/Sources/ViewModels/AppViewModel.swift
+++ /dev/null
@@ -1,19 +0,0 @@
-import Foundation
-
-/// The main application view model responsible for app-wide state management.
-@MainActor
-@Observable
-final class AppViewModel {
- // MARK: Lifecycle
-
- init() {}
-
- // MARK: Internal
-
- private(set) var isLoading: Bool = false
- private(set) var errorMessage: String?
-
- func clearError() {
- self.errorMessage = nil
- }
-}
diff --git a/Fig/Sources/ViewModels/ClaudeMDViewModel.swift b/Fig/Sources/ViewModels/ClaudeMDViewModel.swift
deleted file mode 100644
index 256f3fc..0000000
--- a/Fig/Sources/ViewModels/ClaudeMDViewModel.swift
+++ /dev/null
@@ -1,380 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - ClaudeMDLevel
-
-/// Represents where a CLAUDE.md file sits in the hierarchy.
-enum ClaudeMDLevel: Sendable, Hashable {
- /// Global CLAUDE.md at ~/.claude/CLAUDE.md
- case global
- /// Project root CLAUDE.md
- case projectRoot
- /// Subdirectory CLAUDE.md
- case subdirectory(relativePath: String)
-
- // MARK: Internal
-
- var displayName: String {
- switch self {
- case .global:
- "Global"
- case .projectRoot:
- "Project Root"
- case let .subdirectory(relativePath):
- relativePath
- }
- }
-
- var icon: String {
- switch self {
- case .global:
- "globe"
- case .projectRoot:
- "folder.fill"
- case .subdirectory:
- "folder"
- }
- }
-
- var sortOrder: Int {
- switch self {
- case .global:
- 0
- case .projectRoot:
- 1
- case .subdirectory:
- 2
- }
- }
-}
-
-// MARK: - ClaudeMDFile
-
-/// Represents a single CLAUDE.md file in the hierarchy.
-struct ClaudeMDFile: Identifiable, Sendable {
- let id: String
- let url: URL
- let level: ClaudeMDLevel
- var content: String
- var exists: Bool
- var isTrackedByGit: Bool
-
- var displayPath: String {
- switch self.level {
- case .global:
- "~/.claude/CLAUDE.md"
- case .projectRoot:
- "CLAUDE.md"
- case let .subdirectory(relativePath):
- "\(relativePath)/CLAUDE.md"
- }
- }
-}
-
-// MARK: - ClaudeMDViewModel
-
-/// View model for managing CLAUDE.md files in the project hierarchy.
-@MainActor
-@Observable
-final class ClaudeMDViewModel {
- // MARK: Lifecycle
-
- init(projectPath: String) {
- self.projectPath = projectPath
- self.projectURL = URL(fileURLWithPath: projectPath)
- }
-
- // MARK: Internal
-
- let projectPath: String
- let projectURL: URL
-
- /// All discovered CLAUDE.md files in the hierarchy.
- private(set) var files: [ClaudeMDFile] = []
-
- /// Whether files are being loaded.
- private(set) var isLoading = false
-
- /// The currently selected file ID.
- var selectedFileID: String?
-
- /// Whether we're in editing mode.
- var isEditing = false
-
- /// Content being edited.
- var editContent = ""
-
- /// The currently selected file.
- var selectedFile: ClaudeMDFile? {
- guard let selectedFileID else {
- return nil
- }
- return self.files.first { $0.id == selectedFileID }
- }
-
- /// Discovers and loads all CLAUDE.md files in the hierarchy.
- func loadFiles() async {
- self.isLoading = true
- var discovered: [ClaudeMDFile] = []
-
- // 1. Global CLAUDE.md (~/.claude/CLAUDE.md)
- let globalURL = FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".claude")
- .appendingPathComponent("CLAUDE.md")
- let globalFile = await loadFile(url: globalURL, level: .global)
- discovered.append(globalFile)
-
- // 2. Project root CLAUDE.md
- let projectRootURL = self.projectURL.appendingPathComponent("CLAUDE.md")
- let projectFile = await loadFile(url: projectRootURL, level: .projectRoot)
- discovered.append(projectFile)
-
- // 3. Subdirectory CLAUDE.md files
- let subdirFiles = await discoverSubdirectoryFiles()
- discovered.append(contentsOf: subdirFiles)
-
- self.files = discovered
-
- // Auto-select the first existing file, or the project root
- if self.selectedFileID == nil {
- let firstExisting = self.files.first { $0.exists }
- self.selectedFileID = firstExisting?.id ?? self.files.first { $0.level == .projectRoot }?.id
- }
-
- self.isLoading = false
- }
-
- /// Saves content to the selected file.
- func saveSelectedFile() async {
- guard let selectedFileID,
- let fileIndex = files.firstIndex(where: { $0.id == selectedFileID })
- else {
- return
- }
-
- let url = self.files[fileIndex].url
-
- do {
- // Ensure parent directory exists
- let parentDir = url.deletingLastPathComponent()
- if !FileManager.default.fileExists(atPath: parentDir.path) {
- try FileManager.default.createDirectory(
- at: parentDir,
- withIntermediateDirectories: true
- )
- }
-
- try self.editContent.write(to: url, atomically: true, encoding: .utf8)
-
- self.files[fileIndex].content = self.editContent
- self.files[fileIndex].exists = true
- self.files[fileIndex].isTrackedByGit = await self.checkGitStatus(for: url)
-
- self.isEditing = false
- NotificationManager.shared.showSuccess(
- "Saved",
- message: "CLAUDE.md saved successfully"
- )
- Log.fileIO.info("Saved CLAUDE.md at \(url.path)")
- } catch {
- Log.fileIO.error("Failed to save CLAUDE.md: \(error)")
- NotificationManager.shared.showError(error)
- }
- }
-
- /// Creates a new CLAUDE.md file at the given level.
- func createFile(at level: ClaudeMDLevel) async {
- let url: URL = switch level {
- case .global:
- FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".claude")
- .appendingPathComponent("CLAUDE.md")
- case .projectRoot:
- self.projectURL.appendingPathComponent("CLAUDE.md")
- case let .subdirectory(relativePath):
- self.projectURL
- .appendingPathComponent(relativePath)
- .appendingPathComponent("CLAUDE.md")
- }
-
- do {
- let parentDir = url.deletingLastPathComponent()
- if !FileManager.default.fileExists(atPath: parentDir.path) {
- try FileManager.default.createDirectory(
- at: parentDir,
- withIntermediateDirectories: true
- )
- }
-
- let defaultContent = "# CLAUDE.md\n\n"
- try defaultContent.write(to: url, atomically: true, encoding: .utf8)
-
- await self.loadFiles()
- self.selectedFileID = url.path
- self.editContent = defaultContent
- self.isEditing = true
-
- Log.fileIO.info("Created CLAUDE.md at \(url.path)")
- } catch {
- Log.fileIO.error("Failed to create CLAUDE.md: \(error)")
- NotificationManager.shared.showError(error)
- }
- }
-
- /// Starts editing the selected file.
- func startEditing() {
- guard let selectedFile else {
- return
- }
- self.editContent = selectedFile.content
- self.isEditing = true
- }
-
- /// Cancels editing and discards changes.
- func cancelEditing() {
- self.isEditing = false
- self.editContent = ""
- }
-
- /// Reloads the content of the selected file from disk.
- func reloadSelectedFile() async {
- guard let selectedFileID,
- let fileIndex = files.firstIndex(where: { $0.id == selectedFileID })
- else {
- return
- }
-
- let url = self.files[fileIndex].url
- let reloaded = await loadFile(url: url, level: files[fileIndex].level)
- self.files[fileIndex] = reloaded
-
- if self.isEditing {
- self.editContent = reloaded.content
- }
- }
-
- // MARK: Private
-
- /// Directories to skip when scanning for subdirectory CLAUDE.md files.
- private static let skipDirectories: Set = [
- ".git", ".svn", ".hg",
- "node_modules", ".build", "build", "dist", "DerivedData",
- ".venv", "venv", "__pycache__", ".tox",
- "Pods", "Carthage",
- ".next", ".nuxt",
- "vendor", "target",
- ]
-
- private func loadFile(url: URL, level: ClaudeMDLevel) async -> ClaudeMDFile {
- let exists = FileManager.default.fileExists(atPath: url.path)
- var content = ""
- var isTracked = false
-
- if exists {
- content = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
- isTracked = await self.checkGitStatus(for: url)
- }
-
- return ClaudeMDFile(
- id: url.path,
- url: url,
- level: level,
- content: content,
- exists: exists,
- isTrackedByGit: isTracked
- )
- }
-
- private func discoverSubdirectoryFiles() async -> [ClaudeMDFile] {
- let projectURL = self.projectURL
- let skipDirs = Self.skipDirectories
-
- // Run file enumeration off the main actor to avoid blocking the UI
- let discoveredPaths: [(url: URL, relativePath: String)] = await Task.detached {
- var paths: [(url: URL, relativePath: String)] = []
- let fm = FileManager.default
-
- guard let enumerator = fm.enumerator(
- at: projectURL,
- includingPropertiesForKeys: [.isDirectoryKey],
- options: [.skipsHiddenFiles]
- ) else {
- return paths
- }
-
- while let itemURL = enumerator.nextObject() as? URL {
- let dirName = itemURL.lastPathComponent
-
- // Skip excluded directories
- if skipDirs.contains(dirName) {
- enumerator.skipDescendants()
- continue
- }
-
- // Only check 3 levels deep
- let relative = itemURL.path.dropFirst(projectURL.path.count + 1)
- let depth = relative.components(separatedBy: "/").count
- if depth > 3 {
- enumerator.skipDescendants()
- continue
- }
-
- // Check for CLAUDE.md in this directory
- let resourceValues = try? itemURL.resourceValues(forKeys: [.isDirectoryKey])
- guard resourceValues?.isDirectory == true else {
- continue
- }
-
- let claudeMDURL = itemURL.appendingPathComponent("CLAUDE.md")
- if fm.fileExists(atPath: claudeMDURL.path) {
- let relativePath = String(
- itemURL.path.dropFirst(projectURL.path.count + 1)
- )
- paths.append((url: claudeMDURL, relativePath: relativePath))
- }
- }
-
- return paths
- }.value
-
- var results: [ClaudeMDFile] = []
- for entry in discoveredPaths {
- let file = await loadFile(
- url: entry.url,
- level: .subdirectory(relativePath: entry.relativePath)
- )
- results.append(file)
- }
-
- return results.sorted { lhs, rhs in
- lhs.displayPath < rhs.displayPath
- }
- }
-
- private func checkGitStatus(for url: URL) async -> Bool {
- // Determine which directory to run git in
- let isGlobal = url.path.hasPrefix(
- FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent(".claude").path
- )
- let workingDir = isGlobal ? url.deletingLastPathComponent() : self.projectURL
- let filePath = url.path
-
- return await Task.detached {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
- process.arguments = ["ls-files", "--error-unmatch", filePath]
- process.currentDirectoryURL = workingDir
- process.standardOutput = FileHandle.nullDevice
- process.standardError = FileHandle.nullDevice
-
- do {
- try process.run()
- process.waitUntilExit()
- return process.terminationStatus == 0
- } catch {
- return false
- }
- }.value
- }
-}
diff --git a/Fig/Sources/ViewModels/ConfigExportViewModel.swift b/Fig/Sources/ViewModels/ConfigExportViewModel.swift
deleted file mode 100644
index 678e9c5..0000000
--- a/Fig/Sources/ViewModels/ConfigExportViewModel.swift
+++ /dev/null
@@ -1,173 +0,0 @@
-import AppKit
-import Foundation
-import SwiftUI
-
-// MARK: - ConfigExportViewModel
-
-/// View model for the config export flow.
-@MainActor
-@Observable
-final class ConfigExportViewModel {
- // MARK: Lifecycle
-
- init(projectPath: URL, projectName: String) {
- self.projectPath = projectPath
- self.projectName = projectName
-
- // Default to all non-sensitive components
- self.selectedComponents = [.settings, .mcpServers]
- }
-
- // MARK: Internal
-
- /// The project path to export from.
- let projectPath: URL
-
- /// The project name.
- let projectName: String
-
- /// Selected components to export.
- var selectedComponents: Set = []
-
- /// Whether the user has acknowledged sensitive data warning.
- var acknowledgedSensitiveData = false
-
- /// Whether export is in progress.
- private(set) var isExporting = false
-
- /// Error message if export fails.
- private(set) var errorMessage: String?
-
- /// Whether export was successful.
- private(set) var exportSuccessful = false
-
- /// The URL where the bundle was exported.
- private(set) var exportedURL: URL?
-
- /// Available components based on what exists in the project.
- private(set) var availableComponents: Set = []
-
- /// Whether to include local settings (requires acknowledgment).
- var includeLocalSettings: Bool {
- get { self.selectedComponents.contains(.localSettings) }
- set {
- if newValue {
- self.selectedComponents.insert(.localSettings)
- } else {
- self.selectedComponents.remove(.localSettings)
- }
- }
- }
-
- /// Whether the export can proceed.
- var canExport: Bool {
- guard !self.selectedComponents.isEmpty else {
- return false
- }
- guard !self.isExporting else {
- return false
- }
-
- // Must acknowledge if sensitive data is included
- if self.includeLocalSettings, !self.acknowledgedSensitiveData {
- return false
- }
-
- return true
- }
-
- /// Loads available components from the project.
- func loadAvailableComponents() async {
- self.availableComponents = []
-
- do {
- let configManager = ConfigFileManager.shared
-
- // Check for settings
- if let settings = try await configManager.readProjectSettings(for: projectPath),
- !isSettingsEmpty(settings)
- {
- self.availableComponents.insert(.settings)
- }
-
- // Check for local settings
- if let localSettings = try await configManager.readProjectLocalSettings(for: projectPath),
- !isSettingsEmpty(localSettings)
- {
- self.availableComponents.insert(.localSettings)
- }
-
- // Check for MCP config
- if let mcpConfig = try await configManager.readMCPConfig(for: projectPath),
- mcpConfig.mcpServers?.isEmpty == false
- {
- self.availableComponents.insert(.mcpServers)
- }
-
- // Update selected to only include available
- self.selectedComponents = self.selectedComponents.intersection(self.availableComponents)
-
- } catch {
- // Ignore errors, just show what's available
- }
- }
-
- /// Performs the export with a save panel.
- func performExport() async {
- self.isExporting = true
- self.errorMessage = nil
- self.exportSuccessful = false
- self.exportedURL = nil
-
- do {
- // Create bundle
- let bundle = try await ConfigBundleService.shared.exportBundle(
- projectPath: self.projectPath,
- projectName: self.projectName,
- components: self.selectedComponents
- )
-
- // Show save panel
- let savePanel = NSSavePanel()
- savePanel.title = "Export Configuration"
- savePanel.nameFieldStringValue = "\(self.projectName).\(ConfigBundle.fileExtension)"
- savePanel.allowedContentTypes = [.json]
- savePanel.canCreateDirectories = true
-
- let response = await savePanel.begin()
-
- if response == .OK, let url = savePanel.url {
- try ConfigBundleService.shared.writeBundle(bundle, to: url)
- self.exportedURL = url
- self.exportSuccessful = true
-
- NotificationManager.shared.showSuccess(
- "Export successful",
- message: "Configuration exported to \(url.lastPathComponent)"
- )
- }
-
- } catch {
- self.errorMessage = error.localizedDescription
- NotificationManager.shared.showError(
- "Export failed",
- message: error.localizedDescription
- )
- }
-
- self.isExporting = false
- }
-
- // MARK: Private
-
- private func isSettingsEmpty(_ settings: ClaudeSettings) -> Bool {
- let hasPermissions = settings.permissions?.allow?.isEmpty == false ||
- settings.permissions?.deny?.isEmpty == false
- let hasEnv = settings.env?.isEmpty == false
- let hasHooks = settings.hooks != nil
- let hasDisallowed = settings.disallowedTools?.isEmpty == false
- let hasAttribution = settings.attribution != nil
-
- return !hasPermissions && !hasEnv && !hasHooks && !hasDisallowed && !hasAttribution
- }
-}
diff --git a/Fig/Sources/ViewModels/ConfigHealthCheckViewModel.swift b/Fig/Sources/ViewModels/ConfigHealthCheckViewModel.swift
deleted file mode 100644
index 9fcb4fe..0000000
--- a/Fig/Sources/ViewModels/ConfigHealthCheckViewModel.swift
+++ /dev/null
@@ -1,166 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - ConfigHealthCheckViewModel
-
-/// View model for running and displaying config health check results.
-@MainActor
-@Observable
-final class ConfigHealthCheckViewModel {
- // MARK: Lifecycle
-
- init(
- projectPath: URL,
- configManager: ConfigFileManager = .shared
- ) {
- self.projectPath = projectPath
- self.configManager = configManager
- }
-
- // MARK: Internal
-
- /// The project path being checked.
- let projectPath: URL
-
- /// Current findings from the last check run.
- private(set) var findings: [Finding] = []
-
- /// Whether checks are currently running.
- private(set) var isRunning = false
-
- /// When checks were last run.
- private(set) var lastRunDate: Date?
-
- /// Count of findings by severity.
- var severityCounts: [Severity: Int] {
- Dictionary(grouping: self.findings, by: \.severity)
- .mapValues(\.count)
- }
-
- /// Findings grouped by severity, ordered from most to least severe.
- var groupedFindings: [(severity: Severity, findings: [Finding])] {
- let grouped = Dictionary(grouping: findings, by: \.severity)
- return Severity.allCases.compactMap { severity in
- guard let items = grouped[severity], !items.isEmpty else {
- return nil
- }
- return (severity, items)
- }
- }
-
- /// Runs all health checks against the current project configuration.
- func runChecks(
- globalSettings: ClaudeSettings?,
- projectSettings: ClaudeSettings?,
- projectLocalSettings: ClaudeSettings?,
- mcpConfig: MCPConfig?,
- legacyConfig: LegacyConfig?,
- localSettingsExists: Bool,
- mcpConfigExists: Bool
- ) {
- self.isRunning = true
-
- let context = HealthCheckContext(
- projectPath: self.projectPath,
- globalSettings: globalSettings,
- projectSettings: projectSettings,
- projectLocalSettings: projectLocalSettings,
- mcpConfig: mcpConfig,
- legacyConfig: legacyConfig,
- localSettingsExists: localSettingsExists,
- mcpConfigExists: mcpConfigExists,
- globalConfigFileSize: self.getGlobalConfigFileSize()
- )
-
- self.findings = ConfigHealthCheckService.runAllChecks(context: context)
- self.lastRunDate = Date()
- self.isRunning = false
- }
-
- /// Executes the auto-fix for a finding and re-runs checks.
- func executeAutoFix(
- _ finding: Finding,
- legacyConfig: LegacyConfig?
- ) async {
- guard let autoFix = finding.autoFix else {
- return
- }
-
- do {
- switch autoFix {
- case let .addToDenyList(pattern):
- try await self.addToDenyList(pattern: pattern)
-
- case .createLocalSettings:
- try await self.createLocalSettings()
- }
-
- NotificationManager.shared.showSuccess(
- "Auto-fix Applied",
- message: autoFix.label
- )
-
- // Re-run checks with freshly-read config to reflect the change
- await self.runChecks(
- globalSettings: try? self.configManager.readGlobalSettings(),
- projectSettings: try? self.configManager.readProjectSettings(for: self.projectPath),
- projectLocalSettings: try? self.configManager.readProjectLocalSettings(for: self.projectPath),
- mcpConfig: try? self.configManager.readMCPConfig(for: self.projectPath),
- legacyConfig: legacyConfig,
- localSettingsExists: self.configManager.fileExists(
- at: self.configManager.projectLocalSettingsURL(for: self.projectPath)
- ),
- mcpConfigExists: self.configManager.fileExists(
- at: self.configManager.mcpConfigURL(for: self.projectPath)
- )
- )
- } catch {
- Log.general.error("Auto-fix failed: \(error.localizedDescription)")
- NotificationManager.shared.showError(
- "Auto-fix Failed",
- message: error.localizedDescription
- )
- }
- }
-
- // MARK: Private
-
- private let configManager: ConfigFileManager
-
- /// Gets the file size of `~/.claude.json`.
- private func getGlobalConfigFileSize() -> Int64? {
- let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude.json")
- guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
- let size = attrs[.size] as? Int64
- else {
- return nil
- }
- return size
- }
-
- /// Adds a pattern to the project's deny list in `.claude/settings.json`.
- private func addToDenyList(pattern: String) async throws {
- var settings = try await configManager.readProjectSettings(for: self.projectPath) ?? ClaudeSettings()
-
- var permissions = settings.permissions ?? Permissions()
- var deny = permissions.deny ?? []
-
- guard !deny.contains(pattern) else {
- return
- }
-
- deny.append(pattern)
- permissions.deny = deny
- settings.permissions = permissions
-
- try await self.configManager.writeProjectSettings(settings, for: self.projectPath)
- Log.general.info("Added '\(pattern)' to deny list for \(self.projectPath.lastPathComponent)")
- }
-
- /// Creates an empty `settings.local.json` file.
- private func createLocalSettings() async throws {
- let settings = ClaudeSettings()
- try await configManager.writeProjectLocalSettings(settings, for: self.projectPath)
- Log.general.info("Created settings.local.json for \(self.projectPath.lastPathComponent)")
- }
-}
diff --git a/Fig/Sources/ViewModels/ConfigImportViewModel.swift b/Fig/Sources/ViewModels/ConfigImportViewModel.swift
deleted file mode 100644
index 7708b2d..0000000
--- a/Fig/Sources/ViewModels/ConfigImportViewModel.swift
+++ /dev/null
@@ -1,282 +0,0 @@
-import AppKit
-import Foundation
-import SwiftUI
-
-// MARK: - ImportWizardStep
-
-/// Steps in the import wizard.
-enum ImportWizardStep: Int, CaseIterable {
- case selectFile
- case selectComponents
- case resolveConflicts
- case preview
- case complete
-
- // MARK: Internal
-
- var title: String {
- switch self {
- case .selectFile: "Select File"
- case .selectComponents: "Select Components"
- case .resolveConflicts: "Resolve Conflicts"
- case .preview: "Preview Changes"
- case .complete: "Complete"
- }
- }
-}
-
-// MARK: - ConfigImportViewModel
-
-/// View model for the config import wizard.
-@MainActor
-@Observable
-final class ConfigImportViewModel {
- // MARK: Lifecycle
-
- init(projectPath: URL, projectName: String) {
- self.projectPath = projectPath
- self.projectName = projectName
- }
-
- // MARK: Internal
-
- /// The project to import into.
- let projectPath: URL
- let projectName: String
-
- /// Current wizard step.
- var currentStep: ImportWizardStep = .selectFile
-
- /// The loaded bundle.
- private(set) var bundle: ConfigBundle?
-
- /// URL of the selected file.
- private(set) var selectedFileURL: URL?
-
- /// Selected components to import.
- var selectedComponents: Set = []
-
- /// Detected conflicts.
- private(set) var conflicts: [ImportConflict] = []
-
- /// Conflict resolutions chosen by the user.
- var resolutions: [ConfigBundleComponent: ImportConflict.ImportResolution] = [:]
-
- /// Whether the user has acknowledged sensitive data warning.
- var acknowledgedSensitiveData = false
-
- /// Whether import is in progress.
- private(set) var isImporting = false
-
- /// Whether file is being loaded.
- private(set) var isLoading = false
-
- /// Error message if something fails.
- private(set) var errorMessage: String?
-
- /// The import result.
- private(set) var importResult: ImportResult?
-
- /// Available components in the bundle.
- var availableComponents: [ConfigBundleComponent] {
- var components: [ConfigBundleComponent] = []
- if self.bundle?.settings != nil {
- components.append(.settings)
- }
- if self.bundle?.localSettings != nil {
- components.append(.localSettings)
- }
- if self.bundle?.mcpServers != nil {
- components.append(.mcpServers)
- }
- return components
- }
-
- /// Whether the bundle contains sensitive data.
- var hasSensitiveData: Bool {
- self.bundle?.containsSensitiveData ?? false
- }
-
- /// Whether the current step can proceed.
- var canProceed: Bool {
- switch self.currentStep {
- case .selectFile:
- return self.bundle != nil
- case .selectComponents:
- guard !self.selectedComponents.isEmpty else {
- return false
- }
- if self.selectedComponents.contains(.localSettings), !self.acknowledgedSensitiveData {
- return false
- }
- return true
- case .resolveConflicts:
- // All conflicts must have resolutions
- return self.conflicts.allSatisfy { self.resolutions[$0.component] != nil }
- case .preview:
- return true
- case .complete:
- return true
- }
- }
-
- /// Whether we can go back from the current step.
- var canGoBack: Bool {
- self.currentStep != .selectFile && self.currentStep != .complete
- }
-
- /// Shows the open panel to select a file.
- func selectFile() async {
- let openPanel = NSOpenPanel()
- openPanel.title = "Select Configuration Bundle"
- openPanel.allowedContentTypes = [.json]
- openPanel.canChooseFiles = true
- openPanel.canChooseDirectories = false
- openPanel.allowsMultipleSelection = false
-
- let response = await openPanel.begin()
-
- if response == .OK, let url = openPanel.url {
- await self.loadBundle(from: url)
- }
- }
-
- /// Loads a bundle from a URL.
- func loadBundle(from url: URL) async {
- self.isLoading = true
- self.errorMessage = nil
-
- do {
- self.bundle = try ConfigBundleService.shared.readBundle(from: url)
- self.selectedFileURL = url
-
- // Pre-select all available components
- self.selectedComponents = Set(self.availableComponents)
-
- } catch {
- self.errorMessage = error.localizedDescription
- self.bundle = nil
- self.selectedFileURL = nil
- }
-
- self.isLoading = false
- }
-
- /// Advances to the next step.
- func nextStep() async {
- switch self.currentStep {
- case .selectFile:
- self.currentStep = .selectComponents
-
- case .selectComponents:
- // Detect conflicts
- await self.detectConflicts()
- if self.conflicts.isEmpty {
- self.currentStep = .preview
- } else {
- self.currentStep = .resolveConflicts
- }
-
- case .resolveConflicts:
- self.currentStep = .preview
-
- case .preview:
- await self.performImport()
- self.currentStep = .complete
-
- case .complete:
- break
- }
- }
-
- /// Goes back to the previous step.
- func previousStep() {
- switch self.currentStep {
- case .selectFile:
- break
- case .selectComponents:
- self.currentStep = .selectFile
- case .resolveConflicts:
- self.currentStep = .selectComponents
- case .preview:
- if self.conflicts.isEmpty {
- self.currentStep = .selectComponents
- } else {
- self.currentStep = .resolveConflicts
- }
- case .complete:
- break
- }
- }
-
- /// Resets the wizard.
- func reset() {
- self.currentStep = .selectFile
- self.bundle = nil
- self.selectedFileURL = nil
- self.selectedComponents = []
- self.conflicts = []
- self.resolutions = [:]
- self.acknowledgedSensitiveData = false
- self.errorMessage = nil
- self.importResult = nil
- }
-
- // MARK: Private
-
- private func detectConflicts() async {
- guard let bundle else {
- return
- }
-
- self.conflicts = await ConfigBundleService.shared.detectConflicts(
- bundle: bundle,
- projectPath: self.projectPath,
- components: self.selectedComponents
- )
-
- // Set default resolutions
- for conflict in self.conflicts {
- self.resolutions[conflict.component] = .merge
- }
- }
-
- private func performImport() async {
- guard let bundle else {
- return
- }
-
- self.isImporting = true
- self.errorMessage = nil
-
- do {
- self.importResult = try await ConfigBundleService.shared.importBundle(
- bundle,
- to: self.projectPath,
- components: self.selectedComponents,
- resolutions: self.resolutions
- )
-
- if let result = importResult, result.success {
- NotificationManager.shared.showSuccess(
- "Import successful",
- message: result.message
- )
- } else if let result = importResult, !result.errors.isEmpty {
- NotificationManager.shared.showWarning(
- "Import completed with errors",
- message: result.errors.first ?? "Unknown error"
- )
- }
-
- } catch {
- self.errorMessage = error.localizedDescription
- NotificationManager.shared.showError(
- "Import failed",
- message: error.localizedDescription
- )
- }
-
- self.isImporting = false
- }
-}
diff --git a/Fig/Sources/ViewModels/MCPCopyViewModel.swift b/Fig/Sources/ViewModels/MCPCopyViewModel.swift
deleted file mode 100644
index a9f899e..0000000
--- a/Fig/Sources/ViewModels/MCPCopyViewModel.swift
+++ /dev/null
@@ -1,216 +0,0 @@
-import Foundation
-import SwiftUI
-
-// MARK: - MCPCopyViewModel
-
-/// View model for the MCP server copy flow.
-@MainActor
-@Observable
-final class MCPCopyViewModel {
- // MARK: Lifecycle
-
- init(
- serverName: String,
- server: MCPServer,
- sourceDestination: CopyDestination?,
- configManager: ConfigFileManager = .shared
- ) {
- self.serverName = serverName
- self.server = server
- self.sourceDestination = sourceDestination
- self.configManager = configManager
- self.sensitiveWarnings = []
-
- // Detect sensitive env vars
- Task {
- self.sensitiveWarnings = await MCPServerCopyService.shared
- .detectSensitiveEnvVars(server: server)
- }
- }
-
- // MARK: Internal
-
- /// The name of the server being copied.
- let serverName: String
-
- /// The server configuration being copied.
- let server: MCPServer
-
- /// The source destination (where the server is being copied from).
- let sourceDestination: CopyDestination?
-
- /// Available destinations to copy to.
- private(set) var availableDestinations: [CopyDestination] = []
-
- /// Selected destination.
- var selectedDestination: CopyDestination?
-
- /// Conflict detected during copy.
- private(set) var conflict: CopyConflict?
-
- /// Sensitive environment variable warnings.
- private(set) var sensitiveWarnings: [SensitiveEnvWarning]
-
- /// Whether a copy operation is in progress.
- private(set) var isCopying = false
-
- /// Whether destinations are being loaded.
- private(set) var isLoadingDestinations = false
-
- /// The result of the copy operation.
- private(set) var copyResult: CopyResult?
-
- /// Error message if something goes wrong.
- private(set) var errorMessage: String?
-
- /// New name for the server (when renaming to avoid conflict).
- var renamedServerName: String = ""
-
- /// Whether the user has acknowledged sensitive data warning.
- var acknowledgedSensitiveData = false
-
- /// Whether the copy can proceed.
- var canCopy: Bool {
- guard let destination = selectedDestination else {
- return false
- }
- // Can't copy to same location
- if let source = sourceDestination, source == destination {
- return false
- }
- // Must acknowledge sensitive data if present
- if !self.sensitiveWarnings.isEmpty, !self.acknowledgedSensitiveData {
- return false
- }
- return !self.isCopying
- }
-
- /// Loads available destinations from projects.
- func loadDestinations(projects: [ProjectEntry]) {
- self.isLoadingDestinations = true
-
- var destinations: [CopyDestination] = [.global]
-
- // Add all projects
- for project in projects {
- if let path = project.path {
- destinations.append(.project(
- path: path,
- name: project.name ?? URL(fileURLWithPath: path).lastPathComponent
- ))
- }
- }
-
- // Filter out source if needed
- if let source = sourceDestination {
- destinations = destinations.filter { $0 != source }
- }
-
- self.availableDestinations = destinations
- self.isLoadingDestinations = false
- }
-
- /// Checks for conflicts at the selected destination.
- func checkForConflict() async {
- guard let destination = selectedDestination else {
- self.conflict = nil
- return
- }
-
- self.conflict = await MCPServerCopyService.shared.checkConflict(
- serverName: self.serverName,
- server: self.server,
- destination: destination,
- configManager: self.configManager
- )
-
- // Pre-fill renamed name if conflict exists
- if self.conflict != nil {
- self.renamedServerName = self.serverName + "-copy"
- }
- }
-
- /// Performs the copy with the specified resolution.
- func performCopy(resolution: CopyConflict.ConflictResolution? = nil) async {
- guard let destination = selectedDestination else {
- return
- }
-
- self.isCopying = true
- self.errorMessage = nil
- self.copyResult = nil
-
- do {
- let result: CopyResult = if let conflict, let resolution {
- try await MCPServerCopyService.shared.copyServerWithResolution(
- name: self.serverName,
- server: self.server,
- to: destination,
- resolution: resolution,
- configManager: self.configManager
- )
- } else if conflict != nil {
- // Conflict exists but no resolution provided - skip
- CopyResult(
- serverName: self.serverName,
- destination: destination,
- success: false,
- message: "Conflict not resolved",
- renamed: false,
- newName: nil
- )
- } else {
- // No conflict, direct copy
- try await MCPServerCopyService.shared.copyServer(
- name: self.serverName,
- server: self.server,
- to: destination,
- strategy: .overwrite,
- configManager: self.configManager
- )
- }
-
- self.copyResult = result
-
- if result.success {
- // Show success notification
- NotificationManager.shared.showSuccess(
- "Server copied",
- message: result.message
- )
- }
- } catch {
- self.errorMessage = error.localizedDescription
- NotificationManager.shared.showError(
- "Copy failed",
- message: error.localizedDescription
- )
- }
-
- self.isCopying = false
- }
-
- /// Copies with overwrite resolution.
- func copyWithOverwrite() async {
- await self.performCopy(resolution: .overwrite)
- }
-
- /// Copies with rename resolution.
- func copyWithRename() async {
- let newName = self.renamedServerName.trimmingCharacters(in: .whitespaces)
- guard !newName.isEmpty else {
- self.errorMessage = "Please enter a new name"
- return
- }
- await self.performCopy(resolution: .rename(newName))
- }
-
- /// Skips the copy due to conflict.
- func skipCopy() async {
- await self.performCopy(resolution: .skip)
- }
-
- // MARK: Private
-
- private let configManager: ConfigFileManager
-}
diff --git a/Fig/Sources/ViewModels/MCPPasteViewModel.swift b/Fig/Sources/ViewModels/MCPPasteViewModel.swift
deleted file mode 100644
index d9a0415..0000000
--- a/Fig/Sources/ViewModels/MCPPasteViewModel.swift
+++ /dev/null
@@ -1,172 +0,0 @@
-import Foundation
-
-// MARK: - MCPPasteViewModel
-
-/// View model for the paste/import MCP servers flow.
-@MainActor
-@Observable
-final class MCPPasteViewModel: Identifiable {
- // MARK: Lifecycle
-
- init(
- currentProject: CopyDestination? = nil,
- sharingService: MCPSharingService = .shared
- ) {
- self.currentProject = currentProject
- self.sharingService = sharingService
- self.selectedDestination = currentProject
- }
-
- // MARK: Internal
-
- /// The selected destination for import.
- var selectedDestination: CopyDestination?
-
- /// The conflict strategy to use.
- var conflictStrategy: ConflictStrategy = .rename
-
- /// Whether import is in progress.
- private(set) var isImporting = false
-
- /// Parsed servers from the JSON text.
- private(set) var parsedServers: [String: MCPServer]?
-
- /// Error from parsing.
- private(set) var parseError: String?
-
- /// The import result after a successful import.
- private(set) var importResult: BulkImportResult?
-
- /// Error from import.
- private(set) var errorMessage: String?
-
- /// Available destinations for import.
- private(set) var availableDestinations: [CopyDestination] = []
-
- /// The JSON text entered by the user.
- var jsonText: String = "" {
- didSet {
- self.parseJSON()
- }
- }
-
- /// Number of parsed servers.
- var serverCount: Int {
- self.parsedServers?.count ?? 0
- }
-
- /// Sorted server names from parsed JSON.
- var serverNames: [String] {
- self.parsedServers?.keys.sorted() ?? []
- }
-
- /// Whether the import can proceed.
- var canImport: Bool {
- guard let servers = parsedServers, !servers.isEmpty else {
- return false
- }
- guard self.selectedDestination != nil else {
- return false
- }
- guard !self.isImporting else {
- return false
- }
- return true
- }
-
- /// Whether the import completed successfully.
- var importSucceeded: Bool {
- self.importResult?.totalImported ?? 0 > 0
- }
-
- /// Loads available destinations from projects.
- func loadDestinations(projects: [ProjectEntry]) {
- var destinations: [CopyDestination] = [.global]
-
- for project in projects {
- if let path = project.path, let name = project.name {
- destinations.append(.project(path: path, name: name))
- }
- }
-
- self.availableDestinations = destinations
- }
-
- /// Reads JSON from the system clipboard and sets it as the input text.
- func loadFromClipboard() async {
- if let clipboardText = sharingService.readFromClipboard() {
- self.jsonText = clipboardText
- }
- }
-
- /// Performs the import operation.
- func performImport() async {
- guard let servers = parsedServers, !servers.isEmpty,
- let destination = selectedDestination
- else {
- return
- }
-
- self.isImporting = true
- self.errorMessage = nil
- self.importResult = nil
-
- do {
- let result = try await sharingService.importServers(
- servers,
- to: destination,
- strategy: self.conflictStrategy
- )
-
- self.importResult = result
-
- if result.totalImported > 0 {
- NotificationManager.shared.showSuccess(
- "Import successful",
- message: "\(result.totalImported) server(s) imported"
- )
- } else if !result.skipped.isEmpty {
- NotificationManager.shared.showInfo(
- "Import skipped",
- message: "All servers were skipped due to conflicts"
- )
- }
-
- } catch {
- self.errorMessage = error.localizedDescription
- NotificationManager.shared.showError(
- "Import failed",
- message: error.localizedDescription
- )
- }
-
- self.isImporting = false
- }
-
- // MARK: Private
-
- /// The current project destination (for pre-selection).
- private let currentProject: CopyDestination?
-
- private let sharingService: MCPSharingService
-
- private func parseJSON() {
- self.parsedServers = nil
- self.parseError = nil
-
- let trimmed = self.jsonText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- return
- }
-
- Task {
- do {
- self.parsedServers = try await self.sharingService.parseServersFromJSON(trimmed)
- self.parseError = nil
- } catch {
- self.parsedServers = nil
- self.parseError = error.localizedDescription
- }
- }
- }
-}
diff --git a/Fig/Sources/ViewModels/MCPServerEditorViewModel.swift b/Fig/Sources/ViewModels/MCPServerEditorViewModel.swift
deleted file mode 100644
index f9b4a07..0000000
--- a/Fig/Sources/ViewModels/MCPServerEditorViewModel.swift
+++ /dev/null
@@ -1,226 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - MCPServerEditorViewModel
-
-/// View model for the MCP server add/edit form.
-@MainActor
-@Observable
-final class MCPServerEditorViewModel {
- // MARK: Lifecycle
-
- init(
- formData: MCPServerFormData = MCPServerFormData(),
- projectPath: URL? = nil,
- configManager: ConfigFileManager = .shared,
- notificationManager: NotificationManager = .shared
- ) {
- self.formData = formData
- self.projectPath = projectPath
- self.configManager = configManager
- self.notificationManager = notificationManager
- }
-
- // MARK: Internal
-
- /// The form data being edited.
- var formData: MCPServerFormData
-
- /// Validation errors to display.
- private(set) var validationErrors: [MCPValidationError] = []
-
- /// Whether a save operation is in progress.
- private(set) var isSaving = false
-
- /// The project path for project-scoped servers.
- let projectPath: URL?
-
- /// Whether we're editing an existing server.
- var isEditing: Bool {
- self.formData.isEditing
- }
-
- /// Title for the form.
- var formTitle: String {
- self.isEditing ? "Edit MCP Server" : "Add MCP Server"
- }
-
- /// Whether the form can be saved.
- var canSave: Bool {
- self.validationErrors.isEmpty && !self.isSaving
- }
-
- // MARK: - Factory Methods
-
- /// Creates a view model for adding a new server.
- static func forAdding(
- projectPath: URL?,
- defaultScope: MCPServerScope = .project
- ) -> MCPServerEditorViewModel {
- let formData = MCPServerFormData()
- formData.scope = defaultScope
- return MCPServerEditorViewModel(formData: formData, projectPath: projectPath)
- }
-
- /// Creates a view model for editing an existing server.
- static func forEditing(
- name: String,
- server: MCPServer,
- scope: MCPServerScope,
- projectPath: URL?
- ) -> MCPServerEditorViewModel {
- let formData = MCPServerFormData.from(name: name, server: server, scope: scope)
- return MCPServerEditorViewModel(formData: formData, projectPath: projectPath)
- }
-
- // MARK: - Validation
-
- /// Validates the current form data.
- func validate() {
- Task {
- let existingNames = await getExistingServerNames()
- self.validationErrors = self.formData.validate(existingNames: existingNames)
- }
- }
-
- /// Returns the validation error for a specific field.
- func error(for field: String) -> MCPValidationError? {
- self.validationErrors.first { $0.field == field }
- }
-
- // MARK: - Save
-
- /// Saves the server configuration.
- /// - Returns: `true` if save was successful, `false` otherwise.
- func save() async -> Bool {
- // Validate first
- let existingNames = await getExistingServerNames()
- self.validationErrors = self.formData.validate(existingNames: existingNames)
-
- guard self.validationErrors.isEmpty else {
- return false
- }
-
- self.isSaving = true
- defer { isSaving = false }
-
- do {
- let server = self.formData.toMCPServer()
- let serverName = self.formData.name.trimmingCharacters(in: .whitespaces)
-
- switch self.formData.scope {
- case .project:
- try await self.saveToProject(name: serverName, server: server)
- case .global:
- try await self.saveToGlobal(name: serverName, server: server)
- }
-
- let action = self.isEditing ? "updated" : "added"
- self.notificationManager.showSuccess("Server \(action)", message: "'\(serverName)' saved successfully")
- Log.general.info("MCP server \(action): \(serverName)")
-
- return true
- } catch {
- self.notificationManager.showError(error)
- Log.general.error("Failed to save MCP server: \(error.localizedDescription)")
- return false
- }
- }
-
- // MARK: - Import
-
- /// Imports configuration from JSON.
- func importFromJSON(_ json: String) -> Bool {
- do {
- try self.formData.parseFromJSON(json)
- self.validate()
- return true
- } catch {
- self.notificationManager.showError("Import failed", message: error.localizedDescription)
- return false
- }
- }
-
- /// Imports configuration from a CLI command.
- func importFromCLICommand(_ command: String) -> Bool {
- do {
- try self.formData.parseFromCLICommand(command)
- self.validate()
- return true
- } catch {
- self.notificationManager.showError("Import failed", message: error.localizedDescription)
- return false
- }
- }
-
- // MARK: Private
-
- private let configManager: ConfigFileManager
- private let notificationManager: NotificationManager
-
- private func getExistingServerNames() async -> Set {
- var names = Set()
-
- do {
- switch self.formData.scope {
- case .project:
- if let projectPath {
- let config = try await configManager.readMCPConfig(for: projectPath)
- if let servers = config?.mcpServers {
- names.formUnion(servers.keys)
- }
- }
- case .global:
- let config = try await configManager.readGlobalConfig()
- if let servers = config?.mcpServers {
- names.formUnion(servers.keys)
- }
- }
- } catch {
- Log.general.warning("Failed to read existing server names: \(error.localizedDescription)")
- }
-
- // If editing, remove the original name so we can keep the same name
- if let originalName = formData.originalName {
- names.remove(originalName)
- }
-
- return names
- }
-
- private func saveToProject(name: String, server: MCPServer) async throws {
- guard let projectPath else {
- throw FigError.configurationError(message: "No project path specified for project-scoped server")
- }
-
- var config = try await configManager.readMCPConfig(for: projectPath) ?? MCPConfig()
-
- // If editing and name changed, remove old entry
- if self.isEditing, let originalName = formData.originalName, originalName != name {
- config.mcpServers?.removeValue(forKey: originalName)
- }
-
- if config.mcpServers == nil {
- config.mcpServers = [:]
- }
- config.mcpServers?[name] = server
-
- try await self.configManager.writeMCPConfig(config, for: projectPath)
- }
-
- private func saveToGlobal(name: String, server: MCPServer) async throws {
- var config = try await configManager.readGlobalConfig() ?? LegacyConfig()
-
- // If editing and name changed, remove old entry
- if self.isEditing, let originalName = formData.originalName, originalName != name {
- config.mcpServers?.removeValue(forKey: originalName)
- }
-
- if config.mcpServers == nil {
- config.mcpServers = [:]
- }
- config.mcpServers?[name] = server
-
- try await self.configManager.writeGlobalConfig(config)
- }
-}
diff --git a/Fig/Sources/ViewModels/OnboardingViewModel.swift b/Fig/Sources/ViewModels/OnboardingViewModel.swift
deleted file mode 100644
index 085c621..0000000
--- a/Fig/Sources/ViewModels/OnboardingViewModel.swift
+++ /dev/null
@@ -1,128 +0,0 @@
-import Foundation
-import OSLog
-
-// MARK: - OnboardingViewModel
-
-/// View model for the first-run onboarding experience.
-///
-/// Manages a step-based flow that introduces new users to Fig,
-/// checks filesystem access, discovers existing Claude Code projects,
-/// and provides a brief feature tour.
-@MainActor
-@Observable
-final class OnboardingViewModel {
- // MARK: Lifecycle
-
- init(
- discoveryService: ProjectDiscoveryService = ProjectDiscoveryService(),
- onComplete: @escaping @MainActor () -> Void
- ) {
- self.discoveryService = discoveryService
- self.onComplete = onComplete
- }
-
- // MARK: Internal
-
- /// The steps in the onboarding flow.
- enum Step: Int, CaseIterable, Sendable {
- case welcome = 0
- case permissions = 1
- case discovery = 2
- case tour = 3
- case completion = 4
- }
-
- /// Total number of tour pages.
- static let tourPageCount = 4
-
- /// The current onboarding step.
- private(set) var currentStep: Step = .welcome
-
- /// Whether project discovery is in progress.
- private(set) var isDiscovering = false
-
- /// Projects found during discovery.
- private(set) var discoveredProjects: [DiscoveredProject] = []
-
- /// Error message from discovery, if any.
- private(set) var discoveryError: String?
-
- /// Current page index within the feature tour (0-based).
- var currentTourPage: Int = 0
-
- /// Whether the current step is the first step.
- var isFirstStep: Bool {
- self.currentStep == .welcome
- }
-
- /// Whether the current step is the last step.
- var isLastStep: Bool {
- self.currentStep == .completion
- }
-
- /// Progress fraction (0.0 to 1.0) for the progress indicator.
- var progress: Double {
- guard let maxRaw = Step.allCases.last?.rawValue, maxRaw > 0 else {
- return 0
- }
- return Double(self.currentStep.rawValue) / Double(maxRaw)
- }
-
- /// Advances to the next step, or completes onboarding if on the last step.
- func advance() {
- guard let next = Step(rawValue: self.currentStep.rawValue + 1) else {
- self.completeOnboarding()
- return
- }
- Log.ui.info("Onboarding advancing to step: \(next.rawValue)")
- self.currentStep = next
- }
-
- /// Goes back to the previous step.
- func goBack() {
- guard let previous = Step(rawValue: self.currentStep.rawValue - 1) else {
- return
- }
- self.currentStep = previous
- }
-
- /// Skips the entire onboarding flow and enters the main app.
- func skipToEnd() {
- Log.ui.info("Onboarding skipped entirely")
- self.completeOnboarding()
- }
-
- /// Skips only the feature tour, jumping to the completion screen.
- func skipTour() {
- Log.ui.info("Onboarding tour skipped")
- self.currentStep = .completion
- }
-
- /// Runs project discovery, scanning the filesystem for Claude Code projects.
- func runDiscovery() async {
- self.isDiscovering = true
- self.discoveryError = nil
-
- do {
- self.discoveredProjects = try await self.discoveryService.discoverProjects(
- scanDirectories: true
- )
- Log.ui.info("Onboarding discovered \(self.discoveredProjects.count) projects")
- } catch {
- self.discoveryError = error.localizedDescription
- Log.ui.error("Onboarding discovery failed: \(error.localizedDescription)")
- }
-
- self.isDiscovering = false
- }
-
- // MARK: Private
-
- private let discoveryService: ProjectDiscoveryService
- private let onComplete: @MainActor () -> Void
-
- private func completeOnboarding() {
- Log.ui.info("Onboarding completed")
- self.onComplete()
- }
-}
diff --git a/Fig/Sources/ViewModels/ProjectDetailViewModel.swift b/Fig/Sources/ViewModels/ProjectDetailViewModel.swift
deleted file mode 100644
index 2d55ef6..0000000
--- a/Fig/Sources/ViewModels/ProjectDetailViewModel.swift
+++ /dev/null
@@ -1,445 +0,0 @@
-import AppKit
-import Foundation
-import OSLog
-
-// MARK: - ProjectDetailTab
-
-/// Tabs available in the project detail view.
-enum ProjectDetailTab: String, CaseIterable, Identifiable, Sendable {
- case permissions
- case environment
- case mcpServers
- case hooks
- case claudeMD
- case effectiveConfig
- case healthCheck
- case advanced
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var title: String {
- switch self {
- case .permissions:
- "Permissions"
- case .environment:
- "Environment"
- case .mcpServers:
- "MCP Servers"
- case .hooks:
- "Hooks"
- case .claudeMD:
- "CLAUDE.md"
- case .effectiveConfig:
- "Effective Config"
- case .healthCheck:
- "Health"
- case .advanced:
- "Advanced"
- }
- }
-
- var icon: String {
- switch self {
- case .permissions:
- "lock.shield"
- case .environment:
- "list.bullet.rectangle"
- case .mcpServers:
- "server.rack"
- case .hooks:
- "arrow.triangle.branch"
- case .claudeMD:
- "doc.text"
- case .effectiveConfig:
- "checkmark.rectangle.stack"
- case .healthCheck:
- "stethoscope"
- case .advanced:
- "gearshape.2"
- }
- }
-}
-
-// MARK: - ConfigFileStatus
-
-/// Status of a configuration file.
-struct ConfigFileStatus: Sendable {
- let exists: Bool
- let url: URL
-}
-
-// MARK: - ProjectDetailViewModel
-
-/// View model for displaying project details.
-@MainActor
-@Observable
-final class ProjectDetailViewModel {
- // MARK: Lifecycle
-
- init(projectPath: String, configManager: ConfigFileManager = .shared) {
- self.projectPath = projectPath
- self.projectURL = URL(fileURLWithPath: projectPath)
- self.configManager = configManager
- }
-
- // MARK: Internal
-
- /// The path to the project directory.
- let projectPath: String
-
- /// The URL to the project directory.
- let projectURL: URL
-
- /// The currently selected tab.
- var selectedTab: ProjectDetailTab = .permissions
-
- /// Whether data is currently loading.
- private(set) var isLoading = false
-
- /// The project entry from the global config.
- private(set) var projectEntry: ProjectEntry?
-
- /// The full global legacy config (~/.claude.json).
- private(set) var legacyConfig: LegacyConfig?
-
- /// Global settings.
- private(set) var globalSettings: ClaudeSettings?
-
- /// Project-level shared settings.
- private(set) var projectSettings: ClaudeSettings?
-
- /// Project-level local settings.
- private(set) var projectLocalSettings: ClaudeSettings?
-
- /// MCP configuration for the project.
- private(set) var mcpConfig: MCPConfig?
-
- /// Status of the project settings file.
- private(set) var projectSettingsStatus: ConfigFileStatus?
-
- /// Status of the project local settings file.
- private(set) var projectLocalSettingsStatus: ConfigFileStatus?
-
- /// Status of the MCP config file.
- private(set) var mcpConfigStatus: ConfigFileStatus?
-
- /// The fully merged settings with provenance tracking.
- private(set) var mergedSettings: MergedSettings?
-
- /// Environment variable overrides: keys that appear in multiple sources.
- /// Maps key -> array of (value, source) for all sources, ordered by precedence (lowest first).
- private(set) var envOverrides: [String: [(value: String, source: ConfigSource)]] = [:]
-
- /// The project name derived from the path.
- var projectName: String {
- self.projectURL.lastPathComponent
- }
-
- /// Whether the project directory exists.
- var projectExists: Bool {
- FileManager.default.fileExists(atPath: self.projectPath)
- }
-
- /// All permission rules with their sources.
- var allPermissions: [(rule: String, type: PermissionType, source: ConfigSource)] {
- var permissions: [(rule: String, type: PermissionType, source: ConfigSource)] = []
-
- // Global permissions
- if let global = globalSettings?.permissions {
- for rule in global.allow ?? [] {
- permissions.append((rule, .allow, .global))
- }
- for rule in global.deny ?? [] {
- permissions.append((rule, .deny, .global))
- }
- }
-
- // Project shared permissions
- if let shared = projectSettings?.permissions {
- for rule in shared.allow ?? [] {
- permissions.append((rule, .allow, .projectShared))
- }
- for rule in shared.deny ?? [] {
- permissions.append((rule, .deny, .projectShared))
- }
- }
-
- // Project local permissions
- if let local = projectLocalSettings?.permissions {
- for rule in local.allow ?? [] {
- permissions.append((rule, .allow, .projectLocal))
- }
- for rule in local.deny ?? [] {
- permissions.append((rule, .deny, .projectLocal))
- }
- }
-
- return permissions
- }
-
- /// All environment variables with their sources.
- var allEnvironmentVariables: [(key: String, value: String, source: ConfigSource)] {
- var envVars: [(key: String, value: String, source: ConfigSource)] = []
-
- // Global env vars
- if let global = globalSettings?.env {
- for (key, value) in global.sorted(by: { $0.key < $1.key }) {
- envVars.append((key, value, .global))
- }
- }
-
- // Project shared env vars
- if let shared = projectSettings?.env {
- for (key, value) in shared.sorted(by: { $0.key < $1.key }) {
- envVars.append((key, value, .projectShared))
- }
- }
-
- // Project local env vars
- if let local = projectLocalSettings?.env {
- for (key, value) in local.sorted(by: { $0.key < $1.key }) {
- envVars.append((key, value, .projectLocal))
- }
- }
-
- return envVars
- }
-
- /// All MCP servers with their sources.
- var allMCPServers: [(name: String, server: MCPServer, source: ConfigSource)] {
- var servers: [(name: String, server: MCPServer, source: ConfigSource)] = []
-
- // Project MCP config (.mcp.json)
- if let mcpServers = mcpConfig?.mcpServers {
- for (name, server) in mcpServers.sorted(by: { $0.key < $1.key }) {
- servers.append((name, server, .projectShared))
- }
- }
-
- // Project entry MCP servers (from ~/.claude.json)
- if let entryServers = projectEntry?.mcpServers {
- for (name, server) in entryServers.sorted(by: { $0.key < $1.key }) {
- // Avoid duplicates
- if !servers.contains(where: { $0.name == name }) {
- servers.append((name, server, .global))
- }
- }
- }
-
- return servers
- }
-
- /// All disallowed tools with their sources.
- var allDisallowedTools: [(tool: String, source: ConfigSource)] {
- var tools: [(tool: String, source: ConfigSource)] = []
-
- if let global = globalSettings?.disallowedTools {
- for tool in global {
- tools.append((tool, .global))
- }
- }
-
- if let shared = projectSettings?.disallowedTools {
- for tool in shared {
- tools.append((tool, .projectShared))
- }
- }
-
- if let local = projectLocalSettings?.disallowedTools {
- for tool in local {
- tools.append((tool, .projectLocal))
- }
- }
-
- return tools
- }
-
- /// Attribution settings with their source.
- var attributionSettings: (attribution: Attribution, source: ConfigSource)? {
- // Local overrides shared, shared overrides global
- if let local = projectLocalSettings?.attribution {
- return (local, .projectLocal)
- }
- if let shared = projectSettings?.attribution {
- return (shared, .projectShared)
- }
- if let global = globalSettings?.attribution {
- return (global, .global)
- }
- return nil
- }
-
- /// Loads all configuration data for the project.
- func loadConfiguration() async {
- self.isLoading = true
-
- do {
- // Load global config to get project entry
- let globalConfig = try await configManager.readGlobalConfig()
- self.legacyConfig = globalConfig
- self.projectEntry = globalConfig?.project(at: self.projectPath)
-
- // Load global settings
- self.globalSettings = try await self.configManager.readGlobalSettings()
-
- // Load project settings
- self.projectSettings = try await self.configManager.readProjectSettings(for: self.projectURL)
-
- // Load project local settings
- self.projectLocalSettings = try await self.configManager.readProjectLocalSettings(for: self.projectURL)
-
- // Load MCP config
- self.mcpConfig = try await self.configManager.readMCPConfig(for: self.projectURL)
-
- // Update file statuses
- let settingsURL = await configManager.projectSettingsURL(for: self.projectURL)
- self.projectSettingsStatus = await ConfigFileStatus(
- exists: self.configManager.fileExists(at: settingsURL),
- url: settingsURL
- )
-
- let localSettingsURL = await configManager.projectLocalSettingsURL(for: self.projectURL)
- self.projectLocalSettingsStatus = await ConfigFileStatus(
- exists: self.configManager.fileExists(at: localSettingsURL),
- url: localSettingsURL
- )
-
- let mcpURL = await configManager.mcpConfigURL(for: self.projectURL)
- self.mcpConfigStatus = await ConfigFileStatus(
- exists: self.configManager.fileExists(at: mcpURL),
- url: mcpURL
- )
-
- // Compute merged settings
- let mergeService = SettingsMergeService(configManager: self.configManager)
- self.mergedSettings = await mergeService.mergeSettings(
- global: self.globalSettings,
- projectShared: self.projectSettings,
- projectLocal: self.projectLocalSettings
- )
-
- // Compute env var overrides (keys set in multiple sources)
- self.envOverrides = self.computeEnvOverrides()
-
- Log.general.info("Loaded configuration for project: \(self.projectName)")
- } catch {
- Log.general.error("Failed to load project configuration: \(error.localizedDescription)")
- }
-
- self.isLoading = false
- }
-
- /// Reveals the project in Finder.
- func revealInFinder() {
- NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: self.projectPath)
- }
-
- /// Opens the project in Terminal.
- func openInTerminal() {
- let script = """
- tell application "Terminal"
- do script "cd \(projectPath.replacingOccurrences(of: "\"", with: "\\\""))"
- activate
- end tell
- """
- if let appleScript = NSAppleScript(source: script) {
- var error: NSDictionary?
- appleScript.executeAndReturnError(&error)
- if let error {
- Log.general.error("Failed to open Terminal: \(error)")
- }
- }
- }
-
- // MARK: - MCP Server Management
-
- /// Deletes an MCP server by name and source.
- func deleteMCPServer(name: String, source: ConfigSource) async {
- do {
- switch source {
- case .projectShared:
- // Delete from .mcp.json
- guard var config = mcpConfig else {
- return
- }
- config.mcpServers?.removeValue(forKey: name)
- try await self.configManager.writeMCPConfig(config, for: self.projectURL)
- self.mcpConfig = config
- NotificationManager.shared.showSuccess("Server deleted", message: "'\(name)' removed from project")
-
- case .global:
- // Delete from ~/.claude.json
- guard var globalConfig = try await configManager.readGlobalConfig() else {
- return
- }
- globalConfig.mcpServers?.removeValue(forKey: name)
- try await self.configManager.writeGlobalConfig(globalConfig)
- // Also update projectEntry if it exists
- self.projectEntry = globalConfig.project(at: self.projectPath)
- NotificationManager.shared.showSuccess(
- "Server deleted",
- message: "'\(name)' removed from global config"
- )
-
- case .projectLocal:
- // Local settings don't typically have MCP servers, but handle gracefully
- Log.general.warning("Attempted to delete MCP server from local settings - not supported")
- }
-
- Log.general.info("Deleted MCP server '\(name)' from \(source.label)")
- } catch {
- Log.general.error("Failed to delete MCP server '\(name)': \(error)")
- NotificationManager.shared.showError(error)
- }
- }
-
- // MARK: Private
-
- private let configManager: ConfigFileManager
-
- /// Computes which env var keys have values in multiple sources (for override display).
- private func computeEnvOverrides() -> [String: [(value: String, source: ConfigSource)]] {
- var allValues: [String: [(value: String, source: ConfigSource)]] = [:]
-
- let sources: [(settings: ClaudeSettings?, source: ConfigSource)] = [
- (globalSettings, .global),
- (projectSettings, .projectShared),
- (projectLocalSettings, .projectLocal),
- ]
-
- for (settings, source) in sources {
- guard let env = settings?.env else {
- continue
- }
- for (key, value) in env {
- allValues[key, default: []].append((value, source))
- }
- }
-
- // Only keep keys that appear in more than one source
- return allValues.filter { $0.value.count > 1 }
- }
-}
-
-// MARK: - PermissionType
-
-/// Type of permission rule.
-enum PermissionType: String, Sendable {
- case allow
- case deny
-
- // MARK: Internal
-
- var icon: String {
- switch self {
- case .allow:
- "checkmark.circle.fill"
- case .deny:
- "xmark.circle.fill"
- }
- }
-}
diff --git a/Fig/Sources/ViewModels/ProjectExplorerViewModel.swift b/Fig/Sources/ViewModels/ProjectExplorerViewModel.swift
deleted file mode 100644
index 0aef0ab..0000000
--- a/Fig/Sources/ViewModels/ProjectExplorerViewModel.swift
+++ /dev/null
@@ -1,420 +0,0 @@
-import AppKit
-import Foundation
-import OSLog
-import SwiftUI
-
-// MARK: - FavoritesStorage
-
-/// Handles persistence of favorite and recent projects.
-@MainActor
-@Observable
-final class FavoritesStorage {
- // MARK: Internal
-
- /// Set of favorite project paths.
- private(set) var favoriteProjectPaths: Set = []
-
- /// List of recently opened project paths (most recent first).
- private(set) var recentProjectPaths: [String] = []
-
- /// Maximum number of recent projects to track.
- let maxRecentProjects = 10
-
- /// Loads favorites and recents from UserDefaults.
- func load() {
- if let favorites = UserDefaults.standard.array(forKey: Self.favoritesKey) as? [String] {
- self.favoriteProjectPaths = Set(favorites)
- }
- if let recents = UserDefaults.standard.array(forKey: Self.recentsKey) as? [String] {
- self.recentProjectPaths = recents
- }
- }
-
- /// Adds a project to favorites.
- func addFavorite(_ path: String) {
- self.favoriteProjectPaths.insert(path)
- self.save()
- }
-
- /// Removes a project from favorites.
- func removeFavorite(_ path: String) {
- self.favoriteProjectPaths.remove(path)
- self.save()
- }
-
- /// Toggles a project's favorite status.
- func toggleFavorite(_ path: String) {
- if self.favoriteProjectPaths.contains(path) {
- self.removeFavorite(path)
- } else {
- self.addFavorite(path)
- }
- }
-
- /// Checks if a project is a favorite.
- func isFavorite(_ path: String) -> Bool {
- self.favoriteProjectPaths.contains(path)
- }
-
- /// Records a project as recently opened.
- func recordRecentProject(_ path: String) {
- // Remove if already present
- self.recentProjectPaths.removeAll { $0 == path }
- // Insert at beginning
- self.recentProjectPaths.insert(path, at: 0)
- // Trim to max
- if self.recentProjectPaths.count > self.maxRecentProjects {
- self.recentProjectPaths = Array(self.recentProjectPaths.prefix(self.maxRecentProjects))
- }
- self.save()
- }
-
- /// Removes a project from both favorites and recents.
- func removeProject(_ path: String) {
- self.favoriteProjectPaths.remove(path)
- self.recentProjectPaths.removeAll { $0 == path }
- self.save()
- }
-
- // MARK: Private
-
- private static let favoritesKey = "favoriteProjects"
- private static let recentsKey = "recentProjects"
-
- private func save() {
- UserDefaults.standard.set(Array(self.favoriteProjectPaths), forKey: Self.favoritesKey)
- UserDefaults.standard.set(self.recentProjectPaths, forKey: Self.recentsKey)
- }
-}
-
-// MARK: - ProjectExplorerViewModel
-
-/// View model for the project explorer, managing project discovery and selection.
-@MainActor
-@Observable
-final class ProjectExplorerViewModel {
- // MARK: Lifecycle
-
- init(configManager: ConfigFileManager = .shared) {
- self.configManager = configManager
- self.isGroupedByParent = UserDefaults.standard.bool(forKey: Self.groupByParentKey)
- self.favoritesStorage.load()
- }
-
- // MARK: Internal
-
- /// Storage for favorites and recents.
- let favoritesStorage = FavoritesStorage()
-
- /// All discovered projects from the global config.
- var projects: [ProjectEntry] = []
-
- /// Whether projects are currently being loaded.
- private(set) var isLoading = false
-
- /// The current search query for filtering projects.
- var searchQuery = ""
-
- /// Error message if loading fails.
- private(set) var errorMessage: String?
-
- /// Whether the quick switcher is shown.
- var isQuickSwitcherPresented = false
-
- /// Whether projects are grouped by parent directory in the sidebar.
- var isGroupedByParent: Bool = false {
- didSet {
- UserDefaults.standard.set(self.isGroupedByParent, forKey: Self.groupByParentKey)
- }
- }
-
- /// All projects whose directories no longer exist on disk.
- var missingProjects: [ProjectEntry] {
- self.projects.filter { !self.projectExists($0) }
- }
-
- /// Whether there are any missing projects that can be cleaned up.
- var hasMissingProjects: Bool {
- self.projects.contains { !self.projectExists($0) }
- }
-
- /// Favorite projects.
- var favoriteProjects: [ProjectEntry] {
- self.projects.filter { project in
- guard let path = project.path else {
- return false
- }
- return self.favoritesStorage.isFavorite(path)
- }
- }
-
- /// Recent projects (excluding favorites).
- var recentProjects: [ProjectEntry] {
- let favoritePaths = self.favoritesStorage.favoriteProjectPaths
- return self.favoritesStorage.recentProjectPaths.compactMap { path in
- // Skip if it's a favorite
- guard !favoritePaths.contains(path) else {
- return nil
- }
- return self.projects.first { $0.path == path }
- }
- }
-
- /// Projects filtered by the current search query, excluding favorites and recents.
- var filteredProjects: [ProjectEntry] {
- let favoritePaths = self.favoritesStorage.favoriteProjectPaths
- let recentPaths = Set(favoritesStorage.recentProjectPaths)
-
- let baseProjects = self.projects.filter { project in
- guard let path = project.path else {
- return true
- }
- return !favoritePaths.contains(path) && !recentPaths.contains(path)
- }
-
- if self.searchQuery.isEmpty {
- return baseProjects
- }
- let query = self.searchQuery.lowercased()
- return baseProjects.filter { project in
- let nameMatch = project.name?.lowercased().contains(query) ?? false
- let pathMatch = project.path?.lowercased().contains(query) ?? false
- return nameMatch || pathMatch
- }
- }
-
- /// All projects matching the search query (for quick switcher).
- var searchResults: [ProjectEntry] {
- if self.searchQuery.isEmpty {
- return self.projects
- }
- let query = self.searchQuery.lowercased()
- return self.projects.filter { project in
- let nameMatch = project.name?.lowercased().contains(query) ?? false
- let pathMatch = project.path?.lowercased().contains(query) ?? false
- return nameMatch || pathMatch
- }
- }
-
- /// Projects grouped by their parent directory.
- ///
- /// Groups are sorted alphabetically by parent path.
- /// Projects within each group are sorted by name.
- var groupedProjects: [ProjectGroup] {
- let projects = self.filteredProjects
-
- var groups: [String: [ProjectEntry]] = [:]
- for project in projects {
- let parentPath = self.parentDirectory(for: project)
- groups[parentPath, default: []].append(project)
- }
-
- return groups.keys.sorted().map { parentPath in
- ProjectGroup(
- parentPath: parentPath,
- displayName: self.abbreviatePath(parentPath),
- projects: groups[parentPath]?.sorted { ($0.name ?? "") < ($1.name ?? "") } ?? []
- )
- }
- }
-
- /// Checks if a project directory exists on disk.
- func projectExists(_ project: ProjectEntry) -> Bool {
- guard let path = project.path else {
- return false
- }
- return FileManager.default.fileExists(atPath: path)
- }
-
- /// Loads projects from the global configuration file.
- func loadProjects() async {
- self.isLoading = true
- self.errorMessage = nil
-
- do {
- let config = try await self.configManager.readGlobalConfig()
- self.projects = config?.allProjects ?? []
- Log.general.info("Loaded \(self.projects.count) projects")
- } catch {
- self.errorMessage = error.localizedDescription
- Log.general.error("Failed to load projects: \(error.localizedDescription)")
- }
-
- self.isLoading = false
- }
-
- /// Returns the MCP server count for a project.
- func mcpServerCount(for project: ProjectEntry) -> Int {
- project.mcpServers?.count ?? 0
- }
-
- /// Toggles favorite status for a project.
- func toggleFavorite(_ project: ProjectEntry) {
- guard let path = project.path else {
- return
- }
- self.favoritesStorage.toggleFavorite(path)
- }
-
- /// Checks if a project is a favorite.
- func isFavorite(_ project: ProjectEntry) -> Bool {
- guard let path = project.path else {
- return false
- }
- return self.favoritesStorage.isFavorite(path)
- }
-
- /// Records a project as recently opened.
- func recordRecentProject(_ project: ProjectEntry) {
- guard let path = project.path else {
- return
- }
- self.favoritesStorage.recordRecentProject(path)
- }
-
- /// Reveals a project in Finder.
- func revealInFinder(_ project: ProjectEntry) {
- guard let path = project.path else {
- return
- }
- let url = URL(fileURLWithPath: path)
- NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path)
- }
-
- /// Removes a project from the global configuration.
- ///
- /// This removes the project entry from `~/.claude.json` and cleans up
- /// favorites and recents. The project directory itself is not affected.
- ///
- /// - Note: Callers are responsible for clearing any active selection
- /// referencing this project before invoking this method.
- func deleteProject(_ project: ProjectEntry) async {
- guard let path = project.path else {
- return
- }
-
- do {
- guard var config = try await configManager.readGlobalConfig() else {
- return
- }
- config.projects?.removeValue(forKey: path)
- try await self.configManager.writeGlobalConfig(config)
-
- self.projects.removeAll { $0.path == path }
- self.favoritesStorage.removeProject(path)
-
- NotificationManager.shared.showSuccess(
- "Project removed",
- message: "'\(project.name ?? path)' removed from configuration"
- )
- Log.general.info("Removed project from config: \(path)")
- } catch {
- Log.general.error("Failed to remove project: \(error.localizedDescription)")
- NotificationManager.shared.showError(error)
- }
- }
-
- /// Removes multiple projects from the global configuration in a single operation.
- ///
- /// This removes the project entries from `~/.claude.json` and cleans up
- /// favorites and recents. The project directories themselves are not affected.
- ///
- /// - Note: Callers are responsible for clearing any active selection
- /// referencing these projects before invoking this method.
- func deleteProjects(_ projects: [ProjectEntry]) async {
- let paths = projects.compactMap(\.path)
- guard !paths.isEmpty else {
- return
- }
-
- do {
- guard var config = try await configManager.readGlobalConfig() else {
- return
- }
-
- for path in paths {
- config.projects?.removeValue(forKey: path)
- }
-
- try await self.configManager.writeGlobalConfig(config)
-
- let pathSet = Set(paths)
- self.projects.removeAll { pathSet.contains($0.path ?? "") }
- for path in paths {
- self.favoritesStorage.removeProject(path)
- }
-
- let count = paths.count
- NotificationManager.shared.showSuccess(
- "\(count) project\(count == 1 ? "" : "s") removed",
- message: "Removed from configuration"
- )
- Log.general.info("Removed \(count) projects from config")
- } catch {
- Log.general.error("Failed to remove projects: \(error.localizedDescription)")
- NotificationManager.shared.showError(error)
- }
- }
-
- /// Removes all projects whose directories no longer exist on disk.
- ///
- /// This is a convenience method that identifies missing projects and
- /// removes them in a single batch operation using ``deleteProjects(_:)``.
- ///
- /// - Note: Callers are responsible for clearing any active selection
- /// referencing these projects before invoking this method.
- ///
- /// - Returns: The number of projects removed.
- @discardableResult
- func removeMissingProjects() async -> Int {
- let missing = self.missingProjects
- guard !missing.isEmpty else {
- return 0
- }
- await self.deleteProjects(missing)
- return missing.count
- }
-
- /// Opens a project in Terminal.
- func openInTerminal(_ project: ProjectEntry) {
- guard let path = project.path else {
- return
- }
- let script = """
- tell application "Terminal"
- do script "cd \(path.replacingOccurrences(of: "\"", with: "\\\""))"
- activate
- end tell
- """
- if let appleScript = NSAppleScript(source: script) {
- var error: NSDictionary?
- appleScript.executeAndReturnError(&error)
- if let error {
- Log.general.error("Failed to open Terminal: \(error)")
- }
- }
- }
-
- // MARK: Private
-
- private static let groupByParentKey = "groupProjectsByParent"
-
- private let configManager: ConfigFileManager
-
- /// Returns the parent directory path for a project.
- private func parentDirectory(for project: ProjectEntry) -> String {
- guard let path = project.path else {
- return "Unknown"
- }
- return URL(fileURLWithPath: path).deletingLastPathComponent().path
- }
-
- /// Abbreviates a path by replacing the home directory with ~.
- private func abbreviatePath(_ path: String) -> String {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- if path.hasPrefix(home) {
- return "~" + path.dropFirst(home.count)
- }
- return path
- }
-}
diff --git a/Fig/Sources/ViewModels/SettingsEditorViewModel.swift b/Fig/Sources/ViewModels/SettingsEditorViewModel.swift
deleted file mode 100644
index 4efbc81..0000000
--- a/Fig/Sources/ViewModels/SettingsEditorViewModel.swift
+++ /dev/null
@@ -1,961 +0,0 @@
-import Foundation
-import OSLog
-import SwiftUI
-
-// swiftlint:disable file_length
-
-// MARK: - SettingsEditorViewModel
-
-/// View model for editing settings (global or project-level) with undo/redo, dirty tracking, and file watching.
-@MainActor
-@Observable
-final class SettingsEditorViewModel { // swiftlint:disable:this type_body_length
- // MARK: Lifecycle
-
- /// Creates a view model for editing project settings.
- init(projectPath: String, configManager: ConfigFileManager = .shared) {
- self.projectPath = projectPath
- projectURL = URL(fileURLWithPath: projectPath)
- self.configManager = configManager
- editingTarget = .projectShared
- }
-
- /// Creates a view model for editing global settings.
- static func forGlobal(configManager: ConfigFileManager = .shared) -> SettingsEditorViewModel {
- SettingsEditorViewModel(configManager: configManager)
- }
-
- /// Private initializer for global mode.
- private init(configManager: ConfigFileManager) {
- self.projectPath = nil
- self.projectURL = nil
- self.configManager = configManager
- self.editingTarget = .global
- }
-
- deinit {
- // Clean up file watchers - note: must be called from a Task since deinit is sync
- if let projectURL {
- Task { [configManager, projectURL] in
- let settingsURL = await configManager.projectSettingsURL(for: projectURL)
- let localSettingsURL = await configManager.projectLocalSettingsURL(for: projectURL)
- await configManager.stopWatching(settingsURL)
- await configManager.stopWatching(localSettingsURL)
- }
- } else {
- Task { [configManager] in
- let globalURL = await configManager.globalSettingsURL
- await configManager.stopWatching(globalURL)
- }
- }
- }
-
- // MARK: Internal
-
- // MARK: - Properties
-
- /// Project path, nil when editing global settings.
- let projectPath: String?
-
- /// Project URL, nil when editing global settings.
- let projectURL: URL?
-
- /// Whether this view model is editing global settings.
- var isGlobalMode: Bool {
- projectURL == nil
- }
-
- /// Whether data is currently loading.
- private(set) var isLoading = false
-
- /// Whether there are unsaved changes.
- private(set) var isDirty = false
-
- /// Whether saving is in progress.
- private(set) var isSaving = false
-
- /// Current editing target.
- var editingTarget: EditingTarget = .projectShared {
- didSet {
- if oldValue != editingTarget {
- loadEditableData()
- }
- }
- }
-
- /// Whether switching target requires confirmation due to unsaved changes.
- var pendingTargetSwitch: EditingTarget?
-
- /// Attempts to switch target, returning true if switch can proceed immediately.
- func switchTarget(to newTarget: EditingTarget) -> Bool {
- if isDirty {
- pendingTargetSwitch = newTarget
- return false
- }
- editingTarget = newTarget
- return true
- }
-
- /// Confirms pending target switch, discarding changes.
- func confirmTargetSwitch() {
- guard let newTarget = pendingTargetSwitch else { return }
- pendingTargetSwitch = nil
- editingTarget = newTarget
- }
-
- /// Cancels pending target switch.
- func cancelTargetSwitch() {
- pendingTargetSwitch = nil
- }
-
- // MARK: - Editable Data
-
- /// Permission rules being edited.
- var permissionRules: [EditablePermissionRule] = []
-
- /// Environment variables being edited.
- var environmentVariables: [EditableEnvironmentVariable] = []
-
- /// Attribution settings being edited.
- var attribution: Attribution?
-
- /// Disallowed tools being edited.
- var disallowedTools: [String] = []
-
- /// Hook groups being edited, keyed by event name.
- var hookGroups: [String: [EditableHookGroup]] = [:]
-
- // MARK: - Original Data (for dirty checking)
-
- private(set) var originalPermissionRules: [EditablePermissionRule] = []
- private(set) var originalEnvironmentVariables: [EditableEnvironmentVariable] = []
- private(set) var originalAttribution: Attribution?
- private(set) var originalDisallowedTools: [String] = []
- private(set) var originalHookGroups: [String: [EditableHookGroup]] = [:]
-
- // MARK: - Loaded Settings
-
- private(set) var globalSettings: ClaudeSettings?
- private(set) var projectSettings: ClaudeSettings?
- private(set) var projectLocalSettings: ClaudeSettings?
-
- // MARK: - Conflict Handling
-
- private(set) var hasExternalChanges = false
- private(set) var externalChangeURL: URL?
-
- // MARK: - Computed Properties
-
- var displayName: String {
- if isGlobalMode {
- return "Global Settings"
- }
- return projectURL?.lastPathComponent ?? "Unknown"
- }
-
-
- var allowRules: [EditablePermissionRule] {
- permissionRules.filter { $0.type == .allow }
- }
-
- var denyRules: [EditablePermissionRule] {
- permissionRules.filter { $0.type == .deny }
- }
-
- var canUndo: Bool {
- undoManager?.canUndo ?? false
- }
-
- var canRedo: Bool {
- undoManager?.canRedo ?? false
- }
-
- // MARK: - UndoManager
-
- var undoManager: UndoManager? {
- didSet {
- // Clear undo stack when UndoManager changes
- undoManager?.removeAllActions()
- }
- }
-
- // MARK: - Loading
-
- /// Loads settings for editing.
- func loadSettings() async {
- isLoading = true
-
- do {
- if isGlobalMode {
- globalSettings = try await configManager.readGlobalSettings()
- } else if let projectURL {
- projectSettings = try await configManager.readProjectSettings(for: projectURL)
- projectLocalSettings = try await configManager.readProjectLocalSettings(for: projectURL)
- }
-
- loadEditableData()
- startFileWatching()
-
- Log.general.info("Loaded settings for editing: \(self.displayName)")
- } catch {
- Log.general.error("Failed to load settings for editing: \(error.localizedDescription)")
- }
-
- isLoading = false
- }
-
- /// Reloads settings from disk, discarding local changes.
- func reloadSettings() async {
- await loadSettings()
- isDirty = false
- undoManager?.removeAllActions()
- }
-
- // MARK: - Saving
-
- /// Saves the current settings to the target file.
- func save() async throws {
- guard isDirty else { return }
-
- isSaving = true
- defer { isSaving = false }
-
- let settings = buildSettings()
-
- switch editingTarget {
- case .global:
- try await configManager.writeGlobalSettings(settings)
- globalSettings = settings
- case .projectShared:
- guard let projectURL else { return }
- try await configManager.writeProjectSettings(settings, for: projectURL)
- projectSettings = settings
- case .projectLocal:
- guard let projectURL else { return }
- try await configManager.writeProjectLocalSettings(settings, for: projectURL)
- projectLocalSettings = settings
- }
-
- // Update originals
- updateOriginals()
- isDirty = false
- undoManager?.removeAllActions()
-
- Log.general.info("Saved settings to \(self.editingTarget.label)")
- }
-
- // MARK: - Permission Rule Editing
-
- /// Adds a new permission rule.
- func addPermissionRule(_ rule: String, type: PermissionType) {
- let newRule = EditablePermissionRule(rule: rule, type: type)
- permissionRules.append(newRule)
- markDirty()
-
- registerUndo(actionName: "Add Rule") { [weak self] in
- guard let self else { return }
- self.permissionRules.removeAll { $0.id == newRule.id }
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Add Rule") { [weak self] in
- self?.permissionRules.append(newRule)
- self?.markDirty()
- }
- }
- }
-
- /// Removes a permission rule.
- func removePermissionRule(_ rule: EditablePermissionRule) {
- guard let index = permissionRules.firstIndex(of: rule) else { return }
-
- permissionRules.remove(at: index)
- markDirty()
-
- registerUndo(actionName: "Remove Rule") { [weak self] in
- guard let self else { return }
- let insertIndex = min(index, self.permissionRules.count)
- self.permissionRules.insert(rule, at: insertIndex)
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Remove Rule") { [weak self] in
- self?.permissionRules.removeAll { $0.id == rule.id }
- self?.markDirty()
- }
- }
- }
-
- /// Updates a permission rule.
- func updatePermissionRule(_ rule: EditablePermissionRule, newRule: String, newType: PermissionType) {
- guard let index = permissionRules.firstIndex(of: rule) else { return }
-
- let oldRule = rule.rule
- let oldType = rule.type
-
- permissionRules[index].rule = newRule
- permissionRules[index].type = newType
- markDirty()
-
- registerUndo(actionName: "Update Rule") { [weak self] in
- guard let self, let idx = self.permissionRules.firstIndex(where: { $0.id == rule.id }) else { return }
- self.permissionRules[idx].rule = oldRule
- self.permissionRules[idx].type = oldType
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Update Rule") { [weak self] in
- guard let self, let idx = self.permissionRules.firstIndex(where: { $0.id == rule.id }) else { return }
- self.permissionRules[idx].rule = newRule
- self.permissionRules[idx].type = newType
- self.markDirty()
- }
- }
- }
-
- /// Moves permission rules for reordering.
- func movePermissionRules(type: PermissionType, from source: IndexSet, to destination: Int) {
- // Capture pre-move state for undo
- let previousRules = permissionRules
-
- var rules = type == .allow ? allowRules : denyRules
- rules.move(fromOffsets: source, toOffset: destination)
-
- // Rebuild full permission rules list
- let otherRules = permissionRules.filter { $0.type != type }
- let newRules = otherRules + rules
- permissionRules = newRules
- markDirty()
-
- // Register undo with pre-move state
- registerUndo(actionName: "Reorder Rules") { [weak self] in
- guard let self else { return }
- self.permissionRules = previousRules
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Reorder Rules") { [weak self] in
- self?.permissionRules = newRules
- self?.markDirty()
- }
- }
- }
-
- /// Applies a permission preset.
- func applyPreset(_ preset: PermissionPreset) {
- let originalRules = permissionRules
-
- for (rule, type) in preset.rules {
- let isDuplicate = permissionRules.contains { $0.rule == rule && $0.type == type }
- if !isDuplicate {
- permissionRules.append(EditablePermissionRule(rule: rule, type: type))
- }
- }
-
- let newRules = permissionRules
- if newRules != originalRules {
- markDirty()
-
- registerUndo(actionName: "Apply Preset") { [weak self] in
- guard let self else { return }
- self.permissionRules = originalRules
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Apply Preset") { [weak self] in
- self?.permissionRules = newRules
- self?.markDirty()
- }
- }
- }
- }
-
- // MARK: - Environment Variable Editing
-
- /// Adds a new environment variable.
- func addEnvironmentVariable(key: String, value: String) {
- // Check for duplicate keys
- guard !environmentVariables.contains(where: { $0.key == key }) else { return }
-
- let newVar = EditableEnvironmentVariable(key: key, value: value)
- environmentVariables.append(newVar)
- markDirty()
-
- registerUndo(actionName: "Add Variable") { [weak self] in
- guard let self else { return }
- self.environmentVariables.removeAll { $0.id == newVar.id }
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Add Variable") { [weak self] in
- self?.environmentVariables.append(newVar)
- self?.markDirty()
- }
- }
- }
-
- /// Removes an environment variable.
- func removeEnvironmentVariable(_ envVar: EditableEnvironmentVariable) {
- guard let index = environmentVariables.firstIndex(of: envVar) else { return }
-
- environmentVariables.remove(at: index)
- markDirty()
-
- registerUndo(actionName: "Remove Variable") { [weak self] in
- guard let self else { return }
- let insertIndex = min(index, self.environmentVariables.count)
- self.environmentVariables.insert(envVar, at: insertIndex)
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Remove Variable") { [weak self] in
- self?.environmentVariables.removeAll { $0.id == envVar.id }
- self?.markDirty()
- }
- }
- }
-
- /// Updates an environment variable.
- func updateEnvironmentVariable(_ envVar: EditableEnvironmentVariable, newKey: String, newValue: String) {
- guard let index = environmentVariables.firstIndex(of: envVar) else { return }
-
- // Check for duplicate keys (excluding current)
- if newKey != envVar.key, environmentVariables.contains(where: { $0.key == newKey }) {
- return
- }
-
- let oldKey = envVar.key
- let oldValue = envVar.value
-
- environmentVariables[index].key = newKey
- environmentVariables[index].value = newValue
- markDirty()
-
- registerUndo(actionName: "Update Variable") { [weak self] in
- guard let self, let idx = self.environmentVariables.firstIndex(where: { $0.id == envVar.id }) else { return }
- self.environmentVariables[idx].key = oldKey
- self.environmentVariables[idx].value = oldValue
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Update Variable") { [weak self] in
- guard let self, let idx = self.environmentVariables.firstIndex(where: { $0.id == envVar.id }) else { return }
- self.environmentVariables[idx].key = newKey
- self.environmentVariables[idx].value = newValue
- self.markDirty()
- }
- }
- }
-
- // MARK: - Attribution Editing
-
- /// Updates attribution settings.
- func updateAttribution(commits: Bool?, pullRequests: Bool?) {
- let oldAttribution = attribution
-
- if commits == nil, pullRequests == nil {
- attribution = nil
- } else {
- attribution = Attribution(commits: commits, pullRequests: pullRequests)
- }
-
- let newAttribution = attribution
- markDirty()
-
- registerUndo(actionName: "Update Attribution") { [weak self] in
- guard let self else { return }
- self.attribution = oldAttribution
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Update Attribution") { [weak self] in
- self?.attribution = newAttribution
- self?.markDirty()
- }
- }
- }
-
- // MARK: - Disallowed Tools Editing
-
- /// Adds a disallowed tool.
- func addDisallowedTool(_ tool: String) {
- guard !tool.isEmpty, !disallowedTools.contains(tool) else { return }
-
- disallowedTools.append(tool)
- markDirty()
-
- registerUndo(actionName: "Add Disallowed Tool") { [weak self] in
- guard let self else { return }
- self.disallowedTools.removeAll { $0 == tool }
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Add Disallowed Tool") { [weak self] in
- self?.disallowedTools.append(tool)
- self?.markDirty()
- }
- }
- }
-
- /// Removes a disallowed tool.
- func removeDisallowedTool(_ tool: String) {
- guard let index = disallowedTools.firstIndex(of: tool) else { return }
-
- disallowedTools.remove(at: index)
- markDirty()
-
- registerUndo(actionName: "Remove Disallowed Tool") { [weak self] in
- guard let self else { return }
- let insertIndex = min(index, self.disallowedTools.count)
- self.disallowedTools.insert(tool, at: insertIndex)
- self.markDirty()
- // Register redo
- self.registerUndo(actionName: "Remove Disallowed Tool") { [weak self] in
- self?.disallowedTools.removeAll { $0 == tool }
- self?.markDirty()
- }
- }
- }
-
- // MARK: - Hook Group Editing
-
- /// Adds a new hook group for a specific event.
- func addHookGroup(event: String, matcher: String, commands: [String]) {
- let hooks = commands.map { EditableHookDefinition(type: "command", command: $0) }
- let newGroup = EditableHookGroup(matcher: matcher, hooks: hooks)
-
- var groups = hookGroups[event] ?? []
- groups.append(newGroup)
- hookGroups[event] = groups
- markDirty()
-
- registerUndo(actionName: "Add Hook Group") { [weak self] in
- guard let self else { return }
- self.hookGroups[event]?.removeAll { $0.id == newGroup.id }
- if self.hookGroups[event]?.isEmpty == true { self.hookGroups[event] = nil }
- self.markDirty()
- self.registerUndo(actionName: "Add Hook Group") { [weak self] in
- var groups = self?.hookGroups[event] ?? []
- groups.append(newGroup)
- self?.hookGroups[event] = groups
- self?.markDirty()
- }
- }
- }
-
- /// Removes a hook group from a specific event.
- func removeHookGroup(event: String, group: EditableHookGroup) {
- guard let index = hookGroups[event]?.firstIndex(of: group) else { return }
-
- hookGroups[event]?.remove(at: index)
- if hookGroups[event]?.isEmpty == true { hookGroups[event] = nil }
- markDirty()
-
- registerUndo(actionName: "Remove Hook Group") { [weak self] in
- guard let self else { return }
- var groups = self.hookGroups[event] ?? []
- let insertIndex = min(index, groups.count)
- groups.insert(group, at: insertIndex)
- self.hookGroups[event] = groups
- self.markDirty()
- self.registerUndo(actionName: "Remove Hook Group") { [weak self] in
- self?.hookGroups[event]?.removeAll { $0.id == group.id }
- if self?.hookGroups[event]?.isEmpty == true { self?.hookGroups[event] = nil }
- self?.markDirty()
- }
- }
- }
-
- /// Updates a hook group's matcher pattern.
- func updateHookGroupMatcher(event: String, group: EditableHookGroup, newMatcher: String) {
- guard let index = hookGroups[event]?.firstIndex(where: { $0.id == group.id }) else { return }
-
- let oldMatcher = group.matcher
- hookGroups[event]?[index].matcher = newMatcher
- markDirty()
-
- registerUndo(actionName: "Update Matcher") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == group.id })
- else { return }
- self.hookGroups[event]?[idx].matcher = oldMatcher
- self.markDirty()
- self.registerUndo(actionName: "Update Matcher") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == group.id })
- else { return }
- self.hookGroups[event]?[idx].matcher = newMatcher
- self.markDirty()
- }
- }
- }
-
- /// Adds a hook definition to a group.
- func addHookDefinition(event: String, groupID: UUID, command: String) {
- guard let groupIndex = hookGroups[event]?.firstIndex(where: { $0.id == groupID }) else { return }
-
- let newHook = EditableHookDefinition(type: "command", command: command)
- hookGroups[event]?[groupIndex].hooks.append(newHook)
- markDirty()
-
- registerUndo(actionName: "Add Hook") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID })
- else { return }
- self.hookGroups[event]?[idx].hooks.removeAll { $0.id == newHook.id }
- self.markDirty()
- self.registerUndo(actionName: "Add Hook") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID })
- else { return }
- self.hookGroups[event]?[idx].hooks.append(newHook)
- self.markDirty()
- }
- }
- }
-
- /// Removes a hook definition from a group.
- func removeHookDefinition(event: String, groupID: UUID, hook: EditableHookDefinition) {
- guard let groupIndex = hookGroups[event]?.firstIndex(where: { $0.id == groupID }),
- let hookIndex = hookGroups[event]?[groupIndex].hooks.firstIndex(of: hook)
- else { return }
-
- hookGroups[event]?[groupIndex].hooks.remove(at: hookIndex)
- markDirty()
-
- registerUndo(actionName: "Remove Hook") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID })
- else { return }
- let hookCount = self.hookGroups[event]?[idx].hooks.count ?? 0
- let insertIndex = min(hookIndex, hookCount)
- self.hookGroups[event]?[idx].hooks.insert(hook, at: insertIndex)
- self.markDirty()
- self.registerUndo(actionName: "Remove Hook") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID })
- else { return }
- self.hookGroups[event]?[idx].hooks.removeAll { $0.id == hook.id }
- self.markDirty()
- }
- }
- }
-
- /// Updates a hook definition's command.
- func updateHookDefinition(
- event: String,
- groupID: UUID,
- hook: EditableHookDefinition,
- newCommand: String
- ) {
- guard let groupIndex = hookGroups[event]?.firstIndex(where: { $0.id == groupID }),
- let hookIndex = hookGroups[event]?[groupIndex].hooks
- .firstIndex(where: { $0.id == hook.id })
- else { return }
-
- let oldCommand = hook.command
- hookGroups[event]?[groupIndex].hooks[hookIndex].command = newCommand
- markDirty()
-
- registerUndo(actionName: "Update Hook") { [weak self] in
- guard let self,
- let gIdx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID }),
- let hIdx = self.hookGroups[event]?[gIdx].hooks
- .firstIndex(where: { $0.id == hook.id })
- else { return }
- self.hookGroups[event]?[gIdx].hooks[hIdx].command = oldCommand
- self.markDirty()
- self.registerUndo(actionName: "Update Hook") { [weak self] in
- guard let self,
- let gIdx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID }),
- let hIdx = self.hookGroups[event]?[gIdx].hooks
- .firstIndex(where: { $0.id == hook.id })
- else { return }
- self.hookGroups[event]?[gIdx].hooks[hIdx].command = newCommand
- self.markDirty()
- }
- }
- }
-
- /// Moves hook definitions within a group for reordering.
- func moveHookDefinition(event: String, groupID: UUID, from source: Int, direction: Int) {
- guard let groupIndex = hookGroups[event]?.firstIndex(where: { $0.id == groupID }),
- let hooks = hookGroups[event]?[groupIndex].hooks
- else { return }
-
- let destination = source + direction
- guard destination >= 0, destination < hooks.count else { return }
-
- let previousHooks = hooks
- hookGroups[event]?[groupIndex].hooks.swapAt(source, destination)
- guard let newHooks = hookGroups[event]?[groupIndex].hooks else { return }
- markDirty()
-
- registerUndo(actionName: "Reorder Hooks") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID })
- else { return }
- self.hookGroups[event]?[idx].hooks = previousHooks
- self.markDirty()
- self.registerUndo(actionName: "Reorder Hooks") { [weak self] in
- guard let self,
- let idx = self.hookGroups[event]?.firstIndex(where: { $0.id == groupID })
- else { return }
- self.hookGroups[event]?[idx].hooks = newHooks
- self.markDirty()
- }
- }
- }
-
- /// Moves a hook group within an event for reordering.
- func moveHookGroup(event: String, from source: Int, direction: Int) {
- guard let groups = hookGroups[event] else { return }
-
- let destination = source + direction
- guard destination >= 0, destination < groups.count else { return }
-
- let previousGroups = groups
- hookGroups[event]?.swapAt(source, destination)
- guard let newGroups = hookGroups[event] else { return }
- markDirty()
-
- registerUndo(actionName: "Reorder Hook Groups") { [weak self] in
- guard let self else { return }
- self.hookGroups[event] = previousGroups
- self.markDirty()
- self.registerUndo(actionName: "Reorder Hook Groups") { [weak self] in
- self?.hookGroups[event] = newGroups
- self?.markDirty()
- }
- }
- }
-
- /// Applies a hook template.
- func applyHookTemplate(_ template: HookTemplate) {
- addHookGroup(
- event: template.event.rawValue,
- matcher: template.matcher ?? "",
- commands: template.commands
- )
- undoManager?.setActionName("Apply Template")
- }
-
- // MARK: - Conflict Resolution
-
- /// Acknowledges external changes and chooses resolution.
- func resolveConflict(_ resolution: ConflictResolution) async {
- switch resolution {
- case .keepLocal:
- hasExternalChanges = false
- externalChangeURL = nil
-
- case .useExternal:
- await reloadSettings()
- hasExternalChanges = false
- externalChangeURL = nil
- }
- }
-
- /// Dismisses the external changes notification without action.
- func dismissExternalChanges() {
- hasExternalChanges = false
- externalChangeURL = nil
- }
-
- // MARK: - Validation
-
- /// Validates a permission rule pattern.
- func validatePermissionRule(_ rule: String) -> (isValid: Bool, error: String?) {
- // Empty rule is invalid
- if rule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- return (false, "Rule cannot be empty")
- }
-
- // Check for basic pattern format
- // Valid formats: "ToolName" or "ToolName(pattern)"
- // Tool names can contain letters, digits, underscores, dots, and dashes
- let pattern = #"^[A-Za-z][A-Za-z0-9_.-]*(?:\([^)]*\))?$"#
- let regex = try? NSRegularExpression(pattern: pattern)
- let range = NSRange(rule.startIndex..., in: rule)
-
- if regex?.firstMatch(in: rule, options: [], range: range) == nil {
- return (false, "Invalid format. Use 'Tool' or 'Tool(pattern)'")
- }
-
- return (true, nil)
- }
-
- /// Checks if a permission rule would be a duplicate.
- func isRuleDuplicate(_ rule: String, type: PermissionType, excluding: EditablePermissionRule? = nil) -> Bool {
- permissionRules.contains { existing in
- existing.rule == rule && existing.type == type && existing.id != excluding?.id
- }
- }
-
- // MARK: Private
-
- private let configManager: ConfigFileManager
-
- private func loadEditableData() {
- let settings: ClaudeSettings?
- switch editingTarget {
- case .global:
- settings = globalSettings
- case .projectShared:
- settings = projectSettings
- case .projectLocal:
- settings = projectLocalSettings
- }
-
- // Load permission rules
- var rules: [EditablePermissionRule] = []
- if let allow = settings?.permissions?.allow {
- rules.append(contentsOf: allow.map { EditablePermissionRule(rule: $0, type: .allow) })
- }
- if let deny = settings?.permissions?.deny {
- rules.append(contentsOf: deny.map { EditablePermissionRule(rule: $0, type: .deny) })
- }
- permissionRules = rules
- originalPermissionRules = rules
-
- // Load environment variables
- if let env = settings?.env {
- environmentVariables = env.sorted { $0.key < $1.key }
- .map { EditableEnvironmentVariable(key: $0.key, value: $0.value) }
- } else {
- environmentVariables = []
- }
- originalEnvironmentVariables = environmentVariables
-
- // Load attribution
- attribution = settings?.attribution
- originalAttribution = attribution
-
- // Load disallowed tools
- disallowedTools = settings?.disallowedTools ?? []
- originalDisallowedTools = disallowedTools
-
- // Load hook groups
- var loadedHookGroups: [String: [EditableHookGroup]] = [:]
- if let hooks = settings?.hooks {
- for (event, groups) in hooks {
- loadedHookGroups[event] = groups.map { EditableHookGroup(from: $0) }
- }
- }
- hookGroups = loadedHookGroups
- originalHookGroups = loadedHookGroups
-
- isDirty = false
- }
-
- private func updateOriginals() {
- originalPermissionRules = permissionRules
- originalEnvironmentVariables = environmentVariables
- originalAttribution = attribution
- originalDisallowedTools = disallowedTools
- originalHookGroups = hookGroups
- }
-
- private func buildSettings() -> ClaudeSettings {
- let existingSettings: ClaudeSettings?
- switch editingTarget {
- case .global:
- existingSettings = globalSettings
- case .projectShared:
- existingSettings = projectSettings
- case .projectLocal:
- existingSettings = projectLocalSettings
- }
-
- let allowRules = permissionRules.filter { $0.type == .allow }.map(\.rule)
- let denyRules = permissionRules.filter { $0.type == .deny }.map(\.rule)
-
- let permissions = (allowRules.isEmpty && denyRules.isEmpty) ? nil : Permissions(
- allow: allowRules.isEmpty ? nil : allowRules,
- deny: denyRules.isEmpty ? nil : denyRules
- )
-
- let env: [String: String]? = environmentVariables.isEmpty
- ? nil
- : Dictionary(uniqueKeysWithValues: environmentVariables.map { ($0.key, $0.value) })
-
- // Build hooks dictionary from editable data
- let builtHooks: [String: [HookGroup]]?
- if hookGroups.isEmpty {
- builtHooks = nil
- } else {
- var result: [String: [HookGroup]] = [:]
- for (event, editableGroups) in hookGroups {
- let groups = editableGroups.map { $0.toHookGroup() }
- if !groups.isEmpty {
- result[event] = groups
- }
- }
- builtHooks = result.isEmpty ? nil : result
- }
-
- return ClaudeSettings(
- permissions: permissions,
- env: env,
- hooks: builtHooks,
- disallowedTools: disallowedTools.isEmpty ? nil : disallowedTools,
- attribution: attribution,
- additionalProperties: existingSettings?.additionalProperties
- )
- }
-
- private func markDirty() {
- isDirty = checkDirty()
- }
-
- private func checkDirty() -> Bool {
- permissionRules != originalPermissionRules ||
- environmentVariables != originalEnvironmentVariables ||
- attribution != originalAttribution ||
- disallowedTools != originalDisallowedTools ||
- hookGroups != originalHookGroups
- }
-
- private func registerUndo(actionName: String, handler: @escaping () -> Void) {
- undoManager?.registerUndo(withTarget: self) { _ in
- handler()
- }
- undoManager?.setActionName(actionName)
- }
-
- private func startFileWatching() {
- Task {
- if isGlobalMode {
- let globalURL = await configManager.globalSettingsURL
- await configManager.startWatching(globalURL) { [weak self] url in
- Task { @MainActor in
- self?.handleExternalChange(url: url)
- }
- }
- } else if let projectURL {
- let settingsURL = await configManager.projectSettingsURL(for: projectURL)
- let localSettingsURL = await configManager.projectLocalSettingsURL(for: projectURL)
-
- await configManager.startWatching(settingsURL) { [weak self] url in
- Task { @MainActor in
- self?.handleExternalChange(url: url)
- }
- }
-
- await configManager.startWatching(localSettingsURL) { [weak self] url in
- Task { @MainActor in
- self?.handleExternalChange(url: url)
- }
- }
- }
- }
- }
-
- private func handleExternalChange(url: URL) {
- // Only show conflict if we have unsaved changes
- if isDirty {
- hasExternalChanges = true
- externalChangeURL = url
- Log.general.info("External changes detected with unsaved edits: \(url.lastPathComponent)")
- } else {
- // Auto-reload if no local changes
- Task { @MainActor in
- await reloadSettings()
- NotificationManager.shared.showInfo(
- "Settings Reloaded",
- message: "\(url.lastPathComponent) was modified externally"
- )
- }
- }
- }
-}
diff --git a/Fig/Sources/Views/AttributionEditorViews.swift b/Fig/Sources/Views/AttributionEditorViews.swift
deleted file mode 100644
index aba8a8c..0000000
--- a/Fig/Sources/Views/AttributionEditorViews.swift
+++ /dev/null
@@ -1,229 +0,0 @@
-import SwiftUI
-
-// MARK: - AttributionSettingsEditorView
-
-/// Editor view for attribution and general settings.
-struct AttributionSettingsEditorView: View {
- // MARK: Internal
-
- @Bindable var viewModel: SettingsEditorViewModel
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- // Target selector
- if !self.viewModel.isGlobalMode {
- HStack {
- EditingTargetPicker(selection: self.$viewModel.editingTarget)
- Spacer()
- }
- }
-
- // Attribution settings
- GroupBox("Attribution") {
- VStack(alignment: .leading, spacing: 12) {
- Toggle(isOn: self.commitBinding) {
- VStack(alignment: .leading) {
- Text("Commit Attribution")
- .font(.body)
- Text("Include Claude Code attribution in commit messages")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- Divider()
-
- Toggle(isOn: self.pullRequestBinding) {
- VStack(alignment: .leading) {
- Text("Pull Request Attribution")
- .font(.body)
- Text("Include Claude Code attribution in PR descriptions")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- }
- .padding(.vertical, 4)
- }
-
- // Disallowed tools
- GroupBox("Disallowed Tools") {
- VStack(alignment: .leading, spacing: 8) {
- Text("Tools that Claude Code is not allowed to use")
- .font(.caption)
- .foregroundStyle(.secondary)
-
- // Tag input area
- FlowLayout(spacing: 8) {
- ForEach(self.viewModel.disallowedTools, id: \.self) { tool in
- DisallowedToolTag(tool: tool) {
- self.viewModel.removeDisallowedTool(tool)
- }
- }
-
- // Add new tag
- if self.isAddingTool {
- AddToolInput(
- toolName: self.$newToolName,
- onAdd: self.addNewTool,
- onCancel: {
- self.isAddingTool = false
- self.newToolName = ""
- }
- )
- } else {
- AddToolButton {
- self.isAddingTool = true
- }
- }
- }
- .padding(.vertical, 4)
-
- if self.viewModel.disallowedTools.isEmpty, !self.isAddingTool {
- Text("No tools are disallowed")
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.vertical, 4)
- }
- }
- .padding(.vertical, 4)
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-
- // MARK: Private
-
- @State private var isAddingTool = false
- @State private var newToolName = ""
-
- private var commitBinding: Binding {
- Binding(
- get: { self.viewModel.attribution?.commits ?? false },
- set: { newValue in
- self.viewModel.updateAttribution(
- commits: newValue,
- pullRequests: self.viewModel.attribution?.pullRequests
- )
- }
- )
- }
-
- private var pullRequestBinding: Binding {
- Binding(
- get: { self.viewModel.attribution?.pullRequests ?? false },
- set: { newValue in
- self.viewModel.updateAttribution(
- commits: self.viewModel.attribution?.commits,
- pullRequests: newValue
- )
- }
- )
- }
-
- private func addNewTool() {
- guard !self.newToolName.isEmpty else {
- return
- }
- self.viewModel.addDisallowedTool(self.newToolName)
- self.newToolName = ""
- self.isAddingTool = false
- }
-}
-
-// MARK: - DisallowedToolTag
-
-/// A tag displaying a disallowed tool with remove button.
-struct DisallowedToolTag: View {
- let tool: String
- let onRemove: () -> Void
-
- var body: some View {
- HStack(spacing: 4) {
- Text(self.tool)
- .font(.system(.caption, design: .monospaced))
- Button(action: self.onRemove) {
- Image(systemName: "xmark")
- .font(.caption2)
- }
- .buttonStyle(.plain)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.red.opacity(0.2), in: RoundedRectangle(cornerRadius: 4))
- }
-}
-
-// MARK: - AddToolInput
-
-/// Input field for adding a new disallowed tool.
-struct AddToolInput: View {
- @Binding var toolName: String
-
- let onAdd: () -> Void
- let onCancel: () -> Void
-
- var body: some View {
- HStack(spacing: 4) {
- TextField("Tool name", text: self.$toolName)
- .textFieldStyle(.plain)
- .font(.system(.caption, design: .monospaced))
- .frame(minWidth: 80, maxWidth: 120)
- .onSubmit {
- self.onAdd()
- }
-
- Button(action: self.onAdd) {
- Image(systemName: "checkmark")
- .font(.caption2)
- }
- .buttonStyle(.plain)
- .disabled(self.toolName.isEmpty)
-
- Button(action: self.onCancel) {
- Image(systemName: "xmark")
- .font(.caption2)
- }
- .buttonStyle(.plain)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 4))
- }
-}
-
-// MARK: - AddToolButton
-
-/// Button to start adding a new disallowed tool.
-struct AddToolButton: View {
- let action: () -> Void
-
- var body: some View {
- Button(action: self.action) {
- HStack(spacing: 4) {
- Image(systemName: "plus")
- .font(.caption2)
- Text("Add Tool")
- .font(.caption)
- }
- }
- .buttonStyle(.plain)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 4))
- }
-}
-
-#Preview("Attribution Settings Editor") {
- let viewModel = SettingsEditorViewModel(projectPath: "/Users/test/project")
- viewModel.attribution = Attribution(commits: true, pullRequests: false)
- viewModel.disallowedTools = ["Bash", "WebFetch"]
-
- return AttributionSettingsEditorView(viewModel: viewModel)
- .padding()
- .frame(width: 600, height: 500)
-}
diff --git a/Fig/Sources/Views/ClaudeMDView.swift b/Fig/Sources/Views/ClaudeMDView.swift
deleted file mode 100644
index 317ae44..0000000
--- a/Fig/Sources/Views/ClaudeMDView.swift
+++ /dev/null
@@ -1,280 +0,0 @@
-import MarkdownUI
-import SwiftUI
-
-// MARK: - ClaudeMDView
-
-/// Tab view for previewing and editing CLAUDE.md files in the project hierarchy.
-struct ClaudeMDView: View {
- // MARK: Lifecycle
-
- init(projectPath: String) {
- _viewModel = State(initialValue: ClaudeMDViewModel(projectPath: projectPath))
- }
-
- // MARK: Internal
-
- var body: some View {
- HSplitView {
- // File hierarchy sidebar
- self.claudeMDSidebar
- .frame(minWidth: 180, idealWidth: 220, maxWidth: 280)
-
- // Content area
- self.contentArea
- .frame(minWidth: 300)
- }
- .task {
- await self.viewModel.loadFiles()
- }
- }
-
- // MARK: Private
-
- @State private var viewModel: ClaudeMDViewModel
-
- // MARK: - Sidebar
-
- private var claudeMDSidebar: some View {
- VStack(alignment: .leading, spacing: 0) {
- // Header
- HStack {
- Text("Hierarchy")
- .font(.headline)
- .foregroundStyle(.secondary)
- Spacer()
- Button {
- Task {
- await self.viewModel.loadFiles()
- }
- } label: {
- Image(systemName: "arrow.clockwise")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .help("Refresh")
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
-
- Divider()
-
- if self.viewModel.isLoading {
- ProgressView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- List(selection: self.$viewModel.selectedFileID) {
- // Global section
- Section("Global") {
- ForEach(self.viewModel.files.filter { $0.level == .global }) { file in
- ClaudeMDFileRow(file: file)
- .tag(file.id)
- }
- }
-
- // Project section
- Section("Project") {
- ForEach(
- self.viewModel.files.filter { $0.level != .global }
- ) { file in
- ClaudeMDFileRow(file: file)
- .tag(file.id)
- }
- }
- }
- .listStyle(.sidebar)
- .onChange(of: self.viewModel.selectedFileID) { _, _ in
- self.viewModel.cancelEditing()
- }
- }
- }
- }
-
- // MARK: - Content Area
-
- private var contentArea: some View {
- VStack(spacing: 0) {
- if let file = viewModel.selectedFile {
- // Toolbar
- self.contentToolbar(for: file)
-
- Divider()
-
- // Content
- if self.viewModel.isEditing {
- self.editorView
- } else if file.exists {
- self.previewView(for: file)
- } else {
- self.emptyFileView(for: file)
- }
- } else {
- ContentUnavailableView(
- "Select a File",
- systemImage: "doc.text",
- description: Text("Choose a CLAUDE.md file from the hierarchy to view or edit.")
- )
- }
- }
- }
-
- // MARK: - Editor
-
- private var editorView: some View {
- TextEditor(text: self.$viewModel.editContent)
- .font(.system(.body, design: .monospaced))
- .scrollContentBackground(.hidden)
- .padding(4)
- }
-
- private func contentToolbar(for file: ClaudeMDFile) -> some View {
- HStack {
- // File path
- HStack(spacing: 4) {
- Image(systemName: file.level.icon)
- .foregroundStyle(.secondary)
- Text(file.displayPath)
- .font(.system(.body, design: .monospaced))
- .lineLimit(1)
- }
-
- Spacer()
-
- // Git status badge
- if file.exists {
- GitStatusBadge(isTracked: file.isTrackedByGit)
- }
-
- // Action buttons
- if self.viewModel.isEditing {
- Button("Cancel") {
- self.viewModel.cancelEditing()
- }
-
- Button("Save") {
- Task {
- await self.viewModel.saveSelectedFile()
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.viewModel.editContent == file.content)
- } else if file.exists {
- Button {
- self.viewModel.startEditing()
- } label: {
- Label("Edit", systemImage: "pencil")
- }
- } else {
- Button {
- Task {
- await self.viewModel.createFile(at: file.level)
- }
- } label: {
- Label("Create", systemImage: "plus")
- }
- .buttonStyle(.borderedProminent)
- }
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- }
-
- // MARK: - Preview
-
- private func previewView(for file: ClaudeMDFile) -> some View {
- ScrollView {
- Markdown(file.content)
- .markdownTheme(.gitHub)
- .textSelection(.enabled)
- .padding()
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-
- // MARK: - Empty State
-
- private func emptyFileView(for file: ClaudeMDFile) -> some View {
- ContentUnavailableView {
- Label("No CLAUDE.md", systemImage: "doc.badge.plus")
- } description: {
- Text("No CLAUDE.md file exists at \(file.displayPath).")
- } actions: {
- Button("Create File") {
- Task {
- await self.viewModel.createFile(at: file.level)
- }
- }
- .buttonStyle(.borderedProminent)
- }
- }
-}
-
-// MARK: - ClaudeMDFileRow
-
-/// A row in the CLAUDE.md hierarchy sidebar.
-struct ClaudeMDFileRow: View {
- let file: ClaudeMDFile
-
- var body: some View {
- HStack(spacing: 6) {
- Image(systemName: self.file.exists ? "doc.text.fill" : "doc.badge.plus")
- .foregroundStyle(self.file.exists ? .blue : .secondary)
- .frame(width: 16)
-
- VStack(alignment: .leading, spacing: 1) {
- Text(self.file.level.displayName)
- .font(.body)
- .lineLimit(1)
-
- if case let .subdirectory(path) = file.level {
- Text(path)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
- }
-
- Spacer()
-
- if self.file.exists {
- if self.file.isTrackedByGit {
- Image(systemName: "checkmark.circle.fill")
- .font(.caption2)
- .foregroundStyle(.green)
- .help("Tracked by git")
- } else {
- Image(systemName: "circle.dashed")
- .font(.caption2)
- .foregroundStyle(.orange)
- .help("Not tracked by git")
- }
- }
- }
- .contentShape(Rectangle())
- }
-}
-
-// MARK: - GitStatusBadge
-
-/// Badge showing git tracking status.
-struct GitStatusBadge: View {
- let isTracked: Bool
-
- var body: some View {
- HStack(spacing: 4) {
- Image(systemName: self.isTracked ? "checkmark.circle.fill" : "circle.dashed")
- .foregroundStyle(self.isTracked ? .green : .orange)
- .font(.caption)
- Text(self.isTracked ? "In git" : "Untracked")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
- }
-}
-
-#Preview {
- ClaudeMDView(projectPath: "/Users/test/project")
- .frame(width: 700, height: 500)
-}
diff --git a/Fig/Sources/Views/ConfigExportView.swift b/Fig/Sources/Views/ConfigExportView.swift
deleted file mode 100644
index 405dd77..0000000
--- a/Fig/Sources/Views/ConfigExportView.swift
+++ /dev/null
@@ -1,244 +0,0 @@
-import SwiftUI
-
-// MARK: - ConfigExportView
-
-/// Sheet for exporting project configuration to a bundle file.
-struct ConfigExportView: View {
- // MARK: Internal
-
- @Bindable var viewModel: ConfigExportViewModel
-
- var body: some View {
- VStack(spacing: 0) {
- // Header
- self.header
-
- Divider()
-
- // Content
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- // Component selection
- self.componentSelectionSection
-
- // Sensitive data warning
- if self.viewModel.includeLocalSettings {
- self.sensitiveDataWarning
- }
-
- // Error message
- if let error = viewModel.errorMessage {
- self.errorSection(error: error)
- }
-
- // Success message
- if self.viewModel.exportSuccessful {
- self.successSection
- }
- }
- .padding()
- }
-
- Divider()
-
- // Footer
- self.footer
- }
- .frame(width: 450, height: 400)
- .task {
- await self.viewModel.loadAvailableComponents()
- }
- }
-
- // MARK: Private
-
- @Environment(\.dismiss) private var dismiss
-
- private var header: some View {
- HStack {
- Image(systemName: "square.and.arrow.up")
- .font(.title2)
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading) {
- Text("Export Configuration")
- .font(.headline)
- Text(self.viewModel.projectName)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
- }
- .padding()
- }
-
- private var componentSelectionSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 12) {
- Text("Select components to export:")
- .font(.subheadline)
- .fontWeight(.medium)
-
- if self.viewModel.availableComponents.isEmpty {
- Text("No configuration found in this project.")
- .font(.caption)
- .foregroundStyle(.secondary)
- } else {
- ForEach(ConfigBundleComponent.allCases) { component in
- if self.viewModel.availableComponents.contains(component) {
- ComponentToggle(
- component: component,
- isSelected: Binding(
- get: { self.viewModel.selectedComponents.contains(component) },
- set: { selected in
- if selected {
- self.viewModel.selectedComponents.insert(component)
- } else {
- self.viewModel.selectedComponents.remove(component)
- }
- }
- )
- )
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Components", systemImage: "square.stack.3d.up")
- }
- }
-
- private var sensitiveDataWarning: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- Text("Local settings may contain sensitive data")
- .font(.subheadline)
- .fontWeight(.medium)
- }
-
- Text(
- "The local settings file (settings.local.json) may contain API keys, " +
- "tokens, or other sensitive information. Only share this export " +
- "file with trusted parties."
- )
- .font(.caption)
- .foregroundStyle(.secondary)
-
- Toggle(isOn: self.$viewModel.acknowledgedSensitiveData) {
- Text("I understand the risks")
- .font(.subheadline)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Security Warning", systemImage: "lock.shield")
- }
- }
-
- private var successSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- Text("Export successful!")
- .fontWeight(.medium)
- }
-
- if let url = viewModel.exportedURL {
- Text(url.path)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Success", systemImage: "checkmark.seal")
- }
- }
-
- private var footer: some View {
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- if self.viewModel.exportSuccessful {
- Button("Done") {
- self.dismiss()
- }
- .buttonStyle(.borderedProminent)
- .keyboardShortcut(.defaultAction)
- } else {
- Button("Export...") {
- Task {
- await self.viewModel.performExport()
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(!self.viewModel.canExport)
- .keyboardShortcut(.defaultAction)
- }
- }
- .padding()
- }
-
- private func errorSection(error: String) -> some View {
- GroupBox {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.red)
- Text(error)
- .foregroundStyle(.red)
- }
- .padding(.vertical, 4)
- } label: {
- Label("Error", systemImage: "xmark.octagon")
- }
- }
-}
-
-// MARK: - ComponentToggle
-
-/// Toggle for selecting a component with info.
-private struct ComponentToggle: View {
- let component: ConfigBundleComponent
-
- @Binding var isSelected: Bool
-
- var body: some View {
- Toggle(isOn: self.$isSelected) {
- HStack {
- Image(systemName: self.component.icon)
- .frame(width: 20)
- VStack(alignment: .leading, spacing: 2) {
- Text(self.component.displayName)
- .font(.subheadline)
- if let warning = component.sensitiveWarning {
- Text(warning)
- .font(.caption2)
- .foregroundStyle(.orange)
- }
- }
- }
- }
- }
-}
-
-#Preview {
- ConfigExportView(
- viewModel: ConfigExportViewModel(
- projectPath: URL(fileURLWithPath: "/tmp/project"),
- projectName: "My Project"
- )
- )
-}
diff --git a/Fig/Sources/Views/ConfigHealthCheckView.swift b/Fig/Sources/Views/ConfigHealthCheckView.swift
deleted file mode 100644
index 542a29d..0000000
--- a/Fig/Sources/Views/ConfigHealthCheckView.swift
+++ /dev/null
@@ -1,224 +0,0 @@
-import SwiftUI
-
-// MARK: - ConfigHealthCheckView
-
-/// View for displaying project config health check results.
-struct ConfigHealthCheckView: View {
- // MARK: Lifecycle
-
- init(viewModel: ProjectDetailViewModel) {
- self.viewModel = viewModel
- self._healthCheckVM = State(
- initialValue: ConfigHealthCheckViewModel(projectPath: viewModel.projectURL)
- )
- }
-
- // MARK: Internal
-
- @Bindable var viewModel: ProjectDetailViewModel
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- // Summary header
- HealthCheckHeaderView(
- healthVM: self.healthCheckVM,
- onRunChecks: { self.runChecks() }
- )
-
- if self.healthCheckVM.isRunning {
- ProgressView("Running health checks...")
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.vertical, 20)
- } else if self.healthCheckVM.findings.isEmpty, self.healthCheckVM.lastRunDate != nil {
- ContentUnavailableView(
- "No Findings",
- systemImage: "checkmark.seal.fill",
- description: Text("Your project configuration looks good!")
- )
- } else {
- // Findings grouped by severity
- ForEach(self.healthCheckVM.groupedFindings, id: \.severity) { group in
- FindingSectionView(
- severity: group.severity,
- findings: group.findings,
- onAutoFix: { finding in
- Task { await self.executeAutoFix(finding) }
- }
- )
- }
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .task {
- self.runChecks()
- }
- }
-
- // MARK: Private
-
- @State private var healthCheckVM: ConfigHealthCheckViewModel
-
- private func runChecks() {
- self.healthCheckVM.runChecks(
- globalSettings: self.viewModel.globalSettings,
- projectSettings: self.viewModel.projectSettings,
- projectLocalSettings: self.viewModel.projectLocalSettings,
- mcpConfig: self.viewModel.mcpConfig,
- legacyConfig: self.viewModel.legacyConfig,
- localSettingsExists: self.viewModel.projectLocalSettingsStatus?.exists ?? false,
- mcpConfigExists: self.viewModel.mcpConfigStatus?.exists ?? false
- )
- }
-
- private func executeAutoFix(_ finding: Finding) async {
- await self.healthCheckVM.executeAutoFix(
- finding,
- legacyConfig: self.viewModel.legacyConfig
- )
-
- // Reload project config to reflect changes
- await self.viewModel.loadConfiguration()
- }
-}
-
-// MARK: - HealthCheckHeaderView
-
-/// Summary header showing check status and severity counts.
-struct HealthCheckHeaderView: View {
- let healthVM: ConfigHealthCheckViewModel
- let onRunChecks: () -> Void
-
- var body: some View {
- HStack {
- VStack(alignment: .leading, spacing: 4) {
- Text("Configuration Health")
- .font(.headline)
-
- if let lastRun = healthVM.lastRunDate {
- Text("Last checked: \(lastRun, style: .relative) ago")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- Spacer()
-
- // Severity count badges
- if !self.healthVM.findings.isEmpty {
- HStack(spacing: 6) {
- ForEach(Severity.allCases, id: \.self) { severity in
- if let count = healthVM.severityCounts[severity], count > 0 {
- SeverityCountBadge(severity: severity, count: count)
- }
- }
- }
- }
-
- Button {
- self.onRunChecks()
- } label: {
- Label("Re-check", systemImage: "arrow.clockwise")
- }
- .disabled(self.healthVM.isRunning)
- }
- }
-}
-
-// MARK: - SeverityCountBadge
-
-/// Small badge showing the count for a severity level.
-struct SeverityCountBadge: View {
- let severity: Severity
- let count: Int
-
- var body: some View {
- HStack(spacing: 3) {
- Image(systemName: self.severity.icon)
- .font(.caption2)
- Text("\(self.count)")
- .font(.caption)
- .fontWeight(.medium)
- }
- .foregroundStyle(self.severity.color)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(self.severity.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 4))
- }
-}
-
-// MARK: - FindingSectionView
-
-/// Section showing findings for a single severity level.
-struct FindingSectionView: View {
- let severity: Severity
- let findings: [Finding]
- let onAutoFix: (Finding) -> Void
-
- var body: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(Array(self.findings.enumerated()), id: \.element.id) { index, finding in
- if index > 0 {
- Divider()
- }
- FindingRowView(finding: finding, onAutoFix: self.onAutoFix)
- }
- }
- .padding(.vertical, 4)
- } label: {
- HStack(spacing: 4) {
- Image(systemName: self.severity.icon)
- .foregroundStyle(self.severity.color)
- Text(self.severity.label)
- .fontWeight(.medium)
- Text("(\(self.findings.count))")
- .foregroundStyle(.secondary)
- }
- }
- }
-}
-
-// MARK: - FindingRowView
-
-/// A single finding row with title, description, and optional auto-fix button.
-struct FindingRowView: View {
- let finding: Finding
- let onAutoFix: (Finding) -> Void
-
- var body: some View {
- HStack(alignment: .top, spacing: 8) {
- Image(systemName: self.finding.severity.icon)
- .foregroundStyle(self.finding.severity.color)
- .font(.body)
- .frame(width: 20)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(self.finding.title)
- .font(.body)
- .fontWeight(.medium)
-
- Text(self.finding.description)
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
-
- Spacer()
-
- if let autoFix = finding.autoFix {
- Button {
- self.onAutoFix(self.finding)
- } label: {
- Label(autoFix.label, systemImage: "wand.and.stars")
- .font(.caption)
- }
- .buttonStyle(.bordered)
- .controlSize(.small)
- }
- }
- }
-}
diff --git a/Fig/Sources/Views/ConfigImportView.swift b/Fig/Sources/Views/ConfigImportView.swift
deleted file mode 100644
index f3a13bf..0000000
--- a/Fig/Sources/Views/ConfigImportView.swift
+++ /dev/null
@@ -1,452 +0,0 @@
-import SwiftUI
-
-// MARK: - ConfigImportView
-
-/// Wizard for importing project configuration from a bundle file.
-struct ConfigImportView: View {
- // MARK: Internal
-
- @Bindable var viewModel: ConfigImportViewModel
-
- var body: some View {
- VStack(spacing: 0) {
- // Header with step indicator
- self.header
-
- Divider()
-
- // Step content
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- self.stepContent
- }
- .padding()
- }
-
- Divider()
-
- // Footer
- self.footer
- }
- .frame(width: 500, height: 500)
- }
-
- // MARK: Private
-
- @Environment(\.dismiss) private var dismiss
-
- private var header: some View {
- VStack(spacing: 12) {
- HStack {
- Image(systemName: "square.and.arrow.down")
- .font(.title2)
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading) {
- Text("Import Configuration")
- .font(.headline)
- Text(self.viewModel.projectName)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
- }
-
- // Step indicator
- HStack(spacing: 0) {
- ForEach(Array(ImportWizardStep.allCases.enumerated()), id: \.element) { index, step in
- if index > 0 {
- Rectangle()
- .fill(step.rawValue <= self.viewModel.currentStep.rawValue
- ? Color.accentColor : Color.secondary.opacity(0.3))
- .frame(height: 2)
- }
-
- Circle()
- .fill(step.rawValue <= self.viewModel.currentStep.rawValue
- ? Color.accentColor : Color.secondary.opacity(0.3))
- .frame(width: 10, height: 10)
- }
- }
- .padding(.horizontal)
- }
- .padding()
- }
-
- @ViewBuilder
- private var stepContent: some View {
- switch self.viewModel.currentStep {
- case .selectFile:
- self.selectFileStep
- case .selectComponents:
- self.selectComponentsStep
- case .resolveConflicts:
- self.resolveConflictsStep
- case .preview:
- self.previewStep
- case .complete:
- self.completeStep
- }
- }
-
- // MARK: - Step Views
-
- private var selectFileStep: some View {
- VStack(alignment: .leading, spacing: 16) {
- Text("Select a configuration bundle to import.")
- .font(.subheadline)
-
- if let url = viewModel.selectedFileURL {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Image(systemName: "doc.fill")
- .foregroundStyle(.blue)
- Text(url.lastPathComponent)
- .fontWeight(.medium)
- }
-
- if let bundle = viewModel.bundle {
- Divider()
-
- HStack {
- Text("From:")
- .foregroundStyle(.secondary)
- Text(bundle.projectName)
- }
- .font(.caption)
-
- HStack {
- Text("Exported:")
- .foregroundStyle(.secondary)
- Text(bundle.exportedAt, style: .date)
- }
- .font(.caption)
-
- if !bundle.contentSummary.isEmpty {
- Divider()
- VStack(alignment: .leading, spacing: 2) {
- ForEach(bundle.contentSummary, id: \.self) { item in
- Text(item)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Selected File", systemImage: "doc")
- }
-
- Button("Choose Different File...") {
- Task {
- await self.viewModel.selectFile()
- }
- }
- } else {
- Button {
- Task {
- await self.viewModel.selectFile()
- }
- } label: {
- VStack(spacing: 8) {
- Image(systemName: "doc.badge.plus")
- .font(.largeTitle)
- Text("Select Bundle File")
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 40)
- }
- .buttonStyle(.bordered)
- }
-
- if self.viewModel.isLoading {
- ProgressView("Loading bundle...")
- }
-
- if let error = viewModel.errorMessage {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.red)
- Text(error)
- .foregroundStyle(.red)
- }
- .font(.caption)
- }
- }
- }
-
- private var selectComponentsStep: some View {
- VStack(alignment: .leading, spacing: 16) {
- Text("Select which components to import.")
- .font(.subheadline)
-
- GroupBox {
- VStack(alignment: .leading, spacing: 12) {
- ForEach(self.viewModel.availableComponents) { component in
- Toggle(isOn: Binding(
- get: { self.viewModel.selectedComponents.contains(component) },
- set: { selected in
- if selected {
- self.viewModel.selectedComponents.insert(component)
- } else {
- self.viewModel.selectedComponents.remove(component)
- }
- }
- )) {
- HStack {
- Image(systemName: component.icon)
- .frame(width: 20)
- VStack(alignment: .leading, spacing: 2) {
- Text(component.displayName)
- if let warning = component.sensitiveWarning {
- Text(warning)
- .font(.caption2)
- .foregroundStyle(.orange)
- }
- }
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Components", systemImage: "square.stack.3d.up")
- }
-
- // Sensitive data warning
- if self.viewModel.selectedComponents.contains(.localSettings) {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- Text("Local settings may contain sensitive data")
- .fontWeight(.medium)
- }
- .font(.subheadline)
-
- Toggle(isOn: self.$viewModel.acknowledgedSensitiveData) {
- Text("I trust this bundle and want to import it")
- .font(.subheadline)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Security Warning", systemImage: "lock.shield")
- }
- }
- }
- }
-
- private var resolveConflictsStep: some View {
- VStack(alignment: .leading, spacing: 16) {
- Text("The following conflicts were detected. Choose how to resolve them.")
- .font(.subheadline)
-
- ForEach(self.viewModel.conflicts) { conflict in
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.yellow)
- Text(conflict.description)
- }
-
- Picker("Resolution", selection: Binding(
- get: { self.viewModel.resolutions[conflict.component] ?? .merge },
- set: { self.viewModel.resolutions[conflict.component] = $0 }
- )) {
- ForEach(ImportConflict.ImportResolution.allCases) { resolution in
- Text(resolution.displayName).tag(resolution)
- }
- }
- .pickerStyle(.radioGroup)
- }
- .padding(.vertical, 4)
- } label: {
- Label(conflict.component.displayName, systemImage: conflict.component.icon)
- }
- }
- }
- }
-
- private var previewStep: some View {
- VStack(alignment: .leading, spacing: 16) {
- Text("Review the changes that will be made.")
- .font(.subheadline)
-
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(Array(self.viewModel.selectedComponents)) { component in
- HStack {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- Text(component.displayName)
-
- if let resolution = viewModel.resolutions[component] {
- Text("(\(resolution.displayName))")
- .foregroundStyle(.secondary)
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Components to Import", systemImage: "list.bullet")
- }
-
- if self.viewModel.hasSensitiveData {
- HStack {
- Image(systemName: "lock.shield")
- .foregroundStyle(.orange)
- Text("This import includes potentially sensitive data.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- }
- }
-
- private var completeStep: some View {
- VStack(alignment: .leading, spacing: 16) {
- if let result = viewModel.importResult {
- if result.success {
- VStack(spacing: 12) {
- Image(systemName: "checkmark.circle.fill")
- .font(.system(size: 48))
- .foregroundStyle(.green)
-
- Text("Import Complete!")
- .font(.title2)
- .fontWeight(.semibold)
-
- Text(result.message)
- .foregroundStyle(.secondary)
- }
- .frame(maxWidth: .infinity)
- .padding()
- } else {
- VStack(spacing: 12) {
- Image(systemName: "exclamationmark.triangle.fill")
- .font(.system(size: 48))
- .foregroundStyle(.orange)
-
- Text("Import Completed with Issues")
- .font(.title2)
- .fontWeight(.semibold)
- }
- .frame(maxWidth: .infinity)
- .padding()
- }
-
- if !result.componentsImported.isEmpty {
- GroupBox("Imported") {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(result.componentsImported) { component in
- HStack {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- Text(component.displayName)
- }
- .font(.caption)
- }
- }
- }
- }
-
- if !result.componentsSkipped.isEmpty {
- GroupBox("Skipped") {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(result.componentsSkipped) { component in
- HStack {
- Image(systemName: "minus.circle")
- .foregroundStyle(.secondary)
- Text(component.displayName)
- }
- .font(.caption)
- }
- }
- }
- }
-
- if !result.errors.isEmpty {
- GroupBox("Errors") {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(result.errors, id: \.self) { error in
- HStack {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- Text(error)
- }
- .font(.caption)
- }
- }
- }
- }
- } else if let error = viewModel.errorMessage {
- VStack(spacing: 12) {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 48))
- .foregroundStyle(.red)
-
- Text("Import Failed")
- .font(.title2)
- .fontWeight(.semibold)
-
- Text(error)
- .foregroundStyle(.secondary)
- }
- .frame(maxWidth: .infinity)
- .padding()
- }
- }
- }
-
- private var footer: some View {
- HStack {
- if self.viewModel.canGoBack {
- Button("Back") {
- self.viewModel.previousStep()
- }
- }
-
- Spacer()
-
- if self.viewModel.currentStep == .complete {
- Button("Done") {
- self.dismiss()
- }
- .buttonStyle(.borderedProminent)
- .keyboardShortcut(.defaultAction)
- } else {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Button(self.viewModel.currentStep == .preview ? "Import" : "Next") {
- Task {
- await self.viewModel.nextStep()
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(!self.viewModel.canProceed || self.viewModel.isImporting)
- .keyboardShortcut(.defaultAction)
- }
- }
- .padding()
- }
-}
-
-#Preview {
- ConfigImportView(
- viewModel: ConfigImportViewModel(
- projectPath: URL(fileURLWithPath: "/tmp/project"),
- projectName: "My Project"
- )
- )
-}
diff --git a/Fig/Sources/Views/ConfigTabViews.swift b/Fig/Sources/Views/ConfigTabViews.swift
deleted file mode 100644
index b98962f..0000000
--- a/Fig/Sources/Views/ConfigTabViews.swift
+++ /dev/null
@@ -1,889 +0,0 @@
-import AppKit
-import SwiftUI
-
-// MARK: - SourceBadge
-
-/// A small badge indicating the source of a configuration value.
-struct SourceBadge: View {
- // MARK: Internal
-
- let source: ConfigSource
-
- var body: some View {
- HStack(spacing: 2) {
- Image(systemName: self.source.icon)
- .font(.caption2)
- Text(self.source.label)
- .font(.caption2)
- }
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(self.backgroundColor.opacity(0.2), in: RoundedRectangle(cornerRadius: 4))
- .foregroundStyle(self.backgroundColor)
- .accessibilityLabel("Source: \(self.source.label)")
- }
-
- // MARK: Private
-
- private var backgroundColor: Color {
- switch self.source {
- case .global:
- .blue
- case .projectShared:
- .purple
- case .projectLocal:
- .orange
- }
- }
-}
-
-// MARK: - PermissionsTabView
-
-/// Tab view displaying permission rules.
-struct PermissionsTabView: View {
- // MARK: Internal
-
- var permissions: Permissions?
- var allPermissions: [(rule: String, type: PermissionType, source: ConfigSource)]?
- var source: ConfigSource?
- var emptyMessage = "No permission rules configured."
-
- /// Callback when a rule should be promoted to global settings.
- var onPromoteToGlobal: ((String, PermissionType) -> Void)?
-
- /// Callback when a rule should be copied to another scope.
- var onCopyToScope: ((String, PermissionType, ConfigSource) -> Void)?
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- // Source legend for project views
- if self.allPermissions != nil {
- SourceLegend()
- }
-
- // Allow rules
- GroupBox {
- VStack(alignment: .leading, spacing: 4) {
- Label("Allow Rules", systemImage: "checkmark.circle.fill")
- .font(.headline)
- .foregroundStyle(.green)
-
- let allowRules = self.allowPermissions
- if allowRules.isEmpty {
- Text("No allow rules configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- ForEach(Array(allowRules.enumerated()), id: \.offset) { _, item in
- PermissionRuleRow(
- rule: item.rule,
- type: .allow,
- source: item.source,
- isOverride: self.isRuleOverridingGlobal(
- rule: item.rule,
- type: .allow,
- source: item.source
- ),
- onPromoteToGlobal: self.onPromoteToGlobal,
- onCopyToScope: self.onCopyToScope
- )
- }
- }
- }
- .padding(.vertical, 4)
- }
-
- // Deny rules
- GroupBox {
- VStack(alignment: .leading, spacing: 4) {
- Label("Deny Rules", systemImage: "xmark.circle.fill")
- .font(.headline)
- .foregroundStyle(.red)
-
- let denyRules = self.denyPermissions
- if denyRules.isEmpty {
- Text("No deny rules configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- ForEach(Array(denyRules.enumerated()), id: \.offset) { _, item in
- PermissionRuleRow(
- rule: item.rule,
- type: .deny,
- source: item.source,
- isOverride: self.isRuleOverridingGlobal(
- rule: item.rule,
- type: .deny,
- source: item.source
- ),
- onPromoteToGlobal: self.onPromoteToGlobal,
- onCopyToScope: self.onCopyToScope
- )
- }
- }
- }
- .padding(.vertical, 4)
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-
- // MARK: Private
-
- private var allowPermissions: [(rule: String, source: ConfigSource)] {
- if let allPermissions {
- return allPermissions.filter { $0.type == .allow }.map { ($0.rule, $0.source) }
- }
- if let permissions, let source {
- return (permissions.allow ?? []).map { ($0, source) }
- }
- return []
- }
-
- private var denyPermissions: [(rule: String, source: ConfigSource)] {
- if let allPermissions {
- return allPermissions.filter { $0.type == .deny }.map { ($0.rule, $0.source) }
- }
- if let permissions, let source {
- return (permissions.deny ?? []).map { ($0, source) }
- }
- return []
- }
-
- /// Checks if a project-level rule also exists at the global level.
- private func isRuleOverridingGlobal(rule: String, type: PermissionType, source: ConfigSource) -> Bool {
- guard source != .global, let allPermissions else {
- return false
- }
- return allPermissions.contains { $0.rule == rule && $0.type == type && $0.source == .global }
- }
-}
-
-// MARK: - PermissionRuleRow
-
-/// A row displaying a single permission rule.
-struct PermissionRuleRow: View {
- let rule: String
- let type: PermissionType
- let source: ConfigSource
- var isOverride = false
- var onPromoteToGlobal: ((String, PermissionType) -> Void)?
- var onCopyToScope: ((String, PermissionType, ConfigSource) -> Void)?
-
- var body: some View {
- HStack {
- Image(systemName: self.type.icon)
- .foregroundStyle(self.type == .allow ? .green : .red)
- .frame(width: 20)
-
- Text(self.rule)
- .font(.system(.body, design: .monospaced))
- .lineLimit(1)
- .truncationMode(.middle)
-
- if self.isOverride {
- Image(systemName: "arrow.up.arrow.down")
- .foregroundStyle(.orange)
- .font(.caption2)
- .help("This rule also exists in global settings")
- }
-
- Spacer()
-
- SourceBadge(source: self.source)
- }
- .padding(.vertical, 2)
- .accessibilityElement(children: .combine)
- .accessibilityLabel(
- "\(self.type == .allow ? "Allow" : "Deny") rule: \(self.rule), source: \(self.source.label)"
- )
- .contextMenu {
- Button {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(self.rule, forType: .string)
- } label: {
- Label("Copy Rule", systemImage: "doc.on.doc")
- }
-
- if self.source != .global, self.onPromoteToGlobal != nil {
- Divider()
-
- Button {
- self.onPromoteToGlobal?(self.rule, self.type)
- } label: {
- Label("Promote to Global", systemImage: "arrow.up.to.line")
- }
- }
-
- if self.source == .projectShared || self.source == .projectLocal {
- let otherScope: ConfigSource = self.source == .projectShared ? .projectLocal : .projectShared
- if self.onCopyToScope != nil {
- Button {
- self.onCopyToScope?(self.rule, self.type, otherScope)
- } label: {
- Label("Copy to \(otherScope.label)", systemImage: "arrow.left.arrow.right")
- }
- }
- }
- }
- }
-}
-
-// MARK: - EnvironmentTabView
-
-/// Tab view displaying environment variables.
-struct EnvironmentTabView: View {
- let envVars: [(key: String, value: String, source: ConfigSource)]
- var emptyMessage = "No environment variables configured."
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- if !self.envVars.isEmpty {
- SourceLegend()
- }
-
- if self.envVars.isEmpty {
- ContentUnavailableView(
- "No Environment Variables",
- systemImage: "list.bullet.rectangle",
- description: Text(self.emptyMessage)
- )
- } else {
- GroupBox {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(Array(self.envVars.enumerated()), id: \.offset) { index, item in
- if index > 0 {
- Divider()
- }
- EnvironmentVariableRow(
- key: item.key,
- value: item.value,
- source: item.source
- )
- }
- }
- }
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-}
-
-// MARK: - EnvironmentVariableRow
-
-/// A row displaying a single environment variable.
-struct EnvironmentVariableRow: View {
- // MARK: Internal
-
- let key: String
- let value: String
- let source: ConfigSource
-
- var body: some View {
- HStack(alignment: .top) {
- Text(self.key)
- .font(.system(.body, design: .monospaced))
- .fontWeight(.medium)
- .frame(minWidth: 200, alignment: .leading)
-
- Text("=")
- .foregroundStyle(.secondary)
-
- Group {
- if self.isValueVisible || !self.isSensitive {
- Text(self.value)
- .font(.system(.body, design: .monospaced))
- .lineLimit(2)
- .truncationMode(.middle)
- } else {
- Text(String(repeating: "\u{2022}", count: min(self.value.count, 20)))
- .font(.system(.body, design: .monospaced))
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
-
- if self.isSensitive {
- Button {
- self.isValueVisible.toggle()
- } label: {
- Image(systemName: self.isValueVisible ? "eye.slash" : "eye")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .accessibilityLabel(self.isValueVisible ? "Hide value" : "Show value")
- .accessibilityHint("Toggles visibility of sensitive value for \(self.key)")
- }
-
- SourceBadge(source: self.source)
- }
- .padding(.vertical, 8)
- }
-
- // MARK: Private
-
- @State private var isValueVisible = false
-
- private var isSensitive: Bool {
- let sensitivePatterns = ["token", "key", "secret", "password", "credential", "api"]
- let lowercaseKey = self.key.lowercased()
- return sensitivePatterns.contains { lowercaseKey.contains($0) }
- }
-}
-
-// MARK: - MCPServersTabView
-
-/// Tab view displaying MCP server configurations.
-struct MCPServersTabView: View {
- let servers: [(name: String, server: MCPServer, source: ConfigSource)]
- var emptyMessage = "No MCP servers configured."
- var projectPath: URL?
- var onAdd: (() -> Void)?
- var onEdit: ((String, MCPServer, ConfigSource) -> Void)?
- var onDelete: ((String, ConfigSource) -> Void)?
- var onCopy: ((String, MCPServer) -> Void)?
- var onCopyAll: (() -> Void)?
- var onPasteServers: (() -> Void)?
-
- var hasToolbar: Bool {
- self.onAdd != nil || self.onCopyAll != nil || self.onPasteServers != nil
- }
-
- var body: some View {
- VStack(spacing: 0) {
- // Toolbar
- if self.hasToolbar {
- HStack {
- if self.onPasteServers != nil {
- Button {
- self.onPasteServers?()
- } label: {
- Label("Import from JSON", systemImage: "doc.on.clipboard")
- }
- .buttonStyle(.bordered)
- }
-
- Spacer()
-
- if self.onCopyAll != nil, !self.servers.isEmpty {
- Button {
- self.onCopyAll?()
- } label: {
- Label("Copy All as JSON", systemImage: "doc.on.doc")
- }
- .buttonStyle(.bordered)
- }
-
- if self.onAdd != nil {
- Button {
- self.onAdd?()
- } label: {
- Label("Add Server", systemImage: "plus")
- }
- .buttonStyle(.bordered)
- }
- }
- .padding(.horizontal)
- .padding(.vertical, 8)
-
- Divider()
- }
-
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- if !self.servers.isEmpty {
- SourceLegend()
- }
-
- if self.servers.isEmpty {
- ContentUnavailableView(
- "No MCP Servers",
- systemImage: "server.rack",
- description: Text(self.emptyMessage)
- )
- } else {
- ForEach(Array(self.servers.enumerated()), id: \.offset) { _, item in
- MCPServerCard(
- name: item.name,
- server: item.server,
- source: item.source,
- onEdit: self.onEdit != nil ? {
- self.onEdit?(item.name, item.server, item.source)
- } : nil,
- onDelete: self.onDelete != nil ? {
- self.onDelete?(item.name, item.source)
- } : nil,
- onCopy: self.onCopy != nil ? {
- self.onCopy?(item.name, item.server)
- } : nil
- )
- }
- }
-
- Spacer()
- }
- .padding()
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
- }
-}
-
-// MARK: - MCPServerCard
-
-/// A card displaying MCP server details.
-struct MCPServerCard: View {
- // MARK: Internal
-
- let name: String
- let server: MCPServer
- let source: ConfigSource
- var onEdit: (() -> Void)?
- var onDelete: (() -> Void)?
- var onCopy: (() -> Void)?
-
- var body: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- // Header
- HStack {
- Image(systemName: self.server.isHTTP ? "globe" : "terminal")
- .foregroundStyle(self.server.isHTTP ? .blue : .green)
-
- Text(self.name)
- .font(.headline)
-
- Text(self.server.isHTTP ? "HTTP" : "Stdio")
- .font(.caption)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 4))
-
- Spacer()
-
- // Action buttons
- HStack(spacing: 4) {
- // Health check button
- MCPHealthCheckButton(serverName: self.name, server: self.server)
-
- // Copy to clipboard
- Button {
- self.copyToClipboard()
- } label: {
- Image(systemName: "doc.on.doc")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .help("Copy as JSON")
- .accessibilityLabel("Copy \(self.name) as JSON")
-
- // Copy to project
- if self.onCopy != nil {
- Button {
- self.onCopy?()
- } label: {
- Image(systemName: "arrow.right.doc.on.clipboard")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .help("Copy to...")
- .accessibilityLabel("Copy \(self.name) to another project")
- }
-
- if self.onEdit != nil {
- Button {
- self.onEdit?()
- } label: {
- Image(systemName: "pencil")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .help("Edit server")
- .accessibilityLabel("Edit \(self.name)")
- }
-
- if self.onDelete != nil {
- Button {
- self.onDelete?()
- } label: {
- Image(systemName: "trash")
- .font(.caption)
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- .help("Delete server")
- .accessibilityLabel("Delete \(self.name)")
- }
- }
-
- SourceBadge(source: self.source)
-
- if self.hasExpandableContent {
- Button {
- withAnimation {
- self.isExpanded.toggle()
- }
- } label: {
- Image(systemName: self.isExpanded ? "chevron.up" : "chevron.down")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .accessibilityLabel(self
- .isExpanded ? "Collapse \(self.name) details" : "Expand \(self.name) details")
- }
- }
-
- // Summary line
- if self.server.isHTTP {
- if let url = server.url {
- Text(url)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
- } else if let command = server.command {
- HStack(spacing: 4) {
- Text(command)
- .font(.system(.caption, design: .monospaced))
- if let args = server.args, !args.isEmpty {
- Text(args.joined(separator: " "))
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
- }
- }
-
- // Expanded details
- if self.isExpanded {
- Divider()
-
- if self.server.isHTTP {
- if let headers = server.headers, !headers.isEmpty {
- Text("Headers:")
- .font(.caption)
- .fontWeight(.medium)
- ForEach(Array(headers.keys.sorted()), id: \.self) { key in
- HStack {
- Text(key)
- .font(.system(.caption, design: .monospaced))
- Text(":")
- .foregroundStyle(.secondary)
- Text(self.maskSensitiveValue(key: key, value: headers[key] ?? ""))
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- }
- }
- } else {
- if let env = server.env, !env.isEmpty {
- Text("Environment:")
- .font(.caption)
- .fontWeight(.medium)
- ForEach(Array(env.keys.sorted()), id: \.self) { key in
- HStack {
- Text(key)
- .font(.system(.caption, design: .monospaced))
- Text("=")
- .foregroundStyle(.secondary)
- Text(self.maskSensitiveValue(key: key, value: env[key] ?? ""))
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.secondary)
- }
- }
- }
- }
- }
- }
- }
- .contextMenu {
- Button {
- self.copyToClipboard()
- } label: {
- Label("Copy as JSON", systemImage: "doc.on.doc")
- }
-
- if self.onCopy != nil {
- Button {
- self.onCopy?()
- } label: {
- Label("Copy to...", systemImage: "arrow.right.doc.on.clipboard")
- }
- }
-
- Divider()
-
- if self.onEdit != nil {
- Button {
- self.onEdit?()
- } label: {
- Label("Edit", systemImage: "pencil")
- }
- }
-
- if self.onDelete != nil {
- Divider()
- Button(role: .destructive) {
- self.onDelete?()
- } label: {
- Label("Delete", systemImage: "trash")
- }
- }
- }
- }
-
- // MARK: Private
-
- @State private var isExpanded = false
-
- private var hasExpandableContent: Bool {
- if self.server.isHTTP {
- self.server.headers?.isEmpty == false
- } else {
- self.server.env?.isEmpty == false
- }
- }
-
- private func maskSensitiveValue(key: String, value: String) -> String {
- let sensitivePatterns = ["token", "key", "secret", "password", "credential", "api", "authorization"]
- let lowercaseKey = key.lowercased()
- if sensitivePatterns.contains(where: { lowercaseKey.contains($0) }) {
- return String(repeating: "\u{2022}", count: min(value.count, 20))
- }
- return value
- }
-
- private func copyToClipboard() {
- do {
- let encoder = JSONEncoder()
- encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
- let data = try encoder.encode([name: self.server])
-
- guard let jsonString = String(data: data, encoding: .utf8) else {
- NotificationManager.shared.showError(
- "Copy failed",
- message: "Failed to convert server data to text"
- )
- return
- }
-
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(jsonString, forType: .string)
- NotificationManager.shared.showSuccess(
- "Copied to clipboard",
- message: "Server '\(self.name)' copied as JSON"
- )
- } catch {
- Log.general.error("Failed to encode server '\(self.name)': \(error)")
- NotificationManager.shared.showError(
- "Copy failed",
- message: "Failed to encode server configuration"
- )
- }
- }
-}
-
-// MARK: - HooksTabView
-
-/// Tab view displaying hook configurations.
-struct HooksTabView: View {
- // MARK: Internal
-
- let globalHooks: [String: [HookGroup]]?
- let projectHooks: [String: [HookGroup]]?
- let localHooks: [String: [HookGroup]]?
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- SourceLegend()
-
- if self.allHooksEmpty {
- ContentUnavailableView(
- "No Hooks Configured",
- systemImage: "arrow.triangle.branch",
- description: Text(
- "Hooks allow you to run custom commands before or after Claude Code operations."
- )
- )
- } else {
- // List all hook events
- ForEach(self.allHookEvents, id: \.self) { event in
- HookEventSection(
- event: event,
- globalGroups: self.globalHooks?[event],
- projectGroups: self.projectHooks?[event],
- localGroups: self.localHooks?[event]
- )
- }
- }
-
- HookVariablesReference(isCollapsible: false)
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-
- // MARK: Private
-
- private var allHooksEmpty: Bool {
- (self.globalHooks?.isEmpty ?? true) &&
- (self.projectHooks?.isEmpty ?? true) &&
- (self.localHooks?.isEmpty ?? true)
- }
-
- private var allHookEvents: [String] {
- var events = Set()
- if let global = globalHooks {
- events.formUnion(global.keys)
- }
- if let project = projectHooks {
- events.formUnion(project.keys)
- }
- if let local = localHooks {
- events.formUnion(local.keys)
- }
- return events.sorted()
- }
-}
-
-// MARK: - HookEventSection
-
-/// Section showing hooks for a specific event.
-struct HookEventSection: View {
- let event: String
- let globalGroups: [HookGroup]?
- let projectGroups: [HookGroup]?
- let localGroups: [HookGroup]?
-
- var body: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- Text(self.event)
- .font(.headline)
-
- // Global hooks
- if let groups = globalGroups {
- ForEach(Array(groups.enumerated()), id: \.offset) { _, group in
- HookGroupRow(group: group, source: .global)
- }
- }
-
- // Project hooks
- if let groups = projectGroups {
- ForEach(Array(groups.enumerated()), id: \.offset) { _, group in
- HookGroupRow(group: group, source: .projectShared)
- }
- }
-
- // Local hooks
- if let groups = localGroups {
- ForEach(Array(groups.enumerated()), id: \.offset) { _, group in
- HookGroupRow(group: group, source: .projectLocal)
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
-}
-
-// MARK: - HookGroupRow
-
-/// A row displaying a hook group.
-struct HookGroupRow: View {
- let group: HookGroup
- let source: ConfigSource
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- if let matcher = group.matcher {
- Text("Matcher: \(matcher)")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- SourceBadge(source: self.source)
- }
-
- if let hooks = group.hooks {
- ForEach(Array(hooks.enumerated()), id: \.offset) { _, hook in
- HStack {
- Image(systemName: "terminal")
- .font(.caption)
- .foregroundStyle(.secondary)
- Text(hook.command ?? "No command")
- .font(.system(.caption, design: .monospaced))
- .lineLimit(1)
- }
- .padding(.leading, 16)
- }
- }
- }
- .padding(.vertical, 4)
- .padding(.horizontal, 8)
- .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
- }
-}
-
-// MARK: - SourceLegend
-
-/// Legend explaining source badge colors.
-struct SourceLegend: View {
- var body: some View {
- HStack(spacing: 16) {
- Text("Source:")
- .font(.caption)
- .foregroundStyle(.secondary)
-
- ForEach(ConfigSource.allCases, id: \.rawValue) { source in
- SourceBadge(source: source)
- }
- }
- .padding(.bottom, 8)
- }
-}
-
-#Preview("Permissions Tab") {
- PermissionsTabView(
- allPermissions: [
- ("Bash(npm run *)", .allow, .global),
- ("Read(src/**)", .allow, .projectShared),
- ("Read(.env)", .deny, .projectLocal),
- ]
- )
- .padding()
- .frame(width: 600, height: 400)
-}
-
-#Preview("Environment Tab") {
- EnvironmentTabView(
- envVars: [
- ("CLAUDE_CODE_MAX_OUTPUT_TOKENS", "16384", .global),
- ("API_KEY", "sk-1234567890", .projectLocal),
- ]
- )
- .padding()
- .frame(width: 600, height: 400)
-}
-
-#Preview("MCP Servers Tab") {
- MCPServersTabView(
- servers: [
- ("github", .stdio(command: "npx", args: ["-y", "@mcp/server-github"]), .projectShared),
- ("api", .http(url: "https://mcp.example.com"), .global),
- ]
- )
- .padding()
- .frame(width: 600, height: 400)
-}
diff --git a/Fig/Sources/Views/ContentView.swift b/Fig/Sources/Views/ContentView.swift
deleted file mode 100644
index ee2a832..0000000
--- a/Fig/Sources/Views/ContentView.swift
+++ /dev/null
@@ -1,36 +0,0 @@
-import SwiftUI
-
-/// The main content view with NavigationSplitView layout.
-struct ContentView: View {
- // MARK: Internal
-
- var body: some View {
- NavigationSplitView(columnVisibility: self.$columnVisibility) {
- SidebarView(selection: self.$selection, viewModel: self.viewModel)
- } detail: {
- DetailView(selection: self.selection)
- }
- .navigationSplitViewStyle(.balanced)
- .focusedSceneValue(\.navigationSelection, self.$selection)
- .onKeyPress(keys: [KeyEquivalent("k")], phases: .down) { press in
- guard press.modifiers.contains(.command) else {
- return .ignored
- }
- self.viewModel.isQuickSwitcherPresented = true
- return .handled
- }
- .sheet(isPresented: self.$viewModel.isQuickSwitcherPresented) {
- QuickSwitcherView(viewModel: self.viewModel, selection: self.$selection)
- }
- }
-
- // MARK: Private
-
- @State private var selection: NavigationSelection?
- @State private var columnVisibility: NavigationSplitViewVisibility = .all
- @State private var viewModel = ProjectExplorerViewModel()
-}
-
-#Preview {
- ContentView()
-}
diff --git a/Fig/Sources/Views/DetailView.swift b/Fig/Sources/Views/DetailView.swift
deleted file mode 100644
index 5461061..0000000
--- a/Fig/Sources/Views/DetailView.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-import SwiftUI
-
-/// The detail view displaying content for the selected sidebar item.
-struct DetailView: View {
- let selection: NavigationSelection?
-
- var body: some View {
- Group {
- switch self.selection {
- case .globalSettings:
- GlobalSettingsDetailView()
- case let .project(path):
- ProjectDetailView(projectPath: path)
- .id(path)
- case nil:
- ContentUnavailableView(
- "Select an Item",
- systemImage: "sidebar.left",
- description: Text("Choose an item from the sidebar to get started.")
- )
- }
- }
- .frame(minWidth: 500)
- }
-}
-
-#Preview("Global Settings") {
- DetailView(selection: .globalSettings)
- .frame(width: 700, height: 500)
-}
-
-#Preview("Project") {
- DetailView(selection: .project("/Users/test/project"))
- .frame(width: 700, height: 500)
-}
-
-#Preview("No Selection") {
- DetailView(selection: nil)
- .frame(width: 700, height: 500)
-}
diff --git a/Fig/Sources/Views/EffectiveConfigView.swift b/Fig/Sources/Views/EffectiveConfigView.swift
deleted file mode 100644
index 38c6e9b..0000000
--- a/Fig/Sources/Views/EffectiveConfigView.swift
+++ /dev/null
@@ -1,648 +0,0 @@
-import AppKit
-import SwiftUI
-
-// MARK: - EffectiveConfigView
-
-/// Read-only view showing the fully merged/resolved configuration for a project,
-/// with visual indicators showing where each value comes from.
-struct EffectiveConfigView: View {
- // MARK: Internal
-
- let mergedSettings: MergedSettings
- let envOverrides: [String: [(value: String, source: ConfigSource)]]
-
- var body: some View {
- VStack(spacing: 0) {
- HStack {
- Spacer()
-
- Toggle("View as JSON", isOn: self.$showJSON)
- .toggleStyle(.switch)
- .controlSize(.small)
-
- Button {
- self.exportToClipboard()
- } label: {
- Label("Copy to Clipboard", systemImage: "doc.on.clipboard")
- }
- .buttonStyle(.bordered)
- .controlSize(.small)
- }
- .padding(.horizontal)
- .padding(.vertical, 8)
-
- Divider()
-
- if self.showJSON {
- EffectiveConfigJSONView(mergedSettings: self.mergedSettings)
- } else {
- EffectiveConfigStructuredView(
- mergedSettings: self.mergedSettings,
- envOverrides: self.envOverrides
- )
- }
- }
- }
-
- // MARK: Private
-
- @State private var showJSON = false
-
- private func exportToClipboard() {
- let json = EffectiveConfigSerializer.toJSON(self.mergedSettings)
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(json, forType: .string)
- NotificationManager.shared.showSuccess(
- "Copied to Clipboard",
- message: "Merged configuration exported as JSON"
- )
- }
-}
-
-// MARK: - EffectiveConfigStructuredView
-
-/// Structured view showing merged settings organized by section.
-struct EffectiveConfigStructuredView: View {
- let mergedSettings: MergedSettings
- let envOverrides: [String: [(value: String, source: ConfigSource)]]
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- SourceLegend()
-
- EffectivePermissionsSection(permissions: self.mergedSettings.permissions)
- EffectiveEnvSection(env: self.mergedSettings.env, overrides: self.envOverrides)
- EffectiveHooksSection(hooks: self.mergedSettings.hooks)
- EffectiveDisallowedToolsSection(tools: self.mergedSettings.disallowedTools)
- EffectiveAttributionSection(attribution: self.mergedSettings.attribution)
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-}
-
-// MARK: - EffectiveConfigJSONView
-
-/// Raw JSON view of the merged settings.
-struct EffectiveConfigJSONView: View {
- let mergedSettings: MergedSettings
-
- var body: some View {
- let jsonString = EffectiveConfigSerializer.toJSON(self.mergedSettings)
- ScrollView {
- Text(jsonString)
- .font(.system(.body, design: .monospaced))
- .textSelection(.enabled)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- }
- .background(.quaternary.opacity(0.3))
- }
-}
-
-// MARK: - EffectivePermissionsSection
-
-/// Section showing merged permission rules.
-struct EffectivePermissionsSection: View {
- let permissions: MergedPermissions
-
- var body: some View {
- GroupBox("Permissions") {
- if self.permissions.allow.isEmpty, self.permissions.deny.isEmpty {
- Text("No permission rules configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- VStack(alignment: .leading, spacing: 8) {
- if !self.permissions.allow.isEmpty {
- Label("Allow", systemImage: "checkmark.circle.fill")
- .font(.subheadline)
- .fontWeight(.medium)
- .foregroundStyle(.green)
-
- ForEach(Array(self.permissions.allow.enumerated()), id: \.offset) { _, entry in
- EffectiveRuleRow(
- rule: entry.value,
- source: entry.source,
- icon: "checkmark.circle.fill",
- iconColor: .green
- )
- }
- }
-
- if !self.permissions.allow.isEmpty, !self.permissions.deny.isEmpty {
- Divider()
- }
-
- if !self.permissions.deny.isEmpty {
- Label("Deny", systemImage: "xmark.circle.fill")
- .font(.subheadline)
- .fontWeight(.medium)
- .foregroundStyle(.red)
-
- ForEach(Array(self.permissions.deny.enumerated()), id: \.offset) { _, entry in
- EffectiveRuleRow(
- rule: entry.value,
- source: entry.source,
- icon: "xmark.circle.fill",
- iconColor: .red
- )
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
- }
-}
-
-// MARK: - EffectiveEnvSection
-
-/// Section showing merged environment variables with override indicators.
-struct EffectiveEnvSection: View {
- let env: [String: MergedValue]
- let overrides: [String: [(value: String, source: ConfigSource)]]
-
- var body: some View {
- GroupBox("Environment Variables") {
- if self.env.isEmpty {
- Text("No environment variables configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(Array(self.env.keys.sorted().enumerated()), id: \.offset) { index, key in
- if index > 0 {
- Divider()
- }
-
- let entry = self.env[key]!
- let keyOverrides = self.overrides[key]
-
- EffectiveEnvRow(
- key: key,
- effectiveValue: entry.value,
- effectiveSource: entry.source,
- overriddenEntries: keyOverrides?.filter { $0.source != entry.source } ?? []
- )
- }
- }
- .padding(.vertical, 4)
- }
- }
- }
-}
-
-// MARK: - EffectiveHooksSection
-
-/// Section showing merged hook configurations.
-struct EffectiveHooksSection: View {
- let hooks: MergedHooks
-
- var body: some View {
- GroupBox("Hooks") {
- if self.hooks.eventNames.isEmpty {
- Text("No hooks configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- VStack(alignment: .leading, spacing: 12) {
- ForEach(self.hooks.eventNames, id: \.self) { event in
- if let groups = hooks.groups(for: event) {
- EffectiveHookEventView(event: event, groups: groups)
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
- }
-}
-
-// MARK: - EffectiveHookEventView
-
-/// Displays hook groups for a single event type.
-struct EffectiveHookEventView: View {
- let event: String
- let groups: [MergedValue]
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(self.event)
- .font(.subheadline)
- .fontWeight(.medium)
-
- ForEach(Array(self.groups.enumerated()), id: \.offset) { _, group in
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- if let matcher = group.value.matcher {
- Text("Matcher: \(matcher)")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- if let hookDefs = group.value.hooks {
- ForEach(Array(hookDefs.enumerated()), id: \.offset) { _, hook in
- HStack(spacing: 4) {
- Image(systemName: "terminal")
- .font(.caption2)
- .foregroundStyle(.secondary)
- Text(hook.command ?? "No command")
- .font(.system(.caption, design: .monospaced))
- }
- }
- }
- }
- Spacer()
- SourceBadge(source: group.source)
- }
- .padding(.vertical, 4)
- .padding(.horizontal, 8)
- .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
- }
- }
- }
-}
-
-// MARK: - EffectiveDisallowedToolsSection
-
-/// Section showing merged disallowed tools.
-struct EffectiveDisallowedToolsSection: View {
- let tools: [MergedValue]
-
- var body: some View {
- GroupBox("Disallowed Tools") {
- if self.tools.isEmpty {
- Text("No tools are disallowed.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(Array(self.tools.enumerated()), id: \.offset) { _, entry in
- HStack {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- Text(entry.value)
- .font(.system(.body, design: .monospaced))
- Spacer()
- SourceBadge(source: entry.source)
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
- }
-}
-
-// MARK: - EffectiveAttributionSection
-
-/// Section showing merged attribution settings.
-struct EffectiveAttributionSection: View {
- let attribution: MergedValue?
-
- var body: some View {
- GroupBox("Attribution") {
- if let attr = attribution {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- SourceBadge(source: attr.source)
- Spacer()
- }
- HStack {
- Text("Commit Attribution")
- Spacer()
- Image(systemName: attr.value.commits ?? false
- ? "checkmark.circle.fill" : "xmark.circle")
- .foregroundStyle(attr.value.commits ?? false ? .green : .secondary)
- }
- HStack {
- Text("Pull Request Attribution")
- Spacer()
- Image(systemName: attr.value.pullRequests ?? false
- ? "checkmark.circle.fill" : "xmark.circle")
- .foregroundStyle(attr.value.pullRequests ?? false ? .green : .secondary)
- }
- }
- .padding(.vertical, 4)
- } else {
- Text("Using default attribution settings.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- }
- }
- }
-}
-
-// MARK: - EffectiveRuleRow
-
-/// A row showing a single effective permission rule with its source.
-struct EffectiveRuleRow: View {
- let rule: String
- let source: ConfigSource
- let icon: String
- let iconColor: Color
-
- var body: some View {
- HStack {
- Image(systemName: self.icon)
- .foregroundStyle(self.iconColor)
- .frame(width: 20)
- Text(self.rule)
- .font(.system(.body, design: .monospaced))
- .lineLimit(1)
- .truncationMode(.middle)
- Spacer()
- SourceBadge(source: self.source)
- }
- .padding(.vertical, 2)
- }
-}
-
-// MARK: - EffectiveEnvRow
-
-/// A row displaying an effective environment variable with override information.
-struct EffectiveEnvRow: View {
- // MARK: Internal
-
- let key: String
- let effectiveValue: String
- let effectiveSource: ConfigSource
- let overriddenEntries: [(value: String, source: ConfigSource)]
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- EffectiveEnvValueRow(
- key: self.key,
- value: self.effectiveValue,
- source: self.effectiveSource,
- isSensitive: self.isSensitive,
- isValueVisible: self.$isValueVisible
- )
-
- if !self.overriddenEntries.isEmpty {
- ForEach(Array(self.overriddenEntries.enumerated()), id: \.offset) { _, entry in
- EffectiveEnvOverriddenRow(
- key: self.key,
- value: entry.value,
- source: entry.source,
- isSensitive: self.isSensitive,
- isValueVisible: self.isValueVisible
- )
- }
- }
- }
- .padding(.vertical, 8)
- }
-
- // MARK: Private
-
- @State private var isValueVisible = false
-
- private var isSensitive: Bool {
- let sensitivePatterns = ["token", "key", "secret", "password", "credential", "api"]
- let lowercaseKey = self.key.lowercased()
- return sensitivePatterns.contains { lowercaseKey.contains($0) }
- }
-}
-
-// MARK: - EffectiveEnvValueRow
-
-/// Displays the effective (winning) value for an environment variable.
-struct EffectiveEnvValueRow: View {
- let key: String
- let value: String
- let source: ConfigSource
- let isSensitive: Bool
-
- @Binding var isValueVisible: Bool
-
- var body: some View {
- HStack(alignment: .top) {
- Text(self.key)
- .font(.system(.body, design: .monospaced))
- .fontWeight(.medium)
- .frame(minWidth: 200, alignment: .leading)
-
- Text("=")
- .foregroundStyle(.secondary)
-
- Group {
- if self.isValueVisible || !self.isSensitive {
- Text(self.value)
- .font(.system(.body, design: .monospaced))
- .lineLimit(2)
- .truncationMode(.middle)
- } else {
- Text(String(repeating: "\u{2022}", count: min(self.value.count, 20)))
- .font(.system(.body, design: .monospaced))
- }
- }
- .frame(maxWidth: .infinity, alignment: .leading)
-
- if self.isSensitive {
- Button {
- self.isValueVisible.toggle()
- } label: {
- Image(systemName: self.isValueVisible ? "eye.slash" : "eye")
- .font(.caption)
- }
- .buttonStyle(.plain)
- }
-
- SourceBadge(source: self.source)
- }
- }
-}
-
-// MARK: - EffectiveEnvOverriddenRow
-
-/// Displays an overridden environment variable value with strikethrough.
-struct EffectiveEnvOverriddenRow: View {
- let key: String
- let value: String
- let source: ConfigSource
- let isSensitive: Bool
- let isValueVisible: Bool
-
- var body: some View {
- HStack(alignment: .top) {
- Text(self.key)
- .font(.system(.caption, design: .monospaced))
- .strikethrough()
- .foregroundStyle(.secondary)
- .frame(minWidth: 200, alignment: .leading)
-
- Text("=")
- .foregroundStyle(.tertiary)
- .font(.caption)
-
- Group {
- if self.isValueVisible || !self.isSensitive {
- Text(self.value)
- .font(.system(.caption, design: .monospaced))
- .strikethrough()
- .lineLimit(1)
- .truncationMode(.middle)
- } else {
- Text(String(repeating: "\u{2022}", count: min(self.value.count, 20)))
- .font(.system(.caption, design: .monospaced))
- .strikethrough()
- }
- }
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- Text("overridden")
- .font(.caption2)
- .foregroundStyle(.orange)
- .italic()
-
- SourceBadge(source: self.source)
- }
- .padding(.leading, 8)
- }
-}
-
-// MARK: - EffectiveConfigSerializer
-
-/// Serializes merged settings to JSON format.
-enum EffectiveConfigSerializer {
- // MARK: Internal
-
- static func toJSON(_ settings: MergedSettings) -> String {
- var result: [String: Any] = [:]
-
- result["permissions"] = self.permissionsDict(settings.permissions)
- result["env"] = self.envDict(settings)
- result["hooks"] = self.hooksDict(settings.hooks)
- result["disallowedTools"] = self.toolsArray(settings)
- result["attribution"] = self.attributionDict(settings.attribution)
-
- // Remove nil/empty entries
- result = result.compactMapValues { value in
- if let dict = value as? [String: Any], dict.isEmpty {
- return nil
- }
- if let arr = value as? [Any], arr.isEmpty {
- return nil
- }
- return value
- }
-
- guard let data = try? JSONSerialization.data(
- withJSONObject: result,
- options: [.prettyPrinted, .sortedKeys]
- ),
- let jsonString = String(data: data, encoding: .utf8)
- else {
- return "{}"
- }
- return jsonString
- }
-
- // MARK: Private
-
- private static func permissionsDict(_ permissions: MergedPermissions) -> [String: Any]? {
- var perms: [String: Any] = [:]
- let allow = permissions.allowPatterns
- let deny = permissions.denyPatterns
- if !allow.isEmpty {
- perms["allow"] = allow
- }
- if !deny.isEmpty {
- perms["deny"] = deny
- }
- return perms.isEmpty ? nil : perms
- }
-
- private static func envDict(_ settings: MergedSettings) -> [String: String]? {
- let env = settings.effectiveEnv
- return env.isEmpty ? nil : env
- }
-
- private static func hooksDict(_ hooks: MergedHooks) -> [String: Any]? {
- var dict: [String: [[String: Any]]] = [:]
- for event in hooks.eventNames {
- guard let groups = hooks.groups(for: event) else {
- continue
- }
- dict[event] = groups.map { group in
- var groupDict: [String: Any] = [:]
- if let matcher = group.value.matcher {
- groupDict["matcher"] = matcher
- }
- if let hookDefs = group.value.hooks {
- groupDict["hooks"] = hookDefs.map { hook in
- var hookDict: [String: Any] = [:]
- if let type = hook.type {
- hookDict["type"] = type
- }
- if let command = hook.command {
- hookDict["command"] = command
- }
- return hookDict
- }
- }
- return groupDict
- }
- }
- return dict.isEmpty ? nil : dict
- }
-
- private static func toolsArray(_ settings: MergedSettings) -> [String]? {
- let tools = settings.effectiveDisallowedTools
- return tools.isEmpty ? nil : tools
- }
-
- private static func attributionDict(_ attribution: MergedValue?) -> [String: Any]? {
- guard let attr = attribution else {
- return nil
- }
- var dict: [String: Any] = [:]
- if let commits = attr.value.commits {
- dict["commits"] = commits
- }
- if let prs = attr.value.pullRequests {
- dict["pullRequests"] = prs
- }
- return dict.isEmpty ? nil : dict
- }
-}
-
-#Preview("Effective Config") {
- EffectiveConfigView(
- mergedSettings: MergedSettings(
- permissions: MergedPermissions(
- allow: [
- MergedValue(value: "Bash(npm run *)", source: .global),
- MergedValue(value: "Read(src/**)", source: .projectShared),
- ],
- deny: [
- MergedValue(value: "Read(.env)", source: .projectLocal),
- ]
- ),
- env: [
- "CLAUDE_CODE_MAX_OUTPUT_TOKENS": MergedValue(value: "16384", source: .projectLocal),
- "ANTHROPIC_MODEL": MergedValue(value: "claude-sonnet-4-20250514", source: .global),
- ],
- disallowedTools: [
- MergedValue(value: "WebFetch", source: .projectShared),
- ],
- attribution: MergedValue(
- value: Attribution(commits: true, pullRequests: false),
- source: .projectShared
- )
- ),
- envOverrides: [
- "CLAUDE_CODE_MAX_OUTPUT_TOKENS": [
- ("8192", .global),
- ("16384", .projectLocal),
- ],
- ]
- )
- .padding()
- .frame(width: 700, height: 600)
-}
diff --git a/Fig/Sources/Views/EnvironmentEditorViews.swift b/Fig/Sources/Views/EnvironmentEditorViews.swift
deleted file mode 100644
index 36cfa06..0000000
--- a/Fig/Sources/Views/EnvironmentEditorViews.swift
+++ /dev/null
@@ -1,464 +0,0 @@
-import SwiftUI
-
-// MARK: - EnvironmentVariableEditorView
-
-/// Editor view for managing environment variables.
-struct EnvironmentVariableEditorView: View {
- // MARK: Internal
-
- @Bindable var viewModel: SettingsEditorViewModel
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- // Target selector and add button
- HStack {
- if !self.viewModel.isGlobalMode {
- EditingTargetPicker(selection: self.$viewModel.editingTarget)
- }
-
- Spacer()
-
- Button {
- self.showingAddVariable = true
- } label: {
- Label("Add Variable", systemImage: "plus")
- }
- }
-
- // Known variables info
- DisclosureGroup("Known Variables Reference") {
- LazyVStack(alignment: .leading, spacing: 8) {
- ForEach(KnownEnvironmentVariable.allVariables) { variable in
- KnownVariableRow(
- variable: variable,
- isAdded: self.viewModel.environmentVariables.contains { $0.key == variable.name }
- ) {
- self.showingAddVariable = true
- }
- }
- }
- .padding(.vertical, 8)
- }
- .padding()
- .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
-
- // Variables list
- GroupBox {
- VStack(alignment: .leading, spacing: 0) {
- // Header row
- HStack {
- Text("Key")
- .font(.caption)
- .fontWeight(.medium)
- .foregroundStyle(.secondary)
- .frame(minWidth: 200, alignment: .leading)
-
- Text("Value")
- .font(.caption)
- .fontWeight(.medium)
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- Text("Actions")
- .font(.caption)
- .fontWeight(.medium)
- .foregroundStyle(.secondary)
- .frame(width: 80, alignment: .trailing)
- }
- .padding(.horizontal, 8)
- .padding(.bottom, 8)
-
- Divider()
-
- if self.viewModel.environmentVariables.isEmpty {
- Text("No environment variables configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 16)
- .frame(maxWidth: .infinity, alignment: .center)
- } else {
- ForEach(self.viewModel.environmentVariables) { envVar in
- EditableEnvironmentVariableRow(
- envVar: envVar,
- description: KnownEnvironmentVariable.description(for: envVar.key),
- onUpdate: { newKey, newValue in
- self.viewModel.updateEnvironmentVariable(
- envVar,
- newKey: newKey,
- newValue: newValue
- )
- },
- onDelete: {
- self.viewModel.removeEnvironmentVariable(envVar)
- },
- isDuplicateKey: { key in
- key != envVar.key && self.viewModel.environmentVariables
- .contains { $0.key == key }
- }
- )
- if envVar.id != self.viewModel.environmentVariables.last?.id {
- Divider()
- }
- }
- }
- }
- .padding(.vertical, 4)
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .sheet(isPresented: self.$showingAddVariable) {
- AddEnvironmentVariableSheet { key, value in
- self.viewModel.addEnvironmentVariable(key: key, value: value)
- } isDuplicateKey: { key in
- self.viewModel.environmentVariables.contains { $0.key == key }
- }
- }
- }
-
- // MARK: Private
-
- @State private var showingAddVariable = false
-}
-
-// MARK: - KnownVariableRow
-
-/// Row displaying a known environment variable in the reference section.
-struct KnownVariableRow: View {
- let variable: KnownEnvironmentVariable
- let isAdded: Bool
- let onAddTapped: () -> Void
-
- var body: some View {
- VStack(alignment: .leading, spacing: 2) {
- HStack {
- Text(self.variable.name)
- .font(.system(.caption, design: .monospaced))
- .fontWeight(.medium)
-
- Button {
- self.onAddTapped()
- } label: {
- Image(systemName: "plus.circle")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .disabled(self.isAdded)
- }
- Text(self.variable.description)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-}
-
-// MARK: - EditableEnvironmentVariableRow
-
-/// A row displaying an editable environment variable.
-struct EditableEnvironmentVariableRow: View {
- // MARK: Internal
-
- let envVar: EditableEnvironmentVariable
- let description: String?
- let onUpdate: (String, String) -> Void
- let onDelete: () -> Void
- let isDuplicateKey: (String) -> Bool
-
- var body: some View {
- HStack(alignment: .top) {
- if self.isEditing {
- self.editingContent
- } else {
- self.displayContent
- }
- }
- .padding(.vertical, 8)
- .padding(.horizontal, 8)
- .confirmationDialog(
- "Delete Variable",
- isPresented: self.$showingDeleteConfirmation,
- titleVisibility: .visible
- ) {
- Button("Delete", role: .destructive) {
- self.onDelete()
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("Are you sure you want to delete \(self.envVar.key)?")
- }
- }
-
- // MARK: Private
-
- @State private var isEditing = false
- @State private var editedKey = ""
- @State private var editedValue = ""
- @State private var isValueVisible = false
- @State private var showingDeleteConfirmation = false
-
- private var isSensitive: Bool {
- let sensitivePatterns = ["token", "key", "secret", "password", "credential", "api"]
- let lowercaseKey = self.envVar.key.lowercased()
- return sensitivePatterns.contains { lowercaseKey.contains($0) }
- }
-
- private var canSave: Bool {
- !self.editedKey.isEmpty && !self.isDuplicateKey(self.editedKey)
- }
-
- @ViewBuilder private var editingContent: some View {
- TextField("Key", text: self.$editedKey)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .frame(minWidth: 200, alignment: .leading)
-
- TextField("Value", text: self.$editedValue)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .frame(maxWidth: .infinity)
-
- HStack(spacing: 4) {
- Button("Save") {
- self.saveEdit()
- }
- .disabled(!self.canSave)
-
- Button("Cancel") {
- self.cancelEdit()
- }
- }
- .frame(width: 120)
- }
-
- @ViewBuilder private var displayContent: some View {
- VStack(alignment: .leading, spacing: 2) {
- Text(self.envVar.key)
- .font(.system(.body, design: .monospaced))
- .fontWeight(.medium)
- if let description {
- Text(description)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- .frame(minWidth: 200, alignment: .leading)
-
- Text("=")
- .foregroundStyle(.secondary)
-
- Group {
- if self.isValueVisible || !self.isSensitive {
- Text(self.envVar.value)
- .font(.system(.body, design: .monospaced))
- } else {
- Text(String(repeating: "\u{2022}", count: min(self.envVar.value.count, 20)))
- .font(.system(.body, design: .monospaced))
- }
- }
- .lineLimit(2)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- HStack(spacing: 8) {
- if self.isSensitive {
- Button {
- self.isValueVisible.toggle()
- } label: {
- Image(systemName: self.isValueVisible ? "eye.slash" : "eye")
- .font(.caption)
- }
- .buttonStyle(.plain)
- }
-
- Button {
- self.startEditing()
- } label: {
- Image(systemName: "pencil")
- .font(.caption)
- }
- .buttonStyle(.plain)
-
- Button {
- self.showingDeleteConfirmation = true
- } label: {
- Image(systemName: "trash")
- .font(.caption)
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- }
- .frame(width: 80, alignment: .trailing)
- }
-
- private func startEditing() {
- self.editedKey = self.envVar.key
- self.editedValue = self.envVar.value
- self.isEditing = true
- }
-
- private func saveEdit() {
- guard self.canSave else {
- return
- }
- self.onUpdate(self.editedKey, self.editedValue)
- self.isEditing = false
- }
-
- private func cancelEdit() {
- self.isEditing = false
- self.editedKey = self.envVar.key
- self.editedValue = self.envVar.value
- }
-}
-
-// MARK: - AddEnvironmentVariableSheet
-
-/// Sheet for adding a new environment variable.
-struct AddEnvironmentVariableSheet: View {
- // MARK: Internal
-
- let onAdd: (String, String) -> Void
- let isDuplicateKey: (String) -> Bool
-
- var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- // Header
- HStack {
- Image(systemName: "list.bullet.rectangle")
- .foregroundStyle(.blue)
- .font(.title2)
- Text("Add Environment Variable")
- .font(.title2)
- .fontWeight(.semibold)
- }
-
- Divider()
-
- // Key input with autocomplete
- VStack(alignment: .leading, spacing: 4) {
- Text("Key")
- .font(.headline)
-
- TextField("VARIABLE_NAME", text: self.$key)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
-
- // Autocomplete suggestions
- if !self.key.isEmpty {
- self.autocompleteView
- }
-
- // Show description for known variables
- if let knownVar = KnownEnvironmentVariable.allVariables.first(where: { $0.name == key }) {
- Text(knownVar.description)
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.top, 2)
- }
- }
-
- // Value input
- VStack(alignment: .leading, spacing: 4) {
- Text("Value")
- .font(.headline)
- TextField("value", text: self.$value)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- }
-
- // Validation error
- if let error = validationError {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- Text(error)
- .foregroundStyle(.secondary)
- }
- .font(.caption)
- }
-
- Spacer()
-
- // Buttons
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- Button("Add Variable") {
- self.onAdd(self.key, self.value)
- self.dismiss()
- }
- .keyboardShortcut(.defaultAction)
- .disabled(!self.isValid)
- }
- }
- .padding()
- .frame(width: 450, height: 350)
- }
-
- // MARK: Private
-
- @Environment(\.dismiss)
- private var dismiss
-
- @State private var key = ""
- @State private var value = ""
-
- private var validationError: String? {
- if self.key.isEmpty {
- return nil // Don't show error until they try to submit
- }
- if self.isDuplicateKey(self.key) {
- return "A variable with this key already exists"
- }
- if self.key.contains(" ") {
- return "Key cannot contain spaces"
- }
- return nil
- }
-
- private var isValid: Bool {
- !self.key.isEmpty && !self.isDuplicateKey(self.key) && !self.key.contains(" ")
- }
-
- @ViewBuilder private var autocompleteView: some View {
- let suggestions = KnownEnvironmentVariable.allVariables.filter {
- $0.name.localizedCaseInsensitiveContains(self.key)
- }
- if !suggestions.isEmpty {
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- ForEach(suggestions) { variable in
- Button {
- self.key = variable.name
- } label: {
- Text(variable.name)
- .font(.caption)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 4))
- }
- .buttonStyle(.plain)
- }
- }
- }
- }
- }
-}
-
-#Preview("Environment Variable Editor") {
- let viewModel = SettingsEditorViewModel(projectPath: "/Users/test/project")
- viewModel.environmentVariables = [
- EditableEnvironmentVariable(key: "CLAUDE_CODE_MAX_OUTPUT_TOKENS", value: "16384"),
- EditableEnvironmentVariable(key: "API_KEY", value: "sk-1234567890"),
- ]
-
- return EnvironmentVariableEditorView(viewModel: viewModel)
- .padding()
- .frame(width: 600, height: 500)
-}
diff --git a/Fig/Sources/Views/GlobalSettingsDetailView.swift b/Fig/Sources/Views/GlobalSettingsDetailView.swift
deleted file mode 100644
index c38e863..0000000
--- a/Fig/Sources/Views/GlobalSettingsDetailView.swift
+++ /dev/null
@@ -1,588 +0,0 @@
-import SwiftUI
-
-// MARK: - GlobalSettingsViewModel
-
-/// View model for global settings.
-@MainActor
-@Observable
-final class GlobalSettingsViewModel {
- // MARK: Lifecycle
-
- init(configManager: ConfigFileManager = .shared) {
- self.configManager = configManager
- }
-
- // MARK: Internal
-
- /// Whether data is loading.
- private(set) var isLoading = false
-
- /// The global settings.
- private(set) var settings: ClaudeSettings?
-
- /// The global legacy config.
- private(set) var legacyConfig: LegacyConfig?
-
- /// The selected tab.
- var selectedTab: GlobalSettingsTab = .permissions
-
- /// Status of the global settings file.
- private(set) var settingsFileStatus: ConfigFileStatus?
-
- /// Status of the global config file.
- private(set) var configFileStatus: ConfigFileStatus?
-
- /// Path to the global settings file.
- private(set) var globalSettingsPath: String?
-
- /// Path to the global settings directory.
- private(set) var globalSettingsDirectoryPath: String?
-
- /// Global MCP servers from the legacy config.
- var globalMCPServers: [(name: String, server: MCPServer)] {
- self.legacyConfig?.mcpServers?.map { ($0.key, $0.value) }.sorted { $0.0 < $1.0 } ?? []
- }
-
- /// Loads global settings.
- func load() async {
- self.isLoading = true
-
- do {
- self.settings = try await self.configManager.readGlobalSettings()
- self.legacyConfig = try await self.configManager.readGlobalConfig()
-
- // Load file statuses
- let settingsURL = await configManager.globalSettingsURL
- self.settingsFileStatus = await ConfigFileStatus(
- exists: self.configManager.fileExists(at: settingsURL),
- url: settingsURL
- )
- self.globalSettingsPath = settingsURL.path
- self.globalSettingsDirectoryPath = await self.configManager.globalSettingsDirectory.path
-
- let configURL = await configManager.globalConfigURL
- self.configFileStatus = await ConfigFileStatus(
- exists: self.configManager.fileExists(at: configURL),
- url: configURL
- )
- } catch {
- Log.general.error("Failed to load global settings: \(error.localizedDescription)")
- }
-
- self.isLoading = false
- }
-
- /// Reveals the settings file in Finder.
- func revealSettingsInFinder() {
- guard let path = globalSettingsPath,
- let dirPath = globalSettingsDirectoryPath
- else {
- return
- }
- NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: dirPath)
- }
-
- /// Deletes a global MCP server by name.
- func deleteGlobalMCPServer(name: String) async {
- do {
- guard var config = try await configManager.readGlobalConfig() else {
- return
- }
- config.mcpServers?.removeValue(forKey: name)
- try await self.configManager.writeGlobalConfig(config)
- self.legacyConfig = config
- NotificationManager.shared.showSuccess(
- "Server deleted",
- message: "'\(name)' removed from global configuration"
- )
- } catch {
- NotificationManager.shared.showError(
- "Delete failed",
- message: error.localizedDescription
- )
- }
- }
-
- // MARK: Private
-
- private let configManager: ConfigFileManager
-}
-
-// MARK: - GlobalSettingsTab
-
-/// Tabs for global settings view.
-enum GlobalSettingsTab: String, CaseIterable, Identifiable, Sendable {
- case permissions
- case environment
- case mcpServers
- case advanced
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
-
- var title: String {
- switch self {
- case .permissions:
- "Permissions"
- case .environment:
- "Environment"
- case .mcpServers:
- "MCP Servers"
- case .advanced:
- "Advanced"
- }
- }
-
- var icon: String {
- switch self {
- case .permissions:
- "lock.shield"
- case .environment:
- "list.bullet.rectangle"
- case .mcpServers:
- "server.rack"
- case .advanced:
- "gearshape.2"
- }
- }
-}
-
-// MARK: - GlobalSettingsDetailView
-
-/// Detail view for global settings.
-struct GlobalSettingsDetailView: View {
- // MARK: Internal
-
- var body: some View {
- VStack(spacing: 0) {
- // Header
- GlobalSettingsHeaderView(viewModel: self.viewModel) {
- self.showingEditor = true
- }
-
- Divider()
-
- // Tab content
- if self.viewModel.isLoading {
- ProgressView("Loading settings...")
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- TabView(selection: self.$viewModel.selectedTab) {
- ForEach(GlobalSettingsTab.allCases) { tab in
- self.globalTabContent(for: tab)
- .tabItem {
- Label(tab.title, systemImage: tab.icon)
- }
- .tag(tab)
- }
- }
- .padding()
- }
- }
- .frame(minWidth: 500)
- .focusedSceneValue(\.globalSettingsTab, self.$viewModel.selectedTab)
- .focusedSceneValue(\.addMCPServerAction) {
- self.mcpEditorViewModel = MCPServerEditorViewModel.forAdding(
- projectPath: nil,
- defaultScope: .global
- )
- self.showMCPServerEditor = true
- }
- .focusedSceneValue(\.pasteMCPServersAction) {
- self.showPasteServersSheet()
- }
- .task {
- await self.viewModel.load()
- }
- .sheet(isPresented: self.$showingEditor) {
- GlobalSettingsEditorView {
- Task {
- await self.viewModel.load()
- }
- }
- }
- .sheet(isPresented: self.$showMCPServerEditor, onDismiss: {
- Task { await self.viewModel.load() }
- }) {
- if let editorVM = mcpEditorViewModel {
- MCPServerEditorView(viewModel: editorVM)
- }
- }
- .sheet(isPresented: self.$showCopySheet, onDismiss: {
- Task { await self.viewModel.load() }
- }) {
- if let copyVM = copyViewModel {
- MCPCopySheet(viewModel: copyVM)
- .task {
- let config = try? await ConfigFileManager.shared.readGlobalConfig()
- let projects = config?.allProjects ?? []
- copyVM.loadDestinations(projects: projects)
- }
- }
- }
- .sheet(item: self.$pasteViewModel, onDismiss: {
- Task { await self.viewModel.load() }
- }) { pasteVM in
- MCPPasteSheet(viewModel: pasteVM)
- .task {
- let config = try? await ConfigFileManager.shared.readGlobalConfig()
- let projects = config?.allProjects ?? []
- pasteVM.loadDestinations(projects: projects)
- }
- }
- .alert(
- "Delete Server",
- isPresented: self.$showDeleteConfirmation,
- presenting: self.serverToDelete
- ) { name in
- Button("Cancel", role: .cancel) {}
- Button("Delete", role: .destructive) {
- Task { await self.viewModel.deleteGlobalMCPServer(name: name) }
- }
- } message: { name in
- Text("Are you sure you want to delete '\(name)'? This action cannot be undone.")
- }
- .alert(
- "Sensitive Data Warning",
- isPresented: self.$showSensitiveCopyAlert,
- presenting: self.pendingCopyServers
- ) { servers in
- Button("Cancel", role: .cancel) {
- self.pendingCopyServers = nil
- }
- Button("Copy with Placeholders") {
- self.copyServersToClipboard(servers, redact: true)
- }
- Button("Copy with Secrets") {
- self.copyServersToClipboard(servers, redact: false)
- }
- } message: { _ in
- Text(
- "The MCP configuration contains environment variables that may contain "
- + "secrets (API keys, tokens, etc.). Choose how to copy."
- )
- }
- }
-
- // MARK: Private
-
- @State private var viewModel = GlobalSettingsViewModel()
- @State private var showingEditor = false
- @State private var showMCPServerEditor = false
- @State private var mcpEditorViewModel: MCPServerEditorViewModel?
- @State private var showDeleteConfirmation = false
- @State private var serverToDelete: String?
- @State private var showCopySheet = false
- @State private var copyViewModel: MCPCopyViewModel?
- @State private var pasteViewModel: MCPPasteViewModel?
- @State private var showSensitiveCopyAlert = false
- @State private var pendingCopyServers: [String: MCPServer]?
-
- @ViewBuilder
- private func globalTabContent(for tab: GlobalSettingsTab) -> some View {
- switch tab {
- case .permissions:
- PermissionsTabView(
- permissions: self.viewModel.settings?.permissions,
- source: .global
- )
- case .environment:
- EnvironmentTabView(
- envVars: self.viewModel.settings?.env?.map { ($0.key, $0.value, ConfigSource.global) } ?? [],
- emptyMessage: "No global environment variables configured."
- )
- case .mcpServers:
- MCPServersTabView(
- servers: self.viewModel.globalMCPServers.map { ($0.name, $0.server, ConfigSource.global) },
- emptyMessage: "No global MCP servers configured.",
- onAdd: {
- self.mcpEditorViewModel = MCPServerEditorViewModel.forAdding(
- projectPath: nil,
- defaultScope: .global
- )
- self.showMCPServerEditor = true
- },
- onEdit: { name, server, _ in
- self.mcpEditorViewModel = MCPServerEditorViewModel.forEditing(
- name: name,
- server: server,
- scope: .global,
- projectPath: nil
- )
- self.showMCPServerEditor = true
- },
- onDelete: { name, _ in
- self.serverToDelete = name
- self.showDeleteConfirmation = true
- },
- onCopy: { name, server in
- self.copyViewModel = MCPCopyViewModel(
- serverName: name,
- server: server,
- sourceDestination: .global
- )
- self.showCopySheet = true
- },
- onCopyAll: {
- self.handleCopyAllServers()
- },
- onPasteServers: {
- self.showPasteServersSheet()
- }
- )
- case .advanced:
- GlobalAdvancedTabView(
- settings: self.viewModel.settings,
- legacyConfig: self.viewModel.legacyConfig
- )
- }
- }
-
- private func handleCopyAllServers() {
- let serverDict = Dictionary(
- uniqueKeysWithValues: viewModel.globalMCPServers.map { ($0.name, $0.server) }
- )
-
- guard !serverDict.isEmpty else {
- NotificationManager.shared.showInfo(
- "No servers to copy",
- message: "No global MCP servers to copy."
- )
- return
- }
-
- Task {
- let hasSensitive = await MCPSharingService.shared.containsSensitiveData(
- servers: serverDict
- )
-
- if hasSensitive {
- self.pendingCopyServers = serverDict
- self.showSensitiveCopyAlert = true
- } else {
- self.copyServersToClipboard(serverDict, redact: false)
- }
- }
- }
-
- private func copyServersToClipboard(_ servers: [String: MCPServer], redact: Bool) {
- Task {
- do {
- try MCPSharingService.shared.writeToClipboard(
- servers: servers,
- redactSensitive: redact
- )
- let message = redact
- ? "\(servers.count) server(s) copied with placeholders"
- : "\(servers.count) server(s) copied to clipboard"
- NotificationManager.shared.showSuccess("Copied to clipboard", message: message)
- } catch {
- NotificationManager.shared.showError(
- "Copy failed",
- message: error.localizedDescription
- )
- }
- self.pendingCopyServers = nil
- }
- }
-
- private func showPasteServersSheet() {
- self.pasteViewModel = MCPPasteViewModel(currentProject: .global)
- }
-}
-
-// MARK: - GlobalSettingsHeaderView
-
-/// Header view for global settings.
-struct GlobalSettingsHeaderView: View {
- @Bindable var viewModel: GlobalSettingsViewModel
-
- var onEditSettings: (() -> Void)?
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Image(systemName: "globe")
- .font(.title)
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading, spacing: 2) {
- Text("Global Settings")
- .font(.title2)
- .fontWeight(.semibold)
-
- Button {
- self.viewModel.revealSettingsInFinder()
- } label: {
- Text("~/.claude/settings.json")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Reveal settings file in Finder")
- .onHover { isHovered in
- if isHovered {
- NSCursor.pointingHand.push()
- } else {
- NSCursor.pop()
- }
- }
- }
-
- Spacer()
-
- // File status badges
- HStack(spacing: 8) {
- if let status = viewModel.settingsFileStatus {
- FileStatusBadge(
- label: "settings.json",
- exists: status.exists
- )
- }
- if let status = viewModel.configFileStatus {
- FileStatusBadge(
- label: ".claude.json",
- exists: status.exists
- )
- }
- }
-
- // Edit button
- if let onEditSettings {
- Button {
- onEditSettings()
- } label: {
- Label("Edit Settings", systemImage: "pencil")
- }
- .buttonStyle(.bordered)
- }
- }
- }
- .padding()
- }
-}
-
-// MARK: - GlobalAdvancedTabView
-
-/// Advanced settings tab for global settings.
-struct GlobalAdvancedTabView: View {
- let settings: ClaudeSettings?
- let legacyConfig: LegacyConfig?
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- // Attribution settings
- GroupBox("Attribution") {
- if let attribution = settings?.attribution {
- VStack(alignment: .leading, spacing: 8) {
- AttributionRow(
- label: "Commit Attribution",
- enabled: attribution.commits ?? false
- )
- AttributionRow(
- label: "Pull Request Attribution",
- enabled: attribution.pullRequests ?? false
- )
- }
- .padding(.vertical, 4)
- } else {
- Text("No attribution settings configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- }
- }
-
- // Disallowed tools
- GroupBox("Disallowed Tools") {
- if let tools = settings?.disallowedTools, !tools.isEmpty {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(tools, id: \.self) { tool in
- HStack {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- Text(tool)
- .font(.system(.body, design: .monospaced))
- }
- }
- }
- .padding(.vertical, 4)
- } else {
- Text("No tools are globally disallowed.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- }
- }
-
- // Project count
- GroupBox("Statistics") {
- HStack {
- Label(
- "\(self.legacyConfig?.projects?.count ?? 0) projects",
- systemImage: "folder"
- )
- Spacer()
- Label(
- "\(self.legacyConfig?.mcpServers?.count ?? 0) global MCP servers",
- systemImage: "server.rack"
- )
- }
- .padding(.vertical, 4)
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-}
-
-// MARK: - AttributionRow
-
-/// A row showing attribution status.
-struct AttributionRow: View {
- let label: String
- let enabled: Bool
-
- var body: some View {
- HStack {
- Image(systemName: self.enabled ? "checkmark.circle.fill" : "circle")
- .foregroundStyle(self.enabled ? .green : .secondary)
- Text(self.label)
- Spacer()
- Text(self.enabled ? "Enabled" : "Disabled")
- .foregroundStyle(.secondary)
- }
- }
-}
-
-// MARK: - FileStatusBadge
-
-/// A badge showing file existence status.
-struct FileStatusBadge: View {
- let label: String
- let exists: Bool
-
- var body: some View {
- HStack(spacing: 4) {
- Image(systemName: self.exists ? "checkmark.circle.fill" : "xmark.circle")
- .foregroundStyle(self.exists ? .green : .orange)
- .font(.caption)
- Text(self.label)
- .font(.caption)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
- .accessibilityElement(children: .combine)
- .accessibilityLabel("\(self.label), \(self.exists ? "file exists" : "file not found")")
- }
-}
-
-#Preview {
- GlobalSettingsDetailView()
- .frame(width: 700, height: 500)
-}
diff --git a/Fig/Sources/Views/GlobalSettingsEditorView.swift b/Fig/Sources/Views/GlobalSettingsEditorView.swift
deleted file mode 100644
index 0bf0ce9..0000000
--- a/Fig/Sources/Views/GlobalSettingsEditorView.swift
+++ /dev/null
@@ -1,204 +0,0 @@
-import SwiftUI
-
-// MARK: - GlobalSettingsEditorView
-
-/// Full-featured settings editor for global settings with editing, saving, undo/redo, and conflict handling.
-struct GlobalSettingsEditorView: View {
- // MARK: Internal
-
- /// Callback when editor is dismissed (for parent to reload data).
- var onDismiss: (() -> Void)?
-
- var body: some View {
- VStack(spacing: 0) {
- // Header with save button and dirty indicator
- self.editorHeader
-
- Divider()
-
- // Tab content
- if self.viewModel.isLoading {
- ProgressView("Loading settings...")
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- TabView(selection: self.$selectedTab) {
- PermissionRuleEditorView(viewModel: self.viewModel)
- .tabItem {
- Label("Permissions", systemImage: "lock.shield")
- }
- .tag(EditorTab.permissions)
-
- EnvironmentVariableEditorView(viewModel: self.viewModel)
- .tabItem {
- Label("Environment", systemImage: "list.bullet.rectangle")
- }
- .tag(EditorTab.environment)
-
- HookEditorView(viewModel: self.viewModel)
- .tabItem {
- Label("Hooks", systemImage: "bolt.horizontal")
- }
- .tag(EditorTab.hooks)
-
- AttributionSettingsEditorView(viewModel: self.viewModel)
- .tabItem {
- Label("General", systemImage: "gearshape")
- }
- .tag(EditorTab.general)
- }
- .padding()
- }
- }
- .frame(minWidth: 600, minHeight: 500)
- .interactiveDismissDisabled(self.viewModel.isDirty)
- .task {
- await self.viewModel.loadSettings()
- }
- .onDisappear {
- if self.viewModel.isDirty {
- Log.general.warning("Global editor closed with unsaved changes")
- }
- self.onDismiss?()
- }
- .confirmationDialog(
- "Unsaved Changes",
- isPresented: self.$showingCloseConfirmation,
- titleVisibility: .visible
- ) {
- Button("Save and Close") {
- Task {
- do {
- try await self.viewModel.save()
- self.dismiss()
- } catch {
- NotificationManager.shared.showError(error)
- }
- }
- }
- Button("Discard Changes", role: .destructive) {
- self.dismiss()
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("You have unsaved changes. Would you like to save them before closing?")
- }
- .sheet(isPresented: self.$showingConflictSheet) {
- if let url = viewModel.externalChangeURL {
- ConflictResolutionSheet(fileName: url.lastPathComponent) { resolution in
- Task {
- await self.viewModel.resolveConflict(resolution)
- }
- self.showingConflictSheet = false
- }
- }
- }
- .onChange(of: self.viewModel.hasExternalChanges) { _, hasChanges in
- if hasChanges {
- self.showingConflictSheet = true
- }
- }
- .onAppear {
- self.viewModel.undoManager = self.undoManager
- }
- }
-
- // MARK: Private
-
- private enum EditorTab: String, CaseIterable, Identifiable {
- case permissions
- case environment
- case hooks
- case general
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
- }
-
- @State private var viewModel = SettingsEditorViewModel.forGlobal()
- @State private var selectedTab: EditorTab = .permissions
- @State private var showingCloseConfirmation = false
- @State private var showingConflictSheet = false
-
- @Environment(\.dismiss)
- private var dismiss
-
- @Environment(\.undoManager)
- private var undoManager
-
- private var editorHeader: some View {
- HStack {
- // Global settings info
- HStack(spacing: 8) {
- Image(systemName: "globe")
- .font(.title2)
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- Text("Global Settings")
- .font(.title3)
- .fontWeight(.semibold)
-
- DirtyStateIndicator(isDirty: self.viewModel.isDirty)
- }
-
- Text("~/.claude/settings.json")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- Spacer()
-
- // Undo/Redo buttons
- HStack(spacing: 4) {
- Button {
- self.undoManager?.undo()
- } label: {
- Image(systemName: "arrow.uturn.backward")
- }
- .disabled(!self.viewModel.canUndo)
- .keyboardShortcut("z", modifiers: .command)
- .help("Undo")
-
- Button {
- self.undoManager?.redo()
- } label: {
- Image(systemName: "arrow.uturn.forward")
- }
- .disabled(!self.viewModel.canRedo)
- .keyboardShortcut("z", modifiers: [.command, .shift])
- .help("Redo")
- }
-
- Divider()
- .frame(height: 20)
- .padding(.horizontal, 8)
-
- // Save button
- SaveButton(
- isDirty: self.viewModel.isDirty,
- isSaving: self.viewModel.isSaving
- ) {
- Task {
- do {
- try await self.viewModel.save()
- NotificationManager.shared.showSuccess("Global Settings Saved")
- } catch {
- NotificationManager.shared.showError(error)
- }
- }
- }
- }
- .padding()
- }
-}
-
-// MARK: - Preview
-
-#Preview("Global Settings Editor") {
- GlobalSettingsEditorView()
-}
diff --git a/Fig/Sources/Views/HookEditorViews.swift b/Fig/Sources/Views/HookEditorViews.swift
deleted file mode 100644
index b5ae9a1..0000000
--- a/Fig/Sources/Views/HookEditorViews.swift
+++ /dev/null
@@ -1,757 +0,0 @@
-import SwiftUI
-
-// MARK: - HookEditorView
-
-/// Editor view for managing hook configurations across lifecycle events.
-struct HookEditorView: View {
- // MARK: Internal
-
- @Bindable var viewModel: SettingsEditorViewModel
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- // Target selector and templates
- HStack {
- if !self.viewModel.isGlobalMode {
- EditingTargetPicker(selection: self.$viewModel.editingTarget)
- }
-
- Spacer()
-
- Menu {
- ForEach(HookTemplate.allTemplates) { template in
- Button {
- self.viewModel.applyHookTemplate(template)
- } label: {
- VStack(alignment: .leading) {
- Text(template.name)
- Text(template.description)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- }
- } label: {
- Label("Templates", systemImage: "bolt.fill")
- }
- }
-
- // One section per lifecycle event
- ForEach(HookEvent.allCases) { event in
- EditableHookEventSection(
- event: event,
- groups: self.viewModel.hookGroups[event.rawValue] ?? [],
- viewModel: self.viewModel,
- onAddGroup: {
- self.addingForEvent = event
- self.showingAddHookGroup = true
- }
- )
- }
-
- // Show unrecognized hook events that exist in settings
- // but aren't in the HookEvent enum (e.g., future event types)
- let unrecognizedEvents = self.viewModel.hookGroups.keys
- .filter { key in !HookEvent.allCases.contains(where: { $0.rawValue == key }) }
- .sorted()
-
- if !unrecognizedEvents.isEmpty {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- Label("Other Events", systemImage: "questionmark.circle")
- .font(.headline)
-
- Text(
- "These hook events are not recognized by this editor but will be preserved when saving."
- )
- .font(.caption)
- .foregroundStyle(.secondary)
-
- ForEach(unrecognizedEvents, id: \.self) { eventKey in
- let groupCount = self.viewModel.hookGroups[eventKey]?.count ?? 0
- HStack {
- Text(eventKey)
- .font(.system(.body, design: .monospaced))
- Spacer()
- Text(
- "\(groupCount) group\(groupCount == 1 ? "" : "s")"
- )
- .foregroundStyle(.secondary)
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
-
- // Help reference
- HookVariablesReference()
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .sheet(isPresented: self.$showingAddHookGroup) {
- AddHookGroupSheet(event: self.addingForEvent) { matcher, commands in
- self.viewModel.addHookGroup(
- event: self.addingForEvent.rawValue,
- matcher: matcher,
- commands: commands
- )
- }
- }
- }
-
- // MARK: Private
-
- @State private var showingAddHookGroup = false
- @State private var addingForEvent: HookEvent = .preToolUse
-}
-
-// MARK: - EditableHookEventSection
-
-/// A section displaying hook groups for a specific lifecycle event.
-struct EditableHookEventSection: View {
- let event: HookEvent
- let groups: [EditableHookGroup]
- @Bindable var viewModel: SettingsEditorViewModel
-
- let onAddGroup: () -> Void
-
- var body: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Label(self.event.displayName, systemImage: self.event.icon)
- .font(.headline)
-
- Spacer()
-
- Button {
- self.onAddGroup()
- } label: {
- Label("Add Group", systemImage: "plus")
- }
- .buttonStyle(.borderless)
- }
-
- Text(self.event.description)
- .font(.caption)
- .foregroundStyle(.secondary)
-
- if self.groups.isEmpty {
- Text("No hooks configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- ForEach(Array(self.groups.enumerated()), id: \.element.id) { index, group in
- EditableHookGroupRow(
- event: self.event,
- group: group,
- groupIndex: index,
- groupCount: self.groups.count,
- viewModel: self.viewModel
- )
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
-}
-
-// MARK: - EditableHookGroupRow
-
-/// A row displaying a hook group with its matcher and command list.
-struct EditableHookGroupRow: View {
- // MARK: Internal
-
- let event: HookEvent
- let group: EditableHookGroup
- let groupIndex: Int
- let groupCount: Int
-
- @Bindable var viewModel: SettingsEditorViewModel
-
- var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- // Matcher row
- HStack {
- if self.event.supportsMatcher {
- if self.isEditingMatcher {
- TextField("Matcher pattern", text: self.$editedMatcher)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .onSubmit {
- self.saveMatcher()
- }
-
- Button("Save") {
- self.saveMatcher()
- }
-
- Button("Cancel") {
- self.isEditingMatcher = false
- self.editedMatcher = self.group.matcher
- }
- } else {
- Label(
- self.group.matcher.isEmpty ? "All tools" : self.group.matcher,
- systemImage: "target"
- )
- .font(.system(.body, design: .monospaced))
- .foregroundStyle(self.group.matcher.isEmpty ? .secondary : .primary)
-
- Button {
- self.startEditingMatcher()
- } label: {
- Image(systemName: "pencil")
- .font(.caption)
- }
- .buttonStyle(.plain)
- }
- } else {
- Label("All events", systemImage: "target")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
-
- // Reorder buttons
- if self.groupCount > 1 {
- Button {
- self.viewModel.moveHookGroup(
- event: self.event.rawValue,
- from: self.groupIndex,
- direction: -1
- )
- } label: {
- Image(systemName: "chevron.up")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .disabled(self.groupIndex == 0)
- .help("Move up")
-
- Button {
- self.viewModel.moveHookGroup(
- event: self.event.rawValue,
- from: self.groupIndex,
- direction: 1
- )
- } label: {
- Image(systemName: "chevron.down")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .disabled(self.groupIndex == self.groupCount - 1)
- .help("Move down")
- }
-
- // Add command button
- Button {
- self.showingAddCommand = true
- } label: {
- Image(systemName: "plus.circle")
- .font(.caption)
- }
- .buttonStyle(.plain)
- .help("Add command")
-
- // Delete group button
- Button {
- self.showingDeleteConfirmation = true
- } label: {
- Image(systemName: "trash")
- .font(.caption)
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- }
-
- // Hook definitions (commands)
- ForEach(Array(self.group.hooks.enumerated()), id: \.element.id) { hookIndex, hook in
- HookDefinitionRow(
- hook: hook,
- hookIndex: hookIndex,
- hookCount: self.group.hooks.count,
- onUpdate: { newCommand in
- self.viewModel.updateHookDefinition(
- event: self.event.rawValue,
- groupID: self.group.id,
- hook: hook,
- newCommand: newCommand
- )
- },
- onDelete: {
- self.viewModel.removeHookDefinition(
- event: self.event.rawValue,
- groupID: self.group.id,
- hook: hook
- )
- },
- onMove: { direction in
- self.viewModel.moveHookDefinition(
- event: self.event.rawValue,
- groupID: self.group.id,
- from: hookIndex,
- direction: direction
- )
- }
- )
- }
- }
- .padding(.vertical, 4)
- .padding(.horizontal, 8)
- .background(
- RoundedRectangle(cornerRadius: 6)
- .fill(.quaternary.opacity(0.5))
- )
- .confirmationDialog(
- "Delete Hook Group",
- isPresented: self.$showingDeleteConfirmation,
- titleVisibility: .visible
- ) {
- Button("Delete", role: .destructive) {
- self.viewModel.removeHookGroup(event: self.event.rawValue, group: self.group)
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("Are you sure you want to delete this hook group?")
- }
- .sheet(isPresented: self.$showingAddCommand) {
- AddHookCommandSheet { command in
- self.viewModel.addHookDefinition(
- event: self.event.rawValue,
- groupID: self.group.id,
- command: command
- )
- }
- }
- }
-
- // MARK: Private
-
- @State private var isEditingMatcher = false
- @State private var editedMatcher = ""
- @State private var showingDeleteConfirmation = false
- @State private var showingAddCommand = false
-
- private func startEditingMatcher() {
- self.editedMatcher = self.group.matcher
- self.isEditingMatcher = true
- }
-
- private func saveMatcher() {
- self.viewModel.updateHookGroupMatcher(
- event: self.event.rawValue,
- group: self.group,
- newMatcher: self.editedMatcher
- )
- self.isEditingMatcher = false
- }
-}
-
-// MARK: - HookDefinitionRow
-
-/// A row displaying a single hook command with inline editing.
-struct HookDefinitionRow: View {
- // MARK: Internal
-
- let hook: EditableHookDefinition
- let hookIndex: Int
- let hookCount: Int
- let onUpdate: (String) -> Void
- let onDelete: () -> Void
- let onMove: (Int) -> Void
-
- var body: some View {
- HStack {
- Image(systemName: "terminal")
- .foregroundStyle(.blue)
- .frame(width: 20)
-
- if self.isEditing {
- TextField("Command", text: self.$editedCommand)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .onSubmit {
- self.saveEdit()
- }
-
- Button("Save") {
- self.saveEdit()
- }
- .disabled(self.editedCommand.isEmpty)
-
- Button("Cancel") {
- self.isEditing = false
- self.editedCommand = self.hook.command
- }
- } else {
- Text(self.hook.command)
- .font(.system(.body, design: .monospaced))
- .lineLimit(1)
- .truncationMode(.middle)
-
- Spacer()
-
- // Reorder buttons
- if self.hookCount > 1 {
- Button {
- self.onMove(-1)
- } label: {
- Image(systemName: "chevron.up")
- .font(.caption2)
- }
- .buttonStyle(.plain)
- .disabled(self.hookIndex == 0)
-
- Button {
- self.onMove(1)
- } label: {
- Image(systemName: "chevron.down")
- .font(.caption2)
- }
- .buttonStyle(.plain)
- .disabled(self.hookIndex == self.hookCount - 1)
- }
-
- Button {
- self.isEditing = true
- self.editedCommand = self.hook.command
- } label: {
- Image(systemName: "pencil")
- .font(.caption)
- }
- .buttonStyle(.plain)
-
- Button {
- self.showingDeleteConfirmation = true
- } label: {
- Image(systemName: "trash")
- .font(.caption)
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- }
- }
- .padding(.vertical, 2)
- .padding(.leading, 20)
- .confirmationDialog(
- "Delete Command",
- isPresented: self.$showingDeleteConfirmation,
- titleVisibility: .visible
- ) {
- Button("Delete", role: .destructive) {
- self.onDelete()
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("Are you sure you want to delete this command?\n\(self.hook.command)")
- }
- }
-
- // MARK: Private
-
- @State private var isEditing = false
- @State private var editedCommand = ""
- @State private var showingDeleteConfirmation = false
-
- private func saveEdit() {
- guard !self.editedCommand.isEmpty else {
- return
- }
- self.onUpdate(self.editedCommand)
- self.isEditing = false
- }
-}
-
-// MARK: - AddHookGroupSheet
-
-/// Sheet for adding a new hook group with matcher and command.
-struct AddHookGroupSheet: View {
- // MARK: Internal
-
- let event: HookEvent
- let onAdd: (String, [String]) -> Void
-
- var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- // Header
- HStack {
- Image(systemName: self.event.icon)
- .foregroundStyle(.blue)
- .font(.title2)
- Text("Add Hook Group \u{2014} \(self.event.displayName)")
- .font(.title2)
- .fontWeight(.semibold)
- }
-
- Divider()
-
- // Matcher input (if supported)
- if self.event.supportsMatcher {
- VStack(alignment: .leading, spacing: 4) {
- Text("Matcher Pattern")
- .font(.headline)
- TextField(self.event.matcherPlaceholder, text: self.$matcher)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- Text("Use tool name with optional glob pattern. Leave empty to match all.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- // Command input
- VStack(alignment: .leading, spacing: 4) {
- Text("Command")
- .font(.headline)
- TextField("e.g., npm run lint, black $CLAUDE_FILE_PATH", text: self.$command)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- Text("Shell command to run. You can add more commands after creation.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- // Preview
- VStack(alignment: .leading, spacing: 4) {
- Text("Preview")
- .font(.headline)
- HStack(spacing: 4) {
- Text("When")
- .foregroundStyle(.secondary)
- Text(self.event.displayName)
- .fontWeight(.medium)
- if self.event.supportsMatcher, !self.matcher.isEmpty {
- Text("matches")
- .foregroundStyle(.secondary)
- Text(self.matcher)
- .font(.system(.body, design: .monospaced))
- }
- Text("\u{2192}")
- .foregroundStyle(.secondary)
- Text("Run")
- .foregroundStyle(.secondary)
- Text(self.command.isEmpty ? "..." : self.command)
- .font(.system(.body, design: .monospaced))
- .lineLimit(1)
- .truncationMode(.tail)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 4))
- }
-
- Spacer()
-
- // Buttons
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- Button("Add Hook Group") {
- self.onAdd(self.matcher, [self.command])
- self.dismiss()
- }
- .keyboardShortcut(.defaultAction)
- .disabled(self.command.isEmpty)
- }
- }
- .padding()
- .frame(width: 500, height: 420)
- }
-
- // MARK: Private
-
- @Environment(\.dismiss)
- private var dismiss
-
- @State private var matcher = ""
- @State private var command = ""
-}
-
-// MARK: - AddHookCommandSheet
-
-/// Sheet for adding a command to an existing hook group.
-struct AddHookCommandSheet: View {
- // MARK: Internal
-
- let onAdd: (String) -> Void
-
- var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Image(systemName: "terminal")
- .foregroundStyle(.blue)
- .font(.title2)
- Text("Add Command")
- .font(.title2)
- .fontWeight(.semibold)
- }
-
- Divider()
-
- VStack(alignment: .leading, spacing: 4) {
- Text("Command")
- .font(.headline)
- TextField("Shell command to execute", text: self.$command)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- }
-
- Spacer()
-
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- Button("Add Command") {
- self.onAdd(self.command)
- self.dismiss()
- }
- .keyboardShortcut(.defaultAction)
- .disabled(self.command.isEmpty)
- }
- }
- .padding()
- .frame(width: 400, height: 250)
- }
-
- // MARK: Private
-
- @Environment(\.dismiss)
- private var dismiss
-
- @State private var command = ""
-}
-
-// MARK: - HookVariablesReference
-
-/// Reference section for available hook variables.
-struct HookVariablesReference: View {
- var isCollapsible = true
-
- var body: some View {
- if isCollapsible {
- DisclosureGroup("Available Hook Variables") {
- variablesList
- }
- .padding()
- .background(
- .quaternary.opacity(0.5),
- in: RoundedRectangle(cornerRadius: 8)
- )
- } else {
- GroupBox("Available Hook Variables") {
- variablesList
- }
- }
- }
-
- private var variablesList: some View {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(HookVariable.all) { variable in
- HookVariableRow(variable: variable)
- }
- }
- .padding(.vertical, 8)
- }
-}
-
-// MARK: - HookVariableRow
-
-/// A row displaying a hook variable with click-to-copy and event scope.
-struct HookVariableRow: View {
- let variable: HookVariable
-
- var body: some View {
- HStack(alignment: .center, spacing: 12) {
- Button {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(variable.name, forType: .string)
- NotificationManager.shared.showSuccess(
- "Copied",
- message: variable.name
- )
- } label: {
- HStack(spacing: 4) {
- Text(variable.name)
- .font(.system(.caption, design: .monospaced))
- .fontWeight(.medium)
- Image(systemName: "doc.on.doc")
- .font(.system(size: 9))
- .foregroundStyle(.tertiary)
- }
- }
- .buttonStyle(.plain)
- .help("Click to copy")
-
- Text(variable.description)
- .font(.caption)
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, alignment: .leading)
-
- HStack(spacing: 4) {
- ForEach(variable.events) { event in
- HookEventBadge(event: event)
- }
- }
- }
- }
-}
-
-// MARK: - HookEventBadge
-
-/// A small colored badge indicating a hook event scope.
-struct HookEventBadge: View {
- let event: HookEvent
-
- var body: some View {
- HStack(spacing: 2) {
- Image(systemName: event.icon)
- .font(.system(size: 8))
- Text(event.displayName)
- .font(.caption2)
- }
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(
- event.color.opacity(0.2),
- in: RoundedRectangle(cornerRadius: 4)
- )
- .foregroundStyle(event.color)
- }
-}
-
-// MARK: - Preview
-
-#Preview("Hook Editor") {
- let viewModel = SettingsEditorViewModel(projectPath: "/Users/test/project")
- viewModel.hookGroups = [
- "PostToolUse": [
- EditableHookGroup(
- matcher: "Write(*.py)",
- hooks: [
- EditableHookDefinition(type: "command", command: "black $CLAUDE_FILE_PATH"),
- ]
- ),
- ],
- ]
-
- return HookEditorView(viewModel: viewModel)
- .padding()
- .frame(width: 600, height: 600)
-}
diff --git a/Fig/Sources/Views/MCPCopySheet.swift b/Fig/Sources/Views/MCPCopySheet.swift
deleted file mode 100644
index 3dfb38d..0000000
--- a/Fig/Sources/Views/MCPCopySheet.swift
+++ /dev/null
@@ -1,350 +0,0 @@
-import SwiftUI
-
-// MARK: - MCPCopySheet
-
-/// Sheet for copying an MCP server to another project or global config.
-struct MCPCopySheet: View {
- // MARK: Internal
-
- @Bindable var viewModel: MCPCopyViewModel
-
- var body: some View {
- VStack(spacing: 0) {
- // Header
- self.header
-
- Divider()
-
- // Content
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- // Server info
- self.serverInfoSection
-
- // Sensitive data warnings
- if !self.viewModel.sensitiveWarnings.isEmpty {
- self.sensitiveWarningsSection
- }
-
- // Destination picker
- self.destinationSection
-
- // Conflict resolution (if conflict exists)
- if let conflict = viewModel.conflict {
- self.conflictSection(conflict: conflict)
- }
-
- // Result message
- if let result = viewModel.copyResult {
- self.resultSection(result: result)
- }
-
- // Error message
- if let error = viewModel.errorMessage {
- self.errorSection(error: error)
- }
- }
- .padding()
- }
-
- Divider()
-
- // Footer
- self.footer
- }
- .frame(width: 450, height: 500)
- .onChange(of: self.viewModel.selectedDestination) { _, _ in
- Task {
- await self.viewModel.checkForConflict()
- }
- }
- }
-
- // MARK: Private
-
- @Environment(\.dismiss) private var dismiss
-
- private var header: some View {
- HStack {
- Image(systemName: "doc.on.doc")
- .font(.title2)
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading) {
- Text("Copy MCP Server")
- .font(.headline)
- Text(self.viewModel.serverName)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
- }
- .padding()
- }
-
- private var serverInfoSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text("Server Type:")
- .foregroundStyle(.secondary)
- Text(self.viewModel.server.isStdio ? "Stdio" : "HTTP")
- .fontWeight(.medium)
- }
-
- if self.viewModel.server.isStdio {
- if let command = viewModel.server.command {
- HStack(alignment: .top) {
- Text("Command:")
- .foregroundStyle(.secondary)
- Text("\(command) \((self.viewModel.server.args ?? []).joined(separator: " "))")
- .font(.system(.body, design: .monospaced))
- .lineLimit(2)
- }
- }
- } else if self.viewModel.server.isHTTP {
- if let url = viewModel.server.url {
- HStack(alignment: .top) {
- Text("URL:")
- .foregroundStyle(.secondary)
- Text(url)
- .font(.system(.body, design: .monospaced))
- .lineLimit(1)
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Server Configuration", systemImage: "server.rack")
- }
- }
-
- private var sensitiveWarningsSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- Text("This server contains potentially sensitive environment variables:")
- .font(.subheadline)
- }
-
- VStack(alignment: .leading, spacing: 4) {
- ForEach(self.viewModel.sensitiveWarnings) { warning in
- HStack {
- Text(warning.key)
- .font(.system(.caption, design: .monospaced))
- .fontWeight(.medium)
- Text("-")
- .foregroundStyle(.secondary)
- Text(warning.reason)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- }
-
- Toggle(isOn: self.$viewModel.acknowledgedSensitiveData) {
- Text("I understand these values will be copied")
- .font(.subheadline)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Security Warning", systemImage: "lock.shield")
- }
- }
-
- private var destinationSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- if self.viewModel.isLoadingDestinations {
- ProgressView("Loading destinations...")
- } else if self.viewModel.availableDestinations.isEmpty {
- Text("No destinations available")
- .foregroundStyle(.secondary)
- } else {
- Picker("Destination", selection: self.$viewModel.selectedDestination) {
- Text("Select destination...")
- .tag(nil as CopyDestination?)
-
- ForEach(self.viewModel.availableDestinations) { destination in
- Label(destination.displayName, systemImage: destination.icon)
- .tag(destination as CopyDestination?)
- }
- }
- .pickerStyle(.menu)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Copy To", systemImage: "arrow.right.square")
- }
- }
-
- private var footer: some View {
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- if self.viewModel.copyResult?.success == true {
- Button("Done") {
- self.dismiss()
- }
- .buttonStyle(.borderedProminent)
- .keyboardShortcut(.defaultAction)
- } else if self.viewModel.conflict == nil {
- Button("Copy") {
- Task {
- await self.viewModel.performCopy()
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(!self.viewModel.canCopy)
- .keyboardShortcut(.defaultAction)
- }
- }
- .padding()
- }
-
- private func conflictSection(conflict: CopyConflict) -> some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.yellow)
- Text("A server named '\(conflict.serverName)' already exists at this destination.")
- .font(.subheadline)
- }
-
- VStack(alignment: .leading, spacing: 8) {
- Text("Choose how to resolve:")
- .font(.subheadline)
- .fontWeight(.medium)
-
- // Overwrite option
- Button {
- Task {
- await self.viewModel.copyWithOverwrite()
- }
- } label: {
- HStack {
- Image(systemName: "arrow.triangle.2.circlepath")
- VStack(alignment: .leading) {
- Text("Overwrite")
- .fontWeight(.medium)
- Text("Replace the existing server configuration")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- }
- .padding(8)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
- }
- .buttonStyle(.plain)
-
- // Rename option
- VStack(alignment: .leading, spacing: 4) {
- Button {
- Task {
- await self.viewModel.copyWithRename()
- }
- } label: {
- HStack {
- Image(systemName: "pencil")
- VStack(alignment: .leading) {
- Text("Rename")
- .fontWeight(.medium)
- Text("Copy with a different name")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- }
- .padding(8)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
- }
- .buttonStyle(.plain)
-
- TextField("New name", text: self.$viewModel.renamedServerName)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- }
-
- // Skip option
- Button {
- Task {
- await self.viewModel.skipCopy()
- }
- } label: {
- HStack {
- Image(systemName: "xmark.circle")
- VStack(alignment: .leading) {
- Text("Skip")
- .fontWeight(.medium)
- Text("Cancel this copy operation")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- }
- .padding(8)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
- }
- .buttonStyle(.plain)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Conflict Detected", systemImage: "exclamationmark.circle")
- }
- }
-
- private func resultSection(result: CopyResult) -> some View {
- GroupBox {
- HStack {
- Image(systemName: result.success ? "checkmark.circle.fill" : "xmark.circle.fill")
- .foregroundStyle(result.success ? .green : .red)
- Text(result.message)
- }
- .padding(.vertical, 4)
- } label: {
- Label("Result", systemImage: "checkmark.seal")
- }
- }
-
- private func errorSection(error: String) -> some View {
- GroupBox {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.red)
- Text(error)
- .foregroundStyle(.red)
- }
- .padding(.vertical, 4)
- } label: {
- Label("Error", systemImage: "xmark.octagon")
- }
- }
-}
-
-#Preview {
- MCPCopySheet(
- viewModel: MCPCopyViewModel(
- serverName: "github",
- server: .stdio(
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-github"],
- env: ["GITHUB_TOKEN": "ghp_xxx"]
- ),
- sourceDestination: .project(path: "/tmp/project", name: "My Project")
- )
- )
-}
diff --git a/Fig/Sources/Views/MCPHealthCheckButton.swift b/Fig/Sources/Views/MCPHealthCheckButton.swift
deleted file mode 100644
index ca98a65..0000000
--- a/Fig/Sources/Views/MCPHealthCheckButton.swift
+++ /dev/null
@@ -1,267 +0,0 @@
-import SwiftUI
-
-// MARK: - MCPHealthCheckButton
-
-/// Button that triggers and displays MCP server health check status.
-struct MCPHealthCheckButton: View {
- // MARK: Internal
-
- let serverName: String
- let server: MCPServer
-
- var body: some View {
- Button {
- self.runHealthCheck()
- } label: {
- HStack(spacing: 4) {
- self.statusIcon
- Text("Test")
- .font(.caption2)
- }
- }
- .buttonStyle(.bordered)
- .controlSize(.small)
- .disabled(self.isChecking)
- .help(self.helpText)
- .popover(isPresented: self.$showDetails) {
- HealthCheckResultPopover(result: self.result)
- }
- }
-
- // MARK: Private
-
- private enum CheckState {
- case idle
- case checking
- case completed
- }
-
- @State private var state: CheckState = .idle
- @State private var result: MCPHealthCheckResult?
- @State private var showDetails = false
-
- private var isChecking: Bool {
- self.state == .checking
- }
-
- private var helpText: String {
- switch self.state {
- case .idle:
- "Test server connection"
- case .checking:
- "Testing connection..."
- case .completed:
- if let result {
- result.isSuccess ? "Connection successful (click for details)" : "Connection failed (click for details)"
- } else {
- "Test complete"
- }
- }
- }
-
- @ViewBuilder private var statusIcon: some View {
- switch self.state {
- case .idle:
- Image(systemName: "play.circle")
- .foregroundStyle(.secondary)
- case .checking:
- ProgressView()
- .scaleEffect(0.6)
- .frame(width: 12, height: 12)
- case .completed:
- if let result {
- switch result.status {
- case .success:
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- case .failure:
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- case .timeout:
- Image(systemName: "clock.badge.exclamationmark")
- .foregroundStyle(.orange)
- }
- } else {
- Image(systemName: "questionmark.circle")
- .foregroundStyle(.secondary)
- }
- }
- }
-
- private func runHealthCheck() {
- if self.state == .completed, self.result != nil {
- // Show details if we already have a result
- self.showDetails = true
- return
- }
-
- self.state = .checking
- self.result = nil
-
- Task {
- let checkResult = await MCPHealthCheckService.shared.checkHealth(
- name: self.serverName,
- server: self.server
- )
- self.result = checkResult
- self.state = .completed
-
- // Show toast notification
- switch checkResult.status {
- case let .success(info):
- let name = info?.serverName ?? self.serverName
- NotificationManager.shared.showSuccess(
- "Connected to \(name)",
- message: String(format: "Response time: %.0fms", checkResult.duration * 1000)
- )
- case let .failure(error):
- NotificationManager.shared.showError(
- "Connection failed",
- message: error.localizedDescription
- )
- case .timeout:
- NotificationManager.shared.showWarning(
- "Connection timed out",
- message: "Server did not respond within 10 seconds"
- )
- }
- }
- }
-}
-
-// MARK: - HealthCheckResultPopover
-
-/// Popover showing detailed health check results.
-struct HealthCheckResultPopover: View {
- // MARK: Internal
-
- let result: MCPHealthCheckResult?
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- if let result {
- // Status header
- HStack {
- self.statusIcon(for: result)
- VStack(alignment: .leading) {
- Text(self.statusTitle(for: result))
- .font(.headline)
- Text(String(format: "%.0fms", result.duration * 1000))
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- Divider()
-
- // Details
- switch result.status {
- case let .success(info):
- if let info {
- VStack(alignment: .leading, spacing: 4) {
- if let name = info.serverName {
- HealthCheckDetailRow(label: "Server", value: name)
- }
- if let version = info.serverVersion {
- HealthCheckDetailRow(label: "Version", value: version)
- }
- if let protocolVersion = info.protocolVersion {
- HealthCheckDetailRow(label: "Protocol", value: protocolVersion)
- }
- }
- } else {
- Text("Server responded successfully")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- case let .failure(error):
- VStack(alignment: .leading, spacing: 8) {
- Text(error.localizedDescription ?? "Unknown error")
- .font(.caption)
- .foregroundStyle(.red)
-
- if let suggestion = error.recoverySuggestion {
- Text(suggestion)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- case .timeout:
- VStack(alignment: .leading, spacing: 4) {
- Text("The server did not respond within 10 seconds.")
- .font(.caption)
- Text("The server may be slow, unresponsive, or not running.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- } else {
- Text("No result available")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- .padding()
- .frame(minWidth: 250, maxWidth: 350)
- }
-
- // MARK: Private
-
- @ViewBuilder
- private func statusIcon(for result: MCPHealthCheckResult) -> some View {
- switch result.status {
- case .success:
- Image(systemName: "checkmark.circle.fill")
- .font(.title)
- .foregroundStyle(.green)
- case .failure:
- Image(systemName: "xmark.circle.fill")
- .font(.title)
- .foregroundStyle(.red)
- case .timeout:
- Image(systemName: "clock.badge.exclamationmark")
- .font(.title)
- .foregroundStyle(.orange)
- }
- }
-
- private func statusTitle(for result: MCPHealthCheckResult) -> String {
- switch result.status {
- case .success:
- "Connection Successful"
- case .failure:
- "Connection Failed"
- case .timeout:
- "Connection Timed Out"
- }
- }
-}
-
-// MARK: - HealthCheckDetailRow
-
-/// A simple key-value row for displaying details.
-private struct HealthCheckDetailRow: View {
- let label: String
- let value: String
-
- var body: some View {
- HStack {
- Text(self.label + ":")
- .font(.caption)
- .foregroundStyle(.secondary)
- Text(self.value)
- .font(.caption)
- .fontWeight(.medium)
- }
- }
-}
-
-#Preview("Idle") {
- MCPHealthCheckButton(
- serverName: "test-server",
- server: .stdio(command: "echo", args: ["hello"])
- )
- .padding()
-}
diff --git a/Fig/Sources/Views/MCPPasteSheet.swift b/Fig/Sources/Views/MCPPasteSheet.swift
deleted file mode 100644
index 58adde1..0000000
--- a/Fig/Sources/Views/MCPPasteSheet.swift
+++ /dev/null
@@ -1,298 +0,0 @@
-import SwiftUI
-
-// MARK: - MCPPasteSheet
-
-/// Sheet for importing MCP servers from pasted JSON.
-struct MCPPasteSheet: View {
- // MARK: Internal
-
- @Bindable var viewModel: MCPPasteViewModel
-
- var body: some View {
- VStack(spacing: 0) {
- self.header
-
- Divider()
-
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- self.jsonInputSection
- self.previewSection
- self.destinationSection
- self.conflictSection
-
- if let result = viewModel.importResult {
- self.resultSection(result: result)
- }
-
- if let error = viewModel.errorMessage {
- self.errorSection(error: error)
- }
- }
- .padding()
- }
-
- Divider()
-
- self.footer
- }
- .frame(width: 500, height: 550)
- }
-
- // MARK: Private
-
- @Environment(\.dismiss) private var dismiss
-
- private var header: some View {
- HStack {
- Image(systemName: "doc.on.clipboard")
- .font(.title2)
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading) {
- Text("Import MCP Servers")
- .font(.headline)
- Text("Paste JSON configuration to add servers")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
- }
- .padding()
- }
-
- private var jsonInputSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text("Paste your MCP server JSON below:")
- .font(.subheadline)
-
- Spacer()
-
- Button {
- Task {
- await self.viewModel.loadFromClipboard()
- }
- } label: {
- Label("Paste from Clipboard", systemImage: "clipboard")
- .font(.caption)
- }
- .buttonStyle(.bordered)
- .controlSize(.small)
- }
-
- TextEditor(text: self.$viewModel.jsonText)
- .font(.system(.caption, design: .monospaced))
- .frame(minHeight: 120, maxHeight: 150)
- .scrollContentBackground(.hidden)
- .padding(4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
-
- if let error = viewModel.parseError {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.red)
- .font(.caption)
- Text(error)
- .font(.caption)
- .foregroundStyle(.red)
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("JSON Input", systemImage: "curlybraces")
- }
- }
-
- @ViewBuilder
- private var previewSection: some View {
- if let servers = viewModel.parsedServers, !servers.isEmpty {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- Text("\(self.viewModel.serverCount) server(s) detected")
- .fontWeight(.medium)
- }
-
- VStack(alignment: .leading, spacing: 4) {
- ForEach(self.viewModel.serverNames, id: \.self) { name in
- HStack {
- let server = servers[name]
- Image(systemName: server?.isHTTP == true ? "globe" : "terminal")
- .foregroundStyle(server?.isHTTP == true ? .blue : .green)
- .font(.caption)
- Text(name)
- .font(.system(.caption, design: .monospaced))
- Spacer()
- Text(server?.isHTTP == true ? "HTTP" : "Stdio")
- .font(.caption2)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 4))
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Preview", systemImage: "eye")
- }
- }
- }
-
- @ViewBuilder
- private var destinationSection: some View {
- if self.viewModel.parsedServers != nil {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- if self.viewModel.availableDestinations.isEmpty {
- Text("No destinations available")
- .foregroundStyle(.secondary)
- } else {
- Picker("Import to", selection: self.$viewModel.selectedDestination) {
- Text("Select destination...")
- .tag(nil as CopyDestination?)
-
- ForEach(self.viewModel.availableDestinations) { destination in
- Label(destination.displayName, systemImage: destination.icon)
- .tag(destination as CopyDestination?)
- }
- }
- .pickerStyle(.menu)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Destination", systemImage: "arrow.right.square")
- }
- }
- }
-
- @ViewBuilder
- private var conflictSection: some View {
- if self.viewModel.parsedServers != nil {
- GroupBox {
- Picker("If server already exists", selection: self.$viewModel.conflictStrategy) {
- ForEach(
- [ConflictStrategy.rename, .overwrite, .skip],
- id: \.self
- ) { strategy in
- Text(strategy.displayName).tag(strategy)
- }
- }
- .pickerStyle(.menu)
- .padding(.vertical, 4)
- } label: {
- Label("Conflict Resolution", systemImage: "arrow.triangle.2.circlepath")
- }
- }
- }
-
- private var footer: some View {
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- if self.viewModel.importSucceeded {
- Button("Done") {
- self.dismiss()
- }
- .buttonStyle(.borderedProminent)
- .keyboardShortcut(.defaultAction)
- } else {
- Button("Import") {
- Task {
- await self.viewModel.performImport()
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(!self.viewModel.canImport)
- .keyboardShortcut(.defaultAction)
- }
- }
- .padding()
- }
-
- private func resultSection(result: BulkImportResult) -> some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- if result.totalImported > 0 {
- HStack {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- Text(result.summary)
- }
-
- if !result.renamed.isEmpty {
- ForEach(Array(result.renamed.sorted(by: { $0.key < $1.key })), id: \.key) { old, new in
- HStack {
- Text(old)
- .font(.system(.caption, design: .monospaced))
- .strikethrough()
- .foregroundStyle(.secondary)
- Image(systemName: "arrow.right")
- .font(.caption2)
- .foregroundStyle(.secondary)
- Text(new)
- .font(.system(.caption, design: .monospaced))
- }
- }
- }
- } else {
- HStack {
- Image(systemName: "info.circle.fill")
- .foregroundStyle(.orange)
- Text(result.summary)
- }
- }
-
- if !result.errors.isEmpty {
- ForEach(result.errors, id: \.self) { error in
- HStack {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- .font(.caption)
- Text(error)
- .font(.caption)
- .foregroundStyle(.red)
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Result", systemImage: "checkmark.seal")
- }
- }
-
- private func errorSection(error: String) -> some View {
- GroupBox {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.red)
- Text(error)
- .foregroundStyle(.red)
- }
- .padding(.vertical, 4)
- } label: {
- Label("Error", systemImage: "xmark.octagon")
- }
- }
-}
-
-#Preview {
- MCPPasteSheet(
- viewModel: MCPPasteViewModel(
- currentProject: .project(path: "/tmp/project", name: "My Project")
- )
- )
-}
diff --git a/Fig/Sources/Views/MCPServerEditorView.swift b/Fig/Sources/Views/MCPServerEditorView.swift
deleted file mode 100644
index e70cd38..0000000
--- a/Fig/Sources/Views/MCPServerEditorView.swift
+++ /dev/null
@@ -1,505 +0,0 @@
-import SwiftUI
-
-// MARK: - MCPServerEditorView
-
-/// Form view for adding or editing MCP server configurations.
-struct MCPServerEditorView: View {
- // MARK: Internal
-
- enum Field: Hashable {
- case name
- case command
- case url
- }
-
- @Bindable var viewModel: MCPServerEditorViewModel
-
- var body: some View {
- VStack(spacing: 0) {
- // Header
- self.header
-
- Divider()
-
- // Form content
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- // Server identity section
- self.identitySection
-
- // Target location section
- self.scopeSection
-
- // Type-specific configuration
- if self.viewModel.formData.serverType == .stdio {
- self.stdioSection
- } else {
- self.httpSection
- }
-
- // Import section
- self.importSection
- }
- .padding()
- }
-
- Divider()
-
- // Footer with buttons
- self.footer
- }
- .frame(width: 550, height: 600)
- .onChange(of: self.viewModel.formData.name) { _, _ in self.viewModel.validate() }
- .onChange(of: self.viewModel.formData.command) { _, _ in self.viewModel.validate() }
- .onChange(of: self.viewModel.formData.url) { _, _ in self.viewModel.validate() }
- .onChange(of: self.viewModel.formData.scope) { _, _ in self.viewModel.validate() }
- .onAppear {
- self.viewModel.validate()
- self.focusedField = self.viewModel.isEditing ? .command : .name
- }
- }
-
- // MARK: Private
-
- private enum ImportType: String, CaseIterable, Identifiable {
- case json = "JSON"
- case cli = "CLI Command"
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
- }
-
- @Environment(\.dismiss) private var dismiss
- @FocusState private var focusedField: Field?
-
- @State private var showImportSheet = false
- @State private var importText = ""
- @State private var importType: ImportType = .json
-
- private var header: some View {
- HStack {
- Image(systemName: self.viewModel.formData.serverType.icon)
- .font(.title2)
- .foregroundStyle(self.viewModel.formData.serverType == .http ? .blue : .green)
-
- Text(self.viewModel.formTitle)
- .font(.headline)
-
- Spacer()
- }
- .padding()
- }
-
- private var identitySection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 12) {
- // Server name
- VStack(alignment: .leading, spacing: 4) {
- Text("Server Name")
- .font(.subheadline)
- .fontWeight(.medium)
-
- TextField("my-server", text: self.$viewModel.formData.name)
- .textFieldStyle(.roundedBorder)
- .focused(self.$focusedField, equals: .name)
- .accessibilityLabel("Server name")
-
- if let error = viewModel.error(for: "name") {
- Text(error.message)
- .font(.caption)
- .foregroundStyle(.red)
- }
- }
-
- // Server type picker
- VStack(alignment: .leading, spacing: 4) {
- Text("Server Type")
- .font(.subheadline)
- .fontWeight(.medium)
-
- Picker("Type", selection: self.$viewModel.formData.serverType) {
- ForEach(MCPServerType.allCases) { type in
- Label(type.displayName, systemImage: type.icon)
- .tag(type)
- }
- }
- .pickerStyle(.segmented)
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Server Identity", systemImage: "tag")
- }
- }
-
- private var scopeSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 4) {
- Text("Save Location")
- .font(.subheadline)
- .fontWeight(.medium)
-
- Picker("Scope", selection: self.$viewModel.formData.scope) {
- ForEach(MCPServerScope.allCases) { scope in
- Label(scope.displayName, systemImage: scope.icon)
- .tag(scope)
- }
- }
- .pickerStyle(.radioGroup)
- .disabled(self.viewModel.projectPath == nil && self.viewModel.formData.scope == .project)
- }
- .padding(.vertical, 4)
- } label: {
- Label("Target Location", systemImage: "folder")
- }
- }
-
- private var stdioSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 16) {
- // Command
- VStack(alignment: .leading, spacing: 4) {
- Text("Command")
- .font(.subheadline)
- .fontWeight(.medium)
-
- TextField("npx", text: self.$viewModel.formData.command)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .focused(self.$focusedField, equals: .command)
- .accessibilityLabel("Command")
-
- if let error = viewModel.error(for: "command") {
- Text(error.message)
- .font(.caption)
- .foregroundStyle(.red)
- }
- }
-
- // Arguments
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- Text("Arguments")
- .font(.subheadline)
- .fontWeight(.medium)
- Spacer()
- }
-
- TagInputView(
- tags: self.$viewModel.formData.args,
- placeholder: "Add argument..."
- )
- }
-
- // Environment variables
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text("Environment Variables")
- .font(.subheadline)
- .fontWeight(.medium)
- Spacer()
- Button {
- self.viewModel.formData.addEnvVar()
- } label: {
- Image(systemName: "plus.circle")
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Add environment variable")
- }
-
- if self.viewModel.formData.envVars.isEmpty {
- Text("No environment variables")
- .font(.caption)
- .foregroundStyle(.secondary)
- } else {
- ForEach(Array(self.viewModel.formData.envVars.enumerated()), id: \.element.id) { index, _ in
- KeyValueRow(
- key: self.$viewModel.formData.envVars[index].key,
- value: self.$viewModel.formData.envVars[index].value,
- keyPlaceholder: "KEY",
- valuePlaceholder: "value",
- onDelete: {
- self.viewModel.formData.removeEnvVar(at: index)
- }
- )
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("Stdio Configuration", systemImage: "terminal")
- }
- }
-
- private var httpSection: some View {
- GroupBox {
- VStack(alignment: .leading, spacing: 16) {
- // URL
- VStack(alignment: .leading, spacing: 4) {
- Text("URL")
- .font(.subheadline)
- .fontWeight(.medium)
-
- TextField("https://mcp.example.com/api", text: self.$viewModel.formData.url)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .focused(self.$focusedField, equals: .url)
- .accessibilityLabel("Server URL")
-
- if let error = viewModel.error(for: "url") {
- Text(error.message)
- .font(.caption)
- .foregroundStyle(.red)
- }
- }
-
- // Headers
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text("Headers")
- .font(.subheadline)
- .fontWeight(.medium)
- Spacer()
- Button {
- self.viewModel.formData.addHeader()
- } label: {
- Image(systemName: "plus.circle")
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Add header")
- }
-
- if self.viewModel.formData.headers.isEmpty {
- Text("No headers")
- .font(.caption)
- .foregroundStyle(.secondary)
- } else {
- ForEach(Array(self.viewModel.formData.headers.enumerated()), id: \.element.id) { index, _ in
- KeyValueRow(
- key: self.$viewModel.formData.headers[index].key,
- value: self.$viewModel.formData.headers[index].value,
- keyPlaceholder: "Header-Name",
- valuePlaceholder: "value",
- onDelete: {
- self.viewModel.formData.removeHeader(at: index)
- }
- )
- }
- }
- }
- }
- .padding(.vertical, 4)
- } label: {
- Label("HTTP Configuration", systemImage: "globe")
- }
- }
-
- private var importSection: some View {
- DisclosureGroup {
- VStack(alignment: .leading, spacing: 12) {
- Picker("Import Type", selection: self.$importType) {
- ForEach(ImportType.allCases) { type in
- Text(type.rawValue).tag(type)
- }
- }
- .pickerStyle(.segmented)
-
- TextEditor(text: self.$importText)
- .font(.system(.caption, design: .monospaced))
- .frame(height: 80)
- .overlay(
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color.secondary.opacity(0.3), lineWidth: 1)
- )
-
- HStack {
- Spacer()
- Button("Import") {
- let success = if self.importType == .json {
- self.viewModel.importFromJSON(self.importText)
- } else {
- self.viewModel.importFromCLICommand(self.importText)
- }
- if success {
- self.importText = ""
- }
- }
- .disabled(self.importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- }
- .padding(.top, 8)
- } label: {
- Label("Import from JSON / CLI", systemImage: "square.and.arrow.down")
- }
- .padding(.horizontal, 4)
- }
-
- private var footer: some View {
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- Button(self.viewModel.isEditing ? "Save" : "Add Server") {
- Task {
- if await self.viewModel.save() {
- self.dismiss()
- }
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(!self.viewModel.canSave)
- .keyboardShortcut(.defaultAction)
- }
- .padding()
- }
-}
-
-// MARK: - TagInputView
-
-/// A view for entering tags/arguments with add/remove functionality.
-struct TagInputView: View {
- // MARK: Internal
-
- @Binding var tags: [String]
-
- var placeholder: String = "Add tag..."
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- // Tag display
- FlowLayout(spacing: 4) {
- ForEach(Array(self.tags.enumerated()), id: \.offset) { index, tag in
- TagChip(text: tag) {
- self.tags.remove(at: index)
- }
- }
- }
-
- // Input field
- HStack {
- TextField(self.placeholder, text: self.$newTagText)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .onSubmit {
- self.addTag()
- }
-
- Button {
- self.addTag()
- } label: {
- Image(systemName: "plus.circle.fill")
- }
- .buttonStyle(.plain)
- .disabled(self.newTagText.trimmingCharacters(in: .whitespaces).isEmpty)
- }
- }
- }
-
- // MARK: Private
-
- @State private var newTagText = ""
-
- private func addTag() {
- let trimmed = self.newTagText.trimmingCharacters(in: .whitespaces)
- guard !trimmed.isEmpty else {
- return
- }
- self.tags.append(trimmed)
- self.newTagText = ""
- }
-}
-
-// MARK: - TagChip
-
-/// A removable tag chip.
-struct TagChip: View {
- let text: String
- let onRemove: () -> Void
-
- var body: some View {
- HStack(spacing: 4) {
- Text(self.text)
- .font(.system(.caption, design: .monospaced))
-
- Button {
- self.onRemove()
- } label: {
- Image(systemName: "xmark.circle.fill")
- .font(.caption2)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Remove \(self.text)")
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
- }
-}
-
-// MARK: - KeyValueRow
-
-/// A row for entering key-value pairs with delete button.
-struct KeyValueRow: View {
- @Binding var key: String
- @Binding var value: String
-
- var keyPlaceholder: String = "Key"
- var valuePlaceholder: String = "Value"
- var onDelete: () -> Void
-
- var body: some View {
- HStack(spacing: 8) {
- TextField(self.keyPlaceholder, text: self.$key)
- .textFieldStyle(.roundedBorder)
- .font(.system(.caption, design: .monospaced))
- .frame(width: 150)
-
- Text("=")
- .foregroundStyle(.secondary)
-
- TextField(self.valuePlaceholder, text: self.$value)
- .textFieldStyle(.roundedBorder)
- .font(.system(.caption, design: .monospaced))
-
- Button {
- self.onDelete()
- } label: {
- Image(systemName: "minus.circle.fill")
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("Remove entry")
- }
- }
-}
-
-#Preview("Add Server") {
- MCPServerEditorView(
- viewModel: MCPServerEditorViewModel.forAdding(
- projectPath: URL(fileURLWithPath: "/tmp/test-project")
- )
- )
-}
-
-#Preview("Edit Server") {
- MCPServerEditorView(
- viewModel: MCPServerEditorViewModel.forEditing(
- name: "github",
- server: .stdio(
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-github"],
- env: ["GITHUB_TOKEN": "ghp_xxx"]
- ),
- scope: .project,
- projectPath: URL(fileURLWithPath: "/tmp/test-project")
- )
- )
-}
diff --git a/Fig/Sources/Views/Onboarding/OnboardingCompletionView.swift b/Fig/Sources/Views/Onboarding/OnboardingCompletionView.swift
deleted file mode 100644
index 0b9f4fd..0000000
--- a/Fig/Sources/Views/Onboarding/OnboardingCompletionView.swift
+++ /dev/null
@@ -1,62 +0,0 @@
-import SwiftUI
-
-/// The final onboarding screen shown before entering the main app.
-///
-/// Displays a summary of discovered projects and a button
-/// to complete onboarding and launch the main app.
-struct OnboardingCompletionView: View {
- // MARK: Internal
-
- var viewModel: OnboardingViewModel
-
- var body: some View {
- VStack(spacing: 24) {
- Spacer()
-
- Image(systemName: "checkmark.circle")
- .font(.system(size: 64))
- .foregroundStyle(.green)
- .symbolRenderingMode(.hierarchical)
-
- VStack(spacing: 12) {
- Text("You\u{2019}re All Set!")
- .font(.largeTitle)
- .fontWeight(.bold)
-
- Text(self.summaryMessage)
- .font(.title3)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
-
- Button {
- self.viewModel.advance()
- } label: {
- Text("Open Fig")
- .frame(maxWidth: 200)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
-
- Spacer()
- .frame(height: 40)
- }
- .padding(40)
- }
-
- // MARK: Private
-
- private var summaryMessage: String {
- let count = self.viewModel.discoveredProjects.count
- if count == 0 {
- return "Fig is ready to use."
- }
- let noun = count == 1 ? "project" : "projects"
- return "Fig found \(count) \(noun) and is ready to use."
- }
-}
-
-#Preview {
- OnboardingCompletionView(viewModel: OnboardingViewModel {})
-}
diff --git a/Fig/Sources/Views/Onboarding/OnboardingDiscoveryView.swift b/Fig/Sources/Views/Onboarding/OnboardingDiscoveryView.swift
deleted file mode 100644
index 4780792..0000000
--- a/Fig/Sources/Views/Onboarding/OnboardingDiscoveryView.swift
+++ /dev/null
@@ -1,194 +0,0 @@
-import SwiftUI
-
-/// Runs project discovery and displays found projects.
-///
-/// Automatically scans the filesystem for Claude Code projects
-/// on appear and shows the results in a scrollable list.
-struct OnboardingDiscoveryView: View {
- // MARK: Internal
-
- var viewModel: OnboardingViewModel
-
- var body: some View {
- VStack(spacing: 24) {
- Spacer()
-
- Image(systemName: "magnifyingglass")
- .font(.system(size: 56))
- .foregroundStyle(Color.accentColor)
- .symbolRenderingMode(.hierarchical)
-
- if self.viewModel.isDiscovering {
- self.discoveringContent
- } else if let error = self.viewModel.discoveryError {
- self.errorContent(error)
- } else if self.viewModel.discoveredProjects.isEmpty {
- self.emptyContent
- } else {
- self.resultsContent
- }
-
- Spacer()
-
- HStack {
- Button("Back") {
- self.viewModel.goBack()
- }
- .buttonStyle(.bordered)
-
- Spacer()
-
- Button {
- self.viewModel.advance()
- } label: {
- Text("Continue")
- .frame(maxWidth: 200)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- }
-
- Spacer()
- .frame(height: 40)
- }
- .padding(40)
- .task {
- if self.viewModel.discoveredProjects.isEmpty, !self.viewModel.isDiscovering {
- await self.viewModel.runDiscovery()
- }
- }
- }
-
- // MARK: Private
-
- private var projectCountMessage: String {
- let count = self.viewModel.discoveredProjects.count
- let noun = count == 1 ? "project" : "projects"
- return "Found \(count) Claude Code \(noun) on your system."
- }
-
- private var discoveringContent: some View {
- VStack(spacing: 16) {
- Text("Discovering Your Projects")
- .font(.largeTitle)
- .fontWeight(.bold)
-
- ProgressView()
- .controlSize(.large)
-
- Text("Scanning for Claude Code projects\u{2026}")
- .font(.body)
- .foregroundStyle(.secondary)
- }
- }
-
- private var emptyContent: some View {
- VStack(spacing: 16) {
- Text("No Projects Found")
- .font(.largeTitle)
- .fontWeight(.bold)
-
- Text("No Claude Code projects were found on your system.")
- .font(.body)
- .foregroundStyle(.secondary)
-
- Text("Projects will appear once you use Claude Code in a directory.")
- .font(.body)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 450)
- }
- }
-
- private var resultsContent: some View {
- VStack(spacing: 16) {
- Text("Projects Found")
- .font(.largeTitle)
- .fontWeight(.bold)
-
- Text(self.projectCountMessage)
- .font(.title3)
- .foregroundStyle(.secondary)
-
- self.projectList
- }
- }
-
- private var projectList: some View {
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 8) {
- ForEach(
- Array(self.viewModel.discoveredProjects.prefix(15)),
- id: \.id
- ) { project in
- self.projectRow(project)
- }
-
- if self.viewModel.discoveredProjects.count > 15 {
- Text(
- "and \(self.viewModel.discoveredProjects.count - 15) more\u{2026}"
- )
- .font(.callout)
- .foregroundStyle(.secondary)
- .padding(.leading, 36)
- .padding(.top, 4)
- }
- }
- .padding(16)
- }
- .frame(maxHeight: 250)
- .background(.quaternary.opacity(0.5))
- .clipShape(RoundedRectangle(cornerRadius: 12))
- }
-
- private func errorContent(_ error: String) -> some View {
- VStack(spacing: 16) {
- Text("Discovery Issue")
- .font(.largeTitle)
- .fontWeight(.bold)
-
- Text(error)
- .font(.body)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 400)
-
- Button("Retry") {
- Task {
- await self.viewModel.runDiscovery()
- }
- }
- .buttonStyle(.bordered)
- }
- }
-
- private func projectRow(_ project: DiscoveredProject) -> some View {
- HStack(spacing: 12) {
- Image(systemName: project.exists ? "folder.fill" : "questionmark.folder")
- .foregroundStyle(project.exists ? Color.accentColor : .orange)
- .frame(width: 20)
- VStack(alignment: .leading, spacing: 2) {
- Text(project.displayName)
- .font(.body)
- .fontWeight(.medium)
- Text(self.abbreviatePath(project.path))
- .font(.callout)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- }
- }
- }
-
- private func abbreviatePath(_ path: String) -> String {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- if path.hasPrefix(home) {
- return "~" + path.dropFirst(home.count)
- }
- return path
- }
-}
-
-#Preview {
- OnboardingDiscoveryView(viewModel: OnboardingViewModel {})
-}
diff --git a/Fig/Sources/Views/Onboarding/OnboardingPermissionsView.swift b/Fig/Sources/Views/Onboarding/OnboardingPermissionsView.swift
deleted file mode 100644
index 49082e4..0000000
--- a/Fig/Sources/Views/Onboarding/OnboardingPermissionsView.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-import SwiftUI
-
-/// Explains what configuration files Fig accesses.
-///
-/// Since the app is non-sandboxed, filesystem access is not an issue.
-/// This step is informational, helping users understand what files
-/// Fig reads and writes.
-struct OnboardingPermissionsView: View {
- // MARK: Internal
-
- var viewModel: OnboardingViewModel
-
- var body: some View {
- VStack(spacing: 24) {
- Spacer()
-
- Image(systemName: "lock.shield")
- .font(.system(size: 56))
- .foregroundStyle(Color.accentColor)
- .symbolRenderingMode(.hierarchical)
-
- VStack(spacing: 12) {
- Text("File Access")
- .font(.largeTitle)
- .fontWeight(.bold)
-
- Text("Fig reads and writes Claude Code configuration files on your system.")
- .font(.title3)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 500)
- }
-
- VStack(alignment: .leading, spacing: 16) {
- self.fileAccessRow(
- path: "~/.claude.json",
- description: "Global project registry and MCP servers"
- )
- self.fileAccessRow(
- path: "~/.claude/settings.json",
- description: "Global Claude Code settings"
- )
- self.fileAccessRow(
- path: "/.claude/settings.json",
- description: "Per-project settings and local overrides"
- )
- self.fileAccessRow(
- path: "/.mcp.json",
- description: "Per-project MCP server configuration"
- )
- }
- .padding(20)
- .background(.quaternary.opacity(0.5))
- .clipShape(RoundedRectangle(cornerRadius: 12))
-
- HStack(spacing: 8) {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- Text("Fig only accesses Claude Code configuration files.")
- .font(.callout)
- .foregroundStyle(.secondary)
- }
-
- Spacer()
-
- HStack {
- Button("Back") {
- self.viewModel.goBack()
- }
- .buttonStyle(.bordered)
-
- Spacer()
-
- Button {
- self.viewModel.advance()
- } label: {
- Text("Continue")
- .frame(maxWidth: 200)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- }
-
- Spacer()
- .frame(height: 40)
- }
- .padding(40)
- }
-
- // MARK: Private
-
- private func fileAccessRow(path: String, description: String) -> some View {
- HStack(spacing: 12) {
- Image(systemName: "doc.text")
- .foregroundStyle(.secondary)
- .frame(width: 20)
- VStack(alignment: .leading, spacing: 2) {
- Text(path)
- .font(.system(.body, design: .monospaced))
- Text(description)
- .font(.callout)
- .foregroundStyle(.secondary)
- }
- }
- }
-}
-
-#Preview {
- OnboardingPermissionsView(viewModel: OnboardingViewModel {})
-}
diff --git a/Fig/Sources/Views/Onboarding/OnboardingTourView.swift b/Fig/Sources/Views/Onboarding/OnboardingTourView.swift
deleted file mode 100644
index 983e610..0000000
--- a/Fig/Sources/Views/Onboarding/OnboardingTourView.swift
+++ /dev/null
@@ -1,149 +0,0 @@
-import SwiftUI
-
-/// A multi-page feature tour highlighting key Fig capabilities.
-///
-/// Displays 4 feature pages with manual navigation and a skip option.
-/// Uses custom paging instead of TabView(.page) which is iOS-only.
-struct OnboardingTourView: View {
- // MARK: Internal
-
- var viewModel: OnboardingViewModel
-
- var body: some View {
- VStack(spacing: 24) {
- Spacer()
-
- self.tourPage(for: self.viewModel.currentTourPage)
- .id(self.viewModel.currentTourPage)
- .transition(.asymmetric(
- insertion: .move(edge: .trailing).combined(with: .opacity),
- removal: .move(edge: .leading).combined(with: .opacity)
- ))
-
- self.pageIndicator
-
- Spacer()
-
- HStack {
- Button("Back") {
- withAnimation(.easeInOut(duration: 0.25)) {
- if self.viewModel.currentTourPage > 0 {
- self.viewModel.currentTourPage -= 1
- } else {
- self.viewModel.goBack()
- }
- }
- }
- .buttonStyle(.bordered)
-
- Button("Skip Tour") {
- self.viewModel.skipTour()
- }
- .buttonStyle(.plain)
- .foregroundStyle(.secondary)
- .font(.callout)
-
- Spacer()
-
- Button {
- withAnimation(.easeInOut(duration: 0.25)) {
- let isLastPage = self.viewModel.currentTourPage
- == OnboardingViewModel.tourPageCount - 1
- if !isLastPage {
- self.viewModel.currentTourPage += 1
- } else {
- self.viewModel.advance()
- }
- }
- } label: {
- Text(self.isLastTourPage ? "Finish Tour" : "Next")
- .frame(maxWidth: 200)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- }
-
- Spacer()
- .frame(height: 40)
- }
- .padding(40)
- .animation(.easeInOut(duration: 0.25), value: self.viewModel.currentTourPage)
- }
-
- // MARK: Private
-
- private struct TourPageData {
- let icon: String
- let title: String
- let description: String
- }
-
- // swiftlint:disable line_length
- private static let pages: [TourPageData] = [
- TourPageData(
- icon: "sidebar.left",
- title: "Project Explorer",
- description: "Browse all your Claude Code projects in one place. See which projects have settings, MCP servers, and local configurations. Favorite projects for quick access."
- ),
- TourPageData(
- icon: "slider.horizontal.3",
- title: "Configuration Editor",
- description: "Edit permission rules, environment variables, hooks, and attribution settings with a visual editor. Changes are saved directly to your configuration files with automatic backups."
- ),
- TourPageData(
- icon: "server.rack",
- title: "MCP Server Management",
- description: "View, add, and configure MCP servers across projects. Copy server configurations between projects with a single click and check server health status."
- ),
- TourPageData(
- icon: "globe",
- title: "Global Settings",
- description: "Manage global Claude Code settings that apply across all your projects. Set default permissions, environment variables, and hooks in one place."
- ),
- ]
-
- private var isLastTourPage: Bool {
- self.viewModel.currentTourPage == OnboardingViewModel.tourPageCount - 1
- }
-
- private var pageIndicator: some View {
- HStack(spacing: 8) {
- ForEach(0 ..< OnboardingViewModel.tourPageCount, id: \.self) { index in
- Circle()
- .fill(
- index == self.viewModel.currentTourPage
- ? Color.accentColor
- : Color.secondary.opacity(0.3)
- )
- .frame(width: 8, height: 8)
- }
- }
- }
-
- // swiftlint:enable line_length
-
- @ViewBuilder
- private func tourPage(for index: Int) -> some View {
- let page = Self.pages[index]
- VStack(spacing: 20) {
- Image(systemName: page.icon)
- .font(.system(size: 56))
- .foregroundStyle(Color.accentColor)
- .symbolRenderingMode(.hierarchical)
-
- Text(page.title)
- .font(.largeTitle)
- .fontWeight(.bold)
-
- Text(page.description)
- .font(.title3)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- .frame(maxWidth: 500)
- }
- }
-}
-
-#Preview {
- OnboardingTourView(viewModel: OnboardingViewModel {})
-}
diff --git a/Fig/Sources/Views/Onboarding/OnboardingView.swift b/Fig/Sources/Views/Onboarding/OnboardingView.swift
deleted file mode 100644
index 1d0e23e..0000000
--- a/Fig/Sources/Views/Onboarding/OnboardingView.swift
+++ /dev/null
@@ -1,65 +0,0 @@
-import SwiftUI
-
-/// The top-level onboarding container that routes between steps.
-///
-/// Displays a progress indicator and the current step's view,
-/// with animated transitions between steps.
-struct OnboardingView: View {
- // MARK: Lifecycle
-
- init(onComplete: @escaping @MainActor () -> Void) {
- self._viewModel = State(
- initialValue: OnboardingViewModel(onComplete: onComplete)
- )
- }
-
- // MARK: Internal
-
- var body: some View {
- VStack(spacing: 0) {
- self.progressDots
- .padding(.top, 24)
- .padding(.bottom, 8)
-
- Group {
- switch self.viewModel.currentStep {
- case .welcome:
- OnboardingWelcomeView(viewModel: self.viewModel)
- case .permissions:
- OnboardingPermissionsView(viewModel: self.viewModel)
- case .discovery:
- OnboardingDiscoveryView(viewModel: self.viewModel)
- case .tour:
- OnboardingTourView(viewModel: self.viewModel)
- case .completion:
- OnboardingCompletionView(viewModel: self.viewModel)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- .frame(minWidth: 700, minHeight: 500)
- .animation(.easeInOut(duration: 0.3), value: self.viewModel.currentStep)
- }
-
- // MARK: Private
-
- @State private var viewModel: OnboardingViewModel
-
- private var progressDots: some View {
- HStack(spacing: 8) {
- ForEach(OnboardingViewModel.Step.allCases, id: \.rawValue) { step in
- Circle()
- .fill(
- step.rawValue <= self.viewModel.currentStep.rawValue
- ? Color.accentColor
- : Color.secondary.opacity(0.3)
- )
- .frame(width: 8, height: 8)
- }
- }
- }
-}
-
-#Preview {
- OnboardingView {}
-}
diff --git a/Fig/Sources/Views/Onboarding/OnboardingWelcomeView.swift b/Fig/Sources/Views/Onboarding/OnboardingWelcomeView.swift
deleted file mode 100644
index b5a175a..0000000
--- a/Fig/Sources/Views/Onboarding/OnboardingWelcomeView.swift
+++ /dev/null
@@ -1,88 +0,0 @@
-import SwiftUI
-
-/// The welcome screen shown as the first onboarding step.
-///
-/// Introduces the user to Fig and provides options to
-/// start the setup flow or skip it entirely.
-struct OnboardingWelcomeView: View {
- // MARK: Internal
-
- var viewModel: OnboardingViewModel
-
- var body: some View {
- VStack(spacing: 24) {
- Spacer()
-
- Image(systemName: "gearshape.2")
- .font(.system(size: 64))
- .foregroundStyle(Color.accentColor)
- .symbolRenderingMode(.hierarchical)
-
- VStack(spacing: 12) {
- Text("Welcome to Fig")
- .font(.largeTitle)
- .fontWeight(.bold)
-
- Text("A visual configuration manager for Claude Code")
- .font(.title3)
- .foregroundStyle(.secondary)
- }
-
- VStack(alignment: .leading, spacing: 12) {
- self.featureRow(
- icon: "folder",
- text: "Browse and manage all your Claude Code projects"
- )
- self.featureRow(
- icon: "slider.horizontal.3",
- text: "Edit permissions, environment variables, and hooks"
- )
- self.featureRow(
- icon: "server.rack",
- text: "Configure MCP servers across projects"
- )
- }
- .padding(.top, 8)
-
- Spacer()
-
- VStack(spacing: 12) {
- Button {
- self.viewModel.advance()
- } label: {
- Text("Get Started")
- .frame(maxWidth: 200)
- }
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
-
- Button("Skip Setup") {
- self.viewModel.skipToEnd()
- }
- .buttonStyle(.plain)
- .foregroundStyle(.secondary)
- .font(.callout)
- }
-
- Spacer()
- .frame(height: 40)
- }
- .padding(40)
- }
-
- // MARK: Private
-
- private func featureRow(icon: String, text: String) -> some View {
- HStack(spacing: 12) {
- Image(systemName: icon)
- .frame(width: 24)
- .foregroundStyle(Color.accentColor)
- Text(text)
- .font(.body)
- }
- }
-}
-
-#Preview {
- OnboardingWelcomeView(viewModel: OnboardingViewModel {})
-}
diff --git a/Fig/Sources/Views/PermissionEditorViews.swift b/Fig/Sources/Views/PermissionEditorViews.swift
deleted file mode 100644
index dcb73b1..0000000
--- a/Fig/Sources/Views/PermissionEditorViews.swift
+++ /dev/null
@@ -1,538 +0,0 @@
-import SwiftUI
-
-// MARK: - PermissionRuleEditorView
-
-/// Editor view for managing permission rules.
-struct PermissionRuleEditorView: View {
- // MARK: Internal
-
- @Bindable var viewModel: SettingsEditorViewModel
-
- /// Callback when a rule should be promoted to global settings (only in project mode).
- var onPromoteToGlobal: ((String, PermissionType) -> Void)?
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- // Target selector and quick-add
- HStack {
- if !self.viewModel.isGlobalMode {
- EditingTargetPicker(selection: self.$viewModel.editingTarget)
- }
-
- Spacer()
-
- Menu {
- ForEach(PermissionPreset.allPresets) { preset in
- Button {
- self.viewModel.applyPreset(preset)
- } label: {
- VStack(alignment: .leading) {
- Text(preset.name)
- Text(preset.description)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- }
- } label: {
- Label("Quick Add", systemImage: "bolt.fill")
- }
- }
-
- // Allow rules section
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Label("Allow Rules", systemImage: "checkmark.circle.fill")
- .font(.headline)
- .foregroundStyle(.green)
-
- Spacer()
-
- Button {
- self.showingAddAllowRule = true
- } label: {
- Label("Add Rule", systemImage: "plus")
- }
- .buttonStyle(.borderless)
- }
-
- if self.viewModel.allowRules.isEmpty {
- Text("No allow rules configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- ForEach(self.viewModel.allowRules) { rule in
- EditablePermissionRuleRow(
- rule: rule,
- onUpdate: { newRule, newType in
- self.viewModel.updatePermissionRule(rule, newRule: newRule, newType: newType)
- },
- onDelete: {
- self.viewModel.removePermissionRule(rule)
- },
- validateRule: self.viewModel.validatePermissionRule,
- isDuplicate: { ruleStr, type in
- self.viewModel.isRuleDuplicate(ruleStr, type: type, excluding: rule)
- },
- onPromoteToGlobal: self.onPromoteToGlobal != nil ? {
- self.onPromoteToGlobal?(rule.rule, .allow)
- } : nil
- )
- }
- }
- }
- .padding(.vertical, 4)
- }
-
- // Deny rules section
- GroupBox {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Label("Deny Rules", systemImage: "xmark.circle.fill")
- .font(.headline)
- .foregroundStyle(.red)
-
- Spacer()
-
- Button {
- self.showingAddDenyRule = true
- } label: {
- Label("Add Rule", systemImage: "plus")
- }
- .buttonStyle(.borderless)
- }
-
- if self.viewModel.denyRules.isEmpty {
- Text("No deny rules configured.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- ForEach(self.viewModel.denyRules) { rule in
- EditablePermissionRuleRow(
- rule: rule,
- onUpdate: { newRule, newType in
- self.viewModel.updatePermissionRule(rule, newRule: newRule, newType: newType)
- },
- onDelete: {
- self.viewModel.removePermissionRule(rule)
- },
- validateRule: self.viewModel.validatePermissionRule,
- isDuplicate: { ruleStr, type in
- self.viewModel.isRuleDuplicate(ruleStr, type: type, excluding: rule)
- },
- onPromoteToGlobal: self.onPromoteToGlobal != nil ? {
- self.onPromoteToGlobal?(rule.rule, .deny)
- } : nil
- )
- }
- }
- }
- .padding(.vertical, 4)
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .sheet(isPresented: self.$showingAddAllowRule) {
- AddPermissionRuleSheet(type: .allow) { rule in
- self.viewModel.addPermissionRule(rule, type: .allow)
- } validateRule: { rule in
- self.viewModel.validatePermissionRule(rule)
- } isDuplicate: { rule in
- self.viewModel.isRuleDuplicate(rule, type: .allow)
- }
- }
- .sheet(isPresented: self.$showingAddDenyRule) {
- AddPermissionRuleSheet(type: .deny) { rule in
- self.viewModel.addPermissionRule(rule, type: .deny)
- } validateRule: { rule in
- self.viewModel.validatePermissionRule(rule)
- } isDuplicate: { rule in
- self.viewModel.isRuleDuplicate(rule, type: .deny)
- }
- }
- }
-
- // MARK: Private
-
- @State private var showingAddAllowRule = false
- @State private var showingAddDenyRule = false
-}
-
-// MARK: - EditablePermissionRuleRow
-
-/// A row displaying an editable permission rule.
-struct EditablePermissionRuleRow: View {
- // MARK: Internal
-
- let rule: EditablePermissionRule
- let onUpdate: (String, PermissionType) -> Void
- let onDelete: () -> Void
- let validateRule: (String) -> (isValid: Bool, error: String?)
- let isDuplicate: (String, PermissionType) -> Bool
- var onPromoteToGlobal: (() -> Void)?
-
- var body: some View {
- HStack {
- Image(systemName: self.rule.type.icon)
- .foregroundStyle(self.rule.type == .allow ? .green : .red)
- .frame(width: 20)
-
- if self.isEditing {
- TextField("Rule pattern", text: self.$editedRule)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- .onSubmit {
- self.saveEdit()
- }
-
- Button("Save") {
- self.saveEdit()
- }
- .disabled(!self.canSave)
-
- Button("Cancel") {
- self.isEditing = false
- self.editedRule = self.rule.rule
- }
- } else {
- Text(self.rule.rule)
- .font(.system(.body, design: .monospaced))
- .lineLimit(1)
- .truncationMode(.middle)
-
- Spacer()
-
- Button {
- self.isEditing = true
- self.editedRule = self.rule.rule
- } label: {
- Image(systemName: "pencil")
- .font(.caption)
- }
- .buttonStyle(.plain)
-
- Button {
- self.showingDeleteConfirmation = true
- } label: {
- Image(systemName: "trash")
- .font(.caption)
- .foregroundStyle(.red)
- }
- .buttonStyle(.plain)
- }
- }
- .padding(.vertical, 4)
- .padding(.horizontal, 8)
- .background(
- RoundedRectangle(cornerRadius: 6)
- .fill(.quaternary.opacity(0.5))
- )
- .confirmationDialog(
- "Delete Rule",
- isPresented: self.$showingDeleteConfirmation,
- titleVisibility: .visible
- ) {
- Button("Delete", role: .destructive) {
- self.onDelete()
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("Are you sure you want to delete this rule?\n\(self.rule.rule)")
- }
- .contextMenu {
- Button {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(self.rule.rule, forType: .string)
- } label: {
- Label("Copy Rule", systemImage: "doc.on.doc")
- }
-
- if let onPromoteToGlobal {
- Divider()
-
- Button {
- onPromoteToGlobal()
- } label: {
- Label("Promote to Global", systemImage: "arrow.up.to.line")
- }
- }
-
- Divider()
-
- Button(role: .destructive) {
- self.showingDeleteConfirmation = true
- } label: {
- Label("Delete", systemImage: "trash")
- }
- }
- }
-
- // MARK: Private
-
- @State private var isEditing = false
- @State private var editedRule = ""
- @State private var showingDeleteConfirmation = false
-
- private var canSave: Bool {
- let validation = self.validateRule(self.editedRule)
- return validation.isValid && !self.isDuplicate(self.editedRule, self.rule.type)
- }
-
- private func saveEdit() {
- guard self.canSave else {
- return
- }
- self.onUpdate(self.editedRule, self.rule.type)
- self.isEditing = false
- }
-}
-
-// MARK: - AddPermissionRuleSheet
-
-/// Sheet for adding a new permission rule with a builder UI.
-struct AddPermissionRuleSheet: View {
- // MARK: Lifecycle
-
- init(
- type: PermissionType,
- onAdd: @escaping (String) -> Void,
- validateRule: @escaping (String) -> (isValid: Bool, error: String?),
- isDuplicate: @escaping (String) -> Bool
- ) {
- self.type = type
- self.onAdd = onAdd
- self.validateRule = validateRule
- self.isDuplicate = isDuplicate
- }
-
- // MARK: Internal
-
- let type: PermissionType
- let onAdd: (String) -> Void
- let validateRule: (String) -> (isValid: Bool, error: String?)
- let isDuplicate: (String) -> Bool
-
- var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- // Header
- HStack {
- Image(systemName: self.type == .allow ? "checkmark.circle.fill" : "xmark.circle.fill")
- .foregroundStyle(self.type == .allow ? .green : .red)
- .font(.title2)
- Text("Add \(self.type == .allow ? "Allow" : "Deny") Rule")
- .font(.title2)
- .fontWeight(.semibold)
- }
-
- Divider()
-
- // Tool type selector
- VStack(alignment: .leading, spacing: 4) {
- Text("Tool Type")
- .font(.headline)
- Picker("Tool Type", selection: self.$selectedTool) {
- ForEach(ToolType.allCases) { tool in
- Text(tool.rawValue).tag(tool)
- }
- }
- .pickerStyle(.menu)
- }
-
- // Custom tool name (if custom selected)
- if self.selectedTool == .custom {
- VStack(alignment: .leading, spacing: 4) {
- Text("Custom Tool Name")
- .font(.headline)
- TextField("ToolName", text: self.$customToolName)
- .textFieldStyle(.roundedBorder)
- }
- }
-
- // Pattern input
- VStack(alignment: .leading, spacing: 4) {
- Text("Pattern (optional)")
- .font(.headline)
- TextField(self.selectedTool.placeholder, text: self.$pattern)
- .textFieldStyle(.roundedBorder)
- .font(.system(.body, design: .monospaced))
- Text("Use * for wildcard, ** for recursive match")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- // Preview
- VStack(alignment: .leading, spacing: 4) {
- Text("Preview")
- .font(.headline)
- HStack {
- Image(systemName: self.type.icon)
- .foregroundStyle(self.type == .allow ? .green : .red)
- Text(self.generatedRule)
- .font(.system(.body, design: .monospaced))
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 4))
- }
- }
-
- // Validation error
- if let error = validationError {
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- Text(error)
- .foregroundStyle(.secondary)
- }
- .font(.caption)
- }
-
- Spacer()
-
- // Buttons
- HStack {
- Button("Cancel") {
- self.dismiss()
- }
- .keyboardShortcut(.cancelAction)
-
- Spacer()
-
- Button("Add Rule") {
- self.onAdd(self.generatedRule)
- self.dismiss()
- }
- .keyboardShortcut(.defaultAction)
- .disabled(!self.isValid)
- }
- }
- .padding()
- .frame(width: 400, height: 400)
- }
-
- // MARK: Private
-
- @Environment(\.dismiss)
- private var dismiss
-
- @State private var selectedTool: ToolType = .bash
- @State private var customToolName = ""
- @State private var pattern = ""
-
- private var toolName: String {
- self.selectedTool == .custom ? self.customToolName : self.selectedTool.rawValue
- }
-
- private var generatedRule: String {
- if self.pattern.isEmpty {
- return self.toolName
- }
- return "\(self.toolName)(\(self.pattern))"
- }
-
- private var validationError: String? {
- if self.selectedTool == .custom, self.customToolName.isEmpty {
- return "Enter a custom tool name"
- }
- if self.isDuplicate(self.generatedRule) {
- return "This rule already exists"
- }
- let validation = self.validateRule(self.generatedRule)
- return validation.error
- }
-
- private var isValid: Bool {
- self.validationError == nil && !self.toolName.isEmpty
- }
-}
-
-// MARK: - RulePromotionInfo
-
-/// Info about a rule being promoted, used by the shared promote-to-global modifier.
-struct RulePromotionInfo {
- let rule: String
- let type: PermissionType
-}
-
-// MARK: - PromoteToGlobalModifier
-
-/// Shared view modifier that provides the promote-to-global confirmation alert and action.
-struct PromoteToGlobalModifier: ViewModifier {
- @Binding var isPresented: Bool
- @Binding var ruleToPromote: RulePromotionInfo?
-
- let projectURL: URL?
- var onComplete: (() async -> Void)?
-
- func body(content: Content) -> some View {
- content
- .alert(
- "Promote to Global",
- isPresented: self.$isPresented,
- presenting: self.ruleToPromote
- ) { ruleInfo in
- Button("Cancel", role: .cancel) {}
- Button("Promote") {
- Task {
- do {
- let added = try await PermissionRuleCopyService.shared.copyRule(
- rule: ruleInfo.rule,
- type: ruleInfo.type,
- to: .global,
- projectPath: self.projectURL
- )
- if added {
- NotificationManager.shared.showSuccess(
- "Rule Promoted",
- message: "Rule added to global settings"
- )
- } else {
- NotificationManager.shared.showInfo(
- "Rule Already Exists",
- message: "This rule already exists in global settings"
- )
- }
- await self.onComplete?()
- } catch {
- NotificationManager.shared.showError(error)
- }
- }
- }
- } message: { ruleInfo in
- Text("Copy '\(ruleInfo.rule)' to global settings?\nThis will make it apply to all projects.")
- }
- }
-}
-
-extension View {
- /// Adds promote-to-global alert handling for permission rules.
- func promoteToGlobalAlert(
- isPresented: Binding,
- ruleToPromote: Binding,
- projectURL: URL?,
- onComplete: (() async -> Void)? = nil
- ) -> some View {
- modifier(PromoteToGlobalModifier(
- isPresented: isPresented,
- ruleToPromote: ruleToPromote,
- projectURL: projectURL,
- onComplete: onComplete
- ))
- }
-}
-
-#Preview("Permission Rule Editor") {
- let viewModel = SettingsEditorViewModel(projectPath: "/Users/test/project")
- viewModel.permissionRules = [
- EditablePermissionRule(rule: "Bash(npm run *)", type: .allow),
- EditablePermissionRule(rule: "Read(src/**)", type: .allow),
- EditablePermissionRule(rule: "Read(.env)", type: .deny),
- ]
-
- return PermissionRuleEditorView(viewModel: viewModel)
- .padding()
- .frame(width: 600, height: 500)
-}
diff --git a/Fig/Sources/Views/ProjectDetailView.swift b/Fig/Sources/Views/ProjectDetailView.swift
deleted file mode 100644
index fde623e..0000000
--- a/Fig/Sources/Views/ProjectDetailView.swift
+++ /dev/null
@@ -1,749 +0,0 @@
-import SwiftUI
-
-// MARK: - ProjectDetailView
-
-/// Detail view for a selected project showing tabbed configuration.
-struct ProjectDetailView: View {
- // MARK: Lifecycle
-
- init(projectPath: String) {
- _viewModel = State(initialValue: ProjectDetailViewModel(projectPath: projectPath))
- }
-
- // MARK: Internal
-
- var body: some View {
- VStack(spacing: 0) {
- // Header
- ProjectHeaderView(
- viewModel: self.viewModel,
- onExport: {
- self.exportViewModel = ConfigExportViewModel(
- projectPath: self.viewModel.projectURL,
- projectName: self.viewModel.projectName
- )
- self.showExportSheet = true
- },
- onImport: {
- self.importViewModel = ConfigImportViewModel(
- projectPath: self.viewModel.projectURL,
- projectName: self.viewModel.projectName
- )
- self.showImportSheet = true
- },
- onImportFromJSON: {
- self.showPasteServersSheet()
- },
- onExportMCPJSON: {
- self.exportMCPJSON()
- }
- )
-
- Divider()
-
- // Tab content
- if self.viewModel.isLoading {
- ProgressView("Loading configuration...")
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else if !self.viewModel.projectExists {
- ContentUnavailableView(
- "Project Not Found",
- systemImage: "folder.badge.questionmark",
- description: Text("The project directory no longer exists at:\n\(self.viewModel.projectPath)")
- )
- } else {
- TabView(selection: self.$viewModel.selectedTab) {
- ForEach(ProjectDetailTab.allCases) { tab in
- self.tabContent(for: tab)
- .tabItem {
- Label(tab.title, systemImage: tab.icon)
- }
- .tag(tab)
- }
- }
- .padding()
- }
- }
- .frame(minWidth: 500)
- .focusedSceneValue(\.projectDetailTab, self.$viewModel.selectedTab)
- .focusedSceneValue(\.addMCPServerAction) {
- self.mcpEditorViewModel = MCPServerEditorViewModel.forAdding(
- projectPath: self.viewModel.projectURL,
- defaultScope: .project
- )
- self.showMCPServerEditor = true
- }
- .task {
- await self.viewModel.loadConfiguration()
- }
- .sheet(isPresented: self.$showMCPServerEditor, onDismiss: {
- Task {
- await self.viewModel.loadConfiguration()
- }
- }) {
- if let editorViewModel = mcpEditorViewModel {
- MCPServerEditorView(viewModel: editorViewModel)
- }
- }
- .alert(
- "Delete Server",
- isPresented: self.$showDeleteConfirmation,
- presenting: self.serverToDelete
- ) { server in
- Button("Cancel", role: .cancel) {}
- Button("Delete", role: .destructive) {
- Task {
- await self.viewModel.deleteMCPServer(name: server.name, source: server.source)
- }
- }
- } message: { server in
- Text("Are you sure you want to delete '\(server.name)'? This action cannot be undone.")
- }
- .sheet(isPresented: self.$showCopySheet, onDismiss: {
- Task {
- await self.viewModel.loadConfiguration()
- }
- }) {
- if let copyVM = copyViewModel {
- MCPCopySheet(viewModel: copyVM)
- .task {
- // Load projects for destination picker
- let config = try? await ConfigFileManager.shared.readGlobalConfig()
- let projects = config?.allProjects ?? []
- copyVM.loadDestinations(projects: projects)
- }
- }
- }
- .sheet(isPresented: self.$showExportSheet) {
- if let exportVM = exportViewModel {
- ConfigExportView(viewModel: exportVM)
- }
- }
- .sheet(isPresented: self.$showImportSheet, onDismiss: {
- Task {
- await self.viewModel.loadConfiguration()
- }
- }) {
- if let importVM = importViewModel {
- ConfigImportView(viewModel: importVM)
- }
- }
- .promoteToGlobalAlert(
- isPresented: self.$showPromoteConfirmation,
- ruleToPromote: self.$ruleToPromote,
- projectURL: self.viewModel.projectURL,
- onComplete: { await self.viewModel.loadConfiguration() }
- )
- .sheet(item: self.$pasteViewModel, onDismiss: {
- Task {
- await self.viewModel.loadConfiguration()
- }
- }) { pasteVM in
- MCPPasteSheet(viewModel: pasteVM)
- .task {
- let config = try? await ConfigFileManager.shared.readGlobalConfig()
- let projects = config?.allProjects ?? []
- pasteVM.loadDestinations(projects: projects)
- }
- }
- .alert(
- "Sensitive Data Warning",
- isPresented: self.$showSensitiveCopyAlert,
- presenting: self.pendingCopyServers
- ) { servers in
- Button("Cancel", role: .cancel) {
- self.pendingCopyServers = nil
- }
- Button("Copy with Placeholders") {
- self.copyServersToClipboard(servers, redact: true)
- }
- Button("Copy with Secrets") {
- self.copyServersToClipboard(servers, redact: false)
- }
- } message: { _ in
- Text(
- "The MCP configuration contains environment variables that may contain "
- + "secrets (API keys, tokens, etc.). Choose how to copy."
- )
- }
- .focusedSceneValue(\.pasteMCPServersAction) {
- self.showPasteServersSheet()
- }
- }
-
- // MARK: Private
-
- @State private var viewModel: ProjectDetailViewModel
- @State private var showMCPServerEditor = false
- @State private var mcpEditorViewModel: MCPServerEditorViewModel?
- @State private var showDeleteConfirmation = false
- @State private var serverToDelete: (name: String, source: ConfigSource)?
- @State private var showCopySheet = false
- @State private var copyViewModel: MCPCopyViewModel?
- @State private var showExportSheet = false
- @State private var exportViewModel: ConfigExportViewModel?
- @State private var showImportSheet = false
- @State private var importViewModel: ConfigImportViewModel?
- @State private var showPromoteConfirmation = false
- @State private var ruleToPromote: RulePromotionInfo?
- @State private var pasteViewModel: MCPPasteViewModel?
- @State private var showSensitiveCopyAlert = false
- @State private var pendingCopyServers: [String: MCPServer]?
-
- private var permissionsTabContent: some View {
- PermissionsTabView(
- allPermissions: self.viewModel.allPermissions,
- emptyMessage: "No permission rules configured for this project.",
- onPromoteToGlobal: { rule, type in
- self.ruleToPromote = RulePromotionInfo(rule: rule, type: type)
- self.showPromoteConfirmation = true
- },
- onCopyToScope: { rule, type, targetScope in
- Task {
- do {
- let added = try await PermissionRuleCopyService.shared.copyRule(
- rule: rule,
- type: type,
- to: targetScope,
- projectPath: self.viewModel.projectURL
- )
- if added {
- NotificationManager.shared.showSuccess(
- "Rule Copied",
- message: "Copied to \(targetScope.label)"
- )
- } else {
- NotificationManager.shared.showInfo(
- "Rule Already Exists",
- message: "This rule already exists in \(targetScope.label)"
- )
- }
- await self.viewModel.loadConfiguration()
- } catch {
- NotificationManager.shared.showError(error)
- }
- }
- }
- )
- }
-
- @ViewBuilder
- private func tabContent(for tab: ProjectDetailTab) -> some View {
- switch tab {
- case .permissions:
- self.permissionsTabContent
- case .environment:
- EnvironmentTabView(
- envVars: self.viewModel.allEnvironmentVariables,
- emptyMessage: "No environment variables configured for this project."
- )
- case .mcpServers:
- MCPServersTabView(
- servers: self.viewModel.allMCPServers,
- emptyMessage: "No MCP servers configured for this project.",
- projectPath: self.viewModel.projectURL,
- onAdd: {
- self.mcpEditorViewModel = MCPServerEditorViewModel.forAdding(
- projectPath: self.viewModel.projectURL,
- defaultScope: .project
- )
- self.showMCPServerEditor = true
- },
- onEdit: { name, server, source in
- let scope: MCPServerScope = source == .global ? .global : .project
- self.mcpEditorViewModel = MCPServerEditorViewModel.forEditing(
- name: name,
- server: server,
- scope: scope,
- projectPath: self.viewModel.projectURL
- )
- self.showMCPServerEditor = true
- },
- onDelete: { name, source in
- self.serverToDelete = (name, source)
- self.showDeleteConfirmation = true
- },
- onCopy: { name, server in
- let sourceDestination = CopyDestination.project(
- path: self.viewModel.projectPath,
- name: self.viewModel.projectName
- )
- self.copyViewModel = MCPCopyViewModel(
- serverName: name,
- server: server,
- sourceDestination: sourceDestination
- )
- self.showCopySheet = true
- },
- onCopyAll: {
- self.handleCopyAllServers()
- },
- onPasteServers: {
- self.showPasteServersSheet()
- }
- )
- case .hooks:
- HooksTabView(
- globalHooks: self.viewModel.globalSettings?.hooks,
- projectHooks: self.viewModel.projectSettings?.hooks,
- localHooks: self.viewModel.projectLocalSettings?.hooks
- )
- case .claudeMD:
- ClaudeMDView(projectPath: self.viewModel.projectPath)
- case .effectiveConfig:
- if let merged = viewModel.mergedSettings {
- EffectiveConfigView(
- mergedSettings: merged,
- envOverrides: self.viewModel.envOverrides
- )
- } else {
- ContentUnavailableView(
- "No Configuration",
- systemImage: "checkmark.rectangle.stack",
- description: Text("No merged configuration available.")
- )
- }
- case .healthCheck:
- ConfigHealthCheckView(viewModel: self.viewModel)
- case .advanced:
- AdvancedTabView(viewModel: self.viewModel)
- }
- }
-
- private func handleCopyAllServers() {
- let serverDict = Dictionary(
- uniqueKeysWithValues: viewModel.allMCPServers.map { ($0.name, $0.server) }
- )
-
- guard !serverDict.isEmpty else {
- NotificationManager.shared.showInfo(
- "No servers to copy",
- message: "No MCP servers configured for this project."
- )
- return
- }
-
- Task {
- let hasSensitive = await MCPSharingService.shared.containsSensitiveData(
- servers: serverDict
- )
-
- if hasSensitive {
- self.pendingCopyServers = serverDict
- self.showSensitiveCopyAlert = true
- } else {
- self.copyServersToClipboard(serverDict, redact: false)
- }
- }
- }
-
- private func copyServersToClipboard(_ servers: [String: MCPServer], redact: Bool) {
- Task {
- do {
- try MCPSharingService.shared.writeToClipboard(
- servers: servers,
- redactSensitive: redact
- )
- let message = redact
- ? "\(servers.count) server(s) copied with placeholders"
- : "\(servers.count) server(s) copied to clipboard"
- NotificationManager.shared.showSuccess("Copied to clipboard", message: message)
- } catch {
- NotificationManager.shared.showError(
- "Copy failed",
- message: error.localizedDescription
- )
- }
- self.pendingCopyServers = nil
- }
- }
-
- private func showPasteServersSheet() {
- let currentProject = CopyDestination.project(
- path: self.viewModel.projectPath,
- name: self.viewModel.projectName
- )
- self.pasteViewModel = MCPPasteViewModel(currentProject: currentProject)
- }
-
- private func exportMCPJSON() {
- let projectServers = self.viewModel.allMCPServers
- .filter { $0.source != .global }
- let serverDict = Dictionary(
- uniqueKeysWithValues: projectServers.map { ($0.name, $0.server) }
- )
-
- guard !serverDict.isEmpty else {
- NotificationManager.shared.showInfo(
- "No servers to export",
- message: "This project has no project-level MCP servers to export."
- )
- return
- }
-
- Task {
- do {
- let config = MCPConfig(mcpServers: serverDict)
- try await ConfigFileManager.shared.writeMCPConfig(
- config,
- for: self.viewModel.projectURL
- )
- NotificationManager.shared.showSuccess(
- "Exported .mcp.json",
- message: "MCP configuration written to project root"
- )
- await self.viewModel.loadConfiguration()
- } catch {
- NotificationManager.shared.showError(
- "Export failed",
- message: error.localizedDescription
- )
- }
- }
- }
-}
-
-// MARK: - ProjectHeaderView
-
-/// Header view showing project metadata.
-struct ProjectHeaderView: View {
- // MARK: Internal
-
- @Bindable var viewModel: ProjectDetailViewModel
-
- var onExport: (() -> Void)?
- var onImport: (() -> Void)?
- var onImportFromJSON: (() -> Void)?
- var onExportMCPJSON: (() -> Void)?
-
- var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack {
- // Project icon
- Image(systemName: self.viewModel.projectExists ? "folder.fill" : "folder.badge.questionmark")
- .font(.title)
- .foregroundStyle(self.viewModel.projectExists ? .blue : .orange)
-
- VStack(alignment: .leading, spacing: 2) {
- Text(self.viewModel.projectName)
- .font(.title2)
- .fontWeight(.semibold)
-
- Button {
- self.viewModel.revealInFinder()
- } label: {
- Text(self.abbreviatePath(self.viewModel.projectPath))
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- .disabled(!self.viewModel.projectExists)
- .onHover { isHovered in
- if isHovered, self.viewModel.projectExists {
- NSCursor.pointingHand.push()
- } else {
- NSCursor.pop()
- }
- }
- }
-
- Spacer()
-
- // Action buttons
- HStack(spacing: 8) {
- Button {
- self.viewModel.revealInFinder()
- } label: {
- Label("Reveal", systemImage: "folder")
- }
- .disabled(!self.viewModel.projectExists)
- .accessibilityLabel("Reveal in Finder")
- .accessibilityHint("Opens the project directory in Finder")
-
- Button {
- self.viewModel.openInTerminal()
- } label: {
- Label("Terminal", systemImage: "terminal")
- }
- .disabled(!self.viewModel.projectExists)
- .accessibilityLabel("Open in Terminal")
- .accessibilityHint("Opens a Terminal window at the project directory")
-
- // Export/Import menu
- Menu {
- Button {
- self.onExport?()
- } label: {
- Label("Export Configuration...", systemImage: "square.and.arrow.up")
- }
-
- Button {
- self.onImport?()
- } label: {
- Label("Import Configuration...", systemImage: "square.and.arrow.down")
- }
-
- Divider()
-
- Button {
- self.onImportFromJSON?()
- } label: {
- Label("Import MCP Servers from JSON...", systemImage: "doc.on.clipboard")
- }
-
- Button {
- self.onExportMCPJSON?()
- } label: {
- Label("Export .mcp.json...", systemImage: "doc.badge.arrow.up")
- }
- } label: {
- Label("More", systemImage: "ellipsis.circle")
- }
- .disabled(!self.viewModel.projectExists)
- }
- }
-
- // Config file status badges
- HStack(spacing: 8) {
- if let status = viewModel.projectSettingsStatus {
- ConfigFileBadge(
- label: "settings.json",
- status: status,
- source: .projectShared
- )
- }
- if let status = viewModel.projectLocalSettingsStatus {
- ConfigFileBadge(
- label: "settings.local.json",
- status: status,
- source: .projectLocal
- )
- }
- if let status = viewModel.mcpConfigStatus {
- ConfigFileBadge(
- label: ".mcp.json",
- status: status,
- source: .projectShared
- )
- }
- }
- }
- .padding()
- }
-
- // MARK: Private
-
- private func abbreviatePath(_ path: String) -> String {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- if path.hasPrefix(home) {
- return "~" + path.dropFirst(home.count)
- }
- return path
- }
-}
-
-// MARK: - ConfigFileBadge
-
-/// Badge showing config file status with source color.
-struct ConfigFileBadge: View {
- // MARK: Internal
-
- let label: String
- let status: ConfigFileStatus
- let source: ConfigSource
-
- var body: some View {
- HStack(spacing: 4) {
- Image(systemName: self.status.exists ? "checkmark.circle.fill" : "plus.circle.dashed")
- .foregroundStyle(self.status.exists ? .green : .secondary)
- .font(.caption)
- Text(self.label)
- .font(.caption)
- Image(systemName: self.source.icon)
- .foregroundStyle(self.sourceColor)
- .font(.caption2)
- }
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
- .help(self.status.exists ? "File exists" : "File not created yet")
- .accessibilityElement(children: .combine)
- .accessibilityLabel("\(self.label), \(self.status.exists ? "file exists" : "file not created yet")")
- }
-
- // MARK: Private
-
- private var sourceColor: Color {
- switch self.source {
- case .global:
- .blue
- case .projectShared:
- .purple
- case .projectLocal:
- .orange
- }
- }
-}
-
-// MARK: - AdvancedTabView
-
-/// Advanced settings tab for a project.
-struct AdvancedTabView: View {
- @Bindable var viewModel: ProjectDetailViewModel
-
- var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 20) {
- // Project entry info
- if let entry = viewModel.projectEntry {
- GroupBox("Project Status") {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- Text("Trust Dialog Accepted")
- Spacer()
- Image(systemName: entry.hasTrustDialogAccepted == true
- ? "checkmark.circle.fill" : "xmark.circle")
- .foregroundStyle(entry.hasTrustDialogAccepted == true ? .green : .secondary)
- }
-
- if let tools = entry.allowedTools, !tools.isEmpty {
- Divider()
- Text("Allowed Tools")
- .font(.headline)
- FlowLayout(spacing: 4) {
- ForEach(tools, id: \.self) { tool in
- Text(tool)
- .font(.caption)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(.green.opacity(0.2), in: RoundedRectangle(cornerRadius: 4))
- }
- }
- }
-
- if let history = entry.history, !history.isEmpty {
- Divider()
- HStack {
- Text("Conversation History")
- Spacer()
- Text("\(history.count) conversations")
- .foregroundStyle(.secondary)
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
-
- // Attribution settings
- GroupBox("Attribution") {
- if let (attribution, source) = viewModel.attributionSettings {
- VStack(alignment: .leading, spacing: 8) {
- HStack {
- SourceBadge(source: source)
- Spacer()
- }
- AttributionRow(
- label: "Commit Attribution",
- enabled: attribution.commits ?? false
- )
- AttributionRow(
- label: "Pull Request Attribution",
- enabled: attribution.pullRequests ?? false
- )
- }
- .padding(.vertical, 4)
- } else {
- Text("Using default attribution settings.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- }
- }
-
- // Disallowed tools
- GroupBox("Disallowed Tools") {
- let tools = self.viewModel.allDisallowedTools
- if tools.isEmpty {
- Text("No tools are disallowed for this project.")
- .foregroundStyle(.secondary)
- .padding(.vertical, 8)
- } else {
- VStack(alignment: .leading, spacing: 4) {
- ForEach(Array(tools.enumerated()), id: \.offset) { _, item in
- HStack {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.red)
- Text(item.tool)
- .font(.system(.body, design: .monospaced))
- Spacer()
- SourceBadge(source: item.source)
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
-
- Spacer()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- }
-}
-
-// MARK: - FlowLayout
-
-/// A simple flow layout for wrapping items.
-struct FlowLayout: Layout {
- // MARK: Internal
-
- var spacing: CGFloat = 8
-
- func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
- let result = self.layout(proposal: proposal, subviews: subviews)
- return result.size
- }
-
- func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) {
- let result = self.layout(proposal: proposal, subviews: subviews)
- for (index, position) in result.positions.enumerated() {
- subviews[index].place(
- at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
- proposal: .unspecified
- )
- }
- }
-
- // MARK: Private
-
- private func layout(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
- let maxWidth = proposal.width ?? .infinity
- var positions: [CGPoint] = []
- var currentX: CGFloat = 0
- var currentY: CGFloat = 0
- var lineHeight: CGFloat = 0
- var totalHeight: CGFloat = 0
-
- for subview in subviews {
- let size = subview.sizeThatFits(.unspecified)
-
- if currentX + size.width > maxWidth, currentX > 0 {
- currentX = 0
- currentY += lineHeight + self.spacing
- lineHeight = 0
- }
-
- positions.append(CGPoint(x: currentX, y: currentY))
- lineHeight = max(lineHeight, size.height)
- currentX += size.width + self.spacing
- totalHeight = currentY + lineHeight
- }
-
- return (CGSize(width: maxWidth, height: totalHeight), positions)
- }
-}
-
-#Preview {
- ProjectDetailView(projectPath: "/Users/test/project")
- .frame(width: 700, height: 500)
-}
diff --git a/Fig/Sources/Views/ProjectSettingsEditorView.swift b/Fig/Sources/Views/ProjectSettingsEditorView.swift
deleted file mode 100644
index d498c18..0000000
--- a/Fig/Sources/Views/ProjectSettingsEditorView.swift
+++ /dev/null
@@ -1,235 +0,0 @@
-import SwiftUI
-
-// MARK: - ProjectSettingsEditorView
-
-/// Full-featured settings editor view for a project with editing, saving, undo/redo, and conflict handling.
-struct ProjectSettingsEditorView: View {
- // MARK: Lifecycle
-
- init(projectPath: String) {
- _viewModel = State(initialValue: SettingsEditorViewModel(projectPath: projectPath))
- }
-
- // MARK: Internal
-
- var body: some View {
- VStack(spacing: 0) {
- // Header with save button and dirty indicator
- self.editorHeader
-
- Divider()
-
- // Tab content
- if self.viewModel.isLoading {
- ProgressView("Loading configuration...")
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- TabView(selection: self.$selectedTab) {
- PermissionRuleEditorView(
- viewModel: self.viewModel,
- onPromoteToGlobal: { rule, type in
- self.ruleToPromote = RulePromotionInfo(rule: rule, type: type)
- self.showPromoteConfirmation = true
- }
- )
- .tabItem {
- Label("Permissions", systemImage: "lock.shield")
- }
- .tag(EditorTab.permissions)
-
- EnvironmentVariableEditorView(viewModel: self.viewModel)
- .tabItem {
- Label("Environment", systemImage: "list.bullet.rectangle")
- }
- .tag(EditorTab.environment)
-
- HookEditorView(viewModel: self.viewModel)
- .tabItem {
- Label("Hooks", systemImage: "bolt.horizontal")
- }
- .tag(EditorTab.hooks)
-
- AttributionSettingsEditorView(viewModel: self.viewModel)
- .tabItem {
- Label("General", systemImage: "gearshape")
- }
- .tag(EditorTab.general)
- }
- .padding()
- }
- }
- .frame(minWidth: 600, minHeight: 500)
- .interactiveDismissDisabled(self.viewModel.isDirty)
- .navigationTitle(self.windowTitle)
- .task {
- await self.viewModel.loadSettings()
- }
- .onDisappear {
- if self.viewModel.isDirty {
- // The confirmation dialog should have been shown before navigation
- Log.general.warning("Editor closed with unsaved changes")
- }
- }
- .confirmationDialog(
- "Unsaved Changes",
- isPresented: self.$showingCloseConfirmation,
- titleVisibility: .visible
- ) {
- Button("Save and Close") {
- Task {
- do {
- try await self.viewModel.save()
- self.closeAction?()
- } catch {
- NotificationManager.shared.showError(error)
- }
- }
- }
- Button("Discard Changes", role: .destructive) {
- self.closeAction?()
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("You have unsaved changes. Would you like to save them before closing?")
- }
- .sheet(isPresented: self.$showingConflictSheet) {
- if let url = viewModel.externalChangeURL {
- ConflictResolutionSheet(fileName: url.lastPathComponent) { resolution in
- Task {
- await self.viewModel.resolveConflict(resolution)
- }
- self.showingConflictSheet = false
- }
- }
- }
- .onChange(of: self.viewModel.hasExternalChanges) { _, hasChanges in
- if hasChanges {
- self.showingConflictSheet = true
- }
- }
- .onAppear {
- self.viewModel.undoManager = self.undoManager
- }
- .promoteToGlobalAlert(
- isPresented: self.$showPromoteConfirmation,
- ruleToPromote: self.$ruleToPromote,
- projectURL: self.viewModel.projectURL
- )
- }
-
- // MARK: Private
-
- private enum EditorTab: String, CaseIterable, Identifiable {
- case permissions
- case environment
- case hooks
- case general
-
- // MARK: Internal
-
- var id: String {
- rawValue
- }
- }
-
- @State private var viewModel: SettingsEditorViewModel
- @State private var selectedTab: EditorTab = .permissions
- @State private var showingCloseConfirmation = false
- @State private var showingConflictSheet = false
- @State private var closeAction: (() -> Void)?
- @State private var showPromoteConfirmation = false
- @State private var ruleToPromote: RulePromotionInfo?
-
- @Environment(\.undoManager)
- private var undoManager
-
- private var windowTitle: String {
- var title = self.viewModel.displayName
- if self.viewModel.isDirty {
- title += " \u{2022}" // bullet point
- }
- return title
- }
-
- private var editorHeader: some View {
- HStack {
- // Project info
- HStack(spacing: 8) {
- Image(systemName: "folder.fill")
- .font(.title2)
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- Text(self.viewModel.displayName)
- .font(.title3)
- .fontWeight(.semibold)
-
- DirtyStateIndicator(isDirty: self.viewModel.isDirty)
- }
-
- Text(self.abbreviatePath(self.viewModel.projectPath ?? ""))
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- Spacer()
-
- // Undo/Redo buttons
- HStack(spacing: 4) {
- Button {
- self.undoManager?.undo()
- } label: {
- Image(systemName: "arrow.uturn.backward")
- }
- .disabled(!self.viewModel.canUndo)
- .keyboardShortcut("z", modifiers: .command)
- .help("Undo")
-
- Button {
- self.undoManager?.redo()
- } label: {
- Image(systemName: "arrow.uturn.forward")
- }
- .disabled(!self.viewModel.canRedo)
- .keyboardShortcut("z", modifiers: [.command, .shift])
- .help("Redo")
- }
-
- Divider()
- .frame(height: 20)
- .padding(.horizontal, 8)
-
- // Save button
- SaveButton(
- isDirty: self.viewModel.isDirty,
- isSaving: self.viewModel.isSaving
- ) {
- Task {
- do {
- try await self.viewModel.save()
- NotificationManager.shared.showSuccess("Settings Saved")
- } catch {
- NotificationManager.shared.showError(error)
- }
- }
- }
- }
- .padding()
- }
-
- private func abbreviatePath(_ path: String) -> String {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- if path.hasPrefix(home) {
- return "~" + path.dropFirst(home.count)
- }
- return path
- }
-}
-
-// MARK: - Preview
-
-#Preview("Settings Editor") {
- ProjectSettingsEditorView(projectPath: "/Users/test/project")
-}
diff --git a/Fig/Sources/Views/SettingsEditorViews.swift b/Fig/Sources/Views/SettingsEditorViews.swift
deleted file mode 100644
index 36d0c8b..0000000
--- a/Fig/Sources/Views/SettingsEditorViews.swift
+++ /dev/null
@@ -1,162 +0,0 @@
-import SwiftUI
-
-// MARK: - EditingTargetPicker
-
-/// Picker for selecting the editing target file.
-struct EditingTargetPicker: View {
- @Binding var selection: EditingTarget
-
- var targets: [EditingTarget] = EditingTarget.projectTargets
-
- var body: some View {
- Picker("Save to:", selection: self.$selection) {
- ForEach(self.targets) { target in
- HStack {
- Image(systemName: target.source.icon)
- Text(target.label)
- }
- .tag(target)
- }
- }
- .pickerStyle(.segmented)
- .frame(maxWidth: 350)
- .help(self.selection.description)
- }
-}
-
-// MARK: - ConflictResolutionSheet
-
-/// Sheet for resolving external file change conflicts.
-struct ConflictResolutionSheet: View {
- let fileName: String
- let onResolve: (ConflictResolution) -> Void
-
- var body: some View {
- VStack(spacing: 16) {
- // Header
- HStack {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- .font(.title)
- Text("External Changes Detected")
- .font(.title2)
- .fontWeight(.semibold)
- }
-
- Text("The file \(self.fileName) was modified externally while you have unsaved changes.")
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
-
- Divider()
-
- VStack(spacing: 12) {
- Button {
- self.onResolve(.keepLocal)
- } label: {
- HStack {
- Image(systemName: "pencil.circle.fill")
- .foregroundStyle(.blue)
- VStack(alignment: .leading) {
- Text("Keep My Changes")
- .fontWeight(.medium)
- Text("Discard external changes and keep your edits")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- }
- .padding()
- .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
- }
- .buttonStyle(.plain)
-
- Button {
- self.onResolve(.useExternal)
- } label: {
- HStack {
- Image(systemName: "arrow.down.circle.fill")
- .foregroundStyle(.green)
- VStack(alignment: .leading) {
- Text("Use External Version")
- .fontWeight(.medium)
- Text("Discard your edits and reload the external changes")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Spacer()
- }
- .padding()
- .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
- }
- .buttonStyle(.plain)
- }
-
- Spacer()
- }
- .padding()
- .frame(width: 400, height: 300)
- }
-}
-
-// MARK: - DirtyStateIndicator
-
-/// Indicator shown when there are unsaved changes.
-struct DirtyStateIndicator: View {
- let isDirty: Bool
-
- var body: some View {
- if self.isDirty {
- Circle()
- .fill(.orange)
- .frame(width: 8, height: 8)
- .help("Unsaved changes")
- .accessibilityLabel("Unsaved changes")
- }
- }
-}
-
-// MARK: - SaveButton
-
-/// Save button that shows state based on dirty flag.
-struct SaveButton: View {
- let isDirty: Bool
- let isSaving: Bool
- let action: () -> Void
-
- var body: some View {
- Button(action: self.action) {
- if self.isSaving {
- ProgressView()
- .controlSize(.small)
- } else {
- Label("Save", systemImage: "square.and.arrow.down")
- }
- }
- .disabled(!self.isDirty || self.isSaving)
- .keyboardShortcut("s", modifiers: .command)
- }
-}
-
-// MARK: - EditSettingsButton
-
-/// Button to open the settings editor.
-struct EditSettingsButton: View {
- // MARK: Internal
-
- let projectPath: String
-
- var body: some View {
- Button {
- self.showingEditor = true
- } label: {
- Label("Edit Settings", systemImage: "pencil")
- }
- .sheet(isPresented: self.$showingEditor) {
- ProjectSettingsEditorView(projectPath: self.projectPath)
- }
- }
-
- // MARK: Private
-
- @State private var showingEditor = false
-}
diff --git a/Fig/Sources/Views/SidebarView.swift b/Fig/Sources/Views/SidebarView.swift
deleted file mode 100644
index 0f13e5b..0000000
--- a/Fig/Sources/Views/SidebarView.swift
+++ /dev/null
@@ -1,766 +0,0 @@
-import SwiftUI
-
-// MARK: - SidebarView
-
-/// The sidebar view displaying navigation items and projects.
-struct SidebarView: View {
- // MARK: Internal
-
- @Binding var selection: NavigationSelection?
- @Bindable var viewModel: ProjectExplorerViewModel
-
- var body: some View {
- List(selection: self.$selection) {
- // Global Settings Section
- Section("Configuration") {
- Label("Global Settings", systemImage: "globe")
- .tag(NavigationSelection.globalSettings)
- }
-
- // Favorites Section
- if !self.viewModel.favoriteProjects.isEmpty {
- Section("Favorites") {
- ForEach(self.viewModel.favoriteProjects) { project in
- self.projectRow(for: project, isFavoriteSection: true)
- }
- }
- }
-
- // Recents Section
- if !self.viewModel.recentProjects.isEmpty {
- Section("Recent") {
- ForEach(self.viewModel.recentProjects) { project in
- self.projectRow(for: project, isFavoriteSection: false)
- }
- }
- }
-
- // All Projects Section
- Section {
- if self.viewModel.isLoading {
- HStack {
- ProgressView()
- .controlSize(.small)
- Text("Loading projects...")
- .foregroundStyle(.secondary)
- }
- } else if self.viewModel.filteredProjects.isEmpty, self.viewModel.favoriteProjects.isEmpty,
- self.viewModel.recentProjects.isEmpty
- {
- if self.viewModel.searchQuery.isEmpty {
- ContentUnavailableView(
- "No Projects Found",
- systemImage: "folder.badge.questionmark",
- description: Text(
- "Claude Code projects will appear here once you use Claude Code in a directory."
- )
- )
- } else {
- ContentUnavailableView.search(text: self.viewModel.searchQuery)
- }
- } else if self.viewModel.filteredProjects.isEmpty, !self.viewModel.searchQuery.isEmpty {
- ContentUnavailableView.search(text: self.viewModel.searchQuery)
- } else if self.viewModel.isGroupedByParent {
- ForEach(self.viewModel.groupedProjects) { group in
- DisclosureGroup {
- ForEach(group.projects) { project in
- self.projectRow(for: project, isFavoriteSection: false)
- }
- } label: {
- HStack(spacing: 6) {
- Image(systemName: "folder")
- .foregroundStyle(.secondary)
- .font(.caption)
- Text(group.displayName)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- Spacer()
- Text("\(group.projects.count)")
- .font(.caption2)
- .foregroundStyle(.tertiary)
- .padding(.horizontal, 5)
- .padding(.vertical, 1)
- .background(.quaternary, in: Capsule())
- }
- }
- }
- } else {
- ForEach(self.viewModel.filteredProjects) { project in
- self.projectRow(for: project, isFavoriteSection: false)
- }
- }
- } header: {
- HStack {
- Text("Projects")
- Spacer()
- if !self.viewModel.projects.isEmpty {
- Text("\(self.viewModel.filteredProjects.count)")
- .font(.caption)
- .foregroundStyle(.secondary)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(.quaternary, in: Capsule())
- }
- }
- }
- }
- .listStyle(.sidebar)
- .searchable(
- text: self.$viewModel.searchQuery,
- placement: .sidebar,
- prompt: "Filter projects"
- )
- .navigationTitle("Fig")
- .frame(minWidth: 220)
- .toolbar {
- ToolbarItem(placement: .primaryAction) {
- if !self.viewModel.projects.isEmpty {
- Button(self.isSelectMode ? "Done" : "Select") {
- self.toggleSelectMode()
- }
- }
- }
-
- ToolbarItem(placement: .automatic) {
- if !self.viewModel.projects.isEmpty, !self.isSelectMode {
- Menu {
- Toggle(isOn: Binding(
- get: { self.viewModel.isGroupedByParent },
- set: { newValue in
- withAnimation {
- self.viewModel.isGroupedByParent = newValue
- }
- }
- )) {
- Label("Group by Directory", systemImage: "list.bullet.indent")
- }
-
- Divider()
-
- Button(role: .destructive) {
- self.showRemoveMissingConfirmation = true
- } label: {
- Label(
- "Remove Missing Projects\(self.viewModel.hasMissingProjects ? " (\(self.viewModel.missingProjects.count))" : "")",
- systemImage: "trash.slash"
- )
- }
- .disabled(!self.viewModel.hasMissingProjects)
- } label: {
- Image(systemName: "ellipsis.circle")
- }
- }
- }
- }
- .safeAreaInset(edge: .bottom) {
- if self.isSelectMode {
- VStack(spacing: 0) {
- Divider()
- HStack {
- Button {
- if self.allProjectsSelected {
- self.selectedProjectPaths.removeAll()
- } else {
- self.selectAllProjects()
- }
- } label: {
- Text(self.allProjectsSelected ? "Deselect All" : "Select All")
- }
- .buttonStyle(.borderless)
-
- if self.viewModel.hasMissingProjects {
- Button {
- self.selectMissingProjects()
- } label: {
- Text("Select Missing")
- }
- .buttonStyle(.borderless)
- }
-
- Spacer()
-
- if !self.selectedProjectPaths.isEmpty {
- Text("\(self.selectedProjectPaths.count) selected")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- Button("Remove") {
- self.showBulkDeleteConfirmation = true
- }
- .buttonStyle(.borderedProminent)
- .tint(.red)
- .disabled(self.selectedProjectPaths.isEmpty)
- }
- .padding(.horizontal)
- .padding(.vertical, 8)
- .background(.bar)
- }
- }
- }
- .task {
- await self.viewModel.loadProjects()
- }
- .onChange(of: self.selection) { _, newValue in
- // Record as recent when selecting a project
- if case let .project(path) = newValue {
- if let project = viewModel.projects.first(where: { $0.path == path }) {
- self.viewModel.recordRecentProject(project)
- }
- }
- }
- .sheet(isPresented: self.$viewModel.isQuickSwitcherPresented) {
- QuickSwitcherView(viewModel: self.viewModel, selection: self.$selection)
- }
- .keyboardShortcut("k", modifiers: .command)
- .alert(
- "Remove Project",
- isPresented: self.$showDeleteConfirmation,
- presenting: self.projectToDelete
- ) { project in
- Button("Cancel", role: .cancel) {}
- Button("Remove", role: .destructive) {
- Task {
- if case let .project(path) = self.selection, path == project.path {
- self.selection = nil
- }
- if let path = project.path {
- self.selectedProjectPaths.remove(path)
- }
- await self.viewModel.deleteProject(project)
- }
- }
- } message: { project in
- let name = project.name ?? "this project"
- Text(
- "Are you sure you want to remove '\(name)' from your configuration?" +
- " The project directory will not be affected."
- )
- }
- .alert(
- "Remove \(self.selectedProjectPaths.count) Project\(self.selectedProjectPaths.count == 1 ? "" : "s")",
- isPresented: self.$showBulkDeleteConfirmation
- ) {
- Button("Cancel", role: .cancel) {}
- Button("Remove \(self.selectedProjectPaths.count)", role: .destructive) {
- Task {
- if case let .project(path) = self.selection,
- self.selectedProjectPaths.contains(path)
- {
- self.selection = nil
- }
- let projectsToDelete = self.viewModel.projects.filter { project in
- guard let path = project.path else {
- return false
- }
- return self.selectedProjectPaths.contains(path)
- }
- await self.viewModel.deleteProjects(projectsToDelete)
- self.selectedProjectPaths.removeAll()
- self.isSelectMode = false
- }
- }
- } message: {
- let count = self.selectedProjectPaths.count
- Text(
- "Are you sure you want to remove \(count) project\(count == 1 ? "" : "s") from your configuration?" +
- " The project directories will not be affected."
- )
- }
- .alert(
- "Remove Missing Projects",
- isPresented: self.$showRemoveMissingConfirmation
- ) {
- Button("Cancel", role: .cancel) {}
- Button("Remove \(self.viewModel.missingProjects.count)", role: .destructive) {
- Task {
- let missingPaths = Set(self.viewModel.missingProjects.compactMap(\.path))
- if case let .project(path) = self.selection, missingPaths.contains(path) {
- self.selection = nil
- }
- await self.viewModel.removeMissingProjects()
- }
- }
- } message: {
- let count = self.viewModel.missingProjects.count
- Text(
- "\(count) project\(count == 1 ? "" : "s") with missing " +
- "directories will be removed from your configuration."
- )
- }
- }
-
- // MARK: Private
-
- @State private var showDeleteConfirmation = false
- @State private var projectToDelete: ProjectEntry?
- @State private var isSelectMode = false
- @State private var selectedProjectPaths: Set = []
- @State private var showBulkDeleteConfirmation = false
- @State private var showRemoveMissingConfirmation = false
-
- private var visibleProjectPaths: Set {
- let favorites = self.viewModel.favoriteProjects.compactMap(\.path)
- let recents = self.viewModel.recentProjects.compactMap(\.path)
- let filtered = self.viewModel.filteredProjects.compactMap(\.path)
- return Set(favorites + recents + filtered)
- }
-
- private var allProjectsSelected: Bool {
- let visible = self.visibleProjectPaths
- return !visible.isEmpty && visible.isSubset(of: self.selectedProjectPaths)
- }
-
- @ViewBuilder
- private func projectRow(for project: ProjectEntry, isFavoriteSection: Bool) -> some View {
- let content = HStack(spacing: 6) {
- if self.isSelectMode {
- Image(
- systemName: self.isProjectSelected(project)
- ? "checkmark.circle.fill" : "circle"
- )
- .foregroundStyle(
- self.isProjectSelected(project) ? Color.accentColor : .secondary
- )
- }
-
- ProjectRowView(
- project: project,
- exists: self.viewModel.projectExists(project),
- mcpCount: self.viewModel.mcpServerCount(for: project),
- isFavorite: self.viewModel.isFavorite(project)
- )
- }
- .contentShape(Rectangle())
-
- if self.isSelectMode {
- content.onTapGesture {
- self.toggleProjectSelection(project)
- }
- } else {
- content
- .tag(NavigationSelection.project(project.path ?? ""))
- .contextMenu {
- Button {
- self.viewModel.toggleFavorite(project)
- } label: {
- if self.viewModel.isFavorite(project) {
- Label("Remove from Favorites", systemImage: "star.slash")
- } else {
- Label("Add to Favorites", systemImage: "star")
- }
- }
-
- Divider()
-
- Button {
- self.viewModel.revealInFinder(project)
- } label: {
- Label("Reveal in Finder", systemImage: "folder")
- }
- .disabled(!self.viewModel.projectExists(project))
-
- Button {
- self.viewModel.openInTerminal(project)
- } label: {
- Label("Open in Terminal", systemImage: "terminal")
- }
- .disabled(!self.viewModel.projectExists(project))
-
- Divider()
-
- Button(role: .destructive) {
- self.projectToDelete = project
- self.showDeleteConfirmation = true
- } label: {
- Label("Remove Project", systemImage: "trash")
- }
- }
- }
- }
-
- private func toggleSelectMode() {
- withAnimation {
- self.isSelectMode.toggle()
- if !self.isSelectMode {
- self.selectedProjectPaths.removeAll()
- }
- }
- }
-
- private func toggleProjectSelection(_ project: ProjectEntry) {
- guard let path = project.path else {
- return
- }
- if self.selectedProjectPaths.contains(path) {
- self.selectedProjectPaths.remove(path)
- } else {
- self.selectedProjectPaths.insert(path)
- }
- }
-
- private func isProjectSelected(_ project: ProjectEntry) -> Bool {
- guard let path = project.path else {
- return false
- }
- return self.selectedProjectPaths.contains(path)
- }
-
- private func selectAllProjects() {
- self.selectedProjectPaths = self.visibleProjectPaths
- }
-
- private func selectMissingProjects() {
- let missingPaths = Set(self.viewModel.missingProjects.compactMap(\.path))
- self.selectedProjectPaths = missingPaths.intersection(self.visibleProjectPaths)
- }
-}
-
-// MARK: - ProjectRowView
-
-/// A row in the sidebar representing a single project.
-struct ProjectRowView: View {
- // MARK: Internal
-
- let project: ProjectEntry
- let exists: Bool
- let mcpCount: Int
- var isFavorite: Bool = false
-
- var body: some View {
- HStack(spacing: 8) {
- // Project icon with status
- Image(systemName: self.exists ? "folder.fill" : "folder.badge.questionmark")
- .foregroundStyle(self.exists ? .blue : .orange)
- .frame(width: 20)
-
- // Project info
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- Text(self.project.name ?? "Unknown")
- .font(.body)
- .foregroundStyle(self.exists ? .primary : .secondary)
- .lineLimit(1)
-
- if self.isFavorite {
- Image(systemName: "star.fill")
- .foregroundStyle(.yellow)
- .font(.caption2)
- }
- }
-
- if let path = project.path {
- Text(self.abbreviatePath(path))
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- }
- }
-
- Spacer()
-
- // MCP server badge
- if self.mcpCount > 0 {
- Text("\(self.mcpCount)")
- .font(.caption2)
- .fontWeight(.medium)
- .foregroundStyle(.white)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(.purple, in: Capsule())
- .help("\(self.mcpCount) MCP server\(self.mcpCount == 1 ? "" : "s") configured")
- }
-
- // Missing indicator
- if !self.exists {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(.orange)
- .font(.caption)
- .help("Project directory not found")
- }
- }
- .padding(.vertical, 2)
- .accessibilityElement(children: .combine)
- .accessibilityLabel(self.accessibilityDescription)
- }
-
- // MARK: Private
-
- private var accessibilityDescription: String {
- var parts: [String] = []
- parts.append(self.project.name ?? "Unknown project")
- if !self.exists {
- parts.append("directory not found")
- }
- if self.isFavorite {
- parts.append("favorite")
- }
- if self.mcpCount > 0 {
- parts.append("\(self.mcpCount) MCP server\(self.mcpCount == 1 ? "" : "s")")
- }
- return parts.joined(separator: ", ")
- }
-
- /// Abbreviates a path by replacing the home directory with ~.
- private func abbreviatePath(_ path: String) -> String {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- if path.hasPrefix(home) {
- return "~" + path.dropFirst(home.count)
- }
- return path
- }
-}
-
-// MARK: - QuickSwitcherView
-
-/// A quick switcher sheet for rapidly navigating to projects.
-struct QuickSwitcherView: View {
- // MARK: Internal
-
- @Bindable var viewModel: ProjectExplorerViewModel
- @Binding var selection: NavigationSelection?
-
- var body: some View {
- VStack(spacing: 0) {
- // Search field
- HStack {
- Image(systemName: "magnifyingglass")
- .foregroundStyle(.secondary)
-
- TextField("Search projects...", text: self.$searchText)
- .textFieldStyle(.plain)
- .font(.title3)
- .onSubmit {
- self.selectFirstResult()
- }
-
- if !self.searchText.isEmpty {
- Button {
- self.searchText = ""
- } label: {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- }
- }
- .padding()
-
- Divider()
-
- // Results list
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 0) {
- ForEach(Array(self.filteredResults.enumerated()), id: \.element.id) { index, project in
- QuickSwitcherRow(
- project: project,
- isSelected: index == self.selectedIndex,
- isFavorite: self.viewModel.isFavorite(project)
- )
- .onTapGesture {
- self.selectProject(project)
- }
- }
-
- if self.filteredResults.isEmpty {
- Text("No projects found")
- .foregroundStyle(.secondary)
- .padding()
- .frame(maxWidth: .infinity)
- }
- }
- }
- .frame(maxHeight: 300)
-
- Divider()
-
- // Keyboard hints
- HStack {
- KeyboardHint(key: "↑↓", label: "Navigate")
- KeyboardHint(key: "↵", label: "Open")
- KeyboardHint(key: "esc", label: "Close")
- }
- .padding(.horizontal)
- .padding(.vertical, 8)
- .background(.quaternary.opacity(0.5))
- }
- .frame(width: 500)
- .background(.regularMaterial)
- .clipShape(RoundedRectangle(cornerRadius: 12))
- .onAppear {
- self.searchText = ""
- self.selectedIndex = 0
- }
- .onKeyPress(.upArrow) {
- if self.selectedIndex > 0 {
- self.selectedIndex -= 1
- }
- return .handled
- }
- .onKeyPress(.downArrow) {
- if self.selectedIndex < self.filteredResults.count - 1 {
- self.selectedIndex += 1
- }
- return .handled
- }
- .onKeyPress(.return) {
- self.selectFirstResult()
- return .handled
- }
- .onKeyPress(.escape) {
- self.viewModel.isQuickSwitcherPresented = false
- return .handled
- }
- }
-
- // MARK: Private
-
- @Environment(\.dismiss) private var dismiss
- @State private var searchText = ""
- @State private var selectedIndex = 0
-
- private var filteredResults: [ProjectEntry] {
- if self.searchText.isEmpty {
- // Show favorites first, then recents, then all
- let favorites = self.viewModel.favoriteProjects
- let recents = self.viewModel.recentProjects
- let others = self.viewModel.projects.filter { project in
- guard let path = project.path else {
- return true
- }
- return !self.viewModel.favoritesStorage.favoriteProjectPaths.contains(path) &&
- !self.viewModel.favoritesStorage.recentProjectPaths.contains(path)
- }
- return favorites + recents + others
- }
- let query = self.searchText.lowercased()
- return self.viewModel.projects.filter { project in
- let nameMatch = project.name?.lowercased().contains(query) ?? false
- let pathMatch = project.path?.lowercased().contains(query) ?? false
- return nameMatch || pathMatch
- }
- }
-
- private func selectFirstResult() {
- guard !self.filteredResults.isEmpty else {
- return
- }
- let project = self.filteredResults[min(self.selectedIndex, self.filteredResults.count - 1)]
- self.selectProject(project)
- }
-
- private func selectProject(_ project: ProjectEntry) {
- if let path = project.path {
- self.selection = .project(path)
- self.viewModel.recordRecentProject(project)
- }
- self.viewModel.isQuickSwitcherPresented = false
- }
-}
-
-// MARK: - QuickSwitcherRow
-
-/// A row in the quick switcher results.
-struct QuickSwitcherRow: View {
- // MARK: Internal
-
- let project: ProjectEntry
- let isSelected: Bool
- let isFavorite: Bool
-
- var body: some View {
- HStack(spacing: 12) {
- Image(systemName: "folder.fill")
- .foregroundStyle(.blue)
-
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 4) {
- Text(self.project.name ?? "Unknown")
- .font(.body)
-
- if self.isFavorite {
- Image(systemName: "star.fill")
- .foregroundStyle(.yellow)
- .font(.caption2)
- }
- }
-
- if let path = project.path {
- Text(self.abbreviatePath(path))
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
- Spacer()
- }
- .padding(.horizontal)
- .padding(.vertical, 8)
- .background(self.isSelected ? Color.accentColor.opacity(0.2) : Color.clear)
- .accessibilityElement(children: .combine)
- .accessibilityLabel(self.quickSwitcherAccessibilityLabel)
- .accessibilityAddTraits(self.isSelected ? .isSelected : [])
- }
-
- // MARK: Private
-
- private var quickSwitcherAccessibilityLabel: String {
- var parts: [String] = [self.project.name ?? "Unknown project"]
- if self.isFavorite {
- parts.append("favorite")
- }
- return parts.joined(separator: ", ")
- }
-
- private func abbreviatePath(_ path: String) -> String {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- if path.hasPrefix(home) {
- return "~" + path.dropFirst(home.count)
- }
- return path
- }
-}
-
-// MARK: - KeyboardHint
-
-/// A small keyboard hint label.
-struct KeyboardHint: View {
- let key: String
- let label: String
-
- var body: some View {
- HStack(spacing: 4) {
- Text(self.key)
- .font(.caption)
- .fontWeight(.medium)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 4))
-
- Text(self.label)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-}
-
-#Preview {
- let viewModel = ProjectExplorerViewModel()
- return NavigationSplitView {
- SidebarView(
- selection: .constant(.globalSettings),
- viewModel: viewModel
- )
- } detail: {
- Text("Detail View")
- }
-}
-
-#Preview("Quick Switcher") {
- let viewModel = ProjectExplorerViewModel()
- return QuickSwitcherView(viewModel: viewModel, selection: .constant(nil))
- .padding()
-}
diff --git a/Fig/Sources/Views/ToastView.swift b/Fig/Sources/Views/ToastView.swift
deleted file mode 100644
index 99a019a..0000000
--- a/Fig/Sources/Views/ToastView.swift
+++ /dev/null
@@ -1,179 +0,0 @@
-import SwiftUI
-
-// MARK: - ToastView
-
-/// A toast notification view.
-struct ToastView: View {
- let notification: AppNotification
- let onDismiss: () -> Void
-
- var body: some View {
- HStack(spacing: 12) {
- Image(systemName: self.notification.type.icon)
- .foregroundStyle(self.notification.type.color)
- .font(.title2)
-
- VStack(alignment: .leading, spacing: 2) {
- Text(self.notification.title)
- .font(.headline)
- .foregroundStyle(.primary)
-
- if let message = notification.message {
- Text(message)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- .lineLimit(2)
- }
- }
-
- Spacer()
-
- Button {
- self.onDismiss()
- } label: {
- Image(systemName: "xmark")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- }
- .padding()
- .background {
- RoundedRectangle(cornerRadius: 12)
- .fill(.regularMaterial)
- .shadow(color: .black.opacity(0.1), radius: 8, y: 4)
- }
- .frame(maxWidth: 400)
- }
-}
-
-// MARK: - ToastContainerView
-
-/// Container view for displaying toast notifications.
-struct ToastContainerView: View {
- @Bindable var notificationManager: NotificationManager
-
- var body: some View {
- VStack(spacing: 8) {
- ForEach(self.notificationManager.toasts) { toast in
- ToastView(notification: toast) {
- self.notificationManager.dismissToast(id: toast.id)
- }
- .transition(.asymmetric(
- insertion: .move(edge: .top).combined(with: .opacity),
- removal: .move(edge: .trailing).combined(with: .opacity)
- ))
- }
- }
- .padding()
- .animation(.spring(duration: 0.3), value: self.notificationManager.toasts.map(\.id))
- }
-}
-
-// MARK: - ToastModifier
-
-/// View modifier to add toast notifications to a view.
-struct ToastModifier: ViewModifier {
- @Bindable var notificationManager: NotificationManager
-
- func body(content: Content) -> some View {
- content
- .overlay(alignment: .top) {
- ToastContainerView(notificationManager: self.notificationManager)
- }
- }
-}
-
-extension View {
- /// Adds toast notification support to the view.
- func withToasts(_ notificationManager: NotificationManager) -> some View {
- modifier(ToastModifier(notificationManager: notificationManager))
- }
-}
-
-// MARK: - AlertModifier
-
-/// View modifier to add alert support.
-struct AlertModifier: ViewModifier {
- @Bindable var notificationManager: NotificationManager
-
- func body(content: Content) -> some View {
- content
- .alert(
- self.notificationManager.currentAlert?.title ?? "",
- isPresented: .init(
- get: { self.notificationManager.currentAlert != nil },
- set: { if !$0 {
- self.notificationManager.dismissAlert()
- } }
- ),
- presenting: self.notificationManager.currentAlert
- ) { alertInfo in
- Button(alertInfo.primaryButton.title, role: alertInfo.primaryButton.role) {
- alertInfo.primaryButton.action?()
- self.notificationManager.dismissAlert()
- }
- if let secondary = alertInfo.secondaryButton {
- Button(secondary.title, role: secondary.role) {
- secondary.action?()
- self.notificationManager.dismissAlert()
- }
- }
- } message: { alertInfo in
- if let message = alertInfo.message {
- Text(message)
- }
- }
- }
-}
-
-extension View {
- /// Adds alert support to the view.
- func withAlerts(_ notificationManager: NotificationManager) -> some View {
- modifier(AlertModifier(notificationManager: notificationManager))
- }
-
- /// Adds both toast and alert support to the view.
- func withNotifications(_ notificationManager: NotificationManager) -> some View {
- self.withToasts(notificationManager)
- .withAlerts(notificationManager)
- }
-}
-
-#Preview("Toast Types") {
- VStack(spacing: 20) {
- ToastView(
- notification: AppNotification(
- type: .success,
- title: "Configuration saved",
- message: "Your changes have been saved successfully."
- )
- ) {}
-
- ToastView(
- notification: AppNotification(
- type: .info,
- title: "New version available",
- message: "Version 2.0 is now available for download."
- )
- ) {}
-
- ToastView(
- notification: AppNotification(
- type: .warning,
- title: "File modified externally",
- message: "The configuration file was changed outside of Fig."
- )
- ) {}
-
- ToastView(
- notification: AppNotification(
- type: .error,
- title: "Failed to save",
- message: "Check file permissions and try again."
- )
- ) {}
- }
- .padding()
- .frame(width: 450, height: 400)
-}
diff --git a/Fig/Tests/ConfigFileManagerTests.swift b/Fig/Tests/ConfigFileManagerTests.swift
deleted file mode 100644
index ba9f1e6..0000000
--- a/Fig/Tests/ConfigFileManagerTests.swift
+++ /dev/null
@@ -1,206 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-@Suite("ConfigFileManager Tests")
-struct ConfigFileManagerTests {
- // MARK: Lifecycle
-
- init() throws {
- self.tempDirectory = FileManager.default.temporaryDirectory
- .appendingPathComponent("FigTests-\(UUID().uuidString)")
- try FileManager.default.createDirectory(
- at: self.tempDirectory,
- withIntermediateDirectories: true
- )
- }
-
- // MARK: Internal
-
- // MARK: - Path Tests
-
- @Suite("Path Resolution")
- struct PathTests {
- @Test("Global paths resolve correctly")
- func globalPaths() async {
- let manager = ConfigFileManager.shared
- let homeDir = FileManager.default.homeDirectoryForCurrentUser
-
- let globalConfig = await manager.globalConfigURL
- #expect(globalConfig == homeDir.appendingPathComponent(".claude.json"))
-
- let globalSettings = await manager.globalSettingsURL
- #expect(globalSettings == homeDir.appendingPathComponent(".claude/settings.json"))
- }
-
- @Test("Project paths resolve correctly")
- func projectPaths() async {
- let manager = ConfigFileManager.shared
- let projectPath = URL(fileURLWithPath: "/Users/test/myproject")
-
- let projectSettings = await manager.projectSettingsURL(for: projectPath)
- #expect(projectSettings.path == "/Users/test/myproject/.claude/settings.json")
-
- let localSettings = await manager.projectLocalSettingsURL(for: projectPath)
- #expect(localSettings.path == "/Users/test/myproject/.claude/settings.local.json")
-
- let mcpConfig = await manager.mcpConfigURL(for: projectPath)
- #expect(mcpConfig.path == "/Users/test/myproject/.mcp.json")
- }
- }
-
- let tempDirectory: URL
-
- // MARK: - Read/Write Tests
-
- @Test("Reads and writes ClaudeSettings")
- func readWriteSettings() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("FigTest-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let testFile = tempDir.appendingPathComponent("settings.json")
- let manager = ConfigFileManager.shared
-
- // Write settings
- let settings = ClaudeSettings(
- permissions: Permissions(allow: ["Bash(*)"]),
- env: ["TEST_VAR": "test_value"],
- attribution: Attribution(commits: true)
- )
- try await manager.write(settings, to: testFile)
-
- // Verify file exists
- #expect(FileManager.default.fileExists(atPath: testFile.path))
-
- // Read back
- let readSettings = try await manager.read(ClaudeSettings.self, from: testFile)
- #expect(readSettings != nil)
- #expect(readSettings?.permissions?.allow == ["Bash(*)"])
- #expect(readSettings?.env?["TEST_VAR"] == "test_value")
- #expect(readSettings?.attribution?.commits == true)
- }
-
- @Test("Reads and writes MCPConfig")
- func readWriteMCPConfig() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("FigTest-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let testFile = tempDir.appendingPathComponent(".mcp.json")
- let manager = ConfigFileManager.shared
-
- // Write config
- let config = MCPConfig(
- mcpServers: [
- "github": MCPServer.stdio(
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-github"]
- ),
- ]
- )
- try await manager.write(config, to: testFile)
-
- // Read back
- let readConfig = try await manager.read(MCPConfig.self, from: testFile)
- #expect(readConfig != nil)
- #expect(readConfig?.mcpServers?["github"]?.command == "npx")
- }
-
- @Test("Returns nil for non-existent file")
- func readNonExistent() async throws {
- let manager = ConfigFileManager.shared
- let nonExistent = URL(fileURLWithPath: "/tmp/definitely-does-not-exist-\(UUID()).json")
-
- let result = try await manager.read(ClaudeSettings.self, from: nonExistent)
- #expect(result == nil)
- }
-
- @Test("Creates backup before overwriting")
- func createsBackup() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("FigTest-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let testFile = tempDir.appendingPathComponent("settings.json")
- let manager = ConfigFileManager.shared
-
- // Write initial content
- let initial = ClaudeSettings(env: ["VERSION": "1"])
- try await manager.write(initial, to: testFile)
-
- // Write updated content (should create backup)
- let updated = ClaudeSettings(env: ["VERSION": "2"])
- try await manager.write(updated, to: testFile)
-
- // Check for backup file
- let contents = try FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil)
- let backups = contents.filter { $0.lastPathComponent.contains(".backup.") }
- #expect(backups.count >= 1)
- }
-
- @Test("Creates parent directories when writing")
- func createsParentDirectories() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("FigTest-\(UUID().uuidString)")
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let nestedFile = tempDir
- .appendingPathComponent("nested")
- .appendingPathComponent("deep")
- .appendingPathComponent("settings.json")
- let manager = ConfigFileManager.shared
-
- let settings = ClaudeSettings(env: ["TEST": "value"])
- try await manager.write(settings, to: nestedFile)
-
- #expect(FileManager.default.fileExists(atPath: nestedFile.path))
- }
-
- // MARK: - Error Handling Tests
-
- @Test("Throws on invalid JSON")
- func invalidJSON() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("FigTest-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let testFile = tempDir.appendingPathComponent("invalid.json")
-
- // Write invalid JSON
- try "{ invalid json }".write(to: testFile, atomically: true, encoding: .utf8)
-
- let manager = ConfigFileManager.shared
-
- await #expect(throws: ConfigFileError.self) {
- _ = try await manager.read(ClaudeSettings.self, from: testFile)
- }
- }
-
- // MARK: - File Existence Tests
-
- @Test("fileExists returns correct values")
- func fileExistsCheck() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent("FigTest-\(UUID().uuidString)")
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let existingFile = tempDir.appendingPathComponent("exists.json")
- try "{}".write(to: existingFile, atomically: true, encoding: .utf8)
-
- let nonExistentFile = tempDir.appendingPathComponent("nonexistent.json")
-
- let manager = ConfigFileManager.shared
-
- let existsResult = await manager.fileExists(at: existingFile)
- #expect(existsResult == true)
-
- let notExistsResult = await manager.fileExists(at: nonExistentFile)
- #expect(notExistsResult == false)
- }
-}
diff --git a/Fig/Tests/ConfigHealthCheckTests.swift b/Fig/Tests/ConfigHealthCheckTests.swift
deleted file mode 100644
index a44c530..0000000
--- a/Fig/Tests/ConfigHealthCheckTests.swift
+++ /dev/null
@@ -1,423 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - HealthCheckTestHelpers
-
-enum HealthCheckTestHelpers {
- /// Creates a minimal context for testing with optional overrides.
- static func makeContext(
- globalSettings: ClaudeSettings? = nil,
- projectSettings: ClaudeSettings? = nil,
- projectLocalSettings: ClaudeSettings? = nil,
- mcpConfig: MCPConfig? = nil,
- legacyConfig: LegacyConfig? = nil,
- localSettingsExists: Bool = false,
- mcpConfigExists: Bool = false,
- globalConfigFileSize: Int64? = nil
- ) -> HealthCheckContext {
- HealthCheckContext(
- projectPath: URL(fileURLWithPath: "/tmp/test-project"),
- globalSettings: globalSettings,
- projectSettings: projectSettings,
- projectLocalSettings: projectLocalSettings,
- mcpConfig: mcpConfig,
- legacyConfig: legacyConfig,
- localSettingsExists: localSettingsExists,
- mcpConfigExists: mcpConfigExists,
- globalConfigFileSize: globalConfigFileSize
- )
- }
-}
-
-// MARK: - DenyListSecurityCheckTests
-
-@Suite("DenyListSecurityCheck Tests")
-struct DenyListSecurityCheckTests {
- let check = DenyListSecurityCheck()
-
- @Test("Flags missing .env deny rule")
- func flagsMissingEnvDeny() {
- let context = HealthCheckTestHelpers.makeContext()
- let findings = self.check.check(context: context)
-
- let envFinding = findings.first { $0.title.contains(".env") }
- #expect(envFinding != nil)
- #expect(envFinding?.severity == .security)
- #expect(envFinding?.autoFix == .addToDenyList(pattern: "Read(.env)"))
- }
-
- @Test("Flags missing secrets/ deny rule")
- func flagsMissingSecretsDeny() {
- let context = HealthCheckTestHelpers.makeContext()
- let findings = self.check.check(context: context)
-
- let secretsFinding = findings.first { $0.title.contains("secrets/") }
- #expect(secretsFinding != nil)
- #expect(secretsFinding?.severity == .security)
- #expect(secretsFinding?.autoFix == .addToDenyList(pattern: "Read(secrets/**)"))
- }
-
- @Test("No findings when .env is in deny list")
- func noFindingWhenEnvDenied() {
- let settings = ClaudeSettings(permissions: Permissions(deny: ["Read(.env)"]))
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- let envFinding = findings.first { $0.title.contains(".env") }
- #expect(envFinding == nil)
- }
-
- @Test("No findings when secrets is in deny list")
- func noFindingWhenSecretsDenied() {
- let settings = ClaudeSettings(permissions: Permissions(deny: ["Read(secrets/**)"]))
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- let secretsFinding = findings.first { $0.title.contains("secrets/") }
- #expect(secretsFinding == nil)
- }
-
- @Test("Checks deny rules across all config sources")
- func checksAllSources() {
- let global = ClaudeSettings(permissions: Permissions(deny: ["Read(.env)"]))
- let local = ClaudeSettings(permissions: Permissions(deny: ["Read(secrets/**)"]))
- let context = HealthCheckTestHelpers.makeContext(
- globalSettings: global,
- projectLocalSettings: local
- )
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-}
-
-// MARK: - BroadAllowRulesCheckTests
-
-@Suite("BroadAllowRulesCheck Tests")
-struct BroadAllowRulesCheckTests {
- let check = BroadAllowRulesCheck()
-
- @Test("Flags Bash(*) as overly broad")
- func flagsBroadBash() {
- let settings = ClaudeSettings(permissions: Permissions(allow: ["Bash(*)"]))
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- #expect(findings.count == 1)
- #expect(findings.first?.severity == .warning)
- #expect(findings.first?.title.contains("Bash(*)") == true)
- }
-
- @Test("Does not flag specific allow rules")
- func doesNotFlagSpecific() {
- let settings = ClaudeSettings(permissions: Permissions(allow: ["Bash(npm run *)"]))
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-
- @Test("Flags multiple broad rules")
- func flagsMultipleBroad() {
- let settings = ClaudeSettings(permissions: Permissions(allow: ["Bash(*)", "Read(*)"]))
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- #expect(findings.count == 2)
- }
-
- @Test("No auto-fix for broad rules")
- func noAutoFix() {
- let settings = ClaudeSettings(permissions: Permissions(allow: ["Bash(*)"]))
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- #expect(findings.first?.autoFix == nil)
- }
-}
-
-// MARK: - GlobalConfigSizeCheckTests
-
-@Suite("GlobalConfigSizeCheck Tests")
-struct GlobalConfigSizeCheckTests {
- let check = GlobalConfigSizeCheck()
-
- @Test("Flags config larger than 5MB")
- func flagsLargeConfig() {
- let size: Int64 = 6 * 1024 * 1024 // 6 MB
- let context = HealthCheckTestHelpers.makeContext(globalConfigFileSize: size)
- let findings = self.check.check(context: context)
-
- #expect(findings.count == 1)
- #expect(findings.first?.severity == .warning)
- #expect(findings.first?.title.contains("6.0 MB") == true)
- }
-
- @Test("No finding for small config")
- func noFindingForSmall() {
- let size: Int64 = 1024 // 1 KB
- let context = HealthCheckTestHelpers.makeContext(globalConfigFileSize: size)
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-
- @Test("No finding when size is unknown")
- func noFindingWhenUnknown() {
- let context = HealthCheckTestHelpers.makeContext(globalConfigFileSize: nil)
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-}
-
-// MARK: - MCPHardcodedSecretsCheckTests
-
-@Suite("MCPHardcodedSecretsCheck Tests")
-struct MCPHardcodedSecretsCheckTests {
- let check = MCPHardcodedSecretsCheck()
-
- @Test("Flags hardcoded token in env var")
- func flagsHardcodedToken() {
- let server = MCPServer(command: "npx", env: ["GITHUB_TOKEN": "ghp_1234567890abcdef"])
- let config = MCPConfig(mcpServers: ["github": server])
- let context = HealthCheckTestHelpers.makeContext(mcpConfig: config)
- let findings = self.check.check(context: context)
-
- #expect(!findings.isEmpty)
- #expect(findings.first?.severity == .warning)
- }
-
- @Test("Flags secret-looking key with long value")
- func flagsSecretKey() {
- let server = MCPServer(command: "npx", env: ["API_KEY": "some-long-api-key-value"])
- let config = MCPConfig(mcpServers: ["test": server])
- let context = HealthCheckTestHelpers.makeContext(mcpConfig: config)
- let findings = self.check.check(context: context)
-
- #expect(!findings.isEmpty)
- }
-
- @Test("Does not flag non-secret env vars")
- func doesNotFlagNonSecret() {
- let server = MCPServer(command: "npx", env: ["NODE_ENV": "production"])
- let config = MCPConfig(mcpServers: ["test": server])
- let context = HealthCheckTestHelpers.makeContext(mcpConfig: config)
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-
- @Test("Flags secrets in HTTP headers")
- func flagsHeaderSecrets() {
- let server = MCPServer(type: "http", url: "https://example.com", headers: [
- "Authorization": "Bearer sk-1234567890abcdef",
- ])
- let config = MCPConfig(mcpServers: ["remote": server])
- let context = HealthCheckTestHelpers.makeContext(mcpConfig: config)
- let findings = self.check.check(context: context)
-
- #expect(!findings.isEmpty)
- }
-
- @Test("No findings when no MCP servers")
- func noFindingsNoServers() {
- let context = HealthCheckTestHelpers.makeContext()
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-}
-
-// MARK: - LocalSettingsCheckTests
-
-@Suite("LocalSettingsCheck Tests")
-struct LocalSettingsCheckTests {
- let check = LocalSettingsCheck()
-
- @Test("Suggests creating local settings when missing")
- func suggestsCreation() {
- let context = HealthCheckTestHelpers.makeContext(localSettingsExists: false)
- let findings = self.check.check(context: context)
-
- #expect(findings.count == 1)
- #expect(findings.first?.severity == .suggestion)
- #expect(findings.first?.autoFix == .createLocalSettings)
- }
-
- @Test("No finding when local settings exist")
- func noFindingWhenExists() {
- let context = HealthCheckTestHelpers.makeContext(localSettingsExists: true)
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-}
-
-// MARK: - MCPScopingCheckTests
-
-@Suite("MCPScopingCheck Tests")
-struct MCPScopingCheckTests {
- let check = MCPScopingCheck()
-
- @Test("Suggests scoping when global servers exist without project MCP")
- func suggestsScoping() {
- let legacy = LegacyConfig(mcpServers: [
- "github": MCPServer(command: "npx"),
- ])
- let context = HealthCheckTestHelpers.makeContext(
- legacyConfig: legacy,
- mcpConfigExists: false
- )
- let findings = self.check.check(context: context)
-
- #expect(findings.count == 1)
- #expect(findings.first?.severity == .suggestion)
- }
-
- @Test("No finding when project MCP exists")
- func noFindingWithProjectMCP() {
- let legacy = LegacyConfig(mcpServers: [
- "github": MCPServer(command: "npx"),
- ])
- let context = HealthCheckTestHelpers.makeContext(
- legacyConfig: legacy,
- mcpConfigExists: true
- )
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-
- @Test("No finding when no global servers")
- func noFindingNoGlobalServers() {
- let context = HealthCheckTestHelpers.makeContext(mcpConfigExists: false)
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-}
-
-// MARK: - HookSuggestionsCheckTests
-
-@Suite("HookSuggestionsCheck Tests")
-struct HookSuggestionsCheckTests {
- let check = HookSuggestionsCheck()
-
- @Test("Suggests hooks when none configured")
- func suggestsHooks() {
- let context = HealthCheckTestHelpers.makeContext()
- let findings = self.check.check(context: context)
-
- #expect(findings.count == 1)
- #expect(findings.first?.severity == .suggestion)
- }
-
- @Test("No finding when hooks exist")
- func noFindingWithHooks() {
- let hooks: [String: [HookGroup]] = [
- "PreToolUse": [HookGroup(hooks: [HookDefinition(type: "command", command: "npm run lint")])],
- ]
- let settings = ClaudeSettings(hooks: hooks)
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-}
-
-// MARK: - GoodPracticesCheckTests
-
-@Suite("GoodPracticesCheck Tests")
-struct GoodPracticesCheckTests {
- let check = GoodPracticesCheck()
-
- @Test("Reports good practice for .env in deny list")
- func reportsEnvProtection() {
- let settings = ClaudeSettings(permissions: Permissions(deny: ["Read(.env)"]))
- let context = HealthCheckTestHelpers.makeContext(projectSettings: settings)
- let findings = self.check.check(context: context)
-
- let envFinding = findings.first { $0.title.contains("Sensitive files protected") }
- #expect(envFinding != nil)
- #expect(envFinding?.severity == .good)
- }
-
- @Test("Reports good practice for local settings")
- func reportsLocalSettings() {
- let context = HealthCheckTestHelpers.makeContext(localSettingsExists: true)
- let findings = self.check.check(context: context)
-
- let localFinding = findings.first { $0.title.contains("Local settings") }
- #expect(localFinding != nil)
- #expect(localFinding?.severity == .good)
- }
-
- @Test("Reports good practice for project MCP")
- func reportsProjectMCP() {
- let context = HealthCheckTestHelpers.makeContext(mcpConfigExists: true)
- let findings = self.check.check(context: context)
-
- let mcpFinding = findings.first { $0.title.contains("Project-scoped MCP") }
- #expect(mcpFinding != nil)
- #expect(mcpFinding?.severity == .good)
- }
-
- @Test("No good practices for empty config")
- func noGoodPracticesEmpty() {
- let context = HealthCheckTestHelpers.makeContext()
- let findings = self.check.check(context: context)
-
- #expect(findings.isEmpty)
- }
-}
-
-// MARK: - WellConfiguredProjectTests
-
-@Suite("Well-Configured Project Tests")
-struct WellConfiguredProjectTests {
- @Test("No false positives on well-configured project")
- func noFalsePositives() {
- let settings = ClaudeSettings(
- permissions: Permissions(
- allow: ["Bash(npm run *)", "Read(src/**)"],
- deny: ["Read(.env)", "Read(secrets/**)"]
- ),
- hooks: [
- "PreToolUse": [HookGroup(hooks: [HookDefinition(type: "command", command: "npm run lint")])],
- ]
- )
- let mcpConfig = MCPConfig(mcpServers: [
- "test": MCPServer(command: "npx", env: ["NODE_ENV": "production"]),
- ])
-
- let context = HealthCheckTestHelpers.makeContext(
- projectSettings: settings,
- mcpConfig: mcpConfig,
- localSettingsExists: true,
- mcpConfigExists: true,
- globalConfigFileSize: 1024
- )
-
- let allChecks: [any HealthCheck] = [
- DenyListSecurityCheck(),
- BroadAllowRulesCheck(),
- GlobalConfigSizeCheck(),
- MCPHardcodedSecretsCheck(),
- LocalSettingsCheck(),
- MCPScopingCheck(),
- HookSuggestionsCheck(),
- GoodPracticesCheck(),
- ]
- let findings = allChecks.flatMap { $0.check(context: context) }
-
- // Should have no security or warning findings
- let problems = findings.filter { $0.severity == .security || $0.severity == .warning }
- #expect(problems.isEmpty, "Expected no security/warning findings but got: \(problems.map(\.title))")
-
- // Should have good findings
- let good = findings.filter { $0.severity == .good }
- #expect(!good.isEmpty)
- }
-}
diff --git a/Fig/Tests/GlobalSettingsViewModelTests.swift b/Fig/Tests/GlobalSettingsViewModelTests.swift
deleted file mode 100644
index 40bb25e..0000000
--- a/Fig/Tests/GlobalSettingsViewModelTests.swift
+++ /dev/null
@@ -1,75 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-@Suite("GlobalSettingsViewModel Tests")
-struct GlobalSettingsViewModelTests {
- @Test("Initial state is not loading")
- @MainActor
- func initialStateNotLoading() {
- let viewModel = GlobalSettingsViewModel()
- #expect(viewModel.isLoading == false)
- }
-
- @Test("Default tab is permissions")
- @MainActor
- func defaultTabIsPermissions() {
- let viewModel = GlobalSettingsViewModel()
- #expect(viewModel.selectedTab == .permissions)
- }
-
- @Test("Settings nil before load")
- @MainActor
- func settingsNilBeforeLoad() {
- let viewModel = GlobalSettingsViewModel()
- #expect(viewModel.settings == nil)
- }
-
- @Test("Legacy config nil before load")
- @MainActor
- func legacyConfigNilBeforeLoad() {
- let viewModel = GlobalSettingsViewModel()
- #expect(viewModel.legacyConfig == nil)
- }
-
- @Test("Global MCP servers empty before load")
- @MainActor
- func globalMCPServersEmptyBeforeLoad() {
- let viewModel = GlobalSettingsViewModel()
- #expect(viewModel.globalMCPServers.isEmpty)
- }
-
- @Test("Global settings path nil before load")
- @MainActor
- func globalSettingsPathNilBeforeLoad() {
- let viewModel = GlobalSettingsViewModel()
- #expect(viewModel.globalSettingsPath == nil)
- }
-
- @Test("File statuses nil before load")
- @MainActor
- func fileStatusesNilBeforeLoad() {
- let viewModel = GlobalSettingsViewModel()
- #expect(viewModel.settingsFileStatus == nil)
- #expect(viewModel.configFileStatus == nil)
- }
-
- @Test("All global settings tabs exist")
- func allTabsExist() {
- let tabs = GlobalSettingsTab.allCases
- #expect(tabs.count == 4)
- #expect(tabs.contains(.permissions))
- #expect(tabs.contains(.environment))
- #expect(tabs.contains(.mcpServers))
- #expect(tabs.contains(.advanced))
- }
-
- @Test("Tabs have titles and icons")
- func tabsHaveTitlesAndIcons() {
- for tab in GlobalSettingsTab.allCases {
- #expect(!tab.title.isEmpty)
- #expect(!tab.icon.isEmpty)
- #expect(tab.id == tab.rawValue)
- }
- }
-}
diff --git a/Fig/Tests/MCPHealthCheckServiceTests.swift b/Fig/Tests/MCPHealthCheckServiceTests.swift
deleted file mode 100644
index b423e5a..0000000
--- a/Fig/Tests/MCPHealthCheckServiceTests.swift
+++ /dev/null
@@ -1,183 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - MCPHealthCheckResult Tests
-
-@Suite("MCPHealthCheckResult Tests")
-struct MCPHealthCheckResultTests {
- @Test("Success result reports isSuccess true")
- func successIsSuccess() {
- let result = MCPHealthCheckResult(
- serverName: "test",
- status: .success(serverInfo: nil),
- duration: 0.5
- )
- #expect(result.isSuccess == true)
- }
-
- @Test("Failure result reports isSuccess false")
- func failureIsNotSuccess() {
- let result = MCPHealthCheckResult(
- serverName: "test",
- status: .failure(error: .noCommandOrURL),
- duration: 0.1
- )
- #expect(result.isSuccess == false)
- }
-
- @Test("Timeout result reports isSuccess false")
- func timeoutIsNotSuccess() {
- let result = MCPHealthCheckResult(
- serverName: "test",
- status: .timeout,
- duration: 10.0
- )
- #expect(result.isSuccess == false)
- }
-
- @Test("Success with server info preserves details")
- func successWithServerInfo() {
- let info = MCPServerInfo(
- protocolVersion: "2024-11-05",
- serverName: "my-server",
- serverVersion: "1.2.3"
- )
- let result = MCPHealthCheckResult(
- serverName: "test",
- status: .success(serverInfo: info),
- duration: 0.3
- )
- #expect(result.isSuccess == true)
- if case let .success(serverInfo) = result.status {
- #expect(serverInfo?.serverName == "my-server")
- #expect(serverInfo?.serverVersion == "1.2.3")
- #expect(serverInfo?.protocolVersion == "2024-11-05")
- } else {
- Issue.record("Expected success status")
- }
- }
-}
-
-// MARK: - MCPHealthCheckError Tests
-
-@Suite("MCPHealthCheckError Tests")
-struct MCPHealthCheckErrorTests {
- @Test("All error cases have descriptions")
- func allErrorsHaveDescriptions() {
- let errors: [MCPHealthCheckError] = [
- .processSpawnFailed(underlying: "not found"),
- .processExitedEarly(code: 1, stderr: "error"),
- .invalidHandshakeResponse(details: "bad response"),
- .httpRequestFailed(statusCode: 500, message: "server error"),
- .httpRequestFailed(statusCode: nil, message: "unknown"),
- .networkError(message: "timeout"),
- .timeout,
- .noCommandOrURL,
- ]
-
- for error in errors {
- #expect(error.errorDescription != nil)
- #expect(!error.errorDescription!.isEmpty)
- }
- }
-
- @Test("All error cases have recovery suggestions")
- func allErrorsHaveRecoverySuggestions() {
- let errors: [MCPHealthCheckError] = [
- .processSpawnFailed(underlying: "not found"),
- .processExitedEarly(code: 1, stderr: "error"),
- .invalidHandshakeResponse(details: "bad response"),
- .httpRequestFailed(statusCode: 500, message: "server error"),
- .networkError(message: "timeout"),
- .timeout,
- .noCommandOrURL,
- ]
-
- for error in errors {
- #expect(error.recoverySuggestion != nil)
- #expect(!error.recoverySuggestion!.isEmpty)
- }
- }
-}
-
-// MARK: - MCPHealthCheckService Stdio Tests
-
-@Suite("MCPHealthCheckService Stdio Tests")
-struct MCPHealthCheckServiceStdioTests {
- @Test("Returns failure for server with no command or URL")
- func noCommandOrURL() async {
- let service = MCPHealthCheckService()
- let server = MCPServer()
- let result = await service.checkHealth(name: "empty", server: server)
-
- #expect(result.isSuccess == false)
- if case let .failure(error) = result.status {
- if case .noCommandOrURL = error {
- // Expected
- } else {
- Issue.record("Expected noCommandOrURL, got \(error)")
- }
- } else {
- Issue.record("Expected failure with noCommandOrURL")
- }
- }
-
- @Test("Successful health check against echo-based stdio server")
- func stdioEchoServer() async {
- let service = MCPHealthCheckService()
- // Use cat which echoes stdin to stdout - it will echo back our request
- // which is valid JSON, so the health check should parse it as a success
- let server = MCPServer.stdio(command: "cat")
- let result = await service.checkHealth(name: "cat-test", server: server)
-
- // cat echoes back the request, which contains valid JSON with no "error" field
- // and no "result" field, so it should be treated as success (any JSON = responsive)
- #expect(result.isSuccess == true)
- }
-
- @Test("Returns failure for non-existent command")
- func nonExistentCommand() async {
- let service = MCPHealthCheckService()
- let server = MCPServer.stdio(
- command: "/nonexistent/command/that/does/not/exist"
- )
- let result = await service.checkHealth(name: "bad-cmd", server: server)
-
- #expect(result.isSuccess == false)
- }
-
- @Test("Returns failure for command that exits immediately")
- func commandExitsImmediately() async {
- let service = MCPHealthCheckService()
- let server = MCPServer.stdio(command: "false")
- let result = await service.checkHealth(name: "false-cmd", server: server)
-
- #expect(result.isSuccess == false)
- if case let .failure(error) = result.status {
- if case .processExitedEarly = error {
- // Expected
- } else {
- Issue.record("Expected processExitedEarly, got \(error)")
- }
- }
- }
-
- @Test("Duration is recorded")
- func durationRecorded() async {
- let service = MCPHealthCheckService()
- let server = MCPServer.stdio(command: "cat")
- let result = await service.checkHealth(name: "duration-test", server: server)
-
- #expect(result.duration > 0)
- }
-
- @Test("Server name is preserved in result")
- func serverNamePreserved() async {
- let service = MCPHealthCheckService()
- let server = MCPServer.stdio(command: "cat")
- let result = await service.checkHealth(name: "my-custom-name", server: server)
-
- #expect(result.serverName == "my-custom-name")
- }
-}
diff --git a/Fig/Tests/MCPPasteViewModelTests.swift b/Fig/Tests/MCPPasteViewModelTests.swift
deleted file mode 100644
index be08822..0000000
--- a/Fig/Tests/MCPPasteViewModelTests.swift
+++ /dev/null
@@ -1,123 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - MCPPasteViewModelTests
-
-@Suite("MCP Paste ViewModel Tests")
-@MainActor
-struct MCPPasteViewModelTests {
- @Test("Initial state is empty")
- func initialState() {
- let vm = MCPPasteViewModel()
- #expect(vm.jsonText.isEmpty)
- #expect(vm.parsedServers == nil)
- #expect(vm.parseError == nil)
- #expect(vm.serverCount == 0)
- #expect(vm.canImport == false)
- #expect(vm.importSucceeded == false)
- }
-
- @Test("canImport requires parsed servers and destination")
- func canImportRequirements() {
- let vm = MCPPasteViewModel(
- currentProject: .project(path: "/tmp", name: "test")
- )
-
- // No servers parsed yet
- #expect(vm.canImport == false)
- }
-
- @Test("loadDestinations populates available destinations")
- func loadDestinations() {
- let vm = MCPPasteViewModel()
-
- let projects = [
- ProjectEntry(path: "/path/to/project-a"),
- ProjectEntry(path: "/path/to/project-b"),
- ]
-
- vm.loadDestinations(projects: projects)
-
- // Should have global + 2 projects
- #expect(vm.availableDestinations.count == 3)
- #expect(vm.availableDestinations[0] == .global)
- }
-
- @Test("loadDestinations filters out projects without paths")
- func loadDestinationsFiltersInvalid() {
- let vm = MCPPasteViewModel()
-
- let projects = [
- ProjectEntry(path: "/path/to/valid"),
- ProjectEntry(path: nil),
- ]
-
- vm.loadDestinations(projects: projects)
-
- // Should have global + 1 valid project
- #expect(vm.availableDestinations.count == 2)
- }
-
- @Test("selectedDestination defaults to current project")
- func defaultDestination() {
- let project = CopyDestination.project(path: "/tmp", name: "test")
- let vm = MCPPasteViewModel(currentProject: project)
-
- #expect(vm.selectedDestination == project)
- }
-
- @Test("selectedDestination defaults to nil when no project")
- func defaultDestinationNil() {
- let vm = MCPPasteViewModel()
- #expect(vm.selectedDestination == nil)
- }
-
- @Test("serverNames returns sorted names")
- func serverNamesSorted() async throws {
- let vm = MCPPasteViewModel()
- vm.jsonText = """
- {
- "mcpServers": {
- "zebra": { "command": "z" },
- "alpha": { "command": "a" }
- }
- }
- """
-
- // Allow async parsing to complete
- try await Task.sleep(for: .milliseconds(100))
-
- #expect(vm.serverNames == ["alpha", "zebra"])
- }
-
- @Test("importSucceeded reflects result")
- func importSucceeded() {
- let vm = MCPPasteViewModel()
- #expect(vm.importSucceeded == false)
- }
-
- @Test("conflictStrategy defaults to rename")
- func conflictStrategyDefault() {
- let vm = MCPPasteViewModel()
- #expect(vm.conflictStrategy == .rename)
- }
-
- @Test("conforms to Identifiable")
- func identifiable() {
- let vm1 = MCPPasteViewModel()
- let vm2 = MCPPasteViewModel()
-
- #expect(vm1.id != vm2.id)
- #expect(vm1.id == vm1.id)
- }
-
- @Test("setting to nil clears view model for sheet dismissal")
- func nilableForSheetBinding() {
- var vm: MCPPasteViewModel? = MCPPasteViewModel(currentProject: .global)
- #expect(vm != nil)
-
- vm = nil
- #expect(vm == nil)
- }
-}
diff --git a/Fig/Tests/MCPSharingServiceTests.swift b/Fig/Tests/MCPSharingServiceTests.swift
deleted file mode 100644
index 8fa63ae..0000000
--- a/Fig/Tests/MCPSharingServiceTests.swift
+++ /dev/null
@@ -1,399 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - MCPSharingServiceTests
-
-@Suite("MCP Sharing Service Tests")
-struct MCPSharingServiceTests {
- let service = MCPSharingService.shared
-
- // MARK: - Serialization Tests
-
- @Test("Serializes servers to MCPConfig JSON format")
- func serializeToJSON() throws {
- let servers: [String: MCPServer] = [
- "github": .stdio(command: "npx", args: ["-y", "@mcp/server-github"]),
- "api": .http(url: "https://mcp.example.com"),
- ]
-
- let json = try service.serializeToJSON(servers: servers)
-
- // Verify it's valid MCPConfig format
- let data = try #require(json.data(using: .utf8))
- let config = try JSONDecoder().decode(MCPConfig.self, from: data)
- #expect(config.mcpServers?.count == 2)
- #expect(config.mcpServers?["github"]?.command == "npx")
- #expect(config.mcpServers?["api"]?.url == "https://mcp.example.com")
- }
-
- @Test("Serialized JSON round-trips correctly")
- func serializationRoundTrip() async throws {
- let servers: [String: MCPServer] = [
- "github": .stdio(
- command: "npx",
- args: ["-y", "@mcp/server-github"],
- env: ["GITHUB_TOKEN": "tok-123"]
- ),
- "slack": .stdio(command: "npx", args: ["-y", "@mcp/server-slack"]),
- ]
-
- let json = try service.serializeToJSON(servers: servers)
- let parsed = try await service.parseServersFromJSON(json)
-
- #expect(parsed.count == 2)
- #expect(parsed["github"]?.command == "npx")
- #expect(parsed["github"]?.env?["GITHUB_TOKEN"] == "tok-123")
- #expect(parsed["slack"]?.command == "npx")
- }
-
- @Test("Serializes empty server dictionary")
- func serializeEmpty() throws {
- let json = try service.serializeToJSON(servers: [:])
- let data = try #require(json.data(using: .utf8))
- let config = try JSONDecoder().decode(MCPConfig.self, from: data)
- #expect(config.mcpServers?.isEmpty == true)
- }
-
- @Test("Serialized JSON uses sorted keys")
- func serializeSortedKeys() throws {
- let servers: [String: MCPServer] = [
- "zebra": .stdio(command: "z"),
- "alpha": .stdio(command: "a"),
- ]
-
- let json = try service.serializeToJSON(servers: servers)
-
- // "alpha" should appear before "zebra" in the output
- let alphaRange = try #require(json.range(of: "alpha"))
- let zebraRange = try #require(json.range(of: "zebra"))
- #expect(alphaRange.lowerBound < zebraRange.lowerBound)
- }
-
- // MARK: - Redaction Tests
-
- @Test("Redaction replaces sensitive env values with placeholders")
- func redactSensitiveEnv() async throws {
- let servers: [String: MCPServer] = [
- "github": .stdio(
- command: "npx",
- args: ["-y", "@mcp/server-github"],
- env: [
- "GITHUB_TOKEN": "ghp_secret123",
- "PATH": "/usr/local/bin",
- ]
- ),
- ]
-
- let json = try service.serializeToJSON(servers: servers, redactSensitive: true)
- let parsed = try await service.parseServersFromJSON(json)
-
- #expect(parsed["github"]?.env?["GITHUB_TOKEN"] == "")
- #expect(parsed["github"]?.env?["PATH"] == "/usr/local/bin")
- }
-
- @Test("Redaction replaces sensitive header values with placeholders")
- func redactSensitiveHeaders() async throws {
- let servers: [String: MCPServer] = [
- "api": .http(
- url: "https://example.com",
- headers: [
- "Authorization": "Bearer secret",
- "Content-Type": "application/json",
- ]
- ),
- ]
-
- let json = try service.serializeToJSON(servers: servers, redactSensitive: true)
- let parsed = try await service.parseServersFromJSON(json)
-
- #expect(parsed["api"]?.headers?["Authorization"] == "")
- #expect(parsed["api"]?.headers?["Content-Type"] == "application/json")
- }
-
- @Test("Redaction preserves non-sensitive values")
- func redactPreservesNonSensitive() async throws {
- let servers: [String: MCPServer] = [
- "safe": .stdio(
- command: "npx",
- env: [
- "NODE_ENV": "production",
- "DEBUG": "true",
- ]
- ),
- ]
-
- let json = try service.serializeToJSON(servers: servers, redactSensitive: true)
- let parsed = try await service.parseServersFromJSON(json)
-
- #expect(parsed["safe"]?.env?["NODE_ENV"] == "production")
- #expect(parsed["safe"]?.env?["DEBUG"] == "true")
- }
-
- @Test("Redaction without sensitive data returns same JSON")
- func redactNoSensitiveData() throws {
- let servers: [String: MCPServer] = [
- "safe": .stdio(command: "echo", env: ["PATH": "/bin"]),
- ]
-
- let normalJSON = try service.serializeToJSON(servers: servers, redactSensitive: false)
- let redactedJSON = try service.serializeToJSON(servers: servers, redactSensitive: true)
-
- #expect(normalJSON == redactedJSON)
- }
-
- // MARK: - Parsing Tests
-
- @Test("Parses MCPConfig format")
- func parseMCPConfigFormat() async throws {
- let json = """
- {
- "mcpServers": {
- "github": {
- "command": "npx",
- "args": ["-y", "@mcp/server-github"],
- "env": { "GITHUB_TOKEN": "tok" }
- },
- "api": {
- "type": "http",
- "url": "https://mcp.example.com"
- }
- }
- }
- """
-
- let servers = try await service.parseServersFromJSON(json)
- #expect(servers.count == 2)
- #expect(servers["github"]?.command == "npx")
- #expect(servers["github"]?.env?["GITHUB_TOKEN"] == "tok")
- #expect(servers["api"]?.url == "https://mcp.example.com")
- }
-
- @Test("Parses flat dictionary format")
- func parseFlatDictFormat() async throws {
- let json = """
- {
- "github": {
- "command": "npx",
- "args": ["-y", "@mcp/server-github"]
- },
- "slack": {
- "command": "npx",
- "args": ["-y", "@mcp/server-slack"]
- }
- }
- """
-
- let servers = try await service.parseServersFromJSON(json)
- #expect(servers.count == 2)
- #expect(servers["github"]?.command == "npx")
- #expect(servers["slack"]?.command == "npx")
- }
-
- @Test("Parses single named server")
- func parseSingleNamed() async throws {
- let json = """
- {
- "my-server": {
- "command": "npx",
- "args": ["-y", "some-server"]
- }
- }
- """
-
- let servers = try await service.parseServersFromJSON(json)
- #expect(servers.count == 1)
- #expect(servers["my-server"]?.command == "npx")
- }
-
- @Test("Parses single unnamed server")
- func parseSingleUnnamed() async throws {
- let json = """
- {
- "command": "npx",
- "args": ["-y", "@mcp/server-github"]
- }
- """
-
- let servers = try await service.parseServersFromJSON(json)
- #expect(servers.count == 1)
- #expect(servers["server"]?.command == "npx")
- }
-
- @Test("Parses single unnamed HTTP server")
- func parseSingleUnnamedHTTP() async throws {
- let json = """
- {
- "type": "http",
- "url": "https://mcp.example.com"
- }
- """
-
- let servers = try await service.parseServersFromJSON(json)
- #expect(servers.count == 1)
- #expect(servers["server"]?.url == "https://mcp.example.com")
- }
-
- @Test("Rejects invalid JSON")
- func parseInvalidJSON() async {
- do {
- _ = try await self.service.parseServersFromJSON("not json at all")
- Issue.record("Expected error for invalid JSON")
- } catch {
- #expect(error is MCPSharingError)
- }
- }
-
- @Test("Rejects empty JSON object")
- func parseEmptyObject() async {
- do {
- _ = try await self.service.parseServersFromJSON("{}")
- Issue.record("Expected error for empty JSON object")
- } catch {
- #expect(error is MCPSharingError)
- }
- }
-
- @Test("Rejects JSON array")
- func parseArray() async {
- do {
- _ = try await self.service.parseServersFromJSON("[1, 2, 3]")
- Issue.record("Expected error for JSON array")
- } catch {
- #expect(error is MCPSharingError)
- }
- }
-
- // MARK: - Sensitive Data Detection Tests
-
- @Test("Detects sensitive env vars across multiple servers")
- func detectSensitiveMultipleServers() async {
- let servers: [String: MCPServer] = [
- "github": .stdio(
- command: "npx",
- env: ["GITHUB_TOKEN": "tok", "PATH": "/bin"]
- ),
- "slack": .stdio(
- command: "npx",
- env: ["SLACK_API_KEY": "key"]
- ),
- ]
-
- let warnings = await service.detectSensitiveData(servers: servers)
- let warningKeys = warnings.map(\.key)
-
- #expect(warningKeys.contains("github.GITHUB_TOKEN"))
- #expect(warningKeys.contains("slack.SLACK_API_KEY"))
- #expect(!warningKeys.contains("github.PATH"))
- }
-
- @Test("Detects sensitive HTTP headers")
- func detectSensitiveHeaders() async {
- let servers: [String: MCPServer] = [
- "api": .http(
- url: "https://example.com",
- headers: [
- "Authorization": "Bearer tok",
- "Content-Type": "application/json",
- ]
- ),
- ]
-
- let warnings = await service.detectSensitiveData(servers: servers)
- let warningKeys = warnings.map(\.key)
-
- #expect(warningKeys.contains("api.Authorization"))
- #expect(!warningKeys.contains("api.Content-Type"))
- }
-
- @Test("Returns empty for servers without secrets")
- func noSensitiveData() async {
- let servers: [String: MCPServer] = [
- "safe": .stdio(command: "echo", env: ["NODE_ENV": "prod"]),
- "web": .http(url: "https://example.com"),
- ]
-
- let warnings = await service.detectSensitiveData(servers: servers)
- #expect(warnings.isEmpty)
- }
-
- @Test("containsSensitiveData returns correct boolean")
- func containsSensitiveDataBool() async {
- let withSecrets: [String: MCPServer] = [
- "gh": .stdio(command: "npx", env: ["GITHUB_TOKEN": "tok"]),
- ]
- let withoutSecrets: [String: MCPServer] = [
- "safe": .stdio(command: "echo"),
- ]
-
- #expect(await self.service.containsSensitiveData(servers: withSecrets) == true)
- #expect(await self.service.containsSensitiveData(servers: withoutSecrets) == false)
- }
-
- // MARK: - BulkImportResult Tests
-
- @Test("BulkImportResult summary formatting")
- func bulkImportResultSummary() {
- let result = BulkImportResult(
- imported: ["a", "b"],
- skipped: ["c"],
- renamed: ["d": "d-copy"],
- errors: []
- )
-
- #expect(result.totalImported == 3)
- #expect(result.summary.contains("2 imported"))
- #expect(result.summary.contains("1 renamed"))
- #expect(result.summary.contains("1 skipped"))
- }
-
- @Test("BulkImportResult empty summary")
- func bulkImportResultEmptySummary() {
- let result = BulkImportResult(
- imported: [],
- skipped: [],
- renamed: [:],
- errors: []
- )
-
- #expect(result.totalImported == 0)
- #expect(result.summary.isEmpty)
- }
-
- // MARK: - Serialization with Mixed Sources
-
- @Test("Serializes servers from different sources into single JSON")
- func serializeMixedSourceServers() async throws {
- // Simulates collecting servers from both global and project sources
- // as would happen with "Copy All as JSON" in project view
- let globalServer = MCPServer.http(url: "https://mcp.example.com")
- let projectServer = MCPServer.stdio(command: "npx", args: ["-y", "@mcp/server-github"])
-
- let allServers: [String: MCPServer] = [
- "api": globalServer,
- "github": projectServer,
- ]
-
- let json = try service.serializeToJSON(servers: allServers)
- let parsed = try await service.parseServersFromJSON(json)
-
- #expect(parsed.count == 2)
- #expect(parsed["api"]?.url == "https://mcp.example.com")
- #expect(parsed["github"]?.command == "npx")
- }
-
- @Test("Serializes server with additional properties")
- func serializeWithAdditionalProperties() async throws {
- let server = MCPServer(
- command: "npx",
- args: ["-y", "some-server"],
- additionalProperties: ["customField": "customValue"]
- )
-
- let json = try service.serializeToJSON(servers: ["test": server])
- let parsed = try await service.parseServersFromJSON(json)
-
- #expect(parsed["test"]?.command == "npx")
- #expect(parsed["test"]?.additionalProperties?["customField"]?.value as? String == "customValue")
- }
-}
diff --git a/Fig/Tests/ModelTests.swift b/Fig/Tests/ModelTests.swift
deleted file mode 100644
index 956f01f..0000000
--- a/Fig/Tests/ModelTests.swift
+++ /dev/null
@@ -1,738 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - TestFixtures
-
-enum TestFixtures {
- static let permissionsJSON = """
- {
- "allow": ["Bash(npm run *)", "Read(src/**)"],
- "deny": ["Read(.env)", "Bash(curl *)"],
- "futureField": "preserved"
- }
- """
-
- static let attributionJSON = """
- {
- "commits": true,
- "pullRequests": false,
- "unknownField": 123
- }
- """
-
- static let hookDefinitionJSON = """
- {
- "type": "command",
- "command": "npm run lint",
- "timeout": 30
- }
- """
-
- static let hookGroupJSON = """
- {
- "matcher": "Bash(*)",
- "hooks": [
- { "type": "command", "command": "npm run lint" }
- ],
- "priority": 1
- }
- """
-
- static let mcpServerStdioJSON = """
- {
- "command": "npx",
- "args": ["-y", "@modelcontextprotocol/server-github"],
- "env": { "GITHUB_TOKEN": "test-token" },
- "customOption": true
- }
- """
-
- static let mcpServerHTTPJSON = """
- {
- "type": "http",
- "url": "https://mcp.example.com/api",
- "headers": { "Authorization": "Bearer token" }
- }
- """
-
- static let mcpConfigJSON = """
- {
- "mcpServers": {
- "github": {
- "command": "npx",
- "args": ["-y", "@modelcontextprotocol/server-github"],
- "env": { "GITHUB_TOKEN": "test-token" }
- },
- "remote": {
- "type": "http",
- "url": "https://mcp.example.com/api"
- }
- },
- "version": "1.0"
- }
- """
-
- static let claudeSettingsJSON = """
- {
- "permissions": {
- "allow": ["Bash(npm run *)"],
- "deny": ["Read(.env)"]
- },
- "env": {
- "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "16384"
- },
- "hooks": {
- "PreToolUse": [
- { "matcher": "Bash(*)", "hooks": [{ "type": "command", "command": "echo pre" }] }
- ]
- },
- "disallowedTools": ["DangerousTool"],
- "attribution": {
- "commits": true,
- "pullRequests": true
- },
- "experimentalFeature": "enabled"
- }
- """
-
- static let projectEntryJSON = """
- {
- "allowedTools": ["Bash", "Read", "Write"],
- "hasTrustDialogAccepted": true,
- "history": ["conv-1", "conv-2"],
- "mcpServers": {
- "local": { "command": "node", "args": ["server.js"] }
- },
- "customData": { "nested": "value" }
- }
- """
-
- static let legacyConfigJSON = """
- {
- "projects": {
- "/path/to/project": {
- "allowedTools": ["Bash", "Read"],
- "hasTrustDialogAccepted": true
- }
- },
- "customApiKeyResponses": {
- "key1": "response1"
- },
- "preferences": {
- "theme": "dark"
- },
- "mcpServers": {
- "global-server": { "command": "npx", "args": ["server"] }
- },
- "analytics": false
- }
- """
-}
-
-// MARK: - AnyCodableTests
-
-@Suite("AnyCodable Tests")
-struct AnyCodableTests {
- @Test("Decodes and encodes primitive types")
- func primitiveTypes() throws {
- let json = """
- {
- "string": "hello",
- "int": 42,
- "double": 3.14,
- "bool": true,
- "null": null
- }
- """
-
- let decoded = try JSONDecoder().decode([String: AnyCodable].self, from: #require(json.data(using: .utf8)))
-
- #expect(decoded["string"]?.value as? String == "hello")
- // Note: All JSON numbers decode as Double for round-trip consistency
- #expect(decoded["int"]?.value as? Double == 42.0)
- #expect(decoded["double"]?.value as? Double == 3.14)
- #expect(decoded["bool"]?.value as? Bool == true)
- #expect(decoded["null"]?.value is NSNull)
-
- // Round-trip
- let encoded = try JSONEncoder().encode(decoded)
- let redecoded = try JSONDecoder().decode([String: AnyCodable].self, from: encoded)
- #expect(decoded == redecoded)
- }
-
- @Test("Decodes and encodes nested structures")
- func nestedStructures() throws {
- let json = """
- {
- "array": [1, 2, 3],
- "object": { "key": "value" }
- }
- """
-
- let decoded = try JSONDecoder().decode([String: AnyCodable].self, from: #require(json.data(using: .utf8)))
-
- if let array = decoded["array"]?.value as? [Any] {
- #expect(array.count == 3)
- } else {
- Issue.record("Array not decoded properly")
- }
-
- if let object = decoded["object"]?.value as? [String: Any] {
- #expect(object["key"] as? String == "value")
- } else {
- Issue.record("Object not decoded properly")
- }
-
- // Round-trip
- let encoded = try JSONEncoder().encode(decoded)
- let redecoded = try JSONDecoder().decode([String: AnyCodable].self, from: encoded)
- #expect(decoded == redecoded)
- }
-
- @Test("Supports literal initialization")
- func literalInitialization() {
- let _: AnyCodable = "string"
- let _: AnyCodable = 42
- let _: AnyCodable = 3.14
- let _: AnyCodable = true
- let _: AnyCodable = nil
- let _: AnyCodable = [1, 2, 3]
- let _: AnyCodable = ["key": "value"]
- }
-}
-
-// MARK: - PermissionsTests
-
-@Suite("Permissions Tests")
-struct PermissionsTests {
- @Test("Decodes permissions with allow and deny arrays")
- func decodesPermissions() throws {
- let decoded = try JSONDecoder().decode(
- Permissions.self,
- from: #require(TestFixtures.permissionsJSON.data(using: .utf8))
- )
-
- #expect(decoded.allow == ["Bash(npm run *)", "Read(src/**)"])
- #expect(decoded.deny == ["Read(.env)", "Bash(curl *)"])
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- Permissions.self,
- from: #require(TestFixtures.permissionsJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["futureField"]?.value as? String == "preserved")
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- Permissions.self,
- from: #require(TestFixtures.permissionsJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(Permissions.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - AttributionTests
-
-@Suite("Attribution Tests")
-struct AttributionTests {
- @Test("Decodes attribution settings")
- func decodesAttribution() throws {
- let decoded = try JSONDecoder().decode(
- Attribution.self,
- from: #require(TestFixtures.attributionJSON.data(using: .utf8))
- )
-
- #expect(decoded.commits == true)
- #expect(decoded.pullRequests == false)
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- Attribution.self,
- from: #require(TestFixtures.attributionJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["unknownField"]?.value as? Double == 123.0)
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- Attribution.self,
- from: #require(TestFixtures.attributionJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(Attribution.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - HookDefinitionTests
-
-@Suite("HookDefinition Tests")
-struct HookDefinitionTests {
- @Test("Decodes hook definition")
- func decodesHookDefinition() throws {
- let decoded = try JSONDecoder().decode(
- HookDefinition.self,
- from: #require(TestFixtures.hookDefinitionJSON.data(using: .utf8))
- )
-
- #expect(decoded.type == "command")
- #expect(decoded.command == "npm run lint")
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- HookDefinition.self,
- from: #require(TestFixtures.hookDefinitionJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["timeout"]?.value as? Double == 30.0)
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- HookDefinition.self,
- from: #require(TestFixtures.hookDefinitionJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(HookDefinition.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - HookGroupTests
-
-@Suite("HookGroup Tests")
-struct HookGroupTests {
- @Test("Decodes hook group with matcher and hooks")
- func decodesHookGroup() throws {
- let decoded = try JSONDecoder().decode(
- HookGroup.self,
- from: #require(TestFixtures.hookGroupJSON.data(using: .utf8))
- )
-
- #expect(decoded.matcher == "Bash(*)")
- #expect(decoded.hooks?.count == 1)
- #expect(decoded.hooks?.first?.type == "command")
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- HookGroup.self,
- from: #require(TestFixtures.hookGroupJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["priority"]?.value as? Double == 1.0)
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- HookGroup.self,
- from: #require(TestFixtures.hookGroupJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(HookGroup.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - MCPServerTests
-
-@Suite("MCPServer Tests")
-struct MCPServerTests {
- @Test("Decodes stdio server")
- func decodesStdioServer() throws {
- let decoded = try JSONDecoder().decode(
- MCPServer.self,
- from: #require(TestFixtures.mcpServerStdioJSON.data(using: .utf8))
- )
-
- #expect(decoded.command == "npx")
- #expect(decoded.args == ["-y", "@modelcontextprotocol/server-github"])
- #expect(decoded.env?["GITHUB_TOKEN"] == "test-token")
- #expect(decoded.isStdio == true)
- #expect(decoded.isHTTP == false)
- }
-
- @Test("Decodes HTTP server")
- func decodesHTTPServer() throws {
- let decoded = try JSONDecoder().decode(
- MCPServer.self,
- from: #require(TestFixtures.mcpServerHTTPJSON.data(using: .utf8))
- )
-
- #expect(decoded.type == "http")
- #expect(decoded.url == "https://mcp.example.com/api")
- #expect(decoded.headers?["Authorization"] == "Bearer token")
- #expect(decoded.isStdio == false)
- #expect(decoded.isHTTP == true)
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- MCPServer.self,
- from: #require(TestFixtures.mcpServerStdioJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["customOption"]?.value as? Bool == true)
- }
-
- @Test("Static factory methods work correctly")
- func factoryMethods() {
- let stdio = MCPServer.stdio(command: "node", args: ["server.js"], env: ["PORT": "3000"])
- #expect(stdio.isStdio == true)
- #expect(stdio.command == "node")
-
- let http = MCPServer.http(url: "https://api.example.com", headers: ["X-Key": "abc"])
- #expect(http.isHTTP == true)
- #expect(http.url == "https://api.example.com")
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- MCPServer.self,
- from: #require(TestFixtures.mcpServerStdioJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(MCPServer.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - MCPConfigTests
-
-@Suite("MCPConfig Tests")
-struct MCPConfigTests {
- @Test("Decodes MCP config with multiple servers")
- func decodesMCPConfig() throws {
- let decoded = try JSONDecoder().decode(
- MCPConfig.self,
- from: #require(TestFixtures.mcpConfigJSON.data(using: .utf8))
- )
-
- #expect(decoded.serverNames.count == 2)
- #expect(decoded.server(named: "github")?.command == "npx")
- #expect(decoded.server(named: "remote")?.isHTTP == true)
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- MCPConfig.self,
- from: #require(TestFixtures.mcpConfigJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["version"]?.value as? String == "1.0")
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- MCPConfig.self,
- from: #require(TestFixtures.mcpConfigJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(MCPConfig.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - ClaudeSettingsTests
-
-@Suite("ClaudeSettings Tests")
-struct ClaudeSettingsTests {
- @Test("Decodes complete settings")
- func decodesClaudeSettings() throws {
- let decoded = try JSONDecoder().decode(
- ClaudeSettings.self,
- from: #require(TestFixtures.claudeSettingsJSON.data(using: .utf8))
- )
-
- #expect(decoded.permissions?.allow == ["Bash(npm run *)"])
- #expect(decoded.permissions?.deny == ["Read(.env)"])
- #expect(decoded.env?["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] == "16384")
- #expect(decoded.hooks(for: "PreToolUse")?.count == 1)
- #expect(decoded.disallowedTools == ["DangerousTool"])
- #expect(decoded.isToolDisallowed("DangerousTool") == true)
- #expect(decoded.isToolDisallowed("SafeTool") == false)
- #expect(decoded.attribution?.commits == true)
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- ClaudeSettings.self,
- from: #require(TestFixtures.claudeSettingsJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["experimentalFeature"]?.value as? String == "enabled")
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- ClaudeSettings.self,
- from: #require(TestFixtures.claudeSettingsJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(ClaudeSettings.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - ProjectEntryTests
-
-@Suite("ProjectEntry Tests")
-struct ProjectEntryTests {
- @Test("Decodes project entry")
- func decodesProjectEntry() throws {
- let decoded = try JSONDecoder().decode(
- ProjectEntry.self,
- from: #require(TestFixtures.projectEntryJSON.data(using: .utf8))
- )
-
- #expect(decoded.allowedTools == ["Bash", "Read", "Write"])
- #expect(decoded.hasTrustDialogAccepted == true)
- #expect(decoded.history == ["conv-1", "conv-2"])
- #expect(decoded.hasMCPServers == true)
- }
-
- @Test("Computes name from path")
- func computesName() {
- var entry = ProjectEntry()
- entry.path = "/Users/test/projects/my-app"
- #expect(entry.name == "my-app")
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- ProjectEntry.self,
- from: #require(TestFixtures.projectEntryJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["customData"] != nil)
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- ProjectEntry.self,
- from: #require(TestFixtures.projectEntryJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(ProjectEntry.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - LegacyConfigTests
-
-@Suite("LegacyConfig Tests")
-struct LegacyConfigTests {
- @Test("Decodes legacy config")
- func decodesLegacyConfig() throws {
- let decoded = try JSONDecoder().decode(
- LegacyConfig.self,
- from: #require(TestFixtures.legacyConfigJSON.data(using: .utf8))
- )
-
- #expect(decoded.projectPaths == ["/path/to/project"])
- #expect(decoded.project(at: "/path/to/project")?.hasTrustDialogAccepted == true)
- #expect(decoded.globalServerNames == ["global-server"])
- #expect(decoded.globalServer(named: "global-server")?.command == "npx")
- }
-
- @Test("Returns all projects with paths set")
- func allProjectsWithPaths() throws {
- let decoded = try JSONDecoder().decode(
- LegacyConfig.self,
- from: #require(TestFixtures.legacyConfigJSON.data(using: .utf8))
- )
-
- let projects = decoded.allProjects
- #expect(projects.count == 1)
- #expect(projects.first?.path == "/path/to/project")
- }
-
- @Test("Preserves unknown keys")
- func preservesUnknownKeys() throws {
- let decoded = try JSONDecoder().decode(
- LegacyConfig.self,
- from: #require(TestFixtures.legacyConfigJSON.data(using: .utf8))
- )
-
- #expect(decoded.additionalProperties?["analytics"]?.value as? Bool == false)
- }
-
- @Test("Round-trip preserves data")
- func roundTrip() throws {
- let original = try JSONDecoder().decode(
- LegacyConfig.self,
- from: #require(TestFixtures.legacyConfigJSON.data(using: .utf8))
- )
- let encoded = try JSONEncoder().encode(original)
- let decoded = try JSONDecoder().decode(LegacyConfig.self, from: encoded)
-
- #expect(original == decoded)
- }
-}
-
-// MARK: - SendableTests
-
-@Suite("Sendable Conformance Tests")
-struct SendableTests {
- @Test("All models conform to Sendable")
- func sendableConformance() {
- // These compile-time checks ensure Sendable conformance
- let _: any Sendable = AnyCodable("test")
- let _: any Sendable = Permissions()
- let _: any Sendable = Attribution()
- let _: any Sendable = HookDefinition()
- let _: any Sendable = HookGroup()
- let _: any Sendable = MCPServer()
- let _: any Sendable = MCPConfig()
- let _: any Sendable = ClaudeSettings()
- let _: any Sendable = ProjectEntry()
- let _: any Sendable = LegacyConfig()
- let _: any Sendable = HookVariable(
- name: "$TEST",
- description: "test",
- events: [.preToolUse]
- )
- }
-}
-
-// MARK: - HookVariableTests
-
-@Suite("HookVariable Tests")
-struct HookVariableTests {
- @Test("All variables have names starting with $")
- func variableNamesValid() {
- for variable in HookVariable.all {
- #expect(!variable.name.isEmpty)
- #expect(variable.name.hasPrefix("$"))
- }
- }
-
- @Test("All variables have non-empty descriptions")
- func variableDescriptionsValid() {
- for variable in HookVariable.all {
- #expect(!variable.description.isEmpty)
- }
- }
-
- @Test("All variables have at least one event")
- func variablesHaveEvents() {
- for variable in HookVariable.all {
- #expect(!variable.events.isEmpty)
- }
- }
-
- @Test("Variable IDs are unique")
- func uniqueIds() {
- let ids = HookVariable.all.map(\.id)
- #expect(Set(ids).count == ids.count)
- }
-
- @Test("Catalog contains expected count")
- func variableCount() {
- #expect(HookVariable.all.count == 5)
- }
-
- @Test("Tool variables scoped to tool events")
- func toolVariableScoping() {
- let toolName = HookVariable.all.first { $0.name == "$CLAUDE_TOOL_NAME" }
- #expect(toolName != nil)
- #expect(toolName?.events.contains(.preToolUse) == true)
- #expect(toolName?.events.contains(.postToolUse) == true)
- #expect(toolName?.events.contains(.notification) == false)
- }
-
- @Test("Tool output scoped to PostToolUse only")
- func toolOutputScoping() {
- let toolOutput = HookVariable.all.first { $0.name == "$CLAUDE_TOOL_OUTPUT" }
- #expect(toolOutput != nil)
- #expect(toolOutput?.events == [.postToolUse])
- }
-
- @Test("Notification variable scoped to Notification only")
- func notificationVariableScoping() {
- let notification = HookVariable.all.first { $0.name == "$CLAUDE_NOTIFICATION" }
- #expect(notification != nil)
- #expect(notification?.events == [.notification])
- }
-
- @Test("ID matches name")
- func idMatchesName() {
- for variable in HookVariable.all {
- #expect(variable.id == variable.name)
- }
- }
-}
-
-// MARK: - HookEventTests
-
-@Suite("HookEvent Tests")
-struct HookEventTests {
- @Test("All cases have display names")
- func allCasesHaveDisplayNames() {
- for event in HookEvent.allCases {
- #expect(!event.displayName.isEmpty)
- }
- }
-
- @Test("All cases have icons")
- func allCasesHaveIcons() {
- for event in HookEvent.allCases {
- #expect(!event.icon.isEmpty)
- }
- }
-
- @Test("All cases have descriptions")
- func allCasesHaveDescriptions() {
- for event in HookEvent.allCases {
- #expect(!event.description.isEmpty)
- }
- }
-
- @Test("ID matches raw value")
- func idMatchesRawValue() {
- for event in HookEvent.allCases {
- #expect(event.id == event.rawValue)
- }
- }
-
- @Test("Tool use events support matchers")
- func toolUseEventsSupportMatchers() {
- #expect(HookEvent.preToolUse.supportsMatcher == true)
- #expect(HookEvent.postToolUse.supportsMatcher == true)
- #expect(HookEvent.notification.supportsMatcher == false)
- #expect(HookEvent.stop.supportsMatcher == false)
- }
-}
diff --git a/Fig/Tests/OnboardingViewModelTests.swift b/Fig/Tests/OnboardingViewModelTests.swift
deleted file mode 100644
index 628182c..0000000
--- a/Fig/Tests/OnboardingViewModelTests.swift
+++ /dev/null
@@ -1,195 +0,0 @@
-@testable import Fig
-import Testing
-
-@Suite("OnboardingViewModel Tests")
-struct OnboardingViewModelTests {
- // MARK: - Step Navigation
-
- @Suite("Step Navigation")
- struct StepNavigation {
- @Test("Starts on welcome step")
- @MainActor
- func startsOnWelcome() {
- let viewModel = OnboardingViewModel {}
- #expect(viewModel.currentStep == .welcome)
- }
-
- @Test("Advance moves to next step")
- @MainActor
- func advanceMovesToNextStep() {
- let viewModel = OnboardingViewModel {}
- viewModel.advance()
- #expect(viewModel.currentStep == .permissions)
- viewModel.advance()
- #expect(viewModel.currentStep == .discovery)
- viewModel.advance()
- #expect(viewModel.currentStep == .tour)
- viewModel.advance()
- #expect(viewModel.currentStep == .completion)
- }
-
- @Test("GoBack moves to previous step")
- @MainActor
- func goBackMovesToPreviousStep() {
- let viewModel = OnboardingViewModel {}
- viewModel.advance() // permissions
- viewModel.advance() // discovery
- viewModel.goBack()
- #expect(viewModel.currentStep == .permissions)
- }
-
- @Test("GoBack on welcome does nothing")
- @MainActor
- func goBackOnWelcomeDoesNothing() {
- let viewModel = OnboardingViewModel {}
- viewModel.goBack()
- #expect(viewModel.currentStep == .welcome)
- }
-
- @Test("Advance on completion calls onComplete")
- @MainActor
- func advanceOnCompletionCallsOnComplete() {
- var completed = false
- let viewModel = OnboardingViewModel { completed = true }
- // Navigate to completion
- viewModel.advance() // permissions
- viewModel.advance() // discovery
- viewModel.advance() // tour
- viewModel.advance() // completion
- #expect(!completed)
- viewModel.advance() // past completion
- #expect(completed)
- }
- }
-
- // MARK: - Skip Mechanism
-
- @Suite("Skip Mechanism")
- struct SkipMechanism {
- @Test("SkipToEnd calls onComplete immediately")
- @MainActor
- func skipToEndCallsOnComplete() {
- var completed = false
- let viewModel = OnboardingViewModel { completed = true }
- viewModel.skipToEnd()
- #expect(completed)
- }
-
- @Test("SkipTour jumps to completion step")
- @MainActor
- func skipTourJumpsToCompletion() {
- let viewModel = OnboardingViewModel {}
- viewModel.advance() // permissions
- viewModel.advance() // discovery
- viewModel.advance() // tour
- viewModel.skipTour()
- #expect(viewModel.currentStep == .completion)
- }
- }
-
- // MARK: - Progress
-
- @Suite("Progress")
- struct Progress {
- @Test("Progress is 0 on welcome")
- @MainActor
- func progressIsZeroOnWelcome() {
- let viewModel = OnboardingViewModel {}
- #expect(viewModel.progress == 0.0)
- }
-
- @Test("Progress is 1 on completion")
- @MainActor
- func progressIsOneOnCompletion() {
- let viewModel = OnboardingViewModel {}
- viewModel.advance() // permissions
- viewModel.advance() // discovery
- viewModel.advance() // tour
- viewModel.advance() // completion
- #expect(viewModel.progress == 1.0)
- }
-
- @Test("Progress increases with each step")
- @MainActor
- func progressIncreases() {
- let viewModel = OnboardingViewModel {}
- var lastProgress = viewModel.progress
- for _ in OnboardingViewModel.Step.allCases.dropFirst() {
- viewModel.advance()
- #expect(viewModel.progress > lastProgress)
- lastProgress = viewModel.progress
- }
- }
- }
-
- @Suite("State Properties")
- struct StateProperties {
- @Test("isFirstStep is true only on welcome")
- @MainActor
- func isFirstStepOnlyOnWelcome() {
- let viewModel = OnboardingViewModel {}
- #expect(viewModel.isFirstStep)
- viewModel.advance()
- #expect(!viewModel.isFirstStep)
- }
-
- @Test("isLastStep is true only on completion")
- @MainActor
- func isLastStepOnlyOnCompletion() {
- let viewModel = OnboardingViewModel {}
- #expect(!viewModel.isLastStep)
- viewModel.advance() // permissions
- viewModel.advance() // discovery
- viewModel.advance() // tour
- viewModel.advance() // completion
- #expect(viewModel.isLastStep)
- }
-
- @Test("Tour page starts at 0")
- @MainActor
- func tourPageStartsAtZero() {
- let viewModel = OnboardingViewModel {}
- #expect(viewModel.currentTourPage == 0)
- }
-
- @Test("Tour page count is 4")
- @MainActor
- func tourPageCountIsFour() {
- #expect(OnboardingViewModel.tourPageCount == 4)
- }
- }
-
- // MARK: - Discovery State
-
- @Suite("Discovery State")
- struct DiscoveryState {
- @Test("Starts with empty discovered projects")
- @MainActor
- func startsWithEmptyProjects() {
- let viewModel = OnboardingViewModel {}
- #expect(viewModel.discoveredProjects.isEmpty)
- #expect(!viewModel.isDiscovering)
- #expect(viewModel.discoveryError == nil)
- }
- }
-
- // MARK: - Step Enum
-
- @Suite("Step Enum")
- struct StepEnum {
- @Test("All cases have sequential raw values")
- @MainActor
- func allCasesSequential() {
- let cases = OnboardingViewModel.Step.allCases
- for (index, step) in cases.enumerated() {
- #expect(step.rawValue == index)
- }
- }
-
- @Test("Step count is 5")
- @MainActor
- func stepCountIsFive() {
- #expect(OnboardingViewModel.Step.allCases.count == 5)
- }
- }
-}
diff --git a/Fig/Tests/ProjectDetailViewModelTests.swift b/Fig/Tests/ProjectDetailViewModelTests.swift
deleted file mode 100644
index 47e5816..0000000
--- a/Fig/Tests/ProjectDetailViewModelTests.swift
+++ /dev/null
@@ -1,97 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-@Suite("ProjectDetailViewModel Tests")
-struct ProjectDetailViewModelTests {
- @Test("Initializes with correct project path")
- @MainActor
- func initializesWithPath() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project-a")
- #expect(viewModel.projectPath == "/Users/test/project-a")
- #expect(viewModel.projectName == "project-a")
- }
-
- @Test("Different paths produce distinct view models")
- @MainActor
- func differentPathsProduceDistinctViewModels() {
- let viewModelA = ProjectDetailViewModel(projectPath: "/Users/test/project-a")
- let viewModelB = ProjectDetailViewModel(projectPath: "/Users/test/project-b")
-
- #expect(viewModelA.projectPath != viewModelB.projectPath)
- #expect(viewModelA.projectName == "project-a")
- #expect(viewModelB.projectName == "project-b")
- #expect(viewModelA.projectURL != viewModelB.projectURL)
- }
-
- @Test("Project URL matches path")
- @MainActor
- func projectURLMatchesPath() {
- let path = "/Users/test/my-project"
- let viewModel = ProjectDetailViewModel(projectPath: path)
- #expect(viewModel.projectURL == URL(fileURLWithPath: path))
- }
-
- @Test("Default tab is permissions")
- @MainActor
- func defaultTabIsPermissions() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.selectedTab == .permissions)
- }
-
- @Test("Initial state is not loading")
- @MainActor
- func initialStateNotLoading() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.isLoading == false)
- }
-
- @Test("Project name derived from last path component")
- @MainActor
- func projectNameFromPath() {
- let viewModel = ProjectDetailViewModel(projectPath: "/a/b/c/my-app")
- #expect(viewModel.projectName == "my-app")
- }
-
- @Test("All permissions empty initially")
- @MainActor
- func allPermissionsEmptyInitially() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.allPermissions.isEmpty)
- }
-
- @Test("All MCP servers empty initially")
- @MainActor
- func allMCPServersEmptyInitially() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.allMCPServers.isEmpty)
- }
-
- @Test("All environment variables empty initially")
- @MainActor
- func allEnvVarsEmptyInitially() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.allEnvironmentVariables.isEmpty)
- }
-
- @Test("All disallowed tools empty initially")
- @MainActor
- func allDisallowedToolsEmptyInitially() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.allDisallowedTools.isEmpty)
- }
-
- @Test("Attribution settings nil initially")
- @MainActor
- func attributionSettingsNilInitially() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.attributionSettings == nil)
- }
-
- @Test("Merged settings nil initially")
- @MainActor
- func mergedSettingsNilInitially() {
- let viewModel = ProjectDetailViewModel(projectPath: "/Users/test/project")
- #expect(viewModel.mergedSettings == nil)
- }
-}
diff --git a/Fig/Tests/ProjectDiscoveryServiceTests.swift b/Fig/Tests/ProjectDiscoveryServiceTests.swift
deleted file mode 100644
index 1b7471f..0000000
--- a/Fig/Tests/ProjectDiscoveryServiceTests.swift
+++ /dev/null
@@ -1,278 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - ProjectDiscoveryServiceTests
-
-@Suite("ProjectDiscoveryService Tests")
-struct ProjectDiscoveryServiceTests {
- // MARK: - DiscoveredProject Tests
-
- @Suite("DiscoveredProject Tests")
- struct DiscoveredProjectTests {
- @Test("Initializes with all properties")
- func initialization() {
- let date = Date()
- let project = DiscoveredProject(
- path: "/Users/test/projects/my-app",
- displayName: "my-app",
- exists: true,
- hasSettings: true,
- hasLocalSettings: false,
- hasMCPConfig: true,
- lastModified: date
- )
-
- #expect(project.path == "/Users/test/projects/my-app")
- #expect(project.displayName == "my-app")
- #expect(project.exists == true)
- #expect(project.hasSettings == true)
- #expect(project.hasLocalSettings == false)
- #expect(project.hasMCPConfig == true)
- #expect(project.lastModified == date)
- }
-
- @Test("ID equals path")
- func idEqualsPath() {
- let project = DiscoveredProject(
- path: "/path/to/project",
- displayName: "project",
- exists: true,
- hasSettings: false,
- hasLocalSettings: false,
- hasMCPConfig: false,
- lastModified: nil
- )
-
- #expect(project.id == "/path/to/project")
- }
-
- @Test("URL property returns correct URL")
- func urlProperty() {
- let project = DiscoveredProject(
- path: "/Users/test/my-project",
- displayName: "my-project",
- exists: true,
- hasSettings: false,
- hasLocalSettings: false,
- hasMCPConfig: false,
- lastModified: nil
- )
-
- #expect(project.url.path == "/Users/test/my-project")
- }
-
- @Test("hasAnyConfig returns true when any config exists")
- func hasAnyConfigWithSettings() {
- let project = DiscoveredProject(
- path: "/path",
- displayName: "test",
- exists: true,
- hasSettings: true,
- hasLocalSettings: false,
- hasMCPConfig: false,
- lastModified: nil
- )
-
- #expect(project.hasAnyConfig == true)
- }
-
- @Test("hasAnyConfig returns true for local settings")
- func hasAnyConfigWithLocalSettings() {
- let project = DiscoveredProject(
- path: "/path",
- displayName: "test",
- exists: true,
- hasSettings: false,
- hasLocalSettings: true,
- hasMCPConfig: false,
- lastModified: nil
- )
-
- #expect(project.hasAnyConfig == true)
- }
-
- @Test("hasAnyConfig returns true for MCP config")
- func hasAnyConfigWithMCP() {
- let project = DiscoveredProject(
- path: "/path",
- displayName: "test",
- exists: true,
- hasSettings: false,
- hasLocalSettings: false,
- hasMCPConfig: true,
- lastModified: nil
- )
-
- #expect(project.hasAnyConfig == true)
- }
-
- @Test("hasAnyConfig returns false when no config exists")
- func hasAnyConfigFalse() {
- let project = DiscoveredProject(
- path: "/path",
- displayName: "test",
- exists: true,
- hasSettings: false,
- hasLocalSettings: false,
- hasMCPConfig: false,
- lastModified: nil
- )
-
- #expect(project.hasAnyConfig == false)
- }
-
- @Test("Projects with same path are equal")
- func equality() {
- let date = Date()
- let project1 = DiscoveredProject(
- path: "/path/to/project",
- displayName: "project",
- exists: true,
- hasSettings: true,
- hasLocalSettings: false,
- hasMCPConfig: false,
- lastModified: date
- )
- let project2 = DiscoveredProject(
- path: "/path/to/project",
- displayName: "project",
- exists: true,
- hasSettings: true,
- hasLocalSettings: false,
- hasMCPConfig: false,
- lastModified: date
- )
-
- #expect(project1 == project2)
- }
-
- @Test("Projects are hashable")
- func hashability() {
- let project = DiscoveredProject(
- path: "/path/to/project",
- displayName: "project",
- exists: true,
- hasSettings: false,
- hasLocalSettings: false,
- hasMCPConfig: false,
- lastModified: nil
- )
-
- var set = Set()
- set.insert(project)
-
- #expect(set.contains(project))
- }
- }
-
- // MARK: - Discovery Integration Tests
-
- @Suite("Discovery Integration Tests")
- struct DiscoveryIntegrationTests {
- @Test("Builds project metadata from filesystem path")
- func buildsProjectFromPath() async throws {
- // Create temporary directory structure
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- // Create mock home directory with .claude.json
- let mockHome = tempDir.appendingPathComponent("home")
- try FileManager.default.createDirectory(at: mockHome, withIntermediateDirectories: true)
-
- // Create a mock project directory
- let projectDir = tempDir.appendingPathComponent("projects/my-app")
- let claudeDir = projectDir.appendingPathComponent(".claude")
- try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true)
-
- // Create settings file
- let settingsData = """
- {
- "permissions": { "allow": ["Bash(*)"] }
- }
- """.data(using: .utf8)!
- try settingsData.write(to: claudeDir.appendingPathComponent("settings.json"))
-
- // Test the buildDiscoveredProject method directly
- let service = ProjectDiscoveryService()
- let project = await service.refreshProject(at: projectDir.path)
-
- #expect(project != nil)
- #expect(project?.displayName == "my-app")
- #expect(project?.exists == true)
- #expect(project?.hasSettings == true)
- #expect(project?.hasLocalSettings == false)
- }
-
- @Test("Handles non-existent project gracefully")
- func handlesMissingProject() async {
- let service = ProjectDiscoveryService()
- let project = await service.refreshProject(at: "/nonexistent/path/12345")
-
- #expect(project != nil)
- #expect(project?.exists == false)
- #expect(project?.hasSettings == false)
- }
-
- @Test("Detects MCP config file")
- func detectsMCPConfig() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
-
- // Create .mcp.json
- let mcpData = """
- { "mcpServers": {} }
- """.data(using: .utf8)!
- try mcpData.write(to: tempDir.appendingPathComponent(".mcp.json"))
-
- let service = ProjectDiscoveryService()
- let project = await service.refreshProject(at: tempDir.path)
-
- #expect(project?.hasMCPConfig == true)
- #expect(project?.hasSettings == false)
- }
-
- @Test("Detects local settings file")
- func detectsLocalSettings() async throws {
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString)
- defer { try? FileManager.default.removeItem(at: tempDir) }
-
- let claudeDir = tempDir.appendingPathComponent(".claude")
- try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true)
-
- // Create settings.local.json
- let localData = """
- { "env": { "DEBUG": "true" } }
- """.data(using: .utf8)!
- try localData.write(to: claudeDir.appendingPathComponent("settings.local.json"))
-
- let service = ProjectDiscoveryService()
- let project = await service.refreshProject(at: tempDir.path)
-
- #expect(project?.hasLocalSettings == true)
- #expect(project?.hasSettings == false)
- }
- }
-
- // MARK: - Default Scan Directories Tests
-
- @Suite("Default Scan Directories Tests")
- struct DefaultScanDirectoriesTests {
- @Test("Includes common development directories")
- func includesCommonDirectories() {
- let defaults = ProjectDiscoveryService.defaultScanDirectories
-
- #expect(defaults.contains("~"))
- #expect(defaults.contains("~/code"))
- #expect(defaults.contains("~/Code"))
- #expect(defaults.contains("~/projects"))
- #expect(defaults.contains("~/Projects"))
- #expect(defaults.contains("~/Developer"))
- }
- }
-}
diff --git a/Fig/Tests/ProjectExplorerViewModelTests.swift b/Fig/Tests/ProjectExplorerViewModelTests.swift
deleted file mode 100644
index 008e22d..0000000
--- a/Fig/Tests/ProjectExplorerViewModelTests.swift
+++ /dev/null
@@ -1,289 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - ProjectExplorerViewModelTests
-
-@Suite("ProjectExplorerViewModel Tests")
-@MainActor
-struct ProjectExplorerViewModelTests {
- // MARK: - Grouping
-
- @Suite("Grouping by Parent Directory")
- @MainActor
- struct GroupingTests {
- @Test("isGroupedByParent defaults to false")
- func defaultsToFalse() {
- let vm = ProjectExplorerViewModel()
- #expect(vm.isGroupedByParent == false)
- }
-
- @Test("groupedProjects groups by parent directory")
- func groupsByParent() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/Users/test/code/project-a"),
- ProjectEntry(path: "/Users/test/code/project-b"),
- ProjectEntry(path: "/Users/test/repos/other"),
- ]
-
- let groups = vm.groupedProjects
- #expect(groups.count == 2)
-
- let codeGroup = groups.first { $0.parentPath == "/Users/test/code" }
- #expect(codeGroup != nil)
- #expect(codeGroup?.projects.count == 2)
-
- let reposGroup = groups.first { $0.parentPath == "/Users/test/repos" }
- #expect(reposGroup != nil)
- #expect(reposGroup?.projects.count == 1)
- }
-
- @Test("groupedProjects are sorted by parent path")
- func groupsSortedByPath() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/z/project"),
- ProjectEntry(path: "/a/project"),
- ]
-
- let groups = vm.groupedProjects
- #expect(groups.count == 2)
- #expect(groups.first?.parentPath == "/a")
- #expect(groups.last?.parentPath == "/z")
- }
-
- @Test("projects within group sorted by name")
- func projectsSortedWithinGroup() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/code/zebra"),
- ProjectEntry(path: "/code/alpha"),
- ProjectEntry(path: "/code/middle"),
- ]
-
- let groups = vm.groupedProjects
- #expect(groups.count == 1)
- #expect(groups.first?.projects.count == 3)
- #expect(groups.first?.projects[0].name == "alpha")
- #expect(groups.first?.projects[1].name == "middle")
- #expect(groups.first?.projects[2].name == "zebra")
- }
-
- @Test("groupedProjects respects search filter")
- func respectsSearchFilter() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/code/alpha"),
- ProjectEntry(path: "/code/beta"),
- ]
- vm.searchQuery = "alpha"
-
- let groups = vm.groupedProjects
- #expect(groups.count == 1)
- #expect(groups.first?.projects.count == 1)
- #expect(groups.first?.projects.first?.name == "alpha")
- }
-
- @Test("groupedProjects returns empty when no projects")
- func emptyWhenNoProjects() {
- let vm = ProjectExplorerViewModel()
- #expect(vm.groupedProjects.isEmpty)
- }
-
- @Test("projects without path go into Unknown group")
- func nilPathGroup() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: nil),
- ]
-
- let groups = vm.groupedProjects
- #expect(groups.count == 1)
- #expect(groups.first?.parentPath == "Unknown")
- }
-
- @Test("multiple parent directories create separate groups")
- func multipleParents() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/workspace/fig/doha"),
- ProjectEntry(path: "/workspace/fig/almaty"),
- ProjectEntry(path: "/workspace/fig/cebu"),
- ProjectEntry(path: "/workspace/other/project"),
- ProjectEntry(path: "/home/personal"),
- ]
-
- let groups = vm.groupedProjects
- #expect(groups.count == 3)
-
- let figGroup = groups.first { $0.parentPath == "/workspace/fig" }
- #expect(figGroup?.projects.count == 3)
-
- let otherGroup = groups.first { $0.parentPath == "/workspace/other" }
- #expect(otherGroup?.projects.count == 1)
-
- let homeGroup = groups.first { $0.parentPath == "/home" }
- #expect(homeGroup?.projects.count == 1)
- }
-
- @Test("search that eliminates all projects from a group hides that group")
- func searchHidesEmptyGroups() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/code/alpha"),
- ProjectEntry(path: "/repos/beta"),
- ]
- vm.searchQuery = "alpha"
-
- let groups = vm.groupedProjects
- #expect(groups.count == 1)
- #expect(groups.first?.parentPath == "/code")
- }
-
- @Test("group displayName abbreviates home directory")
- func displayNameAbbreviation() {
- let vm = ProjectExplorerViewModel()
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- vm.projects = [
- ProjectEntry(path: "\(home)/code/project"),
- ]
-
- let groups = vm.groupedProjects
- #expect(groups.first?.displayName == "~/code")
- }
- }
-
- // MARK: - Missing Projects
-
- @Suite("Missing Projects")
- @MainActor
- struct MissingProjectsTests {
- @Test("missingProjects returns projects with non-existent paths")
- func identifiesMissing() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/nonexistent/path/12345"),
- ProjectEntry(path: "/tmp"),
- ]
-
- let missing = vm.missingProjects
- #expect(missing.count == 1)
- #expect(missing.first?.path == "/nonexistent/path/12345")
- }
-
- @Test("hasMissingProjects is true when missing exist")
- func hasMissingTrue() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/nonexistent/path/12345"),
- ]
- #expect(vm.hasMissingProjects == true)
- }
-
- @Test("hasMissingProjects is false when all exist")
- func hasMissingFalse() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/tmp"),
- ]
- #expect(vm.hasMissingProjects == false)
- }
-
- @Test("hasMissingProjects is false when empty")
- func hasMissingEmpty() {
- let vm = ProjectExplorerViewModel()
- #expect(vm.hasMissingProjects == false)
- }
-
- @Test("missingProjects includes nil-path projects")
- func nilPathIsMissing() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: nil),
- ]
-
- #expect(vm.missingProjects.count == 1)
- }
-
- @Test("missingProjects with mix of existing and non-existing")
- func mixedExistence() {
- let vm = ProjectExplorerViewModel()
- vm.projects = [
- ProjectEntry(path: "/tmp"),
- ProjectEntry(path: "/nonexistent/a"),
- ProjectEntry(path: "/nonexistent/b"),
- ProjectEntry(path: "/tmp"),
- ]
-
- #expect(vm.missingProjects.count == 2)
- #expect(vm.hasMissingProjects == true)
- }
- }
-
- // MARK: - Favorites Interaction
-
- @Suite("Favorites and Recents")
- @MainActor
- struct FavoritesTests {
- @Test("toggleFavorite adds and removes")
- func toggleFavorite() {
- let vm = ProjectExplorerViewModel()
- let id = UUID().uuidString
- let project = ProjectEntry(path: "/test/toggle-\(id)")
-
- vm.toggleFavorite(project)
- #expect(vm.isFavorite(project))
-
- vm.toggleFavorite(project)
- #expect(!vm.isFavorite(project))
- }
-
- @Test("favoriteProjects excludes projects not in favorites")
- func favoriteProjectsFiltering() {
- let vm = ProjectExplorerViewModel()
- let id = UUID().uuidString
- let fav = ProjectEntry(path: "/test/fav-filter-\(id)")
- let regular = ProjectEntry(path: "/test/reg-filter-\(id)")
- vm.projects = [fav, regular]
- vm.toggleFavorite(fav)
-
- #expect(vm.favoriteProjects.count == 1)
- #expect(vm.favoriteProjects.first?.path == fav.path)
- }
-
- @Test("filteredProjects excludes favorites and recents")
- func filteredExcludesFavorites() {
- let vm = ProjectExplorerViewModel()
- let id = UUID().uuidString
- let fav = ProjectEntry(path: "/test/fav-excl-\(id)")
- let regular = ProjectEntry(path: "/test/reg-excl-\(id)")
- vm.projects = [fav, regular]
- vm.toggleFavorite(fav)
-
- #expect(vm.filteredProjects.count == 1)
- #expect(vm.filteredProjects.first?.path == regular.path)
- }
- }
-
- // MARK: - Grouped Projects and Favorites Interaction
-
- @Suite("Grouping with Favorites")
- @MainActor
- struct GroupingWithFavoritesTests {
- @Test("groupedProjects excludes favorites")
- func excludesFavorites() {
- let vm = ProjectExplorerViewModel()
- let id = UUID().uuidString
- let fav = ProjectEntry(path: "/code/fav-grp-\(id)")
- let regular = ProjectEntry(path: "/code/reg-grp-\(id)")
- vm.projects = [fav, regular]
- vm.toggleFavorite(fav)
-
- let groups = vm.groupedProjects
- #expect(groups.count == 1)
- #expect(groups.first?.projects.count == 1)
- #expect(groups.first?.projects.first?.path == regular.path)
- }
- }
-}
diff --git a/Fig/Tests/ProjectGroupTests.swift b/Fig/Tests/ProjectGroupTests.swift
deleted file mode 100644
index 87c4e9c..0000000
--- a/Fig/Tests/ProjectGroupTests.swift
+++ /dev/null
@@ -1,51 +0,0 @@
-@testable import Fig
-import Testing
-
-@Suite("ProjectGroup Tests")
-struct ProjectGroupTests {
- @Test("id equals parentPath")
- func idEqualsParentPath() {
- let group = ProjectGroup(
- parentPath: "/Users/test/code",
- displayName: "~/code",
- projects: []
- )
- #expect(group.id == "/Users/test/code")
- }
-
- @Test("stores projects correctly")
- func storesProjects() {
- let projects = [
- ProjectEntry(path: "/code/alpha"),
- ProjectEntry(path: "/code/beta"),
- ]
- let group = ProjectGroup(
- parentPath: "/code",
- displayName: "/code",
- projects: projects
- )
- #expect(group.projects.count == 2)
- #expect(group.projects[0].path == "/code/alpha")
- #expect(group.projects[1].path == "/code/beta")
- }
-
- @Test("preserves displayName")
- func preservesDisplayName() {
- let group = ProjectGroup(
- parentPath: "/Users/alice/code",
- displayName: "~/code",
- projects: []
- )
- #expect(group.displayName == "~/code")
- }
-
- @Test("empty projects list is valid")
- func emptyProjectsList() {
- let group = ProjectGroup(
- parentPath: "/empty",
- displayName: "/empty",
- projects: []
- )
- #expect(group.projects.isEmpty)
- }
-}
diff --git a/Fig/Tests/SettingsMergeServiceTests.swift b/Fig/Tests/SettingsMergeServiceTests.swift
deleted file mode 100644
index e2c9e97..0000000
--- a/Fig/Tests/SettingsMergeServiceTests.swift
+++ /dev/null
@@ -1,570 +0,0 @@
-@testable import Fig
-import Foundation
-import Testing
-
-// MARK: - ConfigSourceTests
-
-@Suite("ConfigSource Tests")
-struct ConfigSourceTests {
- @Test("Has correct precedence order")
- func precedenceOrder() {
- #expect(ConfigSource.global.precedence == 0)
- #expect(ConfigSource.projectShared.precedence == 1)
- #expect(ConfigSource.projectLocal.precedence == 2)
- }
-
- @Test("Comparable implementation")
- func comparable() {
- #expect(ConfigSource.global < ConfigSource.projectShared)
- #expect(ConfigSource.projectShared < ConfigSource.projectLocal)
- #expect(ConfigSource.global < ConfigSource.projectLocal)
- }
-
- @Test("Display names are descriptive")
- func displayNames() {
- #expect(ConfigSource.global.displayName == "Global")
- #expect(ConfigSource.projectShared.displayName == "Project")
- #expect(ConfigSource.projectLocal.displayName == "Local")
- }
-
- @Test("File names are correct")
- func fileNames() {
- #expect(ConfigSource.global.fileName == "~/.claude/settings.json")
- #expect(ConfigSource.projectShared.fileName == ".claude/settings.json")
- #expect(ConfigSource.projectLocal.fileName == ".claude/settings.local.json")
- }
-
- @Test("CaseIterable provides all cases")
- func caseIterable() {
- let allCases = ConfigSource.allCases
- #expect(allCases.count == 3)
- #expect(allCases.contains(.global))
- #expect(allCases.contains(.projectShared))
- #expect(allCases.contains(.projectLocal))
- }
-}
-
-// MARK: - MergedValueTests
-
-@Suite("MergedValue Tests")
-struct MergedValueTests {
- @Test("Stores value and source")
- func storesValueAndSource() {
- let merged = MergedValue(value: "test-value", source: .projectLocal)
-
- #expect(merged.value == "test-value")
- #expect(merged.source == .projectLocal)
- }
-
- @Test("Equatable implementation")
- func equatable() {
- let merged1 = MergedValue(value: "test", source: .global)
- let merged2 = MergedValue(value: "test", source: .global)
- let merged3 = MergedValue(value: "test", source: .projectLocal)
- let merged4 = MergedValue(value: "different", source: .global)
-
- #expect(merged1 == merged2)
- #expect(merged1 != merged3)
- #expect(merged1 != merged4)
- }
-
- @Test("Hashable implementation")
- func hashable() {
- let merged = MergedValue(value: "test", source: .global)
- var set = Set>()
- set.insert(merged)
-
- #expect(set.contains(merged))
- }
-}
-
-// MARK: - MergedPermissionsTests
-
-@Suite("MergedPermissions Tests")
-struct MergedPermissionsTests {
- @Test("Initializes with empty arrays by default")
- func defaultInitialization() {
- let permissions = MergedPermissions()
-
- #expect(permissions.allow.isEmpty)
- #expect(permissions.deny.isEmpty)
- }
-
- @Test("Provides allow patterns")
- func allowPatterns() {
- let permissions = MergedPermissions(
- allow: [
- MergedValue(value: "Bash(*)", source: .global),
- MergedValue(value: "Read(src/**)", source: .projectShared),
- ]
- )
-
- #expect(permissions.allowPatterns == ["Bash(*)", "Read(src/**)"])
- }
-
- @Test("Provides deny patterns")
- func denyPatterns() {
- let permissions = MergedPermissions(
- deny: [
- MergedValue(value: "Read(.env)", source: .global),
- MergedValue(value: "Bash(rm *)", source: .projectLocal),
- ]
- )
-
- #expect(permissions.denyPatterns == ["Read(.env)", "Bash(rm *)"])
- }
-}
-
-// MARK: - MergedHooksTests
-
-@Suite("MergedHooks Tests")
-struct MergedHooksTests {
- @Test("Returns event names")
- func eventNames() {
- let hook1 = HookGroup(matcher: "Bash(*)", hooks: [])
- let hook2 = HookGroup(matcher: "Read(*)", hooks: [])
-
- let mergedHooks = MergedHooks(hooks: [
- "PreToolUse": [MergedValue(value: hook1, source: .global)],
- "PostToolUse": [MergedValue(value: hook2, source: .projectShared)],
- ])
-
- let names = mergedHooks.eventNames
- #expect(names.contains("PreToolUse"))
- #expect(names.contains("PostToolUse"))
- }
-
- @Test("Returns groups for event")
- func groupsForEvent() {
- let hook = HookGroup(matcher: "Bash(*)", hooks: [])
- let mergedHooks = MergedHooks(hooks: [
- "PreToolUse": [MergedValue(value: hook, source: .global)],
- ])
-
- let groups = mergedHooks.groups(for: "PreToolUse")
- #expect(groups?.count == 1)
- #expect(mergedHooks.groups(for: "NonExistent") == nil)
- }
-}
-
-// MARK: - MergedSettingsTests
-
-@Suite("MergedSettings Tests")
-struct MergedSettingsTests {
- @Test("Returns effective environment variables")
- func effectiveEnv() {
- let settings = MergedSettings(
- env: [
- "DEBUG": MergedValue(value: "true", source: .projectLocal),
- "API_URL": MergedValue(value: "https://api.example.com", source: .global),
- ]
- )
-
- let env = settings.effectiveEnv
- #expect(env["DEBUG"] == "true")
- #expect(env["API_URL"] == "https://api.example.com")
- }
-
- @Test("Returns effective disallowed tools")
- func effectiveDisallowedTools() {
- let settings = MergedSettings(
- disallowedTools: [
- MergedValue(value: "DangerousTool", source: .global),
- MergedValue(value: "AnotherTool", source: .projectShared),
- ]
- )
-
- #expect(settings.effectiveDisallowedTools == ["DangerousTool", "AnotherTool"])
- }
-
- @Test("Checks if tool is disallowed")
- func isToolDisallowed() {
- let settings = MergedSettings(
- disallowedTools: [
- MergedValue(value: "DangerousTool", source: .global),
- ]
- )
-
- #expect(settings.isToolDisallowed("DangerousTool") == true)
- #expect(settings.isToolDisallowed("SafeTool") == false)
- }
-
- @Test("Returns env source")
- func envSource() {
- let settings = MergedSettings(
- env: [
- "DEBUG": MergedValue(value: "true", source: .projectLocal),
- ]
- )
-
- #expect(settings.envSource(for: "DEBUG") == .projectLocal)
- #expect(settings.envSource(for: "MISSING") == nil)
- }
-}
-
-// MARK: - SettingsMergeServiceTests
-
-@Suite("SettingsMergeService Tests")
-struct SettingsMergeServiceTests {
- @Suite("Permission Merging Tests")
- struct PermissionMergingTests {
- @Test("Unions allow patterns from all sources")
- func unionsAllowPatterns() async {
- let global = ClaudeSettings(
- permissions: Permissions(allow: ["Bash(npm run *)"])
- )
- let projectShared = ClaudeSettings(
- permissions: Permissions(allow: ["Read(src/**)"])
- )
- let projectLocal = ClaudeSettings(
- permissions: Permissions(allow: ["Write(docs/**)"])
- )
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: projectShared,
- projectLocal: projectLocal
- )
-
- let patterns = merged.permissions.allowPatterns
- #expect(patterns.contains("Bash(npm run *)"))
- #expect(patterns.contains("Read(src/**)"))
- #expect(patterns.contains("Write(docs/**)"))
- }
-
- @Test("Unions deny patterns from all sources")
- func unionsDenyPatterns() async {
- let global = ClaudeSettings(
- permissions: Permissions(deny: ["Read(.env)"])
- )
- let projectShared = ClaudeSettings(
- permissions: Permissions(deny: ["Bash(curl *)"])
- )
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: projectShared,
- projectLocal: nil
- )
-
- let patterns = merged.permissions.denyPatterns
- #expect(patterns.contains("Read(.env)"))
- #expect(patterns.contains("Bash(curl *)"))
- }
-
- @Test("Deduplicates permission patterns")
- func deduplicatesPatterns() async {
- let global = ClaudeSettings(
- permissions: Permissions(allow: ["Bash(*)"])
- )
- let projectShared = ClaudeSettings(
- permissions: Permissions(allow: ["Bash(*)", "Read(*)"])
- )
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: projectShared,
- projectLocal: nil
- )
-
- let patterns = merged.permissions.allowPatterns
- #expect(patterns.count == 2)
- #expect(patterns.count(where: { $0 == "Bash(*)" }) == 1)
- }
-
- @Test("Tracks source for each permission")
- func tracksPermissionSource() async {
- let global = ClaudeSettings(
- permissions: Permissions(allow: ["Bash(*)"])
- )
- let projectLocal = ClaudeSettings(
- permissions: Permissions(allow: ["Read(*)"])
- )
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: projectLocal
- )
-
- let bashEntry = merged.permissions.allow.first { $0.value == "Bash(*)" }
- let readEntry = merged.permissions.allow.first { $0.value == "Read(*)" }
-
- #expect(bashEntry?.source == .global)
- #expect(readEntry?.source == .projectLocal)
- }
- }
-
- @Suite("Environment Variable Merging Tests")
- struct EnvMergingTests {
- @Test("Higher precedence overrides lower")
- func higherPrecedenceOverrides() async {
- let global = ClaudeSettings(env: ["DEBUG": "false", "LOG_LEVEL": "info"])
- let projectLocal = ClaudeSettings(env: ["DEBUG": "true"])
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: projectLocal
- )
-
- #expect(merged.effectiveEnv["DEBUG"] == "true")
- #expect(merged.effectiveEnv["LOG_LEVEL"] == "info")
- #expect(merged.envSource(for: "DEBUG") == .projectLocal)
- #expect(merged.envSource(for: "LOG_LEVEL") == .global)
- }
-
- @Test("Preserves all unique keys")
- func preservesUniqueKeys() async {
- let global = ClaudeSettings(env: ["GLOBAL_VAR": "global"])
- let projectShared = ClaudeSettings(env: ["SHARED_VAR": "shared"])
- let projectLocal = ClaudeSettings(env: ["LOCAL_VAR": "local"])
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: projectShared,
- projectLocal: projectLocal
- )
-
- #expect(merged.effectiveEnv.count == 3)
- #expect(merged.effectiveEnv["GLOBAL_VAR"] == "global")
- #expect(merged.effectiveEnv["SHARED_VAR"] == "shared")
- #expect(merged.effectiveEnv["LOCAL_VAR"] == "local")
- }
- }
-
- @Suite("Hook Merging Tests")
- struct HookMergingTests {
- @Test("Concatenates hooks by event type")
- func concatenatesHooks() async {
- let globalHook = HookGroup(
- matcher: "Bash(*)",
- hooks: [HookDefinition(type: "command", command: "echo global")]
- )
- let localHook = HookGroup(
- matcher: "Read(*)",
- hooks: [HookDefinition(type: "command", command: "echo local")]
- )
-
- let global = ClaudeSettings(hooks: ["PreToolUse": [globalHook]])
- let projectLocal = ClaudeSettings(hooks: ["PreToolUse": [localHook]])
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: projectLocal
- )
-
- let preToolUseHooks = merged.hooks.groups(for: "PreToolUse")
- #expect(preToolUseHooks?.count == 2)
-
- let sources = preToolUseHooks?.map(\.source)
- #expect(sources?.contains(.global) == true)
- #expect(sources?.contains(.projectLocal) == true)
- }
-
- @Test("Preserves hooks for different events")
- func preservesDifferentEvents() async {
- let preHook = HookGroup(hooks: [HookDefinition(type: "command", command: "pre")])
- let postHook = HookGroup(hooks: [HookDefinition(type: "command", command: "post")])
-
- let global = ClaudeSettings(hooks: [
- "PreToolUse": [preHook],
- "PostToolUse": [postHook],
- ])
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: nil
- )
-
- #expect(merged.hooks.eventNames.count == 2)
- #expect(merged.hooks.groups(for: "PreToolUse") != nil)
- #expect(merged.hooks.groups(for: "PostToolUse") != nil)
- }
- }
-
- @Suite("Disallowed Tools Merging Tests")
- struct DisallowedToolsMergingTests {
- @Test("Unions disallowed tools")
- func unionsDisallowedTools() async {
- let global = ClaudeSettings(disallowedTools: ["Tool1"])
- let projectShared = ClaudeSettings(disallowedTools: ["Tool2"])
- let projectLocal = ClaudeSettings(disallowedTools: ["Tool3"])
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: projectShared,
- projectLocal: projectLocal
- )
-
- #expect(merged.effectiveDisallowedTools.count == 3)
- #expect(merged.isToolDisallowed("Tool1"))
- #expect(merged.isToolDisallowed("Tool2"))
- #expect(merged.isToolDisallowed("Tool3"))
- }
-
- @Test("Deduplicates disallowed tools")
- func deduplicatesDisallowedTools() async {
- let global = ClaudeSettings(disallowedTools: ["Tool1"])
- let projectLocal = ClaudeSettings(disallowedTools: ["Tool1", "Tool2"])
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: projectLocal
- )
-
- #expect(merged.effectiveDisallowedTools.count == 2)
- }
- }
-
- @Suite("Attribution Merging Tests")
- struct AttributionMergingTests {
- @Test("Higher precedence wins for scalar values")
- func higherPrecedenceWins() async {
- let global = ClaudeSettings(attribution: Attribution(commits: false, pullRequests: false))
- let projectLocal = ClaudeSettings(attribution: Attribution(commits: true, pullRequests: true))
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: projectLocal
- )
-
- #expect(merged.attribution?.value.commits == true)
- #expect(merged.attribution?.value.pullRequests == true)
- #expect(merged.attribution?.source == .projectLocal)
- }
-
- @Test("Falls back to lower precedence when higher is nil")
- func fallsBackToLowerPrecedence() async {
- let global = ClaudeSettings(attribution: Attribution(commits: true))
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: nil
- )
-
- #expect(merged.attribution?.value.commits == true)
- #expect(merged.attribution?.source == .global)
- }
-
- @Test("Returns nil when no source has attribution")
- func returnsNilWhenNoAttribution() async {
- let global = ClaudeSettings()
- let projectLocal = ClaudeSettings()
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: nil,
- projectLocal: projectLocal
- )
-
- #expect(merged.attribution == nil)
- }
- }
-
- @Suite("Empty Settings Tests")
- struct EmptySettingsTests {
- @Test("Handles all nil settings")
- func handlesAllNil() async {
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: nil,
- projectShared: nil,
- projectLocal: nil
- )
-
- #expect(merged.permissions.allow.isEmpty)
- #expect(merged.permissions.deny.isEmpty)
- #expect(merged.env.isEmpty)
- #expect(merged.hooks.eventNames.isEmpty)
- #expect(merged.disallowedTools.isEmpty)
- #expect(merged.attribution == nil)
- }
-
- @Test("Handles empty settings objects")
- func handlesEmptySettings() async {
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: ClaudeSettings(),
- projectShared: ClaudeSettings(),
- projectLocal: ClaudeSettings()
- )
-
- #expect(merged.permissions.allow.isEmpty)
- #expect(merged.env.isEmpty)
- }
- }
-
- @Suite("Integration Tests")
- struct IntegrationTests {
- @Test("Complex merge scenario")
- func complexMerge() async {
- let global = ClaudeSettings(
- permissions: Permissions(
- allow: ["Bash(npm run *)"],
- deny: ["Read(.env)"]
- ),
- env: ["LOG_LEVEL": "info", "DEBUG": "false"],
- disallowedTools: ["DangerousTool"],
- attribution: Attribution(commits: false)
- )
-
- let projectShared = ClaudeSettings(
- permissions: Permissions(
- allow: ["Read(src/**)"]
- ),
- env: ["API_URL": "https://api.example.com"]
- )
-
- let projectLocal = ClaudeSettings(
- permissions: Permissions(
- deny: ["Bash(rm *)"]
- ),
- env: ["DEBUG": "true"],
- attribution: Attribution(commits: true, pullRequests: true)
- )
-
- let service = SettingsMergeService()
- let merged = await service.mergeSettings(
- global: global,
- projectShared: projectShared,
- projectLocal: projectLocal
- )
-
- // Permissions are unioned
- #expect(merged.permissions.allowPatterns.count == 2)
- #expect(merged.permissions.denyPatterns.count == 2)
-
- // Env vars: DEBUG overridden by projectLocal
- #expect(merged.effectiveEnv["DEBUG"] == "true")
- #expect(merged.envSource(for: "DEBUG") == .projectLocal)
- #expect(merged.effectiveEnv["LOG_LEVEL"] == "info")
- #expect(merged.effectiveEnv["API_URL"] == "https://api.example.com")
-
- // Disallowed tools unioned
- #expect(merged.isToolDisallowed("DangerousTool"))
-
- // Attribution from highest precedence
- #expect(merged.attribution?.value.commits == true)
- #expect(merged.attribution?.source == .projectLocal)
- }
- }
-}
diff --git a/Fig/Tests/SidebarItemTests.swift b/Fig/Tests/SidebarItemTests.swift
deleted file mode 100644
index 8d7dee3..0000000
--- a/Fig/Tests/SidebarItemTests.swift
+++ /dev/null
@@ -1,33 +0,0 @@
-@testable import Fig
-import Testing
-
-@Suite("SidebarItem Tests")
-struct SidebarItemTests {
- @Test("All cases have non-empty titles")
- func allCasesHaveTitles() {
- for item in SidebarItem.allCases {
- #expect(!item.title.isEmpty)
- }
- }
-
- @Test("All cases have valid SF Symbol icons")
- func allCasesHaveIcons() {
- for item in SidebarItem.allCases {
- #expect(!item.icon.isEmpty)
- }
- }
-
- @Test("All cases have descriptions")
- func allCasesHaveDescriptions() {
- for item in SidebarItem.allCases {
- #expect(!item.description.isEmpty)
- }
- }
-
- @Test("ID matches raw value")
- func idMatchesRawValue() {
- for item in SidebarItem.allCases {
- #expect(item.id == item.rawValue)
- }
- }
-}
diff --git a/Fig/lefthook.yml b/Fig/lefthook.yml
deleted file mode 100644
index abc638b..0000000
--- a/Fig/lefthook.yml
+++ /dev/null
@@ -1,67 +0,0 @@
-# lefthook.yml
-# Git hooks configuration for Fig
-
-# Run commands from the Fig directory where Package.swift is located
-source_dir: "{root}/Fig"
-
-pre-commit:
- parallel: true
- commands:
- swift-format:
- glob: "*.swift"
- run: |
- if command -v swiftformat &> /dev/null; then
- swiftformat --lint {staged_files}
- else
- echo "SwiftFormat not installed, skipping format check"
- fi
-
- swift-lint:
- glob: "*.swift"
- run: |
- if command -v swiftlint &> /dev/null; then
- swiftlint lint {staged_files} --quiet
- else
- echo "SwiftLint not installed, skipping lint check"
- fi
-
- trailing-whitespace:
- run: |
- FILES=$(git diff --cached --name-only)
- if [ -n "$FILES" ] && echo "$FILES" | xargs grep -l "[[:blank:]]$" 2>/dev/null; then
- echo "Error: Trailing whitespace detected in staged files"
- exit 1
- fi
-
-pre-push:
- parallel: false
- commands:
- build:
- run: swift build --quiet
- fail_text: "Build failed. Fix build errors before pushing."
-
- test:
- run: swift test --quiet
- fail_text: "Tests failed. Fix failing tests before pushing."
-
-commit-msg:
- commands:
- conventional-commit:
- run: |
- # Validate conventional commit format
- MSG=$(head -1 {1})
- if ! echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|build|ci)(\(.+\))?\!?: .{1,}$"; then
- echo "Error: Commit message must follow Conventional Commits format"
- echo ""
- echo "Format: (): "
- echo ""
- echo "Types: feat, fix, docs, style, refactor, perf, test, chore, build, ci"
- echo ""
- echo "Examples:"
- echo " feat(parser): add ability to parse arrays"
- echo " fix: resolve crash on startup"
- echo " docs: update README with installation instructions"
- echo ""
- echo "Your message: $MSG"
- exit 1
- fi
diff --git a/Fig/scripts/bundle.sh b/Fig/scripts/bundle.sh
deleted file mode 100755
index 2daeb3b..0000000
--- a/Fig/scripts/bundle.sh
+++ /dev/null
@@ -1,95 +0,0 @@
-#!/bin/bash
-set -e
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
-REPO_ROOT="$(dirname "$PROJECT_ROOT")"
-
-# Change to project root where Package.swift lives
-cd "$PROJECT_ROOT"
-
-# Absolute path for output
-ABS_BUILD_DIR="$(pwd)/.build"
-
-CONFIG="${1:-debug}"
-
-# Build the executable
-echo "Building Fig (${CONFIG})..."
-swift build -c "${CONFIG}" --quiet
-APP_NAME="Fig"
-BUNDLE_DIR=".build/${CONFIG}/${APP_NAME}.app"
-CONTENTS_DIR="${BUNDLE_DIR}/Contents"
-MACOS_DIR="${CONTENTS_DIR}/MacOS"
-RESOURCES_DIR="${CONTENTS_DIR}/Resources"
-
-# Create bundle structure
-rm -rf "${BUNDLE_DIR}"
-mkdir -p "${MACOS_DIR}"
-mkdir -p "${RESOURCES_DIR}"
-
-# Copy executable
-cp ".build/${CONFIG}/${APP_NAME}" "${MACOS_DIR}/"
-
-# Copy entitlements if they exist
-if [ -f "Fig.entitlements" ]; then
- cp "Fig.entitlements" "${CONTENTS_DIR}/"
-fi
-
-# Create app icon from fig-logo.png
-LOGO_PATH="${REPO_ROOT}/docs/public/fig-logo.png"
-if [ -f "$LOGO_PATH" ]; then
- ICONSET_DIR=$(mktemp -d)/AppIcon.iconset
- mkdir -p "$ICONSET_DIR"
-
- # Generate all required icon sizes
- sips -z 16 16 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_16x16.png" >/dev/null
- sips -z 32 32 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null
- sips -z 32 32 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null
- sips -z 64 64 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null
- sips -z 128 128 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null
- sips -z 256 256 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null
- sips -z 256 256 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null
- sips -z 512 512 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null
- sips -z 512 512 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null
- sips -z 1024 1024 "$LOGO_PATH" --out "${ICONSET_DIR}/icon_512x512@2x.png" >/dev/null
-
- # Convert iconset to icns
- iconutil -c icns "$ICONSET_DIR" -o "${RESOURCES_DIR}/AppIcon.icns"
- rm -rf "$(dirname "$ICONSET_DIR")"
- echo "Created app icon from fig-logo.png"
-fi
-
-# Create Info.plist
-cat >"${CONTENTS_DIR}/Info.plist" <<'EOF'
-
-
-
-
- CFBundleIdentifier
- io.utensils.fig
- CFBundleName
- Fig
- CFBundleDisplayName
- Fig
- CFBundleVersion
- 1
- CFBundleShortVersionString
- 1.0.0
- CFBundlePackageType
- APPL
- CFBundleExecutable
- Fig
- CFBundleIconFile
- AppIcon
- LSMinimumSystemVersion
- 14.0
- NSHighResolutionCapable
-
-
-
-EOF
-
-# Ad-hoc sign the bundle
-codesign --force --deep --sign - "${BUNDLE_DIR}" 2>/dev/null
-
-echo "Run: open ${ABS_BUILD_DIR}/${CONFIG}/${APP_NAME}.app"
diff --git a/Fig/scripts/setup.sh b/Fig/scripts/setup.sh
deleted file mode 100755
index 844eb32..0000000
--- a/Fig/scripts/setup.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/bash
-# setup.sh
-# Development environment setup script for Fig
-
-set -e
-
-echo "Setting up Fig development environment..."
-echo ""
-
-# Check for Homebrew
-if ! command -v brew &> /dev/null; then
- echo "Error: Homebrew is required but not installed."
- echo "Install it from: https://brew.sh"
- exit 1
-fi
-
-# Get the directory where the script is located
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-FIG_DIR="$(dirname "$SCRIPT_DIR")"
-
-# Install Homebrew dependencies
-echo "Installing Homebrew dependencies..."
-brew bundle install --file="$FIG_DIR/Brewfile"
-
-# Change to Fig directory for remaining commands
-cd "$FIG_DIR"
-
-# Install git hooks
-echo ""
-echo "Installing git hooks..."
-lefthook install
-
-# Resolve Swift package dependencies
-echo ""
-echo "Resolving Swift package dependencies..."
-swift package resolve
-
-echo ""
-echo "Setup complete!"
-echo ""
-echo "From the Fig directory, you can now:"
-echo " - Build: swift build"
-echo " - Test: swift test"
-echo " - Format: swiftformat ."
-echo " - Lint: swiftlint lint"
diff --git a/README.md b/README.md
index a287867..43bdc20 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-A native macOS application for managing [Claude Code](https://github.com/anthropics/claude-code) configuration.
+A cross-platform desktop application for managing [Claude Code](https://github.com/anthropics/claude-code) configuration.
## What is Fig?
@@ -31,21 +31,23 @@ Fig provides a visual interface for managing Claude Code settings, MCP servers,
## Requirements
-- macOS 14.0 (Sonoma) or later
-- Xcode 16.0 or later (for building from source)
-- Swift 6.0
+- Rust 1.78 or later
## Installation
### From Source
```bash
-git clone https://github.com/doomspork/fig.git
-cd fig/Fig
-swift build
+git clone https://github.com/utensils/fig.git
+cd fig
+cargo build --release
```
-Or open `Fig/Package.swift` in Xcode and press Cmd+R to build and run.
+Run with:
+
+```bash
+cargo run --release
+```
### Pre-built Binary
@@ -54,26 +56,25 @@ Coming soon.
## Project Structure
```
-Fig/
-├── Package.swift # Swift Package Manager manifest
-├── Fig.entitlements # Code signing entitlements
-└── Sources/
- ├── App/ # Application entry point
- ├── Models/ # Data models (Sendable conformant)
- ├── ViewModels/ # View models (@MainActor)
- ├── Views/ # SwiftUI views
- ├── Services/ # Business logic (actors for I/O)
- └── Utilities/ # Helper utilities
+fig-core/ # Library crate: models, services, error types
+├── src/
+│ ├── models/ # Data models (serde, Clone, PartialEq)
+│ ├── services/ # Business logic (config I/O, health checks, merging)
+│ └── error.rs # Error types
+fig-ui/ # Binary crate: Iced GUI application
+├── src/
+│ ├── views/ # View functions (detail, sidebar, editors)
+│ ├── styles.rs # Theme constants
+│ └── main.rs # App state, Message enum, update/view
+Cargo.toml # Workspace manifest
```
## Architecture
-Fig uses the MVVM pattern with Swift 6 strict concurrency:
+Fig uses a Cargo workspace with two crates and the Iced Elm architecture:
-- **Models** — Pure data structures conforming to `Sendable`
-- **ViewModels** — `@MainActor` classes for UI state management
-- **Views** — SwiftUI views with `NavigationSplitView` layout
-- **Services** — Actor-based services for thread-safe file I/O
+- **fig-core** — Pure library with models and services. No GUI dependency. Handles configuration file I/O, settings merging, health checks, and MCP operations.
+- **fig-ui** — Binary using [Iced](https://iced.rs) 0.13. Follows the Elm pattern: `Model` (app state) → `Message` (events) → `update` (state transitions) → `view` (render UI).
## Configuration Files
diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx
index 3d46a54..cafd1bd 100644
--- a/docs/src/content/docs/getting-started.mdx
+++ b/docs/src/content/docs/getting-started.mdx
@@ -7,9 +7,7 @@ import { Steps, Aside } from '@astrojs/starlight/components';
## System Requirements
-- **macOS 14.0** (Sonoma) or later
-- **Xcode 16.0+** (for building from source)
-- **Swift 6.0**
+- **Rust 1.78+** (for building from source)
- [Claude Code](https://github.com/anthropics/claude-code) installed and configured
## Installation
@@ -24,19 +22,23 @@ import { Steps, Aside } from '@astrojs/starlight/components';
git clone https://github.com/utensils/fig.git
```
-2. Build with Swift Package Manager:
+2. Build with Cargo:
```bash
- cd fig/Fig
- swift build
+ cd fig
+ cargo build --release
```
- Or open `Package.swift` in Xcode and press **Cmd+R** to build and run.
+3. Run the application:
+
+ ```bash
+ cargo run --release
+ ```
## First Run
@@ -58,7 +60,7 @@ Fig explains which configuration files it needs to read and write:
| `/.claude/settings.json` | Per-project settings |
| `/.mcp.json` | MCP server configuration |
-Fig accesses these files through standard macOS file system APIs. No special permissions are required beyond normal file access.
+Fig accesses these files through standard file system APIs. No special permissions are required beyond normal file access.
### 3. Project Discovery
diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro
index 313d1af..fb74262 100644
--- a/docs/src/pages/index.astro
+++ b/docs/src/pages/index.astro
@@ -8,9 +8,9 @@ import { Image } from 'astro:assets';
Fig - Visual Claude Code Configuration
-
+
-
+
@@ -289,8 +289,7 @@ import { Image } from 'astro:assets';
A visual interface for Claude Code configuration
Stop hand-editing scattered JSON files. Fig discovers your projects
- and lets you manage settings, MCP servers, and hooks from one native
- macOS app.
+ and lets you manage settings, MCP servers, and hooks from one app.
Get Started
@@ -333,11 +332,11 @@ import { Image } from 'astro:assets';
# Clone and build from source
git clone https://github.com/utensils/fig.git
- cd fig/Fig
- swift build
+ cd fig
+ cargo build --release
- # Or open in Xcode
- open Package.swift
+ # Run the application
+ cargo run --release
@@ -347,7 +346,7 @@ import { Image } from 'astro:assets';