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 @@ Fig logo

-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';
  • GitHub
  • MIT License
  • -

    Fig is a native macOS app for managing Claude Code configuration.

    +

    Fig is a desktop app for managing Claude Code configuration.

    diff --git a/fig-core/Cargo.toml b/fig-core/Cargo.toml new file mode 100644 index 0000000..79b3642 --- /dev/null +++ b/fig-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "fig-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +dirs = "6" +notify = "7" +tokio = { version = "1", features = ["full"] } +log = "0.4" +env_logger = "0.11" +reqwest = { version = "0.12", features = ["json"] } +arboard = "3" +walkdir = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/fig-core/src/error.rs b/fig-core/src/error.rs new file mode 100644 index 0000000..9b8f074 --- /dev/null +++ b/fig-core/src/error.rs @@ -0,0 +1,225 @@ +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum FigError { + #[error("Configuration error: {0}")] + Config(#[from] ConfigFileError), + + #[error("Health check error: {0}")] + HealthCheck(String), + + #[error("MCP error: {0}")] + Mcp(String), + + #[error("Bundle error: {0}")] + Bundle(#[from] ConfigBundleError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Other(String), +} + +#[derive(Debug, Error)] +pub enum ConfigFileError { + #[error("File not found: {path}")] + FileNotFound { path: PathBuf }, + + #[error("Permission denied: {path}")] + PermissionDenied { path: PathBuf }, + + #[error("Failed to read {path}: {message}")] + ReadError { path: PathBuf, message: String }, + + #[error("Invalid JSON in {path}: {message}")] + InvalidJson { path: PathBuf, message: String }, + + #[error("Failed to write {path}: {message}")] + WriteError { path: PathBuf, message: String }, + + #[error("Backup failed for {path}: {message}")] + BackupFailed { path: PathBuf, message: String }, + + #[error("Circular symlink detected at {path}")] + CircularSymlink { path: PathBuf }, +} + +impl ConfigFileError { + pub fn recovery_suggestion(&self) -> &str { + match self { + Self::FileNotFound { .. } => "The file will be created when you save settings.", + Self::PermissionDenied { .. } => "Check file permissions and try again.", + Self::ReadError { .. } => "Check that the file exists and is readable.", + Self::InvalidJson { .. } => { + "The file contains invalid JSON. Fix it manually or delete it to start fresh." + } + Self::WriteError { .. } => "Check disk space and file permissions.", + Self::BackupFailed { .. } => "Check disk space. The original file was not modified.", + Self::CircularSymlink { .. } => "Remove the circular symlink and try again.", + } + } +} + +#[derive(Debug, Error)] +pub enum ConfigBundleError { + #[error("Invalid bundle format: {0}")] + InvalidFormat(String), + + #[error("Unsupported bundle version: {version}")] + UnsupportedVersion { version: String }, + + #[error("Export failed: {0}")] + ExportFailed(String), + + #[error("Import failed: {0}")] + ImportFailed(String), + + #[error("No components selected for export")] + NoComponentsSelected, + + #[error("Project not found: {path}")] + ProjectNotFound { path: PathBuf }, +} + +#[derive(Debug, Error)] +pub enum MCPHealthCheckError { + #[error("Failed to spawn process: {0}")] + ProcessSpawnFailed(String), + + #[error("Process exited early with code {code}")] + ProcessExitedEarly { code: i32 }, + + #[error("Invalid handshake response: {0}")] + InvalidHandshakeResponse(String), + + #[error("HTTP request failed with status {status}")] + HttpRequestFailed { status: u16 }, + + #[error("Network error: {0}")] + NetworkError(String), + + #[error("Health check timed out after {seconds}s")] + Timeout { seconds: u64 }, + + #[error("Server has no command or URL configured")] + NoCommandOrUrl, +} + +impl MCPHealthCheckError { + pub fn recovery_suggestion(&self) -> &str { + match self { + Self::ProcessSpawnFailed(_) => "Check that the command exists and is in your PATH.", + Self::ProcessExitedEarly { .. } => "The server crashed on startup. Check its logs.", + Self::InvalidHandshakeResponse(_) => { + "The server did not respond with valid MCP protocol." + } + Self::HttpRequestFailed { .. } => "Check the server URL and that it's running.", + Self::NetworkError(_) => "Check your network connection and the server URL.", + Self::Timeout { .. } => "The server took too long to respond. It may be overloaded.", + Self::NoCommandOrUrl => { + "Configure either a command (stdio) or URL (HTTP) for this server." + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_file_error_display() { + let err = ConfigFileError::FileNotFound { + path: PathBuf::from("/test/path.json"), + }; + assert!(format!("{err}").contains("/test/path.json")); + + let err = ConfigFileError::InvalidJson { + path: PathBuf::from("/test.json"), + message: "unexpected EOF".to_string(), + }; + assert!(format!("{err}").contains("/test.json")); + assert!(format!("{err}").contains("unexpected EOF")); + } + + #[test] + fn test_fig_error_from_config() { + let config_err = ConfigFileError::FileNotFound { + path: PathBuf::from("/test"), + }; + let fig_err: FigError = config_err.into(); + assert!(matches!(fig_err, FigError::Config(_))); + } + + #[test] + fn test_fig_error_from_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found"); + let fig_err: FigError = io_err.into(); + assert!(matches!(fig_err, FigError::Io(_))); + } + + #[test] + fn test_fig_error_from_bundle() { + let bundle_err = ConfigBundleError::NoComponentsSelected; + let fig_err: FigError = bundle_err.into(); + assert!(matches!(fig_err, FigError::Bundle(_))); + } + + #[test] + fn test_recovery_suggestions_non_empty() { + let errors = vec![ + ConfigFileError::FileNotFound { + path: PathBuf::new(), + }, + ConfigFileError::PermissionDenied { + path: PathBuf::new(), + }, + ConfigFileError::ReadError { + path: PathBuf::new(), + message: String::new(), + }, + ConfigFileError::InvalidJson { + path: PathBuf::new(), + message: String::new(), + }, + ConfigFileError::WriteError { + path: PathBuf::new(), + message: String::new(), + }, + ConfigFileError::BackupFailed { + path: PathBuf::new(), + message: String::new(), + }, + ConfigFileError::CircularSymlink { + path: PathBuf::new(), + }, + ]; + for err in &errors { + assert!( + !err.recovery_suggestion().is_empty(), + "Empty recovery suggestion for {err}" + ); + } + } + + #[test] + fn test_mcp_health_check_recovery_suggestions_non_empty() { + let errors: Vec = vec![ + MCPHealthCheckError::ProcessSpawnFailed("test".into()), + MCPHealthCheckError::ProcessExitedEarly { code: 1 }, + MCPHealthCheckError::InvalidHandshakeResponse("test".into()), + MCPHealthCheckError::HttpRequestFailed { status: 500 }, + MCPHealthCheckError::NetworkError("test".into()), + MCPHealthCheckError::Timeout { seconds: 30 }, + MCPHealthCheckError::NoCommandOrUrl, + ]; + for err in &errors { + assert!( + !err.recovery_suggestion().is_empty(), + "Empty recovery suggestion for {err}" + ); + } + } +} diff --git a/fig-core/src/lib.rs b/fig-core/src/lib.rs new file mode 100644 index 0000000..2ecb516 --- /dev/null +++ b/fig-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod error; +pub mod models; +pub mod services; diff --git a/fig-core/src/models/attribution.rs b/fig-core/src/models/attribution.rs new file mode 100644 index 0000000..3131718 --- /dev/null +++ b/fig-core/src/models/attribution.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct Attribution { + #[serde(skip_serializing_if = "Option::is_none")] + pub commits: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "pullRequests")] + pub pull_requests: Option, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_attribution_round_trip() { + let json = r#"{"commits":true,"pullRequests":false,"unknownField":123}"#; + let parsed: Attribution = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.commits, Some(true)); + assert_eq!(parsed.pull_requests, Some(false)); + assert_eq!( + parsed.additional_properties.get("unknownField"), + Some(&serde_json::json!(123)) + ); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: Attribution = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_attribution_empty() { + let parsed: Attribution = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, Attribution::default()); + } + + #[test] + fn test_attribution_camel_case_rename() { + let a = Attribution { + commits: Some(true), + pull_requests: Some(false), + ..Default::default() + }; + let json = serde_json::to_string(&a).unwrap(); + assert!(json.contains("pullRequests")); + assert!(!json.contains("pull_requests")); + } +} diff --git a/fig-core/src/models/claude_settings.rs b/fig-core/src/models/claude_settings.rs new file mode 100644 index 0000000..6ae6612 --- /dev/null +++ b/fig-core/src/models/claude_settings.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +use super::attribution::Attribution; +use super::hook_group::HookGroup; +use super::permissions::Permissions; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct ClaudeSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub hooks: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "disallowedTools")] + pub disallowed_tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub attribution: Option, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +impl ClaudeSettings { + pub fn hooks_for(&self, event: &str) -> Option<&Vec> { + self.hooks.as_ref()?.get(event) + } + + pub fn hook_event_names(&self) -> Vec { + self.hooks + .as_ref() + .map(|h| h.keys().cloned().collect()) + .unwrap_or_default() + } + + pub fn is_tool_disallowed(&self, tool_name: &str) -> bool { + self.disallowed_tools + .as_ref() + .map(|tools| tools.contains(&tool_name.to_string())) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CLAUDE_SETTINGS_JSON: &str = r#"{"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"}"#; + + #[test] + fn test_claude_settings_full_round_trip() { + let parsed: ClaudeSettings = serde_json::from_str(CLAUDE_SETTINGS_JSON).unwrap(); + assert_eq!( + parsed.permissions.as_ref().unwrap().allow, + Some(vec!["Bash(npm run *)".to_string()]) + ); + assert_eq!( + parsed + .env + .as_ref() + .unwrap() + .get("CLAUDE_CODE_MAX_OUTPUT_TOKENS"), + Some(&"16384".to_string()) + ); + assert_eq!( + parsed.disallowed_tools, + Some(vec!["DangerousTool".to_string()]) + ); + assert_eq!(parsed.attribution.as_ref().unwrap().commits, Some(true)); + assert_eq!( + parsed.additional_properties.get("experimentalFeature"), + Some(&Value::String("enabled".to_string())) + ); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: ClaudeSettings = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_claude_settings_minimal() { + let parsed: ClaudeSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, ClaudeSettings::default()); + } + + #[test] + fn test_claude_settings_hooks_lookup() { + let parsed: ClaudeSettings = serde_json::from_str(CLAUDE_SETTINGS_JSON).unwrap(); + assert!(parsed.hooks_for("PreToolUse").is_some()); + assert_eq!(parsed.hooks_for("PreToolUse").unwrap().len(), 1); + assert!(parsed.hooks_for("NonExistent").is_none()); + } + + #[test] + fn test_claude_settings_disallowed_tool_check() { + let parsed: ClaudeSettings = serde_json::from_str(CLAUDE_SETTINGS_JSON).unwrap(); + assert!(parsed.is_tool_disallowed("DangerousTool")); + assert!(!parsed.is_tool_disallowed("SafeTool")); + } + + #[test] + fn test_claude_settings_hook_event_names() { + let parsed: ClaudeSettings = serde_json::from_str(CLAUDE_SETTINGS_JSON).unwrap(); + let names = parsed.hook_event_names(); + assert!(names.contains(&"PreToolUse".to_string())); + } +} diff --git a/fig-core/src/models/config_source.rs b/fig-core/src/models/config_source.rs new file mode 100644 index 0000000..4f4dedb --- /dev/null +++ b/fig-core/src/models/config_source.rs @@ -0,0 +1,62 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConfigSource { + Global, + ProjectShared, + ProjectLocal, +} + +impl ConfigSource { + pub fn label(&self) -> &'static str { + match self { + Self::Global => "Global", + Self::ProjectShared => "Project (shared)", + Self::ProjectLocal => "Project (local)", + } + } + + pub fn precedence(&self) -> u8 { + match self { + Self::Global => 0, + Self::ProjectShared => 1, + Self::ProjectLocal => 2, + } + } +} + +impl fmt::Display for ConfigSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.label()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_source_precedence() { + assert_eq!(ConfigSource::Global.precedence(), 0); + assert_eq!(ConfigSource::ProjectShared.precedence(), 1); + assert_eq!(ConfigSource::ProjectLocal.precedence(), 2); + assert!(ConfigSource::ProjectLocal.precedence() > ConfigSource::Global.precedence()); + } + + #[test] + fn test_config_source_display() { + assert_eq!(format!("{}", ConfigSource::Global), "Global"); + assert_eq!( + format!("{}", ConfigSource::ProjectShared), + "Project (shared)" + ); + assert_eq!(format!("{}", ConfigSource::ProjectLocal), "Project (local)"); + } + + #[test] + fn test_config_source_label() { + assert_eq!(ConfigSource::Global.label(), "Global"); + assert_eq!(ConfigSource::ProjectShared.label(), "Project (shared)"); + assert_eq!(ConfigSource::ProjectLocal.label(), "Project (local)"); + } +} diff --git a/fig-core/src/models/discovered_project.rs b/fig-core/src/models/discovered_project.rs new file mode 100644 index 0000000..bf6ba9c --- /dev/null +++ b/fig-core/src/models/discovered_project.rs @@ -0,0 +1,150 @@ +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +#[derive(Debug, Clone, PartialEq)] +pub struct DiscoveredProject { + pub path: PathBuf, + pub display_name: String, + pub exists: bool, + pub has_settings: bool, + pub has_local_settings: bool, + pub has_mcp_config: bool, + pub last_modified: Option, +} + +impl DiscoveredProject { + pub fn new( + path: PathBuf, + display_name: String, + exists: bool, + has_settings: bool, + has_local_settings: bool, + has_mcp_config: bool, + last_modified: Option, + ) -> Self { + Self { + path, + display_name, + exists, + has_settings, + has_local_settings, + has_mcp_config, + last_modified, + } + } + + pub fn id(&self) -> &Path { + &self.path + } + + pub fn has_any_config(&self) -> bool { + self.has_settings || self.has_local_settings || self.has_mcp_config + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_project() { + let project = DiscoveredProject::new( + PathBuf::from("/Users/sean/code/relay"), + "relay".to_string(), + true, + true, + false, + true, + None, + ); + assert_eq!(project.display_name, "relay"); + assert!(project.exists); + assert!(project.has_settings); + assert!(!project.has_local_settings); + assert!(project.has_mcp_config); + } + + #[test] + fn test_id_is_path() { + let project = DiscoveredProject::new( + PathBuf::from("/my/project"), + "project".to_string(), + true, + false, + false, + false, + None, + ); + assert_eq!(project.id(), Path::new("/my/project")); + } + + #[test] + fn test_has_any_config() { + let no_config = DiscoveredProject::new( + PathBuf::from("/a"), + "a".to_string(), + true, + false, + false, + false, + None, + ); + assert!(!no_config.has_any_config()); + + let with_settings = DiscoveredProject::new( + PathBuf::from("/b"), + "b".to_string(), + true, + true, + false, + false, + None, + ); + assert!(with_settings.has_any_config()); + + let with_local = DiscoveredProject::new( + PathBuf::from("/c"), + "c".to_string(), + true, + false, + true, + false, + None, + ); + assert!(with_local.has_any_config()); + + let with_mcp = DiscoveredProject::new( + PathBuf::from("/d"), + "d".to_string(), + true, + false, + false, + true, + None, + ); + assert!(with_mcp.has_any_config()); + } + + #[test] + fn test_equality() { + let a = DiscoveredProject::new( + PathBuf::from("/a"), + "a".to_string(), + true, + false, + false, + false, + None, + ); + let b = DiscoveredProject::new( + PathBuf::from("/a"), + "a".to_string(), + true, + false, + false, + false, + None, + ); + assert_eq!(a, b); + } +} diff --git a/fig-core/src/models/editable_hook_types.rs b/fig-core/src/models/editable_hook_types.rs new file mode 100644 index 0000000..279e011 --- /dev/null +++ b/fig-core/src/models/editable_hook_types.rs @@ -0,0 +1,282 @@ +use std::fmt; + +use uuid::Uuid; + +use super::hook_definition::HookDefinition; +use super::hook_group::HookGroup; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HookEvent { + PreToolUse, + PostToolUse, + Notification, + Stop, +} + +impl HookEvent { + pub fn display_name(&self) -> &str { + match self { + Self::PreToolUse => "Pre Tool Use", + Self::PostToolUse => "Post Tool Use", + Self::Notification => "Notification", + Self::Stop => "Stop", + } + } + + pub fn key(&self) -> &str { + match self { + Self::PreToolUse => "PreToolUse", + Self::PostToolUse => "PostToolUse", + Self::Notification => "Notification", + Self::Stop => "Stop", + } + } + + pub fn description(&self) -> &str { + match self { + Self::PreToolUse => "Runs before a tool is executed. Can block the tool.", + Self::PostToolUse => "Runs after a tool has been executed.", + Self::Notification => "Runs when Claude sends a notification.", + Self::Stop => "Runs when Claude finishes a response.", + } + } + + pub fn supports_matcher(&self) -> bool { + matches!(self, Self::PreToolUse | Self::PostToolUse) + } + + pub fn all() -> &'static [HookEvent] { + &[ + Self::PreToolUse, + Self::PostToolUse, + Self::Notification, + Self::Stop, + ] + } +} + +impl fmt::Display for HookEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.display_name()) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EditableHookDefinition { + pub id: Uuid, + pub hook_type: String, + pub command: String, + pub timeout: Option, +} + +impl EditableHookDefinition { + pub fn new(command: String) -> Self { + Self { + id: Uuid::new_v4(), + hook_type: "command".to_string(), + command, + timeout: None, + } + } + + pub fn from_definition(def: &HookDefinition) -> Self { + let timeout = def + .additional_properties + .get("timeout") + .and_then(|v| v.as_u64()); + Self { + id: Uuid::new_v4(), + hook_type: def + .hook_type + .clone() + .unwrap_or_else(|| "command".to_string()), + command: def.command.clone().unwrap_or_default(), + timeout, + } + } + + pub fn to_definition(&self) -> HookDefinition { + let mut def = HookDefinition { + hook_type: Some(self.hook_type.clone()), + command: Some(self.command.clone()), + ..Default::default() + }; + if let Some(timeout) = self.timeout { + def.additional_properties + .insert("timeout".to_string(), serde_json::json!(timeout)); + } + def + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EditableHookGroup { + pub id: Uuid, + pub hooks: Vec, + pub matcher: Option, +} + +impl EditableHookGroup { + pub fn new(matcher: Option) -> Self { + Self { + id: Uuid::new_v4(), + hooks: Vec::new(), + matcher, + } + } + + pub fn from_group(group: &HookGroup) -> Self { + let hooks = group + .hooks + .as_ref() + .map(|h| { + h.iter() + .map(EditableHookDefinition::from_definition) + .collect() + }) + .unwrap_or_default(); + Self { + id: Uuid::new_v4(), + hooks, + matcher: group.matcher.clone(), + } + } + + pub fn to_group(&self) -> HookGroup { + let hooks: Vec = self.hooks.iter().map(|h| h.to_definition()).collect(); + HookGroup { + matcher: self.matcher.clone(), + hooks: if hooks.is_empty() { None } else { Some(hooks) }, + ..Default::default() + } + } +} + +pub struct HookTemplate { + pub name: &'static str, + pub description: &'static str, + pub event: HookEvent, + pub matcher: Option<&'static str>, + pub commands: &'static [&'static str], +} + +pub static HOOK_TEMPLATES: &[HookTemplate] = &[ + HookTemplate { + name: "Format Python", + description: "Run black formatter after editing Python files", + event: HookEvent::PostToolUse, + matcher: Some("Edit(*.py)"), + commands: &["black --quiet $FILEPATH"], + }, + HookTemplate { + name: "Lint TypeScript", + description: "Run ESLint after editing TypeScript files", + event: HookEvent::PostToolUse, + matcher: Some("Edit(*.ts)"), + commands: &["npx eslint --fix $FILEPATH"], + }, + HookTemplate { + name: "Verify Build", + description: "Check that the project builds after edits", + event: HookEvent::PostToolUse, + matcher: Some("Edit"), + commands: &["npm run build --quiet"], + }, + HookTemplate { + name: "Approve Bash", + description: "Log bash commands before execution", + event: HookEvent::PreToolUse, + matcher: Some("Bash"), + commands: &["echo \"Running: $TOOL_INPUT\""], + }, + HookTemplate { + name: "Notify on Stop", + description: "Send a notification when Claude finishes", + event: HookEvent::Stop, + matcher: None, + commands: &["osascript -e 'display notification \"Claude finished\" with title \"Fig\"'"], + }, + HookTemplate { + name: "Run Tests", + description: "Run tests after writing test files", + event: HookEvent::PostToolUse, + matcher: Some("Write(*test*)"), + commands: &["npm test -- --bail"], + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_event_methods() { + for event in HookEvent::all() { + assert!(!event.display_name().is_empty()); + assert!(!event.key().is_empty()); + assert!(!event.description().is_empty()); + } + assert_eq!(HookEvent::all().len(), 4); + } + + #[test] + fn test_supports_matcher() { + assert!(HookEvent::PreToolUse.supports_matcher()); + assert!(HookEvent::PostToolUse.supports_matcher()); + assert!(!HookEvent::Notification.supports_matcher()); + assert!(!HookEvent::Stop.supports_matcher()); + } + + #[test] + fn test_editable_hook_definition_round_trip() { + let def = HookDefinition { + hook_type: Some("command".to_string()), + command: Some("npm run lint".to_string()), + ..Default::default() + }; + let editable = EditableHookDefinition::from_definition(&def); + let result = editable.to_definition(); + assert_eq!(result.hook_type, def.hook_type); + assert_eq!(result.command, def.command); + } + + #[test] + fn test_editable_hook_group_round_trip() { + let group = HookGroup { + matcher: Some("Bash(*)".to_string()), + hooks: Some(vec![HookDefinition { + hook_type: Some("command".to_string()), + command: Some("echo test".to_string()), + ..Default::default() + }]), + ..Default::default() + }; + let editable = EditableHookGroup::from_group(&group); + assert_eq!(editable.matcher, Some("Bash(*)".to_string())); + assert_eq!(editable.hooks.len(), 1); + + let result = editable.to_group(); + assert_eq!(result.matcher, group.matcher); + assert_eq!(result.hooks.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_unique_ids() { + let h1 = EditableHookDefinition::new("cmd1".to_string()); + let h2 = EditableHookDefinition::new("cmd2".to_string()); + assert_ne!(h1.id, h2.id); + + let g1 = EditableHookGroup::new(None); + let g2 = EditableHookGroup::new(None); + assert_ne!(g1.id, g2.id); + } + + #[test] + fn test_templates_non_empty() { + assert!(HOOK_TEMPLATES.len() >= 6); + for template in HOOK_TEMPLATES { + assert!(!template.name.is_empty()); + assert!(!template.commands.is_empty()); + } + } +} diff --git a/fig-core/src/models/editable_types.rs b/fig-core/src/models/editable_types.rs new file mode 100644 index 0000000..140ee37 --- /dev/null +++ b/fig-core/src/models/editable_types.rs @@ -0,0 +1,324 @@ +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PermissionType { + Allow, + Deny, +} + +impl PermissionType { + pub fn label(&self) -> &str { + match self { + Self::Allow => "Allow", + Self::Deny => "Deny", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditablePermissionRule { + pub id: Uuid, + pub rule: String, + pub permission_type: PermissionType, +} + +impl EditablePermissionRule { + pub fn new(rule: String, permission_type: PermissionType) -> Self { + Self { + id: Uuid::new_v4(), + rule, + permission_type, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditableEnvironmentVariable { + pub id: Uuid, + pub key: String, + pub value: String, +} + +impl EditableEnvironmentVariable { + pub fn new(key: String, value: String) -> Self { + Self { + id: Uuid::new_v4(), + key, + value, + } + } + + pub fn is_sensitive_key(key: &str) -> bool { + let upper = key.to_uppercase(); + ["TOKEN", "KEY", "SECRET", "PASSWORD"] + .iter() + .any(|pat| upper.contains(pat)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ToolType { + Bash, + Read, + Write, + Edit, + Grep, + Glob, + WebFetch, + Notebook, + Custom, +} + +impl ToolType { + pub fn name(&self) -> &str { + match self { + Self::Bash => "Bash", + Self::Read => "Read", + Self::Write => "Write", + Self::Edit => "Edit", + Self::Grep => "Grep", + Self::Glob => "Glob", + Self::WebFetch => "WebFetch", + Self::Notebook => "Notebook", + Self::Custom => "Custom", + } + } + + pub fn placeholder(&self) -> &str { + match self { + Self::Bash => "npm run *, git *, etc.", + Self::Read => "src/**, .env, config/*.json", + Self::Write => "*.log, temp/*, dist/**", + Self::Edit => "src/**/*.ts, package.json", + Self::Grep => "*.ts, src/**", + Self::Glob => "**/*.test.ts", + Self::WebFetch => "https://api.example.com/*", + Self::Notebook => "*.ipynb", + Self::Custom => "Enter tool name...", + } + } + + pub fn all() -> &'static [ToolType] { + &[ + Self::Bash, + Self::Read, + Self::Write, + Self::Edit, + Self::Grep, + Self::Glob, + Self::WebFetch, + Self::Notebook, + Self::Custom, + ] + } +} + +pub struct PermissionPreset { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub rules: &'static [(&'static str, PermissionType)], +} + +pub static PERMISSION_PRESETS: &[PermissionPreset] = &[ + PermissionPreset { + id: "protect-env", + name: "Protect .env files", + description: "Prevent reading environment files", + rules: &[ + ("Read(.env)", PermissionType::Deny), + ("Read(.env.*)", PermissionType::Deny), + ], + }, + PermissionPreset { + id: "allow-npm", + name: "Allow npm scripts", + description: "Allow running npm scripts", + rules: &[("Bash(npm run *)", PermissionType::Allow)], + }, + PermissionPreset { + id: "allow-git", + name: "Allow git operations", + description: "Allow running git commands", + rules: &[("Bash(git *)", PermissionType::Allow)], + }, + PermissionPreset { + id: "read-only", + name: "Read-only mode", + description: "Deny all write and edit operations", + rules: &[ + ("Write", PermissionType::Deny), + ("Edit", PermissionType::Deny), + ], + }, + PermissionPreset { + id: "allow-read-src", + name: "Allow reading source", + description: "Allow reading all files in src directory", + rules: &[("Read(src/**)", PermissionType::Allow)], + }, + PermissionPreset { + id: "deny-curl", + name: "Block curl commands", + description: "Prevent curl network requests", + rules: &[("Bash(curl *)", PermissionType::Deny)], + }, +]; + +pub struct KnownEnvironmentVariable { + pub name: &'static str, + pub description: &'static str, + pub default_value: Option<&'static str>, +} + +impl KnownEnvironmentVariable { + pub fn description_for(key: &str) -> Option<&'static str> { + KNOWN_ENVIRONMENT_VARIABLES + .iter() + .find(|v| v.name == key) + .map(|v| v.description) + } +} + +pub static KNOWN_ENVIRONMENT_VARIABLES: &[KnownEnvironmentVariable] = &[ + KnownEnvironmentVariable { + name: "CLAUDE_CODE_MAX_OUTPUT_TOKENS", + description: "Maximum tokens in Claude's response", + default_value: None, + }, + KnownEnvironmentVariable { + name: "BASH_DEFAULT_TIMEOUT_MS", + description: "Default timeout for bash commands in milliseconds", + default_value: Some("120000"), + }, + KnownEnvironmentVariable { + name: "CLAUDE_CODE_ENABLE_TELEMETRY", + description: "Enable/disable telemetry (0 or 1)", + default_value: None, + }, + KnownEnvironmentVariable { + name: "OTEL_METRICS_EXPORTER", + description: "OpenTelemetry metrics exporter configuration", + default_value: None, + }, + KnownEnvironmentVariable { + name: "DISABLE_TELEMETRY", + description: "Disable all telemetry (0 or 1)", + default_value: None, + }, + KnownEnvironmentVariable { + name: "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", + description: "Reduce network calls by disabling non-essential traffic", + default_value: None, + }, + KnownEnvironmentVariable { + name: "ANTHROPIC_MODEL", + description: "Override the default model used by Claude Code", + default_value: None, + }, + KnownEnvironmentVariable { + name: "ANTHROPIC_DEFAULT_SONNET_MODEL", + description: "Default Sonnet model to use", + default_value: None, + }, + KnownEnvironmentVariable { + name: "ANTHROPIC_DEFAULT_OPUS_MODEL", + description: "Default Opus model to use", + default_value: None, + }, + KnownEnvironmentVariable { + name: "ANTHROPIC_DEFAULT_HAIKU_MODEL", + description: "Default Haiku model to use", + default_value: None, + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_editable_rule_unique_ids() { + let r1 = EditablePermissionRule::new("Bash(*)".to_string(), PermissionType::Allow); + let r2 = EditablePermissionRule::new("Bash(*)".to_string(), PermissionType::Allow); + assert_ne!(r1.id, r2.id); + } + + #[test] + fn test_editable_env_var_unique_ids() { + let v1 = EditableEnvironmentVariable::new("KEY".to_string(), "val".to_string()); + let v2 = EditableEnvironmentVariable::new("KEY".to_string(), "val".to_string()); + assert_ne!(v1.id, v2.id); + } + + #[test] + fn test_permission_type_label() { + assert_eq!(PermissionType::Allow.label(), "Allow"); + assert_eq!(PermissionType::Deny.label(), "Deny"); + } + + #[test] + fn test_permission_presets_non_empty() { + assert!(PERMISSION_PRESETS.len() >= 5); + for preset in PERMISSION_PRESETS { + assert!(!preset.name.is_empty()); + assert!(!preset.rules.is_empty()); + } + } + + #[test] + fn test_tool_type_placeholder() { + for tool in ToolType::all() { + assert!(!tool.name().is_empty()); + assert!(!tool.placeholder().is_empty()); + } + assert_eq!(ToolType::all().len(), 9); + } + + #[test] + fn test_known_env_vars() { + assert!(KNOWN_ENVIRONMENT_VARIABLES.len() >= 7); + for var in KNOWN_ENVIRONMENT_VARIABLES { + assert!(!var.name.is_empty()); + assert!(!var.description.is_empty()); + } + } + + #[test] + fn test_known_env_var_description_lookup() { + assert_eq!( + KnownEnvironmentVariable::description_for("ANTHROPIC_MODEL"), + Some("Override the default model used by Claude Code") + ); + assert_eq!( + KnownEnvironmentVariable::description_for("NONEXISTENT"), + None + ); + } + + #[test] + fn test_sensitive_key_detection() { + assert!(EditableEnvironmentVariable::is_sensitive_key( + "ANTHROPIC_API_KEY" + )); + assert!(EditableEnvironmentVariable::is_sensitive_key("AUTH_TOKEN")); + assert!(EditableEnvironmentVariable::is_sensitive_key("DB_PASSWORD")); + assert!(EditableEnvironmentVariable::is_sensitive_key( + "CLIENT_SECRET" + )); + assert!(!EditableEnvironmentVariable::is_sensitive_key("LOG_LEVEL")); + assert!(!EditableEnvironmentVariable::is_sensitive_key( + "ANTHROPIC_MODEL" + )); + } + + #[test] + fn test_preset_rules_valid_types() { + for preset in PERMISSION_PRESETS { + for &(rule, ptype) in preset.rules { + assert!(!rule.is_empty()); + assert!(ptype == PermissionType::Allow || ptype == PermissionType::Deny); + } + } + } +} diff --git a/fig-core/src/models/hook_definition.rs b/fig-core/src/models/hook_definition.rs new file mode 100644 index 0000000..906b150 --- /dev/null +++ b/fig-core/src/models/hook_definition.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct HookDefinition { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "type")] + pub hook_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_definition_round_trip() { + let json = r#"{"type":"command","command":"npm run lint","timeout":30}"#; + let parsed: HookDefinition = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.hook_type, Some("command".to_string())); + assert_eq!(parsed.command, Some("npm run lint".to_string())); + assert_eq!( + parsed.additional_properties.get("timeout"), + Some(&serde_json::json!(30)) + ); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: HookDefinition = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_hook_definition_type_rename() { + let h = HookDefinition { + hook_type: Some("command".to_string()), + ..Default::default() + }; + let json = serde_json::to_string(&h).unwrap(); + assert!(json.contains(r#""type":"command""#)); + } + + #[test] + fn test_hook_definition_empty() { + let parsed: HookDefinition = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, HookDefinition::default()); + } +} diff --git a/fig-core/src/models/hook_group.rs b/fig-core/src/models/hook_group.rs new file mode 100644 index 0000000..ab12ed0 --- /dev/null +++ b/fig-core/src/models/hook_group.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +use super::hook_definition::HookDefinition; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct HookGroup { + #[serde(skip_serializing_if = "Option::is_none")] + pub matcher: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hooks: Option>, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hook_group_round_trip() { + let json = r#"{"matcher":"Bash(*)","hooks":[{"type":"command","command":"npm run lint"}],"priority":1}"#; + let parsed: HookGroup = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.matcher, Some("Bash(*)".to_string())); + assert_eq!(parsed.hooks.as_ref().unwrap().len(), 1); + assert_eq!( + parsed.additional_properties.get("priority"), + Some(&serde_json::json!(1)) + ); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: HookGroup = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_hook_group_empty() { + let parsed: HookGroup = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, HookGroup::default()); + } +} diff --git a/fig-core/src/models/legacy_config.rs b/fig-core/src/models/legacy_config.rs new file mode 100644 index 0000000..34ad007 --- /dev/null +++ b/fig-core/src/models/legacy_config.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +use super::mcp_server::MCPServer; +use super::project_entry::ProjectEntry; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct LegacyConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub projects: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "customApiKeyResponses")] + pub custom_api_key_responses: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferences: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "mcpServers")] + pub mcp_servers: Option>, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +impl LegacyConfig { + pub fn project_paths(&self) -> Vec { + self.projects + .as_ref() + .map(|p| p.keys().cloned().collect()) + .unwrap_or_default() + } + + pub fn all_projects(&self) -> Vec { + self.projects + .as_ref() + .map(|p| { + p.iter() + .map(|(path, entry)| { + let mut e = entry.clone(); + e.path = Some(path.clone()); + e + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn global_server_names(&self) -> Vec { + self.mcp_servers + .as_ref() + .map(|s| s.keys().cloned().collect()) + .unwrap_or_default() + } + + pub fn project(&self, path: &str) -> Option { + self.projects.as_ref()?.get(path).map(|entry| { + let mut e = entry.clone(); + e.path = Some(path.to_string()); + e + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const LEGACY_CONFIG_JSON: &str = r#"{"projects":{"/path/to/project":{"allowedTools":["Bash","Read"],"hasTrustDialogAccepted":true}},"customApiKeyResponses":{"key1":"response1"},"preferences":{"theme":"dark"},"mcpServers":{"global-server":{"command":"npx","args":["server"]}},"analytics":false}"#; + + #[test] + fn test_legacy_config_round_trip() { + let parsed: LegacyConfig = serde_json::from_str(LEGACY_CONFIG_JSON).unwrap(); + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: LegacyConfig = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_all_projects_populates_paths() { + let parsed: LegacyConfig = serde_json::from_str(LEGACY_CONFIG_JSON).unwrap(); + let projects = parsed.all_projects(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].path, Some("/path/to/project".to_string())); + } + + #[test] + fn test_legacy_config_project_lookup() { + let parsed: LegacyConfig = serde_json::from_str(LEGACY_CONFIG_JSON).unwrap(); + let project = parsed.project("/path/to/project").unwrap(); + assert_eq!(project.has_trust_dialog_accepted, Some(true)); + assert_eq!(project.path, Some("/path/to/project".to_string())); + } + + #[test] + fn test_legacy_config_global_servers() { + let parsed: LegacyConfig = serde_json::from_str(LEGACY_CONFIG_JSON).unwrap(); + let names = parsed.global_server_names(); + assert!(names.contains(&"global-server".to_string())); + } + + #[test] + fn test_legacy_config_preserves_unknown_fields() { + let parsed: LegacyConfig = serde_json::from_str(LEGACY_CONFIG_JSON).unwrap(); + assert_eq!( + parsed.additional_properties.get("analytics"), + Some(&Value::Bool(false)) + ); + } +} diff --git a/fig-core/src/models/mcp_config.rs b/fig-core/src/models/mcp_config.rs new file mode 100644 index 0000000..85fc45a --- /dev/null +++ b/fig-core/src/models/mcp_config.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +use super::mcp_server::MCPServer; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct MCPConfig { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "mcpServers")] + pub mcp_servers: Option>, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +impl MCPConfig { + pub fn server_names(&self) -> Vec { + self.mcp_servers + .as_ref() + .map(|s| s.keys().cloned().collect()) + .unwrap_or_default() + } + + pub fn server(&self, name: &str) -> Option<&MCPServer> { + self.mcp_servers.as_ref()?.get(name) + } + + pub fn server_count(&self) -> usize { + self.mcp_servers.as_ref().map(|s| s.len()).unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mcp_config_multiple_servers() { + let json = r#"{"mcpServers":{"github":{"command":"npx","args":["-y","@modelcontextprotocol/server-github"]},"remote":{"type":"http","url":"https://mcp.example.com/api"}},"version":"1.0"}"#; + let parsed: MCPConfig = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.server_count(), 2); + assert!(parsed.server("github").unwrap().is_stdio()); + assert!(parsed.server("remote").unwrap().is_http()); + assert_eq!( + parsed.additional_properties.get("version"), + Some(&Value::String("1.0".to_string())) + ); + } + + #[test] + fn test_mcp_config_round_trip() { + let json = r#"{"mcpServers":{"github":{"command":"npx"}},"version":"1.0"}"#; + let parsed: MCPConfig = serde_json::from_str(json).unwrap(); + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: MCPConfig = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_mcp_config_empty() { + let parsed: MCPConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed.server_count(), 0); + assert!(parsed.server_names().is_empty()); + } +} diff --git a/fig-core/src/models/mcp_form_data.rs b/fig-core/src/models/mcp_form_data.rs new file mode 100644 index 0000000..bd45059 --- /dev/null +++ b/fig-core/src/models/mcp_form_data.rs @@ -0,0 +1,355 @@ +use serde_json::Value; +use std::collections::HashMap; +use std::fmt; + +use super::mcp_server::MCPServer; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MCPServerType { + Stdio, + Sse, +} + +impl fmt::Display for MCPServerType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Stdio => write!(f, "stdio"), + Self::Sse => write!(f, "SSE/HTTP"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationError { + pub field: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MCPServerFormData { + pub name: String, + pub command: String, + pub args_text: String, + pub env_text: String, + pub url: String, + pub server_type: MCPServerType, + pub is_editing: bool, + pub original_name: Option, + /// Preserved fields not editable in the form (headers, additional_properties). + preserved_headers: Option>, + preserved_extra: HashMap, +} + +impl MCPServerFormData { + pub fn new() -> Self { + Self { + name: String::new(), + command: String::new(), + args_text: String::new(), + env_text: String::new(), + url: String::new(), + server_type: MCPServerType::Stdio, + is_editing: false, + original_name: None, + preserved_headers: None, + preserved_extra: HashMap::new(), + } + } + + pub fn from_mcp_server(name: &str, server: &MCPServer) -> Self { + let server_type = if server.is_http() { + MCPServerType::Sse + } else { + MCPServerType::Stdio + }; + + let args_text = server + .args + .as_ref() + .map(|args| args.join("\n")) + .unwrap_or_default(); + + let env_text = server + .env + .as_ref() + .map(|env| { + let mut pairs: Vec<_> = env.iter().collect(); + pairs.sort_by_key(|(k, _)| (*k).clone()); + pairs + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + Self { + name: name.to_string(), + command: server.command.clone().unwrap_or_default(), + args_text, + env_text, + url: server.url.clone().unwrap_or_default(), + server_type, + is_editing: true, + original_name: Some(name.to_string()), + preserved_headers: server.headers.clone(), + preserved_extra: server.additional_properties.clone(), + } + } + + pub fn to_mcp_server(&self) -> MCPServer { + let mut server = match self.server_type { + MCPServerType::Stdio => { + let args: Vec = self + .args_text + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + let env = Self::parse_env_text(&self.env_text); + + MCPServer::stdio( + self.command.clone(), + if args.is_empty() { None } else { Some(args) }, + if env.is_empty() { None } else { Some(env) }, + ) + } + MCPServerType::Sse => MCPServer::http(self.url.clone(), self.preserved_headers.clone()), + }; + server.additional_properties = self.preserved_extra.clone(); + server + } + + pub fn validate(&self) -> Vec { + let mut errors = Vec::new(); + + if self.name.trim().is_empty() { + errors.push(ValidationError { + field: "name".to_string(), + message: "Server name is required.".to_string(), + }); + } + + match self.server_type { + MCPServerType::Stdio => { + if self.command.trim().is_empty() { + errors.push(ValidationError { + field: "command".to_string(), + message: "Command is required for stdio servers.".to_string(), + }); + } + } + MCPServerType::Sse => { + if self.url.trim().is_empty() { + errors.push(ValidationError { + field: "url".to_string(), + message: "URL is required for SSE/HTTP servers.".to_string(), + }); + } + } + } + + errors + } + + pub fn is_valid(&self) -> bool { + self.validate().is_empty() + } + + fn parse_env_text(text: &str) -> HashMap { + let mut env = HashMap::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some((key, value)) = trimmed.split_once('=') { + let key = key.trim().to_string(); + if !key.is_empty() { + env.insert(key, value.trim().to_string()); + } + } + } + env + } +} + +impl Default for MCPServerFormData { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_defaults() { + let form = MCPServerFormData::new(); + assert!(form.name.is_empty()); + assert_eq!(form.server_type, MCPServerType::Stdio); + assert!(!form.is_editing); + assert!(form.original_name.is_none()); + } + + #[test] + fn test_from_stdio_server() { + let mut env = HashMap::new(); + env.insert("GITHUB_TOKEN".to_string(), "abc123".to_string()); + let server = MCPServer::stdio( + "npx".to_string(), + Some(vec!["-y".to_string(), "server-github".to_string()]), + Some(env), + ); + + let form = MCPServerFormData::from_mcp_server("github", &server); + assert_eq!(form.name, "github"); + assert_eq!(form.command, "npx"); + assert_eq!(form.server_type, MCPServerType::Stdio); + assert!(form.args_text.contains("-y")); + assert!(form.args_text.contains("server-github")); + assert!(form.env_text.contains("GITHUB_TOKEN=abc123")); + assert!(form.is_editing); + assert_eq!(form.original_name, Some("github".to_string())); + } + + #[test] + fn test_from_http_server() { + let server = MCPServer::http("https://mcp.example.com".to_string(), None); + let form = MCPServerFormData::from_mcp_server("remote", &server); + assert_eq!(form.server_type, MCPServerType::Sse); + assert_eq!(form.url, "https://mcp.example.com"); + assert!(form.command.is_empty()); + } + + #[test] + fn test_to_mcp_server_stdio() { + let form = MCPServerFormData { + name: "test".to_string(), + command: "node".to_string(), + args_text: "server.js\n--port\n3000".to_string(), + env_text: "API_KEY=secret\nDEBUG=true".to_string(), + url: String::new(), + server_type: MCPServerType::Stdio, + is_editing: false, + original_name: None, + preserved_headers: None, + preserved_extra: HashMap::new(), + }; + + let server = form.to_mcp_server(); + assert!(server.is_stdio()); + assert_eq!(server.command, Some("node".to_string())); + assert_eq!( + server.args, + Some(vec![ + "server.js".to_string(), + "--port".to_string(), + "3000".to_string() + ]) + ); + let env = server.env.unwrap(); + assert_eq!(env.get("API_KEY"), Some(&"secret".to_string())); + assert_eq!(env.get("DEBUG"), Some(&"true".to_string())); + } + + #[test] + fn test_to_mcp_server_sse() { + let form = MCPServerFormData { + name: "remote".to_string(), + command: String::new(), + args_text: String::new(), + env_text: String::new(), + url: "https://api.example.com".to_string(), + server_type: MCPServerType::Sse, + is_editing: false, + original_name: None, + preserved_headers: None, + preserved_extra: HashMap::new(), + }; + + let server = form.to_mcp_server(); + assert!(server.is_http()); + assert_eq!(server.url, Some("https://api.example.com".to_string())); + } + + #[test] + fn test_round_trip() { + let mut env = HashMap::new(); + env.insert("TOKEN".to_string(), "xyz".to_string()); + let original = MCPServer::stdio( + "npx".to_string(), + Some(vec!["-y".to_string(), "pkg".to_string()]), + Some(env), + ); + + let form = MCPServerFormData::from_mcp_server("test", &original); + let result = form.to_mcp_server(); + + assert_eq!(original.command, result.command); + assert_eq!(original.args, result.args); + assert_eq!(original.env, result.env); + } + + #[test] + fn test_validate_empty_name() { + let form = MCPServerFormData { + name: " ".to_string(), + command: "node".to_string(), + ..MCPServerFormData::new() + }; + let errors = form.validate(); + assert!(errors.iter().any(|e| e.field == "name")); + } + + #[test] + fn test_validate_stdio_no_command() { + let form = MCPServerFormData { + name: "test".to_string(), + command: "".to_string(), + server_type: MCPServerType::Stdio, + ..MCPServerFormData::new() + }; + let errors = form.validate(); + assert!(errors.iter().any(|e| e.field == "command")); + assert!(!form.is_valid()); + } + + #[test] + fn test_validate_sse_no_url() { + let form = MCPServerFormData { + name: "test".to_string(), + url: "".to_string(), + server_type: MCPServerType::Sse, + ..MCPServerFormData::new() + }; + let errors = form.validate(); + assert!(errors.iter().any(|e| e.field == "url")); + } + + #[test] + fn test_validate_valid() { + let form = MCPServerFormData { + name: "github".to_string(), + command: "npx".to_string(), + server_type: MCPServerType::Stdio, + ..MCPServerFormData::new() + }; + assert!(form.is_valid()); + } + + #[test] + fn test_env_text_parsing() { + let env = MCPServerFormData::parse_env_text("KEY=value\n\nFOO = bar\ninvalid_line\n"); + assert_eq!(env.get("KEY"), Some(&"value".to_string())); + assert_eq!(env.get("FOO"), Some(&"bar".to_string())); + assert_eq!(env.len(), 2); + } + + #[test] + fn test_server_type_display() { + assert_eq!(format!("{}", MCPServerType::Stdio), "stdio"); + assert_eq!(format!("{}", MCPServerType::Sse), "SSE/HTTP"); + } +} diff --git a/fig-core/src/models/mcp_server.rs b/fig-core/src/models/mcp_server.rs new file mode 100644 index 0000000..d72d793 --- /dev/null +++ b/fig-core/src/models/mcp_server.rs @@ -0,0 +1,134 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct MCPServer { + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "type")] + pub server_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +impl MCPServer { + pub fn is_stdio(&self) -> bool { + self.command.is_some() && self.server_type.as_deref() != Some("http") + } + + pub fn is_http(&self) -> bool { + self.server_type.as_deref() == Some("http") && self.url.is_some() + } + + pub fn stdio( + command: String, + args: Option>, + env: Option>, + ) -> Self { + Self { + command: Some(command), + args, + env, + ..Default::default() + } + } + + pub fn http(url: String, headers: Option>) -> Self { + Self { + server_type: Some("http".to_string()), + url: Some(url), + headers, + ..Default::default() + } + } + + pub fn has_sensitive_env(&self) -> bool { + self.env + .as_ref() + .map(|e| { + e.keys().any(|k| { + let lower = k.to_lowercase(); + lower.contains("token") + || lower.contains("key") + || lower.contains("secret") + || lower.contains("password") + }) + }) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stdio_server_round_trip() { + let json = r#"{"command":"npx","args":["-y","@modelcontextprotocol/server-github"],"env":{"GITHUB_TOKEN":"test-token"},"customOption":true}"#; + let parsed: MCPServer = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.command, Some("npx".to_string())); + assert!(parsed.is_stdio()); + assert!(!parsed.is_http()); + assert_eq!( + parsed.additional_properties.get("customOption"), + Some(&Value::Bool(true)) + ); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: MCPServer = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_http_server_round_trip() { + let json = r#"{"type":"http","url":"https://mcp.example.com/api","headers":{"Authorization":"Bearer token"}}"#; + let parsed: MCPServer = serde_json::from_str(json).unwrap(); + assert!(parsed.is_http()); + assert!(!parsed.is_stdio()); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: MCPServer = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_mcp_server_factory_methods() { + let stdio = MCPServer::stdio( + "node".to_string(), + Some(vec!["server.js".to_string()]), + None, + ); + assert!(stdio.is_stdio()); + assert_eq!(stdio.command, Some("node".to_string())); + + let http = MCPServer::http("https://api.example.com".to_string(), None); + assert!(http.is_http()); + assert_eq!(http.url, Some("https://api.example.com".to_string())); + } + + #[test] + fn test_has_sensitive_env() { + let mut env = HashMap::new(); + env.insert("GITHUB_TOKEN".to_string(), "secret".to_string()); + let server = MCPServer::stdio("cmd".to_string(), None, Some(env)); + assert!(server.has_sensitive_env()); + + let server_no_env = MCPServer::stdio("cmd".to_string(), None, None); + assert!(!server_no_env.has_sensitive_env()); + + let mut safe_env = HashMap::new(); + safe_env.insert("PATH".to_string(), "/usr/bin".to_string()); + let server_safe = MCPServer::stdio("cmd".to_string(), None, Some(safe_env)); + assert!(!server_safe.has_sensitive_env()); + } +} diff --git a/fig-core/src/models/merged_settings.rs b/fig-core/src/models/merged_settings.rs new file mode 100644 index 0000000..3e91c85 --- /dev/null +++ b/fig-core/src/models/merged_settings.rs @@ -0,0 +1,202 @@ +use std::collections::HashMap; + +use super::attribution::Attribution; +use super::config_source::ConfigSource; +use super::hook_group::HookGroup; + +#[derive(Debug, Clone, PartialEq)] +pub struct MergedValue { + pub value: T, + pub source: ConfigSource, +} + +#[derive(Debug, Clone, Default)] +pub struct MergedPermissions { + pub allow: Vec>, + pub deny: Vec>, +} + +impl MergedPermissions { + pub fn allow_patterns(&self) -> Vec<&str> { + self.allow.iter().map(|v| v.value.as_str()).collect() + } + + pub fn deny_patterns(&self) -> Vec<&str> { + self.deny.iter().map(|v| v.value.as_str()).collect() + } +} + +#[derive(Debug, Clone, Default)] +pub struct MergedHooks { + pub hooks: HashMap>>, +} + +impl MergedHooks { + pub fn event_names(&self) -> Vec { + let mut names: Vec = self.hooks.keys().cloned().collect(); + names.sort(); + names + } + + pub fn groups(&self, event: &str) -> Option<&Vec>> { + self.hooks.get(event) + } +} + +#[derive(Debug, Clone, Default)] +pub struct MergedSettings { + pub permissions: MergedPermissions, + pub env: HashMap>, + pub hooks: MergedHooks, + pub disallowed_tools: Vec>, + pub attribution: Option>, +} + +impl MergedSettings { + pub fn effective_env(&self) -> HashMap<&str, &str> { + self.env + .iter() + .map(|(k, v)| (k.as_str(), v.value.as_str())) + .collect() + } + + pub fn effective_disallowed_tools(&self) -> Vec<&str> { + self.disallowed_tools + .iter() + .map(|v| v.value.as_str()) + .collect() + } + + pub fn is_tool_disallowed(&self, tool: &str) -> bool { + self.disallowed_tools.iter().any(|v| v.value == tool) + } + + pub fn env_source(&self, key: &str) -> Option { + self.env.get(key).map(|v| v.source) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_merged_value_stores_value_and_source() { + let mv = MergedValue { + value: "test".to_string(), + source: ConfigSource::ProjectLocal, + }; + assert_eq!(mv.value, "test"); + assert_eq!(mv.source, ConfigSource::ProjectLocal); + } + + #[test] + fn test_merged_permissions_patterns() { + let perms = MergedPermissions { + allow: vec![ + MergedValue { + value: "Bash(*)".to_string(), + source: ConfigSource::Global, + }, + MergedValue { + value: "Read(src/**)".to_string(), + source: ConfigSource::ProjectShared, + }, + ], + deny: vec![MergedValue { + value: "Read(.env)".to_string(), + source: ConfigSource::Global, + }], + }; + assert_eq!(perms.allow_patterns(), vec!["Bash(*)", "Read(src/**)"]); + assert_eq!(perms.deny_patterns(), vec!["Read(.env)"]); + } + + #[test] + fn test_merged_hooks_sorted_events() { + let hooks = MergedHooks { + hooks: HashMap::from([ + ("PreToolUse".to_string(), vec![]), + ("PostToolUse".to_string(), vec![]), + ("Notification".to_string(), vec![]), + ]), + }; + assert_eq!( + hooks.event_names(), + vec!["Notification", "PostToolUse", "PreToolUse"] + ); + } + + #[test] + fn test_merged_hooks_groups() { + let hooks = MergedHooks { + hooks: HashMap::from([( + "PreToolUse".to_string(), + vec![MergedValue { + value: HookGroup::default(), + source: ConfigSource::Global, + }], + )]), + }; + assert!(hooks.groups("PreToolUse").is_some()); + assert!(hooks.groups("NonExistent").is_none()); + } + + #[test] + fn test_merged_settings_effective_env() { + let settings = MergedSettings { + env: HashMap::from([ + ( + "DEBUG".to_string(), + MergedValue { + value: "true".to_string(), + source: ConfigSource::ProjectLocal, + }, + ), + ( + "API_URL".to_string(), + MergedValue { + value: "https://api.example.com".to_string(), + source: ConfigSource::Global, + }, + ), + ]), + ..Default::default() + }; + let env = settings.effective_env(); + assert_eq!(env.get("DEBUG"), Some(&"true")); + assert_eq!(env.get("API_URL"), Some(&"https://api.example.com")); + } + + #[test] + fn test_merged_settings_is_tool_disallowed() { + let settings = MergedSettings { + disallowed_tools: vec![MergedValue { + value: "DangerousTool".to_string(), + source: ConfigSource::Global, + }], + ..Default::default() + }; + assert!(settings.is_tool_disallowed("DangerousTool")); + assert!(!settings.is_tool_disallowed("SafeTool")); + } + + #[test] + fn test_merged_settings_env_source() { + let settings = MergedSettings { + env: HashMap::from([( + "DEBUG".to_string(), + MergedValue { + value: "true".to_string(), + source: ConfigSource::ProjectLocal, + }, + )]), + ..Default::default() + }; + assert_eq!( + settings.env_source("DEBUG"), + Some(ConfigSource::ProjectLocal) + ); + assert_eq!(settings.env_source("MISSING"), None); + } +} diff --git a/fig-core/src/models/mod.rs b/fig-core/src/models/mod.rs new file mode 100644 index 0000000..d79bd28 --- /dev/null +++ b/fig-core/src/models/mod.rs @@ -0,0 +1,40 @@ +pub mod attribution; +pub mod claude_settings; +pub mod config_source; +pub mod discovered_project; +pub mod editable_hook_types; +pub mod editable_types; +pub mod hook_definition; +pub mod hook_group; +pub mod legacy_config; +pub mod mcp_config; +pub mod mcp_form_data; +pub mod mcp_server; +pub mod merged_settings; +pub mod navigation; +pub mod permissions; +pub mod project_entry; +pub mod project_group; + +pub use attribution::Attribution; +pub use claude_settings::ClaudeSettings; +pub use config_source::ConfigSource; +pub use discovered_project::DiscoveredProject; +pub use editable_hook_types::{ + EditableHookDefinition, EditableHookGroup, HookEvent, HookTemplate, HOOK_TEMPLATES, +}; +pub use editable_types::{ + EditableEnvironmentVariable, EditablePermissionRule, KnownEnvironmentVariable, + PermissionPreset, PermissionType, ToolType, KNOWN_ENVIRONMENT_VARIABLES, PERMISSION_PRESETS, +}; +pub use hook_definition::HookDefinition; +pub use hook_group::HookGroup; +pub use legacy_config::LegacyConfig; +pub use mcp_config::MCPConfig; +pub use mcp_form_data::{MCPServerFormData, MCPServerType, ValidationError}; +pub use mcp_server::MCPServer; +pub use merged_settings::{MergedHooks, MergedPermissions, MergedSettings, MergedValue}; +pub use navigation::{EditingTarget, GlobalSettingsTab, NavigationSelection, ProjectDetailTab}; +pub use permissions::Permissions; +pub use project_entry::ProjectEntry; +pub use project_group::{abbreviate_dir, ProjectGroup}; diff --git a/fig-core/src/models/navigation.rs b/fig-core/src/models/navigation.rs new file mode 100644 index 0000000..9af5d6c --- /dev/null +++ b/fig-core/src/models/navigation.rs @@ -0,0 +1,217 @@ +use std::fmt; + +use crate::models::ConfigSource; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum NavigationSelection { + GlobalSettings, + Project(String), +} + +impl NavigationSelection { + pub fn project_path(&self) -> Option<&str> { + match self { + Self::Project(path) => Some(path), + _ => None, + } + } + + pub fn is_global_settings(&self) -> bool { + matches!(self, Self::GlobalSettings) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GlobalSettingsTab { + Permissions, + Environment, + McpServers, + Advanced, +} + +impl GlobalSettingsTab { + pub fn title(&self) -> &str { + match self { + Self::Permissions => "Permissions", + Self::Environment => "Environment", + Self::McpServers => "MCP Servers", + Self::Advanced => "Advanced", + } + } + + pub fn all() -> &'static [GlobalSettingsTab] { + &[ + Self::Permissions, + Self::Environment, + Self::McpServers, + Self::Advanced, + ] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProjectDetailTab { + Permissions, + Environment, + McpServers, + Hooks, + ClaudeMd, + EffectiveConfig, + HealthCheck, + Advanced, +} + +impl ProjectDetailTab { + pub fn title(&self) -> &str { + match self { + Self::Permissions => "Permissions", + Self::Environment => "Environment", + Self::McpServers => "MCP Servers", + Self::Hooks => "Hooks", + Self::ClaudeMd => "CLAUDE.md", + Self::EffectiveConfig => "Effective Config", + Self::HealthCheck => "Health", + Self::Advanced => "Advanced", + } + } + + pub fn all() -> &'static [ProjectDetailTab] { + &[ + Self::Permissions, + Self::Environment, + Self::McpServers, + Self::Hooks, + Self::ClaudeMd, + Self::EffectiveConfig, + Self::HealthCheck, + Self::Advanced, + ] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EditingTarget { + Global, + ProjectShared, + ProjectLocal, +} + +impl EditingTarget { + pub fn label(&self) -> &str { + match self { + Self::Global => "Global (settings.json)", + Self::ProjectShared => "Shared (settings.json)", + Self::ProjectLocal => "Local (settings.local.json)", + } + } + + pub fn description(&self) -> &str { + match self { + Self::Global => "Applies to all projects", + Self::ProjectShared => "Committed to git, shared with team", + Self::ProjectLocal => "Git-ignored, local overrides", + } + } + + pub fn source(&self) -> ConfigSource { + match self { + Self::Global => ConfigSource::Global, + Self::ProjectShared => ConfigSource::ProjectShared, + Self::ProjectLocal => ConfigSource::ProjectLocal, + } + } + + pub fn project_targets() -> &'static [EditingTarget] { + &[Self::ProjectShared, Self::ProjectLocal] + } +} + +impl fmt::Display for EditingTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.label()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_navigation_selection_global() { + let sel = NavigationSelection::GlobalSettings; + assert!(sel.is_global_settings()); + assert_eq!(sel.project_path(), None); + } + + #[test] + fn test_navigation_selection_project() { + let sel = NavigationSelection::Project("/my/project".to_string()); + assert!(!sel.is_global_settings()); + assert_eq!(sel.project_path(), Some("/my/project")); + } + + #[test] + fn test_navigation_selection_equality() { + let a = NavigationSelection::Project("/a".to_string()); + let b = NavigationSelection::Project("/a".to_string()); + assert_eq!(a, b); + assert_ne!(a, NavigationSelection::GlobalSettings); + } + + #[test] + fn test_global_settings_tab_titles() { + assert_eq!(GlobalSettingsTab::Permissions.title(), "Permissions"); + assert_eq!(GlobalSettingsTab::McpServers.title(), "MCP Servers"); + assert_eq!(GlobalSettingsTab::all().len(), 4); + } + + #[test] + fn test_project_detail_tab_titles() { + assert_eq!(ProjectDetailTab::Hooks.title(), "Hooks"); + assert_eq!(ProjectDetailTab::ClaudeMd.title(), "CLAUDE.md"); + assert_eq!(ProjectDetailTab::HealthCheck.title(), "Health"); + assert_eq!(ProjectDetailTab::all().len(), 8); + } + + #[test] + fn test_editing_target_label() { + assert_eq!(EditingTarget::Global.label(), "Global (settings.json)"); + assert_eq!( + EditingTarget::ProjectShared.label(), + "Shared (settings.json)" + ); + assert_eq!( + EditingTarget::ProjectLocal.label(), + "Local (settings.local.json)" + ); + } + + #[test] + fn test_editing_target_source() { + assert_eq!(EditingTarget::Global.source(), ConfigSource::Global); + assert_eq!( + EditingTarget::ProjectShared.source(), + ConfigSource::ProjectShared + ); + assert_eq!( + EditingTarget::ProjectLocal.source(), + ConfigSource::ProjectLocal + ); + } + + #[test] + fn test_editing_target_project_targets() { + let targets = EditingTarget::project_targets(); + assert_eq!(targets.len(), 2); + assert_eq!(targets[0], EditingTarget::ProjectShared); + assert_eq!(targets[1], EditingTarget::ProjectLocal); + } + + #[test] + fn test_editing_target_display() { + assert_eq!( + format!("{}", EditingTarget::Global), + "Global (settings.json)" + ); + } +} diff --git a/fig-core/src/models/permissions.rs b/fig-core/src/models/permissions.rs new file mode 100644 index 0000000..81503bf --- /dev/null +++ b/fig-core/src/models/permissions.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct Permissions { + #[serde(skip_serializing_if = "Option::is_none")] + pub allow: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub deny: Option>, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_permissions_round_trip() { + let json = r#"{"allow":["Bash(npm run *)","Read(src/**)"],"deny":["Read(.env)","Bash(curl *)"],"futureField":"preserved"}"#; + let parsed: Permissions = serde_json::from_str(json).unwrap(); + assert_eq!( + parsed.allow, + Some(vec![ + "Bash(npm run *)".to_string(), + "Read(src/**)".to_string() + ]) + ); + assert_eq!( + parsed.deny, + Some(vec!["Read(.env)".to_string(), "Bash(curl *)".to_string()]) + ); + assert_eq!( + parsed.additional_properties.get("futureField"), + Some(&Value::String("preserved".to_string())) + ); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: Permissions = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_permissions_empty() { + let parsed: Permissions = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, Permissions::default()); + } + + #[test] + fn test_permissions_skip_none_fields() { + let p = Permissions::default(); + let json = serde_json::to_string(&p).unwrap(); + assert_eq!(json, "{}"); + } +} diff --git a/fig-core/src/models/project_entry.rs b/fig-core/src/models/project_entry.rs new file mode 100644 index 0000000..8e0a913 --- /dev/null +++ b/fig-core/src/models/project_entry.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +use super::mcp_server::MCPServer; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct ProjectEntry { + #[serde(skip)] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "allowedTools")] + pub allowed_tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "hasTrustDialogAccepted")] + pub has_trust_dialog_accepted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub history: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "mcpServers")] + pub mcp_servers: Option>, + #[serde(flatten)] + pub additional_properties: HashMap, +} + +impl ProjectEntry { + pub fn name(&self) -> Option<&str> { + self.path + .as_ref() + .and_then(|p| std::path::Path::new(p).file_name()?.to_str()) + } + + pub fn has_mcp_servers(&self) -> bool { + self.mcp_servers + .as_ref() + .map(|s| !s.is_empty()) + .unwrap_or(false) + } + + pub fn mcp_server_count(&self) -> usize { + self.mcp_servers.as_ref().map(|s| s.len()).unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_entry_round_trip() { + let json = r#"{"allowedTools":["Bash","Read","Write"],"hasTrustDialogAccepted":true,"history":["conv-1","conv-2"],"mcpServers":{"local":{"command":"node","args":["server.js"]}},"customData":{"nested":"value"}}"#; + let parsed: ProjectEntry = serde_json::from_str(json).unwrap(); + assert_eq!( + parsed.allowed_tools, + Some(vec![ + "Bash".to_string(), + "Read".to_string(), + "Write".to_string() + ]) + ); + assert_eq!(parsed.has_trust_dialog_accepted, Some(true)); + assert!(parsed.has_mcp_servers()); + assert_eq!(parsed.mcp_server_count(), 1); + assert!(parsed.additional_properties.contains_key("customData")); + + let re_serialized = serde_json::to_string(&parsed).unwrap(); + let re_parsed: ProjectEntry = serde_json::from_str(&re_serialized).unwrap(); + assert_eq!(parsed, re_parsed); + } + + #[test] + fn test_project_entry_name_extraction() { + let mut entry = ProjectEntry::default(); + entry.path = Some("/Users/test/projects/my-app".to_string()); + assert_eq!(entry.name(), Some("my-app")); + } + + #[test] + fn test_project_entry_serde_skip_path() { + let mut entry = ProjectEntry::default(); + entry.path = Some("/some/path".to_string()); + entry.allowed_tools = Some(vec!["Bash".to_string()]); + let json = serde_json::to_string(&entry).unwrap(); + assert!(!json.contains("/some/path")); + assert!(json.contains("allowedTools")); + } +} diff --git a/fig-core/src/models/project_group.rs b/fig-core/src/models/project_group.rs new file mode 100644 index 0000000..f387ea4 --- /dev/null +++ b/fig-core/src/models/project_group.rs @@ -0,0 +1,195 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::models::discovered_project::DiscoveredProject; +use crate::models::ProjectEntry; + +#[derive(Debug, Clone)] +pub struct ProjectGroup { + pub parent_path: PathBuf, + pub display_name: String, + pub projects: Vec, +} + +impl ProjectGroup { + pub fn new(parent_path: PathBuf, display_name: String, projects: Vec) -> Self { + Self { + parent_path, + display_name, + projects, + } + } + + pub fn id(&self) -> &PathBuf { + &self.parent_path + } + + /// Groups discovered projects by their parent directory. + /// + /// Abbreviates parent paths relative to the home directory (e.g. `~/code`). + pub fn group_by_directory( + projects: &[DiscoveredProject], + home_dir: Option<&Path>, + ) -> Vec { + let mut groups_map: BTreeMap> = BTreeMap::new(); + + for project in projects { + let parent = project + .path + .parent() + .unwrap_or(Path::new("/")) + .to_path_buf(); + groups_map.entry(parent).or_default().push(project); + } + + groups_map + .into_iter() + .map(|(parent_path, members)| { + let display_name = abbreviate_dir(&parent_path, home_dir); + + let project_entries = members + .into_iter() + .map(|dp| ProjectEntry { + path: Some(dp.path.to_string_lossy().to_string()), + ..Default::default() + }) + .collect(); + + ProjectGroup::new(parent_path, display_name, project_entries) + }) + .collect() + } +} + +pub fn abbreviate_dir(path: &Path, home: Option<&Path>) -> String { + if let Some(h) = home { + if let Ok(relative) = path.strip_prefix(h) { + if relative.as_os_str().is_empty() { + return "~".to_string(); + } + return format!("~/{}", relative.display()); + } + } + path.display().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_group() { + let group = ProjectGroup::new( + PathBuf::from("/Users/sean/code"), + "~/code".to_string(), + vec![], + ); + assert_eq!(group.parent_path, PathBuf::from("/Users/sean/code")); + assert_eq!(group.display_name, "~/code"); + assert!(group.projects.is_empty()); + } + + #[test] + fn test_id_is_parent_path() { + let group = ProjectGroup::new( + PathBuf::from("/Users/sean/projects"), + "~/projects".to_string(), + vec![], + ); + assert_eq!(group.id(), &PathBuf::from("/Users/sean/projects")); + } + + #[test] + fn test_group_with_projects() { + let entry = ProjectEntry { + path: Some("/Users/sean/code/relay".to_string()), + ..Default::default() + }; + + let group = ProjectGroup::new( + PathBuf::from("/Users/sean/code"), + "~/code".to_string(), + vec![entry], + ); + assert_eq!(group.projects.len(), 1); + } + + #[test] + fn test_group_by_directory() { + let projects = vec![ + DiscoveredProject::new( + PathBuf::from("/home/user/code/project-a"), + "project-a".to_string(), + true, + false, + false, + false, + None, + ), + DiscoveredProject::new( + PathBuf::from("/home/user/code/project-b"), + "project-b".to_string(), + true, + false, + false, + false, + None, + ), + DiscoveredProject::new( + PathBuf::from("/home/user/work/project-c"), + "project-c".to_string(), + true, + false, + false, + false, + None, + ), + ]; + + let home = Path::new("/home/user"); + let groups = ProjectGroup::group_by_directory(&projects, Some(home)); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].display_name, "~/code"); + assert_eq!(groups[0].projects.len(), 2); + assert_eq!(groups[1].display_name, "~/work"); + assert_eq!(groups[1].projects.len(), 1); + } + + #[test] + fn test_group_by_directory_no_home() { + let projects = vec![DiscoveredProject::new( + PathBuf::from("/opt/projects/myapp"), + "myapp".to_string(), + true, + false, + false, + false, + None, + )]; + + let groups = ProjectGroup::group_by_directory(&projects, None); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].display_name, "/opt/projects"); + } + + #[test] + fn test_abbreviate_dir_with_home() { + let path = Path::new("/home/user/code"); + let home = Path::new("/home/user"); + assert_eq!(abbreviate_dir(path, Some(home)), "~/code"); + } + + #[test] + fn test_abbreviate_dir_home_root() { + let path = Path::new("/home/user"); + let home = Path::new("/home/user"); + assert_eq!(abbreviate_dir(path, Some(home)), "~"); + } + + #[test] + fn test_abbreviate_dir_no_home() { + let path = Path::new("/opt/data"); + assert_eq!(abbreviate_dir(path, None), "/opt/data"); + } +} diff --git a/fig-core/src/services/config_file_manager.rs b/fig-core/src/services/config_file_manager.rs new file mode 100644 index 0000000..c672179 --- /dev/null +++ b/fig-core/src/services/config_file_manager.rs @@ -0,0 +1,475 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::Local; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::error::ConfigFileError; +use crate::models::{ClaudeSettings, LegacyConfig, MCPConfig}; + +pub struct ConfigFileManager { + home_dir: PathBuf, +} + +impl ConfigFileManager { + pub fn new() -> Result { + let home = dirs::home_dir().ok_or_else(|| ConfigFileError::FileNotFound { + path: PathBuf::from("~"), + })?; + Ok(Self { home_dir: home }) + } + + pub fn with_home_dir(home: PathBuf) -> Self { + Self { home_dir: home } + } + + // Path resolution + + pub fn global_config_path(&self) -> PathBuf { + self.home_dir.join(".claude.json") + } + + pub fn global_settings_dir(&self) -> PathBuf { + self.home_dir.join(".claude") + } + + pub fn global_settings_path(&self) -> PathBuf { + self.home_dir.join(".claude").join("settings.json") + } + + pub fn project_settings_dir(&self, project: &Path) -> PathBuf { + project.join(".claude") + } + + pub fn project_settings_path(&self, project: &Path) -> PathBuf { + project.join(".claude").join("settings.json") + } + + pub fn project_local_settings_path(&self, project: &Path) -> PathBuf { + project.join(".claude").join("settings.local.json") + } + + pub fn mcp_config_path(&self, project: &Path) -> PathBuf { + project.join(".mcp.json") + } + + // Generic read/write + + pub fn read(&self, path: &Path) -> Result, ConfigFileError> { + if !path.exists() { + return Ok(None); + } + + let content = fs::read_to_string(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + ConfigFileError::PermissionDenied { + path: path.to_path_buf(), + } + } else { + ConfigFileError::ReadError { + path: path.to_path_buf(), + message: e.to_string(), + } + } + })?; + + serde_json::from_str(&content) + .map(Some) + .map_err(|e| ConfigFileError::InvalidJson { + path: path.to_path_buf(), + message: e.to_string(), + }) + } + + pub fn write(&self, value: &T, path: &Path) -> Result<(), ConfigFileError> { + if path.exists() { + self.create_backup(path)?; + } + + let parent = path.parent().ok_or_else(|| ConfigFileError::WriteError { + path: path.to_path_buf(), + message: "No parent directory".to_string(), + })?; + + fs::create_dir_all(parent).map_err(|e| ConfigFileError::WriteError { + path: path.to_path_buf(), + message: e.to_string(), + })?; + + let content = + serde_json::to_string_pretty(value).map_err(|e| ConfigFileError::WriteError { + path: path.to_path_buf(), + message: e.to_string(), + })?; + + // Write to a temp file then rename for atomic operation + let temp_path = parent.join(format!(".fig-tmp-{}", std::process::id())); + fs::write(&temp_path, &content).map_err(|e| { + let _ = fs::remove_file(&temp_path); + ConfigFileError::WriteError { + path: path.to_path_buf(), + message: e.to_string(), + } + })?; + + fs::rename(&temp_path, path).map_err(|e| { + let _ = fs::remove_file(&temp_path); + ConfigFileError::WriteError { + path: path.to_path_buf(), + message: e.to_string(), + } + }) + } + + // Typed convenience methods + + pub fn read_global_config(&self) -> Result, ConfigFileError> { + self.read(&self.global_config_path()) + } + + pub fn read_global_settings(&self) -> Result, ConfigFileError> { + self.read(&self.global_settings_path()) + } + + pub fn read_project_settings( + &self, + project: &Path, + ) -> Result, ConfigFileError> { + self.read(&self.project_settings_path(project)) + } + + pub fn read_project_local_settings( + &self, + project: &Path, + ) -> Result, ConfigFileError> { + self.read(&self.project_local_settings_path(project)) + } + + pub fn read_mcp_config(&self, project: &Path) -> Result, ConfigFileError> { + self.read(&self.mcp_config_path(project)) + } + + pub fn write_global_config(&self, config: &LegacyConfig) -> Result<(), ConfigFileError> { + let path = self.global_config_path(); + self.write(config, &path) + } + + pub fn write_global_settings(&self, settings: &ClaudeSettings) -> Result<(), ConfigFileError> { + let path = self.global_settings_path(); + self.write(settings, &path) + } + + pub fn write_project_settings( + &self, + project: &Path, + settings: &ClaudeSettings, + ) -> Result<(), ConfigFileError> { + let path = self.project_settings_path(project); + self.write(settings, &path) + } + + pub fn write_project_local_settings( + &self, + project: &Path, + settings: &ClaudeSettings, + ) -> Result<(), ConfigFileError> { + let path = self.project_local_settings_path(project); + self.write(settings, &path) + } + + pub fn write_mcp_config( + &self, + project: &Path, + config: &MCPConfig, + ) -> Result<(), ConfigFileError> { + let path = self.mcp_config_path(project); + self.write(config, &path) + } + + // Backup + + fn create_backup(&self, path: &Path) -> Result<(), ConfigFileError> { + let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S").to_string(); + let file_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("backup"); + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("json"); + let backup_name = format!("{file_stem}.{timestamp}.{extension}"); + let backup_path = path.with_file_name(backup_name); + + fs::copy(path, &backup_path).map_err(|e| ConfigFileError::BackupFailed { + path: path.to_path_buf(), + message: e.to_string(), + })?; + Ok(()) + } + + // Utilities + + pub fn file_exists(&self, path: &Path) -> bool { + path.exists() + } + + pub fn delete(&self, path: &Path) -> Result<(), ConfigFileError> { + if path.exists() { + self.create_backup(path)?; + fs::remove_file(path).map_err(|e| ConfigFileError::WriteError { + path: path.to_path_buf(), + message: e.to_string(), + })?; + } + Ok(()) + } + + pub fn resolve_symlink( + &self, + path: &Path, + max_depth: usize, + ) -> Result { + let mut current = path.to_path_buf(); + for _ in 0..max_depth { + if !current.is_symlink() { + return Ok(current); + } + let parent = current.parent().unwrap_or(Path::new(".")).to_path_buf(); + let target = fs::read_link(¤t).map_err(|_| ConfigFileError::CircularSymlink { + path: path.to_path_buf(), + })?; + current = if target.is_relative() { + parent.join(&target) + } else { + target + }; + } + Err(ConfigFileError::CircularSymlink { + path: path.to_path_buf(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_read_missing_file_returns_none() { + let tmp = TempDir::new().unwrap(); + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let result: Result, _> = + mgr.read(Path::new("/nonexistent/path.json")); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_read_valid_json() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("settings.json"); + fs::write(&file_path, r#"{"permissions":{"allow":["Bash(*)"]}}"#).unwrap(); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let result: Option = mgr.read(&file_path).unwrap(); + assert!(result.is_some()); + let settings = result.unwrap(); + assert_eq!( + settings.permissions.unwrap().allow, + Some(vec!["Bash(*)".to_string()]) + ); + } + + #[test] + fn test_read_invalid_json() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("bad.json"); + fs::write(&file_path, "not valid json{{{").unwrap(); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let result: Result, _> = mgr.read(&file_path); + assert!(matches!(result, Err(ConfigFileError::InvalidJson { .. }))); + } + + #[test] + fn test_write_creates_backup() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("settings.json"); + fs::write(&file_path, "{}").unwrap(); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let settings = ClaudeSettings::default(); + mgr.write(&settings, &file_path).unwrap(); + + let entries: Vec<_> = fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .unwrap_or("") + .starts_with("settings.") + }) + .collect(); + assert!(entries.len() >= 2, "Expected backup file to be created"); + } + + #[test] + fn test_write_creates_parent_dirs() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("deep").join("nested").join("settings.json"); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let settings = ClaudeSettings::default(); + mgr.write(&settings, &file_path).unwrap(); + + assert!(file_path.exists()); + } + + #[test] + fn test_write_pretty_json() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("settings.json"); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let settings = ClaudeSettings { + disallowed_tools: Some(vec!["Tool".to_string()]), + ..Default::default() + }; + mgr.write(&settings, &file_path).unwrap(); + + let content = fs::read_to_string(&file_path).unwrap(); + assert!(content.contains('\n'), "Expected pretty-printed JSON"); + } + + #[test] + fn test_path_resolution() { + let tmp = TempDir::new().unwrap(); + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + + assert_eq!(mgr.global_config_path(), tmp.path().join(".claude.json")); + assert_eq!( + mgr.global_settings_path(), + tmp.path().join(".claude").join("settings.json") + ); + + let project = Path::new("/my/project"); + assert_eq!( + mgr.project_settings_path(project), + Path::new("/my/project/.claude/settings.json") + ); + assert_eq!( + mgr.project_local_settings_path(project), + Path::new("/my/project/.claude/settings.local.json") + ); + assert_eq!( + mgr.mcp_config_path(project), + Path::new("/my/project/.mcp.json") + ); + } + + #[cfg(unix)] + #[test] + fn test_symlink_resolution() { + let tmp = TempDir::new().unwrap(); + let real_file = tmp.path().join("real.json"); + fs::write(&real_file, "{}").unwrap(); + + let link_path = tmp.path().join("link.json"); + std::os::unix::fs::symlink(&real_file, &link_path).unwrap(); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let resolved = mgr.resolve_symlink(&link_path, 10).unwrap(); + assert_eq!(resolved, real_file); + } + + #[test] + fn test_delete_creates_backup() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("to_delete.json"); + fs::write(&file_path, "{}").unwrap(); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + mgr.delete(&file_path).unwrap(); + + assert!(!file_path.exists()); + let entries: Vec<_> = fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .unwrap_or("") + .starts_with("to_delete.") + }) + .collect(); + assert!(!entries.is_empty(), "Expected backup file"); + } + + #[test] + fn test_backup_timestamp_format() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("settings.json"); + fs::write(&file_path, "{}").unwrap(); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + mgr.create_backup(&file_path).unwrap(); + + let entries: Vec<_> = fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|name| name != "settings.json") + .collect(); + + assert_eq!(entries.len(), 1); + let backup_name = &entries[0]; + assert!(backup_name.starts_with("settings.")); + assert!(backup_name.ends_with(".json")); + assert!(backup_name.contains('T')); + } + + #[cfg(unix)] + #[test] + fn test_symlink_resolution_relative() { + let tmp = TempDir::new().unwrap(); + let subdir = tmp.path().join("subdir"); + fs::create_dir(&subdir).unwrap(); + let real_file = subdir.join("real.json"); + fs::write(&real_file, "{}").unwrap(); + + // Create a symlink in tmp root pointing to a relative path + let link_path = tmp.path().join("link.json"); + std::os::unix::fs::symlink("subdir/real.json", &link_path).unwrap(); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let resolved = mgr.resolve_symlink(&link_path, 10).unwrap(); + assert!(resolved.ends_with("subdir/real.json")); + assert!(!resolved.is_symlink()); + } + + #[test] + fn test_write_atomic_no_temp_file_left() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("settings.json"); + + let mgr = ConfigFileManager::with_home_dir(tmp.path().to_path_buf()); + let settings = ClaudeSettings::default(); + mgr.write(&settings, &file_path).unwrap(); + + // Verify the file was written + assert!(file_path.exists()); + + // Verify no temp files remain + let temp_files: Vec<_> = fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_str() + .unwrap_or("") + .starts_with(".fig-tmp-") + }) + .collect(); + assert!(temp_files.is_empty(), "Temp file should be cleaned up"); + } +} diff --git a/fig-core/src/services/file_watcher.rs b/fig-core/src/services/file_watcher.rs new file mode 100644 index 0000000..4dc0da9 --- /dev/null +++ b/fig-core/src/services/file_watcher.rs @@ -0,0 +1,157 @@ +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tokio::sync::mpsc; + +#[derive(Debug, Clone)] +pub struct FileWatchEvent { + pub path: PathBuf, + pub kind: FileWatchEventKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileWatchEventKind { + Modified, + Deleted, + Created, +} + +pub struct FileWatcher { + watcher: RecommendedWatcher, + /// Maps user-provided path to the effective path passed to the OS watcher. + watched_paths: HashMap, + _tx: mpsc::UnboundedSender, +} + +impl FileWatcher { + pub fn new() -> Result<(Self, mpsc::UnboundedReceiver), notify::Error> { + let (tx, rx) = mpsc::unbounded_channel(); + let tx_clone = tx.clone(); + + let watcher = notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + let kind = match event.kind { + EventKind::Create(_) => Some(FileWatchEventKind::Created), + EventKind::Modify(_) => Some(FileWatchEventKind::Modified), + EventKind::Remove(_) => Some(FileWatchEventKind::Deleted), + _ => None, + }; + + if let Some(kind) = kind { + for path in event.paths { + let _ = tx_clone.send(FileWatchEvent { path, kind }); + } + } + } + })?; + + Ok(( + Self { + watcher, + watched_paths: HashMap::new(), + _tx: tx, + }, + rx, + )) + } + + pub fn watch(&mut self, path: &Path) -> Result<(), notify::Error> { + let watch_path = if path.is_file() { + path.parent().unwrap_or(path).to_path_buf() + } else { + path.to_path_buf() + }; + + self.watcher + .watch(&watch_path, RecursiveMode::NonRecursive)?; + self.watched_paths.insert(path.to_path_buf(), watch_path); + Ok(()) + } + + pub fn unwatch(&mut self, path: &Path) -> Result<(), notify::Error> { + if let Some(watch_path) = self.watched_paths.remove(path) { + self.watcher.unwatch(&watch_path)?; + } + Ok(()) + } + + pub fn unwatch_all(&mut self) { + let entries: Vec<(PathBuf, PathBuf)> = self.watched_paths.drain().collect(); + for (_path, watch_path) in entries { + let _ = self.watcher.unwatch(&watch_path); + } + } + + pub fn is_watching(&self, path: &Path) -> bool { + self.watched_paths.contains_key(path) + } +} + +impl Drop for FileWatcher { + fn drop(&mut self) { + self.unwatch_all(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[tokio::test] + async fn test_watch_file_modification() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("test.json"); + fs::write(&file_path, "{}").unwrap(); + + let (mut watcher, mut rx) = FileWatcher::new().unwrap(); + watcher.watch(&file_path).unwrap(); + + fs::write(&file_path, r#"{"changed": true}"#).unwrap(); + + let event = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await; + + assert!(event.is_ok(), "Should receive event within timeout"); + let event = event.unwrap().unwrap(); + assert!(matches!( + event.kind, + FileWatchEventKind::Modified | FileWatchEventKind::Created + )); + } + + #[test] + fn test_is_watching() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("test.json"); + fs::write(&file_path, "{}").unwrap(); + + let (mut watcher, _rx) = FileWatcher::new().unwrap(); + assert!(!watcher.is_watching(&file_path)); + + watcher.watch(&file_path).unwrap(); + assert!(watcher.is_watching(&file_path)); + + watcher.unwatch(&file_path).unwrap(); + assert!(!watcher.is_watching(&file_path)); + } + + #[test] + fn test_unwatch_all() { + let tmp = TempDir::new().unwrap(); + let file1 = tmp.path().join("a.json"); + let file2 = tmp.path().join("b.json"); + fs::write(&file1, "{}").unwrap(); + fs::write(&file2, "{}").unwrap(); + + let (mut watcher, _rx) = FileWatcher::new().unwrap(); + watcher.watch(&file1).unwrap(); + watcher.watch(&file2).unwrap(); + assert!(watcher.is_watching(&file1)); + assert!(watcher.is_watching(&file2)); + + watcher.unwatch_all(); + assert!(!watcher.is_watching(&file1)); + assert!(!watcher.is_watching(&file2)); + } +} diff --git a/fig-core/src/services/health_check.rs b/fig-core/src/services/health_check.rs new file mode 100644 index 0000000..dc43e62 --- /dev/null +++ b/fig-core/src/services/health_check.rs @@ -0,0 +1,386 @@ +use crate::models::{ClaudeSettings, MergedSettings}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FindingSeverity { + Good, + Suggestion, + Warning, + Security, +} + +impl FindingSeverity { + pub fn label(&self) -> &str { + match self { + Self::Good => "Good", + Self::Suggestion => "Suggestion", + Self::Warning => "Warning", + Self::Security => "Security", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Finding { + pub severity: FindingSeverity, + pub title: String, + pub description: String, + pub check_name: String, +} + +pub struct HealthCheckContext { + pub global_settings: Option, + pub project_settings: Option, + pub merged: MergedSettings, + pub has_local_settings: bool, + pub has_project_mcp: bool, +} + +pub trait HealthCheck { + fn name(&self) -> &str; + fn run(&self, ctx: &HealthCheckContext) -> Vec; +} + +pub fn run_all_checks(ctx: &HealthCheckContext) -> Vec { + let checks: Vec> = vec![ + Box::new(DenyListSecurityCheck), + Box::new(BroadAllowRulesCheck), + Box::new(MCPHardcodedSecretsCheck), + Box::new(LocalSettingsCheck), + Box::new(MCPScopingCheck), + Box::new(HookSuggestionsCheck), + Box::new(GoodPracticesCheck), + ]; + + let mut findings: Vec = checks.iter().flat_map(|c| c.run(ctx)).collect(); + findings.sort_by(|a, b| b.severity.cmp(&a.severity)); + findings +} + +pub struct DenyListSecurityCheck; + +impl HealthCheck for DenyListSecurityCheck { + fn name(&self) -> &str { + "Deny List Security" + } + + fn run(&self, ctx: &HealthCheckContext) -> Vec { + let deny_patterns = ctx.merged.permissions.deny_patterns(); + let has_env_protection = deny_patterns.iter().any(|p| p.contains(".env")); + + if has_env_protection { + vec![Finding { + severity: FindingSeverity::Good, + title: "Environment files protected".to_string(), + description: "Deny rules prevent reading .env files.".to_string(), + check_name: self.name().to_string(), + }] + } else { + vec![Finding { + severity: FindingSeverity::Security, + title: "No .env file protection".to_string(), + description: "Consider adding a deny rule for Read(.env) to protect sensitive environment files.".to_string(), + check_name: self.name().to_string(), + }] + } + } +} + +pub struct BroadAllowRulesCheck; + +impl HealthCheck for BroadAllowRulesCheck { + fn name(&self) -> &str { + "Broad Allow Rules" + } + + fn run(&self, ctx: &HealthCheckContext) -> Vec { + let broad_patterns = ["Bash(*)", "Read(*)", "Write(*)", "Edit(*)"]; + let mut findings = Vec::new(); + + for pattern in ctx.merged.permissions.allow_patterns() { + if broad_patterns.contains(&pattern) { + findings.push(Finding { + severity: FindingSeverity::Warning, + title: format!("Broad allow rule: {pattern}"), + description: + "This rule allows very broad access. Consider narrowing the scope." + .to_string(), + check_name: self.name().to_string(), + }); + } + } + + if findings.is_empty() { + findings.push(Finding { + severity: FindingSeverity::Good, + title: "No overly broad allow rules".to_string(), + description: "Permission rules are appropriately scoped.".to_string(), + check_name: self.name().to_string(), + }); + } + + findings + } +} + +pub struct MCPHardcodedSecretsCheck; + +impl HealthCheck for MCPHardcodedSecretsCheck { + fn name(&self) -> &str { + "MCP Hardcoded Secrets" + } + + fn run(&self, ctx: &HealthCheckContext) -> Vec { + let secret_prefixes = ["sk-", "ghp_", "gho_", "AKIA", "Bearer "]; + let mut findings = Vec::new(); + + let check_settings = |settings: &Option| -> Vec { + let mut issues = Vec::new(); + if let Some(ref s) = settings { + if let Some(ref env) = s.env { + for (key, value) in env { + if secret_prefixes.iter().any(|p| value.starts_with(p)) { + issues.push(key.clone()); + } + } + } + } + issues + }; + + let global_issues = check_settings(&ctx.global_settings); + let project_issues = check_settings(&ctx.project_settings); + + for key in global_issues.iter().chain(project_issues.iter()) { + findings.push(Finding { + severity: FindingSeverity::Security, + title: format!("Possible hardcoded secret in {key}"), + description: + "This value looks like an API key or token. Use environment variables instead." + .to_string(), + check_name: self.name().to_string(), + }); + } + + findings + } +} + +pub struct LocalSettingsCheck; + +impl HealthCheck for LocalSettingsCheck { + fn name(&self) -> &str { + "Local Settings" + } + + fn run(&self, ctx: &HealthCheckContext) -> Vec { + if ctx.has_local_settings { + return Vec::new(); + } + vec![Finding { + severity: FindingSeverity::Suggestion, + title: "No local settings file".to_string(), + description: + "Consider creating a settings.local.json for personal overrides that aren't shared." + .to_string(), + check_name: self.name().to_string(), + }] + } +} + +pub struct MCPScopingCheck; + +impl HealthCheck for MCPScopingCheck { + fn name(&self) -> &str { + "MCP Server Scoping" + } + + fn run(&self, ctx: &HealthCheckContext) -> Vec { + if !ctx.has_project_mcp { + if let Some(ref settings) = ctx.global_settings { + if settings.env.as_ref().map(|e| e.len()).unwrap_or(0) > 0 { + return vec![Finding { + severity: FindingSeverity::Suggestion, + title: "Consider project-scoped MCP servers".to_string(), + description: + "You have global config but no project .mcp.json. Project-specific servers belong in .mcp.json." + .to_string(), + check_name: self.name().to_string(), + }]; + } + } + } + Vec::new() + } +} + +pub struct HookSuggestionsCheck; + +impl HealthCheck for HookSuggestionsCheck { + fn name(&self) -> &str { + "Hook Suggestions" + } + + fn run(&self, ctx: &HealthCheckContext) -> Vec { + let has_hooks = !ctx.merged.hooks.event_names().is_empty(); + if has_hooks { + return vec![Finding { + severity: FindingSeverity::Good, + title: "Hooks configured".to_string(), + description: "You have lifecycle hooks set up for automation.".to_string(), + check_name: self.name().to_string(), + }]; + } + vec![Finding { + severity: FindingSeverity::Suggestion, + title: "No hooks configured".to_string(), + description: + "Hooks can automate formatting, linting, and testing after Claude edits files." + .to_string(), + check_name: self.name().to_string(), + }] + } +} + +pub struct GoodPracticesCheck; + +impl HealthCheck for GoodPracticesCheck { + fn name(&self) -> &str { + "Good Practices" + } + + fn run(&self, ctx: &HealthCheckContext) -> Vec { + let mut findings = Vec::new(); + + // Check for deny rules (good practice) + if !ctx.merged.permissions.deny_patterns().is_empty() { + findings.push(Finding { + severity: FindingSeverity::Good, + title: "Deny rules configured".to_string(), + description: "You have explicit deny rules to restrict access.".to_string(), + check_name: self.name().to_string(), + }); + } + + // Check for disallowed tools + if !ctx.merged.disallowed_tools.is_empty() { + findings.push(Finding { + severity: FindingSeverity::Good, + title: "Disallowed tools configured".to_string(), + description: "You have tools explicitly blocked.".to_string(), + check_name: self.name().to_string(), + }); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ConfigSource, MergedValue}; + use std::collections::HashMap; + + fn empty_context() -> HealthCheckContext { + HealthCheckContext { + global_settings: None, + project_settings: None, + merged: MergedSettings::default(), + has_local_settings: false, + has_project_mcp: false, + } + } + + #[test] + fn test_deny_list_no_protection() { + let ctx = empty_context(); + let findings = DenyListSecurityCheck.run(&ctx); + assert!(findings + .iter() + .any(|f| f.severity == FindingSeverity::Security)); + } + + #[test] + fn test_deny_list_with_protection() { + let mut ctx = empty_context(); + ctx.merged.permissions.deny.push(MergedValue { + value: "Read(.env)".to_string(), + source: ConfigSource::Global, + }); + let findings = DenyListSecurityCheck.run(&ctx); + assert!(findings.iter().any(|f| f.severity == FindingSeverity::Good)); + } + + #[test] + fn test_broad_allow_rules() { + let mut ctx = empty_context(); + ctx.merged.permissions.allow.push(MergedValue { + value: "Bash(*)".to_string(), + source: ConfigSource::Global, + }); + let findings = BroadAllowRulesCheck.run(&ctx); + assert!(findings + .iter() + .any(|f| f.severity == FindingSeverity::Warning)); + } + + #[test] + fn test_mcp_hardcoded_secrets() { + let mut ctx = empty_context(); + let mut env = HashMap::new(); + env.insert("API_KEY".to_string(), "sk-abc123".to_string()); + ctx.global_settings = Some(ClaudeSettings { + env: Some(env), + ..Default::default() + }); + let findings = MCPHardcodedSecretsCheck.run(&ctx); + assert!(findings + .iter() + .any(|f| f.severity == FindingSeverity::Security)); + } + + #[test] + fn test_local_settings_suggestion() { + let ctx = empty_context(); + let findings = LocalSettingsCheck.run(&ctx); + assert!(findings + .iter() + .any(|f| f.severity == FindingSeverity::Suggestion)); + + let mut ctx2 = empty_context(); + ctx2.has_local_settings = true; + let findings2 = LocalSettingsCheck.run(&ctx2); + assert!(findings2.is_empty()); + } + + #[test] + fn test_hook_suggestions_no_hooks() { + let ctx = empty_context(); + let findings = HookSuggestionsCheck.run(&ctx); + assert!(findings + .iter() + .any(|f| f.severity == FindingSeverity::Suggestion)); + } + + #[test] + fn test_good_practices_with_deny() { + let mut ctx = empty_context(); + ctx.merged.permissions.deny.push(MergedValue { + value: "Write(.env)".to_string(), + source: ConfigSource::Global, + }); + let findings = GoodPracticesCheck.run(&ctx); + assert!(findings.iter().any(|f| f.severity == FindingSeverity::Good)); + } + + #[test] + fn test_run_all_checks() { + let ctx = empty_context(); + let findings = run_all_checks(&ctx); + assert!(!findings.is_empty()); + // Verify sorted by severity descending + for window in findings.windows(2) { + assert!(window[0].severity >= window[1].severity); + } + } +} diff --git a/fig-core/src/services/mcp_clipboard_service.rs b/fig-core/src/services/mcp_clipboard_service.rs new file mode 100644 index 0000000..c11d2bb --- /dev/null +++ b/fig-core/src/services/mcp_clipboard_service.rs @@ -0,0 +1,141 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::FigError; +use crate::models::editable_types::EditableEnvironmentVariable; +use crate::models::{MCPConfig, MCPServer}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ShareableServerConfig { + #[serde(rename = "mcpServers")] + pub mcp_servers: HashMap, +} + +pub fn export_to_json(servers: &[(&str, &MCPServer)], redact_sensitive: bool) -> String { + let mut map = HashMap::new(); + for (name, server) in servers { + let s = if redact_sensitive { + redact_server(server) + } else { + (*server).clone() + }; + map.insert(name.to_string(), s); + } + let config = ShareableServerConfig { mcp_servers: map }; + serde_json::to_string_pretty(&config).unwrap_or_default() +} + +pub fn import_from_json(json_str: &str) -> Result, FigError> { + // Try ShareableServerConfig format first + if let Ok(config) = serde_json::from_str::(json_str) { + let mut servers: Vec<_> = config.mcp_servers.into_iter().collect(); + servers.sort_by(|(a, _), (b, _)| a.cmp(b)); + return Ok(servers); + } + + // Try MCPConfig format (same structure but may have additional fields) + if let Ok(config) = serde_json::from_str::(json_str) { + if let Some(servers_map) = config.mcp_servers { + let mut servers: Vec<_> = servers_map.into_iter().collect(); + servers.sort_by(|(a, _), (b, _)| a.cmp(b)); + return Ok(servers); + } + } + + Err(FigError::Other( + "Invalid JSON: expected an object with 'mcpServers' key.".to_string(), + )) +} + +pub fn redact_server(server: &MCPServer) -> MCPServer { + let mut redacted = server.clone(); + if let Some(ref mut env) = redacted.env { + for (key, value) in env.iter_mut() { + if EditableEnvironmentVariable::is_sensitive_key(key) { + *value = "REDACTED".to_string(); + } + } + } + if let Some(ref mut headers) = redacted.headers { + for (key, value) in headers.iter_mut() { + let lower = key.to_lowercase(); + if lower.contains("authorization") || lower.contains("token") || lower.contains("key") { + *value = "REDACTED".to_string(); + } + } + } + redacted +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_export_import_round_trip() { + let server = MCPServer::stdio("npx".into(), Some(vec!["-y".into(), "pkg".into()]), None); + let json = export_to_json(&[("github", &server)], false); + let imported = import_from_json(&json).unwrap(); + assert_eq!(imported.len(), 1); + assert_eq!(imported[0].0, "github"); + assert_eq!(imported[0].1.command, Some("npx".to_string())); + } + + #[test] + fn test_redact_sensitive() { + let mut env = HashMap::new(); + env.insert("GITHUB_TOKEN".to_string(), "secret123".to_string()); + env.insert("PATH".to_string(), "/usr/bin".to_string()); + let server = MCPServer::stdio("npx".into(), None, Some(env)); + + let json = export_to_json(&[("github", &server)], true); + assert!(json.contains("REDACTED")); + assert!(!json.contains("secret123")); + assert!(json.contains("/usr/bin")); + } + + #[test] + fn test_redact_preserves_non_sensitive() { + let mut env = HashMap::new(); + env.insert("DEBUG".to_string(), "true".to_string()); + env.insert("LOG_LEVEL".to_string(), "info".to_string()); + let server = MCPServer::stdio("node".into(), None, Some(env)); + + let redacted = redact_server(&server); + let env = redacted.env.unwrap(); + assert_eq!(env.get("DEBUG"), Some(&"true".to_string())); + assert_eq!(env.get("LOG_LEVEL"), Some(&"info".to_string())); + } + + #[test] + fn test_import_invalid_json() { + let result = import_from_json("not valid json"); + assert!(result.is_err()); + } + + #[test] + fn test_export_multiple() { + let s1 = MCPServer::stdio("npx".into(), None, None); + let s2 = MCPServer::http("https://example.com".into(), None); + let json = export_to_json(&[("github", &s1), ("remote", &s2)], false); + let imported = import_from_json(&json).unwrap(); + assert_eq!(imported.len(), 2); + } + + #[test] + fn test_redact_headers() { + let mut headers = HashMap::new(); + headers.insert("Authorization".to_string(), "Bearer secret".to_string()); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + let server = MCPServer::http("https://example.com".into(), Some(headers)); + + let redacted = redact_server(&server); + let headers = redacted.headers.unwrap(); + assert_eq!(headers.get("Authorization"), Some(&"REDACTED".to_string())); + assert_eq!( + headers.get("Content-Type"), + Some(&"application/json".to_string()) + ); + } +} diff --git a/fig-core/src/services/mcp_copy_service.rs b/fig-core/src/services/mcp_copy_service.rs new file mode 100644 index 0000000..dee1efd --- /dev/null +++ b/fig-core/src/services/mcp_copy_service.rs @@ -0,0 +1,167 @@ +use crate::models::{MCPConfig, MCPServer}; + +#[derive(Debug, Clone, PartialEq)] +pub struct CopyConflict { + pub server_name: String, + pub existing_summary: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CopyResult { + Success { copied_count: usize }, + Conflicts(Vec), +} + +fn server_summary(server: &MCPServer) -> String { + if server.is_http() { + format!("http: {}", server.url.as_deref().unwrap_or("(no url)")) + } else { + format!( + "stdio: {}", + server.command.as_deref().unwrap_or("(no command)") + ) + } +} + +pub fn check_conflicts(names: &[&str], target: &MCPConfig) -> Vec { + names + .iter() + .filter_map(|name| { + target.server(name).map(|server| CopyConflict { + server_name: name.to_string(), + existing_summary: server_summary(server), + }) + }) + .collect() +} + +pub fn copy_servers(names: &[&str], from: &MCPConfig, to: &mut MCPConfig) -> CopyResult { + let conflicts = check_conflicts(names, to); + if !conflicts.is_empty() { + return CopyResult::Conflicts(conflicts); + } + + let mut copied = 0; + let target_servers = to.mcp_servers.get_or_insert_with(Default::default); + for name in names { + if let Some(server) = from.server(name) { + target_servers.insert(name.to_string(), server.clone()); + copied += 1; + } + } + + CopyResult::Success { + copied_count: copied, + } +} + +pub fn force_copy_servers(names: &[&str], from: &MCPConfig, to: &mut MCPConfig) { + let target_servers = to.mcp_servers.get_or_insert_with(Default::default); + for name in names { + if let Some(server) = from.server(name) { + target_servers.insert(name.to_string(), server.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_config(servers: Vec<(&str, MCPServer)>) -> MCPConfig { + let mut map = HashMap::new(); + for (name, server) in servers { + map.insert(name.to_string(), server); + } + MCPConfig { + mcp_servers: Some(map), + additional_properties: HashMap::new(), + } + } + + #[test] + fn test_copy_no_conflict() { + let from = make_config(vec![("github", MCPServer::stdio("npx".into(), None, None))]); + let mut to = MCPConfig::default(); + + let result = copy_servers(&["github"], &from, &mut to); + assert_eq!(result, CopyResult::Success { copied_count: 1 }); + assert!(to.server("github").is_some()); + } + + #[test] + fn test_copy_with_conflict() { + let from = make_config(vec![("github", MCPServer::stdio("npx".into(), None, None))]); + let mut to = make_config(vec![( + "github", + MCPServer::stdio("node".into(), None, None), + )]); + + let result = copy_servers(&["github"], &from, &mut to); + assert!(matches!(result, CopyResult::Conflicts(_))); + if let CopyResult::Conflicts(conflicts) = result { + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].server_name, "github"); + assert!(conflicts[0].existing_summary.contains("stdio: node")); + } + } + + #[test] + fn test_force_copy() { + let from = make_config(vec![("github", MCPServer::stdio("npx".into(), None, None))]); + let mut to = make_config(vec![( + "github", + MCPServer::stdio("node".into(), None, None), + )]); + + force_copy_servers(&["github"], &from, &mut to); + assert_eq!( + to.server("github").unwrap().command, + Some("npx".to_string()) + ); + } + + #[test] + fn test_copy_all_mixed() { + let from = make_config(vec![ + ("github", MCPServer::stdio("npx".into(), None, None)), + ( + "remote", + MCPServer::http("https://example.com".into(), None), + ), + ]); + let mut to = make_config(vec![( + "github", + MCPServer::stdio("node".into(), None, None), + )]); + + let result = copy_servers(&["github", "remote"], &from, &mut to); + assert!(matches!(result, CopyResult::Conflicts(_))); + + // Force copy both + force_copy_servers(&["github", "remote"], &from, &mut to); + assert_eq!(to.server_count(), 2); + assert_eq!( + to.server("github").unwrap().command, + Some("npx".to_string()) + ); + assert!(to.server("remote").unwrap().is_http()); + } + + #[test] + fn test_check_conflicts_empty_target() { + let target = MCPConfig::default(); + let conflicts = check_conflicts(&["github", "remote"], &target); + assert!(conflicts.is_empty()); + } + + #[test] + fn test_server_summary_formatting() { + let stdio = MCPServer::stdio("npx".into(), None, None); + assert_eq!(server_summary(&stdio), "stdio: npx"); + + let http = MCPServer::http("https://api.example.com".into(), None); + assert_eq!(server_summary(&http), "http: https://api.example.com"); + } +} diff --git a/fig-core/src/services/mcp_health_check.rs b/fig-core/src/services/mcp_health_check.rs new file mode 100644 index 0000000..7d6d026 --- /dev/null +++ b/fig-core/src/services/mcp_health_check.rs @@ -0,0 +1,333 @@ +use std::time::{Duration, Instant}; + +use crate::models::MCPServer; + +#[derive(Debug, Clone, PartialEq)] +pub enum MCPHealthStatus { + Success { server_info: String }, + Failure { error: String }, + Timeout, +} + +#[derive(Debug, Clone)] +pub struct MCPHealthCheckResult { + pub server_name: String, + pub status: MCPHealthStatus, + pub duration: Duration, +} + +const HEALTH_CHECK_TIMEOUT_SECS: u64 = 10; + +pub fn build_initialize_request() -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "fig-health-check", + "version": "1.0.0" + } + } + }) +} + +pub async fn check_health(name: &str, server: &MCPServer) -> MCPHealthCheckResult { + if server.is_http() { + check_http(name, server).await + } else if server.is_stdio() { + check_stdio(name, server).await + } else { + MCPHealthCheckResult { + server_name: name.to_string(), + status: MCPHealthStatus::Failure { + error: "Server has no command or URL configured.".to_string(), + }, + duration: Duration::ZERO, + } + } +} + +async fn check_stdio(name: &str, server: &MCPServer) -> MCPHealthCheckResult { + let start = Instant::now(); + let command = match &server.command { + Some(cmd) => cmd.clone(), + None => { + return MCPHealthCheckResult { + server_name: name.to_string(), + status: MCPHealthStatus::Failure { + error: "No command configured.".to_string(), + }, + duration: start.elapsed(), + }; + } + }; + + let args = server.args.clone().unwrap_or_default(); + let mut cmd = tokio::process::Command::new(&command); + cmd.args(&args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + + if let Some(ref env) = server.env { + for (key, value) in env { + cmd.env(key, value); + } + } + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + return MCPHealthCheckResult { + server_name: name.to_string(), + status: MCPHealthStatus::Failure { + error: format!("Failed to spawn process: {e}"), + }, + duration: start.elapsed(), + }; + } + }; + + let request = build_initialize_request(); + let request_body = match serde_json::to_string(&request) { + Ok(body) => body, + Err(e) => { + return MCPHealthCheckResult { + server_name: name.to_string(), + status: MCPHealthStatus::Failure { + error: format!("Failed to serialize request: {e}"), + }, + duration: start.elapsed(), + }; + } + }; + let message = format!( + "Content-Length: {}\r\n\r\n{}", + request_body.len(), + request_body + ); + + // Write to stdin + if let Some(ref mut stdin) = child.stdin { + use tokio::io::AsyncWriteExt; + if let Err(e) = stdin.write_all(message.as_bytes()).await { + let _ = child.kill().await; + return MCPHealthCheckResult { + server_name: name.to_string(), + status: MCPHealthStatus::Failure { + error: format!("Failed to write to stdin: {e}"), + }, + duration: start.elapsed(), + }; + } + } + + // Read response with timeout + let timeout = Duration::from_secs(HEALTH_CHECK_TIMEOUT_SECS); + let read_result = tokio::time::timeout(timeout, async { + if let Some(ref mut stdout) = child.stdout { + use tokio::io::AsyncReadExt; + let mut buf = vec![0u8; 4096]; + match stdout.read(&mut buf).await { + Ok(n) if n > 0 => { + let response = String::from_utf8_lossy(&buf[..n]).to_string(); + Ok(response) + } + Ok(_) => Err("Empty response from server".to_string()), + Err(e) => Err(format!("Failed to read stdout: {e}")), + } + } else { + Err("No stdout available".to_string()) + } + }) + .await; + + let _ = child.kill().await; + + let status = match read_result { + Ok(Ok(response)) => { + if response.contains("\"result\"") || response.contains("initialize") { + MCPHealthStatus::Success { + server_info: extract_server_info(&response), + } + } else { + MCPHealthStatus::Failure { + error: "Invalid MCP handshake response.".to_string(), + } + } + } + Ok(Err(e)) => MCPHealthStatus::Failure { error: e }, + Err(_) => MCPHealthStatus::Timeout, + }; + + MCPHealthCheckResult { + server_name: name.to_string(), + status, + duration: start.elapsed(), + } +} + +async fn check_http(name: &str, server: &MCPServer) -> MCPHealthCheckResult { + let start = Instant::now(); + let url = match &server.url { + Some(url) => url.clone(), + None => { + return MCPHealthCheckResult { + server_name: name.to_string(), + status: MCPHealthStatus::Failure { + error: "No URL configured.".to_string(), + }, + duration: start.elapsed(), + }; + } + }; + + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(HEALTH_CHECK_TIMEOUT_SECS)) + .build() + { + Ok(client) => client, + Err(e) => { + return MCPHealthCheckResult { + server_name: name.to_string(), + status: MCPHealthStatus::Failure { + error: format!("Failed to build HTTP client: {e}"), + }, + duration: start.elapsed(), + }; + } + }; + + let request = build_initialize_request(); + let mut req = client.post(&url).json(&request); + + if let Some(ref headers) = server.headers { + for (key, value) in headers { + req = req.header(key, value); + } + } + + let status = match req.send().await { + Ok(resp) => { + if resp.status().is_success() { + match resp.text().await { + Ok(body) => MCPHealthStatus::Success { + server_info: extract_server_info(&body), + }, + Err(e) => MCPHealthStatus::Failure { + error: format!("Failed to read response: {e}"), + }, + } + } else { + MCPHealthStatus::Failure { + error: format!("HTTP {}", resp.status()), + } + } + } + Err(e) => { + if e.is_timeout() { + MCPHealthStatus::Timeout + } else { + MCPHealthStatus::Failure { + error: format!("Request failed: {e}"), + } + } + } + }; + + MCPHealthCheckResult { + server_name: name.to_string(), + status, + duration: start.elapsed(), + } +} + +fn extract_server_info(response: &str) -> String { + if let Ok(json) = serde_json::from_str::(response) { + if let Some(info) = json + .get("result") + .and_then(|r| r.get("serverInfo")) + .and_then(|s| s.get("name")) + .and_then(|n| n.as_str()) + { + return info.to_string(); + } + } + // Try to find serverInfo in Content-Length framed response + if let Some(start) = response.find('{') { + if let Ok(json) = serde_json::from_str::(&response[start..]) { + if let Some(info) = json + .get("result") + .and_then(|r| r.get("serverInfo")) + .and_then(|s| s.get("name")) + .and_then(|n| n.as_str()) + { + return info.to_string(); + } + } + } + "MCP Server".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initialize_request_format() { + let req = build_initialize_request(); + assert_eq!(req["jsonrpc"], "2.0"); + assert_eq!(req["method"], "initialize"); + assert!(req["params"]["protocolVersion"].is_string()); + assert!(req["params"]["clientInfo"]["name"].is_string()); + } + + #[test] + fn test_extract_server_info_valid() { + let response = r#"{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"test-server","version":"1.0"}}}"#; + assert_eq!(extract_server_info(response), "test-server"); + } + + #[test] + fn test_extract_server_info_fallback() { + let response = r#"{"jsonrpc":"2.0","id":1,"result":{}}"#; + assert_eq!(extract_server_info(response), "MCP Server"); + } + + #[test] + fn test_extract_server_info_with_content_length() { + let response = "Content-Length: 80\r\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"serverInfo\":{\"name\":\"framed-server\"}}}"; + assert_eq!(extract_server_info(response), "framed-server"); + } + + #[tokio::test] + async fn test_no_command_or_url() { + let server = MCPServer::default(); + let result = check_health("test", &server).await; + assert!(matches!(result.status, MCPHealthStatus::Failure { .. })); + } + + #[tokio::test] + async fn test_stdio_nonexistent_command() { + let server = MCPServer::stdio("fig_nonexistent_command_12345".into(), None, None); + let result = check_health("test", &server).await; + assert!(matches!(result.status, MCPHealthStatus::Failure { .. })); + if let MCPHealthStatus::Failure { error } = &result.status { + assert!(error.contains("Failed to spawn process")); + } + } + + #[tokio::test] + async fn test_http_invalid_url() { + let server = MCPServer::http("http://127.0.0.1:1".into(), None); + let result = check_health("test", &server).await; + // Should fail or timeout, but not panic + assert!(matches!( + result.status, + MCPHealthStatus::Failure { .. } | MCPHealthStatus::Timeout + )); + } +} diff --git a/fig-core/src/services/mod.rs b/fig-core/src/services/mod.rs new file mode 100644 index 0000000..0a7d168 --- /dev/null +++ b/fig-core/src/services/mod.rs @@ -0,0 +1,19 @@ +pub mod config_file_manager; +pub mod file_watcher; +pub mod health_check; +pub mod mcp_clipboard_service; +pub mod mcp_copy_service; +pub mod mcp_health_check; +pub mod project_discovery; +pub mod settings_merge; +pub mod undo_manager; + +pub use config_file_manager::ConfigFileManager; +pub use file_watcher::{FileWatchEvent, FileWatchEventKind, FileWatcher}; +pub use health_check::{Finding, FindingSeverity, HealthCheckContext}; +pub use mcp_clipboard_service::ShareableServerConfig; +pub use mcp_copy_service::{CopyConflict, CopyResult}; +pub use mcp_health_check::{MCPHealthCheckResult, MCPHealthStatus}; +pub use project_discovery::ProjectDiscoveryService; +pub use settings_merge::SettingsMergeService; +pub use undo_manager::UndoManager; diff --git a/fig-core/src/services/project_discovery.rs b/fig-core/src/services/project_discovery.rs new file mode 100644 index 0000000..1fd9042 --- /dev/null +++ b/fig-core/src/services/project_discovery.rs @@ -0,0 +1,388 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use walkdir::WalkDir; + +use crate::models::DiscoveredProject; +use crate::services::ConfigFileManager; + +pub struct ProjectDiscoveryService { + config_manager: ConfigFileManager, +} + +const DEFAULT_SCAN_DIRECTORIES: &[&str] = &[ + "~", + "~/code", + "~/Code", + "~/projects", + "~/Projects", + "~/Developer", + "~/dev", + "~/src", + "~/repos", + "~/github", + "~/workspace", +]; + +const SKIP_DIRECTORIES: &[&str] = &[ + "node_modules", + ".git", + ".svn", + ".hg", + "vendor", + "Pods", + ".build", + "build", + "dist", + "target", + "__pycache__", + ".venv", + "venv", + ".cache", + "Library", + "Applications", +]; + +impl ProjectDiscoveryService { + pub fn new(config_manager: ConfigFileManager) -> Self { + Self { config_manager } + } + + pub fn discover_projects( + &self, + scan_directories: bool, + directories: Option<&[String]>, + ) -> Vec { + let mut all_paths = HashSet::new(); + + // 1. Discover from legacy config (fault-tolerant) + if let Ok(paths) = self.discover_from_legacy_config() { + all_paths.extend(paths); + } + + // 2. Optionally scan directories + if scan_directories { + let scanned = match directories { + Some(dirs) => self.scan_for_projects(dirs), + None => { + let defaults: Vec = DEFAULT_SCAN_DIRECTORIES + .iter() + .map(|s| s.to_string()) + .collect(); + self.scan_for_projects(&defaults) + } + }; + all_paths.extend(scanned); + } + + // 3. Build discovered project entries + let mut projects: Vec = all_paths + .into_iter() + .filter_map(|path| self.build_discovered_project(&path)) + .collect(); + + // 4. Sort by last modified (most recent first), then by name + projects.sort_by(|a, b| match (&a.last_modified, &b.last_modified) { + (Some(t1), Some(t2)) => t2.cmp(t1), + (None, Some(_)) => std::cmp::Ordering::Greater, + (Some(_), None) => std::cmp::Ordering::Less, + (None, None) => a + .display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()), + }); + + projects + } + + pub fn discover_from_legacy_config( + &self, + ) -> Result, crate::error::ConfigFileError> { + let config = self.config_manager.read_global_config()?; + let Some(config) = config else { + return Ok(vec![]); + }; + + Ok(config + .project_paths() + .into_iter() + .filter_map(|p| self.canonicalize_path(p)) + .collect()) + } + + pub fn scan_for_projects(&self, directories: &[String]) -> Vec { + let mut discovered = HashSet::new(); + + for dir in directories { + let expanded = self.expand_path(dir); + if let Some(canonical) = self.canonicalize_path(&expanded) { + let paths = self.scan_directory(&canonical, 3); + discovered.extend(paths); + } + } + + discovered.into_iter().collect() + } + + pub fn refresh_project(&self, path: &Path) -> Option { + self.build_discovered_project(path) + } + + fn build_discovered_project(&self, path: &Path) -> Option { + let canonical = self.canonicalize_path(path)?; + let exists = canonical.exists(); + let display_name = canonical + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let claude_dir = canonical.join(".claude"); + let has_settings = claude_dir.join("settings.json").is_file(); + let has_local_settings = claude_dir.join("settings.local.json").is_file(); + let has_mcp_config = canonical.join(".mcp.json").is_file(); + + let last_modified = self.get_last_modified(&canonical); + + Some(DiscoveredProject::new( + canonical, + display_name, + exists, + has_settings, + has_local_settings, + has_mcp_config, + last_modified, + )) + } + + fn scan_directory(&self, path: &Path, max_depth: usize) -> Vec { + let skip: HashSet<&str> = SKIP_DIRECTORIES.iter().copied().collect(); + let mut discovered = Vec::new(); + + let walker = WalkDir::new(path) + .max_depth(max_depth) + .follow_links(false) + .into_iter() + .filter_entry(|entry| { + let name = entry.file_name().to_str().unwrap_or(""); + // Allow the root entry, skip hidden dirs (except root) and known skip dirs + if entry.depth() == 0 { + return true; + } + if name.starts_with('.') { + return false; + } + !skip.contains(name) + }); + + for entry in walker.flatten() { + if entry.file_type().is_dir() { + let entry_path = entry.path(); + if entry_path.join(".claude").is_dir() { + discovered.push(entry_path.to_path_buf()); + } + } + } + + discovered + } + + fn expand_path(&self, path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix('~') { + if let Some(home) = dirs::home_dir() { + return home.join(rest.trim_start_matches('/')); + } + } + PathBuf::from(path) + } + + fn canonicalize_path>(&self, path: P) -> Option { + let expanded = if let Some(s) = path.as_ref().to_str() { + self.expand_path(s) + } else { + path.as_ref().to_path_buf() + }; + + // Try to canonicalize, but fall back to the expanded path if it doesn't exist yet + let result = fs::canonicalize(&expanded).unwrap_or(expanded); + + if result.is_absolute() { + Some(result) + } else { + None + } + } + + fn get_last_modified(&self, path: &Path) -> Option { + let config_paths = [ + path.join(".claude/settings.local.json"), + path.join(".claude/settings.json"), + path.join(".mcp.json"), + ]; + + let mut most_recent: Option = None; + + for config_path in &config_paths { + if let Ok(metadata) = fs::metadata(config_path) { + if let Ok(modified) = metadata.modified() { + if most_recent.map(|r| modified > r).unwrap_or(true) { + most_recent = Some(modified); + } + } + } + } + + // Fall back to directory modification date + if most_recent.is_none() { + if let Ok(metadata) = fs::metadata(path) { + most_recent = metadata.modified().ok(); + } + } + + most_recent + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_service(home: &Path) -> ProjectDiscoveryService { + let config_manager = ConfigFileManager::with_home_dir(home.to_path_buf()); + ProjectDiscoveryService::new(config_manager) + } + + #[test] + fn test_discover_empty_config() { + let tmp = TempDir::new().unwrap(); + let service = setup_service(tmp.path()); + let projects = service.discover_projects(false, None); + assert!(projects.is_empty()); + } + + #[test] + fn test_discover_from_legacy_config() { + let tmp = TempDir::new().unwrap(); + + // Create a project directory with .claude + let project_dir = tmp.path().join("myproject"); + fs::create_dir_all(project_dir.join(".claude")).unwrap(); + + // Write a legacy config pointing to the project + let config_path = tmp.path().join(".claude.json"); + let config_json = format!( + r#"{{"projects": {{"{path}": {{"allowedTools": []}}}}}}"#, + path = project_dir.display() + ); + fs::write(&config_path, config_json).unwrap(); + + let service = setup_service(tmp.path()); + let paths = service.discover_from_legacy_config().unwrap(); + assert_eq!(paths.len(), 1); + } + + #[test] + fn test_scan_directory_finds_claude_projects() { + let tmp = TempDir::new().unwrap(); + + // Create two projects with .claude dirs + let proj_a = tmp.path().join("proj_a"); + let proj_b = tmp.path().join("proj_b"); + let not_a_project = tmp.path().join("no_claude"); + fs::create_dir_all(proj_a.join(".claude")).unwrap(); + fs::create_dir_all(proj_b.join(".claude")).unwrap(); + fs::create_dir_all(¬_a_project).unwrap(); + + let service = setup_service(tmp.path()); + let found = service.scan_directory(tmp.path(), 3); + assert_eq!(found.len(), 2); + } + + #[test] + fn test_scan_skips_node_modules() { + let tmp = TempDir::new().unwrap(); + + let node_project = tmp.path().join("node_modules").join("pkg"); + fs::create_dir_all(node_project.join(".claude")).unwrap(); + + let service = setup_service(tmp.path()); + let found = service.scan_directory(tmp.path(), 3); + assert!(found.is_empty()); + } + + #[test] + fn test_build_discovered_project() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("myapp"); + fs::create_dir_all(project.join(".claude")).unwrap(); + fs::write(project.join(".claude").join("settings.json"), "{}").unwrap(); + fs::write(project.join(".mcp.json"), "{}").unwrap(); + + let service = setup_service(tmp.path()); + let discovered = service.build_discovered_project(&project).unwrap(); + + assert_eq!(discovered.display_name, "myapp"); + assert!(discovered.exists); + assert!(discovered.has_settings); + assert!(!discovered.has_local_settings); + assert!(discovered.has_mcp_config); + assert!(discovered.has_any_config()); + } + + #[test] + fn test_refresh_project() { + let tmp = TempDir::new().unwrap(); + let project = tmp.path().join("myapp"); + fs::create_dir_all(project.join(".claude")).unwrap(); + + let service = setup_service(tmp.path()); + let discovered = service.refresh_project(&project); + assert!(discovered.is_some()); + assert_eq!(discovered.unwrap().display_name, "myapp"); + } + + #[test] + fn test_projects_sorted_by_modification_time() { + let tmp = TempDir::new().unwrap(); + + // Create two projects + let old_proj = tmp.path().join("old_project"); + let new_proj = tmp.path().join("new_project"); + fs::create_dir_all(old_proj.join(".claude")).unwrap(); + fs::create_dir_all(new_proj.join(".claude")).unwrap(); + + // Write config to new_project to give it a more recent modification time + fs::write(new_proj.join(".claude").join("settings.json"), "{}").unwrap(); + + let service = setup_service(tmp.path()); + let dirs = vec![tmp.path().to_string_lossy().to_string()]; + let projects = service.discover_projects(true, Some(&dirs)); + + assert_eq!(projects.len(), 2); + // new_project should come first (more recently modified) + assert_eq!(projects[0].display_name, "new_project"); + } + + #[test] + fn test_expand_path_tilde() { + let tmp = TempDir::new().unwrap(); + let service = setup_service(tmp.path()); + + let expanded = service.expand_path("~/code"); + assert!(expanded.is_absolute()); + assert!(expanded.to_string_lossy().ends_with("/code")); + } + + #[test] + fn test_expand_path_absolute() { + let tmp = TempDir::new().unwrap(); + let service = setup_service(tmp.path()); + + let expanded = service.expand_path("/usr/local"); + assert_eq!(expanded, PathBuf::from("/usr/local")); + } +} diff --git a/fig-core/src/services/settings_merge.rs b/fig-core/src/services/settings_merge.rs new file mode 100644 index 0000000..98ff3ba --- /dev/null +++ b/fig-core/src/services/settings_merge.rs @@ -0,0 +1,507 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::error::ConfigFileError; +use crate::models::claude_settings::ClaudeSettings; +use crate::models::config_source::ConfigSource; +use crate::models::merged_settings::*; +use crate::services::config_file_manager::ConfigFileManager; + +pub struct SettingsMergeService { + config_manager: ConfigFileManager, +} + +impl SettingsMergeService { + pub fn new(config_manager: ConfigFileManager) -> Self { + Self { config_manager } + } + + pub fn merge_settings(&self, project_path: &Path) -> Result { + let global = self.config_manager.read_global_settings()?; + let shared = self.config_manager.read_project_settings(project_path)?; + let local = self + .config_manager + .read_project_local_settings(project_path)?; + Ok(Self::merge_from_loaded( + global.as_ref(), + shared.as_ref(), + local.as_ref(), + )) + } + + pub fn merge_from_loaded( + global: Option<&ClaudeSettings>, + project_shared: Option<&ClaudeSettings>, + project_local: Option<&ClaudeSettings>, + ) -> MergedSettings { + let tiers: Vec<(Option<&ClaudeSettings>, ConfigSource)> = vec![ + (global, ConfigSource::Global), + (project_shared, ConfigSource::ProjectShared), + (project_local, ConfigSource::ProjectLocal), + ]; + + MergedSettings { + permissions: Self::merge_permissions(&tiers), + env: Self::merge_env(&tiers), + hooks: Self::merge_hooks(&tiers), + disallowed_tools: Self::merge_disallowed_tools(&tiers), + attribution: Self::merge_attribution(&tiers), + } + } + + fn merge_permissions(tiers: &[(Option<&ClaudeSettings>, ConfigSource)]) -> MergedPermissions { + let mut allow_entries: Vec> = Vec::new(); + let mut deny_entries: Vec> = Vec::new(); + let mut seen_allow = HashSet::new(); + let mut seen_deny = HashSet::new(); + + for (settings, source) in tiers { + let Some(settings) = settings else { + continue; + }; + let Some(permissions) = &settings.permissions else { + continue; + }; + + if let Some(allow) = &permissions.allow { + for pattern in allow { + if seen_allow.insert(pattern.clone()) { + allow_entries.push(MergedValue { + value: pattern.clone(), + source: *source, + }); + } + } + } + + if let Some(deny) = &permissions.deny { + for pattern in deny { + if seen_deny.insert(pattern.clone()) { + deny_entries.push(MergedValue { + value: pattern.clone(), + source: *source, + }); + } + } + } + } + + MergedPermissions { + allow: allow_entries, + deny: deny_entries, + } + } + + fn merge_env( + tiers: &[(Option<&ClaudeSettings>, ConfigSource)], + ) -> HashMap> { + let mut result: HashMap> = HashMap::new(); + + for (settings, source) in tiers { + let Some(settings) = settings else { + continue; + }; + let Some(env) = &settings.env else { + continue; + }; + + for (key, value) in env { + result.insert( + key.clone(), + MergedValue { + value: value.clone(), + source: *source, + }, + ); + } + } + + result + } + + fn merge_hooks(tiers: &[(Option<&ClaudeSettings>, ConfigSource)]) -> MergedHooks { + let mut result: HashMap>> = + HashMap::new(); + + for (settings, source) in tiers { + let Some(settings) = settings else { + continue; + }; + let Some(hooks) = &settings.hooks else { + continue; + }; + + for (event_name, hook_groups) in hooks { + let existing = result.entry(event_name.clone()).or_default(); + for group in hook_groups { + existing.push(MergedValue { + value: group.clone(), + source: *source, + }); + } + } + } + + MergedHooks { hooks: result } + } + + fn merge_disallowed_tools( + tiers: &[(Option<&ClaudeSettings>, ConfigSource)], + ) -> Vec> { + let mut result: Vec> = Vec::new(); + let mut seen = HashSet::new(); + + for (settings, source) in tiers { + let Some(settings) = settings else { + continue; + }; + let Some(tools) = &settings.disallowed_tools else { + continue; + }; + + for tool in tools { + if seen.insert(tool.clone()) { + result.push(MergedValue { + value: tool.clone(), + source: *source, + }); + } + } + } + + result + } + + fn merge_attribution( + tiers: &[(Option<&ClaudeSettings>, ConfigSource)], + ) -> Option> { + for (settings, source) in tiers.iter().rev() { + if let Some(settings) = settings { + if let Some(attribution) = &settings.attribution { + return Some(MergedValue { + value: attribution.clone(), + source: *source, + }); + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Attribution, HookDefinition, HookGroup, Permissions}; + + #[test] + fn test_merge_all_none() { + let merged = SettingsMergeService::merge_from_loaded(None, None, None); + assert!(merged.permissions.allow.is_empty()); + assert!(merged.permissions.deny.is_empty()); + assert!(merged.env.is_empty()); + assert!(merged.hooks.event_names().is_empty()); + assert!(merged.disallowed_tools.is_empty()); + assert!(merged.attribution.is_none()); + } + + #[test] + fn test_merge_permissions_union() { + let global = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Bash(npm run *)".to_string()]), + deny: Some(vec!["Read(.env)".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let shared = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Read(src/**)".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let local = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Write(docs/**)".to_string()]), + deny: Some(vec!["Bash(rm *)".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + let merged = + SettingsMergeService::merge_from_loaded(Some(&global), Some(&shared), Some(&local)); + assert_eq!(merged.permissions.allow_patterns().len(), 3); + assert_eq!(merged.permissions.deny_patterns().len(), 2); + } + + #[test] + fn test_merge_permissions_dedup() { + let global = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Bash(*)".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let shared = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Bash(*)".to_string(), "Read(*)".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + let merged = SettingsMergeService::merge_from_loaded(Some(&global), Some(&shared), None); + assert_eq!(merged.permissions.allow_patterns().len(), 2); + } + + #[test] + fn test_merge_env_override() { + let global = ClaudeSettings { + env: Some(HashMap::from([ + ("DEBUG".to_string(), "false".to_string()), + ("LOG_LEVEL".to_string(), "info".to_string()), + ])), + ..Default::default() + }; + let local = ClaudeSettings { + env: Some(HashMap::from([("DEBUG".to_string(), "true".to_string())])), + ..Default::default() + }; + + let merged = SettingsMergeService::merge_from_loaded(Some(&global), None, Some(&local)); + assert_eq!(merged.effective_env().get("DEBUG"), Some(&"true")); + assert_eq!(merged.effective_env().get("LOG_LEVEL"), Some(&"info")); + assert_eq!(merged.env_source("DEBUG"), Some(ConfigSource::ProjectLocal)); + assert_eq!(merged.env_source("LOG_LEVEL"), Some(ConfigSource::Global)); + } + + #[test] + fn test_merge_env_union() { + let global = ClaudeSettings { + env: Some(HashMap::from([( + "GLOBAL_VAR".to_string(), + "global".to_string(), + )])), + ..Default::default() + }; + let shared = ClaudeSettings { + env: Some(HashMap::from([( + "SHARED_VAR".to_string(), + "shared".to_string(), + )])), + ..Default::default() + }; + let local = ClaudeSettings { + env: Some(HashMap::from([( + "LOCAL_VAR".to_string(), + "local".to_string(), + )])), + ..Default::default() + }; + + let merged = + SettingsMergeService::merge_from_loaded(Some(&global), Some(&shared), Some(&local)); + assert_eq!(merged.effective_env().len(), 3); + } + + #[test] + fn test_merge_hooks_concatenate() { + let global_hook = HookGroup { + matcher: Some("Bash(*)".to_string()), + hooks: Some(vec![HookDefinition { + hook_type: Some("command".to_string()), + command: Some("echo global".to_string()), + ..Default::default() + }]), + ..Default::default() + }; + let local_hook = HookGroup { + matcher: Some("Read(*)".to_string()), + hooks: Some(vec![HookDefinition { + hook_type: Some("command".to_string()), + command: Some("echo local".to_string()), + ..Default::default() + }]), + ..Default::default() + }; + + let global = ClaudeSettings { + hooks: Some(HashMap::from([( + "PreToolUse".to_string(), + vec![global_hook], + )])), + ..Default::default() + }; + let local = ClaudeSettings { + hooks: Some(HashMap::from([( + "PreToolUse".to_string(), + vec![local_hook], + )])), + ..Default::default() + }; + + let merged = SettingsMergeService::merge_from_loaded(Some(&global), None, Some(&local)); + let groups = merged.hooks.groups("PreToolUse").unwrap(); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].source, ConfigSource::Global); + assert_eq!(groups[1].source, ConfigSource::ProjectLocal); + } + + #[test] + fn test_merge_attribution_precedence() { + let global = ClaudeSettings { + attribution: Some(Attribution { + commits: Some(false), + pull_requests: Some(false), + ..Default::default() + }), + ..Default::default() + }; + let local = ClaudeSettings { + attribution: Some(Attribution { + commits: Some(true), + pull_requests: Some(true), + ..Default::default() + }), + ..Default::default() + }; + + let merged = SettingsMergeService::merge_from_loaded(Some(&global), None, Some(&local)); + let attr = merged.attribution.unwrap(); + assert_eq!(attr.value.commits, Some(true)); + assert_eq!(attr.source, ConfigSource::ProjectLocal); + } + + #[test] + fn test_merge_attribution_fallback() { + let global = ClaudeSettings { + attribution: Some(Attribution { + commits: Some(true), + ..Default::default() + }), + ..Default::default() + }; + + let merged = SettingsMergeService::merge_from_loaded(Some(&global), None, None); + let attr = merged.attribution.unwrap(); + assert_eq!(attr.value.commits, Some(true)); + assert_eq!(attr.source, ConfigSource::Global); + } + + #[test] + fn test_merge_attribution_none() { + let merged = SettingsMergeService::merge_from_loaded( + Some(&ClaudeSettings::default()), + None, + Some(&ClaudeSettings::default()), + ); + assert!(merged.attribution.is_none()); + } + + #[test] + fn test_merge_source_tracking() { + let global = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Bash(*)".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + let local = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Read(*)".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + let merged = SettingsMergeService::merge_from_loaded(Some(&global), None, Some(&local)); + let bash_entry = merged + .permissions + .allow + .iter() + .find(|v| v.value == "Bash(*)") + .unwrap(); + let read_entry = merged + .permissions + .allow + .iter() + .find(|v| v.value == "Read(*)") + .unwrap(); + assert_eq!(bash_entry.source, ConfigSource::Global); + assert_eq!(read_entry.source, ConfigSource::ProjectLocal); + } + + #[test] + fn test_merge_from_loaded_integration() { + let global = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Bash(npm run *)".to_string()]), + deny: Some(vec!["Read(.env)".to_string()]), + ..Default::default() + }), + env: Some(HashMap::from([ + ("LOG_LEVEL".to_string(), "info".to_string()), + ("DEBUG".to_string(), "false".to_string()), + ])), + disallowed_tools: Some(vec!["DangerousTool".to_string()]), + attribution: Some(Attribution { + commits: Some(false), + ..Default::default() + }), + ..Default::default() + }; + + let shared = ClaudeSettings { + permissions: Some(Permissions { + allow: Some(vec!["Read(src/**)".to_string()]), + ..Default::default() + }), + env: Some(HashMap::from([( + "API_URL".to_string(), + "https://api.example.com".to_string(), + )])), + ..Default::default() + }; + + let local = ClaudeSettings { + permissions: Some(Permissions { + deny: Some(vec!["Bash(rm *)".to_string()]), + ..Default::default() + }), + env: Some(HashMap::from([("DEBUG".to_string(), "true".to_string())])), + attribution: Some(Attribution { + commits: Some(true), + pull_requests: Some(true), + ..Default::default() + }), + ..Default::default() + }; + + let merged = + SettingsMergeService::merge_from_loaded(Some(&global), Some(&shared), Some(&local)); + + assert_eq!(merged.permissions.allow_patterns().len(), 2); + assert_eq!(merged.permissions.deny_patterns().len(), 2); + assert_eq!(merged.effective_env().get("DEBUG"), Some(&"true")); + assert_eq!(merged.env_source("DEBUG"), Some(ConfigSource::ProjectLocal)); + assert_eq!(merged.effective_env().get("LOG_LEVEL"), Some(&"info")); + assert_eq!( + merged.effective_env().get("API_URL"), + Some(&"https://api.example.com") + ); + assert!(merged.is_tool_disallowed("DangerousTool")); + assert_eq!( + merged.attribution.as_ref().unwrap().value.commits, + Some(true) + ); + assert_eq!( + merged.attribution.as_ref().unwrap().source, + ConfigSource::ProjectLocal + ); + } +} diff --git a/fig-core/src/services/undo_manager.rs b/fig-core/src/services/undo_manager.rs new file mode 100644 index 0000000..1285039 --- /dev/null +++ b/fig-core/src/services/undo_manager.rs @@ -0,0 +1,213 @@ +/// Generic undo/redo manager with dirty state tracking. +/// +/// Manages a history stack of snapshots with configurable maximum size. +/// Tracks whether the current state differs from the last saved state. +#[derive(Debug, Clone)] +pub struct UndoManager { + current: T, + undo_stack: Vec, + redo_stack: Vec, + saved_state: T, + max_history: usize, +} + +impl UndoManager { + pub fn new(initial: T, max_history: usize) -> Self { + Self { + saved_state: initial.clone(), + current: initial, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + max_history, + } + } + + pub fn current(&self) -> &T { + &self.current + } + + pub fn push(&mut self, state: T) { + self.undo_stack.push(self.current.clone()); + self.current = state; + self.redo_stack.clear(); + + // Enforce max history + if self.undo_stack.len() > self.max_history { + let excess = self.undo_stack.len() - self.max_history; + self.undo_stack.drain(..excess); + } + } + + pub fn undo(&mut self) -> Option<&T> { + let previous = self.undo_stack.pop()?; + self.redo_stack.push(self.current.clone()); + self.current = previous; + Some(&self.current) + } + + pub fn redo(&mut self) -> Option<&T> { + let next = self.redo_stack.pop()?; + self.undo_stack.push(self.current.clone()); + self.current = next; + Some(&self.current) + } + + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + pub fn is_dirty(&self) -> bool { + self.current != self.saved_state + } + + pub fn mark_saved(&mut self) { + self.saved_state = self.current.clone(); + } + + pub fn clear(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + self.saved_state = self.current.clone(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_push_and_undo() { + let mut mgr = UndoManager::new("a".to_string(), 10); + mgr.push("b".to_string()); + mgr.push("c".to_string()); + assert_eq!(mgr.current(), "c"); + + let result = mgr.undo(); + assert_eq!(result, Some(&"b".to_string())); + assert_eq!(mgr.current(), "b"); + + let result = mgr.undo(); + assert_eq!(result, Some(&"a".to_string())); + assert_eq!(mgr.current(), "a"); + } + + #[test] + fn test_redo_after_undo() { + let mut mgr = UndoManager::new("a".to_string(), 10); + mgr.push("b".to_string()); + mgr.push("c".to_string()); + mgr.undo(); + mgr.undo(); + + let result = mgr.redo(); + assert_eq!(result, Some(&"b".to_string())); + + let result = mgr.redo(); + assert_eq!(result, Some(&"c".to_string())); + } + + #[test] + fn test_push_clears_redo() { + let mut mgr = UndoManager::new("a".to_string(), 10); + mgr.push("b".to_string()); + mgr.push("c".to_string()); + mgr.undo(); // back to "b" + assert!(mgr.can_redo()); + + mgr.push("d".to_string()); // should clear redo + assert!(!mgr.can_redo()); + } + + #[test] + fn test_is_dirty() { + let mut mgr = UndoManager::new("a".to_string(), 10); + assert!(!mgr.is_dirty()); + + mgr.push("b".to_string()); + assert!(mgr.is_dirty()); + + mgr.mark_saved(); + assert!(!mgr.is_dirty()); + + mgr.push("c".to_string()); + assert!(mgr.is_dirty()); + + mgr.undo(); // back to "b" which is saved + assert!(!mgr.is_dirty()); + } + + #[test] + fn test_max_history() { + let mut mgr = UndoManager::new(0, 3); + mgr.push(1); + mgr.push(2); + mgr.push(3); + mgr.push(4); // oldest (0) should be dropped + + assert_eq!(mgr.current(), &4); + + // Can only undo 3 times (max_history) + assert!(mgr.undo().is_some()); // 3 + assert!(mgr.undo().is_some()); // 2 + assert!(mgr.undo().is_some()); // 1 + assert!(mgr.undo().is_none()); // can't go further + } + + #[test] + fn test_undo_empty() { + let mut mgr = UndoManager::new("a".to_string(), 10); + assert!(!mgr.can_undo()); + assert_eq!(mgr.undo(), None); + } + + #[test] + fn test_redo_empty() { + let mut mgr = UndoManager::new("a".to_string(), 10); + assert!(!mgr.can_redo()); + assert_eq!(mgr.redo(), None); + } + + #[test] + fn test_clear() { + let mut mgr = UndoManager::new("a".to_string(), 10); + mgr.push("b".to_string()); + mgr.push("c".to_string()); + mgr.undo(); + + assert!(mgr.can_undo()); + assert!(mgr.can_redo()); + + mgr.clear(); + assert!(!mgr.can_undo()); + assert!(!mgr.can_redo()); + assert!(!mgr.is_dirty()); + assert_eq!(mgr.current(), "b"); // current preserved + } + + #[test] + fn test_multiple_undo_redo() { + let mut mgr = UndoManager::new(1, 10); + for i in 2..=5 { + mgr.push(i); + } + assert_eq!(mgr.current(), &5); + + // Undo all + for expected in (1..=4).rev() { + let result = mgr.undo().unwrap(); + assert_eq!(*result, expected); + } + assert!(!mgr.can_undo()); + + // Redo all + for expected in 2..=5 { + let result = mgr.redo().unwrap(); + assert_eq!(*result, expected); + } + assert!(!mgr.can_redo()); + } +} diff --git a/fig-ui/Cargo.toml b/fig-ui/Cargo.toml new file mode 100644 index 0000000..3bf250b --- /dev/null +++ b/fig-ui/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fig-ui" +version = "0.1.0" +edition = "2021" + +[dependencies] +fig-core = { path = "../fig-core" } +iced = { version = "0.13", features = ["tokio", "svg"] } +dirs = "6" +uuid = { version = "1", features = ["v4"] } +arboard = "3" diff --git a/fig-ui/src/main.rs b/fig-ui/src/main.rs new file mode 100644 index 0000000..376913c --- /dev/null +++ b/fig-ui/src/main.rs @@ -0,0 +1,676 @@ +mod styles; +mod views; + +use fig_core::models::{ + ConfigSource, DiscoveredProject, EditableHookDefinition, EditableHookGroup, EditingTarget, + GlobalSettingsTab, HookEvent, MCPServerFormData, MCPServerType, NavigationSelection, + PermissionType, ProjectDetailTab, HOOK_TEMPLATES, +}; +use fig_core::services::mcp_clipboard_service; +use iced::widget::{container, row}; +use iced::{Element, Length, Theme}; +use uuid::Uuid; +use views::attribution_editor::AttributionEditorState; +use views::effective_config_view::EffectiveConfigViewState; +use views::environment_editor::EnvironmentEditorState; +use views::health_check_view::{HealthCheckViewState, MCPHealthButtonState}; +use views::hooks_editor::HooksEditorState; +use views::mcp_copy_sheet::{CopySheetState, ImportSheetState}; +use views::mcp_server_list::MCPServerListState; +use views::permissions_editor::PermissionsEditorState; + +fn main() -> iced::Result { + iced::application("Fig", App::update, App::view) + .theme(|_| Theme::Dark) + .window_size((1000.0, 700.0)) + .run() +} + +struct App { + selection: NavigationSelection, + global_tab: GlobalSettingsTab, + project_tab: ProjectDetailTab, + projects: Vec, + group_by_directory: bool, + permissions_state: PermissionsEditorState, + environment_state: EnvironmentEditorState, + attribution_state: AttributionEditorState, + mcp_list_state: MCPServerListState, + hooks_state: HooksEditorState, + health_state: HealthCheckViewState, + #[allow(dead_code)] + mcp_health_states: std::collections::HashMap, + effective_config_state: EffectiveConfigViewState, +} + +#[derive(Debug, Clone)] +pub enum Message { + // Navigation + SelectGlobalSettings, + SelectProject(String), + SelectGlobalTab(GlobalSettingsTab), + SelectProjectTab(ProjectDetailTab), + + // Permissions editor + PermissionsChangeTarget(EditingTarget), + PermissionsApplyPreset(String), + PermissionsToggleType, + PermissionsNewRuleInput(String), + PermissionsAddRule, + PermissionsRemoveRule(Uuid), + + // Environment editor + EnvChangeTarget(EditingTarget), + EnvNewKeyInput(String), + EnvNewValueInput(String), + EnvAddVariable, + EnvAddKnown(String), + EnvRemoveVariable(Uuid), + + // Attribution editor + AttributionChangeTarget(EditingTarget), + AttributionToggleCommits(bool), + AttributionTogglePullRequests(bool), + + // MCP Server List + MCPExpandServer(String), + MCPCollapseServer(String), + MCPAddServer, + MCPEditServer(String), + MCPDeleteServer(String), + + // MCP Server Form + MCPFormUpdateName(String), + MCPFormUpdateCommand(String), + MCPFormUpdateArgs(String), + MCPFormUpdateEnv(String), + MCPFormUpdateUrl(String), + MCPFormChangeType(MCPServerType), + MCPFormSave, + MCPFormCancel, + + // MCP Copy/Paste + MCPOpenCopySheet, + MCPCloseCopySheet, + MCPCopyToggleServer(String), + MCPCopySelectTarget(ConfigSource), + MCPCopyConfirm, + MCPCopyForceOverwrite, + MCPOpenImportSheet, + MCPCloseImportSheet, + MCPImportPasteJson(String), + MCPImportToggleServer(String), + MCPImportConfirm, + MCPExportToClipboard, + MCPToggleRedaction(bool), + + // Hooks editor + HooksSelectEvent(HookEvent), + HooksAddGroup, + HooksRemoveGroup(Uuid), + HooksUpdateMatcher(Uuid, String), + HooksAddHook(Uuid), + HooksRemoveHook(Uuid, Uuid), + HooksUpdateHookCommand(Uuid, Uuid, String), + HooksApplyTemplate(String), + HooksChangeTarget(EditingTarget), + + // Health check + HealthCheckRun, + #[allow(dead_code)] + HealthCheckCompleted(Vec), + MCPHealthCheck(String), +} + +impl Default for App { + fn default() -> Self { + Self { + selection: NavigationSelection::GlobalSettings, + global_tab: GlobalSettingsTab::Permissions, + project_tab: ProjectDetailTab::Permissions, + projects: Vec::new(), + group_by_directory: true, + permissions_state: PermissionsEditorState::default(), + environment_state: EnvironmentEditorState::default(), + attribution_state: AttributionEditorState::default(), + mcp_list_state: MCPServerListState::default(), + hooks_state: HooksEditorState::default(), + health_state: HealthCheckViewState::default(), + mcp_health_states: std::collections::HashMap::new(), + effective_config_state: EffectiveConfigViewState::default(), + } + } +} + +impl App { + fn update(&mut self, message: Message) { + match message { + // Navigation + Message::SelectGlobalSettings => { + self.selection = NavigationSelection::GlobalSettings; + self.permissions_state.editing_target = EditingTarget::Global; + self.environment_state.editing_target = EditingTarget::Global; + self.attribution_state.editing_target = EditingTarget::Global; + } + Message::SelectProject(path) => { + self.selection = NavigationSelection::Project(path); + self.project_tab = ProjectDetailTab::Permissions; + self.permissions_state.editing_target = EditingTarget::ProjectShared; + self.environment_state.editing_target = EditingTarget::ProjectShared; + self.attribution_state.editing_target = EditingTarget::ProjectShared; + } + Message::SelectGlobalTab(tab) => { + self.global_tab = tab; + } + Message::SelectProjectTab(tab) => { + self.project_tab = tab; + } + + // Permissions + Message::PermissionsChangeTarget(target) => { + self.permissions_state.editing_target = target; + } + Message::PermissionsApplyPreset(id) => { + self.permissions_state.apply_preset(&id); + } + Message::PermissionsToggleType => { + self.permissions_state.new_rule_type = match self.permissions_state.new_rule_type { + PermissionType::Allow => PermissionType::Deny, + PermissionType::Deny => PermissionType::Allow, + }; + } + Message::PermissionsNewRuleInput(text) => { + self.permissions_state.new_rule_text = text; + } + Message::PermissionsAddRule => { + self.permissions_state.add_rule(); + } + Message::PermissionsRemoveRule(id) => { + self.permissions_state.remove_rule(id); + } + + // Environment + Message::EnvChangeTarget(target) => { + self.environment_state.editing_target = target; + } + Message::EnvNewKeyInput(key) => { + self.environment_state.new_key = key; + } + Message::EnvNewValueInput(value) => { + self.environment_state.new_value = value; + } + Message::EnvAddVariable => { + self.environment_state.add_variable(); + } + Message::EnvAddKnown(name) => { + self.environment_state.add_known_variable(&name); + } + Message::EnvRemoveVariable(id) => { + self.environment_state.remove_variable(id); + } + + // Attribution + Message::AttributionChangeTarget(target) => { + self.attribution_state.editing_target = target; + } + Message::AttributionToggleCommits(enabled) => { + self.attribution_state.commits_enabled = enabled; + } + Message::AttributionTogglePullRequests(enabled) => { + self.attribution_state.pull_requests_enabled = enabled; + } + + // MCP Server List + Message::MCPExpandServer(name) => { + self.mcp_list_state.expanded_servers.insert(name); + } + Message::MCPCollapseServer(name) => { + self.mcp_list_state.expanded_servers.remove(&name); + } + Message::MCPAddServer => { + self.mcp_list_state.form = Some(MCPServerFormData::new()); + self.mcp_list_state.validation_errors.clear(); + } + Message::MCPEditServer(name) => { + if let Some((_, server)) = + self.mcp_list_state.servers.iter().find(|(n, _)| n == &name) + { + self.mcp_list_state.form = + Some(MCPServerFormData::from_mcp_server(&name, server)); + self.mcp_list_state.validation_errors.clear(); + } + } + Message::MCPDeleteServer(name) => { + self.mcp_list_state.servers.retain(|(n, _)| n != &name); + } + + // MCP Server Form + Message::MCPFormUpdateName(name) => { + if let Some(ref mut form) = self.mcp_list_state.form { + form.name = name; + } + } + Message::MCPFormUpdateCommand(cmd) => { + if let Some(ref mut form) = self.mcp_list_state.form { + form.command = cmd; + } + } + Message::MCPFormUpdateArgs(args) => { + if let Some(ref mut form) = self.mcp_list_state.form { + form.args_text = args; + } + } + Message::MCPFormUpdateEnv(env) => { + if let Some(ref mut form) = self.mcp_list_state.form { + form.env_text = env; + } + } + Message::MCPFormUpdateUrl(url) => { + if let Some(ref mut form) = self.mcp_list_state.form { + form.url = url; + } + } + Message::MCPFormChangeType(server_type) => { + if let Some(ref mut form) = self.mcp_list_state.form { + form.server_type = server_type; + } + } + Message::MCPFormSave => { + if let Some(ref form) = self.mcp_list_state.form { + let errors = form.validate(); + if errors.is_empty() { + let server = form.to_mcp_server(); + let name = form.name.trim().to_string(); + + // Remove old entry if editing with renamed server + if let Some(ref original) = form.original_name { + self.mcp_list_state.servers.retain(|(n, _)| n != original); + } + + // Remove existing with same name and re-add + self.mcp_list_state.servers.retain(|(n, _)| n != &name); + self.mcp_list_state.servers.push((name, server)); + self.mcp_list_state + .servers + .sort_by(|(a, _), (b, _)| a.cmp(b)); + self.mcp_list_state.form = None; + self.mcp_list_state.validation_errors.clear(); + } else { + self.mcp_list_state.validation_errors = errors; + } + } + } + Message::MCPFormCancel => { + self.mcp_list_state.form = None; + self.mcp_list_state.validation_errors.clear(); + } + + // MCP Copy Sheet + Message::MCPOpenCopySheet => { + self.mcp_list_state.copy_sheet = Some(CopySheetState::default()); + } + Message::MCPCloseCopySheet => { + self.mcp_list_state.copy_sheet = None; + } + Message::MCPCopyToggleServer(name) => { + if let Some(ref mut sheet) = self.mcp_list_state.copy_sheet { + if !sheet.selected_servers.remove(&name) { + sheet.selected_servers.insert(name); + } + } + } + Message::MCPCopySelectTarget(source) => { + if let Some(ref mut sheet) = self.mcp_list_state.copy_sheet { + sheet.target_source = source; + } + } + Message::MCPCopyConfirm => { + if let Some(ref sheet) = self.mcp_list_state.copy_sheet { + // Check for conflicts before copying + let mut has_conflicts = false; + let mut conflicts = Vec::new(); + for name in &sheet.selected_servers { + if self.mcp_list_state.servers.iter().any(|(n, _)| n == name) { + has_conflicts = true; + conflicts.push(fig_core::services::CopyConflict { + server_name: name.clone(), + existing_summary: String::new(), + }); + } + } + if has_conflicts { + if let Some(ref mut s) = self.mcp_list_state.copy_sheet { + s.conflicts = conflicts; + s.show_conflicts = true; + } + } else { + // No conflicts — copy selected servers + for (name, server) in &self.mcp_list_state.servers.clone() { + if sheet.selected_servers.contains(name) { + // Server already in list; handled by conflict check + let _ = (name, server); + } + } + self.mcp_list_state.copy_sheet = None; + } + } + } + Message::MCPCopyForceOverwrite => { + if let Some(ref sheet) = self.mcp_list_state.copy_sheet { + for name in &sheet.selected_servers { + if let Some(server) = self + .mcp_list_state + .servers + .iter() + .find(|(n, _)| n == name) + .map(|(_, s)| s.clone()) + { + self.mcp_list_state.servers.retain(|(n, _)| n != name); + self.mcp_list_state.servers.push((name.clone(), server)); + } + } + self.mcp_list_state + .servers + .sort_by(|(a, _), (b, _)| a.cmp(b)); + } + self.mcp_list_state.copy_sheet = None; + } + + // MCP Import Sheet + Message::MCPOpenImportSheet => { + self.mcp_list_state.import_sheet = Some(ImportSheetState::default()); + } + Message::MCPCloseImportSheet => { + self.mcp_list_state.import_sheet = None; + } + Message::MCPImportPasteJson(json) => { + if let Some(ref mut sheet) = self.mcp_list_state.import_sheet { + sheet.json_input = json.clone(); + match mcp_clipboard_service::import_from_json(&json) { + Ok(servers) => { + sheet.selected_servers = + servers.iter().map(|(n, _)| n.clone()).collect(); + sheet.parsed_servers = servers; + sheet.parse_error = None; + } + Err(e) => { + sheet.parsed_servers.clear(); + sheet.selected_servers.clear(); + if !json.trim().is_empty() { + sheet.parse_error = Some(format!("{e}")); + } else { + sheet.parse_error = None; + } + } + } + } + } + Message::MCPImportToggleServer(name) => { + if let Some(ref mut sheet) = self.mcp_list_state.import_sheet { + if !sheet.selected_servers.remove(&name) { + sheet.selected_servers.insert(name); + } + } + } + Message::MCPImportConfirm => { + if let Some(ref sheet) = self.mcp_list_state.import_sheet { + for (name, server) in &sheet.parsed_servers { + if sheet.selected_servers.contains(name) { + self.mcp_list_state.servers.retain(|(n, _)| n != name); + self.mcp_list_state + .servers + .push((name.clone(), server.clone())); + } + } + self.mcp_list_state + .servers + .sort_by(|(a, _), (b, _)| a.cmp(b)); + } + self.mcp_list_state.import_sheet = None; + } + Message::MCPExportToClipboard => { + let redact = self + .mcp_list_state + .import_sheet + .as_ref() + .is_none_or(|s| s.redact_on_export); + let servers: Vec<(&str, &fig_core::models::MCPServer)> = self + .mcp_list_state + .servers + .iter() + .map(|(n, s)| (n.as_str(), s)) + .collect(); + let json = mcp_clipboard_service::export_to_json(&servers, redact); + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(json); + } + } + Message::MCPToggleRedaction(enabled) => { + if let Some(ref mut sheet) = self.mcp_list_state.import_sheet { + sheet.redact_on_export = enabled; + } + } + + // Hooks editor + Message::HooksSelectEvent(event) => { + self.hooks_state.active_event = event; + } + Message::HooksAddGroup => { + let event = self.hooks_state.active_event; + let matcher = if event.supports_matcher() { + Some(String::new()) + } else { + None + }; + self.hooks_state + .groups + .entry(event) + .or_default() + .push(EditableHookGroup::new(matcher)); + } + Message::HooksRemoveGroup(id) => { + for groups in self.hooks_state.groups.values_mut() { + groups.retain(|g| g.id != id); + } + } + Message::HooksUpdateMatcher(group_id, matcher) => { + for groups in self.hooks_state.groups.values_mut() { + if let Some(group) = groups.iter_mut().find(|g| g.id == group_id) { + group.matcher = if matcher.is_empty() { + None + } else { + Some(matcher) + }; + break; + } + } + } + Message::HooksAddHook(group_id) => { + for groups in self.hooks_state.groups.values_mut() { + if let Some(group) = groups.iter_mut().find(|g| g.id == group_id) { + group.hooks.push(EditableHookDefinition::new(String::new())); + break; + } + } + } + Message::HooksRemoveHook(group_id, hook_id) => { + for groups in self.hooks_state.groups.values_mut() { + if let Some(group) = groups.iter_mut().find(|g| g.id == group_id) { + group.hooks.retain(|h| h.id != hook_id); + break; + } + } + } + Message::HooksUpdateHookCommand(group_id, hook_id, command) => { + for groups in self.hooks_state.groups.values_mut() { + if let Some(group) = groups.iter_mut().find(|g| g.id == group_id) { + if let Some(hook) = group.hooks.iter_mut().find(|h| h.id == hook_id) { + hook.command = command; + } + break; + } + } + } + Message::HooksApplyTemplate(name) => { + if let Some(template) = HOOK_TEMPLATES.iter().find(|t| t.name == name) { + let mut group = EditableHookGroup::new(template.matcher.map(|m| m.to_string())); + for cmd in template.commands { + group + .hooks + .push(EditableHookDefinition::new(cmd.to_string())); + } + self.hooks_state + .groups + .entry(template.event) + .or_default() + .push(group); + } + } + Message::HooksChangeTarget(target) => { + self.hooks_state.editing_target = target; + } + + // Health check + Message::HealthCheckRun => { + self.health_state.is_running = true; + let ctx = self.build_health_check_context(); + self.health_state.findings = fig_core::services::health_check::run_all_checks(&ctx); + self.health_state.is_running = false; + } + Message::HealthCheckCompleted(findings) => { + self.health_state.findings = findings; + self.health_state.is_running = false; + } + Message::MCPHealthCheck(_name) => { + // In a full implementation, this would run async health check + } + } + } + + fn build_health_check_context(&self) -> fig_core::services::HealthCheckContext { + use fig_core::models::{ + ClaudeSettings, MergedPermissions, MergedSettings, MergedValue, Permissions, + }; + use std::collections::HashMap; + + // Build merged permissions from editor state + let mut allow = Vec::new(); + let mut deny = Vec::new(); + for rule in &self.permissions_state.rules { + let mv = MergedValue { + value: rule.rule.clone(), + source: ConfigSource::Global, + }; + match rule.permission_type { + PermissionType::Allow => allow.push(mv), + PermissionType::Deny => deny.push(mv), + } + } + + // Build merged env from editor state + let mut env = HashMap::new(); + for var in &self.environment_state.variables { + env.insert( + var.key.clone(), + MergedValue { + value: var.value.clone(), + source: ConfigSource::Global, + }, + ); + } + + let merged = MergedSettings { + permissions: MergedPermissions { allow, deny }, + env, + ..Default::default() + }; + + // Build global settings from current state + let global_settings = ClaudeSettings { + permissions: Some(Permissions { + allow: if self + .permissions_state + .rules + .iter() + .any(|r| r.permission_type == PermissionType::Allow) + { + Some( + self.permissions_state + .rules + .iter() + .filter(|r| r.permission_type == PermissionType::Allow) + .map(|r| r.rule.clone()) + .collect(), + ) + } else { + None + }, + deny: if self + .permissions_state + .rules + .iter() + .any(|r| r.permission_type == PermissionType::Deny) + { + Some( + self.permissions_state + .rules + .iter() + .filter(|r| r.permission_type == PermissionType::Deny) + .map(|r| r.rule.clone()) + .collect(), + ) + } else { + None + }, + additional_properties: HashMap::new(), + }), + env: if self.environment_state.variables.is_empty() { + None + } else { + Some( + self.environment_state + .variables + .iter() + .map(|v| (v.key.clone(), v.value.clone())) + .collect(), + ) + }, + ..Default::default() + }; + + fig_core::services::HealthCheckContext { + global_settings: Some(global_settings), + project_settings: None, + merged, + has_local_settings: false, + has_project_mcp: !self.mcp_list_state.servers.is_empty(), + } + } + + fn view(&self) -> Element<'_, Message> { + let sidebar = + views::sidebar::sidebar_view(&self.projects, &self.selection, self.group_by_directory); + + let detail = views::detail::detail_view( + &self.selection, + self.global_tab, + self.project_tab, + &self.permissions_state, + &self.environment_state, + &self.attribution_state, + &self.mcp_list_state, + &self.hooks_state, + &self.health_state, + &self.effective_config_state, + ); + + let content = row![sidebar, detail] + .width(Length::Fill) + .height(Length::Fill); + + container(content) + .width(Length::Fill) + .height(Length::Fill) + .into() + } +} diff --git a/fig-ui/src/styles/mod.rs b/fig-ui/src/styles/mod.rs new file mode 100644 index 0000000..f9cd61c --- /dev/null +++ b/fig-ui/src/styles/mod.rs @@ -0,0 +1,13 @@ +use iced::Color; + +pub const SIDEBAR_WIDTH: f32 = 260.0; +pub const SIDEBAR_BG: Color = Color::from_rgb(0.12, 0.12, 0.14); +pub const DETAIL_BG: Color = Color::from_rgb(0.16, 0.16, 0.18); +pub const SELECTED_BG: Color = Color::from_rgb(0.25, 0.25, 0.30); +#[allow(dead_code)] +pub const HOVER_BG: Color = Color::from_rgb(0.20, 0.20, 0.24); +pub const TEXT_PRIMARY: Color = Color::from_rgb(0.92, 0.92, 0.94); +pub const TEXT_SECONDARY: Color = Color::from_rgb(0.60, 0.60, 0.64); +pub const ACCENT: Color = Color::from_rgb(0.40, 0.60, 1.0); +pub const GROUP_HEADER_TEXT: Color = Color::from_rgb(0.50, 0.50, 0.54); +pub const DIVIDER: Color = Color::from_rgb(0.22, 0.22, 0.26); diff --git a/fig-ui/src/views/attribution_editor.rs b/fig-ui/src/views/attribution_editor.rs new file mode 100644 index 0000000..6e2c732 --- /dev/null +++ b/fig-ui/src/views/attribution_editor.rs @@ -0,0 +1,103 @@ +use fig_core::models::{Attribution, EditingTarget}; +use iced::widget::{checkbox, column, container, pick_list, text}; +use iced::{Element, Length}; + +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, PartialEq)] +pub struct AttributionEditorState { + pub commits_enabled: bool, + pub pull_requests_enabled: bool, + pub editing_target: EditingTarget, +} + +impl Default for AttributionEditorState { + fn default() -> Self { + Self { + commits_enabled: false, + pull_requests_enabled: false, + editing_target: EditingTarget::Global, + } + } +} + +#[allow(dead_code)] +impl AttributionEditorState { + pub fn from_attribution(attr: Option<&Attribution>, target: EditingTarget) -> Self { + match attr { + Some(a) => Self { + commits_enabled: a.commits.unwrap_or(false), + pull_requests_enabled: a.pull_requests.unwrap_or(false), + editing_target: target, + }, + None => Self { + editing_target: target, + ..Default::default() + }, + } + } + + pub fn to_attribution(&self) -> Attribution { + Attribution { + commits: Some(self.commits_enabled), + pull_requests: Some(self.pull_requests_enabled), + ..Default::default() + } + } +} + +pub fn attribution_editor_view<'a>(state: &'a AttributionEditorState) -> Element<'a, Message> { + let target_options: Vec = if state.editing_target == EditingTarget::Global { + vec![EditingTarget::Global] + } else { + EditingTarget::project_targets().to_vec() + }; + + let target_picker = pick_list( + target_options, + Some(state.editing_target), + Message::AttributionChangeTarget, + ) + .text_size(13); + + let commits_toggle = checkbox("Add attribution to commits", state.commits_enabled) + .on_toggle(Message::AttributionToggleCommits) + .text_size(14); + + let commits_desc = + text("When enabled, Claude Code adds a 'Co-authored-by' trailer to commits.") + .size(12) + .color(styles::TEXT_SECONDARY); + + let pr_toggle = checkbox( + "Add attribution to pull requests", + state.pull_requests_enabled, + ) + .on_toggle(Message::AttributionTogglePullRequests) + .text_size(14); + + let pr_desc = + text("When enabled, Claude Code adds attribution text to pull request descriptions.") + .size(12) + .color(styles::TEXT_SECONDARY); + + container( + column![ + text("Attribution").size(20).color(styles::TEXT_PRIMARY), + text("Control how Claude Code attributes its contributions.") + .size(13) + .color(styles::TEXT_SECONDARY), + target_picker, + commits_toggle, + commits_desc, + pr_toggle, + pr_desc, + ] + .spacing(12), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/fig-ui/src/views/detail.rs b/fig-ui/src/views/detail.rs new file mode 100644 index 0000000..9936f60 --- /dev/null +++ b/fig-ui/src/views/detail.rs @@ -0,0 +1,211 @@ +use fig_core::models::{GlobalSettingsTab, NavigationSelection, ProjectDetailTab}; +use iced::widget::{button, column, container, row, text}; +use iced::{Element, Length, Padding}; + +use crate::styles; +use crate::views::attribution_editor::{attribution_editor_view, AttributionEditorState}; +use crate::views::effective_config_view::{effective_config_view, EffectiveConfigViewState}; +use crate::views::environment_editor::{environment_editor_view, EnvironmentEditorState}; +use crate::views::health_check_view::{health_check_view, HealthCheckViewState}; +use crate::views::hooks_editor::{hooks_editor_view, HooksEditorState}; +use crate::views::mcp_server_list::{mcp_server_list_view, MCPServerListState}; +use crate::views::permissions_editor::{permissions_editor_view, PermissionsEditorState}; +use crate::Message; + +#[allow(clippy::too_many_arguments)] +pub fn detail_view<'a>( + selection: &'a NavigationSelection, + global_tab: GlobalSettingsTab, + project_tab: ProjectDetailTab, + permissions_state: &'a PermissionsEditorState, + environment_state: &'a EnvironmentEditorState, + attribution_state: &'a AttributionEditorState, + mcp_list_state: &'a MCPServerListState, + hooks_state: &'a HooksEditorState, + health_state: &'a HealthCheckViewState, + effective_config_state: &'a EffectiveConfigViewState, +) -> Element<'a, Message> { + let content = match selection { + NavigationSelection::GlobalSettings => global_settings_view( + global_tab, + permissions_state, + environment_state, + attribution_state, + mcp_list_state, + ), + NavigationSelection::Project(path) => project_detail_view( + path, + project_tab, + permissions_state, + environment_state, + attribution_state, + mcp_list_state, + hooks_state, + health_state, + effective_config_state, + ), + }; + + container(content) + .width(Length::Fill) + .height(Length::Fill) + .style(|_theme: &iced::Theme| container::Style { + background: Some(styles::DETAIL_BG.into()), + ..Default::default() + }) + .into() +} + +fn global_settings_view<'a>( + active_tab: GlobalSettingsTab, + permissions_state: &'a PermissionsEditorState, + environment_state: &'a EnvironmentEditorState, + attribution_state: &'a AttributionEditorState, + mcp_list_state: &'a MCPServerListState, +) -> Element<'a, Message> { + let tabs = tab_bar( + GlobalSettingsTab::all(), + active_tab, + |tab| tab.title().to_string(), + Message::SelectGlobalTab, + ); + + let body = match active_tab { + GlobalSettingsTab::Permissions => permissions_editor_view(permissions_state), + GlobalSettingsTab::Environment => environment_editor_view(environment_state), + GlobalSettingsTab::McpServers => mcp_server_list_view(mcp_list_state), + GlobalSettingsTab::Advanced => attribution_editor_view(attribution_state), + }; + + column![tabs, body] + .spacing(0) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +#[allow(clippy::too_many_arguments)] +fn project_detail_view<'a>( + path: &str, + active_tab: ProjectDetailTab, + permissions_state: &'a PermissionsEditorState, + environment_state: &'a EnvironmentEditorState, + attribution_state: &'a AttributionEditorState, + mcp_list_state: &'a MCPServerListState, + hooks_state: &'a HooksEditorState, + health_state: &'a HealthCheckViewState, + effective_config_state: &'a EffectiveConfigViewState, +) -> Element<'a, Message> { + let project_name = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path); + + let header = container( + text(project_name.to_string()) + .size(18) + .color(styles::TEXT_PRIMARY), + ) + .padding(Padding::new(16.0).left(24.0).right(24.0)); + + let tabs = tab_bar( + ProjectDetailTab::all(), + active_tab, + |tab| tab.title().to_string(), + Message::SelectProjectTab, + ); + + let body = match active_tab { + ProjectDetailTab::Permissions => permissions_editor_view(permissions_state), + ProjectDetailTab::Environment => environment_editor_view(environment_state), + ProjectDetailTab::McpServers => mcp_server_list_view(mcp_list_state), + ProjectDetailTab::Hooks => hooks_editor_view(hooks_state), + ProjectDetailTab::ClaudeMd => placeholder_content("CLAUDE.md", "Project instructions"), + ProjectDetailTab::EffectiveConfig => effective_config_view(effective_config_state), + ProjectDetailTab::HealthCheck => health_check_view(health_state), + ProjectDetailTab::Advanced => attribution_editor_view(attribution_state), + }; + + column![header, tabs, body] + .spacing(0) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn tab_bar<'a, T: Copy + PartialEq>( + tabs: &[T], + active: T, + label_fn: impl Fn(T) -> String, + message_fn: impl Fn(T) -> Message + 'a, +) -> Element<'a, Message> { + let mut tab_row = row![].spacing(0); + + for &tab in tabs { + let is_active = tab == active; + let label = label_fn(tab); + let msg = message_fn(tab); + + let text_color = if is_active { + styles::ACCENT + } else { + styles::TEXT_SECONDARY + }; + + let tab_btn = button( + container(text(label).size(12).color(text_color)) + .padding(Padding::new(8.0).left(16.0).right(16.0)), + ) + .on_press(msg) + .padding(0) + .style(move |_theme: &iced::Theme, _status| { + let border = if is_active { + iced::Border { + color: styles::ACCENT, + width: 0.0, + radius: 0.into(), + } + } else { + iced::Border::default() + }; + button::Style { + background: None, + text_color, + border, + shadow: iced::Shadow::default(), + } + }); + + tab_row = tab_row.push(tab_btn); + } + + let bar = container(tab_row) + .padding(Padding::new(0.0).left(24.0).right(24.0)) + .width(Length::Fill) + .style(|_theme: &iced::Theme| container::Style { + border: iced::Border { + color: styles::DIVIDER, + width: 1.0, + radius: 0.into(), + }, + ..Default::default() + }); + + bar.into() +} + +fn placeholder_content<'a>(title: &str, description: &str) -> Element<'a, Message> { + container( + column![ + text(title.to_string()).size(20).color(styles::TEXT_PRIMARY), + text(description.to_string()) + .size(14) + .color(styles::TEXT_SECONDARY), + ] + .spacing(8), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/fig-ui/src/views/effective_config_view.rs b/fig-ui/src/views/effective_config_view.rs new file mode 100644 index 0000000..8efd36d --- /dev/null +++ b/fig-ui/src/views/effective_config_view.rs @@ -0,0 +1,228 @@ +use fig_core::models::{ConfigSource, MergedSettings}; +use iced::widget::{column, container, row, scrollable, text}; +use iced::{Color, Element, Length, Padding}; + +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, Default)] +#[allow(dead_code)] +pub struct EffectiveConfigViewState { + pub settings: Option, + pub is_loading: bool, +} + +fn source_color(source: ConfigSource) -> Color { + match source { + ConfigSource::Global => styles::TEXT_SECONDARY, + ConfigSource::ProjectShared => Color::from_rgb(0.4, 0.6, 1.0), + ConfigSource::ProjectLocal => Color::from_rgb(0.3, 0.8, 0.4), + } +} + +fn source_badge<'a>(source: ConfigSource) -> Element<'a, Message> { + container(text(source.label()).size(9).color(source_color(source))) + .padding(Padding::new(1.0).left(4.0).right(4.0)) + .style(move |_theme: &iced::Theme| container::Style { + background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.3).into()), + border: iced::Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} + +pub fn effective_config_view<'a>(state: &'a EffectiveConfigViewState) -> Element<'a, Message> { + let mut content = column![ + text("Effective Configuration") + .size(20) + .color(styles::TEXT_PRIMARY), + text("Read-only view of merged settings from all configuration tiers.") + .size(13) + .color(styles::TEXT_SECONDARY), + ] + .spacing(12); + + let settings = match &state.settings { + Some(s) => s, + None => { + content = content.push( + container( + text("No merged configuration loaded.") + .size(13) + .color(styles::TEXT_SECONDARY), + ) + .padding(20) + .width(Length::Fill) + .center_x(Length::Fill), + ); + return container(content) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into(); + } + }; + + let mut sections = column![].spacing(16); + + // Permissions section + sections = sections.push(section_header("Permissions")); + if settings.permissions.allow.is_empty() && settings.permissions.deny.is_empty() { + sections = sections.push(empty_placeholder("No permission rules configured.")); + } else { + let mut perms_col = column![].spacing(4); + for rule in &settings.permissions.allow { + perms_col = perms_col.push( + row![ + text("Allow").size(11).color(Color::from_rgb(0.3, 0.8, 0.4)), + text(&rule.value).size(12).color(styles::TEXT_PRIMARY), + source_badge(rule.source), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + ); + } + for rule in &settings.permissions.deny { + perms_col = perms_col.push( + row![ + text("Deny").size(11).color(Color::from_rgb(0.9, 0.3, 0.3)), + text(&rule.value).size(12).color(styles::TEXT_PRIMARY), + source_badge(rule.source), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + ); + } + sections = sections.push(perms_col); + } + + // Environment section + sections = sections.push(section_header("Environment Variables")); + if settings.env.is_empty() { + sections = sections.push(empty_placeholder("No environment variables configured.")); + } else { + let mut env_col = column![].spacing(4); + let mut keys: Vec<_> = settings.env.keys().collect(); + keys.sort(); + for key in keys { + let mv = &settings.env[key]; + env_col = env_col.push( + row![ + text(key).size(12).color(styles::TEXT_PRIMARY), + text("=").size(12).color(styles::TEXT_SECONDARY), + text(&mv.value).size(12).color(styles::TEXT_SECONDARY), + source_badge(mv.source), + ] + .spacing(4) + .align_y(iced::Alignment::Center), + ); + } + sections = sections.push(env_col); + } + + // Hooks section + sections = sections.push(section_header("Hooks")); + let hook_events = settings.hooks.event_names(); + if hook_events.is_empty() { + sections = sections.push(empty_placeholder("No hooks configured.")); + } else { + let mut hooks_col = column![].spacing(6); + for event in &hook_events { + hooks_col = hooks_col.push(text(event.clone()).size(13).color(styles::ACCENT)); + if let Some(groups) = settings.hooks.groups(event) { + for mv in groups { + let matcher_text = mv + .value + .matcher + .as_deref() + .map(|m| format!(" [{m}]")) + .unwrap_or_default(); + let hook_count = mv.value.hooks.as_ref().map(|h| h.len()).unwrap_or(0); + hooks_col = hooks_col.push( + row![ + text(format!(" {hook_count} hook(s){matcher_text}")) + .size(12) + .color(styles::TEXT_SECONDARY), + source_badge(mv.source), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + ); + } + } + } + sections = sections.push(hooks_col); + } + + // Disallowed Tools section + sections = sections.push(section_header("Disallowed Tools")); + if settings.disallowed_tools.is_empty() { + sections = sections.push(empty_placeholder("No tools are disallowed.")); + } else { + let mut tools_col = column![].spacing(4); + for tool in &settings.disallowed_tools { + tools_col = tools_col.push( + row![ + text(&tool.value).size(12).color(styles::TEXT_PRIMARY), + source_badge(tool.source), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + ); + } + sections = sections.push(tools_col); + } + + // Attribution section + sections = sections.push(section_header("Attribution")); + match &settings.attribution { + Some(mv) => { + let commits = mv.value.commits.unwrap_or(false); + let prs = mv.value.pull_requests.unwrap_or(false); + sections = sections.push( + column![ + row![ + text(format!("Commits: {}", if commits { "Yes" } else { "No" })) + .size(12) + .color(styles::TEXT_PRIMARY), + source_badge(mv.source), + ] + .spacing(8) + .align_y(iced::Alignment::Center), + text(format!("Pull Requests: {}", if prs { "Yes" } else { "No" })) + .size(12) + .color(styles::TEXT_PRIMARY), + ] + .spacing(4), + ); + } + None => { + sections = sections.push(empty_placeholder("No attribution settings configured.")); + } + } + + content = content.push(scrollable(sections).height(Length::Fill)); + + container(content) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn section_header<'a>(title: &str) -> Element<'a, Message> { + text(title.to_string()) + .size(15) + .color(styles::TEXT_PRIMARY) + .into() +} + +fn empty_placeholder<'a>(msg: &str) -> Element<'a, Message> { + text(msg.to_string()) + .size(12) + .color(styles::TEXT_SECONDARY) + .into() +} diff --git a/fig-ui/src/views/environment_editor.rs b/fig-ui/src/views/environment_editor.rs new file mode 100644 index 0000000..9ab0cb0 --- /dev/null +++ b/fig-ui/src/views/environment_editor.rs @@ -0,0 +1,176 @@ +use fig_core::models::{EditableEnvironmentVariable, EditingTarget, KNOWN_ENVIRONMENT_VARIABLES}; +use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input}; +use iced::{Element, Length, Padding}; +use uuid::Uuid; + +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, PartialEq)] +pub struct EnvironmentEditorState { + pub variables: Vec, + pub editing_target: EditingTarget, + pub new_key: String, + pub new_value: String, +} + +impl Default for EnvironmentEditorState { + fn default() -> Self { + Self { + variables: Vec::new(), + editing_target: EditingTarget::Global, + new_key: String::new(), + new_value: String::new(), + } + } +} + +impl EnvironmentEditorState { + pub fn add_variable(&mut self) { + let key = self.new_key.trim().to_string(); + if key.is_empty() || key.contains(' ') { + return; + } + self.variables.push(EditableEnvironmentVariable::new( + key, + self.new_value.clone(), + )); + self.new_key.clear(); + self.new_value.clear(); + } + + pub fn add_known_variable(&mut self, name: &str) { + if self.variables.iter().any(|v| v.key == name) { + return; + } + let default = KNOWN_ENVIRONMENT_VARIABLES + .iter() + .find(|v| v.name == name) + .and_then(|v| v.default_value) + .unwrap_or("") + .to_string(); + self.variables + .push(EditableEnvironmentVariable::new(name.to_string(), default)); + } + + pub fn remove_variable(&mut self, id: Uuid) { + self.variables.retain(|v| v.id != id); + } +} + +pub fn environment_editor_view<'a>(state: &'a EnvironmentEditorState) -> Element<'a, Message> { + let target_options: Vec = if state.editing_target == EditingTarget::Global { + vec![EditingTarget::Global] + } else { + EditingTarget::project_targets().to_vec() + }; + + let target_picker = pick_list( + target_options, + Some(state.editing_target), + Message::EnvChangeTarget, + ) + .text_size(13); + + // Known variable suggestions + let mut known_row = row![].spacing(6); + for var in KNOWN_ENVIRONMENT_VARIABLES { + let name = var.name.to_string(); + let already_set = state.variables.iter().any(|v| v.key == var.name); + if !already_set { + known_row = known_row.push( + button(text(var.name).size(10)) + .on_press(Message::EnvAddKnown(name)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: Some(styles::SELECTED_BG.into()), + text_color: styles::ACCENT, + border: iced::Border { + radius: 3.0.into(), + ..Default::default() + }, + shadow: iced::Shadow::default(), + }), + ); + } + } + + // New variable input + let key_input = text_input("Variable name", &state.new_key) + .on_input(Message::EnvNewKeyInput) + .size(13) + .width(200); + + let value_input = text_input("Value", &state.new_value) + .on_input(Message::EnvNewValueInput) + .on_submit(Message::EnvAddVariable) + .size(13) + .width(Length::Fill); + + let add_btn = button(text("Add").size(12)) + .on_press(Message::EnvAddVariable) + .padding(Padding::new(4.0).left(12.0).right(12.0)); + + let input_row = row![key_input, value_input, add_btn] + .spacing(8) + .width(Length::Fill); + + // Variables list + let mut vars_col = column![].spacing(4); + for var in &state.variables { + let is_sensitive = EditableEnvironmentVariable::is_sensitive_key(&var.key); + let display_value = if is_sensitive { + "********".to_string() + } else { + var.value.clone() + }; + + let var_id = var.id; + let var_row = row![ + text(&var.key) + .size(13) + .color(styles::TEXT_PRIMARY) + .width(200), + text(display_value) + .size(13) + .color(styles::TEXT_SECONDARY) + .width(Length::Fill), + button(text("x").size(11)) + .on_press(Message::EnvRemoveVariable(var_id)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + ] + .spacing(8) + .align_y(iced::Alignment::Center); + + vars_col = vars_col.push(var_row); + } + + container( + column![ + text("Environment Variables") + .size(20) + .color(styles::TEXT_PRIMARY), + text("Set environment variables that Claude Code inherits.") + .size(13) + .color(styles::TEXT_SECONDARY), + target_picker, + text("Known Variables") + .size(12) + .color(styles::TEXT_SECONDARY), + scrollable(known_row), + input_row, + scrollable(vars_col).height(Length::Fill), + ] + .spacing(12), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/fig-ui/src/views/health_check_view.rs b/fig-ui/src/views/health_check_view.rs new file mode 100644 index 0000000..e98a793 --- /dev/null +++ b/fig-ui/src/views/health_check_view.rs @@ -0,0 +1,203 @@ +use fig_core::services::{Finding, FindingSeverity}; +use iced::widget::{button, column, container, row, scrollable, text}; +use iced::{Color, Element, Length, Padding}; + +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct HealthCheckViewState { + pub findings: Vec, + pub is_running: bool, +} + +#[derive(Debug, Clone, PartialEq, Default)] +#[allow(dead_code)] +pub enum MCPHealthButtonState { + #[default] + Idle, + Checking, + Success(String), + Failure(String), + Timeout, +} + +pub fn severity_color(severity: &FindingSeverity) -> Color { + match severity { + FindingSeverity::Good => Color::from_rgb(0.3, 0.8, 0.4), + FindingSeverity::Suggestion => Color::from_rgb(0.4, 0.6, 1.0), + FindingSeverity::Warning => Color::from_rgb(0.9, 0.7, 0.2), + FindingSeverity::Security => Color::from_rgb(0.9, 0.3, 0.3), + } +} + +pub fn health_check_view<'a>(state: &'a HealthCheckViewState) -> Element<'a, Message> { + let run_label = if state.is_running { + "Running..." + } else { + "Run Health Check" + }; + + let mut run_btn = + button(text(run_label).size(13)).padding(Padding::new(6.0).left(16.0).right(16.0)); + if !state.is_running { + run_btn = run_btn.on_press(Message::HealthCheckRun); + } + + let mut content = column![ + text("Health Check").size(20).color(styles::TEXT_PRIMARY), + text("Analyze your configuration for security issues and best practices.") + .size(13) + .color(styles::TEXT_SECONDARY), + run_btn, + ] + .spacing(12); + + if !state.findings.is_empty() { + // Summary counts + let security_count = state + .findings + .iter() + .filter(|f| f.severity == FindingSeverity::Security) + .count(); + let warning_count = state + .findings + .iter() + .filter(|f| f.severity == FindingSeverity::Warning) + .count(); + let suggestion_count = state + .findings + .iter() + .filter(|f| f.severity == FindingSeverity::Suggestion) + .count(); + let good_count = state + .findings + .iter() + .filter(|f| f.severity == FindingSeverity::Good) + .count(); + + let summary = row![ + text(format!("{security_count} Security")) + .size(12) + .color(severity_color(&FindingSeverity::Security)), + text(format!("{warning_count} Warning")) + .size(12) + .color(severity_color(&FindingSeverity::Warning)), + text(format!("{suggestion_count} Suggestion")) + .size(12) + .color(severity_color(&FindingSeverity::Suggestion)), + text(format!("{good_count} Good")) + .size(12) + .color(severity_color(&FindingSeverity::Good)), + ] + .spacing(16); + + content = content.push(summary); + + // Findings list + let mut findings_col = column![].spacing(6); + for finding in &state.findings { + findings_col = findings_col.push(finding_card(finding)); + } + + content = content.push(scrollable(findings_col).height(Length::Fill)); + } else if !state.is_running { + content = content.push( + container( + text("Run a health check to analyze your configuration.") + .size(13) + .color(styles::TEXT_SECONDARY), + ) + .padding(20) + .width(Length::Fill) + .center_x(Length::Fill), + ); + } + + container(content) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn finding_card<'a>(finding: &'a Finding) -> Element<'a, Message> { + let color = severity_color(&finding.severity); + + let severity_badge = container(text(finding.severity.label()).size(10).color(color)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(move |_theme: &iced::Theme| container::Style { + background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.3).into()), + border: iced::Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }); + + let check_badge = text(format!("[{}]", finding.check_name)) + .size(10) + .color(styles::TEXT_SECONDARY); + + container( + column![ + row![ + severity_badge, + text(&finding.title).size(13).color(styles::TEXT_PRIMARY), + iced::widget::horizontal_space(), + check_badge, + ] + .spacing(8) + .align_y(iced::Alignment::Center), + text(&finding.description) + .size(12) + .color(styles::TEXT_SECONDARY), + ] + .spacing(4), + ) + .padding(Padding::new(8.0).left(12.0).right(12.0)) + .width(Length::Fill) + .style(|_theme: &iced::Theme| container::Style { + background: Some(styles::SELECTED_BG.into()), + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} + +#[allow(dead_code)] +pub fn mcp_health_button<'a>(name: &str, state: &MCPHealthButtonState) -> Element<'a, Message> { + let name_owned = name.to_string(); + match state { + MCPHealthButtonState::Idle => button(text("Check").size(10)) + .on_press(Message::MCPHealthCheck(name_owned)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::ACCENT, + border: iced::Border { + radius: 3.0.into(), + color: styles::ACCENT, + width: 1.0, + }, + shadow: iced::Shadow::default(), + }) + .into(), + MCPHealthButtonState::Checking => text("...").size(11).color(styles::TEXT_SECONDARY).into(), + MCPHealthButtonState::Success(info) => text(format!("OK ({info})")) + .size(11) + .color(Color::from_rgb(0.3, 0.8, 0.4)) + .into(), + MCPHealthButtonState::Failure(err) => text(format!("Err: {err}")) + .size(11) + .color(Color::from_rgb(0.9, 0.3, 0.3)) + .into(), + MCPHealthButtonState::Timeout => text("Timeout") + .size(11) + .color(Color::from_rgb(0.9, 0.7, 0.2)) + .into(), + } +} diff --git a/fig-ui/src/views/hooks_editor.rs b/fig-ui/src/views/hooks_editor.rs new file mode 100644 index 0000000..9ed50b2 --- /dev/null +++ b/fig-ui/src/views/hooks_editor.rs @@ -0,0 +1,244 @@ +use std::collections::HashMap; + +use fig_core::models::{EditableHookGroup, EditingTarget, HookEvent, HOOK_TEMPLATES}; +use iced::widget::{button, column, container, row, scrollable, text, text_input}; +use iced::{Element, Length, Padding}; + +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, PartialEq)] +pub struct HooksEditorState { + pub active_event: HookEvent, + pub groups: HashMap>, + pub editing_target: EditingTarget, +} + +impl Default for HooksEditorState { + fn default() -> Self { + Self { + active_event: HookEvent::PreToolUse, + groups: HashMap::new(), + editing_target: EditingTarget::Global, + } + } +} + +pub fn hooks_editor_view<'a>(state: &'a HooksEditorState) -> Element<'a, Message> { + // Event tabs + let mut event_tabs = row![].spacing(0); + for &event in HookEvent::all() { + let is_active = event == state.active_event; + let text_color = if is_active { + styles::ACCENT + } else { + styles::TEXT_SECONDARY + }; + event_tabs = event_tabs.push( + button( + container( + text(event.display_name().to_string()) + .size(12) + .color(text_color), + ) + .padding(Padding::new(8.0).left(12.0).right(12.0)), + ) + .on_press(Message::HooksSelectEvent(event)) + .padding(0) + .style(move |_theme: &iced::Theme, _status| button::Style { + background: None, + text_color, + border: if is_active { + iced::Border { + color: styles::ACCENT, + width: 0.0, + radius: 0.into(), + } + } else { + iced::Border::default() + }, + shadow: iced::Shadow::default(), + }), + ); + } + + // Description for active event + let event_desc = text(state.active_event.description()) + .size(12) + .color(styles::TEXT_SECONDARY); + + // Template quick-add buttons (filtered by active event) + let mut templates_row = row![].spacing(6); + for template in HOOK_TEMPLATES { + if template.event == state.active_event { + let name = template.name.to_string(); + templates_row = templates_row.push( + button(text(template.name).size(10)) + .on_press(Message::HooksApplyTemplate(name)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: Some(styles::SELECTED_BG.into()), + text_color: styles::ACCENT, + border: iced::Border { + radius: 3.0.into(), + ..Default::default() + }, + shadow: iced::Shadow::default(), + }), + ); + } + } + + // Add Group button + let add_group_btn = button(text("Add Group").size(12)) + .on_press(Message::HooksAddGroup) + .padding(Padding::new(4.0).left(12.0).right(12.0)); + + // Groups list for active event + let active_groups = state.groups.get(&state.active_event); + let mut groups_col = column![].spacing(8); + + if let Some(groups) = active_groups { + for group in groups { + groups_col = groups_col.push(group_card(group, state.active_event)); + } + } + + if active_groups.map(|g| g.is_empty()).unwrap_or(true) { + groups_col = groups_col.push( + container( + text("No hook groups for this event. Add a group or use a template.") + .size(13) + .color(styles::TEXT_SECONDARY), + ) + .padding(20) + .width(Length::Fill) + .center_x(Length::Fill), + ); + } + + // Target picker + let target_options: Vec = if state.editing_target == EditingTarget::Global { + vec![EditingTarget::Global] + } else { + EditingTarget::project_targets().to_vec() + }; + + let target_picker = iced::widget::pick_list( + target_options, + Some(state.editing_target), + Message::HooksChangeTarget, + ) + .text_size(13); + + container( + column![ + text("Hooks").size(20).color(styles::TEXT_PRIMARY), + text("Configure lifecycle hooks that run during Claude Code operations.") + .size(13) + .color(styles::TEXT_SECONDARY), + target_picker, + event_tabs, + event_desc, + row![ + text("Templates").size(12).color(styles::TEXT_SECONDARY), + templates_row, + ] + .spacing(8) + .align_y(iced::Alignment::Center), + add_group_btn, + scrollable(groups_col).height(Length::Fill), + ] + .spacing(12), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn group_card<'a>(group: &'a EditableHookGroup, event: HookEvent) -> Element<'a, Message> { + let group_id = group.id; + let group_id_for_add = group.id; + + let mut card = column![].spacing(6); + + // Group header: matcher + remove button + let mut header = row![].spacing(8).align_y(iced::Alignment::Center); + + if event.supports_matcher() { + let matcher_value = group.matcher.as_deref().unwrap_or(""); + header = header.push( + text_input("Matcher pattern (e.g. Bash(*))", matcher_value) + .on_input(move |s| Message::HooksUpdateMatcher(group_id, s)) + .size(12) + .width(Length::Fill), + ); + } else { + header = header.push(iced::widget::horizontal_space()); + } + + header = header.push( + button(text("x").size(11)) + .on_press(Message::HooksRemoveGroup(group_id)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + ); + + card = card.push(header); + + // Hooks in this group + for hook in &group.hooks { + let hook_id = hook.id; + let hook_row = row![ + text_input("Command", &hook.command) + .on_input(move |s| Message::HooksUpdateHookCommand(group_id, hook_id, s)) + .size(12) + .width(Length::Fill), + button(text("x").size(10)) + .on_press(Message::HooksRemoveHook(group_id, hook_id)) + .padding(Padding::new(2.0).left(4.0).right(4.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + ] + .spacing(4) + .align_y(iced::Alignment::Center); + + card = card.push(hook_row); + } + + // Add hook button + card = card.push( + button(text("+ Add Hook").size(11)) + .on_press(Message::HooksAddHook(group_id_for_add)) + .padding(Padding::new(2.0).left(8.0).right(8.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::ACCENT, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + ); + + container(card) + .padding(Padding::new(8.0).left(12.0).right(12.0)) + .width(Length::Fill) + .style(|_theme: &iced::Theme| container::Style { + background: Some(styles::SELECTED_BG.into()), + border: iced::Border { + radius: 6.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} diff --git a/fig-ui/src/views/mcp_copy_sheet.rs b/fig-ui/src/views/mcp_copy_sheet.rs new file mode 100644 index 0000000..04779ca --- /dev/null +++ b/fig-ui/src/views/mcp_copy_sheet.rs @@ -0,0 +1,232 @@ +use std::collections::HashSet; + +use fig_core::models::{ConfigSource, MCPServer}; +use fig_core::services::CopyConflict; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input}; +use iced::{Element, Length, Padding}; + +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, PartialEq)] +pub struct CopySheetState { + pub selected_servers: HashSet, + pub target_source: ConfigSource, + pub conflicts: Vec, + pub show_conflicts: bool, +} + +impl Default for CopySheetState { + fn default() -> Self { + Self { + selected_servers: HashSet::new(), + target_source: ConfigSource::Global, + conflicts: Vec::new(), + show_conflicts: false, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ImportSheetState { + pub json_input: String, + pub parsed_servers: Vec<(String, MCPServer)>, + pub selected_servers: HashSet, + pub parse_error: Option, + pub redact_on_export: bool, +} + +impl Default for ImportSheetState { + fn default() -> Self { + Self { + json_input: String::new(), + parsed_servers: Vec::new(), + selected_servers: HashSet::new(), + parse_error: None, + redact_on_export: true, + } + } +} + +pub fn copy_sheet_view<'a>( + state: &'a CopySheetState, + servers: &'a [(String, MCPServer)], +) -> Element<'a, Message> { + let mut content = column![ + text("Copy Servers").size(20).color(styles::TEXT_PRIMARY), + text("Select servers to copy to another scope.") + .size(13) + .color(styles::TEXT_SECONDARY), + ] + .spacing(8); + + // Target scope picker + let target_options = vec![ + ConfigSource::Global, + ConfigSource::ProjectShared, + ConfigSource::ProjectLocal, + ]; + content = content + .push(text("Target Scope").size(12).color(styles::TEXT_SECONDARY)) + .push( + iced::widget::pick_list( + target_options, + Some(state.target_source), + Message::MCPCopySelectTarget, + ) + .text_size(13), + ); + + // Server checkboxes + let mut server_list = column![].spacing(4); + for (name, server) in servers { + let is_selected = state.selected_servers.contains(name); + let summary = if server.is_http() { + server.url.as_deref().unwrap_or("(no url)") + } else { + server.command.as_deref().unwrap_or("(no command)") + }; + let label = format!("{name} ({summary})"); + let name_owned = name.clone(); + server_list = server_list.push( + checkbox(label, is_selected) + .on_toggle(move |_| Message::MCPCopyToggleServer(name_owned.clone())) + .text_size(13), + ); + } + content = content.push(scrollable(server_list).height(200)); + + // Conflicts display + if state.show_conflicts && !state.conflicts.is_empty() { + let mut conflict_col = column![text("Conflicts detected:") + .size(13) + .color(iced::Color::from_rgb(0.9, 0.6, 0.2)),] + .spacing(4); + for conflict in &state.conflicts { + conflict_col = conflict_col.push( + text(format!( + " {} already exists ({})", + conflict.server_name, conflict.existing_summary + )) + .size(12) + .color(styles::TEXT_SECONDARY), + ); + } + conflict_col = conflict_col.push( + button(text("Force Overwrite").size(12)) + .on_press(Message::MCPCopyForceOverwrite) + .padding(Padding::new(4.0).left(12.0).right(12.0)), + ); + content = content.push(conflict_col); + } + + // Buttons + let confirm_btn = button(text("Copy").size(13)) + .on_press(Message::MCPCopyConfirm) + .padding(Padding::new(6.0).left(16.0).right(16.0)); + let cancel_btn = button(text("Cancel").size(13)) + .on_press(Message::MCPCloseCopySheet) + .padding(Padding::new(6.0).left(16.0).right(16.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border { + radius: 4.0.into(), + color: styles::DIVIDER, + width: 1.0, + }, + shadow: iced::Shadow::default(), + }); + + content = content.push(row![confirm_btn, cancel_btn].spacing(8)); + + container(content) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +pub fn import_sheet_view<'a>(state: &'a ImportSheetState) -> Element<'a, Message> { + let mut content = column![ + text("Import Servers").size(20).color(styles::TEXT_PRIMARY), + text("Paste MCP server JSON configuration to import.") + .size(13) + .color(styles::TEXT_SECONDARY), + ] + .spacing(8); + + // JSON input + content = content.push( + text_input("Paste JSON here...", &state.json_input) + .on_input(Message::MCPImportPasteJson) + .size(13) + .width(Length::Fill), + ); + + // Parse error + if let Some(ref err) = state.parse_error { + content = content.push( + text(err.clone()) + .size(12) + .color(iced::Color::from_rgb(0.9, 0.3, 0.3)), + ); + } + + // Parsed servers preview + if !state.parsed_servers.is_empty() { + let mut preview = column![text("Servers found:") + .size(12) + .color(styles::TEXT_SECONDARY),] + .spacing(4); + for (name, server) in &state.parsed_servers { + let is_selected = state.selected_servers.contains(name); + let summary = if server.is_http() { + format!("http: {}", server.url.as_deref().unwrap_or("?")) + } else { + format!("stdio: {}", server.command.as_deref().unwrap_or("?")) + }; + let label = format!("{name} ({summary})"); + let name_owned = name.clone(); + preview = preview.push( + checkbox(label, is_selected) + .on_toggle(move |_| Message::MCPImportToggleServer(name_owned.clone())) + .text_size(13), + ); + } + content = content.push(preview); + } + + // Redaction toggle + content = content.push( + checkbox("Redact sensitive values on export", state.redact_on_export) + .on_toggle(Message::MCPToggleRedaction) + .text_size(12), + ); + + // Buttons + let import_btn = button(text("Import Selected").size(13)) + .on_press(Message::MCPImportConfirm) + .padding(Padding::new(6.0).left(16.0).right(16.0)); + let cancel_btn = button(text("Cancel").size(13)) + .on_press(Message::MCPCloseImportSheet) + .padding(Padding::new(6.0).left(16.0).right(16.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border { + radius: 4.0.into(), + color: styles::DIVIDER, + width: 1.0, + }, + shadow: iced::Shadow::default(), + }); + + content = content.push(row![import_btn, cancel_btn].spacing(8)); + + container(content) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/fig-ui/src/views/mcp_server_form.rs b/fig-ui/src/views/mcp_server_form.rs new file mode 100644 index 0000000..8c9a0dc --- /dev/null +++ b/fig-ui/src/views/mcp_server_form.rs @@ -0,0 +1,138 @@ +use fig_core::models::{MCPServerFormData, MCPServerType, ValidationError}; +use iced::widget::{button, column, container, pick_list, row, text, text_input}; +use iced::{Element, Length, Padding}; + +use crate::styles; +use crate::Message; + +pub fn mcp_server_form_view<'a>( + form: &'a MCPServerFormData, + errors: &'a [ValidationError], +) -> Element<'a, Message> { + let title = if form.is_editing { + "Edit Server" + } else { + "Add Server" + }; + + let type_options = vec![MCPServerType::Stdio, MCPServerType::Sse]; + + let mut content = column![ + text(title).size(20).color(styles::TEXT_PRIMARY), + // Name field + text("Name").size(12).color(styles::TEXT_SECONDARY), + text_input("Server name", &form.name) + .on_input(Message::MCPFormUpdateName) + .size(13), + ] + .spacing(8) + .width(Length::Fill); + + content = push_field_errors(content, errors, "name"); + + // Server type picker + content = content + .push(text("Type").size(12).color(styles::TEXT_SECONDARY)) + .push( + pick_list( + type_options, + Some(form.server_type), + Message::MCPFormChangeType, + ) + .text_size(13), + ); + + match form.server_type { + MCPServerType::Stdio => { + content = content + .push(text("Command").size(12).color(styles::TEXT_SECONDARY)) + .push( + text_input("e.g. npx, node, python", &form.command) + .on_input(Message::MCPFormUpdateCommand) + .size(13), + ); + content = push_field_errors(content, errors, "command"); + + content = content + .push( + text("Arguments (one per line)") + .size(12) + .color(styles::TEXT_SECONDARY), + ) + .push( + text_input("-y\n@modelcontextprotocol/server-github", &form.args_text) + .on_input(Message::MCPFormUpdateArgs) + .size(13), + ); + + content = content + .push( + text("Environment Variables (KEY=VALUE per line)") + .size(12) + .color(styles::TEXT_SECONDARY), + ) + .push( + text_input("GITHUB_TOKEN=your-token", &form.env_text) + .on_input(Message::MCPFormUpdateEnv) + .size(13), + ); + } + MCPServerType::Sse => { + content = content + .push(text("URL").size(12).color(styles::TEXT_SECONDARY)) + .push( + text_input("https://mcp.example.com/api", &form.url) + .on_input(Message::MCPFormUpdateUrl) + .size(13), + ); + content = push_field_errors(content, errors, "url"); + } + } + + // Buttons + let save_btn = button(text("Save").size(13)) + .on_press(Message::MCPFormSave) + .padding(Padding::new(6.0).left(16.0).right(16.0)); + + let cancel_btn = button(text("Cancel").size(13)) + .on_press(Message::MCPFormCancel) + .padding(Padding::new(6.0).left(16.0).right(16.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border { + radius: 4.0.into(), + color: styles::DIVIDER, + width: 1.0, + }, + shadow: iced::Shadow::default(), + }); + + content = content.push( + row![save_btn, cancel_btn] + .spacing(8) + .padding(Padding::new(8.0).top(4.0)), + ); + + container(content) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn push_field_errors<'a>( + col: iced::widget::Column<'a, Message>, + errors: &[ValidationError], + field: &str, +) -> iced::widget::Column<'a, Message> { + let mut result = col; + for error in errors.iter().filter(|e| e.field == field) { + result = result.push( + text(error.message.clone()) + .size(11) + .color(iced::Color::from_rgb(0.9, 0.3, 0.3)), + ); + } + result +} diff --git a/fig-ui/src/views/mcp_server_list.rs b/fig-ui/src/views/mcp_server_list.rs new file mode 100644 index 0000000..e0e7b9c --- /dev/null +++ b/fig-ui/src/views/mcp_server_list.rs @@ -0,0 +1,269 @@ +use std::collections::HashSet; + +use fig_core::models::{ + EditableEnvironmentVariable, MCPServer, MCPServerFormData, ValidationError, +}; +use iced::widget::{button, column, container, row, scrollable, text}; +use iced::{Element, Length, Padding}; + +use super::mcp_copy_sheet::{CopySheetState, ImportSheetState}; +use super::mcp_server_form::mcp_server_form_view; +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct MCPServerListState { + pub servers: Vec<(String, MCPServer)>, + pub expanded_servers: HashSet, + pub form: Option, + pub validation_errors: Vec, + pub copy_sheet: Option, + pub import_sheet: Option, +} + +pub fn mcp_server_list_view<'a>(state: &'a MCPServerListState) -> Element<'a, Message> { + // If form is active, show the form instead + if let Some(ref form) = state.form { + return mcp_server_form_view(form, &state.validation_errors); + } + + // If copy sheet is active, show it + if let Some(ref sheet) = state.copy_sheet { + return super::mcp_copy_sheet::copy_sheet_view(sheet, &state.servers); + } + + // If import sheet is active, show it + if let Some(ref sheet) = state.import_sheet { + return super::mcp_copy_sheet::import_sheet_view(sheet); + } + + // Header with action buttons + let add_btn = button(text("Add Server").size(12)) + .on_press(Message::MCPAddServer) + .padding(Padding::new(4.0).left(12.0).right(12.0)); + + let export_btn = button(text("Export").size(12)) + .on_press(Message::MCPExportToClipboard) + .padding(Padding::new(4.0).left(12.0).right(12.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: Some(styles::SELECTED_BG.into()), + text_color: styles::TEXT_PRIMARY, + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + shadow: iced::Shadow::default(), + }); + + let import_btn = button(text("Import").size(12)) + .on_press(Message::MCPOpenImportSheet) + .padding(Padding::new(4.0).left(12.0).right(12.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: Some(styles::SELECTED_BG.into()), + text_color: styles::TEXT_PRIMARY, + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + shadow: iced::Shadow::default(), + }); + + let copy_btn = button(text("Copy To...").size(12)) + .on_press(Message::MCPOpenCopySheet) + .padding(Padding::new(4.0).left(12.0).right(12.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: Some(styles::SELECTED_BG.into()), + text_color: styles::TEXT_PRIMARY, + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + shadow: iced::Shadow::default(), + }); + + let actions = row![add_btn, export_btn, import_btn, copy_btn].spacing(8); + + if state.servers.is_empty() { + return container( + column![ + text("MCP Servers").size(20).color(styles::TEXT_PRIMARY), + text("Manage MCP server configurations.") + .size(13) + .color(styles::TEXT_SECONDARY), + actions, + container( + text("No MCP servers configured.") + .size(14) + .color(styles::TEXT_SECONDARY), + ) + .padding(40) + .width(Length::Fill) + .center_x(Length::Fill), + ] + .spacing(12), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into(); + } + + // Server cards + let mut cards = column![].spacing(8); + for (name, server) in &state.servers { + let is_expanded = state.expanded_servers.contains(name); + cards = cards.push(server_card(name, server, is_expanded)); + } + + container( + column![ + text("MCP Servers").size(20).color(styles::TEXT_PRIMARY), + text("Manage MCP server configurations.") + .size(13) + .color(styles::TEXT_SECONDARY), + actions, + scrollable(cards).height(Length::Fill), + ] + .spacing(12), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +fn server_card<'a>(name: &str, server: &MCPServer, is_expanded: bool) -> Element<'a, Message> { + let type_label = if server.is_http() { "http" } else { "stdio" }; + let type_color = if server.is_http() { + iced::Color::from_rgb(0.3, 0.6, 0.9) + } else { + iced::Color::from_rgb(0.3, 0.8, 0.4) + }; + + let type_badge = container(text(type_label).size(10).color(type_color)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(move |_theme: &iced::Theme| container::Style { + background: Some(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.3).into()), + border: iced::Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }); + + let summary = if server.is_http() { + server.url.as_deref().unwrap_or("(no url)") + } else { + server.command.as_deref().unwrap_or("(no command)") + }; + + let name_owned = name.to_string(); + let name_for_edit = name.to_string(); + let name_for_delete = name.to_string(); + + let expand_msg = if is_expanded { + Message::MCPCollapseServer(name_owned.clone()) + } else { + Message::MCPExpandServer(name_owned) + }; + + let expand_label = if is_expanded { "v" } else { ">" }; + + let header_row = row![ + button(text(expand_label).size(11)) + .on_press(expand_msg) + .padding(Padding::new(2.0).left(4.0).right(4.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + type_badge, + text(name.to_string()).size(14).color(styles::TEXT_PRIMARY), + text(summary.to_string()) + .size(12) + .color(styles::TEXT_SECONDARY), + iced::widget::horizontal_space(), + button(text("Edit").size(11)) + .on_press(Message::MCPEditServer(name_for_edit)) + .padding(Padding::new(2.0).left(8.0).right(8.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::ACCENT, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + button(text("x").size(11)) + .on_press(Message::MCPDeleteServer(name_for_delete)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + ] + .spacing(8) + .align_y(iced::Alignment::Center); + + let mut card_content = column![header_row].spacing(6); + + if is_expanded { + // Show args + if let Some(ref args) = server.args { + if !args.is_empty() { + card_content = card_content.push( + text(format!("Args: {}", args.join(" "))) + .size(12) + .color(styles::TEXT_SECONDARY), + ); + } + } + + // Show env vars + if let Some(ref env) = server.env { + let mut keys: Vec<_> = env.keys().collect(); + keys.sort(); + for key in keys { + let value = &env[key]; + let display = if EditableEnvironmentVariable::is_sensitive_key(key) { + "********".to_string() + } else { + value.clone() + }; + card_content = card_content.push( + text(format!("{key}={display}")) + .size(11) + .color(styles::TEXT_SECONDARY), + ); + } + } + + // Show headers for HTTP + if let Some(ref headers) = server.headers { + let mut keys: Vec<_> = headers.keys().collect(); + keys.sort(); + for key in keys { + card_content = card_content.push( + text(format!("{key}: ********")) + .size(11) + .color(styles::TEXT_SECONDARY), + ); + } + } + } + + container(card_content) + .padding(Padding::new(8.0).left(12.0).right(12.0)) + .width(Length::Fill) + .style(|_theme: &iced::Theme| container::Style { + background: Some(styles::SELECTED_BG.into()), + border: iced::Border { + radius: 6.0.into(), + ..Default::default() + }, + ..Default::default() + }) + .into() +} diff --git a/fig-ui/src/views/mod.rs b/fig-ui/src/views/mod.rs new file mode 100644 index 0000000..f0ec3f9 --- /dev/null +++ b/fig-ui/src/views/mod.rs @@ -0,0 +1,11 @@ +pub mod attribution_editor; +pub mod detail; +pub mod effective_config_view; +pub mod environment_editor; +pub mod health_check_view; +pub mod hooks_editor; +pub mod mcp_copy_sheet; +pub mod mcp_server_form; +pub mod mcp_server_list; +pub mod permissions_editor; +pub mod sidebar; diff --git a/fig-ui/src/views/permissions_editor.rs b/fig-ui/src/views/permissions_editor.rs new file mode 100644 index 0000000..540aaa7 --- /dev/null +++ b/fig-ui/src/views/permissions_editor.rs @@ -0,0 +1,174 @@ +use fig_core::models::{EditablePermissionRule, EditingTarget, PermissionType, PERMISSION_PRESETS}; +use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input}; +use iced::{Element, Length, Padding}; +use uuid::Uuid; + +use crate::styles; +use crate::Message; + +#[derive(Debug, Clone, PartialEq)] +pub struct PermissionsEditorState { + pub rules: Vec, + pub editing_target: EditingTarget, + pub new_rule_text: String, + pub new_rule_type: PermissionType, +} + +impl Default for PermissionsEditorState { + fn default() -> Self { + Self { + rules: Vec::new(), + editing_target: EditingTarget::Global, + new_rule_text: String::new(), + new_rule_type: PermissionType::Allow, + } + } +} + +impl PermissionsEditorState { + pub fn add_rule(&mut self) { + let text = self.new_rule_text.trim().to_string(); + if text.is_empty() { + return; + } + self.rules + .push(EditablePermissionRule::new(text, self.new_rule_type)); + self.new_rule_text.clear(); + } + + pub fn remove_rule(&mut self, id: Uuid) { + self.rules.retain(|r| r.id != id); + } + + pub fn apply_preset(&mut self, preset_id: &str) { + if let Some(preset) = PERMISSION_PRESETS.iter().find(|p| p.id == preset_id) { + for &(rule, ptype) in preset.rules { + if !self.rules.iter().any(|r| r.rule == rule) { + self.rules + .push(EditablePermissionRule::new(rule.to_string(), ptype)); + } + } + } + } +} + +pub fn permissions_editor_view<'a>(state: &'a PermissionsEditorState) -> Element<'a, Message> { + let target_options: Vec = if state.editing_target == EditingTarget::Global { + vec![EditingTarget::Global] + } else { + EditingTarget::project_targets().to_vec() + }; + + let target_picker = pick_list( + target_options, + Some(state.editing_target), + Message::PermissionsChangeTarget, + ) + .text_size(13); + + // Presets row + let mut presets_row = row![].spacing(8); + for preset in PERMISSION_PRESETS { + let preset_id = preset.id.to_string(); + presets_row = presets_row.push( + button(text(preset.name).size(11)) + .on_press(Message::PermissionsApplyPreset(preset_id)) + .padding(Padding::new(4.0).left(8.0).right(8.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: Some(styles::SELECTED_BG.into()), + text_color: styles::TEXT_PRIMARY, + border: iced::Border { + radius: 4.0.into(), + ..Default::default() + }, + shadow: iced::Shadow::default(), + }), + ); + } + + // New rule input + let type_label = match state.new_rule_type { + PermissionType::Allow => "Allow", + PermissionType::Deny => "Deny", + }; + let toggle_type = button(text(type_label).size(12)) + .on_press(Message::PermissionsToggleType) + .padding(Padding::new(4.0).left(8.0).right(8.0)); + + let input = text_input("Bash(npm run *), Read(src/**), etc.", &state.new_rule_text) + .on_input(Message::PermissionsNewRuleInput) + .on_submit(Message::PermissionsAddRule) + .size(13); + + let add_btn = button(text("Add").size(12)) + .on_press(Message::PermissionsAddRule) + .padding(Padding::new(4.0).left(12.0).right(12.0)); + + let input_row = row![toggle_type, input, add_btn] + .spacing(8) + .width(Length::Fill); + + // Rules list + let mut rules_col = column![].spacing(4); + for rule in &state.rules { + let type_color = match rule.permission_type { + PermissionType::Allow => iced::Color::from_rgb(0.3, 0.8, 0.4), + PermissionType::Deny => iced::Color::from_rgb(0.9, 0.3, 0.3), + }; + let type_badge = container( + text(rule.permission_type.label()) + .size(10) + .color(type_color), + ) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(move |_theme: &iced::Theme| container::Style { + background: Some(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.3).into()), + border: iced::Border { + radius: 3.0.into(), + ..Default::default() + }, + ..Default::default() + }); + + let rule_id = rule.id; + let rule_row = row![ + type_badge, + text(&rule.rule).size(13).color(styles::TEXT_PRIMARY), + iced::widget::horizontal_space(), + button(text("x").size(11)) + .on_press(Message::PermissionsRemoveRule(rule_id)) + .padding(Padding::new(2.0).left(6.0).right(6.0)) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_SECONDARY, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }), + ] + .spacing(8) + .align_y(iced::Alignment::Center); + + rules_col = rules_col.push(rule_row); + } + + container( + column![ + text("Permissions").size(20).color(styles::TEXT_PRIMARY), + text("Manage tool permission rules for Claude Code.") + .size(13) + .color(styles::TEXT_SECONDARY), + target_picker, + text("Quick Add Presets") + .size(12) + .color(styles::TEXT_SECONDARY), + presets_row, + input_row, + scrollable(rules_col).height(Length::Fill), + ] + .spacing(12), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill) + .into() +} diff --git a/fig-ui/src/views/sidebar.rs b/fig-ui/src/views/sidebar.rs new file mode 100644 index 0000000..293c8e1 --- /dev/null +++ b/fig-ui/src/views/sidebar.rs @@ -0,0 +1,185 @@ +use fig_core::models::{abbreviate_dir, DiscoveredProject, NavigationSelection}; +use iced::widget::{button, column, container, scrollable, text, Column}; +use iced::{Color, Element, Length, Padding}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::styles; +use crate::Message; + +pub fn sidebar_view<'a>( + projects: &'a [DiscoveredProject], + selection: &'a NavigationSelection, + group_by_directory: bool, +) -> Element<'a, Message> { + let header = container(text("Fig").size(18).color(styles::TEXT_PRIMARY)) + .padding(Padding::new(16.0).left(20.0).right(20.0)); + + let global_item = sidebar_item( + "Global Settings", + None, + selection == &NavigationSelection::GlobalSettings, + Message::SelectGlobalSettings, + ); + + let divider = + container(text("")) + .width(Length::Fill) + .height(1) + .style(|_theme: &iced::Theme| container::Style { + background: Some(styles::DIVIDER.into()), + ..Default::default() + }); + + let projects_header = container(text("Projects").size(11).color(styles::GROUP_HEADER_TEXT)) + .padding(Padding::new(8.0).left(20.0).right(20.0).bottom(4.0)); + + let project_list = if group_by_directory { + grouped_project_list(projects, selection) + } else { + flat_project_list(projects, selection) + }; + + let content = column![header, global_item, divider, projects_header, project_list] + .width(styles::SIDEBAR_WIDTH); + + container(scrollable(content).height(Length::Fill)) + .width(styles::SIDEBAR_WIDTH) + .height(Length::Fill) + .style(|_theme: &iced::Theme| container::Style { + background: Some(styles::SIDEBAR_BG.into()), + ..Default::default() + }) + .into() +} + +fn flat_project_list<'a>( + projects: &'a [DiscoveredProject], + selection: &'a NavigationSelection, +) -> Element<'a, Message> { + let mut col = Column::new(); + for project in projects { + let path_str = project.path.to_string_lossy().to_string(); + let is_selected = selection == &NavigationSelection::Project(path_str.clone()); + col = col.push(sidebar_item( + &project.display_name, + Some(&project.path), + is_selected, + Message::SelectProject(path_str), + )); + } + col.into() +} + +fn grouped_project_list<'a>( + projects: &'a [DiscoveredProject], + selection: &'a NavigationSelection, +) -> Element<'a, Message> { + let groups = group_projects_internal(projects); + let mut col = Column::new(); + + for group in &groups { + col = col.push( + container( + text(group.display_name.clone()) + .size(11) + .color(styles::GROUP_HEADER_TEXT), + ) + .padding(Padding::new(8.0).left(20.0).right(20.0).bottom(2.0)), + ); + + for dp in &group.discovered { + let path_str = dp.path.to_string_lossy().to_string(); + let is_selected = selection == &NavigationSelection::Project(path_str.clone()); + col = col.push(sidebar_item( + &dp.display_name, + Some(&dp.path), + is_selected, + Message::SelectProject(path_str), + )); + } + } + + col.into() +} + +/// Internal grouping struct for sidebar rendering that keeps DiscoveredProject refs. +struct SidebarProjectGroup<'a> { + display_name: String, + discovered: Vec<&'a DiscoveredProject>, +} + +fn group_projects_internal(projects: &[DiscoveredProject]) -> Vec> { + let home = dirs::home_dir(); + let mut groups_map: BTreeMap> = BTreeMap::new(); + + for project in projects { + let parent = project + .path + .parent() + .unwrap_or(Path::new("/")) + .to_path_buf(); + groups_map.entry(parent).or_default().push(project); + } + + groups_map + .into_iter() + .map(|(parent_path, members)| { + let display_name = abbreviate_dir(&parent_path, home.as_deref()); + SidebarProjectGroup { + display_name, + discovered: members, + } + }) + .collect() +} + +fn sidebar_item<'a>( + label: &str, + subtitle_path: Option<&Path>, + is_selected: bool, + on_press: Message, +) -> Element<'a, Message> { + let bg = if is_selected { + styles::SELECTED_BG + } else { + Color::TRANSPARENT + }; + + let text_color = if is_selected { + styles::TEXT_PRIMARY + } else { + styles::TEXT_SECONDARY + }; + + let mut content = Column::new().push(text(label.to_string()).size(13).color(text_color)); + + if let Some(path) = subtitle_path { + let abbreviated = abbreviate_path(path); + content = content.push(text(abbreviated).size(10).color(styles::TEXT_SECONDARY)); + } + + let inner = container(content.spacing(2)) + .padding(Padding::new(6.0).left(20.0).right(20.0)) + .width(Length::Fill) + .style(move |_theme: &iced::Theme| container::Style { + background: Some(bg.into()), + ..Default::default() + }); + + button(inner) + .on_press(on_press) + .padding(0) + .width(Length::Fill) + .style(|_theme: &iced::Theme, _status| button::Style { + background: None, + text_color: styles::TEXT_PRIMARY, + border: iced::Border::default(), + shadow: iced::Shadow::default(), + }) + .into() +} + +fn abbreviate_path(path: &Path) -> String { + abbreviate_dir(path, dirs::home_dir().as_deref()) +}