Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

73 changes: 58 additions & 15 deletions Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
self.onShowWelcome = onShowWelcome
}

/// UserDefaults key that toggles between the legacy single-form Settings and the redesigned
/// sidebar Settings. Default is `false` (legacy) until the redesign is feature-complete; flip
/// to `true` to dogfood. The key is intentionally kept narrow so a regression can be reverted
/// with a single `defaults write` without redeploying the app.
static let redesignEnabledDefaultsKey = "cotabbySettingsRedesignEnabled"

private var isRedesignEnabled: Bool {
UserDefaults.standard.bool(forKey: Self.redesignEnabledDefaultsKey)
}

/// Shows the settings window, reusing the existing instance if it is already open.
/// Reusing one window avoids subtle state duplication and matches standard macOS settings
/// behavior where there is a single shared preferences surface for the app.
Expand All @@ -55,22 +65,55 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
return
}

let hostingController = NSHostingController(
rootView: SettingsView(
appUpdateManager: appUpdateManager,
launchAtLoginService: launchAtLoginService,
permissionManager: permissionManager,
suggestionSettings: suggestionSettings,
foundationModelAvailabilityService: foundationModelAvailabilityService,
runtimeModel: runtimeModel,
modelDownloadManager: modelDownloadManager,
huggingFaceSearchService: huggingFaceSearchService,
onShowWelcome: onShowWelcome
let hostingController: NSHostingController<AnyView>
let initialFrame: CGRect
let minSize: NSSize
let autosaveName: String

if isRedesignEnabled {
hostingController = NSHostingController(
rootView: AnyView(
SettingsContainerView(
appUpdateManager: appUpdateManager,
launchAtLoginService: launchAtLoginService,
permissionManager: permissionManager,
suggestionSettings: suggestionSettings,
foundationModelAvailabilityService: foundationModelAvailabilityService,
runtimeModel: runtimeModel,
modelDownloadManager: modelDownloadManager,
huggingFaceSearchService: huggingFaceSearchService,
onShowWelcome: onShowWelcome
)
)
)
)
initialFrame = CGRect(x: 0, y: 0, width: 960, height: 680)
minSize = NSSize(width: 820, height: 540)
// Separate autosave name so the redesigned layout starts from its own default frame
// for users who already had a smaller saved frame from the legacy window.
autosaveName = "CotabbySettingsWindowV2"
} else {
hostingController = NSHostingController(
rootView: AnyView(
SettingsView(
appUpdateManager: appUpdateManager,
launchAtLoginService: launchAtLoginService,
permissionManager: permissionManager,
suggestionSettings: suggestionSettings,
foundationModelAvailabilityService: foundationModelAvailabilityService,
runtimeModel: runtimeModel,
modelDownloadManager: modelDownloadManager,
huggingFaceSearchService: huggingFaceSearchService,
onShowWelcome: onShowWelcome
)
)
)
initialFrame = CGRect(x: 0, y: 0, width: 700, height: 620)
minSize = NSSize(width: 640, height: 520)
autosaveName = "CotabbySettingsWindow"
}

let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 700, height: 620),
contentRect: initialFrame,
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
Expand All @@ -80,8 +123,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
window.isReleasedWhenClosed = false
window.level = .normal
window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
window.minSize = NSSize(width: 640, height: 520)
window.setFrameAutosaveName("CotabbySettingsWindow")
window.minSize = minSize
window.setFrameAutosaveName(autosaveName)
window.delegate = self
window.contentViewController = hostingController

Expand Down
90 changes: 90 additions & 0 deletions Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import SwiftUI

/// File overview:
/// Shared chrome for every settings detail pane. Pulls the form styling, scroll wrapping, and
/// optional top-of-pane callout into one place so individual panes stay focused on their rows
/// rather than repeating layout boilerplate.
///
/// Why a callout slot:
/// The legacy settings window puts a single attention banner at the top of the form. The redesign
/// surfaces attention per pane: when a pane is in a degraded state (missing permission, runtime
/// unavailable) we render an inline callout above the form so the actionable surface lives next to
/// the controls that fix it.
struct SettingsPaneScaffold<Content: View>: View {
let callout: SettingsPaneCallout?
@ViewBuilder let content: () -> Content

init(
callout: SettingsPaneCallout? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.callout = callout
self.content = content
}

var body: some View {
ScrollView {
VStack(spacing: 0) {
if let callout {
SettingsCalloutView(callout: callout)
.padding(.horizontal, 20)
.padding(.top, 16)
}
Form {
content()
}
.formStyle(.grouped)
}
}
}
}

struct SettingsPaneCallout: Equatable {
enum Tone {
case warning
case info
}

let tone: Tone
let message: String
}

private struct SettingsCalloutView: View {
let callout: SettingsPaneCallout

var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: iconName)
.foregroundStyle(tint)
.imageScale(.medium)

Text(callout.message)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(tint.opacity(0.12))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(tint.opacity(0.4), lineWidth: 1)
)
}

private var iconName: String {
switch callout.tone {
case .warning: return "exclamationmark.triangle.fill"
case .info: return "info.circle.fill"
}
}

private var tint: Color {
switch callout.tone {
case .warning: return .orange
case .info: return .accentColor
}
}
}
126 changes: 126 additions & 0 deletions Cotabby/UI/Settings/Panes/AboutPaneView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import AppKit
import SwiftUI

/// File overview:
/// "About" detail pane of the redesigned Settings window. Consolidates what used to live across
/// three legacy sections (header, support CTA, uninstall) plus a new Acknowledgements modal that
/// lists the third-party packages Cotabby ships with.
struct AboutPaneView: View {
let appUpdateManager: AppUpdateManager

@State private var isShowingAcknowledgements = false

var body: some View {
SettingsPaneScaffold {
Section { aboutHeader }
Section("Support") { supportRow }
Section("Links") { linksRow }
Section("Uninstall") { uninstallText }
}
.sheet(isPresented: $isShowingAcknowledgements) {
AcknowledgementsView { isShowingAcknowledgements = false }
}
}

@ViewBuilder
private var aboutHeader: some View {
HStack(spacing: 12) {
Image("CotabbyLogo")
.resizable()
.scaledToFit()
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))

VStack(alignment: .leading, spacing: 2) {
Text("Cotabby")
.font(.system(size: 16, weight: .semibold, design: .rounded))

Text("Local macOS AI Autocomplete")
.font(.system(size: 12, design: .rounded))
.foregroundStyle(.secondary)

Text(appVersionText)
.font(.system(size: 11, design: .rounded))
.foregroundStyle(.secondary)
}

Spacer(minLength: 12)

Button("Check for Updates") {
appUpdateManager.checkForUpdates()
}
}
.padding(.vertical, 4)
}

@ViewBuilder
private var supportRow: some View {
LabeledContent {
if let supportURL = URL(string: "https://ko-fi.com/cotabby") {
Link(destination: supportURL) {
Label("Support", systemImage: "heart.fill")
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
} label: {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Text(
"Cotabby is free and open source, maintained by two university students in our free time. "
+ "If it's useful to you, please consider supporting development."
)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}

@ViewBuilder
private var linksRow: some View {
VStack(alignment: .leading, spacing: 8) {
if let repoURL = URL(string: "https://github.com/FuJacob/Cotabby") {
Link(destination: repoURL) {
Label("GitHub Repository", systemImage: "chevron.left.forwardslash.chevron.right")
}
}
if let wikiURL = URL(string: "https://github.com/FuJacob/Cotabby/wiki") {
Link(destination: wikiURL) {
Label("Wiki & Contributor Guide", systemImage: "book")
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Button {
isShowingAcknowledgements = true
} label: {
Label("Acknowledgements", systemImage: "doc.text")
}
.buttonStyle(.link)
}
}

@ViewBuilder
private var uninstallText: some View {
Text(
"Drag Cotabby.app from Applications to the Trash. "
+ "To remove leftover data, also delete ~/Library/Application Support/Cotabby. "
+ "Privacy permissions can only be revoked in System Settings → Privacy & Security."
)
.font(.caption)
.foregroundStyle(.secondary)
}

/// The app bundle is the canonical source for human-facing version text.
private var appVersionText: String {
let shortVersion =
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String

switch (shortVersion, buildNumber) {
case (let shortVersion?, let buildNumber?) where shortVersion != buildNumber:
return "Version \(shortVersion) (\(buildNumber))"
case (let shortVersion?, _):
return "Version \(shortVersion)"
case (_, let buildNumber?):
return "Build \(buildNumber)"
default:
return "Unknown version"
}
}
}
Loading