diff --git a/bridge/src/config.rs b/bridge/src/config.rs index c790d642..3fd0e126 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -643,6 +643,41 @@ impl CodescribeConfig { msg: error.to_string(), }) } + + /// Wipe all local codescribe data for a privacy "Reset app data" action. + /// + /// Removes the two app-owned data trees — the config / logs / transcription + /// directory (`~/.codescribe`) and the Application Support store + /// (`~/Library/Application Support/Codescribe`, i.e. settings.json + thread + /// history) — sourced from the same runtime path helpers the app reads, so a + /// `CODESCRIBE_DATA_DIR` override is honored and no `~` is ever hardcoded. + /// + /// When `include_keys` is true, also removes every Keychain-backed API key + /// (`KEYCHAIN_ACCOUNTS`) and clears it from the live process env. Deleting the + /// data trees clears the `setup_done` sentinel, so the next launch replays the + /// first-run wizard. TCC / permission grants are deliberately NOT touched — + /// those are the user's to manage in System Settings. + /// + /// UserDefaults (window frames / SwiftUI scene restoration) is a CFPreferences + /// domain with no core helper; the Swift caller clears it before relaunch. + pub fn reset_app_data(&self, include_keys: bool) -> Result<(), CsError> { + for dir in app_data_dirs() { + remove_dir_all_tolerant(&dir).map_err(|error| CsError::Config { + msg: format!("failed to remove {}: {error}", dir.display()), + })?; + } + if include_keys { + for account in KEYCHAIN_ACCOUNTS { + delete_key(account).map_err(|error| CsError::Config { + msg: format!("failed to remove keychain key {account}: {error}"), + })?; + // SAFETY: same single-writer invariant as `set_api_key` / + // `clear_api_key` — settings writes are serialized on one Swift actor. + unsafe { std::env::remove_var(account) }; + } + } + Ok(()) + } } /// Re-seed the live hotkey detector atomics after a settings write so mode @@ -663,6 +698,31 @@ fn reload_hotkey_runtime() { codescribe::os::hotkeys::apply_hotkey_config(&Config::load_without_keychain()); } +/// App-owned data directories a full "Reset app data" wipes: the config / logs / +/// transcription tree first, then the Application Support store. Both come from +/// the runtime path helpers (honoring `CODESCRIBE_DATA_DIR`); when an override +/// collapses them onto one path the duplicate is dropped so we never remove twice. +fn app_data_dirs() -> Vec { + let config_dir = Config::config_dir(); + let settings_dir = codescribe_core::config::UserSettings::settings_dir(); + if settings_dir == config_dir { + vec![config_dir] + } else { + vec![config_dir, settings_dir] + } +} + +/// `remove_dir_all` that treats an already-absent directory as success: a reset +/// must not fail just because a tree (e.g. an Application Support store that was +/// never created) does not exist yet. +fn remove_dir_all_tolerant(dir: &std::path::Path) -> std::io::Result<()> { + match fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + /// Built-in default workspace root when `AGENT_WORKSPACE_ROOTS` is unset. Kept in /// sync with the `list_projects` tool default (`app/agent/tools/workspace.rs`). const DEFAULT_AGENT_WORKSPACE_ROOT: &str = "~/Git"; @@ -778,3 +838,66 @@ fn write_prompt(path: &std::path::Path, content: &str) -> Result<(), CsError> { fs::write(path, content)?; Ok(()) } + +#[cfg(test)] +mod reset_tests { + use super::{app_data_dirs, remove_dir_all_tolerant}; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Unique scratch directory under the OS temp dir (never the real home). + fn scratch(tag: &str) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!("cs_reset_{}_{tag}_{nanos}", std::process::id())) + } + + /// The reset scope must follow the `CODESCRIBE_DATA_DIR` override and wipe + /// exactly that tree — never anything outside it — and a repeat wipe of an + /// already-gone tree must succeed (idempotent). `#[serial]`: mutates the global + /// `CODESCRIBE_DATA_DIR` env var read by the core path helpers. + #[test] + #[serial] + fn reset_scope_follows_data_dir_and_is_idempotent() { + let root = scratch("scope"); + std::fs::create_dir_all(root.join("transcriptions")).unwrap(); + std::fs::write(root.join("settings.json"), b"{}").unwrap(); + std::fs::write(root.join("transcriptions/a.txt"), b"hi").unwrap(); + let root_canon = root.canonicalize().unwrap(); + + let previous = std::env::var("CODESCRIBE_DATA_DIR").ok(); + // SAFETY: single-threaded test body, serialized via `#[serial]`. + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &root) }; + + let dirs = app_data_dirs(); + assert!(!dirs.is_empty(), "reset must target at least one dir"); + // Every target resolves to the override root — nothing escapes it. + for dir in &dirs { + let resolved = dir.canonicalize().unwrap_or_else(|_| dir.clone()); + assert_eq!( + resolved, root_canon, + "reset target escaped the data dir: {dir:?}" + ); + } + + for dir in &dirs { + remove_dir_all_tolerant(dir).unwrap(); + } + assert!(!root_canon.exists(), "data tree survived reset"); + + // Idempotent: wiping an absent tree is a no-op, not an error. + for dir in &dirs { + remove_dir_all_tolerant(dir).unwrap(); + } + + // SAFETY: restore prior env, same serialized single-thread context. + unsafe { + match previous { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + } + } +} diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift index b45302b0..e5775403 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift @@ -265,6 +265,9 @@ struct AgenticReadinessStepView: View { .foregroundStyle(CSColor.textFaint) .padding(.top, 4) statusCard(rows: mcp.rows) + } else if !model.mcpSetupDismissed { + mcpSetupPrompt + .padding(.top, 4) } OnboardingButton(title: "Refresh", kind: .secondary) { @@ -277,6 +280,41 @@ struct AgenticReadinessStepView: View { } } + /// Shown on the readiness step when no MCP server is configured yet: a short, + /// human explainer plus a route into the real setup surface and a no-guilt skip. + /// Replaces the old dead end where a missing `mcp.json` showed nothing at all. + private var mcpSetupPrompt: some View { + VStack(alignment: .leading, spacing: 10) { + Text("MCP servers (optional)") + .font(CSFont.mono(10, .semibold)) + .tracking(0.4) + .foregroundStyle(CSColor.textFaint) + Text("MCP servers give the agent extra tools — things like code search, " + + "PR review, or web search. It's entirely optional: skip it now and " + + "wire servers any time from Settings › Engine.") + .font(CSFont.ui(13)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + OnboardingButton(title: "Set up MCP servers", kind: .primary) { + model.openMcpSettings() + } + OnboardingButton(title: "Skip for now", kind: .secondary) { + model.dismissMcpSetupPrompt() + } + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CSColor.surfaceRaised(0.02))) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1)) + } + private func readinessPill(ready: Bool) -> some View { let accent = ready ? CSColor.olive : CSColor.terracotta let accentLight = ready ? CSColor.oliveLight : CSColor.terracottaLight diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift index 65d6c1e9..b3b1d8ac 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI // State machine for the first-run wizard: current step index (persisted on every @@ -115,6 +116,11 @@ final class OnboardingViewModel: ObservableObject { @Published private(set) var readiness: CsAgenticReadiness? @Published private(set) var mcpStatus: CsMcpStatusReport? + /// Whether the user dismissed the "set up MCP" prompt shown when no MCP server + /// is configured. Session-only: skipping keeps the readiness step moving without + /// implying an error, and re-opening the wizard offers the prompt again. + @Published private(set) var mcpSetupDismissed = false + // API-key step state. @Published private(set) var providers: [CsProviderOption] = [] @Published var selectedProviderId: String @@ -211,6 +217,23 @@ final class OnboardingViewModel: ObservableObject { mcpStatus = agentStatus.mcpStatus() } + /// Route the user to the live MCP management surface (Settings › Engine) via a + /// one-shot deep-link, then open the standard Settings window. The wizard stays + /// open behind it, so the user can wire a server and return to continue. + func openMcpSettings() { + SettingsDeepLink.pendingSection = .engine + if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) { + // Older selector name kept as a defensive fallback. + _ = NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) + } + } + + /// Dismiss the MCP setup prompt for this session so onboarding proceeds without + /// implying MCP is required. + func dismissMcpSetupPrompt() { + mcpSetupDismissed = true + } + private func loadProvidersIfNeeded() { guard providers.isEmpty else { return } providers = engine.availableProviders() diff --git a/macos/Codescribe/Screens/Settings/EnginePanel.swift b/macos/Codescribe/Screens/Settings/EnginePanel.swift index 0d28bbc5..f3f1916a 100644 --- a/macos/Codescribe/Screens/Settings/EnginePanel.swift +++ b/macos/Codescribe/Screens/Settings/EnginePanel.swift @@ -75,6 +75,9 @@ struct EnginePanel: View { MCPServersSection(model: model) .padding(.top, 26) + + ResetAppDataSection(model: model) + .padding(.top, 30) } .padding(.horizontal, 28) .padding(.vertical, 24) @@ -393,6 +396,89 @@ private struct PermissionMatrixCell: View { } } +// MARK: - Reset app data (destructive privacy action) + +/// Danger-zone control at the foot of the Engine panel: a two-step, destructive +/// "Reset app data" flow. The checkbox opts into removing Keychain API keys too; +/// the button arms a confirmation alert that spells out exactly what disappears +/// before the wipe + relaunch runs. +private struct ResetAppDataSection: View { + @ObservedObject var model: SettingsViewModel + @State private var includeKeys = false + @State private var confirming = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SettingsSectionLabel("Reset app data") + + Text("Permanently delete all local codescribe data on this Mac: " + + "conversation history, transcription history, logs, preferences, " + + "and MCP configuration. This cannot be undone.") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textFaint) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 5) + + Toggle(isOn: $includeKeys) { + Text("Also remove API keys from Keychain") + .font(CSFont.ui(12.5, .medium)) + .foregroundStyle(CSColor.textBody) + } + .toggleStyle(.checkbox) + .padding(.top, 13) + + Button { + confirming = true + } label: { + Text("Reset app data…") + .font(CSFont.ui(12, .semibold)) + .foregroundStyle(CSColor.terracottaLight) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .fill(CSColor.terracotta.opacity(0.14)) + ) + .overlay( + RoundedRectangle(cornerRadius: CSRadius.input, style: .continuous) + .strokeBorder(CSColor.terracotta.opacity(0.30), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .padding(.top, 13) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CSColor.terracotta.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(CSColor.terracotta.opacity(0.16), lineWidth: 1) + ) + .alert("Reset app data?", isPresented: $confirming) { + Button("Cancel", role: .cancel) {} + Button("Reset & Relaunch", role: .destructive) { + model.resetAppData(includeKeys: includeKeys) + } + } message: { + Text(confirmMessage) + } + } + + private var confirmMessage: String { + var text = "This permanently deletes conversation history, transcription " + + "history, logs, preferences, and MCP configuration." + if includeKeys { + text += " Your API keys will also be removed from the Keychain." + } + text += " codescribe will then relaunch as a fresh install." + return text + } +} + // MARK: - Shared section label (mono, muted, wide tracking) — used by all panels. struct SettingsSectionLabel: View { diff --git a/macos/Codescribe/Screens/Settings/MCPServersSection.swift b/macos/Codescribe/Screens/Settings/MCPServersSection.swift index 4c8ad295..ed10b718 100644 --- a/macos/Codescribe/Screens/Settings/MCPServersSection.swift +++ b/macos/Codescribe/Screens/Settings/MCPServersSection.swift @@ -45,11 +45,16 @@ struct MCPServersSection: View { } private var emptyState: some View { - HStack(spacing: 8) { - Text("●").font(CSFont.mono(11, .medium)).foregroundStyle(CSColor.textFaint) - Text("no MCP servers configured — add one below") + VStack(alignment: .leading, spacing: 6) { + Text("No MCP servers yet — this is optional.") + .font(CSFont.ui(12.5, .semibold)) + .foregroundStyle(CSColor.textBody) + Text("MCP servers extend the agent with extra tools like code search, " + + "PR review, or web search. Add your first server below, or skip it " + + "and wire one any time.") .font(CSFont.mono(11, .medium)) .foregroundStyle(CSColor.textFaint) + .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) diff --git a/macos/Codescribe/Screens/Settings/SettingsEngine.swift b/macos/Codescribe/Screens/Settings/SettingsEngine.swift index db497d15..b88d759f 100644 --- a/macos/Codescribe/Screens/Settings/SettingsEngine.swift +++ b/macos/Codescribe/Screens/Settings/SettingsEngine.swift @@ -51,6 +51,10 @@ protocol SettingsEngine { func setFormattingPrompt(content: String) throws func setAssistivePrompt(content: String) throws func resetPromptsToDefaults() throws + + // Destructive privacy action: wipe all local app data (config / logs / + // transcriptions + Application Support store), optionally the Keychain keys. + func resetAppData(includeKeys: Bool) throws } // MARK: - Real engine (UniFFI bridge adapter) @@ -103,6 +107,10 @@ final class RealSettingsEngine: SettingsEngine { try config.setAssistivePrompt(content: content) } func resetPromptsToDefaults() throws { try config.resetPromptsToDefaults() } + + func resetAppData(includeKeys: Bool) throws { + try config.resetAppData(includeKeys: includeKeys) + } } // MARK: - Mock engine (previews) @@ -161,6 +169,7 @@ struct MockSettingsEngine: SettingsEngine { func setFormattingPrompt(content: String) throws {} func setAssistivePrompt(content: String) throws {} func resetPromptsToDefaults() throws {} + func resetAppData(includeKeys: Bool) throws {} } // MARK: - Bridge value helpers diff --git a/macos/Codescribe/Screens/Settings/SettingsView.swift b/macos/Codescribe/Screens/Settings/SettingsView.swift index 40a4b411..35d5b27d 100644 --- a/macos/Codescribe/Screens/Settings/SettingsView.swift +++ b/macos/Codescribe/Screens/Settings/SettingsView.swift @@ -26,7 +26,14 @@ struct SettingsView: View { .frame(minWidth: 880, maxWidth: .infinity, minHeight: 620, maxHeight: .infinity) .background(Self.windowGradient.ignoresSafeArea()) .preferredColorScheme(.dark) - .onAppear { model.refresh() } + .onAppear { + model.refresh() + // Honour a one-shot deep-link (e.g. onboarding routing to MCP setup) + // so the window lands on the requested section instead of the default. + if let target = SettingsDeepLink.consume() { + model.select(target) + } + } } @ViewBuilder diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index bce97baf..9d1e2e5d 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -22,6 +22,44 @@ enum SettingsSection: String, CaseIterable, Identifiable { } } +/// One-shot deep-link target for the Settings window. A surface outside Settings +/// (e.g. the onboarding wizard routing the user to MCP setup) sets this before +/// opening the window; `SettingsView` consumes it once on appear and navigates to +/// the requested section. Nil means "open on the last/default section". +@MainActor +enum SettingsDeepLink { + static var pendingSection: SettingsSection? + + /// Take the pending target (if any), clearing it so a later open is unaffected. + static func consume() -> SettingsSection? { + defer { pendingSection = nil } + return pendingSection + } +} + +/// Clears the app's preferences domain and relaunches a fresh instance. Used by +/// the destructive "Reset app data" flow so restored window frames / SwiftUI scene +/// state do not survive the wipe. The relaunch is deferred via a detached `open` +/// so this instance fully exits first — otherwise AppDelegate's duplicate-instance +/// guard would terminate the freshly-launched copy. +@MainActor +enum AppRelaunch { + static func clearDefaultsAndRelaunch() { + if let bundleId = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: bundleId) + UserDefaults.standard.synchronize() + } + let bundlePath = Bundle.main.bundlePath + let task = Process() + task.launchPath = "/bin/sh" + // `$0` carries the bundle path as a positional arg so it is safely quoted, + // never interpolated into the script string. + task.arguments = ["-c", "sleep 1; open \"$0\"", bundlePath] + try? task.run() + NSApp.terminate(nil) + } +} + /// View-model owning the Settings screen state. Seeded with mock data so the /// #Preview renders standalone; the live app injects `RealSettingsEngine` /// (over the `CodescribeConfig` bridge) + the native permission probe. @@ -292,6 +330,23 @@ final class SettingsViewModel: ObservableObject { } } + // MARK: - Reset app data (destructive privacy action) + + /// Wipe all local app data through the Rust bridge, clear the app's + /// UserDefaults domain, then relaunch so codescribe comes up fresh (first-run + /// wizard from the top). `includeKeys` also removes the Keychain API keys. + /// On failure the error surfaces in `lastError` and nothing is relaunched. + func resetAppData(includeKeys: Bool) { + guard let engine else { return } + do { + try engine.resetAppData(includeKeys: includeKeys) + } catch { + lastError = String(describing: error) + return + } + AppRelaunch.clearDefaultsAndRelaunch() + } + // MARK: - Engine-panel derived values (runtime truth) var activeSTT: String {