diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0cc4ed4..60f26650 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,13 +88,13 @@ jobs: fi if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then - SWIFT_FILES="$(find Sources -name '*.swift' -print | sort)" + SWIFT_FILES="$(find Sources Tests -name '*.swift' -print | sort)" else - SWIFT_FILES="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | grep '^Sources/.*\.swift$' || true)" + SWIFT_FILES="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | grep '^\(Sources\|Tests\)/.*\.swift$' || true)" fi if [ -z "$SWIFT_FILES" ]; then - echo "No changed Swift source files to lint." + echo "No changed Swift files to lint." exit 0 fi @@ -105,6 +105,41 @@ jobs: swiftlint lint --config .swiftlint-ci.yml "$file" done + - name: Run swift-format + run: | + set -euo pipefail + + if ! swift format lint --help >/dev/null 2>&1; then + echo "swift format is not available in the selected toolchain" + exit 1 + fi + + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + else + BASE_SHA="${{ github.event.before }}" + HEAD_SHA="${{ github.sha }}" + fi + + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + SWIFT_FILES="$(find Sources Tests -name '*.swift' -print | sort)" + else + SWIFT_FILES="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | grep '^\(Sources\|Tests\)/.*\.swift$' || true)" + fi + + if [ -z "$SWIFT_FILES" ]; then + echo "No changed Swift files to format-check." + exit 0 + fi + + FILE_COUNT="$(printf '%s\n' "$SWIFT_FILES" | sed '/^$/d' | wc -l | tr -d ' ')" + printf 'Format-checking %s changed Swift files\n' "$FILE_COUNT" + printf '%s\n' "$SWIFT_FILES" | while IFS= read -r file; do + [ -n "$file" ] || continue + swift format lint "$file" + done + - name: Run tests run: swift test diff --git a/Package.swift b/Package.swift index 3742e4b0..a9683542 100644 --- a/Package.swift +++ b/Package.swift @@ -67,7 +67,7 @@ let package = Package( path: "Sources/Bugbook", exclude: ["MCP"], swiftSettings: [ - .define("BUGBOOK_BROWSER_WEBKIT") + .define("BUGBOOK_BROWSER_CHROMIUM") ], linkerSettings: [ .linkedFramework("AppKit"), diff --git a/Sources/Bugbook/App/AppState.swift b/Sources/Bugbook/App/AppState.swift index 53945f33..13ddc760 100644 --- a/Sources/Bugbook/App/AppState.swift +++ b/Sources/Bugbook/App/AppState.swift @@ -71,6 +71,10 @@ enum SidebarContextType: Equatable { var isRecording: Bool = false var recordingBlockId: UUID? + /// Active meeting page recording session (independent of pane). + var activeMeetingSession: ActiveMeetingSession? + /// If set, the next meeting page loaded at this path should auto-start recording. + var pendingAutoRecordPath: String? var flashcardReviewOpen: Bool = false var showShortcutOverlay: Bool = false @ObservationIgnored lazy var aiThreadStore = AiThreadStore() diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift index 4dd9101b..1555fe21 100644 --- a/Sources/Bugbook/App/BugbookApp.swift +++ b/Sources/Bugbook/App/BugbookApp.swift @@ -1,7 +1,8 @@ import SwiftUI import Sentry import os -#if BUGBOOK_BROWSER_CHROMIUM +import UserNotifications +#if BUGBOOK_BROWSER_CHROMIUM && canImport(ChromiumBridge) import ChromiumBridge #endif @@ -12,22 +13,7 @@ struct BugbookApp: App { var body: some Scene { WindowGroup { - ContentView() - .tint(Color.fallbackAccent) - .overlay(alignment: .topTrailing) { - if AppEnvironment.isDev { - Text("DEV") - .font(.system(size: 9, weight: .bold, design: .monospaced)) - .foregroundStyle(.white) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(Color.orange.opacity(0.85)) - .clipShape(.capsule) - .padding(.top, 4) - .padding(.trailing, 72) - .allowsHitTesting(false) - } - } + BugbookWindowRootView() } .windowStyle(.hiddenTitleBar) .defaultSize(width: 1100, height: 700) @@ -262,8 +248,94 @@ struct BugbookApp: App { } } -class AppDelegate: NSObject, NSApplicationDelegate { +private struct BugbookWindowRootView: View { + let bootstrap: ContentViewBootstrap? + + init(bootstrap: ContentViewBootstrap? = nil) { + self.bootstrap = bootstrap + } + + var body: some View { + ContentView(bootstrap: bootstrap) + .tint(Color.fallbackAccent) + .overlay(alignment: .topTrailing) { + if AppEnvironment.isDev { + Text("DEV") + .font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.85)) + .clipShape(.capsule) + .padding(.top, 4) + .padding(.trailing, 72) + .allowsHitTesting(false) + } + } + } +} + +@MainActor +final class DetachedWindowManager { + static let shared = DetachedWindowManager() + + private var windows: [UUID: NSWindow] = [:] + private var delegates: [UUID: DetachedWindowDelegate] = [:] + + func openWindow( + title: String, + bootstrap: ContentViewBootstrap, + size: CGSize = CGSize(width: 1100, height: 700) + ) { + let windowID = UUID() + let contentView = BugbookWindowRootView(bootstrap: bootstrap) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier(windowID.uuidString) + window.title = title + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unifiedCompact + window.isReleasedWhenClosed = false + window.contentViewController = NSHostingController(rootView: contentView) + let delegate = DetachedWindowDelegate { [weak self] in + self?.closeWindow(id: windowID) + } + window.delegate = delegate + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + windows[windowID] = window + delegates[windowID] = delegate + } + + private func closeWindow(id: UUID) { + windows.removeValue(forKey: id) + delegates.removeValue(forKey: id) + } +} + +@MainActor +private final class DetachedWindowDelegate: NSObject, NSWindowDelegate { + private let onClose: () -> Void + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + } + + func windowWillClose(_ notification: Notification) { + onClose() + } +} + +class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { func applicationDidFinishLaunching(_ notification: Notification) { + UNUserNotificationCenter.current().delegate = self NSApplication.shared.setActivationPolicy(.regular) NSApplication.shared.activate(ignoringOtherApps: true) Log.app.info("Bugbook launching") @@ -442,6 +514,48 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + // MARK: - UNUserNotificationCenterDelegate + + /// Show notifications even when app is in foreground. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } + + /// Handle notification action buttons. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + let eventId = userInfo["eventId"] as? String ?? "" + let eventTitle = userInfo["eventTitle"] as? String ?? "Meeting" + + switch response.actionIdentifier { + case MeetingNotificationService.recordActionIdentifier: + NotificationCenter.default.post( + name: .meetingNotificationRecord, + object: nil, + userInfo: ["eventId": eventId, "eventTitle": eventTitle] + ) + case MeetingNotificationService.openNotesActionIdentifier, + UNNotificationDefaultActionIdentifier: + NotificationCenter.default.post( + name: .meetingNotificationOpenNotes, + object: nil, + userInfo: ["eventId": eventId, "eventTitle": eventTitle] + ) + default: + break + } + + completionHandler() + } } extension Notification.Name { @@ -454,7 +568,6 @@ extension Notification.Name { static let quickOpenNewTab = Notification.Name("quickOpenNewTab") static let openSettings = Notification.Name("openSettings") static let openAIPanel = Notification.Name("openAIPanel") - static let openFullChat = Notification.Name("openFullChat") static let askAI = Notification.Name("askAI") static let toggleTheme = Notification.Name("toggleTheme") static let newDatabase = Notification.Name("newDatabase") @@ -464,7 +577,6 @@ extension Notification.Name { static let openDailyNote = Notification.Name("openDailyNote") static let openGraphView = Notification.Name("openGraphView") static let openMail = Notification.Name("openMail") - static let openMessages = Notification.Name("openMessages") static let editorZoomIn = Notification.Name("editorZoomIn") static let editorZoomOut = Notification.Name("editorZoomOut") static let editorZoomReset = Notification.Name("editorZoomReset") @@ -480,6 +592,9 @@ extension Notification.Name { static let movePageToDir = Notification.Name("movePageToDir") static let addToSidebar = Notification.Name("addToSidebar") + static let stopMeetingRecording = Notification.Name("stopMeetingRecording") + static let meetingNotificationRecord = Notification.Name("meetingNotificationRecord") + static let meetingNotificationOpenNotes = Notification.Name("meetingNotificationOpenNotes") static let findInPage = Notification.Name("findInPage") static let findInPane = Notification.Name("findInPane") diff --git a/Sources/Bugbook/Lib/MarkdownBlockParser.swift b/Sources/Bugbook/Lib/MarkdownBlockParser.swift index 0bfa9caa..50fb7351 100644 --- a/Sources/Bugbook/Lib/MarkdownBlockParser.swift +++ b/Sources/Bugbook/Lib/MarkdownBlockParser.swift @@ -11,6 +11,43 @@ enum MarkdownBlockParser { var fullWidth: Bool = false } + /// Strip YAML frontmatter (between `---` delimiters) from the top of a markdown string. + /// Returns the raw frontmatter string (without delimiters) and the remaining content. + static func stripYAMLFrontmatter(_ markdown: String) -> (yamlFrontmatter: String, content: String) { + let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.first?.trimmingCharacters(in: .whitespaces) == "---" else { + return ("", markdown) + } + // Find closing --- + for i in 1.. String? { + for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("\(key):") { + let value = trimmed.dropFirst(key.count + 1).trimmingCharacters(in: .whitespaces) + // Strip surrounding quotes + if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 { + return String(value.dropFirst().dropLast()) + } + return value.isEmpty ? nil : value + } + } + return nil + } + /// Parse file-level metadata comments from the top of the markdown string. /// Returns the metadata and the remaining markdown content after metadata lines. static func parseMetadata(_ markdown: String) -> (Metadata, String) { diff --git a/Sources/Bugbook/Lib/MarkdownParser.swift b/Sources/Bugbook/Lib/MarkdownParser.swift deleted file mode 100644 index 88a15170..00000000 --- a/Sources/Bugbook/Lib/MarkdownParser.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -struct MarkdownParser { - - // MARK: - Metadata Parsing - - /// Parse icon from comment - static func parseIcon(from content: String) -> String? { - let pattern = "" - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)), - let range = Range(match.range(at: 1), in: content) else { return nil } - let value = String(content[range]).trimmingCharacters(in: .whitespaces) - return value.isEmpty ? nil : value - } - - /// Parse cover from comment - static func parseCover(from content: String) -> String? { - let pattern = "" - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)), - let range = Range(match.range(at: 1), in: content) else { return nil } - let value = String(content[range]).trimmingCharacters(in: .whitespaces) - return value.isEmpty ? nil : value - } - - /// Parse full-width from comment - static func parseFullWidth(from content: String) -> Bool { - let pattern = "" - guard let regex = try? NSRegularExpression(pattern: pattern), - let match = regex.firstMatch(in: content, range: NSRange(content.startIndex..., in: content)), - let range = Range(match.range(at: 1), in: content) else { return false } - return String(content[range]) == "true" - } - - /// Extract the first H1 title from content - static func parseTitle(from content: String) -> String? { - let lines = content.components(separatedBy: .newlines) - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("# ") { - return String(trimmed.dropFirst(2)) - } - // Skip metadata comments - if trimmed.hasPrefix(" comment) - static func setIcon(_ icon: String?, in content: String) -> String { - var result = removeMetadata("icon", from: content) - if let icon = icon { - result = "\n" + result - } - return result - } - - /// Set cover in content - static func setCover(_ cover: String?, in content: String) -> String { - var result = removeMetadata("cover", from: content) - if let cover = cover { - let iconLine = parseIcon(from: content).map { "\n" } ?? "" - // Insert after icon if exists - if !iconLine.isEmpty { - result = removeMetadata("icon", from: result) - result = iconLine + "\n" + result - } else { - result = "\n" + result - } - } - return result - } - - /// Reconstruct full content from parts - static func reconstructContent(title: String, body: String, icon: String?, cover: String?, fullWidth: Bool) -> String { - var parts: [String] = [] - - if let icon = icon { - parts.append("") - } - if let cover = cover { - parts.append("") - } - if fullWidth { - parts.append("") - } - parts.append("# \(title)") - parts.append("") - parts.append(body) - - return parts.joined(separator: "\n") - } - - /// Extract all wiki link names from content - static func extractWikiLinks(from content: String) -> [String] { - let pattern = "\\[\\[([^\\]]+)\\]\\]" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } - let range = NSRange(content.startIndex..., in: content) - return regex.matches(in: content, range: range).compactMap { match in - guard let r = Range(match.range(at: 1), in: content) else { return nil } - return String(content[r]) - } - } - - // MARK: - Helpers - - private static func removeMetadata(_ key: String, from content: String) -> String { - let pattern = "\\n?" - guard let regex = try? NSRegularExpression(pattern: pattern) else { return content } - let range = NSRange(content.startIndex..., in: content) - return regex.stringByReplacingMatches(in: content, range: range, withTemplate: "") - } -} diff --git a/Sources/Bugbook/Models/ActiveMeetingSession.swift b/Sources/Bugbook/Models/ActiveMeetingSession.swift new file mode 100644 index 00000000..53bffbec --- /dev/null +++ b/Sources/Bugbook/Models/ActiveMeetingSession.swift @@ -0,0 +1,28 @@ +import Foundation + +/// App-level state for an active meeting recording session. +/// Owned by AppState, independent of any pane — survives pane navigation. +@MainActor +@Observable +class ActiveMeetingSession { + /// Path to the meeting page file being recorded. + var meetingPagePath: String + /// When the recording started. + var startDate: Date + /// Live transcript segments (confirmed). + var confirmedSegments: [String] = [] + /// In-progress speech recognition text (not yet confirmed). + var volatileText: String = "" + /// Current audio input level (0–1). + var audioLevel: Float = 0 + + init(meetingPagePath: String, startDate: Date = .now) { + self.meetingPagePath = meetingPagePath + self.startDate = startDate + } + + /// Full transcript text from all confirmed segments. + var fullTranscript: String { + confirmedSegments.joined(separator: " ") + } +} diff --git a/Sources/Bugbook/Models/AppSettings.swift b/Sources/Bugbook/Models/AppSettings.swift index 4f4915d5..7c405b38 100644 --- a/Sources/Bugbook/Models/AppSettings.swift +++ b/Sources/Bugbook/Models/AppSettings.swift @@ -124,6 +124,10 @@ struct AppSettings: Codable, Equatable { /// Path to the page opened for new/empty tabs. Empty string = default Bugbook landing page. var defaultNewTabPage: String var browserSearchEngine: BrowserSearchEngine + var browserHistoryEnabled: Bool + var browserSuggestionsEnabled: Bool + var browserSuggestionLimit: Int + var browserSuggestsBugbookPages: Bool var browserChrome: BrowserChromeConfiguration var browserQuickLaunchItems: [BrowserQuickLaunchItem] var browserDefaultSaveFolder: String @@ -156,6 +160,10 @@ struct AppSettings: Codable, Equatable { mailMemoryLearningEnabled: true, defaultNewTabPage: "", browserSearchEngine: .duckDuckGo, + browserHistoryEnabled: true, + browserSuggestionsEnabled: true, + browserSuggestionLimit: 8, + browserSuggestsBugbookPages: true, browserChrome: .minimal, browserQuickLaunchItems: [ BrowserQuickLaunchItem(title: "Bugbook", url: "https://github.com/maxforsey/bugbook", icon: "book.pages"), @@ -191,6 +199,10 @@ struct AppSettings: Codable, Equatable { case mailMemoryLearningEnabled case defaultNewTabPage case browserSearchEngine + case browserHistoryEnabled + case browserSuggestionsEnabled + case browserSuggestionLimit + case browserSuggestsBugbookPages case browserChrome case browserQuickLaunchItems case browserDefaultSaveFolder @@ -230,6 +242,10 @@ struct AppSettings: Codable, Equatable { mailMemoryLearningEnabled = try container.decodeIfPresent(Bool.self, forKey: .mailMemoryLearningEnabled) ?? true defaultNewTabPage = try container.decodeIfPresent(String.self, forKey: .defaultNewTabPage) ?? "" browserSearchEngine = try container.decodeIfPresent(BrowserSearchEngine.self, forKey: .browserSearchEngine) ?? .duckDuckGo + browserHistoryEnabled = try container.decodeIfPresent(Bool.self, forKey: .browserHistoryEnabled) ?? true + browserSuggestionsEnabled = try container.decodeIfPresent(Bool.self, forKey: .browserSuggestionsEnabled) ?? true + browserSuggestionLimit = max(3, min(12, try container.decodeIfPresent(Int.self, forKey: .browserSuggestionLimit) ?? 8)) + browserSuggestsBugbookPages = try container.decodeIfPresent(Bool.self, forKey: .browserSuggestsBugbookPages) ?? true browserChrome = try container.decodeIfPresent(BrowserChromeConfiguration.self, forKey: .browserChrome) ?? .minimal browserQuickLaunchItems = try container.decodeIfPresent([BrowserQuickLaunchItem].self, forKey: .browserQuickLaunchItems) ?? AppSettings.default.browserQuickLaunchItems browserDefaultSaveFolder = try container.decodeIfPresent(String.self, forKey: .browserDefaultSaveFolder) ?? "Web Clippings" @@ -265,6 +281,10 @@ struct AppSettings: Codable, Equatable { mailMemoryLearningEnabled: Bool = true, defaultNewTabPage: String, browserSearchEngine: BrowserSearchEngine = .duckDuckGo, + browserHistoryEnabled: Bool = true, + browserSuggestionsEnabled: Bool = true, + browserSuggestionLimit: Int = 8, + browserSuggestsBugbookPages: Bool = true, browserChrome: BrowserChromeConfiguration = .minimal, browserQuickLaunchItems: [BrowserQuickLaunchItem] = [], browserDefaultSaveFolder: String = "Web Clippings", @@ -294,6 +314,10 @@ struct AppSettings: Codable, Equatable { self.mailMemoryLearningEnabled = mailMemoryLearningEnabled self.defaultNewTabPage = defaultNewTabPage self.browserSearchEngine = browserSearchEngine + self.browserHistoryEnabled = browserHistoryEnabled + self.browserSuggestionsEnabled = browserSuggestionsEnabled + self.browserSuggestionLimit = max(3, min(12, browserSuggestionLimit)) + self.browserSuggestsBugbookPages = browserSuggestsBugbookPages self.browserChrome = browserChrome self.browserQuickLaunchItems = browserQuickLaunchItems self.browserDefaultSaveFolder = browserDefaultSaveFolder @@ -326,6 +350,10 @@ struct AppSettings: Codable, Equatable { try container.encode(mailMemoryLearningEnabled, forKey: .mailMemoryLearningEnabled) try container.encode(defaultNewTabPage, forKey: .defaultNewTabPage) try container.encode(browserSearchEngine, forKey: .browserSearchEngine) + try container.encode(browserHistoryEnabled, forKey: .browserHistoryEnabled) + try container.encode(browserSuggestionsEnabled, forKey: .browserSuggestionsEnabled) + try container.encode(browserSuggestionLimit, forKey: .browserSuggestionLimit) + try container.encode(browserSuggestsBugbookPages, forKey: .browserSuggestsBugbookPages) try container.encode(browserChrome, forKey: .browserChrome) try container.encode(browserQuickLaunchItems, forKey: .browserQuickLaunchItems) try container.encode(browserDefaultSaveFolder, forKey: .browserDefaultSaveFolder) diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index e7605b1e..5ac5ed45 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -91,6 +91,11 @@ class BlockDocument { var meetingVolatileText: String = "" @ObservationIgnored var onStartMeeting: ((UUID) -> Void)? @ObservationIgnored var onStopMeeting: ((UUID) -> Void)? + + /// Raw YAML frontmatter (without `---` delimiters), preserved through load/save cycle. + var yamlFrontmatter: String = "" + /// Whether this page has `type: meeting` in its YAML frontmatter. + var isMeetingPage: Bool { MarkdownBlockParser.yamlValue(for: "type", in: yamlFrontmatter) == "meeting" } @ObservationIgnored var transcriptionService: TranscriptionService? @ObservationIgnored var availablePages: [FileEntry] = [] @ObservationIgnored var filePath: String? @@ -101,6 +106,14 @@ class BlockDocument { @ObservationIgnored private var persistsBlockIDs: Bool = false var markdown: String { + var parts: [String] = [] + + // YAML frontmatter + if !yamlFrontmatter.isEmpty { + parts.append("---\n\(yamlFrontmatter)\n---") + } + + // Comment-style metadata (icon, cover, full-width) let metadata = MarkdownBlockParser.Metadata( icon: icon, coverUrl: coverUrl, @@ -108,11 +121,15 @@ class BlockDocument { fullWidth: fullWidth ) let metaStr = MarkdownBlockParser.serializeMetadata(metadata) - let blockStr = MarkdownBlockParser.serialize(blocks, includeBlockIDComments: persistsBlockIDs) - if metaStr.isEmpty { - return blockStr + if !metaStr.isEmpty { + parts.append(metaStr) } - return metaStr + "\n" + blockStr + + // Block content + let blockStr = MarkdownBlockParser.serialize(blocks, includeBlockIDComments: persistsBlockIDs) + parts.append(blockStr) + + return parts.joined(separator: "\n") } init(markdown: String) { @@ -123,6 +140,7 @@ class BlockDocument { self.coverPosition = parsed.metadata.coverPosition self.fullWidth = parsed.metadata.fullWidth self.blocks = parsed.blocks + self.yamlFrontmatter = parsed.yamlFrontmatter } func replaceMarkdown(_ markdown: String) { @@ -133,6 +151,7 @@ class BlockDocument { coverPosition = parsed.metadata.coverPosition fullWidth = parsed.metadata.fullWidth blocks = parsed.blocks + yamlFrontmatter = parsed.yamlFrontmatter } func block(for id: UUID) -> Block? { @@ -1854,14 +1873,17 @@ class BlockDocument { private static func parseDocument(_ markdown: String) -> ( metadata: MarkdownBlockParser.Metadata, blocks: [Block], - persistsBlockIDs: Bool + persistsBlockIDs: Bool, + yamlFrontmatter: String ) { - let (metadata, content) = MarkdownBlockParser.parseMetadata(markdown) + let (yaml, afterYaml) = MarkdownBlockParser.stripYAMLFrontmatter(markdown) + let (metadata, content) = MarkdownBlockParser.parseMetadata(afterYaml) let output = MarkdownBlockParser.parseWithFlags(content) return ( metadata: metadata, blocks: output.blocks, - persistsBlockIDs: output.hasBlockIDs + persistsBlockIDs: output.hasBlockIDs, + yamlFrontmatter: yaml ) } diff --git a/Sources/Bugbook/Models/BrowserModels.swift b/Sources/Bugbook/Models/BrowserModels.swift index 637c6f3d..9f2eb860 100644 --- a/Sources/Bugbook/Models/BrowserModels.swift +++ b/Sources/Bugbook/Models/BrowserModels.swift @@ -30,7 +30,7 @@ struct BrowserTabSnapshot: Codable, Equatable, Identifiable { title: String = "New Tab", urlString: String = "", savedRecordID: UUID? = nil, - pageZoom: Double = 1.0 + pageZoom: Double = BrowserPageState.defaultPageZoom ) { self.id = id self.title = title @@ -83,7 +83,7 @@ struct BrowserTabState: Identifiable, Equatable { estimatedProgress: Double = 0, hoverURLString: String? = nil, savedRecordID: UUID? = nil, - pageZoom: Double = 1.0, + pageZoom: Double = BrowserPageState.defaultPageZoom, canGoBack: Bool = false, canGoForward: Bool = false, securityIconName: String = "magnifyingglass" @@ -204,3 +204,4 @@ protocol ContextualSidebarProviding { protocol PaneDropdownProviding { func makePaneDropdown() -> AnyView } + diff --git a/Sources/Bugbook/Models/PaneNode.swift b/Sources/Bugbook/Models/PaneNode.swift index 39a7b17a..14cf7840 100644 --- a/Sources/Bugbook/Models/PaneNode.swift +++ b/Sources/Bugbook/Models/PaneNode.swift @@ -89,6 +89,17 @@ indirect enum PaneNode: Identifiable, Codable, Equatable { } } + /// First document leaf in depth-first order (short-circuits without materializing the full tree). + var firstDocumentLeaf: Leaf? { + switch self { + case .leaf(let leaf): + if case .document = leaf.content { return leaf } + return nil + case .split(let split): + return split.first.firstDocumentLeaf ?? split.second.firstDocumentLeaf + } + } + /// All leaves flattened in depth-first order. var allLeaves: [Leaf] { switch self { diff --git a/Sources/Bugbook/Models/Workspace.swift b/Sources/Bugbook/Models/Workspace.swift index 380a0f7b..f177b5bd 100644 --- a/Sources/Bugbook/Models/Workspace.swift +++ b/Sources/Bugbook/Models/Workspace.swift @@ -53,15 +53,4 @@ struct Workspace: Identifiable, Codable, Equatable { ) } - /// Create a workspace from an existing OpenFile (migration from tab system). - static func fromOpenFile(_ file: OpenFile, name: String = "Workspace") -> Workspace { - Workspace( - id: UUID(), - name: name, - icon: nil, - root: .leaf(.init(id: file.id, content: .document(openFile: file))), - focusedPaneId: file.id, - createdAt: Date() - ) - } } diff --git a/Sources/Bugbook/Models/WorkspaceManager.swift b/Sources/Bugbook/Models/WorkspaceManager.swift index 8bfc0c4d..b76dc121 100644 --- a/Sources/Bugbook/Models/WorkspaceManager.swift +++ b/Sources/Bugbook/Models/WorkspaceManager.swift @@ -11,6 +11,7 @@ class WorkspaceManager { var activeWorkspaceIndex: Int = 0 /// Set after each successful layout save; UI can observe this for a brief indicator. var lastSavedAt: Date? + var layoutPersistenceEnabled = true @ObservationIgnored private var persistTask: Task? @@ -109,10 +110,22 @@ class WorkspaceManager { schedulePersist() } - func renameWorkspace(at index: Int, name: String) { - guard index >= 0, index < workspaces.count else { return } - workspaces[index].name = name + func detachWorkspace(at index: Int) -> Workspace? { + guard index >= 0, index < workspaces.count else { return nil } + + let detachedWorkspace = workspaces.remove(at: index) + + if workspaces.isEmpty { + workspaces = [Workspace.makeDefault(name: "Workspace")] + activeWorkspaceIndex = 0 + } else if activeWorkspaceIndex >= workspaces.count { + activeWorkspaceIndex = workspaces.count - 1 + } else if activeWorkspaceIndex > index { + activeWorkspaceIndex -= 1 + } + schedulePersist() + return detachedWorkspace } // MARK: - Pane Operations @@ -184,31 +197,6 @@ class WorkspaceManager { schedulePersist() } - /// Merge another tab's content into the current layout as a split next to `targetPaneId`. - func mergeTab(at tabIndex: Int, intoPane targetPaneId: UUID, axis: PaneNode.Split.Axis) { - guard tabIndex >= 0, tabIndex < workspaces.count else { return } - guard tabIndex != activeWorkspaceIndex else { return } - - // Grab the other tab's root node - let otherWs = workspaces[tabIndex] - let mergedNode = otherWs.root - - // Remove the other tab - workspaces.remove(at: tabIndex) - // Adjust active index if needed - if activeWorkspaceIndex > tabIndex { - activeWorkspaceIndex -= 1 - } else if activeWorkspaceIndex >= workspaces.count { - activeWorkspaceIndex = workspaces.count - 1 - } - - // Insert the merged content as a split next to the target pane - guard var ws = activeWorkspace else { return } - ws.root = ws.root.insertingSplit(replacing: targetPaneId, axis: axis, newSibling: mergedNode) - activeWorkspace = ws - schedulePersist() - } - /// Close a pane by its ID. func closePane(id: UUID) { guard var ws = activeWorkspace else { return } @@ -291,16 +279,6 @@ class WorkspaceManager { return results } - /// Find the first document leaf in the active workspace that isn't the given pane. - func nearestDocumentLeaf(from paneId: UUID) -> PaneNode.Leaf? { - guard let ws = activeWorkspace else { return nil } - for leaf in ws.allLeaves { - if leaf.id != paneId, case .document = leaf.content { - return leaf - } - } - return nil - } // MARK: - Persistence @@ -323,6 +301,7 @@ class WorkspaceManager { } func schedulePersist() { + guard layoutPersistenceEnabled else { return } persistTask?.cancel() persistTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 500_000_000) diff --git a/Sources/Bugbook/Services/BrowserEngine.swift b/Sources/Bugbook/Services/BrowserEngine.swift index 4720d684..a2f7a135 100644 --- a/Sources/Bugbook/Services/BrowserEngine.swift +++ b/Sources/Bugbook/Services/BrowserEngine.swift @@ -2,6 +2,10 @@ import AppKit import Foundation struct BrowserPageState { + /// Default zoom level for browser panes — slightly reduced from 1.0 so content + /// renders at a natural scale in the split-pane context. + static let defaultPageZoom: Double = 0.85 + var title: String? var url: URL? var isLoading: Bool @@ -17,7 +21,7 @@ struct BrowserPageState { estimatedProgress: 0, canGoBack: false, canGoForward: false, - pageZoom: 1.0 + pageZoom: defaultPageZoom ) } @@ -39,6 +43,7 @@ protocol BrowserEngine: AnyObject { initialURL: URL?, eventHandler: @escaping BrowserPageEventHandler ) -> any BrowserPage + func clearCookies() async throws } @MainActor diff --git a/Sources/Bugbook/Services/BrowserEngineFactory.swift b/Sources/Bugbook/Services/BrowserEngineFactory.swift index 1be9c362..a9558063 100644 --- a/Sources/Bugbook/Services/BrowserEngineFactory.swift +++ b/Sources/Bugbook/Services/BrowserEngineFactory.swift @@ -1,13 +1,13 @@ import Foundation -#if BUGBOOK_BROWSER_CHROMIUM +#if BUGBOOK_BROWSER_CHROMIUM && canImport(ChromiumBridge) import ChromiumBridge #endif enum BrowserEngineFactory { @MainActor static func makeDefault() -> any BrowserEngine { - #if BUGBOOK_BROWSER_CHROMIUM + #if BUGBOOK_BROWSER_CHROMIUM && canImport(ChromiumBridge) return ChromiumBrowserEngine() #else return WebKitBrowserEngine() diff --git a/Sources/Bugbook/Services/BrowserManager.swift b/Sources/Bugbook/Services/BrowserManager.swift index 0320c89c..4b56cc0a 100644 --- a/Sources/Bugbook/Services/BrowserManager.swift +++ b/Sources/Bugbook/Services/BrowserManager.swift @@ -6,16 +6,22 @@ import Observation @Observable final class BrowserManager { private(set) var sessions: [UUID: BrowserPaneSession] = [:] + private(set) var browsingHistory: [BrowserRecentVisit] + var isHistoryEnabled = true @ObservationIgnored private let engine: any BrowserEngine @ObservationIgnored private let snapshotStore: BrowserPaneSnapshotStore + @ObservationIgnored private let historyStore: BrowserHistoryStore init( engine: (any BrowserEngine)? = nil, - snapshotStore: BrowserPaneSnapshotStore = BrowserPaneSnapshotStore() + snapshotStore: BrowserPaneSnapshotStore = BrowserPaneSnapshotStore(), + historyStore: BrowserHistoryStore = BrowserHistoryStore() ) { self.engine = engine ?? BrowserEngineFactory.makeDefault() self.snapshotStore = snapshotStore + self.historyStore = historyStore + self.browsingHistory = historyStore.load() } func session(for paneID: UUID) -> BrowserPaneSession { @@ -52,6 +58,37 @@ final class BrowserManager { } } + func clearHistory() { + browsingHistory.removeAll() + historyStore.clear() + snapshotStore.clearHistory() + for paneID in sessions.keys { + sessions[paneID]?.recentVisits.removeAll() + persistSession(paneID) + } + } + + func clearCookies() async throws { + try await engine.clearCookies() + } + + func setHistoryEnabled(_ enabled: Bool) { + isHistoryEnabled = enabled + } + + func restoreSessionSnapshot(_ snapshot: BrowserPaneSnapshot, for paneID: UUID) { + let session = BrowserPaneSession(paneID: paneID, snapshot: snapshot) + session.manager = self + sessions[paneID] = session + } + + func snapshot(for paneID: UUID) -> BrowserPaneSnapshot? { + if let session = sessions[paneID] { + return session.snapshot + } + return snapshotStore.snapshot(for: paneID) + } + func ensurePage(for paneID: UUID, tabID: UUID) -> any BrowserPage { let session = session(for: paneID) if let existing = session.pages[tabID] { @@ -131,9 +168,11 @@ final class BrowserManager { } fileprivate func recordVisit(title: String, url: URL, paneID: UUID) { + guard isHistoryEnabled else { return } let session = session(for: paneID) let visit = BrowserRecentVisit(title: title, urlString: url.absoluteString) session.recordVisit(visit) + recordGlobalVisit(visit) persistSession(paneID) } @@ -154,6 +193,15 @@ final class BrowserManager { session(for: paneID).lastDownloadMessage = message } + private func recordGlobalVisit(_ visit: BrowserRecentVisit) { + browsingHistory.removeAll { $0.urlString == visit.urlString } + browsingHistory.insert(visit, at: 0) + if browsingHistory.count > 200 { + browsingHistory = Array(browsingHistory.prefix(200)) + } + historyStore.save(browsingHistory) + } + private func makeSession(for paneID: UUID) -> BrowserPaneSession { let snapshot = snapshotStore.snapshot(for: paneID) ?? BrowserPaneSnapshot(paneID: paneID) let session = BrowserPaneSession(paneID: paneID, snapshot: snapshot) @@ -285,6 +333,24 @@ final class BrowserPaneSession { manager?.persistSession(paneID) } + func moveTab(from sourceIndex: Int, to destinationIndex: Int) { + guard sourceIndex != destinationIndex, + sourceIndex >= 0, sourceIndex < tabs.count, + destinationIndex >= 0, destinationIndex <= tabs.count else { return } + + let selectedTabID = self.selectedTabID + let tab = tabs.remove(at: sourceIndex) + let adjustedDestination = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex + tabs.insert(tab, at: adjustedDestination) + + if let selectedTabID, + let updatedIndex = tabs.firstIndex(where: { $0.id == selectedTabID }) { + self.selectedTabID = tabs[updatedIndex].id + } + + manager?.persistSession(paneID) + } + func selectTab(_ tabID: UUID) { selectedTabID = tabID if let activeTab, @@ -391,6 +457,22 @@ struct BrowserPaneSnapshotStore { try? fileManager.removeItem(at: fileURL(for: paneID)) } + func clearHistory() { + guard let enumerator = fileManager.enumerator(at: directoryURL, includingPropertiesForKeys: nil) else { + return + } + + for case let url as URL in enumerator where url.pathExtension == "json" { + guard let data = try? Data(contentsOf: url), + var snapshot = try? decoder.decode(BrowserPaneSnapshot.self, from: data) else { + continue + } + snapshot.recentVisits.removeAll() + guard let encoded = try? encoder.encode(snapshot) else { continue } + try? encoded.write(to: url, options: .atomic) + } + } + private func ensureDirectoryExists() throws { guard !fileManager.fileExists(atPath: directoryURL.path) else { return } try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -408,3 +490,60 @@ struct BrowserPaneSnapshotStore { .appendingPathComponent("BrowserPanes", isDirectory: true) } } + +struct BrowserHistoryStore { + private let fileManager: FileManager + private let fileURL: URL + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + init(fileManager: FileManager = .default, fileURL: URL? = nil) { + self.fileManager = fileManager + self.fileURL = fileURL ?? Self.defaultFileURL(fileManager: fileManager) + } + + func load() -> [BrowserRecentVisit] { + guard let data = try? Data(contentsOf: fileURL), + let visits = try? decoder.decode([BrowserRecentVisit].self, from: data) else { + return [] + } + return visits + } + + func save(_ visits: [BrowserRecentVisit]) { + do { + try ensureDirectoryExists() + let data = try encoder.encode(visits) + try data.write(to: fileURL, options: .atomic) + } catch { + Log.app.error("Failed to persist browser history: \(error.localizedDescription)") + } + } + + func clear() { + try? fileManager.removeItem(at: fileURL) + } + + private func ensureDirectoryExists() throws { + let directoryURL = fileURL.deletingLastPathComponent() + guard !fileManager.fileExists(atPath: directoryURL.path) else { return } + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } + + private static func defaultFileURL(fileManager: FileManager) -> URL { + let baseDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + return baseDirectory + .appendingPathComponent("Bugbook", isDirectory: true) + .appendingPathComponent("BrowserHistory", isDirectory: true) + .appendingPathComponent("history.json") + } +} diff --git a/Sources/Bugbook/Services/ChromiumBrowserEngine.swift b/Sources/Bugbook/Services/ChromiumBrowserEngine.swift index de1ae126..26b16f91 100644 --- a/Sources/Bugbook/Services/ChromiumBrowserEngine.swift +++ b/Sources/Bugbook/Services/ChromiumBrowserEngine.swift @@ -1,4 +1,4 @@ -#if BUGBOOK_BROWSER_CHROMIUM +#if BUGBOOK_BROWSER_CHROMIUM && canImport(ChromiumBridge) import AppKit import Foundation @preconcurrency import ChromiumBridge @@ -16,6 +16,10 @@ final class ChromiumBrowserEngine: BrowserEngine { BBChromiumRuntime.startIfNeeded() return ChromiumBrowserPage(initialURL: initialURL, eventHandler: eventHandler) } + + func clearCookies() async throws { + BBChromiumRuntime.clearCookies() + } } @MainActor @@ -122,7 +126,7 @@ private final class ChromiumBrowserPage: NSObject, BrowserPage, @preconcurrency estimatedProgress: state.estimatedProgress, canGoBack: state.canGoBack, canGoForward: state.canGoForward, - pageZoom: state.pageZoom > 0 ? state.pageZoom : 1.0 + pageZoom: state.pageZoom > 0 ? state.pageZoom : BrowserPageState.defaultPageZoom ) } diff --git a/Sources/Bugbook/Services/FileSystemService.swift b/Sources/Bugbook/Services/FileSystemService.swift index 4ca2b934..ccd8ddc5 100644 --- a/Sources/Bugbook/Services/FileSystemService.swift +++ b/Sources/Bugbook/Services/FileSystemService.swift @@ -19,7 +19,9 @@ class FileSystemService { nonisolated private static let hiddenSidebarFolders: Set = [ "attachments", "inbox", "raw", "aithreads", "assets", "comparisons", "covers", "icons", - "settings", "workspacelayouts", "daily notes 2" + "settings", "workspacelayouts", "daily notes 2", + // Logseq vault leftovers + "journals", "logseq", "whiteboards" ] init() { @@ -126,10 +128,15 @@ class FileSystemService { let fullPath = (path as NSString).appendingPathComponent(name) if WorkspacePathRules.shouldIgnoreAbsolutePath(fullPath) { continue } - var isDir: ObjCBool = false - guard fm.fileExists(atPath: fullPath, isDirectory: &isDir) else { continue } + // Single stat() syscall for both directory check and file size, instead of + // calling fileExists then attributesOfItem separately. + let resourceValues = try? URL(fileURLWithPath: fullPath).resourceValues( + forKeys: [.isDirectoryKey, .fileSizeKey] + ) + guard let resourceValues else { continue } + let isDirBool = resourceValues.isDirectory ?? false - if isDir.boolValue { + if isDirBool { // Check for _schema.json directly — avoids a second fileExists call // in isDatabaseFolder and then a redundant data load let schemaPath = (fullPath as NSString).appendingPathComponent("_schema.json") @@ -148,16 +155,25 @@ class FileSystemService { continue } else { let children = buildFileTree(at: fullPath, depth: depth + 1) + // Skip folders that have no visible children (after filtering empties) + if children.isEmpty { continue } folders.append(FileEntry( id: fullPath, name: name, path: fullPath, isDirectory: true, - children: children.isEmpty ? nil : children + children: children )) } } else if name.hasSuffix(".md") { let isDbFile = name.hasSuffix(".db.md") + + // Skip empty .md files (and the `# \n` placeholder from createNewFile). + // Database files are kept regardless of size since they store metadata elsewhere. + if !isDbFile, let size = resourceValues.fileSize, size < 10 { + continue + } + let icon = parseIconFromFile(at: fullPath) // Check for companion folder — use siblingNames set instead of filesystem call diff --git a/Sources/Bugbook/Services/MailIntelligenceService.swift b/Sources/Bugbook/Services/MailIntelligenceService.swift deleted file mode 100644 index 2ead7205..00000000 --- a/Sources/Bugbook/Services/MailIntelligenceService.swift +++ /dev/null @@ -1,1014 +0,0 @@ -import Foundation -import BugbookCore - -enum MailIntelligenceError: LocalizedError { - case noModelProvider - case missingWorkspace - case missingCalendarToken - case noSelectedThread - - var errorDescription: String? { - switch self { - case .noModelProvider: - return "Configure an AI engine before using Mail intelligence." - case .missingWorkspace: - return "Open a workspace to use Bugbook-linked mail actions." - case .missingCalendarToken: - return "Connect Google Calendar before creating events from Mail." - case .noSelectedThread: - return "Select a thread first." - } - } -} - -enum MailModelExecutionPath: String, Equatable { - case anthropicAPI - case claudeCLI - case codexCLI - case unavailable -} - -struct MailModelProviderResolver { - static func resolve(settings: AppSettings, engineStatus: AiEngineStatus) -> MailModelExecutionPath { - let hasAPIKey = !settings.anthropicApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - - switch settings.preferredAIEngine { - case .claudeAPI: - return hasAPIKey ? .anthropicAPI : .unavailable - case .claude: - return engineStatus.claudeAvailable ? .claudeCLI : .unavailable - case .codex: - return engineStatus.codexAvailable ? .codexCLI : .unavailable - case .auto: - if hasAPIKey { return .anthropicAPI } - if engineStatus.claudeAvailable { return .claudeCLI } - if engineStatus.codexAvailable { return .codexCLI } - return .unavailable - } - } -} - -@MainActor -protocol MailModelProvider { - func executionPath(using settings: AppSettings) async -> MailModelExecutionPath - func generate(systemPrompt: String, userPrompt: String, workspacePath: String?, settings: AppSettings, maxTokens: Int) async throws -> String -} - -@MainActor -final class AiServiceMailModelProvider: MailModelProvider { - private let aiService: AiService - - init(aiService: AiService) { - self.aiService = aiService - } - - func executionPath(using settings: AppSettings) async -> MailModelExecutionPath { - let status = await aiService.ensureDetectedEngines() - return MailModelProviderResolver.resolve(settings: settings, engineStatus: status) - } - - func generate( - systemPrompt: String, - userPrompt: String, - workspacePath: String?, - settings: AppSettings, - maxTokens: Int = 2048 - ) async throws -> String { - let path = await executionPath(using: settings) - let engine: PreferredAIEngine - - switch path { - case .anthropicAPI: - engine = .claudeAPI - case .claudeCLI: - engine = .claude - case .codexCLI: - engine = .codex - case .unavailable: - throw MailIntelligenceError.noModelProvider - } - - return try await aiService.executePrompt( - engine: engine, - workspacePath: workspacePath, - systemPrompt: systemPrompt, - prompt: userPrompt, - apiKey: settings.anthropicApiKey, - model: settings.anthropicModel, - maxTokens: maxTokens - ) - } -} - -@MainActor -@Observable -final class MailIntelligenceService { - var records: [String: MailThreadIntelligenceRecord] = [:] - var priorityOverrides: [MailPriorityOverride] = [] - var memories: [MailMemory] = [] - var agentSessions: [String: MailAgentSession] = [:] - var isAnalyzing = false - var isGeneratingDraft = false - var isLoadingContext = false - var isRunningAgentAction = false - var lastSavedAt: Date? - var error: String? - - @ObservationIgnored private let accountStore: MailIntelligenceStore - @ObservationIgnored private let workspaceStore: MailAgentSessionStore - @ObservationIgnored private let fileSystem: FileSystemService - @ObservationIgnored private let agentWorkspaceStore: AgentWorkspaceStore - - private var activeAccountEmail: String? - private var activeWorkspacePath: String? - - init( - accountStore: MailIntelligenceStore = MailIntelligenceStore(), - workspaceStore: MailAgentSessionStore = MailAgentSessionStore(), - fileSystem: FileSystemService? = nil, - agentWorkspaceStore: AgentWorkspaceStore = AgentWorkspaceStore() - ) { - self.accountStore = accountStore - self.workspaceStore = workspaceStore - self.fileSystem = fileSystem ?? FileSystemService() - self.agentWorkspaceStore = agentWorkspaceStore - } - - func load(accountEmail: String, workspacePath: String?, mailService: MailService) { - let normalizedEmail = accountEmail.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if activeAccountEmail != normalizedEmail { - records = [:] - lastSavedAt = nil - } - - activeAccountEmail = normalizedEmail - if let snapshot = accountStore.load(accountEmail: normalizedEmail) { - records = snapshot.threadRecords - lastSavedAt = snapshot.savedAt - } - - if let workspacePath { - activeWorkspacePath = workspacePath - let snapshot = workspaceStore.load(workspacePath: workspacePath) - priorityOverrides = snapshot.priorityOverrides - memories = snapshot.memories - agentSessions = Dictionary(uniqueKeysWithValues: snapshot.agentSessions.map { ($0.threadID, $0) }) - } else { - activeWorkspacePath = nil - priorityOverrides = [] - memories = [] - agentSessions = [:] - } - - mailService.applyIntelligenceRecords(records) - } - - func record(for threadID: String) -> MailThreadIntelligenceRecord? { - records[threadID] - } - - func session(for thread: MailThreadDetail) -> MailAgentSession { - if let existing = agentSessions[thread.id] { - return existing - } - - let newSession = MailAgentSession( - threadID: thread.id, - proposals: defaultProposals(for: thread), - entries: [ - MailAgentSessionEntry( - role: .system, - content: "Mail agent session started for \(thread.subject). Actions stay local to this workspace." - ) - ] - ) - agentSessions[thread.id] = newSession - persistWorkspaceStateIfPossible() - return newSession - } - - func runBackgroundAnalysis( - mailService: MailService, - token: GoogleOAuthToken, - settings: AppSettings, - workspacePath: String?, - aiService: AiService - ) async { - guard settings.mailBackgroundAnalysisEnabled else { return } - let provider = AiServiceMailModelProvider(aiService: aiService) - guard await provider.executionPath(using: settings) != .unavailable else { return } - - let inboxThreads = mailService.mailboxThreads[.inbox] ?? [] - let candidates = inboxThreads.filter { shouldRefresh(thread: $0) } - guard !candidates.isEmpty else { return } - - isAnalyzing = true - error = nil - defer { - isAnalyzing = false - persistAccountStateIfPossible() - } - - for threadSummary in candidates { - do { - let detail = try await mailService.fetchThreadDetailSnapshot( - id: threadSummary.id, - mailbox: threadSummary.mailbox ?? .inbox, - token: token - ) - let record = try await analyze(thread: detail, summary: threadSummary, workspacePath: workspacePath, settings: settings, provider: provider) - upsert(record, into: mailService) - - if settings.mailBackgroundDraftGenerationEnabled, - let analysis = record.analysis, - analysis.shouldGenerateDraft { - try await generateDraftIfNeeded(for: detail, mailService: mailService, settings: settings, workspacePath: workspacePath, provider: provider) - } - } catch { - self.error = error.localizedDescription - } - } - } - - func ensureThreadArtifacts( - for thread: MailThreadDetail, - mailService: MailService, - settings: AppSettings, - workspacePath: String?, - aiService: AiService - ) async { - let provider = AiServiceMailModelProvider(aiService: aiService) - if record(for: thread.id)?.analysis == nil, - settings.mailBackgroundAnalysisEnabled, - await provider.executionPath(using: settings) != .unavailable { - do { - let syntheticSummary = MailThreadSummary( - id: thread.id, - mailbox: thread.mailbox, - subject: thread.subject, - snippet: thread.snippet, - participants: thread.participants, - date: thread.lastDate, - messageCount: thread.messages.count, - labelIds: thread.labelIds, - historyId: thread.historyId - ) - let record = try await analyze(thread: thread, summary: syntheticSummary, workspacePath: workspacePath, settings: settings, provider: provider) - upsert(record, into: mailService) - } catch { - self.error = error.localizedDescription - } - } - - if settings.mailBackgroundDraftGenerationEnabled, - let analysis = records[thread.id]?.analysis, - analysis.shouldGenerateDraft, - records[thread.id]?.draftSuggestion == nil, - await provider.executionPath(using: settings) != .unavailable { - do { - try await generateDraftIfNeeded(for: thread, mailService: mailService, settings: settings, workspacePath: workspacePath, provider: provider) - } catch { - self.error = error.localizedDescription - } - } - - if settings.mailSenderLookupEnabled, records[thread.id]?.senderContext == nil { - do { - let context = try await buildSenderContext(for: thread, workspacePath: workspacePath, settings: settings, provider: provider) - var record = records[thread.id] ?? MailThreadIntelligenceRecord(threadID: thread.id) - record.senderContext = context - record.annotation.hasSenderContext = true - record.updatedAt = Date() - upsert(record, into: mailService) - } catch { - self.error = error.localizedDescription - } - } - } - - func refineDraft( - for thread: MailThreadDetail, - instruction: String, - mailService: MailService, - settings: AppSettings, - workspacePath: String?, - aiService: AiService - ) async { - let trimmedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedInstruction.isEmpty else { return } - - let provider = AiServiceMailModelProvider(aiService: aiService) - guard await provider.executionPath(using: settings) != .unavailable else { - error = MailIntelligenceError.noModelProvider.localizedDescription - return - } - - guard var record = records[thread.id], let draft = record.draftSuggestion else { return } - - isGeneratingDraft = true - error = nil - defer { - isGeneratingDraft = false - persistAccountStateIfPossible() - } - - do { - let systemPrompt = """ - You refine an email reply draft for a human user. Return only JSON with keys: - body, rationale. - Keep the same intent unless the instruction explicitly changes it. - """ - let prompt = """ - Thread subject: \(thread.subject) - - Existing draft: - \(draft.body) - - Refinement instruction: - \(trimmedInstruction) - """ - let response = try await provider.generate( - systemPrompt: systemPrompt, - userPrompt: prompt, - workspacePath: workspacePath, - settings: settings, - maxTokens: 1400 - ) - let payload = try decodeJSON(response, as: DraftRefinementPayload.self) - let refinement = MailDraftRefinement(instruction: trimmedInstruction, body: payload.body) - record.draftSuggestion?.body = payload.body - record.draftSuggestion?.rationale = payload.rationale - record.draftSuggestion?.status = .suggested - record.draftSuggestion?.refinementHistory.append(refinement) - record.annotation.draftStatus = .suggested - record.updatedAt = Date() - upsert(record, into: mailService) - } catch { - self.error = error.localizedDescription - } - } - - func acceptDraft(for thread: MailThreadDetail, connectedEmail: String, mailService: MailService) { - guard var record = records[thread.id], var draftSuggestion = record.draftSuggestion else { return } - mailService.prepareReplyDraft( - thread: thread, - connectedEmail: connectedEmail, - replyAll: record.analysis?.prefersReplyAll ?? false - ) - mailService.composer.subject = draftSuggestion.subject - mailService.composer.body = draftSuggestion.body - draftSuggestion.status = .accepted - record.draftSuggestion = draftSuggestion - record.acceptedDraftBody = draftSuggestion.body - record.annotation.draftStatus = .accepted - record.updatedAt = Date() - upsert(record, into: mailService) - } - - func recordDraftEditIfNeeded(for threadID: String, finalBody: String) { - guard var record = records[threadID] else { return } - let normalizedFinal = finalBody.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedFinal.isEmpty else { return } - record.editedDraftBody = normalizedFinal - if normalizedFinal != record.acceptedDraftBody?.trimmingCharacters(in: .whitespacesAndNewlines) { - record.annotation.draftStatus = .edited - record.draftSuggestion?.status = .edited - } - record.updatedAt = Date() - records[threadID] = record - persistAccountStateIfPossible() - } - - func learnFromSentDraft(threadID: String, subject: String, finalBody: String) { - let trimmedBody = finalBody.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedBody.isEmpty else { return } - - recordDraftEditIfNeeded(for: threadID, finalBody: trimmedBody) - - let senderEmail = activeAccountEmail - let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) - let excerpt = trimmedBody - .components(separatedBy: .newlines) - .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - .prefix(3) - .joined(separator: " ") - - memories.insert( - MailMemory( - kind: .writingStyle, - title: "Sent style: \(subject)", - detail: excerpt.isEmpty ? trimmedBody : excerpt, - senderEmail: senderEmail, - senderDomain: senderDomain - ), - at: 0 - ) - persistWorkspaceStateIfPossible() - } - - func recordPriorityOverride( - _ priority: MailPriority, - note: String, - for thread: MailThreadDetail, - mailService: MailService - ) { - let subjectToken = subjectHint(for: thread.subject) - let senderEmail = thread.messages.last?.from?.email.lowercased() - let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) - - let override = MailPriorityOverride( - senderEmail: senderEmail, - senderDomain: senderDomain, - subjectContains: subjectToken, - priority: priority, - note: note.trimmingCharacters(in: .whitespacesAndNewlines) - ) - priorityOverrides.insert(override, at: 0) - - if !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - memories.insert( - MailMemory( - kind: .priorityPreference, - title: "Priority override for \(thread.subject)", - detail: note, - senderEmail: senderEmail, - senderDomain: senderDomain - ), - at: 0 - ) - } - - var record = records[thread.id] ?? MailThreadIntelligenceRecord(threadID: thread.id) - record.annotation.suggestedPriority = priority - record.analysis?.priority = priority - record.updatedAt = Date() - upsert(record, into: mailService) - persistWorkspaceStateIfPossible() - } - - func createManualMemory(title: String, detail: String, thread: MailThreadDetail) { - let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedDetail = detail.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedTitle.isEmpty, !trimmedDetail.isEmpty else { return } - - let senderEmail = thread.messages.last?.from?.email.lowercased() - let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) - memories.insert( - MailMemory( - kind: .manualNote, - title: trimmedTitle, - detail: trimmedDetail, - senderEmail: senderEmail, - senderDomain: senderDomain - ), - at: 0 - ) - persistWorkspaceStateIfPossible() - } - - func performAgentAction( - _ action: MailAgentActionKind, - thread: MailThreadDetail, - mailService: MailService, - settings: AppSettings, - workspacePath: String?, - aiService: AiService, - calendarService: CalendarService, - calendarToken: GoogleOAuthToken? - ) async -> String? { - guard let workspacePath else { - error = MailIntelligenceError.missingWorkspace.localizedDescription - return nil - } - - isRunningAgentAction = true - defer { isRunningAgentAction = false } - - var session = session(for: thread) - session.entries.append(MailAgentSessionEntry(role: .user, content: action.displayName)) - agentSessions[thread.id] = session - persistWorkspaceStateIfPossible() - - let run = try? agentWorkspaceStore.startRun( - in: workspacePath, - agent: "mail-agent", - cwd: workspacePath - ) - defer { - if let run { - _ = try? agentWorkspaceStore.finishRun( - in: workspacePath, - runId: run.id, - status: .succeeded, - summary: "Completed \(action.displayName.lowercased()) for \(thread.subject)" - ) - } - } - - do { - let result: String - switch action { - case .draftReply: - await ensureThreadArtifacts( - for: thread, - mailService: mailService, - settings: settings, - workspacePath: workspacePath, - aiService: aiService - ) - acceptDraft(for: thread, connectedEmail: settings.googleConnectedEmail, mailService: mailService) - result = "Inserted a suggested reply into the composer." - case .createTask: - let task = try agentWorkspaceStore.createTask( - in: workspacePath, - title: thread.subject, - detail: taskDetail(for: thread), - labels: ["mail"] - ) - result = "Created task \(task.title)." - case .createNote: - let path = try createNote(from: thread, workspacePath: workspacePath, titlePrefix: "Mail Note") - result = "Created note at \(path)." - case .createCalendarEvent: - guard let calendarToken else { throw MailIntelligenceError.missingCalendarToken } - let draft = calendarDraft(for: thread) - let event = try await calendarService.createGoogleEvent( - workspace: workspacePath, - token: calendarToken, - draft: draft - ) - result = "Created calendar event \(event.title)." - case .summarizeToNote: - let path = try await createSummaryNote(from: thread, settings: settings, workspacePath: workspacePath, aiService: aiService) - result = "Created thread summary note at \(path)." - case .gatherContext: - await ensureThreadArtifacts( - for: thread, - mailService: mailService, - settings: settings, - workspacePath: workspacePath, - aiService: aiService - ) - result = "Updated sender context from local workspace notes." - } - - var updatedSession = self.session(for: thread) - updatedSession.entries.append(MailAgentSessionEntry(role: .action, content: result)) - updatedSession.updatedAt = Date() - agentSessions[thread.id] = updatedSession - persistWorkspaceStateIfPossible() - if let run { - _ = try? agentWorkspaceStore.logEvent(in: workspacePath, runId: run.id, level: .info, message: result) - } - return result - } catch { - self.error = error.localizedDescription - var updatedSession = self.session(for: thread) - updatedSession.entries.append(MailAgentSessionEntry(role: .assistant, content: error.localizedDescription)) - updatedSession.updatedAt = Date() - agentSessions[thread.id] = updatedSession - persistWorkspaceStateIfPossible() - if let run { - _ = try? agentWorkspaceStore.logEvent(in: workspacePath, runId: run.id, level: .error, message: error.localizedDescription) - _ = try? agentWorkspaceStore.finishRun( - in: workspacePath, - runId: run.id, - status: .failed, - summary: error.localizedDescription - ) - } - return nil - } - } - - private func shouldRefresh(thread: MailThreadSummary) -> Bool { - let record = records[thread.id] - guard let sourceSignature = threadSignature(thread: thread) else { - return record == nil - } - return record?.sourceSignature != sourceSignature - } - - private func analyze( - thread: MailThreadDetail, - summary: MailThreadSummary, - workspacePath: String?, - settings: AppSettings, - provider: MailModelProvider - ) async throws -> MailThreadIntelligenceRecord { - let systemPrompt = """ - You triage an email thread for a local-first desktop mail client inspired by Exo. - Return only JSON with keys: - priority, reason, suggested_action, flags, should_generate_draft, prefers_reply_all. - priority must be one of: high, medium, low, skip. - flags must only contain: needs_reply, waiting, archive_ready. - Be concise, pragmatic, and prioritize actionable triage. - """ - let prompt = """ - Connected account: \(settings.googleConnectedEmail) - - Thread: - \(threadTranscript(for: thread)) - - Local preferences: - \(memoryPrompt(for: thread)) - """ - let response = try await provider.generate( - systemPrompt: systemPrompt, - userPrompt: prompt, - workspacePath: workspacePath, - settings: settings, - maxTokens: 1200 - ) - let payload = try decodeJSON(response, as: AnalysisPayload.self) - let priority = appliedPriorityOverride(for: thread) ?? payload.priority - let analysis = MailThreadAnalysis( - priority: priority, - reason: payload.reason, - suggestedAction: payload.suggestedAction, - flags: payload.flags, - shouldGenerateDraft: payload.shouldGenerateDraft, - prefersReplyAll: payload.prefersReplyAll, - analyzedAt: Date() - ) - let annotation = MailThreadAnnotation( - analysisStatus: .complete, - analysisUpdatedAt: analysis.analyzedAt, - suggestedPriority: priority, - statusFlags: analysis.flags, - draftStatus: records[thread.id]?.annotation.draftStatus ?? .none, - hasSenderContext: records[thread.id]?.annotation.hasSenderContext ?? false - ) - return MailThreadIntelligenceRecord( - threadID: thread.id, - sourceSignature: threadSignature(thread: summary), - annotation: annotation, - analysis: analysis, - draftSuggestion: records[thread.id]?.draftSuggestion, - senderContext: records[thread.id]?.senderContext, - acceptedDraftBody: records[thread.id]?.acceptedDraftBody, - editedDraftBody: records[thread.id]?.editedDraftBody, - updatedAt: Date() - ) - } - - private func generateDraftIfNeeded( - for thread: MailThreadDetail, - mailService: MailService, - settings: AppSettings, - workspacePath: String?, - provider: MailModelProvider - ) async throws { - guard let analysis = records[thread.id]?.analysis, analysis.shouldGenerateDraft else { return } - guard records[thread.id]?.draftSuggestion == nil else { return } - - isGeneratingDraft = true - defer { isGeneratingDraft = false } - - let systemPrompt = """ - You draft a thoughtful email reply for a human user. - Return only JSON with keys: - subject, body, rationale. - The draft must be ready for review, not for auto-send. - """ - let prompt = """ - Connected account: \(settings.googleConnectedEmail) - Thread subject: \(thread.subject) - Suggested action: \(analysis.suggestedAction) - - Thread: - \(threadTranscript(for: thread)) - - Writing preferences and local memories: - \(memoryPrompt(for: thread)) - """ - let response = try await provider.generate( - systemPrompt: systemPrompt, - userPrompt: prompt, - workspacePath: workspacePath, - settings: settings, - maxTokens: 1800 - ) - let payload = try decodeJSON(response, as: DraftPayload.self) - var record = records[thread.id] ?? MailThreadIntelligenceRecord(threadID: thread.id) - record.draftSuggestion = MailDraftSuggestion( - threadID: thread.id, - subject: payload.subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? MailService.replySubject(for: thread.subject) : payload.subject, - body: payload.body, - rationale: payload.rationale - ) - record.annotation.draftStatus = .suggested - record.updatedAt = Date() - upsert(record, into: mailService) - } - - private func buildSenderContext( - for thread: MailThreadDetail, - workspacePath: String?, - settings: AppSettings, - provider: MailModelProvider - ) async throws -> MailSenderContext { - isLoadingContext = true - defer { isLoadingContext = false } - - let sender = thread.messages.last?.from ?? thread.messages.first?.from ?? MailMessageRecipient(name: nil, email: "unknown@example.com") - let references = workspaceMatches(for: thread, workspacePath: workspacePath) - - var summary = references.isEmpty - ? "No related workspace notes matched this sender yet." - : references.prefix(3).map { "\(($0.path as NSString).lastPathComponent): \($0.excerpt)" }.joined(separator: "\n") - - if !references.isEmpty, await provider.executionPath(using: settings) != .unavailable { - let systemPrompt = """ - You summarize sender context for a local desktop mail client. - Return only JSON with key summary. - Ground everything in the provided workspace references. - """ - let prompt = """ - Sender: \(sender.displayName) - Email: \(sender.email) - Thread subject: \(thread.subject) - - Workspace references: - \(references.map { "\($0.path): \($0.excerpt)" }.joined(separator: "\n\n")) - """ - if let response = try? await provider.generate( - systemPrompt: systemPrompt, - userPrompt: prompt, - workspacePath: workspacePath, - settings: settings, - maxTokens: 900 - ) { - let payload = try decodeJSON(response, as: ContextPayload.self) - summary = payload.summary - } - } - - return MailSenderContext( - threadID: thread.id, - senderName: sender.name ?? sender.email, - senderEmail: sender.email, - summary: summary, - references: references, - generatedAt: Date() - ) - } - - private func upsert(_ record: MailThreadIntelligenceRecord, into mailService: MailService) { - records[record.threadID] = record - mailService.applyIntelligenceRecord(record) - persistAccountStateIfPossible() - } - - private func persistAccountStateIfPossible() { - guard let activeAccountEmail, !activeAccountEmail.isEmpty else { return } - let snapshot = MailIntelligenceAccountSnapshot(threadRecords: records, savedAt: Date()) - accountStore.save(snapshot, accountEmail: activeAccountEmail) - lastSavedAt = snapshot.savedAt - } - - private func persistWorkspaceStateIfPossible() { - guard let activeWorkspacePath else { return } - let snapshot = MailWorkspaceIntelligenceSnapshot( - priorityOverrides: priorityOverrides, - memories: memories, - agentSessions: Array(agentSessions.values).sorted { $0.updatedAt > $1.updatedAt } - ) - workspaceStore.save(snapshot, workspacePath: activeWorkspacePath) - } - - private func appliedPriorityOverride(for thread: MailThreadDetail) -> MailPriority? { - let senderEmail = thread.messages.last?.from?.email.lowercased() - let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) - let subject = thread.subject.lowercased() - - for override in priorityOverrides { - let emailMatches = override.senderEmail?.lowercased() == senderEmail - let domainMatches = override.senderDomain?.lowercased() == senderDomain - let subjectMatches: Bool - if let subjectContains = override.subjectContains?.lowercased(), !subjectContains.isEmpty { - subjectMatches = subject.contains(subjectContains) - } else { - subjectMatches = true - } - - if (emailMatches || domainMatches) && subjectMatches { - return override.priority - } - } - - return nil - } - - private func memoryPrompt(for thread: MailThreadDetail) -> String { - let senderEmail = thread.messages.last?.from?.email.lowercased() - let senderDomain = senderEmail?.split(separator: "@").last.map(String.init) - let relevantMemories = memories.filter { memory in - memory.senderEmail?.lowercased() == senderEmail || - memory.senderDomain?.lowercased() == senderDomain || - memory.kind == .writingStyle - } - if relevantMemories.isEmpty { - return "No stored mail memories." - } - return relevantMemories.prefix(6).map { "[\($0.kind.rawValue)] \($0.title): \($0.detail)" }.joined(separator: "\n") - } - - private func defaultProposals(for thread: MailThreadDetail) -> [MailAgentActionProposal] { - [ - MailAgentActionProposal(kind: .draftReply, title: "Draft a reply", detail: "Prepare a suggested reply for review."), - MailAgentActionProposal(kind: .createTask, title: "Create a task", detail: "Turn this thread into a Bugbook task."), - MailAgentActionProposal(kind: .createNote, title: "Create a note", detail: "Capture this thread as a workspace note."), - MailAgentActionProposal(kind: .createCalendarEvent, title: "Create a calendar event", detail: "Schedule follow-up work in Google Calendar."), - MailAgentActionProposal(kind: .summarizeToNote, title: "Summarize to note", detail: "Save a concise thread summary locally."), - MailAgentActionProposal(kind: .gatherContext, title: "Gather context", detail: "Refresh sender and workspace context."), - ] - } - - private func createNote(from thread: MailThreadDetail, workspacePath: String, titlePrefix: String) throws -> String { - let name = "\(titlePrefix) \(thread.subject)" - let path = try fileSystem.createNewFile(in: workspacePath, name: sanitizedFileTitle(name)) - let content = """ - # \(thread.subject) - - ## Participants - \(thread.participants.joined(separator: ", ")) - - ## Summary - \(thread.snippet) - - ## Thread - \(threadTranscript(for: thread)) - """ - try fileSystem.saveFile(at: path, content: content) - return path - } - - private func createSummaryNote(from thread: MailThreadDetail, settings: AppSettings, workspacePath: String, aiService: AiService) async throws -> String { - let provider = AiServiceMailModelProvider(aiService: aiService) - var summary = thread.snippet - if await provider.executionPath(using: settings) != .unavailable { - let systemPrompt = """ - Summarize an email thread into markdown. - Return only markdown with sections: - ## Summary - ## Action Items - """ - let prompt = threadTranscript(for: thread) - if let response = try? await provider.generate( - systemPrompt: systemPrompt, - userPrompt: prompt, - workspacePath: workspacePath, - settings: settings, - maxTokens: 900 - ) { - summary = response - } - } - - let path = try fileSystem.createNewFile(in: workspacePath, name: sanitizedFileTitle("Mail Summary \(thread.subject)")) - let content = """ - # \(thread.subject) - - \(summary) - """ - try fileSystem.saveFile(at: path, content: content) - return path - } - - private func calendarDraft(for thread: MailThreadDetail) -> CalendarEventDraft { - let startDate = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date().addingTimeInterval(3600) - let endDate = startDate.addingTimeInterval(3600) - let summary = records[thread.id]?.analysis?.suggestedAction ?? thread.snippet - return CalendarEventDraft( - title: thread.subject, - startDate: startDate, - endDate: endDate, - isAllDay: false, - notes: summary, - calendarId: "primary" - ) - } - - private func taskDetail(for thread: MailThreadDetail) -> String { - var lines = [ - "Thread: \(thread.subject)", - "Participants: \(thread.participants.joined(separator: ", "))", - "Snippet: \(thread.snippet)", - ] - if let action = records[thread.id]?.analysis?.suggestedAction { - lines.append("Suggested action: \(action)") - } - return lines.joined(separator: "\n") - } - - private func threadTranscript(for thread: MailThreadDetail) -> String { - thread.messages.suffix(6).map { message in - let sender = message.from?.displayName ?? "Unknown" - let body = message.bodyText.trimmingCharacters(in: .whitespacesAndNewlines) - return """ - From: \(sender) - Date: \(message.date?.formatted(date: .abbreviated, time: .shortened) ?? "Unknown") - Subject: \(message.subject) - Body: - \(body.isEmpty ? message.snippet : body) - """ - }.joined(separator: "\n\n---\n\n") - } - - private func threadSignature(thread: MailThreadSummary) -> String? { - if let historyId = thread.historyId, !historyId.isEmpty { - return historyId - } - guard let date = thread.date else { return nil } - return "\(thread.messageCount)|\(date.timeIntervalSince1970)" - } - - private func workspaceMatches(for thread: MailThreadDetail, workspacePath: String?) -> [MailSenderContextReference] { - guard let workspacePath else { return [] } - let senderTokens = candidateTokens(for: thread) - guard !senderTokens.isEmpty else { return [] } - - var matches: [MailSenderContextReference] = [] - let enumerator = FileManager.default.enumerator(atPath: workspacePath) - while let item = enumerator?.nextObject() as? String { - guard item.hasSuffix(".md") else { continue } - let fullPath = (workspacePath as NSString).appendingPathComponent(item) - guard let content = try? String(contentsOfFile: fullPath, encoding: .utf8) else { continue } - let lowercased = content.lowercased() - guard senderTokens.contains(where: { lowercased.contains($0) }) else { continue } - let excerpt = content - .components(separatedBy: .newlines) - .first(where: { line in senderTokens.contains { line.lowercased().contains($0) } }) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - ?? String(content.prefix(140)) - matches.append(MailSenderContextReference(path: fullPath, excerpt: excerpt)) - if matches.count >= 5 { break } - } - return matches - } - - private func candidateTokens(for thread: MailThreadDetail) -> [String] { - guard let sender = thread.messages.last?.from ?? thread.messages.first?.from else { return [] } - var tokens = [sender.email.lowercased()] - if let name = sender.name?.lowercased() { - tokens.append(name) - tokens.append(contentsOf: name.split(separator: " ").map(String.init).filter { $0.count > 2 }) - } - return Array(Set(tokens)) - } - - private func subjectHint(for subject: String) -> String? { - let trimmed = subject.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed.split(separator: " ").prefix(3).joined(separator: " ") - } - - private func sanitizedFileTitle(_ value: String) -> String { - let sanitized = value - .replacingOccurrences(of: "[/:]+", with: "-", options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - return sanitized.isEmpty ? "Mail Note" : sanitized - } - - private func decodeJSON(_ raw: String, as type: T.Type) throws -> T { - let cleaned = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "^```(?:json)?\\s*", with: "", options: .regularExpression) - .replacingOccurrences(of: "\\s*```$", with: "", options: .regularExpression) - guard let start = cleaned.firstIndex(of: "{"), - let end = cleaned.lastIndex(of: "}") else { - throw MailIntelligenceError.noModelProvider - } - let jsonString = String(cleaned[start...end]) - let data = Data(jsonString.utf8) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(T.self, from: data) - } -} - -private struct AnalysisPayload: Decodable { - var priority: MailPriority - var reason: String - var suggestedAction: String - var flags: [MailThreadFlag] - var shouldGenerateDraft: Bool - var prefersReplyAll: Bool -} - -private struct DraftPayload: Decodable { - var subject: String - var body: String - var rationale: String -} - -private struct DraftRefinementPayload: Decodable { - var body: String - var rationale: String -} - -private struct ContextPayload: Decodable { - var summary: String -} diff --git a/Sources/Bugbook/Services/MeetingNoteService.swift b/Sources/Bugbook/Services/MeetingNoteService.swift index 1428413c..9f771ad6 100644 --- a/Sources/Bugbook/Services/MeetingNoteService.swift +++ b/Sources/Bugbook/Services/MeetingNoteService.swift @@ -1,18 +1,10 @@ import Foundation import BugbookCore -// MARK: - Transcription Result - -struct TranscriptionResult { - let fullText: String - let timestampedText: String -} - @MainActor @Observable class MeetingNoteService { var isCreating = false - var isProcessingTranscript = false var error: String? @ObservationIgnored private let fm = FileManager.default @@ -70,85 +62,6 @@ class MeetingNoteService { } } - // MARK: - Create Meeting Note with Transcript - - /// Creates a meeting note page enriched with a transcript, AI-generated summary, and action items. - /// If a calendar event is provided, links the note to it. Returns the file path to navigate to. - func createMeetingNoteWithTranscript( - transcription: TranscriptionResult, - event: CalendarEvent?, - workspace: String, - aiService: AiService, - apiKey: String - ) async -> String? { - // If there's an event with an existing linked page, return it - if let event, let existing = event.linkedPagePath, - fm.fileExists(atPath: existing) { - return existing - } - - isCreating = true - isProcessingTranscript = true - defer { - isCreating = false - isProcessingTranscript = false - } - - // Generate AI summary from transcript - let summary: AiService.TranscriptSummary - if !apiKey.isEmpty { - do { - summary = try await aiService.summarizeTranscript(transcription.fullText, apiKey: apiKey) - } catch { - self.error = "AI summary failed: \(error.localizedDescription)" - summary = AiService.TranscriptSummary(summary: "_(AI summary unavailable)_", actionItems: "- [ ] ") - } - } else { - summary = AiService.TranscriptSummary(summary: "_(No API key configured — add one in Settings to enable AI summaries)_", actionItems: "- [ ] ") - } - - // Determine title and date - let title = event?.title ?? "Meeting Notes" - let meetingDate = event?.startDate ?? Date() - - // Build content - let content = buildTranscriptNoteContent( - title: title, - date: meetingDate, - endDate: event?.endDate, - summary: summary.summary, - actionItems: summary.actionItems, - timestampedTranscript: transcription.timestampedText, - event: event - ) - - let filename = sanitizeFilename(title) - let dateStr = formatDateForFilename(meetingDate) - let pageName = "\(dateStr) — \(filename)" - let pagePath = (workspace as NSString).appendingPathComponent("\(pageName).md") - - // Write the page - do { - try await Task.detached { - try content.write(toFile: pagePath, atomically: true, encoding: .utf8) - }.value - - // Link to calendar event if present - if let event { - try? eventStore.linkEventToPage(eventId: event.id, pagePath: pagePath, in: workspace) - - await Task.detached { [fm] in - self.ensurePersonPagesSync(for: event.attendees, workspace: workspace, fm: fm) - }.value - } - - return pagePath - } catch { - self.error = error.localizedDescription - return nil - } - } - // MARK: - Import Recording /// Create a meeting note from an imported audio recording. @@ -205,31 +118,12 @@ class MeetingNoteService { } } - /// Append a transcript from an imported recording to an existing meeting note file. - func appendTranscriptToNote( - filePath: String, - fileURL: URL, - transcriptionService: TranscriptionService - ) async -> Bool { - do { - let segments = try await transcriptionService.transcribe(fileURL: fileURL) - let transcript = TranscriptionService.markdownFromSegments(segments) - var existing = (try? String(contentsOfFile: filePath, encoding: .utf8)) ?? "" - existing += "\n\n" + transcript - try existing.write(toFile: filePath, atomically: true, encoding: .utf8) - return true - } catch { - self.error = error.localizedDescription - return false - } - } - private func buildImportedRecordingContent(title: String, segments: [TranscriptSegment], transcript: String) -> String { var lines: [String] = [] // YAML frontmatter lines.append("---") - lines.append("title: \(yamlEscape(title))") + lines.append("title: \(Self.yamlEscape(title))") lines.append("date: \(Self.isoDateFormatter.string(from: Date()))") if let last = segments.last { let duration = Int(last.timestamp) / 60 @@ -264,6 +158,47 @@ class MeetingNoteService { return lines.joined(separator: "\n") } + // MARK: - Create Ad-Hoc Meeting Page + + /// Creates a blank meeting page with `type: meeting` frontmatter. Returns the file path. + /// Appends a counter to the filename if a collision exists. + func createAdHocMeetingPage(title: String, date: Date, workspace: String) -> String? { + let dateStr = formatDateForFilename(date) + let baseFilename = Self.sanitize(title) + let baseName = "\(dateStr) — \(baseFilename)" + + // Find a unique filename — bounded so a misbehaving fileExists can't spin forever. + var pagePath = (workspace as NSString).appendingPathComponent("\(baseName).md") + var counter = 2 + while fm.fileExists(atPath: pagePath) && counter <= 999 { + pagePath = (workspace as NSString).appendingPathComponent("\(baseName) \(counter).md") + counter += 1 + } + guard !fm.fileExists(atPath: pagePath) else { return nil } + + // Empty H1 so the title block renders the placeholder ("New Meeting") + // and the user can start typing immediately without having to delete text. + var lines: [String] = [] + lines.append("---") + lines.append("title: \(Self.yamlEscape(title))") + lines.append("date: \(Self.isoDateFormatter.string(from: date))") + lines.append("type: meeting") + lines.append("meeting_id: \(UUID().uuidString)") + lines.append("---") + lines.append("") + lines.append("# ") + lines.append("") + + let content = lines.joined(separator: "\n") + do { + try content.write(toFile: pagePath, atomically: true, encoding: .utf8) + return pagePath + } catch { + self.error = error.localizedDescription + return nil + } + } + // MARK: - Cached Formatters private static let longDateFormatter: DateFormatter = { @@ -272,12 +207,12 @@ class MeetingNoteService { private static let shortTimeFormatter: DateFormatter = { let df = DateFormatter(); df.dateFormat = "h:mm a"; return df }() - private static let isoDateFormatter: ISO8601DateFormatter = { + static let isoDateFormatter: ISO8601DateFormatter = { let df = ISO8601DateFormatter() df.formatOptions = [.withInternetDateTime] return df }() - private static let filenameDateFormatter: DateFormatter = { + static let filenameDateFormatter: DateFormatter = { let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"; return df }() @@ -288,7 +223,7 @@ class MeetingNoteService { // YAML frontmatter lines.append("---") - lines.append("title: \(yamlEscape(event.title))") + lines.append("title: \(Self.yamlEscape(event.title))") lines.append("date: \(Self.isoDateFormatter.string(from: event.startDate))") if !event.isAllDay { let duration = Int(event.endDate.timeIntervalSince(event.startDate) / 60) @@ -298,10 +233,11 @@ class MeetingNoteService { lines.append("participants:") for attendee in event.attendees { let name = attendee.displayName ?? attendee.email - lines.append(" - \(yamlEscape(name))") + lines.append(" - \(Self.yamlEscape(name))") } } lines.append("type: meeting") + lines.append("meeting_id: \(UUID().uuidString)") lines.append("---") lines.append("") @@ -359,76 +295,6 @@ class MeetingNoteService { return lines.joined(separator: "\n") } - private func buildTranscriptNoteContent( - title: String, - date: Date, - endDate: Date?, - summary: String, - actionItems: String, - timestampedTranscript: String, - event: CalendarEvent? - ) -> String { - var lines: [String] = [] - - // Title - lines.append("# \(title)") - lines.append("") - - // Metadata line - var meta = "**Date:** \(Self.longDateFormatter.string(from: date))" - if let endDate { - let minutes = Int(endDate.timeIntervalSince(date) / 60) - if minutes > 0 { - let duration = minutes >= 60 ? "\(minutes / 60) hr \(minutes % 60) min" : "\(minutes) min" - meta += " | **Duration:** \(duration)" - } - } - lines.append(meta) - lines.append("") - - // Attendees (if from a calendar event) - if let event, !event.attendees.isEmpty { - lines.append("## Attendees") - lines.append("") - for attendee in event.attendees { - let name = attendee.displayName ?? attendee.email - let wikilink = "[[\(sanitizeWikilinkName(name))]]" - let statusIcon = attendeeStatusIcon(attendee.responseStatus) - lines.append("- \(statusIcon) \(wikilink)") - } - lines.append("") - } - - // Summary - lines.append("## Summary") - lines.append("") - lines.append(summary) - lines.append("") - - // Action Items - lines.append("## Action Items") - lines.append("") - lines.append(actionItems) - lines.append("") - - // Transcript in a collapsible toggle - lines.append("") - lines.append("Full Transcript") - lines.append(timestampedTranscript) - lines.append("") - lines.append("") - - // Event description if present - if let notes = event?.notes, !notes.isEmpty { - lines.append("## Event Description") - lines.append("") - lines.append(notes) - lines.append("") - } - - return lines.joined(separator: "\n") - } - // MARK: - Person Pages private nonisolated func ensurePersonPagesSync(for attendees: [Attendee], workspace: String, fm: FileManager) { @@ -477,8 +343,14 @@ class MeetingNoteService { } /// Wrap value in quotes if it contains YAML-special characters. - private func yamlEscape(_ value: String) -> String { - let needsQuoting = value.contains(":") || value.contains("#") || value.contains("\"") || value.contains("'") || value.hasPrefix("-") || value.hasPrefix("{") || value.hasPrefix("[") + static func yamlEscape(_ value: String) -> String { + let needsQuoting = value.contains(":") + || value.contains("#") + || value.contains("\"") + || value.contains("'") + || value.hasPrefix("-") + || value.hasPrefix("{") + || value.hasPrefix("[") if needsQuoting { return "\"\(value.replacingOccurrences(of: "\"", with: "\\\""))\"" } diff --git a/Sources/Bugbook/Services/MeetingNotificationService.swift b/Sources/Bugbook/Services/MeetingNotificationService.swift new file mode 100644 index 00000000..52ad0a08 --- /dev/null +++ b/Sources/Bugbook/Services/MeetingNotificationService.swift @@ -0,0 +1,124 @@ +import Foundation +import UserNotifications +import BugbookCore + +/// Schedules macOS notifications for upcoming calendar events that qualify as meetings. +/// A meeting qualifies if: non-all-day AND (2+ attendees OR has a conference URL). +/// Notifications fire at meeting start time with "Record" and "Open Notes" actions. +@MainActor +@Observable +class MeetingNotificationService { + private var scheduledEventIds: Set = [] + private var pollingTask: Task? + + static let categoryIdentifier = "MEETING_REMINDER" + static let recordActionIdentifier = "RECORD_MEETING" + static let openNotesActionIdentifier = "OPEN_NOTES" + + /// Request notification permission and register action category. + func setup() { + let center = UNUserNotificationCenter.current() + + // Define actions + let recordAction = UNNotificationAction( + identifier: Self.recordActionIdentifier, + title: "Record", + options: [.foreground] + ) + let openNotesAction = UNNotificationAction( + identifier: Self.openNotesActionIdentifier, + title: "Open Notes", + options: [.foreground] + ) + + let category = UNNotificationCategory( + identifier: Self.categoryIdentifier, + actions: [recordAction, openNotesAction], + intentIdentifiers: [], + options: [] + ) + center.setNotificationCategories([category]) + + // Request permission + center.requestAuthorization(options: [.alert, .sound]) { _, _ in } + } + + /// Start polling calendar events and scheduling notifications. + func startPolling(calendarService: CalendarService) { + pollingTask?.cancel() + pollingTask = Task { + while !Task.isCancelled { + scheduleNotifications(for: calendarService.events) + try? await Task.sleep(for: .seconds(60)) + } + } + } + + /// Schedule notifications for qualifying events that start within the next hour. + private func scheduleNotifications(for events: [CalendarEvent]) { + let now = Date() + let horizon = now.addingTimeInterval(3600) // 1 hour ahead + + for event in events { + guard !event.isAllDay, + event.startDate > now, + event.startDate <= horizon, + !scheduledEventIds.contains(event.id), + isMeetingEvent(event) else { continue } + + scheduleNotification(for: event) + scheduledEventIds.insert(event.id) + } + + // Prune IDs for events that have already started (no longer need tracking) + let pastIds = Set(events.filter { $0.startDate <= now }.map(\.id)) + scheduledEventIds.subtract(pastIds) + } + + /// Check if an event qualifies as a meeting (2+ attendees or has conference URL). + private func isMeetingEvent(_ event: CalendarEvent) -> Bool { + if event.attendees.count >= 2 { return true } + if let url = event.conferenceURL, !url.isEmpty { return true } + return false + } + + private func scheduleNotification(for event: CalendarEvent) { + let content = UNMutableNotificationContent() + content.title = event.title + content.body = formatMeetingBody(event) + content.sound = .default + content.categoryIdentifier = Self.categoryIdentifier + content.userInfo = ["eventId": event.id, "eventTitle": event.title] + + // Fire at the event start time + let components = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute], + from: event.startDate + ) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + + let request = UNNotificationRequest( + identifier: "meeting-\(event.id)", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error { + Log.app.error("Failed to schedule meeting notification: \(error.localizedDescription)") + } + } + } + + private func formatMeetingBody(_ event: CalendarEvent) -> String { + var parts: [String] = [] + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + parts.append("Starts at \(formatter.string(from: event.startDate))") + if !event.attendees.isEmpty { + let names = event.attendees.prefix(3).compactMap { $0.displayName ?? $0.email } + parts.append("with \(names.joined(separator: ", "))") + } + return parts.joined(separator: " ") + } +} diff --git a/Sources/Bugbook/Services/MeetingTranscriptStore.swift b/Sources/Bugbook/Services/MeetingTranscriptStore.swift new file mode 100644 index 00000000..1e94f630 --- /dev/null +++ b/Sources/Bugbook/Services/MeetingTranscriptStore.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A single transcribed utterance. +struct MeetingTranscriptEntry: Codable, Identifiable, Equatable { + let id: UUID + var text: String + /// Seconds from the start of the recording. + var timestamp: TimeInterval + /// Speaker label. "self" = the user, "other" = remote, or a name string. Defaults to "self". + var speaker: String + + init(id: UUID = UUID(), text: String, timestamp: TimeInterval = 0, speaker: String = "self") { + self.id = id + self.text = text + self.timestamp = timestamp + self.speaker = speaker + } +} + +struct MeetingTranscript: Codable, Equatable { + var entries: [MeetingTranscriptEntry] = [] + var summary: [String] = [] + var actionItems: [String] = [] + /// IDs of blocks injected into the meeting page by AI generation (Summary heading + bullets, + /// Action Items heading + tasks). Tracked here so regenerate can find and remove them + /// even if the user has renamed the headings. + var generatedBlockIds: [UUID] = [] + var createdAt: Date = .now + + var fullText: String { + entries.map(\.text).joined(separator: " ") + } +} + +/// Persists meeting transcripts to `/.bugbook/meetings/.json`. +/// Stored separately from the markdown so transcript size doesn't bloat autosave. +/// Keyed by `meeting_id` UUID stored in the meeting page's YAML frontmatter, so transcripts +/// follow the page through renames and moves. +/// +/// Nonisolated so callers can perform file I/O off the main actor via `Task.detached`. +final class MeetingTranscriptStore: @unchecked Sendable { + private let fm = FileManager.default + + /// Load a transcript by meeting ID, or return an empty one. + func load(meetingId: String, workspace: String) -> MeetingTranscript { + let url = fileURL(meetingId: meetingId, workspace: workspace) + guard fm.fileExists(atPath: url.path) else { return MeetingTranscript() } + do { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(MeetingTranscript.self, from: data) + } catch { + Log.app.error("Failed to load meeting transcript \(meetingId): \(error.localizedDescription)") + return MeetingTranscript() + } + } + + /// Save a transcript by meeting ID. + func save(_ transcript: MeetingTranscript, meetingId: String, workspace: String) { + let url = fileURL(meetingId: meetingId, workspace: workspace) + let dir = url.deletingLastPathComponent() + do { + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(transcript) + try data.write(to: url, options: .atomic) + } catch { + Log.app.error("Failed to save meeting transcript \(meetingId): \(error.localizedDescription)") + } + } + + private func fileURL(meetingId: String, workspace: String) -> URL { + URL(fileURLWithPath: workspace, isDirectory: true) + .appendingPathComponent(".bugbook", isDirectory: true) + .appendingPathComponent("meetings", isDirectory: true) + .appendingPathComponent("\(meetingId).json") + } +} diff --git a/Sources/Bugbook/Services/TerminalSession.swift b/Sources/Bugbook/Services/TerminalSession.swift index 36e21e43..d29438da 100644 --- a/Sources/Bugbook/Services/TerminalSession.swift +++ b/Sources/Bugbook/Services/TerminalSession.swift @@ -7,7 +7,8 @@ private let log = Logger(subsystem: "com.bugbook.app", category: "Terminal") /// Tracks the most recently focused Ghostty surface for clipboard callbacks. /// Set on the main thread in becomeFirstResponder; read in C callbacks that also run on main. -nonisolated(unsafe) var _activeSurface: ghostty_surface_t? = nil +// swiftlint:disable:next identifier_name +nonisolated(unsafe) var _activeSurface: ghostty_surface_t? /// Manages a single terminal instance backed by a libghostty surface. @MainActor @@ -89,6 +90,21 @@ class GhosttySurfaceHostView: NSView { } } + private var lastSentWidth: UInt32 = 0 + private var lastSentHeight: UInt32 = 0 + + override func layout() { + super.layout() + guard let surface, bounds.width > 0, bounds.height > 0 else { return } + let scale = window?.backingScaleFactor ?? 2.0 + let w = UInt32(bounds.width * scale) + let h = UInt32(bounds.height * scale) + guard w != lastSentWidth || h != lastSentHeight else { return } + lastSentWidth = w + lastSentHeight = h + ghostty_surface_set_size(surface, w, h) + } + override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if let surface { @@ -112,21 +128,25 @@ class GhosttySurfaceHostView: NSView { override func performKeyEquivalent(with event: NSEvent) -> Bool { guard let surface else { return super.performKeyEquivalent(with: event) } - // Forward key equivalents to ghostty before AppKit routes them - // through the menu system. This ensures Cmd+V (paste), Cmd+C - // (copy), and other ghostty bindings are handled by the terminal. + // Let the menu system and SwiftUI keyboard shortcuts try first. + // This ensures app-level shortcuts (Cmd+N, Cmd+T, Cmd+W, Cmd+., + // Cmd+Shift+C/D, etc.) work when the terminal has focus. + // Terminal paste still works because the menu's Edit > Paste + // sends paste: to the first responder (this view). + if super.performKeyEquivalent(with: event) { return true } + + // If the menu system didn't handle it, forward to Ghostty for + // terminal-specific bindings (Cmd+C copy, etc.) let text = event.ghosttyCharacters var keyEv = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS) if let text, !text.isEmpty, let codepoint = text.utf8.first, codepoint >= 0x20 { - let handled = text.withCString { ptr -> Bool in + return text.withCString { ptr -> Bool in keyEv.text = ptr return ghostty_surface_key(surface, keyEv) } - if handled { return true } } else { - if ghostty_surface_key(surface, keyEv) { return true } + return ghostty_surface_key(surface, keyEv) } - return super.performKeyEquivalent(with: event) } // MARK: - Paste (fallback for Edit menu) @@ -187,7 +207,35 @@ class GhosttySurfaceHostView: NSView { override func scrollWheel(with event: NSEvent) { guard let surface else { super.scrollWheel(with: event); return } - ghostty_surface_mouse_scroll(surface, event.scrollingDeltaX, event.scrollingDeltaY, ghostty_input_scroll_mods_t(ghosttyMods(event.modifierFlags).rawValue)) + + var x = event.scrollingDeltaX + var y = event.scrollingDeltaY + + // Ghostty's official macOS app applies a 2x multiplier to trackpad deltas. + if event.hasPreciseScrollingDeltas { + x *= 2 + y *= 2 + } + + // Build packed scroll mods: [precision:1][momentum:3] + // Matches Ghostty's ScrollMods layout from Ghostty.Input.swift. + var scrollMods: Int32 = 0 + if event.hasPreciseScrollingDeltas { + scrollMods |= 1 // bit 0: precision + } + let momentum: Int32 + switch event.momentumPhase { + case .began: momentum = 1 + case .stationary: momentum = 2 + case .changed: momentum = 3 + case .ended: momentum = 4 + case .cancelled: momentum = 5 + case .mayBegin: momentum = 6 + default: momentum = 0 + } + scrollMods |= (momentum << 1) // bits 1-3: momentum phase + + ghostty_surface_mouse_scroll(surface, x, y, ghostty_input_scroll_mods_t(scrollMods)) } override func updateTrackingAreas() { diff --git a/Sources/Bugbook/Services/WebKitBrowserEngine.swift b/Sources/Bugbook/Services/WebKitBrowserEngine.swift index 24d82833..ae11ab2a 100644 --- a/Sources/Bugbook/Services/WebKitBrowserEngine.swift +++ b/Sources/Bugbook/Services/WebKitBrowserEngine.swift @@ -5,6 +5,7 @@ import WebKit @MainActor final class WebKitBrowserEngine: BrowserEngine { private let websiteDataStore = WKWebsiteDataStore.default() + private var popupWindows: [UUID: WebKitBrowserPopupWindowController] = [:] func makePage( for paneID: UUID, @@ -15,29 +16,60 @@ final class WebKitBrowserEngine: BrowserEngine { WebKitBrowserPage( websiteDataStore: websiteDataStore, initialURL: initialURL, - eventHandler: eventHandler + eventHandler: eventHandler, + popupPresenter: { [weak self] configuration in + self?.presentPopup(using: configuration) + } ) } + + func clearCookies() async throws { + let cookieDataTypes: Set = [WKWebsiteDataTypeCookies] + let records = await websiteDataStore.dataRecords(ofTypes: cookieDataTypes) + guard !records.isEmpty else { return } + await websiteDataStore.removeData(ofTypes: cookieDataTypes, for: records) + } + + private func presentPopup(using configuration: WKWebViewConfiguration) -> WKWebView { + let popupID = UUID() + let controller = WebKitBrowserPopupWindowController( + configuration: configuration, + onClose: { [weak self] in + self?.popupWindows.removeValue(forKey: popupID) + }, + popupPresenter: { [weak self] nestedConfiguration in + self?.presentPopup(using: nestedConfiguration) + } + ) + popupWindows[popupID] = controller + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return controller.webView + } } @MainActor private final class WebKitBrowserPage: NSObject, BrowserPage { let webView: WKWebView private let eventHandler: BrowserPageEventHandler + private let popupPresenter: (WKWebViewConfiguration) -> WKWebView? private lazy var coordinator = WebKitBrowserPageCoordinator(page: self) private lazy var downloadDelegate = WebKitBrowserDownloadDelegate(page: self) init( websiteDataStore: WKWebsiteDataStore, initialURL: URL?, - eventHandler: @escaping BrowserPageEventHandler + eventHandler: @escaping BrowserPageEventHandler, + popupPresenter: @escaping (WKWebViewConfiguration) -> WKWebView? ) { self.eventHandler = eventHandler + self.popupPresenter = popupPresenter let configuration = WKWebViewConfiguration() configuration.websiteDataStore = websiteDataStore configuration.defaultWebpagePreferences.allowsContentJavaScript = true - configuration.applicationNameForUserAgent = "HarborDesktop" + configuration.preferences.javaScriptCanOpenWindowsAutomatically = true let userContentController = WKUserContentController() configuration.userContentController = userContentController @@ -159,6 +191,10 @@ private final class WebKitBrowserPage: NSObject, BrowserPage { eventHandler(.openInNewTab(url)) } + fileprivate func presentPopup(configuration: WKWebViewConfiguration) -> WKWebView? { + popupPresenter(configuration) + } + fileprivate func emitDownloadStatus(_ message: String) { eventHandler(.downloadStatusChanged(message)) } @@ -268,15 +304,172 @@ private final class WebKitBrowserPageCoordinator: NSObject, WKNavigationDelegate for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { - guard navigationAction.targetFrame == nil, - let url = navigationAction.request.url else { - return nil + configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + + let shouldPresentPopupWindow = + navigationAction.navigationType != .linkActivated + || windowFeatures.width != nil + || windowFeatures.height != nil + + if shouldPresentPopupWindow, + let popupWebView = page?.presentPopup(configuration: configuration) { + return popupWebView + } + + if navigationAction.targetFrame == nil, + let url = navigationAction.request.url { + page?.emitOpenInNewTab(url) } - page?.emitOpenInNewTab(url) return nil } } +@MainActor +private final class WebKitBrowserPopupWindowController: NSWindowController { + let webView: WKWebView + private let onClose: () -> Void + private lazy var coordinator = WebKitPopupCoordinator( + webView: webView, + onClose: onClose, + popupPresenter: popupPresenter + ) + private let popupPresenter: (WKWebViewConfiguration) -> WKWebView? + + init( + configuration: WKWebViewConfiguration, + onClose: @escaping () -> Void, + popupPresenter: @escaping (WKWebViewConfiguration) -> WKWebView? + ) { + configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + self.webView = WKWebView(frame: .zero, configuration: configuration) + self.onClose = onClose + self.popupPresenter = popupPresenter + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unifiedCompact + window.isReleasedWhenClosed = false + + let container = NSView(frame: window.contentView?.bounds ?? .zero) + container.autoresizingMask = [.width, .height] + webView.frame = container.bounds + webView.autoresizingMask = [.width, .height] + webView.allowsBackForwardNavigationGestures = true + webView.setValue(false, forKey: "drawsBackground") + if #available(macOS 13.3, *), AppEnvironment.isDev { + webView.isInspectable = true + } + container.addSubview(webView) + window.contentView = container + + super.init(window: window) + + webView.navigationDelegate = coordinator + webView.uiDelegate = coordinator + coordinator.attach() + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [onClose] _ in + onClose() + } + window.center() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +@MainActor +private final class WebKitPopupCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + private weak var webView: WKWebView? + private let onClose: () -> Void + private let popupPresenter: (WKWebViewConfiguration) -> WKWebView? + private var observations: [NSKeyValueObservation] = [] + private let downloadDelegate = WebKitPopupDownloadDelegate() + + init( + webView: WKWebView, + onClose: @escaping () -> Void, + popupPresenter: @escaping (WKWebViewConfiguration) -> WKWebView? + ) { + self.webView = webView + self.onClose = onClose + self.popupPresenter = popupPresenter + } + + func attach() { + guard let webView else { return } + observations = [ + webView.observe(\.title, options: [.initial, .new]) { webView, _ in + webView.window?.title = webView.title ?? "Browser" + if let host = webView.url?.host, webView.window?.title == "Browser" { + webView.window?.title = host + } + } + ] + } + + func webViewDidClose(_ webView: WKWebView) { + webView.window?.close() + onClose() + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + let shouldPresentPopupWindow = + navigationAction.navigationType != .linkActivated + || windowFeatures.width != nil + || windowFeatures.height != nil + guard shouldPresentPopupWindow else { return nil } + return popupPresenter(configuration) + } + + func webView( + _ webView: WKWebView, + navigationAction: WKNavigationAction, + didBecome download: WKDownload + ) { + download.delegate = downloadDelegate + } + + func webView( + _ webView: WKWebView, + navigationResponse: WKNavigationResponse, + didBecome download: WKDownload + ) { + download.delegate = downloadDelegate + } +} + +@MainActor +private final class WebKitPopupDownloadDelegate: NSObject, WKDownloadDelegate { + func download( + _ download: WKDownload, + decideDestinationUsing response: URLResponse, + suggestedFilename: String, + completionHandler: @escaping @MainActor @Sendable (URL?) -> Void + ) { + let directory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + completionHandler(directory.appendingPathComponent(suggestedFilename)) + } +} + @MainActor private final class WebKitBrowserDownloadDelegate: NSObject, WKDownloadDelegate { private weak var page: WebKitBrowserPage? diff --git a/Sources/Bugbook/ViewModels/MeetingsViewModel.swift b/Sources/Bugbook/ViewModels/MeetingsViewModel.swift index 9f37278a..798d78d0 100644 --- a/Sources/Bugbook/ViewModels/MeetingsViewModel.swift +++ b/Sources/Bugbook/ViewModels/MeetingsViewModel.swift @@ -112,35 +112,61 @@ final class MeetingsViewModel { if WorkspacePathRules.shouldIgnoreAbsolutePath(url.path) { continue } - // Skip trash if url.path.contains("/.trash/") { continue } let filePath = url.path let filename = url.lastPathComponent let pageName = String(filename.dropLast(3)) // strip .md - // Compute parent page name relative to workspace let relativePath = String(filePath.dropFirst(workspace.count)) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) let parentPageName = (relativePath as NSString).deletingLastPathComponent let displayParent = parentPageName.isEmpty ? pageName : "\(parentPageName)/\(pageName)" - // Strategy 1: Look for blocks inside file content - if let content = try? String(contentsOfFile: filePath, encoding: .utf8) { - let meetingMatches = parseMeetingBlocks(content: content, pageName: pageName, parentDisplay: displayParent, filePath: filePath) - results.append(contentsOf: meetingMatches) - if !meetingMatches.isEmpty { continue } - } + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { continue } - // Strategy 2: Date-prefixed meeting note pattern (YYYY-MM-DD - Title.md) - if let meeting = parseDatePrefixedMeeting(filename: filename, displayParent: displayParent, filePath: filePath) { + // Strategy 1: YAML frontmatter with `type: meeting` — the canonical meeting format + if let meeting = parseFrontmatterMeeting(content: content, pageName: pageName, displayParent: displayParent, filePath: filePath) { results.append(meeting) + continue } + + // Strategy 2: legacy `` blocks embedded in any page + let meetingMatches = parseMeetingBlocks(content: content, pageName: pageName, parentDisplay: displayParent, filePath: filePath) + results.append(contentsOf: meetingMatches) } return results } + /// Parse a meeting page that uses `type: meeting` YAML frontmatter. + /// Returns nil if the file isn't a frontmatter-typed meeting page. + private nonisolated static func parseFrontmatterMeeting( + content: String, + pageName: String, + displayParent: String, + filePath: String + ) -> DiscoveredMeeting? { + let (yaml, _) = MarkdownBlockParser.stripYAMLFrontmatter(content) + guard !yaml.isEmpty, + MarkdownBlockParser.yamlValue(for: "type", in: yaml) == "meeting" else { + return nil + } + + let titleField = MarkdownBlockParser.yamlValue(for: "title", in: yaml) + let dateField = MarkdownBlockParser.yamlValue(for: "date", in: yaml) + + let title = (titleField?.isEmpty == false) ? titleField! : pageName + let timestamp = dateField.flatMap { MeetingNoteService.isoDateFormatter.date(from: $0) } ?? fileModDate(filePath) + + return DiscoveredMeeting( + title: title, + timestamp: timestamp, + parentPageName: displayParent, + filePath: filePath + ) + } + /// Parse `...` blocks from markdown content. /// Extracts title from first heading inside the block, or uses page name. /// Extracts timestamp from **Date:** metadata or falls back to file mod date. @@ -184,45 +210,6 @@ final class MeetingsViewModel { return results } - /// Parse date-prefixed meeting note files like "2024-01-15 - Weekly Standup.md" - /// or "2024-01-15 \u{2014} Weekly Standup.md" - private nonisolated static func parseDatePrefixedMeeting( - filename: String, - displayParent: String, - filePath: String - ) -> DiscoveredMeeting? { - let name = String(filename.dropLast(3)) // strip .md - // Match YYYY-MM-DD followed by separator - guard name.count >= 10 else { return nil } - let datePrefix = String(name.prefix(10)) - - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.locale = Locale(identifier: "en_US_POSIX") - guard let date = formatter.date(from: datePrefix) else { return nil } - - // Extract title after separator (dash, em-dash, or pipe) - let afterDate = String(name.dropFirst(10)).trimmingCharacters(in: .whitespaces) - let title: String - if afterDate.hasPrefix("\u{2014}") || afterDate.hasPrefix("-") || afterDate.hasPrefix("|") { - title = String(afterDate.dropFirst()).trimmingCharacters(in: .whitespaces) - } else if afterDate.isEmpty { - title = datePrefix - } else { - // Doesn't match the meeting note pattern - return nil - } - - guard !title.isEmpty else { return nil } - - return DiscoveredMeeting( - title: title, - timestamp: date, - parentPageName: displayParent, - filePath: filePath - ) - } - // MARK: - Helpers private nonisolated static func extractFirstHeading(from text: String) -> String? { @@ -245,24 +232,27 @@ final class MeetingsViewModel { return nil } + private nonisolated static let legacyLongDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE, MMMM d, yyyy" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private nonisolated static let legacyShortDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + private nonisolated static func extractDateFromMetadata(_ text: String) -> Date? { - // Look for **Date:** EEEE, MMMM d, yyyy or yyyy-MM-dd for line in text.split(separator: "\n", omittingEmptySubsequences: true) { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("**Date:**") else { continue } let dateStr = String(trimmed.dropFirst("**Date:**".count)).trimmingCharacters(in: .whitespaces) - - // Try long format first - let longFmt = DateFormatter() - longFmt.dateFormat = "EEEE, MMMM d, yyyy" - longFmt.locale = Locale(identifier: "en_US_POSIX") - if let date = longFmt.date(from: dateStr) { return date } - - // Try ISO-style - let isoFmt = DateFormatter() - isoFmt.dateFormat = "yyyy-MM-dd" - isoFmt.locale = Locale(identifier: "en_US_POSIX") - if let date = isoFmt.date(from: dateStr) { return date } + if let date = legacyLongDateFormatter.date(from: dateStr) { return date } + if let date = legacyShortDateFormatter.date(from: dateStr) { return date } } return nil } diff --git a/Sources/Bugbook/ViewModels/SidebarPeekState.swift b/Sources/Bugbook/ViewModels/SidebarPeekState.swift deleted file mode 100644 index 22acb47e..00000000 --- a/Sources/Bugbook/ViewModels/SidebarPeekState.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUI - -@MainActor -@Observable -final class SidebarPeekState { - var isVisible = false - var toggleHovering = false - var edgeHovering = false - var overlayHovering = false - var trashPopoverPresented = false - - @ObservationIgnored private var dismissTask: Task? - @ObservationIgnored private var dwellTask: Task? - - var interactionActive: Bool { - toggleHovering || edgeHovering || overlayHovering || trashPopoverPresented - } - - func setToggleHovering(_ hovering: Bool, eligible: Bool, reduceMotion: Bool) { - guard toggleHovering != hovering else { return } - toggleHovering = hovering - sync(eligible: eligible, reduceMotion: reduceMotion) - } - - func setEdgeHovering(_ hovering: Bool, eligible: Bool, reduceMotion: Bool) { - guard edgeHovering != hovering else { return } - edgeHovering = hovering - sync(eligible: eligible, reduceMotion: reduceMotion) - } - - func setOverlayHovering(_ hovering: Bool, eligible: Bool, reduceMotion: Bool) { - guard overlayHovering != hovering else { return } - overlayHovering = hovering - sync(eligible: eligible, reduceMotion: reduceMotion) - } - - func dismiss(immediately: Bool, reduceMotion: Bool) { - cancelDismissTask() - trashPopoverPresented = false - resetHoverState() - guard isVisible else { return } - if immediately { - isVisible = false - } else { - withAnimation(animation(reduceMotion: reduceMotion)) { - isVisible = false - } - } - } - - func sync(eligible: Bool, reduceMotion: Bool) { - cancelDismissTask() - - guard eligible else { - cancelDwellTask() - dismiss(immediately: true, reduceMotion: reduceMotion) - return - } - - let anim = animation(reduceMotion: reduceMotion) - - if interactionActive { - if isVisible { return } - - if toggleHovering || overlayHovering { - cancelDwellTask() - withAnimation(anim) { isVisible = true } - return - } - - if edgeHovering && dwellTask == nil { - dwellTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 200_000_000) - guard !Task.isCancelled else { return } - guard edgeHovering else { return } - withAnimation(anim) { isVisible = true } - } - } - return - } - - // No interaction — schedule dismiss - cancelDwellTask() - guard isVisible else { return } - dismissTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 150_000_000) - guard !Task.isCancelled else { return } - guard !interactionActive else { return } - withAnimation(anim) { isVisible = false } - } - } - - func cancelDwellTask() { - dwellTask?.cancel() - dwellTask = nil - } - - func cleanUp() { - dismissTask?.cancel() - dismissTask = nil - dwellTask?.cancel() - dwellTask = nil - } - - // MARK: - Private - - private func cancelDismissTask() { - dismissTask?.cancel() - guard dismissTask != nil else { return } - dismissTask = nil - } - - private func resetHoverState() { - guard toggleHovering || edgeHovering || overlayHovering else { return } - toggleHovering = false - edgeHovering = false - overlayHovering = false - } - - private func animation(reduceMotion: Bool) -> Animation { - .easeInOut(duration: reduceMotion ? 0.1 : 0.18) - } -} diff --git a/Sources/Bugbook/Views/Agent/AgentHubView.swift b/Sources/Bugbook/Views/Agent/AgentHubView.swift deleted file mode 100644 index f2c09555..00000000 --- a/Sources/Bugbook/Views/Agent/AgentHubView.swift +++ /dev/null @@ -1,286 +0,0 @@ -import SwiftUI -import BugbookCore - -struct AgentHubView: View { - var workspacePath: String? - - @State private var viewModel = AgentHubViewModel() - @State private var newTaskTitle: String = "" - @State private var newTaskAssignee: String = "" - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - header - metricsRow - createTaskSection - activityList - } - .padding(20) - .background(Color.fallbackEditorBg) - .onAppear { - viewModel.start(workspacePath: workspacePath) - } - .onDisappear { - viewModel.stop() - } - .onChange(of: workspacePath) { _, newValue in - viewModel.start(workspacePath: newValue) - } - } - - private var header: some View { - HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 4) { - Text("Agent Hub") - .font(.system(size: 28, weight: .bold)) - if let workspacePath { - Text((workspacePath as NSString).lastPathComponent) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - Spacer() - - Button { - viewModel.refresh(workspacePath: workspacePath) - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - } - } - - private var metricsRow: some View { - ScrollView(.horizontal) { - HStack(spacing: 10) { - statusCard(.backlog, color: .gray) - statusCard(.todo, color: .blue) - statusCard(.inProgress, color: .orange) - statusCard(.blocked, color: .red) - statusCard(.done, color: .green) - statusCard(.cancelled, color: .secondary) - } - } - .scrollIndicators(.hidden) - } - - private func statusCard(_ status: AgentTaskStatus, color: Color) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(statusLabel(status)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - - Text("\(viewModel.statusCount(status))") - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(color) - } - .frame(width: 92, alignment: .leading) - .padding(12) - .background(Color.primary.opacity(0.04)) - .clipShape(.rect(cornerRadius: 10)) - } - - private var createTaskSection: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Quick Add Task") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - TextField("Task title", text: $newTaskTitle) - .textFieldStyle(.roundedBorder) - TextField("Assignee (optional)", text: $newTaskAssignee) - .textFieldStyle(.roundedBorder) - .frame(width: 220) - Button("Add") { - viewModel.createTask( - workspacePath: workspacePath, - title: newTaskTitle, - assignee: newTaskAssignee - ) - newTaskTitle = "" - newTaskAssignee = "" - } - .disabled(newTaskTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(14) - .background(Color.primary.opacity(0.04)) - .clipShape(.rect(cornerRadius: 10)) - } - - private var activityList: some View { - List { - if let error = viewModel.error { - Section("Status") { - Text(error) - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - } - - Section("Active Tasks") { - if viewModel.tasks.isEmpty { - Text("No active tasks") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - - ForEach(viewModel.tasks) { task in - taskRow(task) - } - } - - Section("Recent Runs") { - if viewModel.runs.isEmpty { - Text("No runs yet") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - - ForEach(viewModel.runs) { run in - HStack(spacing: 8) { - statusDot(run.status) - VStack(alignment: .leading, spacing: 2) { - Text(run.id) - .font(.system(size: 12, design: .monospaced)) - Text(run.summary ?? "\(run.agent) • \(run.status.rawValue)") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - Spacer() - Text(shortTime(run.endedAt ?? run.startedAt)) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - .padding(.vertical, 2) - } - } - - Section("Recent Events") { - if viewModel.events.isEmpty { - Text("No events yet") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - - ForEach(viewModel.events) { event in - HStack(spacing: 8) { - eventIcon(event.level) - .foregroundStyle(eventColor(event.level)) - VStack(alignment: .leading, spacing: 2) { - Text(event.message) - .font(.system(size: 13)) - .lineLimit(2) - Text("\(event.runId ?? "No run") • \(shortTime(event.timestamp))") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 2) - } - } - } - .listStyle(.inset) - } - - private func taskRow(_ task: AgentTask) -> some View { - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(task.title) - .font(.system(size: 14, weight: .medium)) - let metadata = [task.assignee, task.id].compactMap { $0 }.joined(separator: " • ") - if !metadata.isEmpty { - Text(metadata) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - - Spacer() - - Menu { - ForEach(AgentTaskStatus.allCases, id: \.self) { status in - Button(statusLabel(status)) { - viewModel.updateTaskStatus( - workspacePath: workspacePath, - taskId: task.id, - status: status - ) - } - } - } label: { - Text(statusLabel(task.status)) - .font(.system(size: 11, weight: .semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(statusColor(task.status).opacity(0.18)) - .foregroundStyle(statusColor(task.status)) - .clipShape(.rect(cornerRadius: 6)) - } - .buttonStyle(.plain) - } - .padding(.vertical, 2) - } - - private func shortTime(_ value: String) -> String { - if let date = ISO8601DateFormatter().date(from: value) { - return RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date()) - } - return value - } - - private func statusLabel(_ status: AgentTaskStatus) -> String { - switch status { - case .backlog: return "Backlog" - case .todo: return "Todo" - case .inProgress: return "In Progress" - case .blocked: return "Blocked" - case .done: return "Done" - case .cancelled: return "Cancelled" - } - } - - private func statusColor(_ status: AgentTaskStatus) -> Color { - switch status { - case .backlog: return .gray - case .todo: return .blue - case .inProgress: return .orange - case .blocked: return .red - case .done: return .green - case .cancelled: return .secondary - } - } - - private func statusDot(_ status: AgentRunStatus) -> some View { - Circle() - .fill(runColor(status)) - .frame(width: 8, height: 8) - } - - private func runColor(_ status: AgentRunStatus) -> Color { - switch status { - case .running: return .orange - case .succeeded: return .green - case .failed: return .red - case .cancelled: return .secondary - } - } - - private func eventIcon(_ level: AgentEventLevel) -> Image { - switch level { - case .info: return Image(systemName: "info.circle.fill") - case .warning: return Image(systemName: "exclamationmark.triangle.fill") - case .error: return Image(systemName: "xmark.octagon.fill") - } - } - - private func eventColor(_ level: AgentEventLevel) -> Color { - switch level { - case .info: return .blue - case .warning: return .orange - case .error: return .red - } - } -} diff --git a/Sources/Bugbook/Views/Browser/BrowserPaneView.swift b/Sources/Bugbook/Views/Browser/BrowserPaneView.swift index 0c5d67c9..55c292f5 100644 --- a/Sources/Bugbook/Views/Browser/BrowserPaneView.swift +++ b/Sources/Bugbook/Views/Browser/BrowserPaneView.swift @@ -15,6 +15,7 @@ struct BrowserPaneView: View { let onOpenBugbookEntry: (FileEntry) -> Void @FocusState private var omnibarFocused: Bool + @FocusState private var newTabSearchFocused: Bool @FocusState private var findFocused: Bool @State private var omnibarText = "" @State private var newTabSearchText = "" @@ -25,22 +26,30 @@ struct BrowserPaneView: View { @State private var isApplyingCleanup = false @State private var saveMessage: String? @State private var hoveredTabID: UUID? + @State private var searchableEntries: [BrowserSearchableEntry] = [] + @State private var savedRecords: [SavedWebPageRecord] = [] + @State private var omnibarSuggestions: [BrowserSuggestionItem] = [] + @State private var newTabSuggestions: [BrowserSuggestionItem] = [] + @State private var browserTabFrames: [UUID: CGRect] = [:] + @State private var draggedTabID: UUID? + @State private var draggedTabOffset: CGSize = .zero private let agentService = BrowserAgentService() private let savedPageStore = SavedWebPageStore() private let relativeDateFormatter = RelativeDateTimeFormatter() + private let browserTabDetachThreshold: CGFloat = 90 private var activeTab: BrowserTabState? { session.activeTab } private var activeSavedRecord: SavedWebPageRecord? { - guard let workspacePath = appState.workspacePath else { return nil } - if let recordID = activeTab?.savedRecordID { - return savedPageStore.records(in: workspacePath).first(where: { $0.id == recordID }) + guard let activeTab else { return nil } + if let recordID = activeTab.savedRecordID { + return savedRecords.first(where: { $0.id == recordID }) } - if let urlString = activeTab?.urlString { - return savedPageStore.record(forURL: urlString, in: workspacePath) + if !activeTab.urlString.isEmpty { + return savedRecords.first(where: { $0.urlString == activeTab.urlString }) } return nil } @@ -50,8 +59,16 @@ struct BrowserPaneView: View { } private var readLaterRecords: [SavedWebPageRecord] { - guard let workspacePath = appState.workspacePath else { return [] } - return agentService.listReadLater(in: workspacePath) + savedRecords.filter { $0.status == .unread } + } + + private var recentHistory: [BrowserRecentVisit] { + guard appState.settings.browserHistoryEnabled else { return [] } + return browserManager.browsingHistory + } + + private var showsTabStrip: Bool { + !chrome.autoHidesTabPills || session.tabs.count > 1 } var body: some View { @@ -103,75 +120,118 @@ struct BrowserPaneView: View { private func applyBrowserLifecycle(to view: V) -> some View { view .onAppear { - refreshSelectedTabDisplay() + refreshSavedRecords() + refreshSearchableEntries() + refreshSelectedTabDisplay(force: true) + refreshSuggestions() + DispatchQueue.main.async { + if activeTab?.urlString.isEmpty != false { + newTabSearchFocused = true + } else { + omnibarFocused = true + } + } } .onChange(of: session.selectedTabID) { _, _ in - refreshSelectedTabDisplay() + refreshSelectedTabDisplay(force: true) + refreshSuggestions() } .onChange(of: session.tabs) { _, _ in syncDisplayedText() } + .onChange(of: fileTree) { _, _ in + refreshSearchableEntries() + refreshSuggestions() + } + .onChange(of: appState.workspacePath) { _, _ in + refreshSavedRecords() + } + .onChange(of: appState.settings.browserSuggestionsEnabled) { _, _ in + refreshSuggestions() + } + .onChange(of: appState.settings.browserSuggestsBugbookPages) { _, _ in + refreshSuggestions() + } + .onChange(of: appState.settings.browserSuggestionLimit) { _, _ in + refreshSuggestions() + } + .onChange(of: appState.settings.browserSearchEngine) { _, _ in + refreshSuggestions() + } + .onChange(of: appState.settings.browserQuickLaunchItems) { _, _ in + refreshSuggestions() + } + .onChange(of: appState.settings.browserHistoryEnabled) { _, _ in + refreshSuggestions() + } + .onChange(of: browserManager.browsingHistory) { _, _ in + refreshSuggestions() + } + .onChange(of: omnibarText) { _, _ in + omnibarSuggestions = suggestions(for: omnibarText) + } + .onChange(of: newTabSearchText) { _, _ in + newTabSuggestions = suggestions(for: newTabSearchText) + } } private func applyBrowserNotifications(to view: V) -> some View { view - .onReceive(NotificationCenter.default.publisher(for: .browserFocusAddressBar)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserFocusAddressBar)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } + newTabSearchFocused = false omnibarFocused = true } - .onReceive(NotificationCenter.default.publisher(for: .browserNewTab)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserNewTab)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } createNewTab() } .onReceive(NotificationCenter.default.publisher(for: .browserCloseTab)) { notification in - let targetPaneID = notification.object as? UUID - if let targetPaneID, targetPaneID != paneID { - return - } - guard isFocusedPane, let selected = session.selectedTabID else { return } + guard shouldHandleBrowserCommand(notification), + let selected = session.selectedTabID else { return } session.closeTab(selected) } - .onReceive(NotificationCenter.default.publisher(for: .browserBack)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserBack)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } browserManager.goBack(in: paneID) } - .onReceive(NotificationCenter.default.publisher(for: .browserForward)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserForward)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } browserManager.goForward(in: paneID) } - .onReceive(NotificationCenter.default.publisher(for: .browserFind)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserFind)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } withAnimation(.easeInOut(duration: 0.15)) { showFindBar = true } findFocused = true } - .onReceive(NotificationCenter.default.publisher(for: .browserPrint)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserPrint)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } browserManager.printActiveTab(in: paneID) } - .onReceive(NotificationCenter.default.publisher(for: .browserSavePage)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserSavePage)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } Task { await saveCurrentTab() } } - .onReceive(NotificationCenter.default.publisher(for: .browserZoomIn)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserZoomIn)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } adjustZoom(by: 0.1) } - .onReceive(NotificationCenter.default.publisher(for: .browserZoomOut)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserZoomOut)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } adjustZoom(by: -0.1) } - .onReceive(NotificationCenter.default.publisher(for: .browserZoomReset)) { _ in - guard isFocusedPane else { return } - browserManager.setPageZoom(1.0, in: paneID) + .onReceive(NotificationCenter.default.publisher(for: .browserZoomReset)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } + browserManager.setPageZoom(BrowserPageState.defaultPageZoom, in: paneID) } - .onReceive(NotificationCenter.default.publisher(for: .browserPreviousTab)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserPreviousTab)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } selectAdjacentTab(step: -1) } - .onReceive(NotificationCenter.default.publisher(for: .browserNextTab)) { _ in - guard isFocusedPane else { return } + .onReceive(NotificationCenter.default.publisher(for: .browserNextTab)) { notification in + guard shouldHandleBrowserCommand(notification) else { return } selectAdjacentTab(step: 1) } .onReceive(NotificationCenter.default.publisher(for: .browserOpenCleanup)) { notification in @@ -188,15 +248,44 @@ struct BrowserPaneView: View { workspaceManager.activeWorkspace?.focusedPaneId == paneID } - private func refreshSelectedTabDisplay() { - syncDisplayedText() + private func shouldHandleBrowserCommand(_ notification: Notification) -> Bool { + if let targetPaneID = notification.object as? UUID { + return targetPaneID == paneID + } + return isFocusedPane + } + + private func refreshSelectedTabDisplay(force: Bool = false) { + syncDisplayedText(force: force) guard let tabID = session.selectedTabID else { return } _ = browserManager.ensurePage(for: paneID, tabID: tabID) } private var chromeBar: some View { VStack(spacing: 0) { - // Compact tab bar — tabs fill the row, active tab shows URL inline + if showsTabStrip { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(session.tabs) { tab in + compactTab(tab) + } + + Button { + createNewTab() + } label: { + Image(systemName: "plus") + .font(.system(size: 11, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.top, 8) + } + .onPreferenceChange(BrowserTabFramePreferenceKey.self) { browserTabFrames = $0 } + } + HStack(spacing: 4) { if chrome.showsBackForwardButtons { navButton("chevron.left", enabled: activeTab?.canGoBack == true) { @@ -232,11 +321,34 @@ struct BrowserPaneView: View { ) ) + if chrome.showsSaveButton { + Button { + Task { await saveCurrentTab() } + } label: { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .disabled(activeTab?.urlString.isEmpty ?? true) + } + browserActionMenu } .padding(.horizontal, 8) .padding(.vertical, 6) + if omnibarFocused && !omnibarSuggestions.isEmpty { + suggestionsList(omnibarSuggestions) + .padding(.horizontal, 8) + .padding(.bottom, 6) + } + + if chrome.showsBookmarksBar && !appState.settings.browserQuickLaunchItems.isEmpty { + bookmarksBar + } + if let saveMessage, !saveMessage.isEmpty { HStack { Text(saveMessage) @@ -261,37 +373,24 @@ struct BrowserPaneView: View { let isHovered = hoveredTabID == tab.id return HStack(spacing: 6) { - // Favicon dot Circle() .fill(tabColor(for: tab)) .frame(width: 6, height: 6) .padding(.leading, 8) - if isSelected { - // Active tab: show editable URL field inline - TextField("Search or enter URL", text: $omnibarText) - .textFieldStyle(.plain) + Button { + session.selectTab(tab.id) + } label: { + Text(tab.displayTitle) .font(.system(size: 12)) - .focused($omnibarFocused) - .onSubmit { - submitOmnibar(omnibarText) - } - } else { - // Inactive tab: show title - Button { - session.selectTab(tab.id) - } label: { - Text(tab.displayTitle) - .font(.system(size: 12)) - .lineLimit(1) - .foregroundStyle(.primary) - } - .buttonStyle(.plain) + .lineLimit(1) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) Spacer(minLength: 0) - // Close button on hover if isHovered && session.tabs.count > 1 { Button { session.closeTab(tab.id) @@ -311,7 +410,9 @@ struct BrowserPaneView: View { } } .frame(height: 28) - .frame(maxWidth: isSelected ? .infinity : 160) + .frame(width: 170) + .opacity(draggedTabID == tab.id ? 0.55 : 1.0) + .offset(draggedTabID == tab.id ? draggedTabOffset : .zero) .background( RoundedRectangle(cornerRadius: 8) .fill(isSelected ? Color.primary.opacity(0.06) : (isHovered ? Color.primary.opacity(0.03) : Color.clear)) @@ -321,9 +422,32 @@ struct BrowserPaneView: View { .strokeBorder(isSelected ? Color.primary.opacity(0.08) : Color.clear, lineWidth: 0.5) ) .contentShape(RoundedRectangle(cornerRadius: 8)) + .background(browserTabFrameReader(for: tab.id)) .onHover { hovering in hoveredTabID = hovering ? tab.id : (hoveredTabID == tab.id ? nil : hoveredTabID) } + .gesture( + DragGesture(minimumDistance: 4, coordinateSpace: .global) + .onChanged { value in + handleBrowserTabDragChanged( + tabID: tab.id, + location: value.location, + translation: value.translation + ) + } + .onEnded { value in + handleBrowserTabDragEnded( + tabID: tab.id, + location: value.location, + translation: value.translation + ) + } + ) + .contextMenu { + Button("Move Tab to New Window") { + detachBrowserTab(tab.id) + } + } } private var browserFindBar: some View { @@ -400,6 +524,81 @@ struct BrowserPaneView: View { } } + private var bookmarksBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(appState.settings.browserQuickLaunchItems) { item in + Button { + openQuickLaunch(item) + } label: { + HStack(spacing: 6) { + Image(systemName: item.icon.isEmpty ? "globe" : item.icon) + .font(.system(size: 11, weight: .medium)) + Text(item.title) + .font(.system(size: 12)) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: Radius.md) + .fill(Color.primary.opacity(0.04)) + ) + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 8) + } + } + + private func suggestionsList(_ suggestions: [BrowserSuggestionItem]) -> some View { + VStack(spacing: 0) { + ForEach(Array(suggestions.enumerated()), id: \.element.id) { index, suggestion in + Button { + applySuggestion(suggestion) + } label: { + HStack(spacing: 10) { + Image(systemName: suggestion.iconName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 16) + VStack(alignment: .leading, spacing: 2) { + Text(suggestion.title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + if !suggestion.subtitle.isEmpty { + Text(suggestion.subtitle) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + } + .buttonStyle(.plain) + + if index < suggestions.count - 1 { + Divider() + .padding(.leading, 38) + } + } + } + .background( + RoundedRectangle(cornerRadius: Radius.lg) + .fill(Container.cardBg) + .shadow(color: .black.opacity(0.06), radius: 12, x: 0, y: 6) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.lg) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + } private var browserActionMenu: some View { Menu { @@ -427,6 +626,9 @@ struct BrowserPaneView: View { Divider() + Button("New Tab") { + createNewTab() + } Button("Clean Tabs") { prepareCleanup() } @@ -459,7 +661,7 @@ struct BrowserPaneView: View { adjustZoom(by: -0.1) } Button("Actual Size") { - browserManager.setPageZoom(1.0, in: paneID) + browserManager.setPageZoom(BrowserPageState.defaultPageZoom, in: paneID) } } Menu("Quick Pane Switching") { @@ -488,7 +690,6 @@ struct BrowserPaneView: View { .fixedSize() } - @ViewBuilder private var saveSection: some View { if let savedRecord = activeSavedRecord { @@ -558,6 +759,7 @@ struct BrowserPaneView: View { TextField("Search the web or your notes...", text: $newTabSearchText) .textFieldStyle(.plain) .font(.system(size: 16)) + .focused($newTabSearchFocused) .padding(.horizontal, 16) .padding(.vertical, 14) .frame(maxWidth: 520) @@ -569,15 +771,45 @@ struct BrowserPaneView: View { submitOmnibar(newTabSearchText) } + if newTabSearchFocused && !newTabSuggestions.isEmpty { + suggestionsList(newTabSuggestions) + .frame(maxWidth: 520) + } - if chrome.showsNewTabRecentVisits, !session.recentVisits.isEmpty { + if chrome.showsNewTabQuickLaunch, !appState.settings.browserQuickLaunchItems.isEmpty { + FlowLayout(spacing: 10) { + ForEach(appState.settings.browserQuickLaunchItems) { item in + Button { + openQuickLaunch(item) + } label: { + HStack(spacing: 8) { + Image(systemName: item.icon.isEmpty ? "globe" : item.icon) + .font(.system(size: 12, weight: .medium)) + Text(item.title) + .font(.system(size: 13, weight: .medium)) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: Radius.md) + .fill(Color.primary.opacity(0.04)) + ) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: 620) + } + + if chrome.showsNewTabRecentVisits, !recentHistory.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Recent") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.secondary) VStack(spacing: 6) { - ForEach(session.recentVisits.prefix(8)) { visit in + ForEach(recentHistory.prefix(8)) { visit in Button { if let url = visit.url { browserManager.openURL(url, in: paneID, newTab: false) @@ -751,16 +983,251 @@ struct BrowserPaneView: View { } private func createNewTab() { + workspaceManager.setFocusedPane(id: paneID) _ = session.openNewTab() newTabSearchText = "" omnibarText = "" - omnibarFocused = true + omnibarSuggestions = [] + newTabSuggestions = [] + newTabSearchFocused = true + omnibarFocused = false + } + + private func browserTabFrameReader(for tabID: UUID) -> some View { + GeometryReader { proxy in + Color.clear + .preference( + key: BrowserTabFramePreferenceKey.self, + value: [tabID: proxy.frame(in: .global)] + ) + } + } + + private func handleBrowserTabDragChanged( + tabID: UUID, + location: CGPoint, + translation: CGSize + ) { + draggedTabID = tabID + draggedTabOffset = translation + } + + private func handleBrowserTabDragEnded( + tabID: UUID, + location: CGPoint, + translation: CGSize + ) { + defer { resetBrowserTabDragState() } + + guard let sourceIndex = session.tabs.firstIndex(where: { $0.id == tabID }) else { + return + } + + if abs(translation.height) >= browserTabDetachThreshold { + detachBrowserTab(tabID) + return + } + + guard let targetIndex = browserTabInsertionIndex(for: location) else { return } + session.moveTab(from: sourceIndex, to: targetIndex) + } + + private func browserTabInsertionIndex(for location: CGPoint) -> Int? { + let orderedFrames = session.tabs.compactMap { tab in + browserTabFrames[tab.id].map { (tab.id, $0) } + } + .sorted { $0.1.minX < $1.1.minX } + + guard !orderedFrames.isEmpty else { return nil } + + for (index, frame) in orderedFrames.enumerated() where location.x < frame.1.midX { + return index + } + + return orderedFrames.count + } + + private func detachBrowserTab(_ tabID: UUID) { + guard let tab = session.tabs.first(where: { $0.id == tabID }) else { return } + + let paneID = UUID() + let browserFile = OpenFile( + id: paneID, + path: "bugbook://browser", + content: "", + isDirty: false, + isEmptyTab: false, + kind: .browser, + displayName: "Browser", + icon: "globe" + ) + let workspace = Workspace( + id: UUID(), + name: tab.displayTitle, + icon: "globe", + root: .leaf(.init(id: paneID, content: .document(openFile: browserFile))), + focusedPaneId: paneID, + createdAt: Date() + ) + let snapshot = BrowserPaneSnapshot( + paneID: paneID, + tabs: [ + BrowserTabSnapshot( + id: tab.id, + title: tab.title, + urlString: tab.urlString, + savedRecordID: tab.savedRecordID, + pageZoom: tab.pageZoom + ) + ], + selectedTabID: tab.id, + recentVisits: session.recentVisits, + isReadLaterDrawerOpen: false + ) + let bootstrap = ContentViewBootstrap( + workspaces: [workspace], + activeWorkspaceIndex: 0, + browserSnapshots: [paneID: snapshot], + layoutPersistenceEnabled: false + ) + + DetachedWindowManager.shared.openWindow(title: tab.displayTitle, bootstrap: bootstrap) + session.closeTab(tabID) + } + + private func resetBrowserTabDragState() { + draggedTabID = nil + draggedTabOffset = .zero + } + + private func syncDisplayedText(force: Bool = false) { + if force || !omnibarFocused { + omnibarText = activeTab?.displayURL ?? "" + } + if (force || !newTabSearchFocused), activeTab?.urlString.isEmpty != false { + newTabSearchText = "" + } + } + + private func refreshSavedRecords() { + guard let workspacePath = appState.workspacePath else { + savedRecords = [] + return + } + savedRecords = savedPageStore.records(in: workspacePath) + } + + private func refreshSearchableEntries() { + searchableEntries = makeSearchableEntries(from: fileTree) + } + + private func refreshSuggestions() { + omnibarSuggestions = suggestions(for: omnibarText) + newTabSuggestions = suggestions(for: newTabSearchText) } - private func syncDisplayedText() { - omnibarText = activeTab?.displayURL ?? "" - if activeTab?.urlString.isEmpty != false { + private func suggestions(for input: String) -> [BrowserSuggestionItem] { + guard appState.settings.browserSuggestionsEnabled else { return [] } + + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let normalized = trimmed.lowercased() + let limit = appState.settings.browserSuggestionLimit + var suggestions: [BrowserSuggestionItem] = [] + var seenIDs = Set() + + func append(_ suggestion: BrowserSuggestionItem) { + guard seenIDs.insert(suggestion.id).inserted else { return } + suggestions.append(suggestion) + } + + if appState.settings.browserHistoryEnabled { + for visit in recentHistory where suggestions.count < limit { + let haystack = [visit.title, visit.urlString, visit.host].joined(separator: " ").lowercased() + guard haystack.contains(normalized) else { continue } + append( + BrowserSuggestionItem( + id: "history:\(visit.urlString)", + title: visit.title, + subtitle: visit.urlString, + iconName: "clock.arrow.circlepath", + destination: .url(URL(string: visit.urlString)) + ) + ) + } + } + + for item in appState.settings.browserQuickLaunchItems where suggestions.count < limit { + let haystack = [item.title, item.url].joined(separator: " ").lowercased() + guard haystack.contains(normalized) else { continue } + append( + BrowserSuggestionItem( + id: "shortcut:\(item.id.uuidString)", + title: item.title, + subtitle: item.url, + iconName: item.icon.isEmpty ? "globe" : item.icon, + destination: .url(URL(string: item.url)) + ) + ) + } + + if appState.settings.browserSuggestsBugbookPages { + for entry in searchableEntries where suggestions.count < limit { + guard entry.normalizedHaystack.contains(normalized) else { continue } + append( + BrowserSuggestionItem( + id: "entry:\(entry.id)", + title: entry.displayName, + subtitle: entry.entry.path, + iconName: entry.iconName, + destination: .entry(entry.entry) + ) + ) + } + } + + if let directURL = resolvedURL(from: trimmed), suggestions.count < limit { + append( + BrowserSuggestionItem( + id: "url:\(directURL.absoluteString)", + title: directURL.absoluteString, + subtitle: "Open address", + iconName: "link", + destination: .url(directURL) + ) + ) + } + + if suggestions.count < limit { + append( + BrowserSuggestionItem( + id: "search:\(normalized)", + title: "Search \(appState.settings.browserSearchEngine.displayName)", + subtitle: trimmed, + iconName: "magnifyingglass", + destination: .search(trimmed) + ) + ) + } + + return Array(suggestions.prefix(limit)) + } + + private func applySuggestion(_ suggestion: BrowserSuggestionItem) { + switch suggestion.destination { + case .url(let url): + guard let url else { return } + browserManager.openURL(url, in: paneID, newTab: false) + omnibarText = url.absoluteString + newTabSearchText = "" + dismissSuggestionUI() + case .entry(let entry): + dismissSuggestionUI() newTabSearchText = "" + onOpenBugbookEntry(entry) + case .search(let query): + submitOmnibar(query) } } @@ -772,11 +1239,23 @@ struct BrowserPaneView: View { case .directURL(let url), .webSearch(let url): browserManager.openURL(url, in: paneID, newTab: false) omnibarText = url.absoluteString + newTabSearchText = "" + dismissSuggestionUI() case .bugbookEntry(let entry): + dismissSuggestionUI() + newTabSearchText = "" onOpenBugbookEntry(entry) } } + private func openQuickLaunch(_ item: BrowserQuickLaunchItem) { + guard let url = URL(string: item.url) else { return } + browserManager.openURL(url, in: paneID, newTab: false) + omnibarText = url.absoluteString + newTabSearchText = "" + dismissSuggestionUI() + } + private func resolveDestination(for input: String) -> BrowserOmnibarDestination { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) let wikiLink = trimmed.hasPrefix("[[") && trimmed.hasSuffix("]]") @@ -809,16 +1288,19 @@ struct BrowserPaneView: View { private func matchEntry(for input: String) -> FileEntry? { let normalized = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !normalized.isEmpty else { return nil } - return flattenedEntries(from: fileTree).first { entry in - let displayName = entry.name.hasSuffix(".md") ? String(entry.name.dropLast(3)) : entry.name - return displayName.lowercased() == normalized || entry.path.lowercased() == normalized - } + return searchableEntries.first { + $0.normalizedDisplayName == normalized || $0.normalizedPath == normalized + }?.entry } - private func flattenedEntries(from entries: [FileEntry]) -> [FileEntry] { + private func makeSearchableEntries(from entries: [FileEntry]) -> [BrowserSearchableEntry] { entries.flatMap { entry in - let children = flattenedEntries(from: entry.children ?? []) - return [entry] + children + var searchable: [BrowserSearchableEntry] = [] + if !entry.isDirectory { + searchable.append(BrowserSearchableEntry(entry: entry)) + } + searchable.append(contentsOf: makeSearchableEntries(from: entry.children ?? [])) + return searchable } } @@ -857,6 +1339,7 @@ struct BrowserPaneView: View { settings: appState.settings, aiService: aiService ) { + refreshSavedRecords() saveMessage = "Saved to \((result.record.folderPath as NSString).lastPathComponent)/" } } @@ -877,12 +1360,14 @@ struct BrowserPaneView: View { guard let workspacePath = appState.workspacePath else { return } let nextStatus: SavedWebPageStatus = record.status == .read ? .unread : .read savedPageStore.markStatus(nextStatus, for: record.id, in: workspacePath) + refreshSavedRecords() saveMessage = nextStatus == .read ? "Marked read" : "Marked unread" } private func unsave(_ record: SavedWebPageRecord) { guard let workspacePath = appState.workspacePath else { return } savedPageStore.remove(recordID: record.id, in: workspacePath) + refreshSavedRecords() if let tabID = session.selectedTabID { session.updateSavedRecordID(nil, for: tabID) } @@ -938,9 +1423,60 @@ struct BrowserPaneView: View { aiService: aiService ) isApplyingCleanup = false + refreshSavedRecords() saveMessage = summary showCleanupSheet = false } + + private func dismissSuggestionUI() { + omnibarSuggestions = [] + newTabSuggestions = [] + omnibarFocused = false + newTabSearchFocused = false + } +} + +private struct BrowserSuggestionItem: Identifiable { + enum Destination { + case url(URL?) + case entry(FileEntry) + case search(String) + } + + let id: String + let title: String + let subtitle: String + let iconName: String + let destination: Destination +} + +private struct BrowserSearchableEntry: Identifiable { + let entry: FileEntry + let displayName: String + let normalizedDisplayName: String + let normalizedPath: String + let normalizedHaystack: String + let iconName: String + + var id: String { entry.id } + + init(entry: FileEntry) { + let displayName = entry.name.hasSuffix(".md") ? String(entry.name.dropLast(3)) : entry.name + self.entry = entry + self.displayName = displayName + self.normalizedDisplayName = displayName.lowercased() + self.normalizedPath = entry.path.lowercased() + self.normalizedHaystack = [displayName, entry.path].joined(separator: " ").lowercased() + self.iconName = entry.icon ?? "doc.text" + } +} + +private struct BrowserTabFramePreferenceKey: PreferenceKey { + static var defaultValue: [UUID: CGRect] = [:] + + static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } } private struct BrowserHostViewContainer: NSViewRepresentable { diff --git a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift index 964f35d2..17af4c72 100644 --- a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift +++ b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift @@ -416,84 +416,6 @@ struct WorkspaceCalendarView: View { } } -// MARK: - Source Picker - -struct CalendarSourcePicker: View { - let sources: [CalendarSource] - let overlays: [CalendarOverlay] - var onToggleSource: (String) -> Void - var onToggleOverlay: (String) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - if !sources.isEmpty { - Text("Calendars") - .font(.system(size: Typography.caption, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - - ForEach(sources) { source in - Toggle(isOn: Binding( - get: { source.isVisible }, - set: { _ in onToggleSource(source.id) } - )) { - HStack(spacing: 6) { - Circle() - .fill(calendarColor(source.color)) - .frame(width: 8, height: 8) - Text(source.name) - .font(.system(size: Typography.body)) - } - } - .toggleStyle(.checkbox) - } - } - - if !overlays.isEmpty { - if !sources.isEmpty { Divider() } - - Text("Database Overlays") - .font(.system(size: Typography.caption, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - - ForEach(overlays) { overlay in - Toggle(isOn: Binding( - get: { overlay.isVisible }, - set: { _ in onToggleOverlay(overlay.id) } - )) { - HStack(spacing: 6) { - Circle() - .fill(TagColor.color(for: overlay.color)) - .frame(width: 8, height: 8) - Text("\(overlay.databaseName) — \(overlay.datePropertyName)") - .font(.system(size: Typography.body)) - } - } - .toggleStyle(.checkbox) - } - } - - if sources.isEmpty && overlays.isEmpty { - Text("No calendars or overlays configured.\nSync with Google Calendar or add a database overlay in Settings.") - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - } - .padding(16) - .frame(width: 280) - } - - private func calendarColor(_ hex: String) -> Color { - if hex.hasPrefix("#") { - return Color(hex: String(hex.dropFirst())) - } - return TagColor.color(for: hex) - } -} - struct CalendarEventComposerSheet: View { @Binding var draft: CalendarEventDraft let connectedEmail: String diff --git a/Sources/Bugbook/Views/Components/BreadcrumbView.swift b/Sources/Bugbook/Views/Components/BreadcrumbView.swift index 90528b97..ddf540a0 100644 --- a/Sources/Bugbook/Views/Components/BreadcrumbView.swift +++ b/Sources/Bugbook/Views/Components/BreadcrumbView.swift @@ -79,34 +79,3 @@ struct BreadcrumbView: View { } } -struct BacklinksMenuButton: View { - let backlinks: [Backlink] - let onNavigate: (String) -> Void - @State private var isHovered = false - - var body: some View { - Menu { - Section("Backlinks") { - ForEach(backlinks) { backlink in - Button(backlink.sourceName) { - onNavigate(backlink.sourcePath) - } - } - } - } label: { - Image(systemName: "link") - .font(ShellZoomMetrics.font(Typography.bodySmall, weight: .medium)) - .foregroundStyle(.secondary) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .frame(width: ShellZoomMetrics.size(32), height: ShellZoomMetrics.size(32)) - .background( - RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm)) - .fill(isHovered ? Color.primary.opacity(0.1) : Color.clear) - ) - .contentShape(Rectangle()) - .onHover { isHovered = $0 } - } -} diff --git a/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift index be234690..5c052adf 100644 --- a/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift +++ b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift @@ -24,7 +24,7 @@ final class FloatingRecordingPillPanel: NSPanel { isOpaque = false backgroundColor = .clear - hasShadow = true + hasShadow = false level = .floating collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] isMovableByWindowBackground = true @@ -71,44 +71,27 @@ private struct RecordingPillView: View { var body: some View { HStack(spacing: 6) { - // Tappable area: icon + bars + duration → brings app to front - HStack(spacing: 6) { - Image(systemName: "ladybug.fill") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.9)) + Image(systemName: "ladybug.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.9)) - AudioBarsView(isAnimating: isAnimating) - .frame(width: 16, height: 14) - - if isAnimating { - DurationLabel(since: recordingStart) - } - } - .contentShape(Capsule()) - .onTapGesture { - NSApplication.shared.activate(ignoringOtherApps: true) - onTap?() - } + AudioBarsView(isAnimating: isAnimating) + .frame(width: 16, height: 14) if isAnimating { - // Stop button - Button { - onStop?() - } label: { - RoundedRectangle(cornerRadius: 2) - .fill(Color.white) - .frame(width: 8, height: 8) - } - .buttonStyle(.borderless) + DurationLabel(since: recordingStart) } } - .padding(.horizontal, 10) + .padding(.horizontal, 12) .padding(.vertical, 6) - .background( - Capsule() - .fill(Color(hex: "1a1a1a")) - .shadow(color: .black.opacity(0.3), radius: 4, y: 2) - ) + .background(Capsule().fill(Color(hex: "1a1a1a"))) + .contentShape(Capsule()) + .onTapGesture { + NSApplication.shared.activate(ignoringOtherApps: true) + onTap?() + } + .shadow(color: .black.opacity(0.3), radius: 4, y: 2) + .padding(6) } } diff --git a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift index 8e846bd2..95537980 100644 --- a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift +++ b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift @@ -6,15 +6,21 @@ import AppKit /// Tab bar at the top of the content area. Each tab owns a pane layout. struct WorkspaceTabBar: View { var workspaceManager: WorkspaceManager + var browserManager: BrowserManager var sidebarOpen: Bool var currentView: ViewMode = .editor + var recordingPagePath: String? @State private var dragOverIndex: Int? - @State private var draggingId: UUID? + @State private var draggedWorkspaceID: UUID? + @State private var draggedWorkspaceOffset: CGSize = .zero + @State private var workspaceTabFrames: [UUID: CGRect] = [:] @State private var showNewMenu = false @State private var showSavedIndicator = false @State private var savedIndicatorTask: Task? + private let detachThreshold: CGFloat = 90 + var body: some View { HStack(alignment: .bottom, spacing: 0) { // Sidebar toggle — only visible in tab bar when sidebar is closed @@ -46,21 +52,34 @@ struct WorkspaceTabBar: View { title: tabTitle(for: workspace), icon: tabIcon(for: workspace), isActive: index == workspaceManager.activeWorkspaceIndex, + isRecording: isWorkspaceRecording(workspace), onSelect: { workspaceManager.switchWorkspace(to: index) }, - onClose: { workspaceManager.closeWorkspace(at: index) } + onClose: { workspaceManager.closeWorkspace(at: index) }, + onMoveToNewWindow: { + detachWorkspace(id: workspace.id) + } ) .zIndex(index == workspaceManager.activeWorkspaceIndex ? 1 : 0) - .opacity(draggingId == workspace.id ? 0.4 : 1.0) - .onDrag { - draggingId = workspace.id - return NSItemProvider(object: workspace.id.uuidString as NSString) - } - .onDrop(of: [.text], delegate: TabDropDelegate( - targetIndex: index, - workspaceManager: workspaceManager, - dragOverIndex: $dragOverIndex, - draggingId: $draggingId - )) + .opacity(draggedWorkspaceID == workspace.id ? 0.55 : 1.0) + .offset(draggedWorkspaceID == workspace.id ? draggedWorkspaceOffset : .zero) + .background(workspaceTabFrameReader(for: workspace.id)) + .gesture( + DragGesture(minimumDistance: 4, coordinateSpace: .global) + .onChanged { value in + handleWorkspaceDragChanged( + workspaceID: workspace.id, + location: value.location, + translation: value.translation + ) + } + .onEnded { value in + handleWorkspaceDragEnded( + workspaceID: workspace.id, + location: value.location, + translation: value.translation + ) + } + ) } } @@ -85,12 +104,6 @@ struct WorkspaceTabBar: View { NewPanePopover(workspaceManager: workspaceManager, dismiss: { showNewMenu = false }) .popoverSurface() } - .onDrop(of: [.text], delegate: TabDropDelegate( - targetIndex: workspaceManager.workspaces.count, - workspaceManager: workspaceManager, - dragOverIndex: $dragOverIndex, - draggingId: $draggingId - )) } .padding(.leading, 0) Spacer(minLength: 0) @@ -107,6 +120,7 @@ struct WorkspaceTabBar: View { withAnimation(.easeOut(duration: 0.3)) { showSavedIndicator = false } } } + .onPreferenceChange(WorkspaceTabFramePreferenceKey.self) { workspaceTabFrames = $0 } } @ViewBuilder @@ -127,8 +141,9 @@ struct WorkspaceTabBar: View { if currentView == .graphView { return "Graph" } if currentView == .calendar { return "Calendar" } } - // Derive name from the focused pane's content - if let leaf = ws.focusedLeaf { + // In splits, prefer the first document pane over terminal for the tab title + let leaf = ws.root.firstDocumentLeaf ?? ws.focusedLeaf + if let leaf { switch leaf.content { case .document(let file): if let name = file.displayName, !name.isEmpty { return name } @@ -147,6 +162,15 @@ struct WorkspaceTabBar: View { return ws.name } + private func isWorkspaceRecording(_ ws: Workspace) -> Bool { + guard let path = recordingPagePath else { return false } + guard let leaf = ws.focusedLeaf else { return false } + if case .document(let file) = leaf.content { + return file.path == path + } + return false + } + private func tabIcon(for ws: Workspace) -> String? { if ws.id == workspaceManager.activeWorkspace?.id { if currentView == .chat { return "sf:bubble.left.and.bubble.right" } @@ -167,6 +191,101 @@ struct WorkspaceTabBar: View { return "sf:terminal" } } + + private func workspaceTabFrameReader(for workspaceID: UUID) -> some View { + GeometryReader { proxy in + Color.clear + .preference( + key: WorkspaceTabFramePreferenceKey.self, + value: [workspaceID: proxy.frame(in: .global)] + ) + } + } + + private func handleWorkspaceDragChanged( + workspaceID: UUID, + location: CGPoint, + translation: CGSize + ) { + draggedWorkspaceID = workspaceID + draggedWorkspaceOffset = translation + + guard abs(translation.height) < detachThreshold else { + dragOverIndex = nil + return + } + + dragOverIndex = insertionIndex(for: location) + } + + private func handleWorkspaceDragEnded( + workspaceID: UUID, + location: CGPoint, + translation: CGSize + ) { + defer { resetWorkspaceDragState() } + + guard let sourceIndex = workspaceManager.workspaces.firstIndex(where: { $0.id == workspaceID }) else { + return + } + + if abs(translation.height) >= detachThreshold { + detachWorkspace(id: workspaceID) + return + } + + guard let targetIndex = insertionIndex(for: location) else { return } + workspaceManager.reorderWorkspace(from: sourceIndex, to: targetIndex) + } + + private func detachWorkspace(id: UUID) { + guard let index = workspaceManager.workspaces.firstIndex(where: { $0.id == id }), + let workspace = workspaceManager.detachWorkspace(at: index) else { + return + } + + let title = tabTitle(for: workspace) + let browserSnapshots = detachedBrowserSnapshots(for: workspace) + let bootstrap = ContentViewBootstrap( + workspaces: [workspace], + activeWorkspaceIndex: 0, + browserSnapshots: browserSnapshots, + layoutPersistenceEnabled: false + ) + DetachedWindowManager.shared.openWindow(title: title, bootstrap: bootstrap) + } + + private func detachedBrowserSnapshots(for workspace: Workspace) -> [UUID: BrowserPaneSnapshot] { + Dictionary(uniqueKeysWithValues: workspace.allLeaves.compactMap { leaf in + guard case .document(let file) = leaf.content, + file.isBrowser, + let snapshot = browserManager.snapshot(for: leaf.id) else { + return nil + } + return (leaf.id, snapshot) + }) + } + + private func insertionIndex(for location: CGPoint) -> Int? { + let orderedFrames = workspaceManager.workspaces.compactMap { workspace in + workspaceTabFrames[workspace.id].map { (workspace.id, $0) } + } + .sorted { $0.1.minX < $1.1.minX } + + guard !orderedFrames.isEmpty else { return nil } + + for (index, frame) in orderedFrames.enumerated() where location.x < frame.1.midX { + return index + } + + return orderedFrames.count + } + + private func resetWorkspaceDragState() { + dragOverIndex = nil + draggedWorkspaceID = nil + draggedWorkspaceOffset = .zero + } } // MARK: - New Pane Popover @@ -260,8 +379,10 @@ private struct TabItemView: View { let title: String var icon: String? let isActive: Bool + var isRecording: Bool = false var onSelect: () -> Void var onClose: () -> Void + var onMoveToNewWindow: () -> Void @State private var isHovered = false @State private var isCloseHovered = false @@ -269,7 +390,12 @@ private struct TabItemView: View { var body: some View { Button(action: onSelect) { HStack(spacing: ShellZoomMetrics.size(6)) { - tabIconView + if isRecording { + PulsingRecordDot() + .scaleEffect(0.75) + } else { + tabIconView + } Text(title) .font(ShellZoomMetrics.font(Typography.bodySmall, weight: isActive ? .semibold : .regular)) @@ -303,6 +429,11 @@ private struct TabItemView: View { } .buttonStyle(.plain) .onHover { isHovered = $0 } + .contextMenu { + Button("Move to New Window") { + onMoveToNewWindow() + } + } } @ViewBuilder @@ -319,32 +450,12 @@ private struct TabItemView: View { } } -// MARK: - Tab Drop Delegate - -private struct TabDropDelegate: DropDelegate { - let targetIndex: Int - let workspaceManager: WorkspaceManager - @Binding var dragOverIndex: Int? - @Binding var draggingId: UUID? - - func dropEntered(info: DropInfo) { dragOverIndex = targetIndex } - func dropExited(info: DropInfo) { if dragOverIndex == targetIndex { dragOverIndex = nil } } - func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } +private struct WorkspaceTabFramePreferenceKey: PreferenceKey { + static var defaultValue: [UUID: CGRect] = [:] - func performDrop(info: DropInfo) -> Bool { - dragOverIndex = nil - guard let draggingId, - let sourceIndex = workspaceManager.workspaces.firstIndex(where: { $0.id == draggingId }) else { - self.draggingId = nil - return false - } - guard sourceIndex != targetIndex else { self.draggingId = nil; return true } - workspaceManager.reorderWorkspace(from: sourceIndex, to: targetIndex) - self.draggingId = nil - return true + static func reduce(value: inout [UUID: CGRect], nextValue: () -> [UUID: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) } - - func validateDrop(info: DropInfo) -> Bool { true } } // MARK: - Connected Tab Shape diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 85d80740..cb15921a 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -6,11 +6,19 @@ import Sentry import BugbookCore import GhosttyKit +struct ContentViewBootstrap { + var workspaces: [Workspace] + var activeWorkspaceIndex: Int + var browserSnapshots: [UUID: BrowserPaneSnapshot] = [:] + var layoutPersistenceEnabled: Bool = true +} + // swiftlint:disable:next type_body_length struct ContentView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion private let editorDraftStore = EditorDraftStore() + private let bootstrap: ContentViewBootstrap? @State private var appState = AppState() @State private var appSettingsStore = AppSettingsStore() @@ -22,6 +30,8 @@ struct ContentView: View { @State private var meetingNoteService = MeetingNoteService() @State private var transcriptionService = TranscriptionService() @State private var meetingsVM = MeetingsViewModel() + @State private var meetingNotificationService = MeetingNotificationService() + @State private var meetingTranscriptStore = MeetingTranscriptStore() @State private var backlinkService = BacklinkService() @State private var blockDocuments: [UUID: BlockDocument] = [:] @State private var workspaceManager = WorkspaceManager() @@ -72,6 +82,10 @@ struct ContentView: View { let id: UUID // unique per request so repeated selections of the same entry still fire } + init(bootstrap: ContentViewBootstrap? = nil) { + self.bootstrap = bootstrap + } + var body: some View { configuredLayout } @@ -93,6 +107,7 @@ struct ContentView: View { onSelectEntry: { entry in handleSidebarFileSelect(entry) }, onRefreshTree: { refreshFileTree() }, onOpenSettings: { openSettingsTab() }, + onNavItemTap: { item, inNewTab in handleNavItemTap(item, inNewTab: inNewTab) }, contextualLabel: sidebarContextLabel, contextualContent: { sidebarContextualContent } ) @@ -126,6 +141,7 @@ struct ContentView: View { onSelectEntry: { entry in handleSidebarFileSelect(entry) }, onRefreshTree: { refreshFileTree() }, onOpenSettings: { openSettingsTab() }, + onNavItemTap: { item, inNewTab in handleNavItemTap(item, inNewTab: inNewTab) }, contextualLabel: sidebarContextLabel, contextualContent: { sidebarContextualContent } ) @@ -180,6 +196,9 @@ struct ContentView: View { appSettingsStore.save(newSettings) applyTerminalColorScheme(newSettings.terminalColorScheme) } + .onChange(of: appState.settings.browserHistoryEnabled) { _, enabled in + browserManager.setHistoryEnabled(enabled) + } .onChange(of: appState.settings.theme) { _, newTheme in applyTheme(newTheme) } @@ -306,7 +325,16 @@ struct ContentView: View { } private func handleRecordingChange(_ recording: Bool) { - if recording, let blockId = appState.recordingBlockId { + if recording, let session = appState.activeMeetingSession { + // Meeting page recording — pill navigates to the meeting tab (opens if closed) + recordingPillController.onStop = { + NotificationCenter.default.post(name: .stopMeetingRecording, object: nil) + } + recordingPillController.onTap = { + navigateToFilePath(session.meetingPagePath) + } + } else if recording, let blockId = appState.recordingBlockId { + // Legacy inline block recording let doc = blockDocuments.values.first { $0.blocks.contains(where: { $0.id == blockId }) } recordingPillController.onStop = { [weak doc] in doc?.onStopMeeting?(blockId) @@ -409,6 +437,19 @@ struct ContentView: View { return } guard let paneId = workspaceManager.activeWorkspace?.focusedPaneId else { return } + + // Warn if closing a pane with an active meeting recording + if let session = appState.activeMeetingSession, + let doc = blockDocuments[paneId], + doc.filePath == session.meetingPagePath { + let alert = NSAlert() + alert.messageText = "Recording in Progress" + alert.informativeText = "A meeting is being recorded in this tab. The recording will continue in the background. You can get back via the floating pill." + alert.addButton(withTitle: "Close Tab") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + } + cleanupTabDocuments(paneId) terminalManager.closeSession(paneId) browserManager.closeSession(paneId) @@ -468,6 +509,12 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .openMeetings)) { _ in presentEditorPane(.meetingsDocument()) } + .onReceive(NotificationCenter.default.publisher(for: .meetingNotificationRecord)) { notification in + handleMeetingNotification(notification, startRecording: true) + } + .onReceive(NotificationCenter.default.publisher(for: .meetingNotificationOpenNotes)) { notification in + handleMeetingNotification(notification, startRecording: false) + } .onReceive(NotificationCenter.default.publisher(for: .openGateway)) { _ in presentEditorPane(.gatewayDocument()) } @@ -522,10 +569,6 @@ struct ContentView: View { ensureAiInitializedIfNeeded() appState.toggleAiPanel() } - .onReceive(NotificationCenter.default.publisher(for: .openFullChat)) { _ in - ensureAiInitializedIfNeeded() - appState.toggleAiPanel() - } .onReceive(NotificationCenter.default.publisher(for: .askAI)) { notification in let prompt = notification.userInfo?["prompt"] as? String ?? notification.userInfo?["query"] as? String @@ -719,11 +762,15 @@ struct ContentView: View { @discardableResult private func postBrowserCommandIfFocused(_ name: Notification.Name, object: Any? = nil) -> Bool { - guard focusedBrowserPaneID != nil else { return false } - NotificationCenter.default.post(name: name, object: object) + guard let targetPaneID = browserCommandPaneID else { return false } + NotificationCenter.default.post(name: name, object: object ?? targetPaneID) return true } + private var browserCommandPaneID: UUID? { + focusedBrowserPaneID ?? soleBrowserPaneID + } + private var activeWorkspaceLeaves: [PaneNode.Leaf] { workspaceManager.activeWorkspace?.allLeaves ?? [] } @@ -738,6 +785,15 @@ struct ContentView: View { return leaf.id } + private var soleBrowserPaneID: UUID? { + let browserLeaves = activeWorkspaceLeaves.filter { leaf in + guard case .document(let file) = leaf.content else { return false } + return file.isBrowser + } + guard browserLeaves.count == 1 else { return nil } + return browserLeaves.first?.id + } + private var contextualSidebarActiveFilePath: String? { guard let leaf = workspaceManager.activeWorkspace?.focusedLeaf, case .document(let file) = leaf.content, @@ -769,6 +825,42 @@ struct ContentView: View { openOrFocusPane(content) } + /// Handle a tap on a sidebar fixed-zone navigation item. + /// Default: replace the focused pane. Cmd-click: open in a new workspace tab. + private func handleNavItemTap(_ item: ShellNavItem, inNewTab: Bool) { + appState.currentView = .editor + appState.showSettings = false + + // Modal / file actions don't have pane semantics — fall back to notifications. + switch item.id { + case "search": + NotificationCenter.default.post(name: .quickOpen, object: nil) + return + case "notes": + NotificationCenter.default.post(name: .openDailyNote, object: nil) + return + default: + break + } + + let content: PaneContent + switch item.id { + case "home": content = .gatewayDocument() + case "meeting": content = .meetingsDocument() + case "calendar": content = .calendarDocument() + case "terminal": content = .terminal + case "browser": content = .browserDocument() + case "mail": content = .mailDocument() + default: return + } + + if inNewTab { + workspaceManager.addWorkspaceWith(content: content) + } else { + openContentInFocusedPane(content) + } + } + private func handleSidebarToggle() { withAnimation(.easeInOut(duration: 0.15)) { appState.sidebarVisible.toggle() @@ -1110,8 +1202,10 @@ struct ContentView: View { if !appState.showSettings && appState.currentView != .graphView { WorkspaceTabBar( workspaceManager: workspaceManager, + browserManager: browserManager, sidebarOpen: shellShowsSidebarPanel, - currentView: appState.currentView + currentView: appState.currentView, + recordingPagePath: appState.activeMeetingSession?.meetingPagePath ) .opacity(editorUI.focusModeActive ? 0.0 : 1.0) } @@ -1120,7 +1214,7 @@ struct ContentView: View { ZStack(alignment: .trailing) { VStack(spacing: 0) { if appState.showSettings { - SettingsView(appState: appState) + SettingsView(appState: appState, browserManager: browserManager) .background(Container.cardBg) .clipShape(RoundedRectangle(cornerRadius: Container.cardRadius)) } else if appState.currentView == .graphView { @@ -1385,7 +1479,7 @@ struct ContentView: View { private func paneContent(_ existing: PaneContent, matches target: PaneContent) -> Bool { switch (existing, target) { case (.terminal, .terminal): - return true + return false // Each terminal gets its own workspace — no reuse case let (.document(file), .document(targetFile)): return file.kind == targetFile.kind default: @@ -1405,6 +1499,7 @@ struct ContentView: View { private func replacePaneContent(paneId: UUID, with content: PaneContent) { cleanupPaneResources(paneId) workspaceManager.updatePaneContent(paneId: paneId, content: content) + updateSidebarContextType() } private func cleanupPaneResources(_ paneId: UUID) { @@ -1462,9 +1557,7 @@ struct ContentView: View { MeetingsView( appState: appState, viewModel: meetingsVM, - transcriptionService: transcriptionService, meetingNoteService: meetingNoteService, - aiService: aiService, onNavigateToFile: { path in navigateToFilePath(path) } @@ -1529,6 +1622,8 @@ struct ContentView: View { DatabaseFullPageView(dbPath: file.path, initialRowId: dbInitialRowId) .id(leaf.id) .onAppear { dbInitialRowId = nil } + } else if let doc = blockDocuments[leaf.id], doc.isMeetingPage { + meetingPageView(for: file, document: doc) } else { editorView(for: file) } @@ -1676,6 +1771,80 @@ struct ContentView: View { } } + // MARK: - Meeting Notification Handling + + private func handleMeetingNotification(_ notification: Foundation.Notification, startRecording: Bool) { + guard let eventId = notification.userInfo?["eventId"] as? String, + let eventTitle = notification.userInfo?["eventTitle"] as? String, + let workspace = appState.workspacePath else { return } + + // Find the calendar event + let event = calendarService.events.first { $0.id == eventId } + + Task { + // Create or open the meeting note page + let path: String? + if let event { + path = await meetingNoteService.createOrOpenMeetingNote(for: event, workspace: workspace) + } else { + // Event not found in cache — open the deterministic page for this title, + // creating it only if it doesn't already exist. Writing unconditionally + // would clobber prior edits if the notification action is triggered twice. + let dateStr = MeetingNoteService.sanitize(eventTitle) + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyy-MM-dd" + let pageName = "\(dateFmt.string(from: Date())) — \(dateStr)" + let pagePath = (workspace as NSString).appendingPathComponent("\(pageName).md") + + if !FileManager.default.fileExists(atPath: pagePath) { + let content = """ + --- + title: \(eventTitle) + date: \(ISO8601DateFormatter().string(from: Date())) + type: meeting + meeting_id: \(UUID().uuidString) + --- + + # \(eventTitle) + + ## Notes + + """ + try? content.write(toFile: pagePath, atomically: true, encoding: .utf8) + } + path = pagePath + } + + guard let path else { return } + if startRecording { + appState.pendingAutoRecordPath = path + } + navigateToFilePath(path) + } + } + + // MARK: - Meeting Page + + @ViewBuilder + private func meetingPageView(for file: OpenFile, document: BlockDocument) -> some View { + MeetingPageView( + appState: appState, + document: document, + transcriptionService: transcriptionService, + meetingNoteService: meetingNoteService, + transcriptStore: meetingTranscriptStore, + onTextChange: { + guard appState.activeTabIndex < appState.openTabs.count else { return } + if !appState.openTabs[appState.activeTabIndex].isDirty { + appState.openTabs[appState.activeTabIndex].isDirty = true + } + scheduleSave() + }, + onTyping: { triggerFocusMode() }, + onNavigateToFile: { path in navigateToFilePath(path) } + ) + } + private func wireUpDocumentCallbacks(_ doc: BlockDocument) { doc.onCreateDatabase = { [weak appState] name in guard appState?.workspacePath != nil else { return nil } @@ -2254,6 +2423,9 @@ struct ContentView: View { file.icon = doc.icon if let rawTitle = doc.titleBlock?.text, !rawTitle.isEmpty { file.displayName = AttributedStringConverter.plainText(from: rawTitle) + } else if doc.isMeetingPage, + let yamlTitle = MarkdownBlockParser.yamlValue(for: "title", in: doc.yamlFrontmatter) { + file.displayName = yamlTitle } } } @@ -2361,12 +2533,14 @@ struct ContentView: View { } let isDatabase = fileSystem.isDatabaseFolder(at: targetPath) + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: targetPath, isDirectory: &isDir) let kind: TabKind = isDatabase ? .database : .page let entry = FileEntry( id: targetPath, name: item.name, path: targetPath, - isDirectory: isDatabase, + isDirectory: isDir.boolValue, kind: kind, icon: item.icon ) @@ -2375,9 +2549,7 @@ struct ContentView: View { private func isOpenableBreadcrumbPath(_ path: String) -> Bool { if fileSystem.isDatabaseFolder(at: path) { return true } - var isDir: ObjCBool = false - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { return false } - return !isDir.boolValue + return FileManager.default.fileExists(atPath: path) } private func warmUpTranscriptionModel() { @@ -2388,6 +2560,7 @@ struct ContentView: View { private func loadAppSettings() { appState.settings = appSettingsStore.load() + browserManager.setHistoryEnabled(appState.settings.browserHistoryEnabled) } private func initializeWorkspace() { @@ -2435,7 +2608,20 @@ struct ContentView: View { // Initialize workspace manager (restore saved layout or migrate from tabs) restoredWorkspaceDocuments = false - workspaceManager.restoreOrCreateDefault() + if let bootstrap { + workspaceManager.layoutPersistenceEnabled = bootstrap.layoutPersistenceEnabled + if bootstrap.workspaces.isEmpty { + workspaceManager.restoreOrCreateDefault() + } else { + workspaceManager.workspaces = bootstrap.workspaces + workspaceManager.activeWorkspaceIndex = min(bootstrap.activeWorkspaceIndex, bootstrap.workspaces.count - 1) + for (paneID, snapshot) in bootstrap.browserSnapshots { + browserManager.restoreSessionSnapshot(snapshot, for: paneID) + } + } + } else { + workspaceManager.restoreOrCreateDefault() + } restoreWorkspaceDocumentsIfNeeded() startWorkspaceWatcher(path: workspacePath) @@ -2448,6 +2634,10 @@ struct ContentView: View { self.appState.mcpServers = servers } } + + // Setup meeting notifications + meetingNotificationService.setup() + meetingNotificationService.startPolling(calendarService: calendarService) } private func restoreWorkspaceDocumentsIfNeeded() { @@ -3071,9 +3261,7 @@ struct ContentView: View { let path = try fileSystem.openOrCreateDailyNote(in: workspace) let name = (path as NSString).lastPathComponent let entry = FileEntry(id: path, name: name, path: path, isDirectory: false) - appState.currentView = .editor - appState.showSettings = false - navigateToEntry(entry, preferExistingTab: true) + navigateToEntryInPane(entry) refreshFileTree() } catch { Log.fileSystem.error("Failed to open daily note: \(error.localizedDescription)") diff --git a/Sources/Bugbook/Views/Editor/BlockTextView.swift b/Sources/Bugbook/Views/Editor/BlockTextView.swift index 41398881..ecdaddec 100644 --- a/Sources/Bugbook/Views/Editor/BlockTextView.swift +++ b/Sources/Bugbook/Views/Editor/BlockTextView.swift @@ -1488,6 +1488,21 @@ class BlockNSTextView: NSTextView { return // Image paste handled } + // Strip Bugbook metadata comments (, , etc.) + // from pasted markdown so "Copy Page Content" round-trips cleanly back into the editor. + if let string = NSPasteboard.general.string(forType: .string), + string.contains("\\n?", + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned != string { + insertText(cleaned, replacementRange: selectedRange()) + return + } + } + super.paste(sender) } diff --git a/Sources/Bugbook/Views/Editor/MeetingKnowledgeView.swift b/Sources/Bugbook/Views/Editor/MeetingKnowledgeView.swift deleted file mode 100644 index df869596..00000000 --- a/Sources/Bugbook/Views/Editor/MeetingKnowledgeView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import SwiftUI - -/// Collapsible panel that surfaces relevant workspace knowledge during meetings. -/// Designed to be embedded in the page editor when live meeting context is active. -/// Queries the WorkspaceKnowledgeService periodically based on transcript/note content. -struct MeetingKnowledgeView: View { - var knowledgeService: WorkspaceKnowledgeService - /// The current text to search against (e.g. recent transcript or note content). - let sourceText: String - /// Called when the user taps a result to navigate to its source page. - var onNavigate: ((String) -> Void)? - - @State private var isExpanded = true - @State private var results: [KnowledgeResult] = [] - @State private var queryTask: Task? - @State private var lastQueryText = "" - - var body: some View { - if !results.isEmpty || !sourceText.isEmpty { - VStack(alignment: .leading, spacing: 0) { - header - if isExpanded && !results.isEmpty { - resultsList - } - } - .background(Color.fallbackSurfaceSubtle) - .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) - .overlay( - RoundedRectangle(cornerRadius: Radius.sm) - .strokeBorder(Color.fallbackDividerColor, lineWidth: 1) - ) - .padding(.horizontal, 76) - .padding(.vertical, 8) - .onAppear { scheduleQuery() } - .onChange(of: sourceText) { _, _ in scheduleQuery() } - .onDisappear { queryTask?.cancel() } - } - } - - // MARK: - Header - - private var header: some View { - Button { - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } label: { - HStack(spacing: 6) { - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(Color.fallbackTextSecondary) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - - Image(systemName: "sparkles") - .font(.system(size: Typography.caption)) - .foregroundStyle(Color.fallbackAccent) - - Text("Related Notes") - .font(.system(size: Typography.caption, weight: .medium)) - .foregroundStyle(Color.fallbackTextSecondary) - - Spacer() - - if !results.isEmpty { - Text("\(results.count)") - .font(.system(size: Typography.caption2, weight: .medium)) - .foregroundStyle(Color.fallbackTextSecondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.fallbackBadgeBg) - .clipShape(Capsule()) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - // MARK: - Results - - private var resultsList: some View { - VStack(alignment: .leading, spacing: 0) { - Divider() - .padding(.horizontal, 12) - - ForEach(results) { result in - resultRow(result) - } - } - } - - private func resultRow(_ result: KnowledgeResult) -> some View { - Button { - onNavigate?(result.filePath) - } label: { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Image(systemName: "doc.text") - .font(.system(size: 10)) - .foregroundStyle(Color.fallbackTextSecondary) - - Text(result.title) - .font(.system(size: Typography.bodySmall, weight: .medium)) - .foregroundStyle(Color.fallbackTextPrimary) - .lineLimit(1) - } - - if !result.snippet.isEmpty { - Text(result.snippet) - .font(.system(size: Typography.caption)) - .foregroundStyle(Color.fallbackTextSecondary) - .lineLimit(2) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(Color.clear) - } - - // MARK: - Query Logic - - private func scheduleQuery() { - queryTask?.cancel() - queryTask = Task { - // Debounce — wait before querying - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - - let text = sourceText - guard text != lastQueryText, !text.isEmpty else { return } - - let newResults = knowledgeService.query(text, limit: 5) - guard !Task.isCancelled else { return } - - lastQueryText = text - withAnimation(.easeInOut(duration: 0.2)) { - results = newResults - } - } - } -} diff --git a/Sources/Bugbook/Views/Editor/MeetingNotesEditor.swift b/Sources/Bugbook/Views/Editor/MeetingNotesEditor.swift deleted file mode 100644 index 2e17abf9..00000000 --- a/Sources/Bugbook/Views/Editor/MeetingNotesEditor.swift +++ /dev/null @@ -1,300 +0,0 @@ -import SwiftUI -import AppKit - -/// Lightweight markdown-aware text editor for meeting notes. -/// Renders `- ` as bullets, `# ` as headers, `**text**` as bold. -/// Auto-continues bullets on Enter. -struct MeetingNotesEditor: NSViewRepresentable { - @Binding var text: String - var font: NSFont = .systemFont(ofSize: Typography.body) - var textColor: NSColor = .labelColor - var placeholder: String = "Write notes..." - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSTextView.scrollableTextView() - let textView = scrollView.documentView as! NSTextView // SAFETY: scrollableTextView always creates an NSTextView - - textView.isRichText = true - textView.isEditable = true - textView.isSelectable = true - textView.drawsBackground = false - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainerInset = NSSize(width: 4, height: 2) - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticTextReplacementEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.allowsUndo = true - textView.delegate = context.coordinator - - scrollView.hasVerticalScroller = false - scrollView.hasHorizontalScroller = false - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - - context.coordinator.textView = textView - context.coordinator.applyMarkdownStyling() - - return scrollView - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView else { return } - let coord = context.coordinator - - // Only update if text changed externally - if textView.string != text && !coord.isEditing { - coord.isUpdating = true - textView.string = text - coord.applyMarkdownStyling() - coord.isUpdating = false - } - - // Show/hide placeholder - coord.updatePlaceholder() - } - - // MARK: - Coordinator - - class Coordinator: NSObject, NSTextViewDelegate { - var parent: MeetingNotesEditor - weak var textView: NSTextView? - var isEditing = false - var isUpdating = false - private var placeholderView: NSTextField? - - init(_ parent: MeetingNotesEditor) { - self.parent = parent - } - - func textDidChange(_ notification: Notification) { - guard !isUpdating, let textView else { return } - isEditing = true - parent.text = textView.string - applyMarkdownStyling() - updatePlaceholder() - isEditing = false - } - - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if commandSelector == #selector(NSResponder.insertNewline(_:)) { - return handleEnter(textView) - } - if commandSelector == #selector(NSResponder.insertTab(_:)) { - return handleTab(textView, indent: true) - } - if commandSelector == #selector(NSResponder.insertBacktab(_:)) { - return handleTab(textView, indent: false) - } - return false - } - - // MARK: - Enter: auto-continue bullets - - private func handleEnter(_ textView: NSTextView) -> Bool { - let string = textView.string as NSString - let cursorLocation = textView.selectedRange().location - let lineRange = string.lineRange(for: NSRange(location: cursorLocation, length: 0)) - let line = string.substring(with: lineRange).trimmingCharacters(in: .newlines) - - // Detect bullet prefix - let bulletPrefixes = ["- [ ] ", "- [x] ", "- ", "* ", "+ "] - for prefix in bulletPrefixes { - if line.hasPrefix(prefix) { - let content = String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) - if content.isEmpty { - // Empty bullet — remove it and stop the list - let replaceRange = NSRange(location: lineRange.location, length: lineRange.length) - textView.insertText("\n", replacementRange: replaceRange) - return true - } - // Detect indentation - let indent = leadingWhitespace(line) - textView.insertText("\n\(indent)\(prefix)", replacementRange: textView.selectedRange()) - return true - } - // Check with leading whitespace - let trimmedLine = line.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression) - if trimmedLine.hasPrefix(prefix) { - let content = String(trimmedLine.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) - if content.isEmpty { - let replaceRange = NSRange(location: lineRange.location, length: lineRange.length) - textView.insertText("\n", replacementRange: replaceRange) - return true - } - let indent = leadingWhitespace(line) - textView.insertText("\n\(indent)\(prefix)", replacementRange: textView.selectedRange()) - return true - } - } - - // Detect numbered list (e.g., "1. ") - let numberedPattern = try? NSRegularExpression(pattern: "^(\\s*)(\\d+)\\. ") - if let match = numberedPattern?.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { - let indent = leadingWhitespace(line) - let numRange = Range(match.range(at: 2), in: line)! - let num = Int(line[numRange]) ?? 1 - let content = String(line.dropFirst(match.range.length)).trimmingCharacters(in: .whitespaces) - if content.isEmpty { - let replaceRange = NSRange(location: lineRange.location, length: lineRange.length) - textView.insertText("\n", replacementRange: replaceRange) - return true - } - textView.insertText("\n\(indent)\(num + 1). ", replacementRange: textView.selectedRange()) - return true - } - - return false - } - - // MARK: - Tab: indent/outdent - - private func handleTab(_ textView: NSTextView, indent: Bool) -> Bool { - let string = textView.string as NSString - let cursorLocation = textView.selectedRange().location - let lineRange = string.lineRange(for: NSRange(location: cursorLocation, length: 0)) - let line = string.substring(with: lineRange) - - // Only indent/outdent list items - let isList = line.trimmingCharacters(in: .whitespaces).hasPrefix("-") || - line.trimmingCharacters(in: .whitespaces).hasPrefix("*") || - line.trimmingCharacters(in: .whitespaces).hasPrefix("+") || - line.trimmingCharacters(in: .whitespaces).range(of: "^\\d+\\. ", options: .regularExpression) != nil - - guard isList else { return false } - - if indent { - textView.insertText(" " + line, replacementRange: lineRange) - } else { - if line.hasPrefix(" ") { - textView.insertText(String(line.dropFirst(2)), replacementRange: lineRange) - } - } - return true - } - - // MARK: - Markdown Styling - - func applyMarkdownStyling() { - guard let textView, let textStorage = textView.textStorage else { return } - - let fullRange = NSRange(location: 0, length: textStorage.length) - let string = textStorage.string as NSString - - // Preserve cursor - let selectedRange = textView.selectedRange() - - textStorage.beginEditing() - - // Reset to base style - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 2 - textStorage.setAttributes([ - .font: parent.font, - .foregroundColor: parent.textColor, - .paragraphStyle: paragraphStyle - ], range: fullRange) - - // Process line by line - string.enumerateSubstrings(in: fullRange, options: .byLines) { line, lineRange, _, _ in - guard let line else { return } - let trimmed = line.trimmingCharacters(in: .whitespaces) - - // Headers: # ## ### - if trimmed.hasPrefix("### ") { - textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: self.parent.font.pointSize + 1, weight: .semibold), range: lineRange) - } else if trimmed.hasPrefix("## ") { - textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: self.parent.font.pointSize + 2, weight: .semibold), range: lineRange) - } else if trimmed.hasPrefix("# ") { - textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: self.parent.font.pointSize + 4, weight: .bold), range: lineRange) - } - - // Bullets: replace "- " visual with bullet character styling - if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("- [x] ") { - // Task items — dim the checkbox prefix - let prefixLen = trimmed.hasPrefix("- [ ] ") ? 6 : 6 - let offset = line.count - trimmed.count - let prefixRange = NSRange(location: lineRange.location + offset, length: prefixLen) - textStorage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: prefixRange) - } else if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") { - // Dim the dash/asterisk - let offset = line.count - trimmed.count - let dashRange = NSRange(location: lineRange.location + offset, length: 1) - textStorage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: dashRange) - } - } - - // Bold: **text** - let boldPattern = try? NSRegularExpression(pattern: "\\*\\*(.+?)\\*\\*") - boldPattern?.enumerateMatches(in: string as String, range: fullRange) { match, _, _ in - guard let match else { return } - // Bold the content - let contentRange = match.range(at: 1) - let boldFont = NSFontManager.shared.convert(self.parent.font, toHaveTrait: .boldFontMask) - textStorage.addAttribute(.font, value: boldFont, range: contentRange) - // Dim the ** markers - let openRange = NSRange(location: match.range.location, length: 2) - let closeRange = NSRange(location: match.range.location + match.range.length - 2, length: 2) - textStorage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: openRange) - textStorage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: closeRange) - } - - // Italic: *text* (not inside **) - let italicPattern = try? NSRegularExpression(pattern: "(? String { - String(line.prefix(while: { $0 == " " || $0 == "\t" })) - } - } -} diff --git a/Sources/Bugbook/Views/Editor/TextBlockView.swift b/Sources/Bugbook/Views/Editor/TextBlockView.swift index 9488bd88..929dbf41 100644 --- a/Sources/Bugbook/Views/Editor/TextBlockView.swift +++ b/Sources/Bugbook/Views/Editor/TextBlockView.swift @@ -88,7 +88,7 @@ struct TextBlockView: View { guard block.type == .heading, block.headingLevel == 1 else { return nil } let blocks = document.blocks guard !blocks.isEmpty, blocks[0].id == block.id else { return nil } - return "New page" + return document.isMeetingPage ? "New Meeting" : "New page" } private var swiftUIFont: Font { diff --git a/Sources/Bugbook/Views/Editor/WikiLinkView.swift b/Sources/Bugbook/Views/Editor/WikiLinkView.swift index c68eac41..1e0d2893 100644 --- a/Sources/Bugbook/Views/Editor/WikiLinkView.swift +++ b/Sources/Bugbook/Views/Editor/WikiLinkView.swift @@ -26,6 +26,9 @@ struct WikiLinkView: View { .font(.system(size: EditorTypography.bodyFontSize)) .foregroundStyle(.primary) .underline() + Image(systemName: "arrow.up.right") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(.secondary.opacity(0.6)) } .contentShape(Rectangle()) .onTapGesture(perform: onNavigate) diff --git a/Sources/Bugbook/Views/Meetings/MeetingPageView.swift b/Sources/Bugbook/Views/Meetings/MeetingPageView.swift new file mode 100644 index 00000000..9d025b49 --- /dev/null +++ b/Sources/Bugbook/Views/Meetings/MeetingPageView.swift @@ -0,0 +1,710 @@ +import SwiftUI + +/// Dedicated meeting page layout. +/// Standard page header + title + database-style property pills + block editor body. +/// Recording controls live in the property pill row. +struct MeetingPageView: View { + var appState: AppState + var document: BlockDocument + var transcriptionService: TranscriptionService + var meetingNoteService: MeetingNoteService + var transcriptStore: MeetingTranscriptStore + var onTextChange: () -> Void + var onTyping: () -> Void + var onNavigateToFile: (String) -> Void + + @State private var transcript: MeetingTranscript = MeetingTranscript() + @State private var isTranscriptExpanded = false + @State private var transcriptSearch = "" + @State private var copyConfirmation = false + @State private var isGeneratingSummary = false + + /// Cached YAML-derived values, refreshed only when frontmatter changes. + /// Avoids re-parsing on every view body invalidation (which fires 10x/sec during recording). + @State private var cachedParticipants: [String] = [] + @State private var cachedMeetingId: String? + @State private var cachedMeetingDate: Date? + + private static let columnHorizontalPadding: CGFloat = 76 + + private var isRecordingThisPage: Bool { + guard let session = appState.activeMeetingSession else { return false } + return session.meetingPagePath == document.filePath + } + + // MARK: - YAML Parsing + + private static func parseParticipants(from yaml: String) -> [String] { + let lines = yaml.split(separator: "\n", omittingEmptySubsequences: false) + var inParticipants = false + var result: [String] = [] + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("participants:") { + inParticipants = true + continue + } + if inParticipants { + if trimmed.hasPrefix("- ") { + result.append(String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)) + } else { + break + } + } + } + return result + } + + private func refreshCachedFrontmatter() { + let yaml = document.yamlFrontmatter + cachedParticipants = Self.parseParticipants(from: yaml) + cachedMeetingId = MarkdownBlockParser.yamlValue(for: "meeting_id", in: yaml) + if let raw = MarkdownBlockParser.yamlValue(for: "date", in: yaml) { + cachedMeetingDate = MeetingNoteService.isoDateFormatter.date(from: raw) + } else { + cachedMeetingDate = nil + } + } + + var body: some View { + ZStack(alignment: .bottom) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + PageHeaderView( + icon: Binding(get: { document.icon }, set: { document.icon = $0; onTextChange() }), + coverUrl: Binding(get: { document.coverUrl }, set: { document.coverUrl = $0; onTextChange() }), + coverPosition: Binding(get: { document.coverPosition }, set: { document.coverPosition = $0; onTextChange() }), + fullWidth: false, + contentColumnMaxWidth: 860 + ) + + columnAligned { + VStack(alignment: .leading, spacing: 8) { + if let titleBlock = document.titleBlock { + TextBlockView(document: document, block: titleBlock, onTyping: onTyping) + } + + propertyPills + } + .padding(.horizontal, Self.columnHorizontalPadding) + .padding(.top, 8) + .padding(.bottom, 12) + } + + BlockEditorView( + document: document, + onTextChange: onTextChange, + onTyping: onTyping, + onPagePathDrop: { _, _ in }, + contentColumnMaxWidth: 860 + ) + + // Bottom spacer so the user can scroll past the last note + // without it being hidden behind the floating transcript header. + if isRecordingThisPage || !transcript.entries.isEmpty { + Color.clear.frame(height: 80) + } + } + } + + // Floating transcript widget — fixed to the bottom of the meeting page. + // Header is always visible; tap to expand the body upward as an overlay. + if isRecordingThisPage || !transcript.entries.isEmpty { + columnAligned { + transcriptWidget + .padding(.horizontal, Self.columnHorizontalPadding) + .padding(.bottom, 16) + } + } + } + .background(Color.fallbackEditorBg) + .onAppear { + refreshCachedFrontmatter() + loadTranscriptIfNeeded() + + // Auto-start recording if this page was just created via "New Meeting" + if let pending = appState.pendingAutoRecordPath, pending == document.filePath { + appState.pendingAutoRecordPath = nil + if appState.activeMeetingSession == nil { + startRecording() + } + } + } + .onChange(of: document.yamlFrontmatter) { _, _ in + refreshCachedFrontmatter() + } + .onReceive(NotificationCenter.default.publisher(for: .stopMeetingRecording)) { _ in + if isRecordingThisPage { + stopRecording() + } + } + } + + private func loadTranscriptIfNeeded() { + // Skip if already loaded (in-memory transcript has data) or no meeting id + guard transcript.entries.isEmpty, transcript.summary.isEmpty, + let id = cachedMeetingId, + let workspace = appState.workspacePath else { return } + + // Background read so a large transcript JSON doesn't block the main thread. + // Store is nonisolated so the load runs on a background thread. + let store = transcriptStore + Task.detached(priority: .userInitiated) { + let loaded = store.load(meetingId: id, workspace: workspace) + await MainActor.run { + self.transcript = loaded + } + } + } + + /// Center a block of content in the page column, matching the block editor layout. + @ViewBuilder + private func columnAligned(@ViewBuilder _ content: () -> Content) -> some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 0) { + content() + } + .frame(maxWidth: 860) + Spacer(minLength: 0) + } + } + + // MARK: - Transcript Widget + + /// Combined live + persisted transcript entries for display. + private var displayedEntries: [MeetingTranscriptEntry] { + if let session = appState.activeMeetingSession, isRecordingThisPage { + // During recording, show the live polled segments from the session + return session.confirmedSegments.map { MeetingTranscriptEntry(text: $0) } + } + return transcript.entries + } + + private var filteredEntries: [MeetingTranscriptEntry] { + let query = transcriptSearch.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return displayedEntries } + return displayedEntries.filter { $0.text.lowercased().contains(query) } + } + + private var transcriptWidget: some View { + // Header is anchored at the bottom; body grows upward when expanded. + // Floats over the notes — needs an opaque background. + VStack(alignment: .leading, spacing: 0) { + if isTranscriptExpanded { + transcriptBody + Divider() + transcriptControls + Divider() + } + transcriptHeader + } + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.fallbackCardBg) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.primary.opacity(Opacity.medium), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + } + + private var transcriptHeader: some View { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + isTranscriptExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + // Chevron points up when collapsed (will open upward), down when expanded. + Image(systemName: "chevron.up") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isTranscriptExpanded ? 180 : 0)) + + Image(systemName: isRecordingThisPage ? "waveform" : "text.bubble") + .font(.system(size: 12)) + .foregroundStyle(isRecordingThisPage ? StatusColor.error : .secondary) + + Text("Transcript") + .font(.system(size: Typography.bodySmall, weight: .medium)) + .foregroundStyle(.primary) + + Spacer() + + if isRecordingThisPage { + PulsingRecordDot() + .scaleEffect(0.8) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var transcriptControls: some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + TextField("Search transcript", text: $transcriptSearch) + .textFieldStyle(.plain) + .font(.system(size: Typography.caption)) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(.rect(cornerRadius: Radius.xs)) + + Spacer() + + Button(action: copyTranscript) { + HStack(spacing: 4) { + Image(systemName: copyConfirmation ? "checkmark" : "doc.on.doc") + .font(.system(size: 10)) + Text(copyConfirmation ? "Copied" : "Copy") + .font(.system(size: Typography.caption, weight: .medium)) + } + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.primary.opacity(Opacity.light)) + .clipShape(.rect(cornerRadius: Radius.xs)) + } + .buttonStyle(.plain) + .disabled(displayedEntries.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var transcriptBody: some View { + Group { + if filteredEntries.isEmpty && displayedEntries.isEmpty { + Text(isRecordingThisPage ? "Listening..." : "No transcript") + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } else if filteredEntries.isEmpty { + Text("No matches") + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(filteredEntries) { entry in + transcriptBubble(entry: entry) + } + if isRecordingThisPage, + let session = appState.activeMeetingSession, + !session.volatileText.isEmpty { + transcriptBubble(entry: MeetingTranscriptEntry(text: session.volatileText), isVolatile: true) + } + } + .padding(12) + } + .frame(maxHeight: 300) + } + } + } + + private func transcriptBubble(entry: MeetingTranscriptEntry, isVolatile: Bool = false) -> some View { + let isSelf = entry.speaker == "self" + return HStack { + if isSelf { Spacer(minLength: 40) } + Text(entry.text) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(isVolatile ? .tertiary : .primary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(isSelf ? Color.accentColor.opacity(0.18) : Color.primary.opacity(Opacity.light)) + ) + .frame(maxWidth: .infinity, alignment: isSelf ? .trailing : .leading) + if !isSelf { Spacer(minLength: 40) } + } + } + + private func copyTranscript() { + let text = displayedEntries.map(\.text).joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + copyConfirmation = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + copyConfirmation = false + } + } + + // MARK: - Property Pills + + private var propertyPills: some View { + HStack(spacing: 8) { + datePill + recordingPill + if !cachedParticipants.isEmpty { + participantsPill + } + } + } + + private var datePill: some View { + let date = cachedMeetingDate ?? Date() + return propertyChip(icon: "calendar", text: Self.pillDateFormatter.string(from: date)) + } + + @ViewBuilder + private var recordingPill: some View { + if isRecordingThisPage, let session = appState.activeMeetingSession { + Button(action: stopRecording) { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .frame(width: 9, height: 9) + TimelineView(.periodic(from: session.startDate, by: 1)) { context in + let elapsed = Int(context.date.timeIntervalSince(session.startDate)) + Text(String(format: "%d:%02d", elapsed / 60, elapsed % 60)) + .font(.system(size: Typography.bodySmall, weight: .semibold).monospacedDigit()) + .foregroundStyle(.white) + } + Text("Stop Recording") + .font(.system(size: Typography.bodySmall, weight: .semibold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Capsule().fill(StatusColor.error)) + } + .buttonStyle(.plain) + } else if appState.activeMeetingSession == nil { + Button(action: startRecording) { + HStack(spacing: 5) { + Circle().fill(StatusColor.error).frame(width: 7, height: 7) + Text("Record") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .overlay( + Capsule().strokeBorder(Color.primary.opacity(Opacity.medium), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + + private var participantsPill: some View { + propertyChip(icon: "person.2", text: cachedParticipants.joined(separator: ", ")) + } + + private func propertyChip(icon: String, text: String) -> some View { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(text) + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .overlay( + Capsule().strokeBorder(Color.primary.opacity(Opacity.medium), lineWidth: 1) + ) + } + + // MARK: - Formatters + + private static let pillDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM d, h:mm a" + f.timeZone = .current + return f + }() + + // MARK: - Actions + + private func startRecording() { + guard let filePath = document.filePath else { return } + let session = ActiveMeetingSession(meetingPagePath: filePath) + appState.activeMeetingSession = session + appState.isRecording = true + + Task { + await transcriptionService.startRecording() + + // Poll transcript and audio level. Each property write goes through an + // @Observable session, so we guard each one to avoid spurious 10Hz invalidations. + var lastSegmentCount = 0 + var lastVolatile = "" + var lastLevel: Float = -1 + while transcriptionService.isRecording { + let level = transcriptionService.audioLevel + if level != lastLevel { + lastLevel = level + session.audioLevel = level + } + + let segmentCount = transcriptionService.confirmedSegments.count + if segmentCount != lastSegmentCount { + lastSegmentCount = segmentCount + session.confirmedSegments = transcriptionService.confirmedSegments + } + + let volatile = transcriptionService.volatileText + if volatile != lastVolatile { + lastVolatile = volatile + session.volatileText = volatile + } + + try? await Task.sleep(for: .milliseconds(100)) + } + } + } + + private func stopRecording() { + let fullText = transcriptionService.stopRecording() + appState.isRecording = false + + guard let session = appState.activeMeetingSession else { + appState.activeMeetingSession = nil + return + } + + // Read directly from the transcription service to capture any segments + // that were flushed synchronously by stopRecording() after the last poll tick. + let finalSegments = transcriptionService.confirmedSegments + + // Save the transcript entries to the sidecar store + if !finalSegments.isEmpty, + let id = cachedMeetingId, + let workspace = appState.workspacePath { + let entries = finalSegments.map { MeetingTranscriptEntry(text: $0) } + transcript = MeetingTranscript( + entries: entries, + summary: transcript.summary, + actionItems: transcript.actionItems, + createdAt: session.startDate + ) + transcriptStore.save(transcript, meetingId: id, workspace: workspace) + } + + // Update frontmatter with duration + let duration = Int(Date().timeIntervalSince(session.startDate)) + updateFrontmatterDuration(duration) + onTextChange() + + // Clear the session so isRecording and pill state are consistent + appState.activeMeetingSession = nil + + // Auto-generate summary in the background + if !fullText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + isGeneratingSummary = true + Task { + await generateSummary(transcript: fullText) + isGeneratingSummary = false + } + } + } + + private func updateFrontmatterDuration(_ seconds: Int) { + let minutes = seconds / 60 + var yaml = document.yamlFrontmatter + if yaml.contains("duration:") { + yaml = yaml.replacingOccurrences( + of: #"duration:.*"#, + with: "duration: \(minutes)m", + options: .regularExpression + ) + } else { + yaml += "\nduration: \(minutes)m" + } + document.yamlFrontmatter = yaml + } + + private func generateSummary(transcript: String) async { + let prompt = """ + Summarize this meeting transcript. Output in this exact format with no preamble: + + TITLE: + + SUMMARY: + - + - + - + + ACTION ITEMS: + - + - + + Rules: + - Title is one line, no quotes, no trailing punctuation. + - Summary bullets are short (one sentence each), capturing the main points discussed. + - Action items are concrete tasks or follow-ups. Omit the section if there are none. + - Do not include any text outside these three sections. + + Transcript: + \(transcript) + """ + + // Run the CLI process off the main thread to avoid blocking the UI. + // Use a login shell so the user's PATH (including ~/.local/bin) is loaded. + let output: String? = try? await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-l", "-c", "claude --model haiku -p"] + + let inputPipe = Pipe() + let outputPipe = Pipe() + process.standardInput = inputPipe + process.standardOutput = outputPipe + process.standardError = Pipe() + + try process.run() + + if let data = prompt.data(using: .utf8) { + inputPipe.fileHandleForWriting.write(data) + } + try? inputPipe.fileHandleForWriting.close() + + process.waitUntilExit() + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + }.value + + let parsed = parseSummaryOutput(output ?? "") + + // Persist on the transcript sidecar for future reference + if parsed.sawSummarySection { + self.transcript.summary = parsed.summary + } else { + self.transcript.summary = ["Summary generation failed"] + } + self.transcript.actionItems = parsed.actionItems + + if let id = cachedMeetingId, let workspace = appState.workspacePath { + transcriptStore.save(self.transcript, meetingId: id, workspace: workspace) + } + + // Apply title to the empty H1 title block, and inject summary + action items as document blocks + applyGeneratedTitle(parsed.title) + injectSummaryAndActionsBlocks(summary: parsed.summary, actionItems: parsed.actionItems) + onTextChange() + } + + private func parseSummaryOutput(_ output: String) -> (title: String?, summary: [String], actionItems: [String], sawSummarySection: Bool) { + var title: String? + var summary: [String] = [] + var actionItems: [String] = [] + var inSummary = false + var inActions = false + var sawSummarySection = false + + for line in output.split(separator: "\n", omittingEmptySubsequences: false) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("TITLE:") { + let value = String(trimmed.dropFirst(6)) + .trimmingCharacters(in: CharacterSet(charactersIn: " \"'")) + if !value.isEmpty { title = value } + inSummary = false + inActions = false + continue + } + if trimmed.hasPrefix("SUMMARY:") { + inSummary = true + inActions = false + sawSummarySection = true + continue + } + if trimmed.hasPrefix("ACTION ITEMS:") { + inSummary = false + inActions = true + continue + } + if trimmed.hasPrefix("- ") { + let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) + guard !item.isEmpty else { continue } + if inSummary { + summary.append(item) + } else if inActions { + actionItems.append(item) + } + } + } + + return (title, summary, actionItems, sawSummarySection) + } + + // MARK: - Document Mutation + + /// Set the H1 title block to the AI-generated title if it's currently empty. + private func applyGeneratedTitle(_ title: String?) { + guard let title, !title.isEmpty, + let titleBlock = document.titleBlock, + titleBlock.text.trimmingCharacters(in: .whitespaces).isEmpty else { return } + document.updateBlockProperty(id: titleBlock.id) { block in + block.text = title + } + } + + /// Insert (or replace) the Summary and Action Items sections in the document body. + /// Idempotent: previously-generated blocks are tracked by ID in the transcript sidecar + /// and removed before fresh ones are inserted. This is robust to the user renaming the + /// section headings. + private func injectSummaryAndActionsBlocks(summary: [String], actionItems: [String]) { + // Remove blocks from any previous generation by ID + let staleIds = Set(transcript.generatedBlockIds) + if !staleIds.isEmpty { + document.blocks.removeAll { staleIds.contains($0.id) } + } + + // Build the new blocks and capture their IDs + var newBlocks: [Block] = [] + if !summary.isEmpty { + newBlocks.append(Block(type: .heading, text: "Summary", headingLevel: 2)) + for bullet in summary { + newBlocks.append(Block(type: .bulletListItem, text: bullet)) + } + } + if !actionItems.isEmpty { + newBlocks.append(Block(type: .heading, text: "Action Items", headingLevel: 2)) + for item in actionItems { + newBlocks.append(Block(type: .taskItem, text: item)) + } + } + + transcript.generatedBlockIds = newBlocks.map(\.id) + guard !newBlocks.isEmpty else { return } + document.blocks.append(contentsOf: newBlocks) + } +} + +// MARK: - Pulsing Record Dot (reused from MeetingsView) + +struct PulsingRecordDot: View { + @State private var pulse = false + + var body: some View { + Circle() + .fill(StatusColor.error) + .frame(width: 8, height: 8) + .scaleEffect(pulse ? 1.3 : 1.0) + .opacity(pulse ? 0.6 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + pulse = true + } + } + } +} diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift index 8db26e45..36d0fd93 100644 --- a/Sources/Bugbook/Views/Meetings/MeetingsView.swift +++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift @@ -1,40 +1,19 @@ import SwiftUI import BugbookCore +/// Meeting dashboard — lists past meetings, "New Meeting" creates a meeting page and navigates there. +/// No recording UI here; recording happens in the MeetingPageView. struct MeetingsView: View { var appState: AppState @Bindable var viewModel: MeetingsViewModel - var transcriptionService: TranscriptionService var meetingNoteService: MeetingNoteService - var aiService: AiService var onNavigateToFile: (String) -> Void - @State private var meetingTitle = "" - @State private var isRecording = false - @State private var liveTranscript: [String] = [] - @State private var volatileText = "" - @State private var audioLevel: Float = 0 - @State private var pollingTask: Task? - @State private var isSaving = false - @State private var showTranscript = false - @State private var notesText = "" - @State private var showRecordSetup = false - var body: some View { VStack(spacing: 0) { header - - if isRecording { - recordingView - } else if isSaving { - savingView - } else { - if showRecordSetup { - recordSetupBar - Divider() - } - recentRecordings - } + Divider() + recentRecordings } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .ignoresSafeArea(.container, edges: .top) @@ -59,213 +38,26 @@ struct MeetingsView: View { Spacer() - if isRecording { - PulsingRecordDot() - Text("Recording") - .font(.system(size: Typography.caption, weight: .medium)) - .foregroundStyle(StatusColor.error) - } else if !isSaving { - Button(action: { - withAnimation(.easeInOut(duration: 0.15)) { - showRecordSetup.toggle() - } - }) { - HStack(spacing: 5) { - Circle() - .fill(showRecordSetup ? StatusColor.error : Color.primary.opacity(0.5)) - .frame(width: 7, height: 7) - Text("Record") - .font(.system(size: Typography.caption, weight: .medium)) - } - .foregroundStyle(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.primary.opacity(showRecordSetup ? Opacity.light : Opacity.subtle)) - .clipShape(.rect(cornerRadius: Radius.xs)) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - } - - // MARK: - Record Setup Bar (inline, shown when record is toggled) - - private var recordSetupBar: some View { - HStack(spacing: 8) { - TextField("Meeting title (optional)", text: $meetingTitle) - .textFieldStyle(.plain) - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(.primary) - - Button(action: { - showRecordSetup = false - startRecording() - }) { + Button(action: createNewMeeting) { HStack(spacing: 5) { - Circle() - .fill(StatusColor.error) - .frame(width: 7, height: 7) - Text("Start") + Image(systemName: "plus") + .font(.system(size: 10, weight: .semibold)) + Text("New Meeting") .font(.system(size: Typography.caption, weight: .medium)) } .foregroundStyle(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(Color.primary.opacity(Opacity.light)) + .background(Color.primary.opacity(Opacity.subtle)) .clipShape(.rect(cornerRadius: Radius.xs)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xs) - .strokeBorder(Color.primary.opacity(Opacity.medium), lineWidth: 1) - ) } .buttonStyle(.plain) } .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Color.primary.opacity(Opacity.subtle)) - } - - // MARK: - Recording View (active state) - - private var recordingView: some View { - VStack(spacing: 0) { - // Title (editable during recording) - TextField("Meeting title", text: $meetingTitle) - .textFieldStyle(.plain) - .font(.system(size: Typography.body, weight: .medium)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - - // Notes / Transcript toggle - HStack(spacing: 0) { - toggleTab("Notes", isActive: !showTranscript) { showTranscript = false } - toggleTab("Transcript", isActive: showTranscript) { showTranscript = true } - Spacer() - } - .padding(.horizontal, 10) - .padding(.bottom, 4) - - Divider() - - // Content area — either notes or transcript - if showTranscript { - transcriptView - } else { - notesView - } - - Divider() - - // Waveform + stop button - HStack(spacing: 12) { - HStack(spacing: 2) { - ForEach(0..<5, id: \.self) { i in - RoundedRectangle(cornerRadius: 1) - .fill(StatusColor.error.opacity(0.7)) - .frame(width: 3, height: barHeight(index: i)) - } - } - .frame(height: 20) - - Spacer() - - Button(action: stopRecording) { - HStack(spacing: 6) { - RoundedRectangle(cornerRadius: 2) - .fill(.primary) - .frame(width: 10, height: 10) - Text("Stop") - .font(.system(size: Typography.bodySmall, weight: .medium)) - } - .foregroundStyle(.primary) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.primary.opacity(Opacity.light)) - .clipShape(.rect(cornerRadius: Radius.sm)) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - } - } - - // MARK: - Notes / Transcript Views - - private func toggleTab(_ label: String, isActive: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(label) - .font(.system(size: Typography.caption, weight: .medium)) - .foregroundStyle(isActive ? .primary : .tertiary) - .padding(.vertical, 3) - .padding(.horizontal, 8) - .background(isActive ? Color.primary.opacity(Opacity.light) : Color.clear) - .clipShape(.rect(cornerRadius: Radius.xs)) - } - .buttonStyle(.plain) - } - - private var notesView: some View { - TextEditor(text: $notesText) - .font(.system(size: Typography.body)) - .scrollContentBackground(.hidden) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private var transcriptView: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(Array(liveTranscript.enumerated()), id: \.offset) { idx, segment in - Text(segment) - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(.primary) - .id(idx) - } - - if !volatileText.isEmpty { - Text(volatileText) - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(.tertiary) - .id("volatile") - } - - if liveTranscript.isEmpty && volatileText.isEmpty { - Text("Listening...") - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(.quaternary) - } - } - .padding(12) - } - .onChange(of: liveTranscript.count) { _, _ in - if let last = liveTranscript.indices.last { - proxy.scrollTo(last, anchor: .bottom) - } - } - } - } - - // MARK: - Saving View - - private var savingView: some View { - VStack(spacing: 12) { - Spacer() - ProgressView() - .controlSize(.regular) - Text("Saving meeting note...") - .font(.system(size: Typography.bodySmall)) - .foregroundStyle(.secondary) - Spacer() - } - .frame(maxWidth: .infinity) + .padding(.vertical, 6) } - // MARK: - Recent Recordings (primary content) + // MARK: - Recent Recordings @ViewBuilder private var recentRecordings: some View { @@ -276,7 +68,7 @@ struct MeetingsView: View { Text("No meetings yet") .font(.system(size: Typography.bodySmall)) .foregroundStyle(.quaternary) - Text("Press Record to start one") + Text("Create a new meeting to get started") .font(.system(size: Typography.caption)) .foregroundStyle(.quaternary) Spacer() @@ -299,111 +91,31 @@ struct MeetingsView: View { // MARK: - Actions - private func startRecording() { - isRecording = true - appState.isRecording = true - liveTranscript = [] - volatileText = "" - - pollingTask = Task { - await transcriptionService.startRecording() - - var lastSegmentCount = 0 - var lastVolatile = "" - - while transcriptionService.isRecording { - let segments = transcriptionService.confirmedSegments - let vol = transcriptionService.volatileText - let level = transcriptionService.audioLevel - - if segments.count != lastSegmentCount { - lastSegmentCount = segments.count - liveTranscript = segments - } - if vol != lastVolatile { - lastVolatile = vol - volatileText = vol - } - audioLevel = level - - try? await Task.sleep(for: .milliseconds(100)) - } - } - } - - private func stopRecording() { - let transcript = transcriptionService.stopRecording() - pollingTask?.cancel() - pollingTask = nil - isRecording = false - appState.isRecording = false - audioLevel = 0 - - guard !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let workspace = appState.workspacePath else { - return - } - - isSaving = true - let title = meetingTitle.trimmingCharacters(in: .whitespacesAndNewlines) - let effectiveTitle = title.isEmpty ? "Meeting \(Self.dateFormatter.string(from: Date()))" : title - - Task { - // Create a lightweight event placeholder to carry the title - let placeholderEvent = CalendarEvent( - id: UUID().uuidString, - title: effectiveTitle, - startDate: Date(), - endDate: Date().addingTimeInterval(3600), - isAllDay: false, - calendarId: "" - ) - let path = await meetingNoteService.createMeetingNoteWithTranscript( - transcription: TranscriptionResult(fullText: transcript, timestampedText: transcript), - event: placeholderEvent, - workspace: workspace, - aiService: aiService, - apiKey: appState.settings.anthropicApiKey - ) - isSaving = false - meetingTitle = "" - liveTranscript = [] - - if let path { - onNavigateToFile(path) - } + private func createNewMeeting() { + guard let workspace = appState.workspacePath else { return } - // Refresh the list - viewModel.scan(workspace: workspace) - } - } + let effectiveTitle = "New Meeting" - // MARK: - Waveform + guard let path = meetingNoteService.createAdHocMeetingPage( + title: effectiveTitle, date: Date(), workspace: workspace + ) else { return } - private func barHeight(index: Int) -> CGFloat { - let base: CGFloat = 4 - let scale = CGFloat(audioLevel) * 16 - let offset = sin(Double(index) * 1.3 + Date().timeIntervalSinceReferenceDate * 3) * 0.5 + 0.5 - return base + scale * CGFloat(offset) + // Signal that the new page should auto-start recording when it loads + appState.pendingAutoRecordPath = path + onNavigateToFile(path) + viewModel.scan(workspace: workspace) } // MARK: - Components private func sectionDivider(_ title: String) -> some View { - HStack(spacing: 8) { - Text(title) - .font(.system(size: Typography.caption2, weight: .medium)) - .foregroundStyle(.tertiary) - .textCase(.uppercase) - .fixedSize() - - Rectangle() - .fill(Color.primary.opacity(Opacity.subtle)) - .frame(height: 1) - } - .padding(.horizontal, 12) - .padding(.top, 10) - .padding(.bottom, 4) + Text(title) + .font(.system(size: Typography.caption2, weight: .medium)) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) } private func meetingRow(_ meeting: DiscoveredMeeting) -> some View { @@ -412,19 +124,15 @@ struct MeetingsView: View { Text(formattedTime(meeting.timestamp)) .font(.system(size: Typography.caption, design: .monospaced)) .foregroundStyle(.tertiary) - .frame(width: 48, alignment: .trailing) + .lineLimit(1) + .frame(width: 60, alignment: .leading) Text(meeting.title) .font(.system(size: Typography.body)) .foregroundStyle(.primary) .lineLimit(1) - Spacer(minLength: 4) - - Text(meeting.parentPageName) - .font(.system(size: Typography.caption)) - .foregroundStyle(.quaternary) - .lineLimit(1) + Spacer(minLength: 0) } .padding(.horizontal, 12) .padding(.vertical, 6) @@ -466,25 +174,6 @@ struct MeetingsView: View { }() } -// MARK: - Pulsing Record Dot - -private struct PulsingRecordDot: View { - @State private var pulse = false - - var body: some View { - Circle() - .fill(StatusColor.error) - .frame(width: 8, height: 8) - .scaleEffect(pulse ? 1.3 : 1.0) - .opacity(pulse ? 0.6 : 1.0) - .onAppear { - withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { - pulse = true - } - } - } -} - // MARK: - Hover Highlight private struct HoverHighlight: View { diff --git a/Sources/Bugbook/Views/Panes/PaneChromeBar.swift b/Sources/Bugbook/Views/Panes/PaneChromeBar.swift index 69add08a..8d196b87 100644 --- a/Sources/Bugbook/Views/Panes/PaneChromeBar.swift +++ b/Sources/Bugbook/Views/Panes/PaneChromeBar.swift @@ -110,15 +110,14 @@ struct PaneChromeBar: View { if breadcrumbs.count > 1, let onNav = onBreadcrumbNavigate { // Show parent breadcrumbs (all except last) as clickable, then current page as label ForEach(breadcrumbs.dropLast()) { crumb in - Button { - onNav(crumb) - } label: { - Text(crumb.name) - .font(.system(size: 11)) - .foregroundStyle(mutedColor) - .lineLimit(1) - } - .buttonStyle(.plain) + Text(crumb.name) + .font(.system(size: 11)) + .foregroundStyle(mutedColor) + .lineLimit(1) + .padding(.vertical, 2) + .contentShape(Rectangle()) + .onTapGesture { onNav(crumb) } + .appCursor(.pointingHand) Image(systemName: "chevron.right") .font(.system(size: 8, weight: .medium)) diff --git a/Sources/Bugbook/Views/Panes/PaneFindBar.swift b/Sources/Bugbook/Views/Panes/PaneFindBar.swift deleted file mode 100644 index 8e68cc3f..00000000 --- a/Sources/Bugbook/Views/Panes/PaneFindBar.swift +++ /dev/null @@ -1,91 +0,0 @@ -import SwiftUI - -/// Compact find-in-page bar that slides between the chrome bar and document content. -/// Searches all visible block text in the active document pane. -struct PaneFindBar: View { - @Binding var query: String - let matchCount: Int - let currentMatch: Int - let onNext: () -> Void - let onPrevious: () -> Void - let onClose: () -> Void - - @FocusState private var isFieldFocused: Bool - - var body: some View { - HStack(spacing: 6) { - // Search field - TextField("Find...", text: $query) - .textFieldStyle(.plain) - .font(.system(size: Typography.caption)) - .focused($isFieldFocused) - .onSubmit { - if NSApp.currentEvent?.modifierFlags.contains(.shift) == true { - onPrevious() - } else { - onNext() - } - } - .onExitCommand { onClose() } - .frame(minWidth: 120, maxWidth: 220) - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: Radius.xs) - .fill(Color.fallbackEditorBg) - .overlay( - RoundedRectangle(cornerRadius: Radius.xs) - .strokeBorder(Color.fallbackBorderColor, lineWidth: 1) - ) - ) - - // Match count - if !query.isEmpty { - Text(matchCount > 0 ? "\(currentMatch) of \(matchCount)" : "No results") - .font(.system(size: Typography.caption)) - .foregroundStyle(.secondary) - .monospacedDigit() - } - - // Previous / Next - Button(action: onPrevious) { - Image(systemName: "chevron.up") - .font(.system(size: 10, weight: .medium)) - .frame(width: 22, height: 22) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(matchCount == 0) - .help("Previous match (Shift+Enter)") - - Button(action: onNext) { - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .medium)) - .frame(width: 22, height: 22) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(matchCount == 0) - .help("Next match (Enter)") - - // Close - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .medium)) - .frame(width: 22, height: 22) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .help("Close (Escape)") - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Color.fallbackEditorBg) - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color.fallbackChromeBorder) - .frame(height: 0.5) - } - .onAppear { isFieldFocused = true } - } -} diff --git a/Sources/Bugbook/Views/Panes/PaneFocusIndicator.swift b/Sources/Bugbook/Views/Panes/PaneFocusIndicator.swift index 9118aa64..0fa0a2eb 100644 --- a/Sources/Bugbook/Views/Panes/PaneFocusIndicator.swift +++ b/Sources/Bugbook/Views/Panes/PaneFocusIndicator.swift @@ -38,6 +38,11 @@ struct PaneFocusTracker: NSViewRepresentable { let locationInView = self.convert(event.locationInWindow, from: nil) if self.bounds.contains(locationInView) { self.onFocus?(self.paneId) + // Resign the terminal synchronously before the click dispatches, + // so the target view (e.g. NSTextView) can become first responder. + if window.firstResponder is GhosttySurfaceHostView { + window.makeFirstResponder(nil) + } } return event } diff --git a/Sources/Bugbook/Views/Settings/BrowserSettingsView.swift b/Sources/Bugbook/Views/Settings/BrowserSettingsView.swift index 34d2f2e4..c7b7e967 100644 --- a/Sources/Bugbook/Views/Settings/BrowserSettingsView.swift +++ b/Sources/Bugbook/Views/Settings/BrowserSettingsView.swift @@ -2,6 +2,9 @@ import SwiftUI struct BrowserSettingsView: View { @Bindable var appState: AppState + @Bindable var browserManager: BrowserManager + @State private var dataMessage: String? + @State private var isClearingCookies = false var body: some View { VStack(alignment: .leading, spacing: 24) { @@ -13,6 +16,23 @@ struct BrowserSettingsView: View { } .pickerStyle(.segmented) + Toggle("Enable search suggestions", isOn: $appState.settings.browserSuggestionsEnabled) + Toggle("Suggest Bugbook pages", isOn: $appState.settings.browserSuggestsBugbookPages) + .disabled(!appState.settings.browserSuggestionsEnabled) + + HStack { + Text("Suggestion Count") + .font(.system(size: 13)) + Spacer() + Stepper(value: $appState.settings.browserSuggestionLimit, in: 3...12) { + Text("\(appState.settings.browserSuggestionLimit)") + .font(.system(size: 13, weight: .medium)) + .monospacedDigit() + } + .frame(width: 140) + .disabled(!appState.settings.browserSuggestionsEnabled) + } + HStack { Text("Default Save Folder") .font(.system(size: 13)) @@ -23,10 +43,73 @@ struct BrowserSettingsView: View { } } + SettingsSection("History & Privacy") { + Toggle("Save browsing history", isOn: $appState.settings.browserHistoryEnabled) + + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("History") + .font(.system(size: 13, weight: .medium)) + Text(historySummary) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + Button("Clear History") { + browserManager.clearHistory() + dataMessage = "Browsing history cleared." + } + .disabled(browserManager.browsingHistory.isEmpty) + } + + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Cookies") + .font(.system(size: 13, weight: .medium)) + Text("Clear stored site cookies for the embedded browser.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + Button(isClearingCookies ? "Clearing…" : "Clear Cookies") { + Task { await clearCookies() } + } + .disabled(isClearingCookies) + } + + if let dataMessage, !dataMessage.isEmpty { + Text(dataMessage) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + if browserManager.browsingHistory.isEmpty { + Text("No browsing history yet.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(browserManager.browsingHistory.prefix(6))) { visit in + VStack(alignment: .leading, spacing: 2) { + Text(visit.title) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Text(visit.urlString) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + } + SettingsSection("Browser Chrome") { Toggle("Show back/forward buttons", isOn: $appState.settings.browserChrome.showsBackForwardButtons) Toggle("Show bookmarks bar", isOn: $appState.settings.browserChrome.showsBookmarksBar) Toggle("Auto-hide tab pills when only one tab exists", isOn: $appState.settings.browserChrome.autoHidesTabPills) + Toggle("Show save button", isOn: $appState.settings.browserChrome.showsSaveButton) Toggle("Show status bar on link hover", isOn: $appState.settings.browserChrome.showsStatusBar) } @@ -67,4 +150,24 @@ struct BrowserSettingsView: View { private func removeQuickLaunch(_ id: UUID) { appState.settings.browserQuickLaunchItems.removeAll { $0.id == id } } + + private var historySummary: String { + let count = browserManager.browsingHistory.count + if count == 0 { + return "Your browser history is empty." + } + return "\(count) saved visit\(count == 1 ? "" : "s")" + } + + private func clearCookies() async { + isClearingCookies = true + defer { isClearingCookies = false } + + do { + try await browserManager.clearCookies() + dataMessage = "Cookies cleared." + } catch { + dataMessage = "Failed to clear cookies: \(error.localizedDescription)" + } + } } diff --git a/Sources/Bugbook/Views/Settings/SettingsView.swift b/Sources/Bugbook/Views/Settings/SettingsView.swift index 909f08d8..fd77ada1 100644 --- a/Sources/Bugbook/Views/Settings/SettingsView.swift +++ b/Sources/Bugbook/Views/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsView: View { var appState: AppState + var browserManager: BrowserManager private var tabTitle: String { switch appState.selectedSettingsTab { @@ -39,7 +40,7 @@ struct SettingsView: View { case "terminal": TerminalSettingsView(appState: appState) case "browser": - BrowserSettingsView(appState: appState) + BrowserSettingsView(appState: appState, browserManager: browserManager) case "agents": AgentsSettingsView(appState: appState) case "search": diff --git a/Sources/Bugbook/Views/Shell/ShellNavigationViews.swift b/Sources/Bugbook/Views/Shell/ShellNavigationViews.swift index 03b01894..15f4e622 100644 --- a/Sources/Bugbook/Views/Shell/ShellNavigationViews.swift +++ b/Sources/Bugbook/Views/Shell/ShellNavigationViews.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import BugbookCore enum ShellSidebarMetrics { @@ -28,6 +29,7 @@ struct HarborSidebarView: View { let onSelectEntry: (FileEntry) -> Void let onRefreshTree: () -> Void let onOpenSettings: () -> Void + let onNavItemTap: (ShellNavItem, _ inNewTab: Bool) -> Void let contextualLabel: String? @ViewBuilder let contextualContent: () -> ContextualContent @@ -39,6 +41,7 @@ struct HarborSidebarView: View { let stored = UserDefaults.standard.stringArray(forKey: "expandedFolders") ?? [] return Set(stored) }() + @State private var showTrashPopover = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -53,19 +56,23 @@ struct HarborSidebarView: View { .padding(.trailing, inset) // ── Fixed Zone ────────────────────────────────── - // Icon row: Home, Search - HStack(spacing: ShellZoomMetrics.size(2)) { - fixedIconButton("house", help: "Home") { - NotificationCenter.default.post(name: .openGateway, object: nil) - } - fixedIconButton("magnifyingglass", help: "Search") { - NotificationCenter.default.post(name: .quickOpen, object: nil) + // Vertical navigation list. Click replaces focused pane; Cmd+click opens new workspace tab. + VStack(alignment: .leading, spacing: ShellZoomMetrics.size(1)) { + ForEach(ShellNavigationItems.all) { item in + ShellSidebarShortcutRow( + title: item.label, + systemImage: item.icon, + verticalPadding: ShellZoomMetrics.size(5), + action: { + let cmdHeld = NSEvent.modifierFlags.contains(.command) + onNavItemTap(item, cmdHeld) + } + ) } - Spacer(minLength: 0) } - .padding(.top, ShellZoomMetrics.size(2)) + .padding(.top, ShellZoomMetrics.size(4)) .padding(.horizontal, inset) - .padding(.bottom, ShellZoomMetrics.size(6)) + .padding(.bottom, ShellZoomMetrics.size(8)) // Favorites if !appState.favorites.isEmpty { @@ -94,9 +101,6 @@ struct HarborSidebarView: View { // ── Contextual Zone ───────────────────────────── if let label = contextualLabel { - Divider() - .padding(.horizontal, inset) - Text(label.uppercased()) .font(ShellZoomMetrics.font(Typography.caption, weight: .medium)) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) @@ -110,19 +114,45 @@ struct HarborSidebarView: View { Spacer(minLength: 0) - // Footer — settings - Button(action: onOpenSettings) { - HStack(spacing: ShellZoomMetrics.size(8)) { - Image(systemName: "gearshape") - .font(ShellZoomMetrics.font(Typography.bodySmall)) - Text("Settings") - .font(ShellZoomMetrics.font(Typography.body)) - Spacer(minLength: 0) + // Footer — Trash above Settings + VStack(alignment: .leading, spacing: 0) { + Button(action: { showTrashPopover.toggle() }) { + HStack(spacing: ShellZoomMetrics.size(8)) { + Image(systemName: "trash") + .font(ShellZoomMetrics.font(Typography.bodySmall)) + Text("Trash") + .font(ShellZoomMetrics.font(Typography.body)) + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .padding(.vertical, ShellZoomMetrics.size(6)) + .contentShape(Rectangle()) } - .foregroundStyle(.secondary) - .padding(.vertical, ShellZoomMetrics.size(10)) + .buttonStyle(.plain) + .floatingPopover(isPresented: $showTrashPopover) { + TrashPopoverView( + appState: appState, + fileSystem: fileSystem, + onRestore: { onRefreshTree() } + ) + .frame(width: 360, height: 480) + .popoverSurface() + } + + Button(action: onOpenSettings) { + HStack(spacing: ShellZoomMetrics.size(8)) { + Image(systemName: "gearshape") + .font(ShellZoomMetrics.font(Typography.bodySmall)) + Text("Settings") + .font(ShellZoomMetrics.font(Typography.body)) + Spacer(minLength: 0) + } + .foregroundStyle(.secondary) + .padding(.vertical, ShellZoomMetrics.size(6)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) .padding(.horizontal, inset) .padding(.bottom, ShellZoomMetrics.size(8)) } @@ -144,6 +174,29 @@ struct HarborSidebarView: View { } } +// MARK: - Fixed Navigation Items + +/// A row in the fixed top navigation list of HarborSidebarView. +struct ShellNavItem: Identifiable { + let id: String + let label: String + let icon: String + let notification: Notification.Name +} + +enum ShellNavigationItems { + static let all: [ShellNavItem] = [ + ShellNavItem(id: "home", label: "Home", icon: "house", notification: .openGateway), + ShellNavItem(id: "search", label: "Search", icon: "magnifyingglass", notification: .quickOpen), + ShellNavItem(id: "meeting", label: "Meeting", icon: "waveform", notification: .openMeetings), + ShellNavItem(id: "calendar", label: "Calendar", icon: "calendar.badge.clock", notification: .openCalendar), + ShellNavItem(id: "terminal", label: "Terminal", icon: "terminal", notification: .openTerminal), + ShellNavItem(id: "browser", label: "Browser", icon: "globe", notification: .openBrowser), + ShellNavItem(id: "mail", label: "Mail", icon: "envelope", notification: .openMail), + ShellNavItem(id: "notes", label: "Notes", icon: "doc.text", notification: .openDailyNote) + ] +} + // MARK: - Sidebar Resize Handle struct SidebarResizeHandle: View { @@ -214,8 +267,9 @@ private struct ShellSidebarSectionHeaderView: View { private struct ShellSidebarShortcutRow: View { let title: String let systemImage: String - var trailingText: String? = nil + var trailingText: String? var isSelected = false + var verticalPadding: CGFloat? var action: () -> Void var body: some View { @@ -236,7 +290,7 @@ private struct ShellSidebarShortcutRow: View { } .foregroundStyle(isSelected ? Color.accentColor : .primary) .padding(.horizontal, ShellSidebarMetrics.rowHorizontalPadding) - .padding(.vertical, ShellSidebarMetrics.rowVerticalPadding) + .padding(.vertical, verticalPadding ?? ShellSidebarMetrics.rowVerticalPadding) .background( RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm)) .fill(isSelected ? Color.accentColor.opacity(0.08) : Color.clear) @@ -502,27 +556,6 @@ struct CalendarContextualSidebarView: View { .padding(.horizontal, ShellZoomMetrics.size(10)) .padding(.bottom, ShellZoomMetrics.size(12)) - HStack(spacing: ShellZoomMetrics.size(8)) { - ForEach(CalendarViewMode.allCases, id: \.self) { mode in - Button { - calendarVM.viewMode = mode - } label: { - Text(mode.rawValue) - .font(ShellZoomMetrics.font(11, weight: calendarVM.viewMode == mode ? .medium : .regular)) - .foregroundStyle(calendarVM.viewMode == mode ? Color.accentColor : .primary) - .padding(.horizontal, ShellZoomMetrics.size(8)) - .padding(.vertical, ShellZoomMetrics.size(5)) - .background( - RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm)) - .fill(calendarVM.viewMode == mode ? Color.accentColor.opacity(0.08) : Color.primary.opacity(0.04)) - ) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, ShellZoomMetrics.size(12)) - .padding(.bottom, ShellZoomMetrics.size(12)) - Text("Calendars") .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold)) .foregroundStyle(.secondary) diff --git a/Sources/Bugbook/Views/Sidebar/SidebarView.swift b/Sources/Bugbook/Views/Sidebar/SidebarView.swift deleted file mode 100644 index c1a7ced6..00000000 --- a/Sources/Bugbook/Views/Sidebar/SidebarView.swift +++ /dev/null @@ -1,662 +0,0 @@ -import SwiftUI - -struct SidebarView: View { - enum LayoutMode { - case full - case compact - } - - var appState: AppState - var fileSystem: FileSystemService - var onSelectFile: (FileEntry) -> Void - var onToggleSidebar: () -> Void - var onAddSidebarReference: (SidebarReferenceDragPayload) -> Void - var layoutMode: LayoutMode = .full - var onActionInvoked: (() -> Void)? = nil - var trashPopoverOverride: Binding? = nil - @State private var hoveredButton: String? - @State private var isFullScreen: Bool = false - @State private var localTrashPopoverPresented: Bool = false - @State private var isSidebarReferenceDropTargeted = false - @AppStorage("sidebar_favorites_expanded") private var favoritesExpanded = true - @AppStorage("sidebar_agents_expanded") private var agentsExpanded = true - @AppStorage("sidebar_workspace_expanded") private var workspaceExpanded = true - @State private var expandedFolders: Set = { - let arr = UserDefaults.standard.stringArray(forKey: "expandedFolders") ?? [] - return Set(arr) - }() - - private let settingsTabs: [(id: String, label: String, icon: String)] = [ - ("general", "General", "gearshape"), - ("appearance", "Appearance", "paintbrush"), - ("ai", "AI", "cpu"), - ("google", "Google", "person.badge.key"), - ("terminal", "Terminal", "terminal"), - ("agents", "Agents", "person.2"), - ("search", "Search", "magnifyingglass"), - ("shortcuts", "Shortcuts", "keyboard"), - ] - - private var isCompact: Bool { - layoutMode == .compact - } - - private var sidebarMinWidth: CGFloat { - ShellZoomMetrics.size(isCompact ? 170 : 160) - } - - private var sidebarIdealWidth: CGFloat { - ShellZoomMetrics.size(isCompact ? 185 : 190) - } - - private var sidebarMaxWidth: CGFloat { - ShellZoomMetrics.size(isCompact ? 195 : 240) - } - - private var topSpacerHeight: CGFloat { - ShellZoomMetrics.size(isCompact ? 4 : 12) - } - - private var chromeButtonSpacing: CGFloat { - ShellZoomMetrics.size(isCompact ? 4 : 8) - } - - private var sectionSpacing: CGFloat { - ShellZoomMetrics.size(isCompact ? 0 : 2) - } - - private var sectionHorizontalPadding: CGFloat { - ShellZoomMetrics.size(isCompact ? 5 : 8) - } - - private var sectionVerticalPadding: CGFloat { - ShellZoomMetrics.size(isCompact ? 3 : 6) - } - - private var rowHorizontalPadding: CGFloat { - ShellZoomMetrics.size(isCompact ? 8 : 12) - } - - private var rowVerticalPadding: CGFloat { - ShellZoomMetrics.size(isCompact ? 3 : 6) - } - - private var headerTopPadding: CGFloat { - ShellZoomMetrics.size(isCompact ? 1 : 4) - } - - private var treeVerticalPadding: CGFloat { - ShellZoomMetrics.size(isCompact ? 2 : 4) - } - - private var trashPopoverPresented: Binding { - trashPopoverOverride ?? $localTrashPopoverPresented - } - - var body: some View { - VStack(spacing: 0) { - if appState.showSettings { - settingsNav - } else { - fileTreeNav - } - } - .frame( - minWidth: sidebarMinWidth, - idealWidth: sidebarIdealWidth, - maxWidth: sidebarMaxWidth - ) - .background(isCompact ? Color.fallbackEditorBg : Color.fallbackSidebarBg) - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in - isFullScreen = true - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in - isFullScreen = false - } - .onAppear { - isFullScreen = NSApp.mainWindow?.styleMask.contains(.fullScreen) ?? false - } - } - - // MARK: - File Tree (default sidebar) - - private var fileTreeNav: some View { - VStack(spacing: 0) { - // Traffic light spacing - Spacer().frame(height: topSpacerHeight) - - // Action buttons - HStack(spacing: chromeButtonSpacing) { - if isCompact { - Spacer() - newPageMenuButton - } else { - if !isFullScreen { - Spacer() - } - chromeButton(icon: "sidebar.left", help: "Toggle Sidebar", action: onToggleSidebar) - newPageMenuButton - if isFullScreen { - Spacer() - } - } - } - .padding(.horizontal, ShellZoomMetrics.size(isCompact ? 10 : 12)) - .padding(.leading, !isCompact && isFullScreen ? ShellZoomMetrics.size(8) : 0) - .padding(.bottom, ShellZoomMetrics.size(isCompact ? 2 : 6)) - - // Search & AI - VStack(spacing: sectionSpacing) { - Button(action: { invokeAction { NotificationCenter.default.post(name: .quickOpen, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "magnifyingglass") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Search") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "search" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "search" : nil } - - Button(action: { invokeAction { NotificationCenter.default.post(name: .openFullChat, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "bubble.left.and.bubble.right") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Chat") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "ai" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "ai" : nil } - } - .padding(.horizontal, sectionHorizontalPadding) - .padding(.vertical, sectionVerticalPadding) - - // Gateway, Daily note & Graph (hidden in compact/peek mode) - if !isCompact { - VStack(spacing: sectionSpacing) { - Button(action: { invokeAction { NotificationCenter.default.post(name: .openGateway, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "house") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Home") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "gateway" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "gateway" : nil } - - Button(action: { invokeAction { NotificationCenter.default.post(name: .openMail, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "envelope") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Mail") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "mail" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "mail" : nil } - - Button(action: { invokeAction { NotificationCenter.default.post(name: .openCalendar, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "calendar.badge.clock") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Calendar") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "calendar" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "calendar" : nil } - - Button(action: { invokeAction { NotificationCenter.default.post(name: .openMeetings, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "waveform") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Meetings") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "meetings" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "meetings" : nil } - - Button(action: { invokeAction { NotificationCenter.default.post(name: .openTerminal, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "terminal") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Terminal") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "terminal" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "terminal" : nil } - } - .padding(.horizontal, sectionHorizontalPadding) - } - - // Favorites section - if !appState.favorites.isEmpty { - sidebarSectionHeader("Favorites", isExpanded: $favoritesExpanded) - - if favoritesExpanded { - VStack(spacing: ShellZoomMetrics.size(isCompact ? 3 : 4)) { - ForEach(appState.favorites) { entry in - FileTreeItemView( - entry: entry, - activeFilePath: appState.activeTab?.path, - fileSystem: fileSystem, - workspacePath: appState.workspacePath, - onSelectFile: onSelectFile, - onRefreshTree: refreshTree, - expandedFolders: $expandedFolders - ) - } - } - .padding(.horizontal, sectionHorizontalPadding) - } - } - - // Workspace header - sidebarSectionHeader("Workspace", isExpanded: $workspaceExpanded) - - // File tree - ScrollView { - VStack(spacing: ShellZoomMetrics.size(isCompact ? 3 : 4)) { - if workspaceExpanded { - Button(action: { invokeAction { NotificationCenter.default.post(name: .openDailyNote, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "calendar") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Today") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "today" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "today" : nil } - - Button(action: { invokeAction { NotificationCenter.default.post(name: .openGraphView, object: nil) } }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "point.3.connected.trianglepath.dotted") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Graph") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "graph" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "graph" : nil } - - if !appState.sidebarReferences.isEmpty { - VStack(spacing: ShellZoomMetrics.size(1)) { - ForEach(appState.sidebarReferences) { entry in - FileTreeItemView( - entry: entry, - activeFilePath: appState.activeTab?.path, - fileSystem: fileSystem, - workspacePath: appState.workspacePath, - onSelectFile: onSelectFile, - onRefreshTree: refreshTree, - isSidebarReference: true, - expandedFolders: $expandedFolders - ) - } - } - } - - FileTreeView( - entries: fileTreeWithoutFavorites, - activeFilePath: appState.activeTab?.path, - fileSystem: fileSystem, - workspacePath: appState.workspacePath, - onSelectFile: onSelectFile, - onRefreshTree: refreshTree, - expandedFolders: $expandedFolders - ) - } - - // Agents section (inside scroll, right after workspace files) - if !appState.agentSkills.isEmpty || !appState.mcpServers.isEmpty { - sidebarSectionHeader("Agents", isExpanded: $agentsExpanded) - .padding(.top, ShellZoomMetrics.size(8)) - .padding(.horizontal, -sectionHorizontalPadding) - - if agentsExpanded { - VStack(spacing: ShellZoomMetrics.size(isCompact ? 3 : 4)) { - ForEach(appState.agentSkills) { entry in - FileTreeItemView( - entry: entry, - activeFilePath: appState.activeTab?.path, - fileSystem: fileSystem, - workspacePath: appState.workspacePath, - onSelectFile: onSelectFile, - onRefreshTree: refreshTree, - expandedFolders: $expandedFolders - ) - } - - ForEach(appState.mcpServers) { server in - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "powerplug") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 0) { - Text(server.name) - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.primary) - Text(server.command) - .font(ShellZoomMetrics.font(Typography.caption)) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - } - } - } - } - } - .padding(.horizontal, sectionHorizontalPadding) - .padding(.vertical, treeVerticalPadding) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - } - .dropDestination( - for: SidebarReferenceDragPayload.self, - action: { items, _ in - guard let payload = items.first else { return false } - onAddSidebarReference(payload) - return true - }, - isTargeted: { isTargeted in - isSidebarReferenceDropTargeted = isTargeted - } - ) - .overlay { - RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm)) - .stroke(isSidebarReferenceDropTargeted ? Color.accentColor.opacity(0.8) : Color.clear, lineWidth: 1.5) - .padding(.horizontal, sectionHorizontalPadding) - .padding(.vertical, treeVerticalPadding) - .allowsHitTesting(false) - } - .accessibilityIdentifier("sidebar-file-tree") - - // Bottom bar: trash, settings - VStack(spacing: sectionSpacing) { - Button(action: { trashPopoverPresented.wrappedValue.toggle() }) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "trash") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Trash") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background((hoveredButton == "trash" || trashPopoverPresented.wrappedValue) ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "trash" : nil } - .floatingPopover(isPresented: trashPopoverPresented) { - TrashPopoverView( - appState: appState, - fileSystem: fileSystem, - onRestore: { - refreshTree() - } - ) - } - - Button(action: openSettings) { - HStack(spacing: chromeButtonSpacing) { - Image(systemName: "gearshape") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Text("Settings") - .font(ShellZoomMetrics.font(Typography.body)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, rowHorizontalPadding) - .padding(.vertical, rowVerticalPadding) - .background(hoveredButton == "settings" ? Color.primary.opacity(0.06) : Color.clear) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in hoveredButton = hovering ? "settings" : nil } - } - .padding(.horizontal, sectionHorizontalPadding) - .padding(.vertical, sectionVerticalPadding) - } - } - - // MARK: - Settings Nav - - private var settingsNav: some View { - VStack(spacing: 0) { - // Traffic light spacing - Spacer().frame(height: ShellZoomMetrics.size(38)) - - // Back button - Button(action: { appState.showSettings = false }) { - HStack(spacing: ShellZoomMetrics.size(6)) { - Image(systemName: "arrow.left") - .font(ShellZoomMetrics.font(Typography.body)) - Text("Back to app") - .font(ShellZoomMetrics.font(Typography.body)) - Spacer() - } - .foregroundStyle(.secondary) - .padding(.horizontal, ShellZoomMetrics.size(12)) - .padding(.vertical, ShellZoomMetrics.size(10)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - // Settings categories - VStack(spacing: ShellZoomMetrics.size(2)) { - ForEach(settingsTabs, id: \.id) { tab in - Button(action: { appState.selectedSettingsTab = tab.id }) { - HStack(spacing: ShellZoomMetrics.size(10)) { - Image(systemName: tab.icon) - .font(ShellZoomMetrics.font(15)) - .frame(width: ShellZoomMetrics.size(20)) - Text(tab.label) - .font(ShellZoomMetrics.font(Typography.body)) - Spacer() - } - .padding(.horizontal, ShellZoomMetrics.size(12)) - .padding(.vertical, ShellZoomMetrics.size(8)) - .background( - appState.selectedSettingsTab == tab.id - ? Color.primary.opacity(0.08) - : Color.clear - ) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.sm))) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(.primary) - } - } - .padding(.horizontal, ShellZoomMetrics.size(8)) - .padding(.top, ShellZoomMetrics.size(4)) - - Spacer() - } - } - - // MARK: - Helpers - - private func sidebarSectionHeader(_ title: String, isExpanded: Binding) -> some View { - SidebarSectionHeaderView(title: title, isExpanded: isExpanded, isCompact: isCompact, headerTopPadding: headerTopPadding) - } - - private var fileTreeWithoutFavorites: [FileEntry] { - let favPaths = Set(appState.favorites.map(\.path)) - guard !favPaths.isEmpty else { return appState.fileTree } - return appState.fileTree.filter { !favPaths.contains($0.path) } - } - - private func refreshTree() { - guard let workspace = appState.workspacePath else { return } - appState.fileTree = fileSystem.buildFileTree(at: workspace) - } - - private func invokeAction(_ action: () -> Void) { - action() - onActionInvoked?() - } - - private func createFile() { - invokeAction { - NotificationCenter.default.post(name: .newNote, object: nil) - } - } - - private var newPageMenuButton: some View { - Button(action: { createFile() }) { - Image(systemName: "square.and.pencil") - .font(ShellZoomMetrics.font(Typography.body, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: ShellZoomMetrics.size(24), height: ShellZoomMetrics.size(24)) - } - .buttonStyle(.borderless) - .help("New Page") - } - - private func openSettings() { - invokeAction { - NotificationCenter.default.post(name: .openSettings, object: nil) - } - } - - - @ViewBuilder - private func chromeButton( - icon: String, - help: String, - isEnabled: Bool = true, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(systemName: icon) - .font(ShellZoomMetrics.font(Typography.body, weight: .medium)) - .foregroundStyle(isEnabled ? Color.secondary : Color.secondary.opacity(0.45)) - .frame(width: ShellZoomMetrics.size(24), height: ShellZoomMetrics.size(24)) - } - .buttonStyle(.borderless) - .help(help) - .disabled(!isEnabled) - } -} - -private struct SidebarSectionHeaderView: View { - let title: String - @Binding var isExpanded: Bool - let isCompact: Bool - let headerTopPadding: CGFloat - @State private var isHovering = false - - var body: some View { - Button { - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } label: { - HStack(spacing: 4) { - Text(title) - .font(ShellZoomMetrics.font(Typography.caption, weight: .medium)) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - Image(systemName: "chevron.right") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - .opacity(isHovering ? 1 : 0) - Spacer() - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.horizontal, ShellZoomMetrics.size(isCompact ? 12 : 14)) - .padding(.top, headerTopPadding) - .padding(.bottom, ShellZoomMetrics.size(2)) - .onHover { isHovering = $0 } - } -} diff --git a/Sources/Bugbook/Views/Terminal/TerminalPaneView.swift b/Sources/Bugbook/Views/Terminal/TerminalPaneView.swift index 1c0923ff..2173bc2c 100644 --- a/Sources/Bugbook/Views/Terminal/TerminalPaneView.swift +++ b/Sources/Bugbook/Views/Terminal/TerminalPaneView.swift @@ -24,21 +24,39 @@ private struct GhosttyTerminalView: NSViewRepresentable { let session: TerminalSession let isFocused: Bool + func makeCoordinator() -> Coordinator { Coordinator() } + func makeNSView(context: Context) -> NSView { guard let surfaceView = session.surfaceView else { return NSView() } surfaceView.translatesAutoresizingMaskIntoConstraints = false + context.coordinator.wasFocused = isFocused return surfaceView } func updateNSView(_ nsView: NSView, context: Context) { - if isFocused { - DispatchQueue.main.async { - if let window = nsView.window, window.firstResponder !== nsView { + let wasFocused = context.coordinator.wasFocused + context.coordinator.wasFocused = isFocused + + // Only act on focus transitions to avoid re-stealing focus on unrelated renders + guard wasFocused != isFocused else { return } + + DispatchQueue.main.async { + guard let window = nsView.window else { return } + if isFocused { + if window.firstResponder !== nsView { window.makeFirstResponder(nsView) } + } else { + if window.firstResponder === nsView { + window.makeFirstResponder(nil) + } } } } + + class Coordinator { + var wasFocused = false + } } diff --git a/Tests/BugbookTests/BrowserEngineTests.swift b/Tests/BugbookTests/BrowserEngineTests.swift index 0d9d63a3..5614b3bf 100644 --- a/Tests/BugbookTests/BrowserEngineTests.swift +++ b/Tests/BugbookTests/BrowserEngineTests.swift @@ -146,6 +146,63 @@ final class BrowserEngineTests: XCTestCase { XCTAssertTrue(content.contains("First sentence")) } + func testBrowserManagerClearsCookiesThroughEngine() async throws { + let engine = FakeBrowserEngine() + let snapshotStore = BrowserPaneSnapshotStore(directoryURL: temporaryDirectory()) + let manager = BrowserManager(engine: engine, snapshotStore: snapshotStore) + + try await manager.clearCookies() + + XCTAssertEqual(engine.clearCookiesCallCount, 1) + } + + func testBrowserManagerDoesNotRecordHistoryWhenDisabled() { + let directoryURL = temporaryDirectory() + let engine = FakeBrowserEngine() + let snapshotStore = BrowserPaneSnapshotStore(directoryURL: directoryURL.appendingPathComponent("snapshots", isDirectory: true)) + let historyStore = BrowserHistoryStore(fileURL: directoryURL.appendingPathComponent("history.json")) + let manager = BrowserManager(engine: engine, snapshotStore: snapshotStore, historyStore: historyStore) + let paneID = UUID() + let session = manager.session(for: paneID) + let tabID = tryUnwrap(session.selectedTabID) + + _ = manager.ensurePage(for: paneID, tabID: tabID) + let page = tryUnwrap(engine.pages[tabID]) + manager.setHistoryEnabled(false) + + page.emit(.didFinishNavigation(title: "Example", url: URL(string: "https://example.com")!)) + + XCTAssertTrue(manager.browsingHistory.isEmpty) + XCTAssertTrue(session.recentVisits.isEmpty) + XCTAssertTrue(historyStore.load().isEmpty) + } + + func testBrowserManagerClearHistoryRemovesGlobalAndSessionHistory() { + let directoryURL = temporaryDirectory() + let engine = FakeBrowserEngine() + let snapshotStore = BrowserPaneSnapshotStore(directoryURL: directoryURL.appendingPathComponent("snapshots", isDirectory: true)) + let historyStore = BrowserHistoryStore(fileURL: directoryURL.appendingPathComponent("history.json")) + let manager = BrowserManager(engine: engine, snapshotStore: snapshotStore, historyStore: historyStore) + let paneID = UUID() + let session = manager.session(for: paneID) + let tabID = tryUnwrap(session.selectedTabID) + + _ = manager.ensurePage(for: paneID, tabID: tabID) + let page = tryUnwrap(engine.pages[tabID]) + page.emit(.didFinishNavigation(title: "Example", url: URL(string: "https://example.com")!)) + + XCTAssertEqual(manager.browsingHistory.count, 1) + XCTAssertEqual(session.recentVisits.count, 1) + XCTAssertEqual(historyStore.load().count, 1) + + manager.clearHistory() + + XCTAssertTrue(manager.browsingHistory.isEmpty) + XCTAssertTrue(session.recentVisits.isEmpty) + XCTAssertTrue(historyStore.load().isEmpty) + XCTAssertTrue(snapshotStore.snapshot(for: paneID)?.recentVisits.isEmpty == true) + } + private func temporaryDirectory() -> URL { let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -164,6 +221,7 @@ final class BrowserEngineTests: XCTestCase { @MainActor private final class FakeBrowserEngine: BrowserEngine { private(set) var pages: [UUID: FakeBrowserPage] = [:] + private(set) var clearCookiesCallCount = 0 func makePage( for paneID: UUID, @@ -175,6 +233,10 @@ private final class FakeBrowserEngine: BrowserEngine { pages[tabID] = page return page } + + func clearCookies() async throws { + clearCookiesCallCount += 1 + } } @MainActor diff --git a/Tests/BugbookTests/BrowserFeatureTests.swift b/Tests/BugbookTests/BrowserFeatureTests.swift index f58cf8ab..ca0b9d2d 100644 --- a/Tests/BugbookTests/BrowserFeatureTests.swift +++ b/Tests/BugbookTests/BrowserFeatureTests.swift @@ -31,6 +31,10 @@ final class BrowserFeatureTests: XCTestCase { func testAppSettingsBrowserFieldsRoundTrip() throws { var settings = AppSettings.default settings.browserSearchEngine = .kagi + settings.browserHistoryEnabled = false + settings.browserSuggestionsEnabled = true + settings.browserSuggestionLimit = 10 + settings.browserSuggestsBugbookPages = false settings.browserChrome.showsBackForwardButtons = true settings.browserChrome.showsStatusBar = true settings.browserQuickLaunchItems = [ @@ -42,6 +46,10 @@ final class BrowserFeatureTests: XCTestCase { let decoded = try JSONDecoder().decode(AppSettings.self, from: data) XCTAssertEqual(decoded.browserSearchEngine, .kagi) + XCTAssertFalse(decoded.browserHistoryEnabled) + XCTAssertTrue(decoded.browserSuggestionsEnabled) + XCTAssertEqual(decoded.browserSuggestionLimit, 10) + XCTAssertFalse(decoded.browserSuggestsBugbookPages) XCTAssertTrue(decoded.browserChrome.showsBackForwardButtons) XCTAssertTrue(decoded.browserChrome.showsStatusBar) XCTAssertEqual(decoded.browserQuickLaunchItems, settings.browserQuickLaunchItems) @@ -98,6 +106,50 @@ final class BrowserFeatureTests: XCTestCase { XCTAssertNil(store.snapshot(for: paneID)) } + func testBrowserHistoryStoreRoundTripsVisits() { + let directoryURL = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let store = BrowserHistoryStore(fileURL: directoryURL.appendingPathComponent("history.json")) + let visit = BrowserRecentVisit( + title: "Example", + urlString: "https://example.com", + visitedAt: Date(timeIntervalSince1970: 123) + ) + + store.save([visit]) + XCTAssertEqual(store.load(), [visit]) + + store.clear() + XCTAssertTrue(store.load().isEmpty) + } + + func testBrowserPaneSessionMoveTabPreservesSelectedTab() { + let manager = BrowserManager(snapshotStore: BrowserPaneSnapshotStore(directoryURL: temporaryDirectory())) + let paneID = UUID() + let session = manager.session(for: paneID) + let secondTabID = session.openNewTab(url: URL(string: "https://example.com/second")) + let thirdTabID = session.openNewTab(url: URL(string: "https://example.com/third")) + + session.selectTab(secondTabID) + session.moveTab(from: 1, to: 3) + + XCTAssertEqual(session.tabs.map(\.id), [session.tabs[0].id, thirdTabID, secondTabID]) + XCTAssertEqual(session.selectedTabID, secondTabID) + } + + func testWorkspaceManagerDetachWorkspaceLeavesFallbackWorkspace() { + let manager = WorkspaceManager() + manager.layoutPersistenceEnabled = false + manager.addWorkspace(name: "One") + + let detached = manager.detachWorkspace(at: 0) + + XCTAssertEqual(detached?.name, "One") + XCTAssertEqual(manager.workspaces.count, 1) + XCTAssertEqual(manager.activeWorkspaceIndex, 0) + } + func testBrowserCleanupProposalUsesSavedAndDuplicateSignals() { let directoryURL = temporaryDirectory() defer { try? FileManager.default.removeItem(at: directoryURL) } diff --git a/Tests/BugbookTests/MailIntelligenceFeatureTests.swift b/Tests/BugbookTests/MailIntelligenceFeatureTests.swift deleted file mode 100644 index 079ddadc..00000000 --- a/Tests/BugbookTests/MailIntelligenceFeatureTests.swift +++ /dev/null @@ -1,242 +0,0 @@ -import Foundation -import XCTest -@testable import Bugbook -import BugbookCore - -@MainActor -final class MailIntelligenceFeatureTests: XCTestCase { - func testMailModelProviderResolverPrefersAPIKeyInAutoMode() { - var settings = AppSettings.default - settings.preferredAIEngine = .auto - settings.anthropicApiKey = "sk-ant-test" - - let resolved = MailModelProviderResolver.resolve( - settings: settings, - engineStatus: AiEngineStatus(claudeAvailable: true, claudeVersion: "1.0", codexAvailable: true, codexVersion: "1.0") - ) - - XCTAssertEqual(resolved, .anthropicAPI) - } - - func testMailModelProviderResolverFallsBackToCodex() { - var settings = AppSettings.default - settings.preferredAIEngine = .auto - - let resolved = MailModelProviderResolver.resolve( - settings: settings, - engineStatus: AiEngineStatus(claudeAvailable: false, claudeVersion: nil, codexAvailable: true, codexVersion: "1.0") - ) - - XCTAssertEqual(resolved, .codexCLI) - } - - func testMailIntelligenceStoreRoundTripsRecords() throws { - let directoryURL = temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directoryURL) } - - let store = MailIntelligenceStore(directoryURL: directoryURL) - let snapshot = MailIntelligenceAccountSnapshot( - threadRecords: ["thread-1": sampleRecord()], - savedAt: Date(timeIntervalSince1970: 5_000) - ) - - store.save(snapshot, accountEmail: "Test.User+alias@gmail.com") - let loaded = try XCTUnwrap(store.load(accountEmail: "Test.User+alias@gmail.com")) - - XCTAssertEqual(loaded, snapshot) - } - - func testMailAgentSessionStoreRoundTripsWorkspaceSnapshot() throws { - let workspace = temporaryDirectory() - defer { try? FileManager.default.removeItem(at: workspace) } - - let fixedDate = Date(timeIntervalSince1970: 100) - let store = MailAgentSessionStore() - let snapshot = MailWorkspaceIntelligenceSnapshot( - priorityOverrides: [ - MailPriorityOverride( - senderEmail: "alice@example.com", - priority: .high, - note: "Founder emails are urgent", - createdAt: fixedDate, - updatedAt: fixedDate - ) - ], - memories: [ - MailMemory( - kind: .writingStyle, - title: "Tone", - detail: "Keep replies concise.", - createdAt: fixedDate, - updatedAt: fixedDate - ) - ], - agentSessions: [ - MailAgentSession( - id: "session-1", - threadID: "thread-1", - proposals: [MailAgentActionProposal(id: "proposal-1", kind: .createTask, title: "Create Task", detail: "Turn this into work.")], - entries: [MailAgentSessionEntry(id: "entry-1", role: .system, content: "Started", createdAt: fixedDate)], - createdAt: fixedDate, - updatedAt: fixedDate - ) - ] - ) - - store.save(snapshot, workspacePath: workspace.path) - let loaded = store.load(workspacePath: workspace.path) - - XCTAssertEqual(loaded, snapshot) - } - - func testMailServiceApplyIntelligenceRecordAnnotatesThreadState() { - let directoryURL = temporaryDirectory() - defer { try? FileManager.default.removeItem(at: directoryURL) } - - let service = MailService(cacheStore: MailCacheStore(directoryURL: directoryURL)) - let snapshot = sampleSnapshot(savedAt: Date(timeIntervalSince1970: 200)) - service.mailboxThreads = snapshot.mailboxThreads - service.threadDetails = snapshot.threadDetails - - let record = sampleRecord() - service.applyIntelligenceRecord(record) - - XCTAssertEqual(service.mailboxThreads[.inbox]?.first?.annotation?.suggestedPriority, .high) - XCTAssertEqual(service.threadDetails["thread-1"]?.annotation?.statusFlags, [.needsReply]) - XCTAssertEqual(service.threadDetails["thread-1"]?.draftSuggestion?.body, "Thanks for the update. I can take this on.") - XCTAssertEqual(service.threadDetails["thread-1"]?.senderContext?.senderEmail, "alice@example.com") - } - - func testMailIntelligenceServiceLearnsFromSentDraftAndPersistsWorkspaceMemory() { - let cacheDirectory = temporaryDirectory() - let intelligenceDirectory = temporaryDirectory() - let workspaceDirectory = temporaryDirectory() - defer { - try? FileManager.default.removeItem(at: cacheDirectory) - try? FileManager.default.removeItem(at: intelligenceDirectory) - try? FileManager.default.removeItem(at: workspaceDirectory) - } - - let service = MailIntelligenceService( - accountStore: MailIntelligenceStore(directoryURL: intelligenceDirectory), - workspaceStore: MailAgentSessionStore(), - fileSystem: FileSystemService(), - agentWorkspaceStore: AgentWorkspaceStore() - ) - let mailService = MailService(cacheStore: MailCacheStore(directoryURL: cacheDirectory)) - service.load(accountEmail: "me@example.com", workspacePath: workspaceDirectory.path, mailService: mailService) - service.records["thread-1"] = sampleRecord() - - service.learnFromSentDraft(threadID: "thread-1", subject: "Hello", finalBody: "Thanks. I will handle this today.") - - XCTAssertEqual(service.records["thread-1"]?.annotation.draftStatus, .edited) - XCTAssertEqual(service.memories.first?.kind, .writingStyle) - XCTAssertTrue(service.memories.first?.detail.contains("Thanks. I will handle this today.") ?? false) - - let reloaded = MailAgentSessionStore().load(workspacePath: workspaceDirectory.path) - XCTAssertEqual(reloaded.memories.first?.kind, .writingStyle) - } - - private func sampleRecord() -> MailThreadIntelligenceRecord { - let fixedDate = Date(timeIntervalSince1970: 100) - return MailThreadIntelligenceRecord( - threadID: "thread-1", - sourceSignature: "history-1", - annotation: MailThreadAnnotation( - analysisStatus: .complete, - analysisUpdatedAt: fixedDate, - suggestedPriority: .high, - statusFlags: [.needsReply], - draftStatus: .suggested, - hasSenderContext: true - ), - analysis: MailThreadAnalysis( - priority: .high, - reason: "This thread is asking for a direct response.", - suggestedAction: "Reply with next steps.", - flags: [.needsReply], - shouldGenerateDraft: true, - prefersReplyAll: false, - analyzedAt: fixedDate - ), - draftSuggestion: MailDraftSuggestion( - id: "draft-1", - threadID: "thread-1", - subject: "Re: Hello", - body: "Thanks for the update. I can take this on.", - rationale: "Direct and concise.", - generatedAt: fixedDate - ), - senderContext: MailSenderContext( - threadID: "thread-1", - senderName: "Alice", - senderEmail: "alice@example.com", - summary: "Alice is tied to roadmap work.", - references: [MailSenderContextReference(id: "ref-1", path: "/tmp/roadmap.md", excerpt: "Alice owns roadmap planning.")], - generatedAt: fixedDate - ), - acceptedDraftBody: "Thanks for the update.", - editedDraftBody: nil, - updatedAt: fixedDate - ) - } - - private func sampleSnapshot(savedAt: Date) -> MailCacheSnapshot { - let sender = MailMessageRecipient(name: "Alice", email: "alice@example.com") - let message = MailMessage( - id: "message-1", - threadId: "thread-1", - subject: "Hello", - snippet: "Test snippet", - labelIds: ["INBOX", "UNREAD"], - from: sender, - to: [MailMessageRecipient(name: "Me", email: "me@example.com")], - cc: [], - bcc: [], - date: Date(timeIntervalSince1970: 100), - plainBody: "Hello world", - htmlBody: nil, - messageIDHeader: "", - referencesHeader: nil - ) - - let detail = MailThreadDetail( - id: "thread-1", - mailbox: .inbox, - subject: "Hello", - snippet: "Test snippet", - participants: [sender.displayName], - messages: [message], - labelIds: ["INBOX", "UNREAD"], - historyId: "history-1", - annotation: nil, - draftSuggestion: nil, - senderContext: nil - ) - - let summary = MailThreadSummary( - id: "thread-1", - mailbox: .inbox, - subject: "Hello", - snippet: "Test snippet", - participants: [sender.displayName], - date: Date(timeIntervalSince1970: 100), - messageCount: 1, - labelIds: ["INBOX", "UNREAD"], - historyId: "history-1", - annotation: nil - ) - - return MailCacheSnapshot( - mailboxThreads: [.inbox: [summary]], - threadDetails: ["thread-1": detail], - savedAt: savedAt - ) - } - - private func temporaryDirectory() -> URL { - let directoryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) - try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - return directoryURL - } -} diff --git a/Tests/BugbookTests/perf_baseline.tsv b/Tests/BugbookTests/perf_baseline.tsv index 2dea02e1..b7481355 100644 --- a/Tests/BugbookTests/perf_baseline.tsv +++ b/Tests/BugbookTests/perf_baseline.tsv @@ -1,9 +1,9 @@ test_name metric value timestamp -block_document_init_50 ms 0.251 2026-04-08T16:55:04Z -database_load_100 ms 5.993 2026-04-08T16:55:05Z -filesystem_tree_100 ms 5.022 2026-04-08T16:55:05Z -markdown_parse_500 ms 3.576 2026-04-08T16:55:06Z -markdown_serialize_500 ms 2.649 2026-04-08T16:55:06Z -qmd_find_binary ms 0.015 2026-04-08T16:55:08Z -row_deserialize_100 ms 3.826 2026-04-08T16:55:10Z -row_serialize_100 ms 1.201 2026-04-08T16:55:10Z \ No newline at end of file +block_document_init_50 ms 0.654 2026-04-10T17:26:57Z +database_load_100 ms 5.967 2026-04-10T17:26:57Z +filesystem_tree_100 ms 5.659 2026-04-10T17:26:58Z +markdown_parse_500 ms 3.559 2026-04-10T17:26:58Z +markdown_serialize_500 ms 2.721 2026-04-10T17:26:58Z +qmd_find_binary ms 0.010 2026-04-10T17:26:59Z +row_deserialize_100 ms 3.883 2026-04-10T17:26:59Z +row_serialize_100 ms 1.183 2026-04-10T17:26:59Z \ No newline at end of file diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj index 6478463e..fb1676a6 100644 --- a/macos/Bugbook.xcodeproj/project.pbxproj +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -61,7 +61,6 @@ 3DDB05C2ECD6C705685F5F16 /* WorkspaceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B66941CAB290DD322DB303C /* WorkspaceManager.swift */; }; 3FAC46CC7B9DE37B65C2D6D9 /* ShortcutsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */; }; 41093BBBDD10E3C59B63F7F0 /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D1A2D4E326152C4B324139 /* ListView.swift */; }; - 45ABE1F981E46B748D9C724A /* SidebarPeekState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */; }; 471F0FA462EF697DF4368445 /* SavedWebPageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6378B4F86BC88F06B6E38B20 /* SavedWebPageStore.swift */; }; 4C7AB83E38D1649A967CFF40 /* CalendarWeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */; }; 4E4724D2134A490B31D2441E /* AttributedStringConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */; }; @@ -77,8 +76,8 @@ 55AED705FAC0BD8F729640FD /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 189FFD8CFB64645025DB5876 /* FileEntry.swift */; }; 569B127B96923692E102B939 /* CalendarMonthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B1E2BE4DD4A5DBE8AAD786 /* CalendarMonthView.swift */; }; 57F11FACF4E5C3025AE96A31 /* IOSurface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 977F61568113A500928E2313 /* IOSurface.framework */; }; - 59C8876C7E8E337E6D37F861 /* MeetingKnowledgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */; }; 5AC81CC4AEA7FE4D4424DEE4 /* RowSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79DDB549EBE9DC5DAE9FB8CE /* RowSerializer.swift */; }; + 5F56C1FAAF4D6D842773AB34 /* BugbookHelper.app in Resources */ = {isa = PBXBuildFile; fileRef = 499263DF1C981D957935D1B4 /* BugbookHelper.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 60473B1C3084B649F5458D8D /* Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398B466A4F5F82ACEA4874F6 /* Block.swift */; }; 609E0123537ADDC449A1027F /* BacklinkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E071A97E9E6B0892FA767898 /* BacklinkService.swift */; }; 640881FBAEA115C91FA7BABF /* CodeBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 753ED053F39EBB80B7270496 /* CodeBlockView.swift */; }; @@ -88,10 +87,11 @@ 6812B42D26F8E4E999E46AC5 /* Chromium Embedded Framework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A07C5107DAD4416595662CF /* Chromium Embedded Framework.framework */; }; 682E890492C635AF3960D295 /* WebKitBrowserEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA92142221395701E3594F3B /* WebKitBrowserEngine.swift */; }; 694EBF21623BCEA74940150D /* AgentWorkspaceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5A24662D72186FD809E326 /* AgentWorkspaceTemplate.swift */; }; + 6993B4A526F9AC005BF0AE27 /* ChromiumBridge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE83CC78041D0E3A141E7B08 /* ChromiumBridge.framework */; }; 6AED4644678A23789357E98F /* TableBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E1AF8805EE73E377D8DF0E /* TableBlockView.swift */; }; 6B4F10DF5DC543A9054FC61E /* QmdService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF0F24D7EAF03362E0B0441 /* QmdService.swift */; }; - 6C702AC88341E9C25D4CFBDB /* PaneFindBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F6987F6DB975D02C042D28 /* PaneFindBar.swift */; }; 6DA321F4231C50E72B995CEC /* Chromium Embedded Framework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A07C5107DAD4416595662CF /* Chromium Embedded Framework.framework */; }; + 7079D6C54FAD323FABCF9182 /* MeetingTranscriptStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6988199BE7B6C7A18AAEC1F5 /* MeetingTranscriptStore.swift */; }; 70A9F55EB761E74043D75928 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2AE42817B3F7E154211B1 /* DesignTokens.swift */; }; 71150606FE80AB392A835F21 /* OpenFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F534031257A5C2CBDA600E6 /* OpenFile.swift */; }; 721CB785106F5B870FC0840A /* BrowserPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE3A24F9ECB9E85969607AAB /* BrowserPaneView.swift */; }; @@ -99,7 +99,6 @@ 736679A853BAC31DAE966F2D /* WorkspaceWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */; }; 73733159AA52E171C502A90A /* Agent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D16799A267ED55B558F74BD /* Agent.swift */; }; 7427AAE45109FAD01C2383F4 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 94892689AEB2C5AAD27C3783 /* main.mm */; }; - 74D36D5BBF4F6AD3BBF4076D /* AgentHubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */; }; 7599744149050661CCF8FB27 /* DatabaseDateValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FDBEE7FFDAA06C8410D140 /* DatabaseDateValue.swift */; }; 76139D5A678EE0329AD1280B /* Schema.swift in Sources */ = {isa = PBXBuildFile; fileRef = D399795586ADE5448AB72D1C /* Schema.swift */; }; 76E3F554517581E83297A297 /* TrashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33262B570A72E09F2E88ECF2 /* TrashView.swift */; }; @@ -108,6 +107,7 @@ 7ADBA4F0047957F15F4CDD43 /* TerminalPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8754B7A6F668B3311F152557 /* TerminalPaneView.swift */; }; 7C3131421C95964975D5B5FB /* FileTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C165B4FFDB916884D4293C /* FileTreeView.swift */; }; 7C8D7191B8B1FF31105C4608 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B66C38F452E8D9E75E81F /* AppState.swift */; }; + 7CAA254A47BAB8452D725D3E /* BugbookHelperRenderer.app in Resources */ = {isa = PBXBuildFile; fileRef = 8F17E1D0A1AED418CA050552 /* BugbookHelperRenderer.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7DDC7E5E799945BE79D9434F /* WikiLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DEFDD7A7DE801DEDBF0890 /* WikiLinkView.swift */; }; 7E22DC9B531139E72F7A045C /* DatabaseViewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B306A8D5E45215DD58F3FF0D /* DatabaseViewHelpers.swift */; }; 7E38D58E6E15F20B2F6831F3 /* PaneTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE960E4847703CED511990C6 /* PaneTreeView.swift */; }; @@ -138,7 +138,6 @@ 99B81B2510FEA9BB0B092324 /* QueryEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A49740409C913AF7DE543D6 /* QueryEngine.swift */; }; 9E7E6AC9222DBAAC08FAD6BF /* IndexManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70DE6960E273C11B104C1DD9 /* IndexManager.swift */; }; A017A5DD5FB8198FEEBAC1D1 /* PagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */; }; - A07943BEB656C525B2DEDF14 /* MailIntelligenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C428A29D68D42CE79E2FED6F /* MailIntelligenceService.swift */; }; A11F532663FB6DCE02C0C4ED /* WorkspacePathRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579A2C2A019CFEAC7344FE9C /* WorkspacePathRules.swift */; }; A395087E03AB1A889F4620AE /* SplitDividerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70D0E04210ECEB0AD796BE41 /* SplitDividerView.swift */; }; A5C00BCC3313FC7DADD70BFA /* TranscriptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */; }; @@ -161,8 +160,8 @@ B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */; }; B947094FE31C56FA60F836F8 /* BrowserSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5E1B394217B8F7BF7022BE6 /* BrowserSettingsView.swift */; }; BA264475137FDA61D8B523DA /* MailIntelligenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F51C3794803B47AB531929A /* MailIntelligenceStore.swift */; }; - BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */; }; BB63147ADC84BB1CB310FAD6 /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7150DDE83E7ED425899B1 /* TableView.swift */; }; + BBBFAB38F98456C7CBDFD422 /* MeetingPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9247C78A0DD23AA84EE20164 /* MeetingPageView.swift */; }; BD8320F85A870BCF0237959A /* AISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6639981D84F86643DBB91CD /* AISettingsView.swift */; }; BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */; }; BFB8093FEFA111171A691180 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F22A7B4A57E1178ABD90C2F /* HomeView.swift */; }; @@ -175,11 +174,13 @@ C615A4810265456A1BFD7665 /* AiContextItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E007AA9E5194AC13E45B4A /* AiContextItem.swift */; }; C967897AA5001A491AF24AC9 /* FileSystemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */; }; CA33D3B11860722C31061744 /* Chromium Embedded Framework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A07C5107DAD4416595662CF /* Chromium Embedded Framework.framework */; }; + CABB44506FC5E5205199FB14 /* MeetingNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15DB1413260BD309ED70A064 /* MeetingNotificationService.swift */; }; CB2949653AC3B2A849EBE288 /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 94892689AEB2C5AAD27C3783 /* main.mm */; }; CBB52CB1DEEB63DEF5B95C42 /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59394152B4415173A37EE498 /* CalendarService.swift */; }; CBDA6D4C212A797378E7C062 /* OnboardingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22439903C988ACDC2263371A /* OnboardingService.swift */; }; CD507991F60FBD164AADCC1B /* Chromium Embedded Framework.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A07C5107DAD4416595662CF /* Chromium Embedded Framework.framework */; }; CD5A00C7795061BC2DD7ADA9 /* HomeBottomZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7522F2BBEE8963CD915BD5F /* HomeBottomZone.swift */; }; + CE4550FE2C4A063975E3DC77 /* ActiveMeetingSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9031DC94573845C29A69235 /* ActiveMeetingSession.swift */; }; CE669594EE968AB7CD4FF9E9 /* DatabaseTemplatePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D914C317DA10399836E4DECC /* DatabaseTemplatePickerView.swift */; }; CE6A8D6D7B08D23CE62776BE /* TemplatePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1075BF7C8AE8B706A1799531 /* TemplatePickerView.swift */; }; D029F4E3DFFC61628D54BE90 /* PageHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */; }; @@ -191,7 +192,6 @@ D2B9ECA5C7304C1A09E2DF36 /* ThreadPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4761C541993589BF2FBC460D /* ThreadPickerRow.swift */; }; D33327707F2E0FFCE3EDFB12 /* SchemaValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27404EBA915A3E0AEE0D7483 /* SchemaValidator.swift */; }; D3925CE4995D456C8362FD41 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7712E15B025309AF9EAF67D /* WelcomeView.swift */; }; - D5404002070C02A7C49FAD38 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1036E491194645E064E4D08B /* SidebarView.swift */; }; D5A7B7D7582EC21C39092E60 /* DatabaseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB082A1507F6A50D99EEF49 /* DatabaseStore.swift */; }; D76DEEE7E4AF655BCC15E804 /* RenderLoopDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EC5A7963942301B2BBEAAEF /* RenderLoopDetector.swift */; }; D809F8300B119795D5F00A8B /* AiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C68748E44202E7C8D638C24B /* AiService.swift */; }; @@ -200,12 +200,12 @@ DB652669C66A472349133778 /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = 7C4D37FADAAFA4137EF3A9F6 /* FluidAudio */; }; DBF41C1912A5B93FD20BDE90 /* DatabaseRowFullPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B116D36FE355D650ADC6B0F6 /* DatabaseRowFullPageView.swift */; }; DC399F897D16862221AE55FF /* RowPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89D0872A52B79E2B456963C /* RowPageView.swift */; }; + DC838A47B327E2D2EEA33F85 /* BugbookHelperGPU.app in Resources */ = {isa = PBXBuildFile; fileRef = 9B88415209A31D235EE07A1D /* BugbookHelperGPU.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */; }; DF60FC019C9D04629FF291AA /* CoverPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8A5DE98CE38F83FE2B092CF /* CoverPickerView.swift */; }; E39A529A05BAE723F9AB5BB4 /* BlockMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29502EEA0A700D17E1A1E809 /* BlockMenuView.swift */; }; E3BB767C73688C6F648CD743 /* BreadcrumbView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF65B82523BECFB622E38CCA /* BreadcrumbView.swift */; }; E45345FAE65412E3DFFB0272 /* DatabaseService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */; }; - E704106BDA659E0F10A43A3A /* MeetingNotesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */; }; E70E530BB9DFC76494536EAB /* FullEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C864B6F1244A0E7F07EE260A /* FullEmojiPickerView.swift */; }; E7CF6114C033056C7B567615 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A62448B1FDB26E6C7434DF04 /* QuartzCore.framework */; }; E89317C6440E0357A8F3D179 /* BlockCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1D94EABC044FCDB416EC80 /* BlockCellView.swift */; }; @@ -219,15 +219,25 @@ F138E57CF6D943B0AF4B485C /* KeyboardShortcutOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64761AE2591D8E6B1F8E1B5 /* KeyboardShortcutOverlay.swift */; }; F1D183B0EC41EE0853B0913F /* DatabaseTemplateEditorModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C636CE45E2D5E20C53C703 /* DatabaseTemplateEditorModal.swift */; }; F3E112BD02FAE061A4E91DF4 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AC347581598E6C53507F712 /* CoreGraphics.framework */; }; + F492E7DCAADB2C109E631E08 /* BugbookHelperAlerts.app in Resources */ = {isa = PBXBuildFile; fileRef = 9315785DF0DBB82EFAD3799E /* BugbookHelperAlerts.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F6196917751CF468444068C8 /* RowStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC779B217122291C509E482 /* RowStore.swift */; }; F63BE3F6A864D6E246D47F8B /* FormattingToolbarPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B553FF577D3D25B20DA5DAC5 /* FormattingToolbarPanel.swift */; }; F65C1F27B0D5F2963A1BAAB7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6265630F0923203C1F9764 /* ContentView.swift */; }; F7250EE969D2D3094CD9172E /* RelationResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */; }; + F7F5B022B6C01E12BDC1696B /* BugbookHelperPlugin.app in Resources */ = {isa = PBXBuildFile; fileRef = 11CC03A7D9CC07C448D8A90D /* BugbookHelperPlugin.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F989CBE88DBF74238D485E14 /* PageIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B384938321E8648F5447AE4D /* PageIcon.swift */; }; + FBF8E1DABDC49A12B29F85B8 /* ChromiumBridge.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = AE83CC78041D0E3A141E7B08 /* ChromiumBridge.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; FFCF132F09098A60325158E6 /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 476358F19D676EC12E2AC59A /* CoreText.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 202E8B786B3F0CE3CF714287 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = F20716B4CF6F634564DBEAC0; + remoteInfo = BugbookHelperGPU; + }; 2253322A3195258138D98182 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; @@ -235,6 +245,13 @@ remoteGlobalIDString = E1B57C400F29488DA7523D56; remoteInfo = BugbookCore; }; + 6E022B0A1FB3F04465545580 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = F3A97FB724E88A86BA7E25F8; + remoteInfo = BugbookHelperAlerts; + }; 700FC8BAD6EC52AFAA8DE8DB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; @@ -242,6 +259,34 @@ remoteGlobalIDString = 8DD6EDE4722CA92DDB665594; remoteInfo = BugbookApp; }; + 8E9FA6DE81CDD3D89352BFA9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 55331F0ED59F84A9DD06A435; + remoteInfo = ChromiumBridge; + }; + B7C5ADB6C17D8589FD8D66D6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5787CF0A201A6BECEEF9B33A; + remoteInfo = BugbookHelperPlugin; + }; + E0E81822EC5F2394A090EEBC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1AF54EDA6E40CB8E7E55EC47; + remoteInfo = BugbookHelper; + }; + FE9F23CBFBCCD5E5B684BAAB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 489B65C275F5B7D174B72AC4; + remoteInfo = BugbookHelperRenderer; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -252,6 +297,7 @@ dstSubfolderSpec = 10; files = ( 65A641EC216A6E2ABF031321 /* BugbookCore.framework in Embed Frameworks */, + FBF8E1DABDC49A12B29F85B8 /* ChromiumBridge.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -264,10 +310,10 @@ 0E2720BDB254FE084FC3EFBF /* FloatingRecordingPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingRecordingPill.swift; sourceTree = ""; }; 0EB095B5E515A2CC72C6F391 /* GatewayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayViewModel.swift; sourceTree = ""; }; 0F534031257A5C2CBDA600E6 /* OpenFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenFile.swift; sourceTree = ""; }; - 1036E491194645E064E4D08B /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 1075BF7C8AE8B706A1799531 /* TemplatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatePickerView.swift; sourceTree = ""; }; 11CC03A7D9CC07C448D8A90D /* BugbookHelperPlugin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BugbookHelperPlugin.app; sourceTree = BUILT_PRODUCTS_DIR; }; 12E66496064A29930C3F3B17 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 15DB1413260BD309ED70A064 /* MeetingNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotificationService.swift; sourceTree = ""; }; 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHubViewModel.swift; sourceTree = ""; }; 1799B738BA3EC09A6406E599 /* HomeTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimeView.swift; sourceTree = ""; }; 17B465CD69F3B8BE6083AF39 /* AiThreadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiThreadStore.swift; sourceTree = ""; }; @@ -333,7 +379,6 @@ 579A2C2A019CFEAC7344FE9C /* WorkspacePathRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePathRules.swift; sourceTree = ""; }; 57BA5285DC6913D9E172BA99 /* AgentsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentsSettingsView.swift; sourceTree = ""; }; 59394152B4415173A37EE498 /* CalendarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarService.swift; sourceTree = ""; }; - 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingKnowledgeView.swift; sourceTree = ""; }; 5C6265630F0923203C1F9764 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5D16799A267ED55B558F74BD /* Agent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Agent.swift; sourceTree = ""; }; 5D63A4E1D2116A8BCB5134EF /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = ""; }; @@ -344,9 +389,9 @@ 661BA33DC8D61D2884B32072 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugbookUITests.swift; sourceTree = ""; }; 684C60C1D927125CD76DD2C0 /* WorkspaceResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceResolver.swift; sourceTree = ""; }; + 6988199BE7B6C7A18AAEC1F5 /* MeetingTranscriptStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingTranscriptStore.swift; sourceTree = ""; }; 6ADD216C50E87964622BD9C8 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; }; 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterService.swift; sourceTree = ""; }; - 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesEditor.swift; sourceTree = ""; }; 6F22A7B4A57E1178ABD90C2F /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 704F620630C8D38A78C3DA37 /* DatabaseFullPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFullPageView.swift; sourceTree = ""; }; 70D0E04210ECEB0AD796BE41 /* SplitDividerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitDividerView.swift; sourceTree = ""; }; @@ -372,10 +417,10 @@ 84E6EFB8132BB410F0C39308 /* PaneFocusIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneFocusIndicator.swift; sourceTree = ""; }; 8535862722D5936666055431 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; 8545DDCD2B4069B7F04C42D2 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = GhosttyKit.xcframework; path = ../Frameworks/GhosttyKit.xcframework; sourceTree = ""; }; - 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPeekState.swift; sourceTree = ""; }; 8754B7A6F668B3311F152557 /* TerminalPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneView.swift; sourceTree = ""; }; 8F17E1D0A1AED418CA050552 /* BugbookHelperRenderer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BugbookHelperRenderer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarWeekView.swift; sourceTree = ""; }; + 9247C78A0DD23AA84EE20164 /* MeetingPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingPageView.swift; sourceTree = ""; }; 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionService.swift; sourceTree = ""; }; 9315785DF0DBB82EFAD3799E /* BugbookHelperAlerts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BugbookHelperAlerts.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9365350F95628047D144D9F7 /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = ""; }; @@ -391,7 +436,6 @@ 9FC779B217122291C509E482 /* RowStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowStore.swift; sourceTree = ""; }; A2C165B4FFDB916884D4293C /* FileTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeView.swift; sourceTree = ""; }; A2E6CAEA3C923DDA24C7C36A /* ToggleBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleBlockView.swift; sourceTree = ""; }; - A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownParser.swift; sourceTree = ""; }; A44064313070DE5650CFC708 /* RawInboxWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawInboxWriter.swift; sourceTree = ""; }; A52CFB20317770DA9DB4D732 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseService.swift; sourceTree = ""; }; @@ -401,7 +445,6 @@ A7712E15B025309AF9EAF67D /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; A79EB60575489CA58B52DDDC /* BrowserEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserEngine.swift; sourceTree = ""; }; A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasePointerCursor.swift; sourceTree = ""; }; - A8F6987F6DB975D02C042D28 /* PaneFindBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneFindBar.swift; sourceTree = ""; }; AC7E8D79870694E7CD09B43A /* BrowserAgentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAgentService.swift; sourceTree = ""; }; AE400CA9718CD5697976941B /* BlockTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockTextView.swift; sourceTree = ""; }; AE83CC78041D0E3A141E7B08 /* ChromiumBridge.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ChromiumBridge.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -421,7 +464,6 @@ C2262869710B320C9D232D7B /* CalendarEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventStore.swift; sourceTree = ""; }; C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNoteService.swift; sourceTree = ""; }; C2FA24D6D73B2E569BC2370D /* BrowserEngineFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserEngineFactory.swift; sourceTree = ""; }; - C428A29D68D42CE79E2FED6F /* MailIntelligenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailIntelligenceService.swift; sourceTree = ""; }; C5E1B394217B8F7BF7022BE6 /* BrowserSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserSettingsView.swift; sourceTree = ""; }; C68748E44202E7C8D638C24B /* AiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiService.swift; sourceTree = ""; }; C72383D5865CC78156A21463 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = ""; }; @@ -437,6 +479,7 @@ D399795586ADE5448AB72D1C /* Schema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Schema.swift; sourceTree = ""; }; D3F977D14DD3400DC5FF3583 /* BugbookUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BugbookUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5E1AF8805EE73E377D8DF0E /* TableBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableBlockView.swift; sourceTree = ""; }; + D9031DC94573845C29A69235 /* ActiveMeetingSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveMeetingSession.swift; sourceTree = ""; }; D914C317DA10399836E4DECC /* DatabaseTemplatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTemplatePickerView.swift; sourceTree = ""; }; DB9D402516F42FFD4940DB38 /* FormulaEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormulaEngine.swift; sourceTree = ""; }; DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayView.swift; sourceTree = ""; }; @@ -452,7 +495,6 @@ EA1B04557D2E66868F56829B /* PaneNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaneNode.swift; sourceTree = ""; }; EAE175C836C7455A3A8153B2 /* SidebarReferenceDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarReferenceDragPayload.swift; sourceTree = ""; }; EBC605E52D91C41E419FBF35 /* FloatingPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPopover.swift; sourceTree = ""; }; - EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHubView.swift; sourceTree = ""; }; ED677E977CBBB33D0ECA74CE /* GraphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphView.swift; sourceTree = ""; }; ED7530043DEEF28753C2CD6C /* Query.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Query.swift; sourceTree = ""; }; EDC4FD1E4254A70EF2702977 /* BBChromiumBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BBChromiumBridge.h; sourceTree = ""; }; @@ -488,6 +530,7 @@ buildActionMask = 2147483647; files = ( AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */, + 6993B4A526F9AC005BF0AE27 /* ChromiumBridge.framework in Frameworks */, DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */, 0150805DFE2AF45CF4B0E7DC /* Sentry in Frameworks */, DB652669C66A472349133778 /* FluidAudio in Frameworks */, @@ -573,6 +616,7 @@ 1B4C22DE16CEBDAEF95DE127 /* Models */ = { isa = PBXGroup; children = ( + D9031DC94573845C29A69235 /* ActiveMeetingSession.swift */, F3E007AA9E5194AC13E45B4A /* AiContextItem.swift */, 12E66496064A29930C3F3B17 /* AppSettings.swift */, 398B466A4F5F82ACEA4874F6 /* Block.swift */, @@ -616,7 +660,6 @@ 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */, F0E08AB3843737425588F4B7 /* DatabaseEmbedPathResolver.swift */, C74982BB99A95A4D3C5FDDFB /* MarkdownBlockParser.swift */, - A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */, ); path = Lib; sourceTree = ""; @@ -637,8 +680,6 @@ 77D74F02ED2A5522822B783A /* GripDotsView.swift */, 00F20EDB7EB07349C54B1DB8 /* HeadingToggleBlockView.swift */, 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */, - 5C1C9B25D6FC2B7377D1729E /* MeetingKnowledgeView.swift */, - 6C3077EA488C1FCAA795FEB4 /* MeetingNotesEditor.swift */, 4E4ED842193C600650451387 /* MentionPickerView.swift */, FB1D2F73F8536CB22D1959D7 /* OutlineBlockView.swift */, 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */, @@ -665,7 +706,6 @@ 2CCC42D8987196F88C0F0468 /* Agent */ = { isa = PBXGroup; children = ( - EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */, 33892ABFB220B57504C88B44 /* SkillDetailView.swift */, ); path = Agent; @@ -790,7 +830,6 @@ 82603F77EB0544EA989F4BE7 /* EditorUIState.swift */, 0EB095B5E515A2CC72C6F391 /* GatewayViewModel.swift */, 7AE556881D0438C7C049970E /* MeetingsViewModel.swift */, - 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */, ); path = ViewModels; sourceTree = ""; @@ -843,10 +882,11 @@ 3EE8F831BB714FE7AEB89798 /* GoogleAuthService.swift */, F60C3B84C6B10437033B8D43 /* KeychainSecretStore.swift */, 4CF3076E2CD78CCA495DCDCB /* Logger.swift */, - C428A29D68D42CE79E2FED6F /* MailIntelligenceService.swift */, 4F51C3794803B47AB531929A /* MailIntelligenceStore.swift */, 2D326464530C4745129644F6 /* MailService.swift */, C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */, + 15DB1413260BD309ED70A064 /* MeetingNotificationService.swift */, + 6988199BE7B6C7A18AAEC1F5 /* MeetingTranscriptStore.swift */, 22439903C988ACDC2263371A /* OnboardingService.swift */, EFF0F24D7EAF03362E0B0441 /* QmdService.swift */, 6378B4F86BC88F06B6E38B20 /* SavedWebPageStore.swift */, @@ -884,7 +924,6 @@ children = ( E5CF30FE4A58B2212047CA84 /* FileTreeItemView.swift */, A2C165B4FFDB916884D4293C /* FileTreeView.swift */, - 1036E491194645E064E4D08B /* SidebarView.swift */, 33262B570A72E09F2E88ECF2 /* TrashView.swift */, ); path = Sidebar; @@ -1028,7 +1067,6 @@ children = ( E9F14A6F57884D8FEF4E3D86 /* PaneChromeBar.swift */, 4358B9082C37BE1854810696 /* PaneContentView.swift */, - A8F6987F6DB975D02C042D28 /* PaneFindBar.swift */, 84E6EFB8132BB410F0C39308 /* PaneFocusIndicator.swift */, 5124AF9921CE7B70639D57F3 /* PaneLauncher.swift */, FE960E4847703CED511990C6 /* PaneTreeView.swift */, @@ -1059,6 +1097,7 @@ F3EE2A2FCDD080B2CD99613C /* Meetings */ = { isa = PBXGroup; children = ( + 9247C78A0DD23AA84EE20164 /* MeetingPageView.swift */, 18386D38CC079F0F28BE5CF6 /* MeetingsView.swift */, ); path = Meetings; @@ -1178,11 +1217,18 @@ B50CDE78BBB41AD95526BB61 /* Resources */, 1B95CDF35EF986ABC011DFCE /* Frameworks */, F27336E2ABFB64ABFE05709A /* Embed Frameworks */, + 54A5A7E9BECA77872FB1DF18 /* Copy CEF Assets */, ); buildRules = ( ); dependencies = ( 90A8427D78C82A57D97C22FC /* PBXTargetDependency */, + 4A6348CECACE8BB1E67AE30A /* PBXTargetDependency */, + D8B78D11C413456785D38D47 /* PBXTargetDependency */, + 7BED0340FE867A0FC2484195 /* PBXTargetDependency */, + FF42AF1BDA8DB5E3508B5034 /* PBXTargetDependency */, + E3EC8A917E802A4FB54A4711 /* PBXTargetDependency */, + B15159792E51E98112C64AD8 /* PBXTargetDependency */, ); name = BugbookApp; packageProductDependencies = ( @@ -1302,11 +1348,37 @@ buildActionMask = 2147483647; files = ( 7EF33F80D6BB6B2F0A6CBD74 /* Assets.xcassets in Resources */, + 5F56C1FAAF4D6D842773AB34 /* BugbookHelper.app in Resources */, + F492E7DCAADB2C109E631E08 /* BugbookHelperAlerts.app in Resources */, + DC838A47B327E2D2EEA33F85 /* BugbookHelperGPU.app in Resources */, + F7F5B022B6C01E12BDC1696B /* BugbookHelperPlugin.app in Resources */, + 7CAA254A47BAB8452D725D3E /* BugbookHelperRenderer.app in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 54A5A7E9BECA77872FB1DF18 /* Copy CEF Assets */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy CEF Assets"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "bash \"$PROJECT_DIR/../scripts/copy-cef-assets.sh\" \\\n \"$TARGET_BUILD_DIR/$WRAPPER_NAME\" \\\n \"$BUILT_PRODUCTS_DIR/BugbookHelper.app\" \\\n \"$BUILT_PRODUCTS_DIR/BugbookHelperAlerts.app\" \\\n \"$BUILT_PRODUCTS_DIR/BugbookHelperGPU.app\" \\\n \"$BUILT_PRODUCTS_DIR/BugbookHelperPlugin.app\" \\\n \"$BUILT_PRODUCTS_DIR/BugbookHelperRenderer.app\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 167F327082DA821208F00B4D /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -1376,7 +1448,7 @@ buildActionMask = 2147483647; files = ( BD8320F85A870BCF0237959A /* AISettingsView.swift in Sources */, - 74D36D5BBF4F6AD3BBF4076D /* AgentHubView.swift in Sources */, + CE4550FE2C4A063975E3DC77 /* ActiveMeetingSession.swift in Sources */, 3AA74BAA18B9F57D4266402F /* AgentHubViewModel.swift in Sources */, 0044A6D4562A8D96730E1E8E /* AgentsSettingsView.swift in Sources */, C615A4810265456A1BFD7665 /* AiContextItem.swift in Sources */, @@ -1469,17 +1541,16 @@ 41093BBBDD10E3C59B63F7F0 /* ListView.swift in Sources */, 31319D254BD21B3105E098D8 /* Logger.swift in Sources */, B739759EBB24637E9AB8D608 /* MailIntelligenceModels.swift in Sources */, - A07943BEB656C525B2DEDF14 /* MailIntelligenceService.swift in Sources */, BA264475137FDA61D8B523DA /* MailIntelligenceStore.swift in Sources */, 12F9C2F7025065DBF7872E5F /* MailModels.swift in Sources */, C00B25FE68F8B8172E1A00E7 /* MailPaneView.swift in Sources */, 89894179425F2C21F8DBAA2A /* MailService.swift in Sources */, 3D9B4F3DBA52CA2B67CFBBB8 /* MarkdownBlockParser.swift in Sources */, - BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */, 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */, - 59C8876C7E8E337E6D37F861 /* MeetingKnowledgeView.swift in Sources */, 7EDA4964EE97D79FFE09EF59 /* MeetingNoteService.swift in Sources */, - E704106BDA659E0F10A43A3A /* MeetingNotesEditor.swift in Sources */, + CABB44506FC5E5205199FB14 /* MeetingNotificationService.swift in Sources */, + BBBFAB38F98456C7CBDFD422 /* MeetingPageView.swift in Sources */, + 7079D6C54FAD323FABCF9182 /* MeetingTranscriptStore.swift in Sources */, 14D253E632857BE6D9923006 /* MeetingsView.swift in Sources */, 50C208360E6765C158168DBC /* MeetingsViewModel.swift in Sources */, 215DC599267BF8F4A44B11D2 /* MentionPickerView.swift in Sources */, @@ -1494,7 +1565,6 @@ 021B6FAA83CB1D0ABC05BE24 /* PaneChromeBar.swift in Sources */, 7822807DA20A9A6954D3ABBC /* PaneContent.swift in Sources */, 132EE00837A422455AC5AA40 /* PaneContentView.swift in Sources */, - 6C702AC88341E9C25D4CFBDB /* PaneFindBar.swift in Sources */, EB44EC9D598AA910AE80CE79 /* PaneFocusIndicator.swift in Sources */, EAFDE8BE0DC7D666B701E33C /* PaneLauncher.swift in Sources */, 391868D020C69C0C614E7B8B /* PaneNode.swift in Sources */, @@ -1510,9 +1580,7 @@ 208879A6F607FAA57195F686 /* ShellZoomMetrics.swift in Sources */, 3FAC46CC7B9DE37B65C2D6D9 /* ShortcutsSettingsView.swift in Sources */, 279A6420F93CE53298E2AA56 /* SidebarDragPreview.swift in Sources */, - 45ABE1F981E46B748D9C724A /* SidebarPeekState.swift in Sources */, 264D0B95D6702BC01DDA5B34 /* SidebarReferenceDragPayload.swift in Sources */, - D5404002070C02A7C49FAD38 /* SidebarView.swift in Sources */, 3587299AB024CEF71130E759 /* SkillDetailView.swift in Sources */, 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */, A395087E03AB1A889F4620AE /* SplitDividerView.swift in Sources */, @@ -1569,6 +1637,16 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 4A6348CECACE8BB1E67AE30A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 55331F0ED59F84A9DD06A435 /* ChromiumBridge */; + targetProxy = 8E9FA6DE81CDD3D89352BFA9 /* PBXContainerItemProxy */; + }; + 7BED0340FE867A0FC2484195 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F3A97FB724E88A86BA7E25F8 /* BugbookHelperAlerts */; + targetProxy = 6E022B0A1FB3F04465545580 /* PBXContainerItemProxy */; + }; 901EE8084F9554105AB8B922 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 8DD6EDE4722CA92DDB665594 /* BugbookApp */; @@ -1579,6 +1657,26 @@ target = E1B57C400F29488DA7523D56 /* BugbookCore */; targetProxy = 2253322A3195258138D98182 /* PBXContainerItemProxy */; }; + B15159792E51E98112C64AD8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 489B65C275F5B7D174B72AC4 /* BugbookHelperRenderer */; + targetProxy = FE9F23CBFBCCD5E5B684BAAB /* PBXContainerItemProxy */; + }; + D8B78D11C413456785D38D47 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1AF54EDA6E40CB8E7E55EC47 /* BugbookHelper */; + targetProxy = E0E81822EC5F2394A090EEBC /* PBXContainerItemProxy */; + }; + E3EC8A917E802A4FB54A4711 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5787CF0A201A6BECEEF9B33A /* BugbookHelperPlugin */; + targetProxy = B7C5ADB6C17D8589FD8D66D6 /* PBXContainerItemProxy */; + }; + FF42AF1BDA8DB5E3508B5034 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F20716B4CF6F634564DBEAC0 /* BugbookHelperGPU */; + targetProxy = 202E8B786B3F0CE3CF714287 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1607,6 +1705,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook.dev.helper.plugin; PRODUCT_NAME = "Bugbook Helper (Plugin)"; SDKROOT = macosx; @@ -1638,6 +1737,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook.dev.helper.alerts; PRODUCT_NAME = "Bugbook Helper (Alerts)"; SDKROOT = macosx; @@ -1726,6 +1826,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook.dev.helper.renderer; PRODUCT_NAME = "Bugbook Helper (Renderer)"; SDKROOT = macosx; @@ -1757,6 +1858,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook.dev.helper.gpu; PRODUCT_NAME = "Bugbook Helper (GPU)"; SDKROOT = macosx; @@ -1872,7 +1974,7 @@ PRODUCT_NAME = Bugbook; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( - BUGBOOK_BROWSER_WEBKIT, + BUGBOOK_BROWSER_CHROMIUM, ); }; name = Release; @@ -1898,6 +2000,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ( "-lz", "-lc++", @@ -1908,7 +2011,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = ( DEBUG, BUGBOOK_DEV, - BUGBOOK_BROWSER_WEBKIT, + BUGBOOK_BROWSER_CHROMIUM, ); }; name = Debug; @@ -1969,6 +2072,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook.dev.helper; PRODUCT_NAME = "Bugbook Helper"; SDKROOT = macosx; @@ -2205,6 +2309,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.ChromiumBridge; PRODUCT_MODULE_NAME = ChromiumBridge; PRODUCT_NAME = ChromiumBridge; diff --git a/macos/ChromiumBridge/BBChromiumBridge.h b/macos/ChromiumBridge/BBChromiumBridge.h index 49b89e55..915cadda 100644 --- a/macos/ChromiumBridge/BBChromiumBridge.h +++ b/macos/ChromiumBridge/BBChromiumBridge.h @@ -25,6 +25,7 @@ NS_ASSUME_NONNULL_BEGIN @interface BBChromiumRuntime : NSObject + (void)startIfNeeded; + (NSString *)runtimeDescription; ++ (void)clearCookies; @end @interface BBChromiumPage : NSObject diff --git a/macos/ChromiumBridge/BBChromiumBridge.mm b/macos/ChromiumBridge/BBChromiumBridge.mm index e421f3f3..47162375 100644 --- a/macos/ChromiumBridge/BBChromiumBridge.mm +++ b/macos/ChromiumBridge/BBChromiumBridge.mm @@ -10,6 +10,7 @@ #include "include/cef_api_hash.h" #include "include/capi/cef_browser_capi.h" #include "include/capi/cef_client_capi.h" +#include "include/capi/cef_cookie_capi.h" #include "include/capi/cef_devtools_message_observer_capi.h" #include "include/capi/cef_download_handler_capi.h" #include "include/capi/cef_download_item_capi.h" @@ -171,19 +172,41 @@ static void BBChromiumReleaseRefCounted(cef_base_ref_counted_t *base) { return [BBChromiumRootCachePath() stringByAppendingPathComponent:@"Default"]; } +static BOOL BBChromiumEnvironmentFlagEnabled(NSString *name) { + NSString *value = NSProcessInfo.processInfo.environment[name]; + if (!value) { + return NO; + } + + NSString *normalized = value.lowercaseString; + return [normalized isEqualToString:@"1"] + || [normalized isEqualToString:@"true"] + || [normalized isEqualToString:@"yes"] + || [normalized isEqualToString:@"on"]; +} + static NSArray *BBChromiumAdditionalArguments(void) { static NSArray *arguments = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - // Metal-backed ANGLE + GPU rasterization pipeline. - // Replaces SwiftShader (pure CPU) with hardware-accelerated rendering. - // Flags validated via automated benchmark sweep (40 configs tested). + if (BBChromiumEnvironmentFlagEnabled(@"BUGBOOK_CHROMIUM_AGGRESSIVE_GPU")) { + // Opt-in fast path for local benchmarking. This has proven unstable on + // some dev machines, so it should not be the default runtime mode. + arguments = @[ + @"--use-gl=angle", + @"--use-angle=metal", + @"--enable-gpu-rasterization", + @"--enable-zero-copy", + @"--num-raster-threads=4", + ]; + return; + } + + // Stable default for dev use: keep Chromium on the software renderer so + // browser tabs still work even when the GPU helper is not usable. arguments = @[ - @"--use-gl=angle", - @"--use-angle=metal", - @"--enable-gpu-rasterization", - @"--enable-zero-copy", - @"--num-raster-threads=4", + @"--disable-gpu", + @"--disable-gpu-compositing", ]; }); return arguments; @@ -410,10 +433,21 @@ @interface BBChromiumRuntime () + (cef_request_context_t *)sharedRequestContext; @end +@class BBChromiumPage; + +@interface BBChromiumPopupWindowController : NSWindowController +@property(nonatomic, readonly) NSUUID *popupID; +@property(nonatomic, readonly) BBChromiumPage *page; +- (instancetype)initWithOwnerPage:(BBChromiumPage *)ownerPage initialURLString:(nullable NSString *)initialURLString; +- (void)showPopupWindow; +- (void)browserDidClose; +@end + @interface BBChromiumPage () @property(nonatomic, strong) BBChromiumHostContainerView *hostContainerView; @property(nonatomic, strong) NSView *browserView; @property(nonatomic, strong) NSMutableDictionary *pendingJavaScriptEvaluations; +@property(nonatomic, strong) NSMutableDictionary *popupControllers; @property(nonatomic, copy, nullable) NSString *pendingURLString; @property(nonatomic, copy, nullable) NSString *currentURLString; @property(nonatomic, copy, nullable) NSString *currentTitle; @@ -423,9 +457,11 @@ @interface BBChromiumPage () @property(nonatomic, assign) BOOL canGoBack; @property(nonatomic, assign) BOOL canGoForward; @property(nonatomic, assign) double pageZoom; +@property(nonatomic, assign) BOOL expectsPopupBrowser; @property(nonatomic, assign) BOOL browserCreationAttempted; @property(nonatomic, assign) BOOL disposed; @property(nonatomic, assign) int nextDevToolsMessageID; +@property(nonatomic, weak, nullable) BBChromiumPopupWindowController *owningPopupWindowController; @property(nonatomic, assign) cef_browser_t *browser; @property(nonatomic, assign) cef_browser_host_t *browserHost; @property(nonatomic, assign) cef_registration_t *devToolsRegistration; @@ -436,7 +472,14 @@ @interface BBChromiumPage () @property(nonatomic, assign) BBChromiumDownloadHandlerWrapper *downloadHandlerWrapper; @property(nonatomic, assign) BBChromiumFindHandlerWrapper *findHandlerWrapper; @property(nonatomic, assign) BBChromiumDevToolsObserverWrapper *devToolsObserverWrapper; +- (instancetype)initWithInitialURLString:(nullable NSString *)initialURLString expectsPopupBrowser:(BOOL)expectsPopupBrowser; +- (void)commonInitWithInitialURLString:(nullable NSString *)initialURLString expectsPopupBrowser:(BOOL)expectsPopupBrowser; - (void)ensureBrowserCreatedIfPossible; +- (BOOL)configurePopupWindowInfo:(cef_window_info_t *)windowInfo + client:(cef_client_t **)client + targetURLString:(NSString *)targetURLString; +- (void)releasePopupControllerWithID:(NSUUID *)popupID; +- (void)closeChildPopups; - (void)handleAddressChange:(NSString *)urlString browser:(cef_browser_t *)browser; - (void)handleTitleChange:(NSString *)title browser:(cef_browser_t *)browser; - (void)handleStatusMessage:(NSString *)status; @@ -537,6 +580,24 @@ + (NSString *)runtimeDescription { return [NSString stringWithFormat:@"CEF %s", CEF_VERSION]; } ++ (void)clearCookies { + cef_request_context_t *context = [self sharedRequestContext]; + if (!context) { + return; + } + + cef_cookie_manager_t *cookieManager = context->get_cookie_manager(context, nullptr); + if (!cookieManager) { + return; + } + + cookieManager->delete_cookies(cookieManager, nullptr, nullptr, nullptr); + if (cookieManager->flush_store) { + cookieManager->flush_store(cookieManager, nullptr); + } + BBChromiumReleaseRefCounted(&cookieManager->base); +} + + (cef_request_context_t *)sharedRequestContext { [self startIfNeeded]; if (!BBChromiumDidInitialize || BBChromiumSharedRequestContext) { @@ -759,13 +820,24 @@ static int CEF_CALLBACK BBChromiumOnBeforePopup(cef_life_span_handler_t *handler __unused cef_window_open_disposition_t target_disposition, __unused int user_gesture, __unused const cef_popup_features_t *popupFeatures, - __unused cef_window_info_t *windowInfo, - __unused cef_client_t **client, - __unused cef_browser_settings_t *settings, + cef_window_info_t *windowInfo, + cef_client_t **client, + cef_browser_settings_t *settings, __unused cef_dictionary_value_t **extra_info, __unused int *no_javascript_access) { BBChromiumPage *page = BBChromiumLifeSpanHandlerFrom(handler)->page; - [page handlePopupURL:BBChromiumStringFromCef(target_url)]; + NSString *targetURLString = BBChromiumStringFromCef(target_url); + if (page && windowInfo && client + && [page configurePopupWindowInfo:windowInfo client:client targetURLString:targetURLString]) { + if (settings) { + settings->size = sizeof(*settings); + settings->background_color = CefColorSetARGB(255, 255, 255, 255); + settings->javascript_access_clipboard = STATE_ENABLED; + settings->webgl = STATE_ENABLED; + } + return 0; + } + [page handlePopupURL:targetURLString]; return 1; } @@ -874,6 +946,66 @@ static void CEF_CALLBACK BBChromiumOnDevToolsAgentDetached(cef_dev_tools_message [page failPendingJavaScriptEvaluationsWithError:BBChromiumError(5, @"Chromium DevTools agent detached.")]; } +@implementation BBChromiumPopupWindowController { + __weak BBChromiumPage *_ownerPage; + BOOL _closingFromBrowser; +} + +- (instancetype)initWithOwnerPage:(BBChromiumPage *)ownerPage initialURLString:(nullable NSString *)initialURLString { + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 520, 720) + styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable + backing:NSBackingStoreBuffered + defer:NO]; + self = [super initWithWindow:window]; + if (!self) { + return nil; + } + + _ownerPage = ownerPage; + _popupID = [NSUUID UUID]; + _page = [[BBChromiumPage alloc] initWithInitialURLString:initialURLString expectsPopupBrowser:YES]; + _page.owningPopupWindowController = self; + + NSView *hostView = _page.hostView; + hostView.frame = NSMakeRect(0, 0, 520, 720); + hostView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + + window.contentView = hostView; + window.delegate = self; + window.titleVisibility = NSWindowTitleHidden; + window.titlebarAppearsTransparent = YES; + window.toolbarStyle = NSWindowToolbarStyleUnifiedCompact; + window.releasedWhenClosed = NO; + window.title = initialURLString.length > 0 ? ([NSURL URLWithString:initialURLString].host ?: @"Browser") : @"Browser"; + return self; +} + +- (void)showPopupWindow { + [self.window center]; + [self showWindow:nil]; + [self.window makeKeyAndOrderFront:nil]; + [NSApp activateIgnoringOtherApps:YES]; +} + +- (void)browserDidClose { + _closingFromBrowser = YES; + if (self.window.isVisible) { + [self close]; + return; + } + [_ownerPage releasePopupControllerWithID:self.popupID]; +} + +- (void)windowWillClose:(NSNotification *)notification { + (void)notification; + [_ownerPage releasePopupControllerWithID:self.popupID]; + if (!_closingFromBrowser) { + [self.page dispose]; + } +} + +@end + @implementation BBChromiumPage - (instancetype)initWithInitialURLString:(nullable NSString *)initialURLString { @@ -882,10 +1014,25 @@ - (instancetype)initWithInitialURLString:(nullable NSString *)initialURLString { return nil; } + [self commonInitWithInitialURLString:initialURLString expectsPopupBrowser:NO]; + return self; +} + +- (instancetype)initWithInitialURLString:(nullable NSString *)initialURLString expectsPopupBrowser:(BOOL)expectsPopupBrowser { + self = [self initWithInitialURLString:initialURLString]; + if (self) { + _expectsPopupBrowser = expectsPopupBrowser; + } + return self; +} + +- (void)commonInitWithInitialURLString:(nullable NSString *)initialURLString expectsPopupBrowser:(BOOL)expectsPopupBrowser { _pendingJavaScriptEvaluations = [NSMutableDictionary dictionary]; + _popupControllers = [NSMutableDictionary dictionary]; _pendingURLString = initialURLString.length > 0 ? [initialURLString copy] : nil; _pageZoom = 1.0; _nextDevToolsMessageID = 1; + _expectsPopupBrowser = expectsPopupBrowser; _hostContainerView = [[BBChromiumHostContainerView alloc] initWithFrame:NSZeroRect]; _hostContainerView.page = self; @@ -895,7 +1042,6 @@ - (instancetype)initWithInitialURLString:(nullable NSString *)initialURLString { [self setupCEFCallbacks]; [self notifyStateChanged]; - return self; } - (void)dealloc { @@ -1040,6 +1186,7 @@ - (void)dispose { self.disposed = YES; self.delegate = nil; self.hostContainerView.page = nil; + [self closeChildPopups]; [self failPendingJavaScriptEvaluationsWithError:BBChromiumError(6, @"Chromium page was disposed.")]; @@ -1149,7 +1296,7 @@ - (void)setupCEFCallbacks { } - (void)ensureBrowserCreatedIfPossible { - if (self.disposed || self.browser || self.browserCreationAttempted) { + if (self.disposed || self.browser || self.browserCreationAttempted || self.expectsPopupBrowser) { return; } if (!self.hostContainerView.window) { @@ -1207,6 +1354,62 @@ - (void)ensureBrowserCreatedIfPossible { [self notifyStateChanged]; } +- (BOOL)configurePopupWindowInfo:(cef_window_info_t *)windowInfo + client:(cef_client_t **)client + targetURLString:(NSString *)targetURLString { + __block BOOL configured = NO; + void (^configurePopup)(void) = ^{ + BBChromiumPopupWindowController *controller = [[BBChromiumPopupWindowController alloc] initWithOwnerPage:self + initialURLString:targetURLString]; + if (!controller) { + return; + } + + self.popupControllers[controller.popupID] = controller; + [controller showPopupWindow]; + + BBChromiumPage *popupPage = controller.page; + if (!popupPage.clientWrapper) { + [self.popupControllers removeObjectForKey:controller.popupID]; + return; + } + + NSRect bounds = popupPage.browserView.bounds; + CGFloat width = NSWidth(bounds) > 1 ? NSWidth(bounds) : 520; + CGFloat height = NSHeight(bounds) > 1 ? NSHeight(bounds) : 720; + windowInfo->size = sizeof(*windowInfo); + windowInfo->parent_view = CAST_NSVIEW_TO_CEF_WINDOW_HANDLE(popupPage.hostContainerView); + windowInfo->view = CAST_NSVIEW_TO_CEF_WINDOW_HANDLE(popupPage.browserView); + windowInfo->bounds = cef_rect_t{0, 0, (int)width, (int)height}; + windowInfo->runtime_style = CEF_RUNTIME_STYLE_ALLOY; + *client = BBChromiumRetainAPI(&popupPage.clientWrapper->client); + configured = YES; + }; + + if (NSThread.isMainThread) { + configurePopup(); + } else { + dispatch_sync(dispatch_get_main_queue(), configurePopup); + } + + return configured; +} + +- (void)releasePopupControllerWithID:(NSUUID *)popupID { + if (!popupID) { + return; + } + [self.popupControllers removeObjectForKey:popupID]; +} + +- (void)closeChildPopups { + NSArray *controllers = self.popupControllers.allValues; + [self.popupControllers removeAllObjects]; + for (BBChromiumPopupWindowController *controller in controllers) { + [controller close]; + } +} + - (void)syncStateFromBrowser { if (!self.browser || !self.browser->is_valid(self.browser)) { return; @@ -1257,6 +1460,7 @@ - (void)handleBrowserBeforeClose { BBChromiumReleaseRefCounted(&self.browser->base); self.browser = nullptr; } + [self.owningPopupWindowController browserDidClose]; } - (void)handleAddressChange:(NSString *)urlString browser:(cef_browser_t *)browser { diff --git a/macos/project.yml b/macos/project.yml index a357a625..a7d2a1e4 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -62,6 +62,9 @@ targets: HEADER_SEARCH_PATHS: - $(SRCROOT)/vendor/cef/current SKIP_INSTALL: YES + configs: + Debug: + ONLY_ACTIVE_ARCH: YES BugbookHelper: type: application platform: macOS @@ -91,6 +94,7 @@ targets: configs: Debug: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.dev.helper + ONLY_ACTIVE_ARCH: YES Release: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.helper BugbookHelperAlerts: @@ -122,6 +126,7 @@ targets: configs: Debug: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.dev.helper.alerts + ONLY_ACTIVE_ARCH: YES Release: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.helper.alerts BugbookHelperGPU: @@ -153,6 +158,7 @@ targets: configs: Debug: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.dev.helper.gpu + ONLY_ACTIVE_ARCH: YES Release: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.helper.gpu BugbookHelperPlugin: @@ -184,6 +190,7 @@ targets: configs: Debug: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.dev.helper.plugin + ONLY_ACTIVE_ARCH: YES Release: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.helper.plugin BugbookHelperRenderer: @@ -215,6 +222,7 @@ targets: configs: Debug: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.dev.helper.renderer + ONLY_ACTIVE_ARCH: YES Release: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.helper.renderer BugbookApp: @@ -228,6 +236,12 @@ targets: - path: App/Assets.xcassets dependencies: - target: BugbookCore + - target: ChromiumBridge + - target: BugbookHelper + - target: BugbookHelperAlerts + - target: BugbookHelperGPU + - target: BugbookHelperPlugin + - target: BugbookHelperRenderer - package: Sparkle - package: SentryCocoa product: Sentry @@ -241,6 +255,16 @@ targets: - sdk: QuartzCore.framework - sdk: IOSurface.framework - sdk: WebKit.framework + postBuildScripts: + - name: Copy CEF Assets + script: | + bash "$PROJECT_DIR/../scripts/copy-cef-assets.sh" \ + "$TARGET_BUILD_DIR/$WRAPPER_NAME" \ + "$BUILT_PRODUCTS_DIR/BugbookHelper.app" \ + "$BUILT_PRODUCTS_DIR/BugbookHelperAlerts.app" \ + "$BUILT_PRODUCTS_DIR/BugbookHelperGPU.app" \ + "$BUILT_PRODUCTS_DIR/BugbookHelperPlugin.app" \ + "$BUILT_PRODUCTS_DIR/BugbookHelperRenderer.app" info: path: App/Info.plist properties: @@ -284,10 +308,11 @@ targets: Debug: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook.dev ASSETCATALOG_COMPILER_APPICON_NAME: DevAppIcon - SWIFT_ACTIVE_COMPILATION_CONDITIONS: [DEBUG, BUGBOOK_DEV, BUGBOOK_BROWSER_WEBKIT] + SWIFT_ACTIVE_COMPILATION_CONDITIONS: [DEBUG, BUGBOOK_DEV, BUGBOOK_BROWSER_CHROMIUM] + ONLY_ACTIVE_ARCH: YES Release: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook - SWIFT_ACTIVE_COMPILATION_CONDITIONS: [BUGBOOK_BROWSER_WEBKIT] + SWIFT_ACTIVE_COMPILATION_CONDITIONS: [BUGBOOK_BROWSER_CHROMIUM] BugbookUITests: type: bundle.ui-testing platform: macOS diff --git a/scripts/precommit.sh b/scripts/precommit.sh new file mode 100755 index 00000000..86dc424d --- /dev/null +++ b/scripts/precommit.sh @@ -0,0 +1,59 @@ +#!/bin/sh +# Local pre-commit checks — mirrors what CI runs in .github/workflows/ci.yml +# so failures show up before push, not 5 minutes into the PR build. +# +# Install with: +# ln -sf ../../scripts/precommit.sh .git/hooks/pre-commit +# +# Skip with: +# git commit --no-verify + +set -e + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +# Find changed Swift files vs HEAD (staged + unstaged) +CHANGED_SWIFT_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '^(Sources|Tests)/.*\.swift$' || true) + +if [ -z "$CHANGED_SWIFT_FILES" ]; then + echo "[pre-commit] no Swift changes — skipping" + exit 0 +fi + +FILE_COUNT=$(printf '%s\n' "$CHANGED_SWIFT_FILES" | wc -l | tr -d ' ') +echo "[pre-commit] checking $FILE_COUNT changed Swift files" + +# 1. swift build — fast incremental build catches compile errors +echo "[pre-commit] swift build..." +if ! swift build 2>&1 | grep -E "error:" >&2 ; then + : # no errors found +fi +if ! swift build > /dev/null 2>&1 ; then + echo "[pre-commit] swift build FAILED" >&2 + swift build 2>&1 | tail -20 >&2 + exit 1 +fi + +# 2. SwiftLint — same config as CI +if command -v swiftlint > /dev/null 2>&1 ; then + echo "[pre-commit] swiftlint..." + LINT_OUTPUT=$(printf '%s\n' "$CHANGED_SWIFT_FILES" | xargs swiftlint lint --config .swiftlint-ci.yml --quiet 2>&1) + if printf '%s\n' "$LINT_OUTPUT" | grep -q "error:" ; then + echo "[pre-commit] swiftlint FAILED (errors found)" >&2 + printf '%s\n' "$LINT_OUTPUT" | grep "error:" >&2 + exit 1 + fi +else + echo "[pre-commit] swiftlint not installed — skipping (install: brew install swiftlint)" >&2 +fi + +# 3. swift format lint — only fails on parser errors, not style warnings (matches CI) +if swift format lint --help > /dev/null 2>&1 ; then + echo "[pre-commit] swift format lint..." + if ! printf '%s\n' "$CHANGED_SWIFT_FILES" | xargs swift format lint > /dev/null 2>&1 ; then + : # warnings, not errors — CI tolerates these + fi +fi + +echo "[pre-commit] all checks passed"