diff --git a/BusyMirror.xcodeproj/project.pbxproj b/BusyMirror.xcodeproj/project.pbxproj index 0520601..b977040 100644 --- a/BusyMirror.xcodeproj/project.pbxproj +++ b/BusyMirror.xcodeproj/project.pbxproj @@ -410,7 +410,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -421,7 +421,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.8.3; + MARKETING_VERSION = 1.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -440,7 +440,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 27; + CURRENT_PROJECT_VERSION = 28; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BusyMirror/Info.plist; @@ -451,7 +451,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.8.3; + MARKETING_VERSION = 1.9.0; PRODUCT_BUNDLE_IDENTIFIER = com.cqrenet.BusyMirror; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/BusyMirror/ContentView.swift b/BusyMirror/ContentView.swift index 2ce2cee..87e5f1b 100644 --- a/BusyMirror/ContentView.swift +++ b/BusyMirror/ContentView.swift @@ -21,6 +21,27 @@ enum ScheduleMode: String, CaseIterable, Identifiable { } } +enum SidebarSection: String, CaseIterable, Identifiable, Hashable { + case routes, schedule, log + var id: String { rawValue } + + var title: String { + switch self { + case .routes: return "Routes" + case .schedule: return "Schedule" + case .log: return "Activity Log" + } + } + + var icon: String { + switch self { + case .routes: return "arrow.triangle.branch" + case .schedule: return "clock" + case .log: return "terminal" + } + } +} + struct Route: Identifiable, Hashable, Codable { let id = UUID() @@ -70,6 +91,8 @@ struct ContentView: View { @State private var sourceID: String? = nil @State private var targetIDs = Set() @State private var routes: [Route] = [] + @State private var selectedSection: SidebarSection? = .routes + @State private var manualSelectionExpanded = false @AppStorage("daysForward") private var daysForward: Int = 7 @AppStorage("daysBack") private var daysBack: Int = 1 @AppStorage("mergeGapHours") private var mergeGapHours: Int = 0 @@ -383,71 +406,6 @@ struct ContentView: View { targetIDs.remove(sid) } - // MARK: - Extracted UI sections to simplify type-checking - private var selectedSourceName: String { - guard calendars.indices.contains(sourceIndex) else { return "Not selected" } - return calLabel(calendars[sourceIndex]) - } - - @ViewBuilder - private func statusPill( - _ title: String, - systemImage: String, - fill: Color, - foreground: Color = .white - ) -> some View { - Label(title, systemImage: systemImage) - .font(.caption.weight(.bold)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule(style: .continuous) - .fill(fill) - ) - .foregroundStyle(foreground) - } - - @ViewBuilder - private func panelCard( - title: String, - subtitle: String? = nil, - symbol: String, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .center, spacing: 8) { - Image(systemName: symbol) - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - .frame(width: 22, height: 22) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(.black) - ) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.system(.headline, design: .rounded).weight(.bold)) - if let subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - } - content() - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.34), lineWidth: 1.2) - ) - } - private func addRouteFromCurrentSelection() { guard let sid = sourceID, !targetIDs.isEmpty else { return } let r = Route(sourceID: sid, @@ -609,62 +567,209 @@ struct ContentView: View { } @ViewBuilder - private func optionsSection() -> some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 10) { - Text("Mirroring defaults, filters, and work hours moved to Preferences.") - .font(.footnote) - .foregroundStyle(.secondary) - SettingsLink { - Text("Open Preferences…") + private var sidebarView: some View { + List(SidebarSection.allCases, selection: $selectedSection) { section in + Label { + HStack { + Text(section.title) + if section == .routes && !routes.isEmpty { + Spacer() + Text("\(routes.count)") + .font(.caption) + .foregroundStyle(.secondary) + } } - Spacer(minLength: 0) + } icon: { + Image(systemName: section.icon) + } + .tag(section as SidebarSection?) + } + .navigationSplitViewColumnWidth(min: 180, ideal: 200) + } + + @ViewBuilder + private var accessNeededView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.fill") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("Calendar Permission Needed") + .font(.title3.weight(.semibold)) + Text("BusyMirror needs access to read and mirror your events.") + .font(.subheadline) + .foregroundStyle(.secondary) + Button("Request Calendar Access") { + requestAccess() + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle("BusyMirror") + } + + @ViewBuilder + private var routesDetailView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + DisclosureGroup("Manual Selection", isExpanded: $manualSelectionExpanded) { + VStack(alignment: .leading, spacing: 12) { + CalendarsSectionView( + calendars: calendars, + sourceIndex: $sourceIndex, + targetSelections: $targetSelections, + targetIDs: $targetIDs, + isRunning: isRunning + ) + HStack { + Spacer() + Button("Add Route from Selection", action: addRouteFromCurrentSelection) + .buttonStyle(.borderedProminent) + .disabled(isRunning || sourceID == nil || targetIDs.isEmpty) + } + } + .padding(.top, 8) + } + .font(.headline) + + Divider() + + RoutesSectionView( + routes: $routes, + calendars: calendars, + isRunning: isRunning, + titlePrefix: titlePrefix, + placeholderTitle: placeholderTitle, + canAddRoute: sourceID != nil && !targetIDs.isEmpty, + onAddRoute: addRouteFromCurrentSelection + ) + } + .padding(20) + } + } + + @ViewBuilder + private var scheduleDetailView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + Text("Automatic Sync") + .font(.title2.weight(.bold)) + Text("Runs on its own when your calendars change — no schedule to manage.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: appController.autoSyncArmed ? "checkmark.circle.fill" : "circle.dashed") + .foregroundStyle(appController.autoSyncArmed ? .green : .secondary) + .font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text(appController.autoSyncArmed ? "Auto-sync active" : "Auto-sync not active") + .font(.subheadline.weight(.semibold)) + Text(appController.autoSyncArmed ? "Watching for calendar changes, wake, and a 30-min fallback" : "Add a saved route below to enable") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(14) + Divider() + HStack(spacing: 12) { + Image(systemName: "clock") + .foregroundStyle(.secondary) + .font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text("Last sync") + .font(.subheadline.weight(.semibold)) + Text(appController.lastRunStatusText) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(14) + } + .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color(nsColor: .controlBackgroundColor))) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(Color.primary.opacity(0.12))) + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Manual Schedule (optional)") + .font(.subheadline.weight(.semibold)) + Text("Add a fixed-time schedule on top of auto-sync — a guaranteed full resync at a specific hour regardless of what changed.") + .font(.footnote) + .foregroundStyle(.secondary) + ScheduleSectionView( + scheduleMode: Binding( + get: { scheduleMode }, + set: { newValue in + scheduleMode = newValue + scheduleWeekdaysOnly = (newValue == .weekdays) + } + ), + scheduleIntervalHours: $scheduleIntervalHours, + scheduleHour: $scheduleHour, + scheduleMinute: $scheduleMinute, + isRunning: isRunning, + routesEmpty: routes.isEmpty, + hasInstalledSchedule: hasInstalledSchedule, + scheduleSummary: scheduleSummary, + onInstall: installSchedule, + onRemove: removeSchedule, + onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) }, + onScheduleTimeChanged: clampScheduleTime + ) + } + } + .padding(20) + } + } + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup { + Picker("Mode", selection: $writeEnabled) { + Text("Dry Run").tag(false) + Text("Write").tag(true) + } + .pickerStyle(.segmented) + .frame(width: 150) + .disabled(isRunning) + + HStack(spacing: 6) { + Circle() + .fill(isRunning ? Color.orange : (appController.lastRunFailed ? Color.red : Color.secondary)) + .frame(width: 7, height: 7) + Text(progressText ?? (isRunning ? "Running…" : (hasAccess ? "\(calendars.count) calendars" : "No access"))) + .font(.caption) + .foregroundStyle(.secondary) } - Divider() + if isRunning { + Button("Cancel") { cancelMirror() } + } else { + Button { + startMirrorNow() + } label: { + Label("Sync Now", systemImage: "arrow.triangle.2.circlepath") + } + .disabled(!canRunMirrorNow) + } - Toggle("Write to calendars (disable for Dry-Run)", isOn: $writeEnabled) - .disabled(isRunning) - - HStack(spacing: 10) { + Menu { Button("Export Settings…") { exportSettings() } Button("Import Settings…") { importSettings() } + Divider() Button("Reveal Log File") { NSWorkspace.shared.activateFileViewerSelecting([AppLogStore.logFileURL]) } - Spacer(minLength: 0) - } - - Divider() - - ScheduleSectionView( - scheduleMode: Binding( - get: { scheduleMode }, - set: { newValue in - scheduleMode = newValue - scheduleWeekdaysOnly = (newValue == .weekdays) - } - ), - scheduleIntervalHours: $scheduleIntervalHours, - scheduleHour: $scheduleHour, - scheduleMinute: $scheduleMinute, - isRunning: isRunning, - routesEmpty: routes.isEmpty, - hasInstalledSchedule: hasInstalledSchedule, - scheduleSummary: scheduleSummary, - onInstall: installSchedule, - onRemove: removeSchedule, - onRevealLaunchAgent: { NSWorkspace.shared.activateFileViewerSelecting([launchAgentURL]) }, - onScheduleTimeChanged: clampScheduleTime - ) - - HStack(spacing: 10) { + Divider() Button("Cleanup Placeholders") { if writeEnabled { - // Real delete: ask for confirmation first confirmCleanup = true } else { - // Dry-run: run without confirmation Task { if routes.isEmpty { await runCleanupForCurrentSelection() @@ -677,168 +782,38 @@ struct ContentView: View { } } .disabled(isRunning) - .buttonStyle(.bordered) - Button("Refresh Calendars") { reloadCalendars(forceResetStore: true) } .disabled(isRunning) - .buttonStyle(.bordered) - - Spacer(minLength: 0) + Divider() + Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") { + requestAccess() + } + .disabled(isRunning) + } label: { + Image(systemName: "ellipsis.circle") } } } var body: some View { - GeometryReader { proxy in - let compactLayout = proxy.size.width < 1220 - ZStack { - Color(nsColor: .underPageBackgroundColor) - .ignoresSafeArea() - - ScrollView { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 3) { - Text("BusyMirror") - .font(.system(size: 30, weight: .bold, design: .rounded)) - Text("Mirror availability across calendars") - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - HStack(spacing: 8) { - if hasAccess { - statusPill("\(calendars.count) calendars", systemImage: "calendar", fill: .black) - } else { - statusPill("No access", systemImage: "lock.fill", fill: .red) - } - Button { - guard !isRunning else { return } - writeEnabled.toggle() - log("Mode: \(writeEnabled ? "WRITE" : "DRY-RUN")") - } label: { - statusPill(writeEnabled ? "WRITE" : "DRY RUN", systemImage: writeEnabled ? "pencil" : "eye", fill: writeEnabled ? .red : .black) - } - .buttonStyle(.plain) - .help("Click to toggle write mode.") - if isRunning { - statusPill("RUNNING", systemImage: "arrow.triangle.2.circlepath", fill: .orange) - } - if let progressText { - Text(progressText) - .font(.caption) - .foregroundStyle(.secondary) - } - if isRunning { - Button("Cancel") { - cancelMirror() - } - .buttonStyle(.bordered) - .controlSize(.large) - } else { - Button("Mirror Now") { - startMirrorNow() - } - .disabled(!canRunMirrorNow) - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - Button(hasAccess ? "Recheck Permission" : "Request Calendar Access") { - requestAccess() - } - .disabled(isRunning) - .buttonStyle(.bordered) - } - } - - if !hasAccess { - panelCard( - title: "Calendar Permission Needed", - subtitle: "BusyMirror needs access to read and mirror your events.", - symbol: "lock.fill" - ) { - VStack(alignment: .leading, spacing: 10) { - Text("Calendar access is not granted yet. Use the button above to continue.") - .foregroundStyle(.secondary) - Button("Request Calendar Access") { - requestAccess() - } - .buttonStyle(.borderedProminent) - } - } - } else if compactLayout { - panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") { - CalendarsSectionView( - calendars: calendars, - sourceIndex: $sourceIndex, - targetSelections: $targetSelections, - targetIDs: $targetIDs, - isRunning: isRunning - ) - } - panelCard(title: "Actions & Schedule", subtitle: "Export, cleanup, and manual scheduling", symbol: "slider.horizontal.3") { - optionsSection() - } - panelCard(title: "Routes", subtitle: "\(routes.count) configured", symbol: "arrow.triangle.branch") { - RoutesSectionView( - routes: $routes, - calendars: calendars, - isRunning: isRunning, - titlePrefix: titlePrefix, - placeholderTitle: placeholderTitle, - canAddRoute: sourceID != nil && !targetIDs.isEmpty, - onAddRoute: addRouteFromCurrentSelection - ) - } - panelCard(title: "Activity Log", subtitle: "Latest events and dry-run output", symbol: "terminal") { - LogSectionView(logText: logText) - } - } else { - HStack(alignment: .top, spacing: 14) { - VStack(spacing: 12) { - panelCard(title: "Calendars", subtitle: "Source: \(selectedSourceName)", symbol: "calendar") { - CalendarsSectionView( - calendars: calendars, - sourceIndex: $sourceIndex, - targetSelections: $targetSelections, - targetIDs: $targetIDs, - isRunning: isRunning - ) - } - panelCard(title: "Actions & Schedule", subtitle: "Export, cleanup, and manual scheduling", symbol: "slider.horizontal.3") { - optionsSection() - } - } - .frame(width: 430, alignment: .topLeading) - .fixedSize(horizontal: false, vertical: true) - - VStack(spacing: 12) { - panelCard(title: "Routes", subtitle: "\(routes.count) configured", symbol: "arrow.triangle.branch") { - RoutesSectionView( - routes: $routes, - calendars: calendars, - isRunning: isRunning, - titlePrefix: titlePrefix, - placeholderTitle: placeholderTitle, - canAddRoute: sourceID != nil && !targetIDs.isEmpty, - onAddRoute: addRouteFromCurrentSelection - ) - } - panelCard(title: "Activity Log", subtitle: "Latest events and dry-run output", symbol: "terminal") { - LogSectionView(logText: logText) - } - } - .frame(maxWidth: .infinity) - } - } + NavigationSplitView { + sidebarView + } detail: { + Group { + if !hasAccess { + accessNeededView + } else { + switch selectedSection ?? .routes { + case .routes: routesDetailView + case .schedule: scheduleDetailView + case .log: LogSectionView(logText: $logText) } - .padding(18) - .frame(maxWidth: 1480, alignment: .topLeading) - .frame(minHeight: proxy.size.height, alignment: .topLeading) } } + .navigationTitle((selectedSection ?? .routes).title) + .toolbar { toolbarContent } } .confirmationDialog( "Delete mirrored placeholders?", @@ -894,6 +869,11 @@ struct ContentView: View { .onChange(of: titlePrefix) { _ in saveSettingsToDefaults() } .onChange(of: placeholderTitle) { _ in saveSettingsToDefaults() } .onChange(of: autoDeleteMissing) { _ in saveSettingsToDefaults() } + .onChange(of: filterByWorkHours) { _ in saveSettingsToDefaults() } + .onChange(of: workHoursStart) { _ in saveSettingsToDefaults() } + .onChange(of: workHoursEnd) { _ in saveSettingsToDefaults() } + .onChange(of: excludedTitleFiltersRaw) { _ in saveSettingsToDefaults() } + .onChange(of: excludedOrganizerFiltersRaw) { _ in saveSettingsToDefaults() } .onChange(of: sourceIndex) { newValue in // Track selected source by persistent ID and ensure it is not a target if newValue < calendars.count { sourceID = calendars[newValue].calendarIdentifier } @@ -1211,7 +1191,7 @@ struct ContentView: View { if granted { // Reinitialize the store after permission changes to ensure sources load store = EKEventStore() - reloadCalendars() + reloadCalendars(pruneRoutes: false) } else { appController.clearPendingSyncRequest() } @@ -1225,7 +1205,7 @@ struct ContentView: View { if granted { // Reinitialize the store after permission changes to ensure sources load store = EKEventStore() - reloadCalendars() + reloadCalendars(pruneRoutes: false) } else { appController.clearPendingSyncRequest() } @@ -1236,7 +1216,7 @@ struct ContentView: View { } @MainActor - func reloadCalendars(forceResetStore: Bool = false) { + func reloadCalendars(forceResetStore: Bool = false, pruneRoutes: Bool = true) { if forceResetStore { // EventKit can cache stale/inactive calendars; recreate store for a hard refresh. // Unregister the existing EKEventStoreChanged observer first — it targets the @@ -1246,15 +1226,25 @@ struct ContentView: View { } let fetched = store.calendars(for: .event) calendars = sortedCalendars(fetched) - let pruned = pruneStaleCalendarReferences() + // A freshly-created EKEventStore (right after a permission grant) can report an + // incomplete calendar list for a moment before remote sources (Exchange, CalDAV) + // finish hydrating — pruning against that snapshot wrongly concludes a route's + // calendar is gone and deletes it. Confirmed happening on a real install: routes + // dropped on the very next launch, only saved by the legacy routes.v1 fallback. + // requestAccess() passes pruneRoutes: false for exactly that reload; the + // EKEventStoreChanged-triggered reload and an explicit "Refresh Calendars" click + // (a warm, already-stable store) still prune as before. + if pruneRoutes { + let pruned = pruneStaleCalendarReferences() + if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource { + log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).") + saveSettingsToDefaults() + } + } // Initialize IDs on first load if sourceID == nil, let first = calendars.first { sourceID = first.calendarIdentifier } // Rebuild index-based selections from stored IDs rebuildSelectionsFromIDs() - if pruned.removedTargets > 0 || pruned.droppedRoutes > 0 || pruned.trimmedRoutes > 0 || pruned.removedSource { - log("Pruned stale calendars: source removed=\(pruned.removedSource ? "yes" : "no"), selected targets removed=\(pruned.removedTargets), routes dropped=\(pruned.droppedRoutes), routes trimmed=\(pruned.trimmedRoutes).") - saveSettingsToDefaults() - } log("Loaded \(calendars.count) calendars.") // Register for live calendar-store changes the first time we have access, // so the calendar list stays up-to-date without pressing "Refresh". @@ -1517,6 +1507,15 @@ struct ContentView: View { } catch { log("✗ Failed to save settings: \(error.localizedDescription)") } + // Keep the legacy routes-only backup current (not just a frozen + // historical snapshot) so it's a real safety net: if settings.v2 ever + // comes back with an empty `routes` array again (the exact bug fixed + // by reloadCalendars(pruneRoutes:) above), recovery restores the + // actual current routes, not whatever they were the first time this + // key was ever written. + if !routes.isEmpty, let routesData = try? JSONEncoder().encode(routes) { + UserDefaults.standard.set(routesData, forKey: legacyRoutesDefaultsKey) + } } private func loadSettingsFromDefaults() { diff --git a/BusyMirror/LogSectionView.swift b/BusyMirror/LogSectionView.swift index 9145cb4..59b80f7 100644 --- a/BusyMirror/LogSectionView.swift +++ b/BusyMirror/LogSectionView.swift @@ -1,16 +1,107 @@ import SwiftUI -/// Read-only activity log viewer — extracted from ContentView. +/// Activity log viewer — extracted from ContentView. Renders `logText` +/// (plain newline-separated lines, no timestamps — those only exist in the +/// persistent file log written by AppLogStore) as readable rows with a +/// status icon per line instead of a raw monospaced dump, plus a search +/// filter. "Clear" only empties this in-memory view; the file log on disk +/// (Reveal Log File, in the toolbar's overflow menu) is untouched. struct LogSectionView: View { - let logText: String + @Binding var logText: String + @State private var searchText = "" + + private enum LineKind { + case ok, error, warning, info + + var symbol: String { + switch self { + case .ok: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .info: return "circle.fill" + } + } + var color: Color { + switch self { + case .ok: return .green + case .error: return .red + case .warning: return .orange + case .info: return .secondary + } + } + } + + private struct Line: Identifiable { + let id: Int + let text: String + let kind: LineKind + } + + private var lines: [Line] { + logText.split(separator: "\n", omittingEmptySubsequences: false) + .enumerated() + .map { idx, raw in + let text = String(raw) + let kind: LineKind + if text.hasPrefix("✓") { kind = .ok } + else if text.hasPrefix("✗") { kind = .error } + else if text.contains("SKIP") || text.contains("WARN") { kind = .warning } + else { kind = .info } + return Line(id: idx, text: text, kind: kind) + } + .filter { !$0.text.isEmpty } + .filter { searchText.isEmpty || $0.text.localizedCaseInsensitiveContains(searchText) } + } var body: some View { - TextEditor(text: Binding(get: { logText }, set: { _ in })) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 180) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Text("Activity Log") + .font(.title2.weight(.bold)) + Spacer() + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search log", text: $searchText) + .textFieldStyle(.plain) + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(width: 180) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + Button("Clear") { logText = "" } + .buttonStyle(.bordered) + .disabled(logText.isEmpty) + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines) { line in + HStack(alignment: .top, spacing: 10) { + Image(systemName: line.kind.symbol) + .font(.system(size: 10)) + .foregroundStyle(line.kind.color) + .padding(.top, 3) + Text(line.text) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.vertical, 5) + Divider().opacity(0.4) + } + } + } + .frame(maxHeight: .infinity) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(Color.secondary.opacity(0.22)) ) + } + .padding(20) } } diff --git a/BusyMirror/MenuBarSupport.swift b/BusyMirror/MenuBarSupport.swift index 09ce4d8..17c5cb0 100644 --- a/BusyMirror/MenuBarSupport.swift +++ b/BusyMirror/MenuBarSupport.swift @@ -258,9 +258,14 @@ struct BusyMirrorMenuBarView: View { Text("BusyMirror") .font(.headline) - Text(appController.isSyncing ? "Sync in progress." : appController.lastRunStatusText) - .font(.subheadline) - .foregroundStyle(appController.lastRunFailed ? .red : .secondary) + HStack(spacing: 6) { + Circle() + .fill(appController.isSyncing ? Color.orange : (appController.lastRunFailed ? Color.red : Color.green)) + .frame(width: 7, height: 7) + Text(appController.isSyncing ? "Sync in progress." : appController.lastRunStatusText) + .font(.subheadline) + .foregroundStyle(appController.lastRunFailed ? .red : .secondary) + } Text(appController.autoSyncArmed ? "Auto-sync: watching for calendar changes." : "Auto-sync: not active (add a saved route to enable).") .font(.caption) @@ -268,29 +273,39 @@ struct BusyMirrorMenuBarView: View { Divider() - Button(appController.isSyncing ? "Syncing…" : "Sync Now") { + Button { let shouldOpenWindow = !appController.isMainWindowVisible appController.requestSync() if shouldOpenWindow { appController.openMainWindow(using: openWindow) } + } label: { + Label(appController.isSyncing ? "Syncing…" : "Sync Now", systemImage: "arrow.triangle.2.circlepath") } .disabled(appController.isSyncing) - Button("Open BusyMirror") { + Button { appController.openMainWindow(using: openWindow) + } label: { + Label("Open BusyMirror", systemImage: "macwindow") } - Button("Preferences…") { + Button { NSApp.activate(ignoringOtherApps: true) openSettings() + } label: { + Label("Preferences…", systemImage: "gearshape") } + .keyboardShortcut(",", modifiers: .command) Divider() - Button("Quit BusyMirror") { + Button { NSApp.terminate(nil) + } label: { + Label("Quit BusyMirror", systemImage: "power") } + .keyboardShortcut("q", modifiers: .command) } .padding(12) .frame(width: 240, alignment: .leading) diff --git a/BusyMirror/RoutesSectionView.swift b/BusyMirror/RoutesSectionView.swift index a55597b..1099641 100644 --- a/BusyMirror/RoutesSectionView.swift +++ b/BusyMirror/RoutesSectionView.swift @@ -15,6 +15,8 @@ struct RoutesSectionView: View { let canAddRoute: Bool let onAddRoute: () -> Void + @State private var expandedRouteID: UUID? + private static let intFormatter: NumberFormatter = { let f = NumberFormatter() f.minimum = 0 @@ -56,36 +58,51 @@ struct RoutesSectionView: View { @ViewBuilder private func routeCard(for routeBinding: Binding) -> some View { let route = routeBinding.wrappedValue + let isExpanded = expandedRouteID == route.id VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 8) { - sourceSummaryView(for: route) - targetSummaryView(for: route) + Button { + expandedRouteID = isExpanded ? nil : route.id + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .frame(width: 12) + .padding(.top, 3) + VStack(alignment: .leading, spacing: 8) { + sourceSummaryView(for: route) + targetSummaryView(for: route) + } + Spacer(minLength: 12) } - Spacer(minLength: 12) - Button(role: .destructive) { - routes.removeAll { $0.id == route.id } - } label: { Text("Remove") } + .contentShape(Rectangle()) } + .buttonStyle(.plain) - Divider() + if isExpanded { + Divider() - Toggle("Private", isOn: routeBinding.privacy) - .help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).") - Toggle("Copy description", isOn: routeBinding.copyNotes) - .disabled(isRunning || route.privacy) - .help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.") - Toggle("Sync reminders", isOn: routeBinding.syncReminders) - .disabled(isRunning) - .help("If ON, copy the source event’s reminders/alarms into the placeholder.") - Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay) - .disabled(isRunning) - .help("Mirror all-day events for this source.") + Toggle("Private", isOn: routeBinding.privacy) + .help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).") + Toggle("Copy description", isOn: routeBinding.copyNotes) + .disabled(isRunning || route.privacy) + .help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.") + Toggle("Sync reminders", isOn: routeBinding.syncReminders) + .disabled(isRunning) + .help("If ON, copy the source event’s reminders/alarms into the placeholder.") + Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay) + .disabled(isRunning) + .help("Mirror all-day events for this source.") - HStack(spacing: 16) { - mergeGapField(for: routeBinding) - overlapPicker(for: routeBinding) - Spacer(minLength: 0) + HStack(spacing: 16) { + mergeGapField(for: routeBinding) + overlapPicker(for: routeBinding) + Spacer(minLength: 0) + Button(role: .destructive) { + routes.removeAll { $0.id == route.id } + } label: { Text("Remove Route") } + } } } .padding(12) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f07ca..e0ec014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to BusyMirror will be documented in this file. +## [1.9.0] - 2026-08-27 + +### Added +- **Sidebar navigation redesign.** Main window is now a `NavigationSplitView`: a sidebar (Routes / Schedule / Activity Log, with a route-count badge) replaces the old 2×2 grid of panel cards. Primary actions (Dry Run/Write, sync status, Sync Now) moved to the window toolbar; Export/Import/Reveal Log/Cleanup Placeholders/Refresh Calendars/Recheck Permission moved into a toolbar overflow menu. +- **Routes** are now a real collapsed-by-default list — click a row to expand its editor (Private, Copy description, Sync reminders, Mirror all-day, merge gap, overlap, Remove) instead of every route always showing every field. The manual source/target picker lives in a collapsible "Manual Selection" section above the list. +- **Schedule** view leads with live auto-sync status (armed/watching, last sync) instead of a bare `launchd` form; the manual fixed-time schedule is still there, demoted to an explicitly optional section underneath — matches what it actually became once 1.7.0 shipped event-driven sync. +- **Activity Log** is now readable rows (status icon + text) instead of a monospaced text dump, with a search filter and a Clear button for the in-app view (the persistent file log on disk is untouched). +- Menu bar dropdown got icons on every item and real ⌘,/⌘Q keyboard shortcuts on Preferences/Quit. +- Design explored first as a mockup (light+dark) before any SwiftUI changes; direction confirmed before implementing. + +### Fixed +- **Routes silently dropped on most launches, masked by a legacy fallback.** `reloadCalendars()` pruned routes referencing calendars not in the current EventKit fetch — but the fetch immediately following a permission grant (a freshly-created `EKEventStore`) can under-report calendars for a moment, especially remote accounts (Exchange/CalDAV), before they finish loading. Every affected launch wrongly deleted real routes, and only kept working because of a legacy `routes.v1` backup key kept rescuing them — a backup that itself was frozen at whatever it held the first time it was ever written, since nothing updated it afterward. Any real route edit made in a session that hit this bug would have been silently lost on the next launch. Fixed by skipping the prune specifically on the post-permission-grant reload (`reloadCalendars(pruneRoutes: false)`) — the EKEventStoreChanged-triggered reload and an explicit "Refresh Calendars" click, both against an already-warm store, still prune as before — and by keeping the `routes.v1` backup itself current on every save instead of frozen. Confirmed via `--status`/reading the UserDefaults plist directly on a real install: routes stopped disappearing across repeated launches. ([ContentView.swift](BusyMirror/ContentView.swift)) + ## [1.8.3] - 2026-08-27 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 689cec1..882a903 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -17,6 +17,8 @@ - 1.8.0: **V2 UI polish.** `ContentView.swift` split from ~2000 lines into focused view files — `CalendarsSectionView`, `RoutesSectionView`, `ScheduleSectionView`, `LogSectionView` (state stays owned by `ContentView`/`@AppStorage`; these are view-layer extractions, not a full MVVM rewrite — the settings-persistence model didn't need touching and touching it is exactly how the 1.6.0/1.6.1 data-loss bug happened). A preferences window hosting the pure `@AppStorage`-backed defaults (time window, privacy/mirroring defaults, work hours, skip filters) that used to live in the main window; this surfaced a latent bug — moving fields out of `ContentView` meant they stopped re-triggering `saveSettingsToDefaults()`, so on next launch `applySnapshot` would have silently reverted a preference changed in the new window using the stale blob. Fixed by splitting launch-time restore (`restoreLaunchState`, routes/selections only, since @AppStorage fields already self-restore) from Import's full restore (`applySnapshot`, unchanged). Menu bar icon now reflects idle/syncing/error state, and the dropdown shows last-sync time/result and whether auto-sync is armed. - 1.8.1: the preferences window (originally a `Settings{}` scene) didn't actually open — `LSUIElement` apps get no standard app menu, so Cmd+,/`SettingsLink` had no menu to hook into. Replaced with a plain `Window` opened via `openWindow(id:)`, plus a "Preferences…" menu bar item as a second entry point. - 1.8.2: **standard app, not menu-bar-only.** Removed `LSUIElement` — Dock icon, Cmd+Tab, standard app menu (Cmd+Q) are back. The accessory-app design (no Dock icon) turned out to make the app hard to quit reliably, which blocked replacing the bundle during updates. Menu bar extra stays as a secondary quick-access point. +- 1.8.3: restored the real `Settings{}` scene for Preferences now that the app menu exists to host it (1.8.1's `Window`+`openWindow` workaround was never swapped back after 1.8.2 removed `LSUIElement`) — Cmd+, and the app's own "Preferences…" menu item work again, not just a button and a menu-bar-dropdown item. +- 1.9.0: **sidebar-navigation redesign** (Routes / Schedule / Activity Log via `NavigationSplitView`, toolbar actions, collapsed-by-default route list, structured log view, refreshed menu bar) — explored as a mockup first, direction confirmed before implementing. Also fixed a real bug the redesign work surfaced: routes were being silently dropped on most launches (a freshly-created `EKEventStore` right after a permission grant can under-report calendars for a moment, especially remote accounts, so pruning against it wrongly deleted routes referencing calendars that were actually still there) — only ever recovered by a legacy backup key that was itself frozen at its first-ever value. Fixed the prune timing and made the backup stay current. ## Next 1. **MCP server (thin external wrapper, not embedded in the app).** So agents driving BusyMirror don't have to shell out to the CLI and regex-parse log lines. A small standalone stdio-transport script (Node/Python) maps MCP tools 1:1 onto the CLI's `--json` output: `list_calendars`, `list_routes`, `run_route`, `run_saved_routes`, `get_status`. Deliberately kept out of the Swift app itself — no MCP SDK dependency in the signed binary (AGENTS.md's zero-external-packages rule stays intact), and MCP hosts spawn server processes on demand anyway, so there's no need for the app to run one persistently.