diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 794ec4f..f7866a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - name: Run SwiftLint run: | brew install swiftlint - swiftlint lint --strict + swiftlint lint Sources/ - name: Run tests run: swift test diff --git a/.gitignore b/.gitignore index b289b8f..31439af 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,3 @@ DerivedData/ # Local skill/tool metadata skills-lock.json -.swiftlint.yml diff --git a/.go/progress.md b/.go/progress.md new file mode 100644 index 0000000..6a5ecc2 --- /dev/null +++ b/.go/progress.md @@ -0,0 +1,24 @@ +# Long Run — 2026-03-25 + +Started: 7:15 PM +Status: Working (batch 1/2) + +## Plan +- Batch 1: [High] Meeting recording view clipped +- Batch 2 (sequential): [Medium] Improve AI meeting notes + +## Completed +- [x] Meeting recording view clipped — merged to dev (removed clipShape, shaped background) +- [x] Fixed stale loadExpandedState() call in FileTreeItemView + +## In Progress +- [ ] Improve AI meeting notes — worker running (6 sub-features) + +## Remaining +(none) + +## Blocked / Skipped +- Google OAuth — requires external credentials + +## Build Status +Pending. diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..2158f77 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,83 @@ +# SwiftLint configuration for Bugbook +# Thresholds set to pass existing codebase. Tighten as code is refactored. + +excluded: + - .build + - build + - DerivedData + - Package.swift + +opt_in_rules: + - empty_count + - empty_string + - fatal_error_message + - first_where + - last_where + - contains_over_filter_count + - flatmap_over_map_reduce + - toggle_bool + - unavailable_function + - unowned_variable_capture + +disabled_rules: + - trailing_comma + - todo + - opening_brace + - multiple_closures_with_trailing_closure + - force_cast + - force_try + - shorthand_operator + +line_length: + warning: 150 + error: 300 + ignores_comments: true + ignores_urls: true + ignores_interpolated_strings: true + +function_body_length: + warning: 100 + error: 300 + +type_body_length: + warning: 500 + error: 2500 + +file_length: + warning: 1500 + error: 3000 + +cyclomatic_complexity: + warning: 15 + error: 50 + +function_parameter_count: + warning: 6 + error: 12 + +large_tuple: + warning: 4 + error: 6 + +nesting: + type_level: + warning: 3 + function_level: + warning: 5 + +identifier_name: + min_length: + warning: 2 + error: 1 + max_length: + warning: 60 + error: 80 + excluded: + - id + - x + - y + - i + - j + - db + - to + - op diff --git a/.swiftpm/xcode/package.xcworkspace/xcuserdata/maxforsey.xcuserdatad/UserInterfaceState.xcuserstate b/.swiftpm/xcode/package.xcworkspace/xcuserdata/maxforsey.xcuserdatad/UserInterfaceState.xcuserstate index 0d7b815..09391b5 100644 Binary files a/.swiftpm/xcode/package.xcworkspace/xcuserdata/maxforsey.xcuserdatad/UserInterfaceState.xcuserstate and b/.swiftpm/xcode/package.xcworkspace/xcuserdata/maxforsey.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Package.resolved b/Package.resolved index 10539b4..0f14f76 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/EventSource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "fluidaudio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/FluidInference/FluidAudio.git", + "state" : { + "revision" : "9830ce835881c0d0d40f90aabfaae3a6da5bebfb", + "version" : "0.12.4" + } + }, { "identity" : "sentry-cocoa", "kind" : "remoteSourceControl", @@ -27,6 +45,87 @@ "version" : "1.7.0" } }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "9f542610331815e29cc3821d3b6f488db8715517", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "fa308c07a6fa04a727212d793e761460e41049c3", + "version" : "4.3.0" + } + }, + { + "identity" : "swift-huggingface", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-huggingface.git", + "state" : { + "revision" : "b721959445b617d0bf03910b2b4aced345fd93bf", + "version" : "0.9.0" + } + }, + { + "identity" : "swift-jinja", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-jinja.git", + "state" : { + "revision" : "f731f03bf746481d4fda07f817c3774390c4d5b9", + "version" : "2.3.2" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "b31565862a8f39866af50bc6676160d8dda7de35", + "version" : "2.96.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-transformers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/huggingface/swift-transformers", + "state" : { + "revision" : "eed7264ac5e4ec5dfa6165c6e5c5577364344fe4", + "version" : "1.2.0" + } + }, { "identity" : "yams", "kind" : "remoteSourceControl", @@ -35,6 +134,15 @@ "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", "version" : "6.2.1" } + }, + { + "identity" : "yyjson", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ibireme/yyjson.git", + "state" : { + "revision" : "8b4a38dc994a110abaec8a400615567bd996105f", + "version" : "0.12.0" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 3a1a593..be7c15a 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"), .package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.40.0"), .package(url: "https://github.com/jpsim/Yams", from: "6.0.1"), + .package(url: "https://github.com/FluidInference/FluidAudio.git", from: "0.7.9"), ], targets: [ // Shared library — models, storage, engines @@ -55,6 +56,7 @@ let package = Package( "BugbookCore", .product(name: "Sparkle", package: "Sparkle"), .product(name: "Sentry", package: "sentry-cocoa"), + .product(name: "FluidAudio", package: "FluidAudio"), ], path: "Sources/Bugbook" ), @@ -77,7 +79,8 @@ let package = Package( "Bugbook", "BugbookCore", ], - path: "Tests/BugbookTests" + path: "Tests/BugbookTests", + exclude: ["perf_baseline.tsv"] ), .testTarget( name: "BugbookCLITests", diff --git a/Sources/Bugbook/App/AppState.swift b/Sources/Bugbook/App/AppState.swift index 005d89e..c16c758 100644 --- a/Sources/Bugbook/App/AppState.swift +++ b/Sources/Bugbook/App/AppState.swift @@ -31,9 +31,11 @@ enum ViewMode { var aiSidePanelOpen: Bool = false var aiInitialPrompt: String? var aiSelectionContext: String? + var aiReferencedItems: [AiContextItem] = [] var currentView: ViewMode = .editor var movePagePath: String? // non-nil triggers move page picker - var flashcardReviewOpen: Bool = false + + var isRecording: Bool = false var activeTab: OpenFile? { guard activeTabIndex >= 0, activeTabIndex < openTabs.count else { return nil } @@ -243,14 +245,12 @@ enum ViewMode { } let schemaPath = (path as NSString).appendingPathComponent("_schema.json") let isDatabase = FileManager.default.fileExists(atPath: schemaPath) - let canvasPath = (path as NSString).appendingPathComponent("_canvas.json") - let isCanvas = FileManager.default.fileExists(atPath: canvasPath) - let kind: TabKind = isDatabase ? .database : isCanvas ? .canvas : .page + let kind: TabKind = isDatabase ? .database : .page return FileEntry( id: path, name: (path as NSString).lastPathComponent, path: path, - isDirectory: isDatabase || isCanvas, + isDirectory: isDatabase, kind: kind ) } @@ -362,6 +362,30 @@ enum ViewMode { activeTabIndex = openTabs.count - 1 } + func openMeetings() { + showSettings = false + currentView = .editor + + // Open meetings as a tab (reuse existing if open) + let meetingsPath = "bugbook://meetings" + if let existingIndex = openTabs.firstIndex(where: { $0.isMeetings }) { + activeTabIndex = existingIndex + return + } + let tab = OpenFile( + id: UUID(), + path: meetingsPath, + content: "", + isDirty: false, + isEmptyTab: false, + kind: .meetings, + displayName: "Meetings", + icon: "person.2" + ) + openTabs.append(tab) + activeTabIndex = openTabs.count - 1 + } + func toggleAiPanel(prompt: String? = nil) { if aiSidePanelOpen { aiSidePanelOpen = false @@ -370,8 +394,11 @@ enum ViewMode { openAiPanel(prompt: prompt) } - func openAiPanel(prompt: String? = nil) { + func openAiPanel(prompt: String? = nil, referencedItems: [AiContextItem] = []) { aiInitialPrompt = prompt + if !referencedItems.isEmpty { + aiReferencedItems.append(contentsOf: referencedItems) + } showSettings = false if currentView == .chat { currentView = .editor diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift index 9ec5ced..f838ec9 100644 --- a/Sources/Bugbook/App/BugbookApp.swift +++ b/Sources/Bugbook/App/BugbookApp.swift @@ -101,12 +101,6 @@ struct BugbookApp: App { } .keyboardShortcut("l", modifiers: [.command, .shift]) - Button("Review Flashcards") { - NotificationCenter.default.post(name: .reviewFlashcards, object: nil) - } - .keyboardShortcut("f", modifiers: [.command, .shift]) - - Divider() Button("Zoom In") { NotificationCenter.default.post(name: .editorZoomIn, object: nil) @@ -285,6 +279,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func configureWindows() { for window in NSApplication.shared.windows { + guard !(window is NSPanel) else { continue } guard !window.titlebarAppearsTransparent else { continue } window.titlebarAppearsTransparent = true window.titleVisibility = .hidden @@ -314,14 +309,14 @@ extension Notification.Name { static let navigateForward = Notification.Name("navigateForward") static let openDailyNote = Notification.Name("openDailyNote") static let openGraphView = Notification.Name("openGraphView") - static let newCanvas = Notification.Name("newCanvas") static let editorZoomIn = Notification.Name("editorZoomIn") static let editorZoomOut = Notification.Name("editorZoomOut") static let editorZoomReset = Notification.Name("editorZoomReset") static let openCalendar = Notification.Name("openCalendar") + static let openMeetings = Notification.Name("openMeetings") static let fileDeleted = Notification.Name("fileDeleted") static let fileMoved = Notification.Name("fileMoved") static let movePage = Notification.Name("movePage") static let movePageToDir = Notification.Name("movePageToDir") - static let reviewFlashcards = Notification.Name("reviewFlashcards") + } diff --git a/Sources/Bugbook/Extensions/Color+Theme.swift b/Sources/Bugbook/Extensions/Color+Theme.swift index 889d90f..bd16df1 100644 --- a/Sources/Bugbook/Extensions/Color+Theme.swift +++ b/Sources/Bugbook/Extensions/Color+Theme.swift @@ -19,6 +19,9 @@ extension Color { static let borderColor = Color("borderColor", bundle: nil) static let dividerColor = Color("dividerColor", bundle: nil) + // MARK: - Drag & Drop + static let dragIndicator = Color(light: Color(hex: "B4D7FF"), dark: Color(hex: "B4D7FF").opacity(0.7)) + // MARK: - Surfaces static let sidebarBg = Color("sidebarBg", bundle: nil) static let editorBg = Color("editorBg", bundle: nil) @@ -43,9 +46,11 @@ extension Color { static let fallbackTextSecondary = Color(light: Color(hex: "6b6b6b"), dark: Color(hex: "9b9b9b")) static let fallbackTextMuted = Color(light: Color(hex: "9b9b9b"), dark: Color(hex: "373737")) - // Accent - static let fallbackAccent = Color(light: Color(hex: "2383e2"), dark: Color(hex: "528bcc")) - static let fallbackAccentLight = Color(light: Color(hex: "dbeafe"), dark: Color(hex: "1e3a5f")) + // Accent — neutral charcoal (light) / soft gray (dark) + static let fallbackAccent = Color(light: Color(hex: "2d2d2d"), dark: Color(hex: "b0b0b0")) + static let fallbackAccentLight = Color(light: Color(hex: "e8e8e8"), dark: Color(hex: "3a3a3a")) + // Text on accent fill — white on charcoal (light), dark on gray (dark) + static let fallbackAccentFg = Color(light: .white, dark: Color(hex: "1f1f1f")) // Borders & dividers static let fallbackBorderColor = Color(light: Color(hex: "e8e8e5"), dark: Color(hex: "2e2e2e")) @@ -63,6 +68,9 @@ extension Color { static let fallbackSurfaceHover = Color(light: Color(hex: "0000000A"), dark: Color(hex: "ffffff0A")) static let fallbackSurfaceSubtle = Color(light: Color(hex: "00000008"), dark: Color(hex: "ffffff08")) static let fallbackBadgeBg = Color(light: Color(hex: "0000001A"), dark: Color(hex: "ffffff1A")) + + // Selection / highlight + static let selectionHighlight = Color(light: Color(hex: "B4D7FF").opacity(0.45), dark: Color(hex: "B4D7FF").opacity(0.2)) } // MARK: - Helpers diff --git a/Sources/Bugbook/Extensions/DesignTokens.swift b/Sources/Bugbook/Extensions/DesignTokens.swift index 4ca860d..b5b8948 100644 --- a/Sources/Bugbook/Extensions/DesignTokens.swift +++ b/Sources/Bugbook/Extensions/DesignTokens.swift @@ -82,7 +82,7 @@ enum StatusColor { static let neutral = Color(light: Color(hex: "787774"), dark: Color(hex: "979a9b")) /// Queued, ready, informational - static let info = Color(light: Color(hex: "2383e2"), dark: Color(hex: "528bcc")) + static let info = Color(light: Color(hex: "787774"), dark: Color(hex: "979a9b")) /// Active, in progress, running static let active = Color(light: Color(hex: "d9730d"), dark: Color(hex: "e8993f")) @@ -124,18 +124,6 @@ enum Elevation { static let shadowOpacity: Double = 0.12 } -// MARK: - Brand -// -// The ladybug. Warm red-orange for identity moments — logo tints, empty states, -// onboarding, highlights. Not the primary action color (that stays blue/accent). - -enum Brand { - /// Ladybug red — the signature color - static let primary = Color(light: Color(hex: "e8453c"), dark: Color(hex: "ef6b64")) - - /// Soft ladybug tint for backgrounds - static let subtle = Color(light: Color(hex: "fef2f1"), dark: Color(hex: "3a2424")) -} // MARK: - Tag/Label Colors // diff --git a/Sources/Bugbook/Extensions/FloatingPopover.swift b/Sources/Bugbook/Extensions/FloatingPopover.swift index 54a5908..45a859a 100644 --- a/Sources/Bugbook/Extensions/FloatingPopover.swift +++ b/Sources/Bugbook/Extensions/FloatingPopover.swift @@ -16,6 +16,7 @@ extension View { func floatingPopover( isPresented: Binding, arrowEdge: Edge = .top, + becomesKey: Bool = false, onDelete: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> Content ) -> some View { @@ -24,6 +25,7 @@ extension View { FloatingPopoverAnchor( isPresented: isPresented, arrowEdge: arrowEdge, + becomesKey: becomesKey, onDelete: onDelete, content: content ) @@ -67,6 +69,7 @@ private class PopoverPanel: NSPanel { private struct FloatingPopoverAnchor: NSViewRepresentable { @Binding var isPresented: Bool let arrowEdge: Edge + let becomesKey: Bool var onDelete: (() -> Void)? let content: () -> PopoverContent @@ -74,14 +77,33 @@ private struct FloatingPopoverAnchor: NSViewRepresentable func updateNSView(_ nsView: NSView, context: Context) { if isPresented { - if context.coordinator.panel == nil { + if !context.coordinator.isVisible { + // Panel hidden or never created — show() handles both reuse and first-time creation. + // When the anchor is conditionally rendered (e.g. `if isTarget { ... }`), + // the NSView may not be in the window hierarchy on the first updateNSView call. + // Retry on the next run loop iteration if show() fails due to missing window. context.coordinator.show( anchor: nsView, arrowEdge: arrowEdge, + becomesKey: becomesKey, content: content(), onDelete: onDelete, dismiss: { isPresented = false } ) + if !context.coordinator.isVisible { + // Anchor wasn't in window yet — retry once after layout completes + DispatchQueue.main.async { [weak nsView] in + guard let nsView, self.isPresented, !context.coordinator.isVisible else { return } + context.coordinator.show( + anchor: nsView, + arrowEdge: self.arrowEdge, + becomesKey: self.becomesKey, + content: self.content(), + onDelete: self.onDelete, + dismiss: { self.isPresented = false } + ) + } + } } else { context.coordinator.updateContent(content(), dismiss: { isPresented = false }) } @@ -100,15 +122,43 @@ private struct FloatingPopoverAnchor: NSViewRepresentable var resignObserver: NSObjectProtocol? /// Always kept in sync with the current SwiftUI binding so it never goes stale. var dismissClosure: (() -> Void)? + /// Whether the panel is currently visible (ordered in). + private(set) var isVisible = false - deinit { cleanup() } + deinit { destroyPanel() } - func show(anchor: NSView, arrowEdge: Edge = .top, content: some View, onDelete: (() -> Void)? = nil, dismiss: @escaping () -> Void) { - if panel != nil { cleanup() } + func show(anchor: NSView, arrowEdge: Edge = .top, becomesKey: Bool = false, content: some View, onDelete: (() -> Void)? = nil, dismiss: @escaping () -> Void) { guard let window = anchor.window else { return } dismissClosure = dismiss let wrapped = AnyView(content.environment(\.popoverDismiss, dismiss)) + + // Reuse existing panel + hosting view when possible (avoids expensive + // NSHostingView creation + fittingSize on repeated show/dismiss cycles). + if let panel, let hosting = hostingView { + hosting.rootView = wrapped + let size = hosting.fittingSize + guard size.width > 0, size.height > 0 else { return } + hosting.setFrameSize(size) + + let origin = Self.computeOrigin(anchor: anchor, window: window, arrowEdge: arrowEdge, size: size) + panel.setFrame(NSRect(origin: origin, size: size), display: true) + + if !isVisible { + window.addChildWindow(panel, ordered: .above) + if becomesKey { + panel.makeKeyAndOrderFront(nil) + } else { + panel.orderFront(nil) + } + PopoverPanel.activePanels.add(panel) + isVisible = true + installEventMonitors(panel: panel, onDelete: onDelete) + } + return + } + + // First-time creation — build the NSPanel + NSHostingView from scratch. let hosting = NSHostingView(rootView: wrapped) hosting.setFrameSize(hosting.fittingSize) let size = hosting.fittingSize @@ -128,28 +178,103 @@ private struct FloatingPopoverAnchor: NSViewRepresentable p.isMovableByWindowBackground = false p.isMovable = false - // Position below the anchor by default, or to the left for .leading + let origin = Self.computeOrigin(anchor: anchor, window: window, arrowEdge: arrowEdge, size: size) + p.setFrameOrigin(origin) + p.hidesOnDeactivate = true + window.addChildWindow(p, ordered: .above) + if becomesKey { + p.makeKeyAndOrderFront(nil) + } else { + p.orderFront(nil) + } + PopoverPanel.activePanels.add(p) + self.panel = p + self.hostingView = hosting + self.isVisible = true + + installEventMonitors(panel: p, onDelete: onDelete) + } + + func updateContent(_ content: some View, dismiss: @escaping () -> Void) { + dismissClosure = dismiss + hostingView?.rootView = AnyView(content.environment(\.popoverDismiss, dismiss)) + + // Resize the panel to fit updated content (e.g. submenus appearing/disappearing). + if let hosting = hostingView, let panel { + let newSize = hosting.fittingSize + if newSize != panel.frame.size, newSize.width > 0, newSize.height > 0 { + var frame = panel.frame + // Keep the top-left corner anchored (macOS coordinates: pin maxY). + let topY = frame.maxY + frame.size = newSize + frame.origin.y = topY - newSize.height + + // Clamp to the visible screen area. + if let screen = panel.screen ?? NSScreen.main { + let vis = screen.visibleFrame + frame.origin.x = max(vis.minX + 4, min(frame.origin.x, vis.maxX - frame.width - 4)) + frame.origin.y = max(vis.minY + 4, min(frame.origin.y, vis.maxY - frame.height - 4)) + } + + panel.setFrame(frame, display: true) + hosting.setFrameSize(newSize) + } + } + } + + /// Sets binding to false AND hides the panel immediately. + func dismissAndCleanup() { + dismissClosure?() + hide() + } + + func dismiss() { hide() } + + /// Hide the panel without destroying it — allows fast reuse on next show(). + private func hide() { + if let p = panel { + PopoverPanel.activePanels.remove(p) + p.parent?.removeChildWindow(p) + p.orderOut(nil) + } + isVisible = false + removeEventMonitors() + } + + /// Fully tear down the panel (used only on deinit). + private func destroyPanel() { + if let p = panel { + PopoverPanel.activePanels.remove(p) + p.parent?.removeChildWindow(p) + } + panel?.close() + panel = nil + hostingView = nil + isVisible = false + removeEventMonitors() + } + + // MARK: - Positioning + + private static func computeOrigin(anchor: NSView, window: NSWindow, arrowEdge: Edge, size: CGSize) -> NSPoint { let anchorBounds = anchor.convert(anchor.bounds, to: nil) let screenRect = window.convertToScreen(anchorBounds) let gap: Double = 4 var origin: NSPoint if arrowEdge == .leading { - // To the left of the anchor, vertically centered let anchorMidY = screenRect.midY origin = NSPoint( x: screenRect.minX - size.width - gap, y: anchorMidY - size.height / 2 ) } else if arrowEdge == .trailing { - // To the right of the anchor, vertically centered let anchorMidY = screenRect.midY origin = NSPoint( x: screenRect.maxX + gap, y: anchorMidY - size.height / 2 ) } else { - // Below the anchor, left-aligned origin = NSPoint( x: screenRect.minX, y: screenRect.minY - size.height - gap @@ -163,11 +288,13 @@ private struct FloatingPopoverAnchor: NSViewRepresentable origin.y = max(vis.minY + 4, min(origin.y, vis.maxY - size.height - 4)) } - p.setFrameOrigin(origin) - p.orderFront(nil) - PopoverPanel.activePanels.add(p) - self.panel = p - self.hostingView = hosting + return origin + } + + // MARK: - Event Monitors + + private func installEventMonitors(panel: PopoverPanel, onDelete: (() -> Void)?) { + removeEventMonitors() // Dismiss on click outside, Escape, or Backspace (local) localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .keyDown]) { [weak self] event in @@ -199,10 +326,10 @@ private struct FloatingPopoverAnchor: NSViewRepresentable // Dismiss when panel loses key — but only if focus went to a non-popover window resignObserver = NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, - object: p, + object: panel, queue: .main ) { [weak self] _ in - guard self?.panel != nil else { return } + guard self?.isVisible == true else { return } if let keyWindow = NSApp.keyWindow as? PopoverPanel, PopoverPanel.activePanels.contains(keyWindow) { return @@ -211,46 +338,7 @@ private struct FloatingPopoverAnchor: NSViewRepresentable } } - func updateContent(_ content: some View, dismiss: @escaping () -> Void) { - dismissClosure = dismiss - hostingView?.rootView = AnyView(content.environment(\.popoverDismiss, dismiss)) - - // Resize the panel to fit updated content (e.g. submenus appearing/disappearing). - if let hosting = hostingView, let panel { - let newSize = hosting.fittingSize - if newSize != panel.frame.size, newSize.width > 0, newSize.height > 0 { - var frame = panel.frame - // Keep the top-left corner anchored (macOS coordinates: pin maxY). - let topY = frame.maxY - frame.size = newSize - frame.origin.y = topY - newSize.height - - // Clamp to the visible screen area. - if let screen = panel.screen ?? NSScreen.main { - let vis = screen.visibleFrame - frame.origin.x = max(vis.minX + 4, min(frame.origin.x, vis.maxX - frame.width - 4)) - frame.origin.y = max(vis.minY + 4, min(frame.origin.y, vis.maxY - frame.height - 4)) - } - - panel.setFrame(frame, display: true) - hosting.setFrameSize(newSize) - } - } - } - - /// Sets binding to false AND removes the panel immediately. - func dismissAndCleanup() { - dismissClosure?() - cleanup() - } - - func dismiss() { cleanup() } - - private func cleanup() { - if let p = panel { PopoverPanel.activePanels.remove(p) } - panel?.close() - panel = nil - hostingView = nil + private func removeEventMonitors() { if let m = localMonitor { NSEvent.removeMonitor(m); localMonitor = nil } if let m = globalMonitor { NSEvent.removeMonitor(m); globalMonitor = nil } if let o = resignObserver { NotificationCenter.default.removeObserver(o); resignObserver = nil } diff --git a/Sources/Bugbook/Lib/AttributedStringConverter.swift b/Sources/Bugbook/Lib/AttributedStringConverter.swift index be0e8b2..d080d58 100644 --- a/Sources/Bugbook/Lib/AttributedStringConverter.swift +++ b/Sources/Bugbook/Lib/AttributedStringConverter.swift @@ -104,6 +104,18 @@ enum AttributedStringConverter { continue } + // Double-equals separator: " == " → arrow indicator + if let end = parseDoubleEqualsSeparator(markdown, from: i) { + var attrs = baseAttributes + attrs[.foregroundColor] = NSColor.secondaryLabelColor + attrs[Self.markdownSourceKey] = " == " + let arrowFont = NSFont.systemFont(ofSize: font.pointSize * 0.85, weight: .medium) + attrs[.font] = arrowFont + result.append(NSAttributedString(string: " ⇌ ", attributes: attrs)) + i = end + continue + } + // Plain character result.append(NSAttributedString(string: String(markdown[i]), attributes: baseAttributes)) i = markdown.index(after: i) @@ -222,6 +234,20 @@ enum AttributedStringConverter { return nil } + /// Parse double-equals separator: " == " (with spaces on both sides) + private static func parseDoubleEqualsSeparator( + _ str: String, + from start: String.Index + ) -> String.Index? { + let separator = " == " + guard str[start...].hasPrefix(separator) else { return nil } + // Ensure there's content before and after the separator + guard start > str.startIndex else { return nil } + let end = str.index(start, offsetBy: separator.count) + guard end < str.endIndex else { return nil } + return end + } + /// Parse markdown link: [text](url) private static func parseLink( _ str: String, diff --git a/Sources/Bugbook/Lib/MarkdownBlockParser.swift b/Sources/Bugbook/Lib/MarkdownBlockParser.swift index 20fbcdc..dc97652 100644 --- a/Sources/Bugbook/Lib/MarkdownBlockParser.swift +++ b/Sources/Bugbook/Lib/MarkdownBlockParser.swift @@ -15,7 +15,7 @@ enum MarkdownBlockParser { /// Returns the metadata and the remaining markdown content after metadata lines. static func parseMetadata(_ markdown: String) -> (Metadata, String) { var metadata = Metadata() - let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false) var contentStartIndex = 0 for line in lines { @@ -57,8 +57,7 @@ enum MarkdownBlockParser { break } - let remainingLines = Array(lines.dropFirst(contentStartIndex)) - let remaining = remainingLines.joined(separator: "\n") + let remaining = lines.dropFirst(contentStartIndex).joined(separator: "\n") return (metadata, remaining) } @@ -85,7 +84,7 @@ enum MarkdownBlockParser { return [Block(type: .paragraph)] } - var lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var lines = markdown.split(separator: "\n", omittingEmptySubsequences: false) if lines.count > 1, lines.last == "" { lines.removeLast() } @@ -136,7 +135,7 @@ enum MarkdownBlockParser { } while i < lines.count { - let line = lines[i] + let line = String(lines[i]) let trimmed = line.trimmingCharacters(in: .whitespaces) if let blockID = parseBlockIDComment(line) { @@ -154,7 +153,7 @@ enum MarkdownBlockParser { // Code fence if line.hasPrefix("```") { let language = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) - var codeLines: [String] = [] + var codeLines: [Substring] = [] i += 1 while i < lines.count { if lines[i].hasPrefix("```") { @@ -253,10 +252,10 @@ enum MarkdownBlockParser { let collapsed = trimmed.contains("collapsed") i += 1 // First line is the toggle title - let title = i < lines.count ? lines[i] : "" + let title = i < lines.count ? String(lines[i]) : "" i += 1 // Remaining lines until are children - var childLines: [String] = [] + var childLines: [Substring] = [] while i < lines.count { if lines[i].trimmingCharacters(in: .whitespaces) == "" { i += 1 @@ -270,11 +269,31 @@ enum MarkdownBlockParser { continue } + // Heading toggle block + if let headingToggleLevel = parseHeadingToggleComment(trimmed) { + let collapsed = trimmed.contains("collapsed") + i += 1 + let title = i < lines.count ? lines[i] : "" + i += 1 + var childLines: [String] = [] + while i < lines.count { + if lines[i].trimmingCharacters(in: .whitespaces) == "" { + i += 1 + break + } + childLines.append(String(lines[i])) + i += 1 + } + let children = childLines.isEmpty ? [] : parse(childLines.joined(separator: "\n")) + blocks.append(makeBlock(type: .headingToggle, text: String(title), headingLevel: headingToggleLevel, children: children, isExpanded: !collapsed)) + continue + } + // Column block if trimmed == "" { var allChildren: [Block] = [] var currentColumnIndex = 0 - var currentColumnLines: [String] = [] + var currentColumnLines: [Substring] = [] i += 1 while i < lines.count { let colLine = lines[i] @@ -313,6 +332,58 @@ enum MarkdownBlockParser { continue } + // Meeting block + if trimmed == "" { + i += 1 + var title = "" + var transcript = "" + var summary = "" + var actionItems = "" + var notes = "" + var section = "" + while i < lines.count { + let mLine = lines[i].trimmingCharacters(in: .whitespaces) + if mLine == "" { + i += 1 + break + } + if mLine.hasPrefix("") { + title = String(mLine.dropFirst(19).dropLast(3)).trimmingCharacters(in: .whitespaces) + } else if mLine == "" { + section = "summary" + } else if mLine == "" { + section = "actions" + } else if mLine == "" { + section = "transcript" + } else if mLine == "" { + section = "notes" + } else { + switch section { + case "summary": + summary += (summary.isEmpty ? "" : "\n") + lines[i] + case "actions": + actionItems += (actionItems.isEmpty ? "" : "\n") + lines[i] + case "transcript": + transcript += (transcript.isEmpty ? "" : "\n") + lines[i] + case "notes": + notes += (notes.isEmpty ? "" : "\n") + lines[i] + default: + break + } + } + i += 1 + } + var meetingBlock = makeBlock(type: .meeting) + meetingBlock.meetingTitle = title + meetingBlock.meetingTranscript = transcript + meetingBlock.meetingSummary = summary + meetingBlock.meetingActionItems = actionItems + meetingBlock.meetingNotes = notes + meetingBlock.meetingState = .complete + blocks.append(meetingBlock) + continue + } + // Paragraph (including empty lines) blocks.append(makeBlock(type: .paragraph, text: unescapeParagraphText(line))) i += 1 @@ -337,7 +408,7 @@ enum MarkdownBlockParser { // Emit color comment before blocks that have non-default colors let hasColor = block.textColor != .default || block.backgroundColor != .default - if hasColor, block.type != .column, block.type != .toggle { + if hasColor, block.type != .column, block.type != .toggle, block.type != .headingToggle { var parts: [String] = [] if block.textColor != .default { parts.append("color:\(block.textColor.rawValue)") @@ -402,6 +473,16 @@ enum MarkdownBlockParser { } lines.append("") + case .headingToggle: + let level = max(1, min(3, block.headingLevel)) + let collapsed = block.isExpanded ? "" : " collapsed" + lines.append("") + lines.append(block.text) + if !block.children.isEmpty { + lines.append(serialize(block.children, includeBlockIDComments: includeBlockIDComments)) + } + lines.append("") + case .column: lines.append("") let maxCol = block.children.map(\.columnIndex).max() ?? 0 @@ -415,6 +496,31 @@ enum MarkdownBlockParser { } } lines.append("") + + case .meeting: + // Only serialize completed meetings; recording/processing blocks are transient + guard block.meetingState == .complete else { break } + lines.append("") + if !block.meetingTitle.isEmpty { + lines.append("") + } + if !block.meetingSummary.isEmpty { + lines.append("") + lines.append(block.meetingSummary) + } + if !block.meetingActionItems.isEmpty { + lines.append("") + lines.append(block.meetingActionItems) + } + if !block.meetingNotes.isEmpty { + lines.append("") + lines.append(block.meetingNotes) + } + if !block.meetingTranscript.isEmpty { + lines.append("") + lines.append(block.meetingTranscript) + } + lines.append("") } } @@ -473,12 +579,23 @@ enum MarkdownBlockParser { if line.hasPrefix(">") || parseImage(line) != nil || parseDatabaseEmbed(line) != nil || parseWikiLink(line) != nil || parsePageLinkComment(line) != nil { return true } - return trimmed == "" + if trimmed == "" || trimmed == "" || trimmed == "" + || trimmed == "" || trimmed == "" || trimmed == "" || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" { + return true + } + if parseHeadingToggleComment(trimmed) != nil { return true } + return false } private static func isHorizontalRule(_ line: String) -> Bool { @@ -634,6 +751,18 @@ enum MarkdownBlockParser { return name.isEmpty ? nil : name } + private static func parseHeadingToggleComment(_ trimmed: String) -> Int? { + guard trimmed.hasPrefix("") else { return nil } + let inner = trimmed.dropFirst(4).dropLast(3).trimmingCharacters(in: .whitespaces) + // inner is like "toggle-heading 2" or "toggle-heading 2 collapsed" + guard inner.hasPrefix("toggle-heading") else { return nil } + let rest = inner.dropFirst("toggle-heading".count).trimmingCharacters(in: .whitespaces) + // rest is like "2" or "2 collapsed" + let parts = rest.split(separator: " ", maxSplits: 1) + guard let levelStr = parts.first, let level = Int(levelStr), level >= 1, level <= 3 else { return nil } + return level + } + private static func parsePageLinkComment(_ line: String) -> String? { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("") else { return nil } diff --git a/Sources/Bugbook/Models/AiContextItem.swift b/Sources/Bugbook/Models/AiContextItem.swift new file mode 100644 index 0000000..3d349fc --- /dev/null +++ b/Sources/Bugbook/Models/AiContextItem.swift @@ -0,0 +1,63 @@ +import Foundation + +/// A referenced item (block or page) attached as context to an AI sidebar prompt. +enum AiContextItem: Identifiable, Equatable { + case block(id: UUID, preview: String, markdown: String) + case page(path: String, name: String) + + var id: String { + switch self { + case .block(let id, _, _): return "block-\(id.uuidString)" + case .page(let path, _): return "page-\(path)" + } + } + + var displayLabel: String { + switch self { + case .block(_, let preview, _): + let trimmed = preview.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count > 30 { + return String(trimmed.prefix(30)) + "..." + } + return trimmed.isEmpty ? "Block" : trimmed + case .page(_, let name): + return cleanPageName(name) + } + } + + var iconName: String { + switch self { + case .block: return "text.quote" + case .page: return "doc.text" + } + } + + /// Heading label used when building AI prompt context (not truncated). + var contextHeading: String { + switch self { + case .block(_, let preview, _): + let trimmed = preview.trimmingCharacters(in: .whitespacesAndNewlines) + return "Referenced block (\(trimmed.isEmpty ? "untitled" : trimmed))" + case .page(_, let name): + return "Referenced page \"\(cleanPageName(name))\"" + } + } + + private func cleanPageName(_ name: String) -> String { + name.hasSuffix(".md") ? String(name.dropLast(3)) : name + } + + var contextMarkdown: String { + switch self { + case .block(_, _, let markdown): return markdown + case .page(let path, _): + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return "[Could not read page content]" + } + let maxChars = 8_000 + let snippet = String(content.prefix(maxChars)) + let truncated = content.count > maxChars ? "\n...[truncated]" : "" + return snippet + truncated + } + } +} diff --git a/Sources/Bugbook/Models/AppSettings.swift b/Sources/Bugbook/Models/AppSettings.swift index 6e1a038..dd59b4b 100644 --- a/Sources/Bugbook/Models/AppSettings.swift +++ b/Sources/Bugbook/Models/AppSettings.swift @@ -13,6 +13,18 @@ enum PreferredAIEngine: String, Codable, CaseIterable { case claudeAPI = "API Key" } +enum AnthropicModel: String, Codable, CaseIterable { + case haiku = "claude-haiku-4-5-20251001" + case sonnet = "claude-sonnet-4-20250514" + + var displayName: String { + switch self { + case .haiku: return "Haiku (fast)" + case .sonnet: return "Sonnet (quality)" + } + } +} + enum ExecutionPolicy: String, Codable, CaseIterable { case ask = "Ask Before Running" case autoApprove = "Auto-Approve" @@ -28,15 +40,16 @@ struct AppSettings: Codable { var agentsMdContent: String var qmdSearchMode: QmdSearchMode var anthropicApiKey: String + var anthropicModel: AnthropicModel /// Path to the page opened for new/empty tabs. Empty string = default Bugbook landing page. var defaultNewTabPage: String // Google Calendar - var googleCalendarClientId: String - var googleCalendarClientSecret: String var googleCalendarRefreshToken: String var googleCalendarAccessToken: String var googleCalendarTokenExpiry: Double + var googleCalendarConnectedEmail: String + var googleCalendarBannerDismissed: Bool static let `default` = AppSettings( theme: .system, @@ -47,12 +60,13 @@ struct AppSettings: Codable { agentsMdContent: "", qmdSearchMode: .bm25, anthropicApiKey: "", + anthropicModel: .sonnet, defaultNewTabPage: "", - googleCalendarClientId: "", - googleCalendarClientSecret: "", googleCalendarRefreshToken: "", googleCalendarAccessToken: "", - googleCalendarTokenExpiry: 0 + googleCalendarTokenExpiry: 0, + googleCalendarConnectedEmail: "", + googleCalendarBannerDismissed: false ) // Backward-compatible decoding — new fields default gracefully @@ -66,12 +80,13 @@ struct AppSettings: Codable { agentsMdContent = try container.decodeIfPresent(String.self, forKey: .agentsMdContent) ?? "" qmdSearchMode = try container.decodeIfPresent(QmdSearchMode.self, forKey: .qmdSearchMode) ?? .bm25 anthropicApiKey = try container.decodeIfPresent(String.self, forKey: .anthropicApiKey) ?? "" + anthropicModel = try container.decodeIfPresent(AnthropicModel.self, forKey: .anthropicModel) ?? .sonnet defaultNewTabPage = try container.decodeIfPresent(String.self, forKey: .defaultNewTabPage) ?? "" - googleCalendarClientId = try container.decodeIfPresent(String.self, forKey: .googleCalendarClientId) ?? "" - googleCalendarClientSecret = try container.decodeIfPresent(String.self, forKey: .googleCalendarClientSecret) ?? "" googleCalendarRefreshToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarRefreshToken) ?? "" googleCalendarAccessToken = try container.decodeIfPresent(String.self, forKey: .googleCalendarAccessToken) ?? "" googleCalendarTokenExpiry = try container.decodeIfPresent(Double.self, forKey: .googleCalendarTokenExpiry) ?? 0 + googleCalendarConnectedEmail = try container.decodeIfPresent(String.self, forKey: .googleCalendarConnectedEmail) ?? "" + googleCalendarBannerDismissed = try container.decodeIfPresent(Bool.self, forKey: .googleCalendarBannerDismissed) ?? false } init( @@ -83,12 +98,13 @@ struct AppSettings: Codable { agentsMdContent: String, qmdSearchMode: QmdSearchMode, anthropicApiKey: String, + anthropicModel: AnthropicModel = .sonnet, defaultNewTabPage: String, - googleCalendarClientId: String = "", - googleCalendarClientSecret: String = "", googleCalendarRefreshToken: String = "", googleCalendarAccessToken: String = "", - googleCalendarTokenExpiry: Double = 0 + googleCalendarTokenExpiry: Double = 0, + googleCalendarConnectedEmail: String = "", + googleCalendarBannerDismissed: Bool = false ) { self.theme = theme self.focusModeOnType = focusModeOnType @@ -98,11 +114,12 @@ struct AppSettings: Codable { self.agentsMdContent = agentsMdContent self.qmdSearchMode = qmdSearchMode self.anthropicApiKey = anthropicApiKey + self.anthropicModel = anthropicModel self.defaultNewTabPage = defaultNewTabPage - self.googleCalendarClientId = googleCalendarClientId - self.googleCalendarClientSecret = googleCalendarClientSecret self.googleCalendarRefreshToken = googleCalendarRefreshToken self.googleCalendarAccessToken = googleCalendarAccessToken self.googleCalendarTokenExpiry = googleCalendarTokenExpiry + self.googleCalendarConnectedEmail = googleCalendarConnectedEmail + self.googleCalendarBannerDismissed = googleCalendarBannerDismissed } } diff --git a/Sources/Bugbook/Models/Block.swift b/Sources/Bugbook/Models/Block.swift index a0c9911..a0ceba4 100644 --- a/Sources/Bugbook/Models/Block.swift +++ b/Sources/Bugbook/Models/Block.swift @@ -14,6 +14,16 @@ enum BlockType: Equatable { case pageLink case column case toggle + case headingToggle + case meeting +} + +/// The lifecycle state of a meeting recording block. +enum MeetingBlockState: Equatable { + case ready + case recording + case processing + case complete } struct Block: Identifiable, Equatable { @@ -35,6 +45,15 @@ struct Block: Identifiable, Equatable { var columnIndex: Int // which column this belongs to (only meaningful inside .column parent) var isExpanded: Bool + // Meeting block properties + var meetingState: MeetingBlockState + var meetingTranscript: String + var meetingSummary: String + var meetingActionItems: String + var meetingTitle: String + var meetingNotes: String + var transcriptEntries: [String] = [] + init( id: UUID = UUID(), type: BlockType = .paragraph, @@ -52,7 +71,13 @@ struct Block: Identifiable, Equatable { backgroundColor: BlockColor = .default, children: [Block] = [], columnIndex: Int = 0, - isExpanded: Bool = true + isExpanded: Bool = true, + meetingState: MeetingBlockState = .complete, + meetingTranscript: String = "", + meetingSummary: String = "", + meetingActionItems: String = "", + meetingTitle: String = "", + meetingNotes: String = "" ) { self.id = id self.type = type @@ -71,5 +96,11 @@ struct Block: Identifiable, Equatable { self.children = children self.columnIndex = columnIndex self.isExpanded = isExpanded + self.meetingState = meetingState + self.meetingTranscript = meetingTranscript + self.meetingSummary = meetingSummary + self.meetingActionItems = meetingActionItems + self.meetingTitle = meetingTitle + self.meetingNotes = meetingNotes } } diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index 2e7a563..c8857f4 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -60,6 +60,7 @@ class BlockDocument { } @ObservationIgnored var onCreateDatabase: ((String) -> String?)? + @ObservationIgnored var onCreateMeetingDatabase: (() -> String?)? @ObservationIgnored var onCreateSubPage: ((String) -> String?)? @ObservationIgnored var onDeleteSubPage: ((String) -> Void)? @ObservationIgnored var onNavigateToPage: ((String) -> Void)? @@ -67,6 +68,14 @@ class BlockDocument { @ObservationIgnored var onSubmitAiPrompt: ((String) -> Void)? @ObservationIgnored var onCancelAiPrompt: (() -> Void)? @ObservationIgnored var onMoveBlock: ((UUID, String) -> Void)? + /// Called when a page is dropped from the sidebar into the editor. + /// Parameters: (sourcePath, insertionIndex). Should move the file and refresh tree. + @ObservationIgnored var onDropPageFromSidebar: ((String, Int) -> Void)? + var meetingAudioLevel: Float = 0 + var meetingVolatileText: String = "" + @ObservationIgnored var onStartMeeting: ((UUID) -> Void)? + @ObservationIgnored var onStopMeeting: ((UUID) -> Void)? + @ObservationIgnored var transcriptionService: TranscriptionService? @ObservationIgnored var availablePages: [FileEntry] = [] @ObservationIgnored var filePath: String? @ObservationIgnored var workspacePath: String? @@ -140,6 +149,22 @@ class BlockDocument { } } + func updateMeetingSummary(blockId: UUID, summary: String) { + updateBlockProperty(id: blockId) { $0.language = summary } + } + + func updateMeetingTitle(blockId: UUID, title: String) { + updateBlockProperty(id: blockId) { $0.meetingTitle = title } + } + + func updateMeetingNotes(blockId: UUID, notes: String) { + updateBlockProperty(id: blockId) { $0.meetingNotes = notes } + } + + func updateMeetingState(blockId: UUID, state: MeetingBlockState) { + updateBlockProperty(id: blockId) { $0.meetingState = state } + } + /// Safely update a block's properties whether it's top-level or inside a column. func updateBlockProperty(id: UUID, _ mutate: (inout Block) -> Void) { guard let loc = blockLocation(for: id) else { return } @@ -749,37 +774,50 @@ class BlockDocument { case template case imagePicker case askAI + case meetingNotes + case meeting } struct SlashCommand { let name: String let icon: String let action: SlashCommandAction + let section: String + var keywords: [String] = [] + + func matches(_ query: String) -> Bool { + if name.localizedStandardContains(query) { return true } + return keywords.contains { $0.localizedStandardContains(query) } + } } static let slashCommands: [SlashCommand] = [ - SlashCommand(name: "Text", icon: "text.alignleft", action: .blockType(.paragraph, headingLevel: 0)), - SlashCommand(name: "Heading 1", icon: "textformat.size.larger", action: .blockType(.heading, headingLevel: 1)), - SlashCommand(name: "Heading 2", icon: "textformat.size", action: .blockType(.heading, headingLevel: 2)), - SlashCommand(name: "Heading 3", icon: "textformat.size.smaller", action: .blockType(.heading, headingLevel: 3)), - SlashCommand(name: "Bullet List", icon: "list.bullet", action: .blockType(.bulletListItem, headingLevel: 0)), - SlashCommand(name: "Numbered List", icon: "list.number", action: .blockType(.numberedListItem, headingLevel: 0)), - SlashCommand(name: "To-do", icon: "checkmark.square", action: .blockType(.taskItem, headingLevel: 0)), - SlashCommand(name: "Quote", icon: "text.quote", action: .blockType(.blockquote, headingLevel: 0)), - SlashCommand(name: "Code", icon: "chevron.left.forwardslash.chevron.right", action: .blockType(.codeBlock, headingLevel: 0)), - SlashCommand(name: "Divider", icon: "minus", action: .blockType(.horizontalRule, headingLevel: 0)), - SlashCommand(name: "Toggle", icon: "chevron.right", action: .blockType(.toggle, headingLevel: 0)), - SlashCommand(name: "Page", icon: "doc.text", action: .createPage), - SlashCommand(name: "Link to Page", icon: "link", action: .linkToPage), - SlashCommand(name: "Image", icon: "photo", action: .imagePicker), - SlashCommand(name: "Database", icon: "tablecells", action: .blockType(.databaseEmbed, headingLevel: 0)), - SlashCommand(name: "Template", icon: "doc.on.doc", action: .template), - SlashCommand(name: "Ask AI", icon: "ladybug", action: .askAI), + // Suggested + SlashCommand(name: "Ask AI", icon: "ladybug", action: .askAI, section: "Suggested", keywords: ["ai", "chat", "generate", "write"]), + SlashCommand(name: "Image", icon: "photo", action: .imagePicker, section: "Suggested", keywords: ["photo", "picture", "media", "upload"]), + SlashCommand(name: "Template", icon: "doc.on.doc", action: .template, section: "Suggested", keywords: ["snippet", "preset"]), + SlashCommand(name: "Meeting", icon: "mic.fill", action: .meeting, section: "Suggested", keywords: ["record", "transcribe", "audio", "notes"]), + // Basic blocks + SlashCommand(name: "Text", icon: "text.alignleft", action: .blockType(.paragraph, headingLevel: 0), section: "Basic blocks", keywords: ["paragraph", "plain"]), + SlashCommand(name: "Heading 1", icon: "textformat.size.larger", action: .blockType(.heading, headingLevel: 1), section: "Basic blocks", keywords: ["h1", "title", "header"]), + SlashCommand(name: "Heading 2", icon: "textformat.size", action: .blockType(.heading, headingLevel: 2), section: "Basic blocks", keywords: ["h2", "subtitle", "header"]), + SlashCommand(name: "Heading 3", icon: "textformat.size.smaller", action: .blockType(.heading, headingLevel: 3), section: "Basic blocks", keywords: ["h3", "header"]), + SlashCommand(name: "Bullet List", icon: "list.bullet", action: .blockType(.bulletListItem, headingLevel: 0), section: "Basic blocks", keywords: ["unordered", "bullets", "ul"]), + SlashCommand(name: "Numbered List", icon: "list.number", action: .blockType(.numberedListItem, headingLevel: 0), section: "Basic blocks", keywords: ["ordered", "ol", "numbers"]), + SlashCommand(name: "To-do", icon: "checkmark.square", action: .blockType(.taskItem, headingLevel: 0), section: "Basic blocks", keywords: ["checkbox", "task", "checklist", "check"]), + SlashCommand(name: "Quote", icon: "text.quote", action: .blockType(.blockquote, headingLevel: 0), section: "Basic blocks", keywords: ["blockquote", "callout"]), + SlashCommand(name: "Code", icon: "chevron.left.forwardslash.chevron.right", action: .blockType(.codeBlock, headingLevel: 0), section: "Basic blocks", keywords: ["codeblock", "snippet", "programming"]), + SlashCommand(name: "Divider", icon: "minus", action: .blockType(.horizontalRule, headingLevel: 0), section: "Basic blocks", keywords: ["separator", "line", "hr", "horizontal rule"]), + SlashCommand(name: "Toggle", icon: "chevron.right", action: .blockType(.toggle, headingLevel: 0), section: "Basic blocks", keywords: ["collapse", "expand", "accordion", "dropdown"]), + // Inline + SlashCommand(name: "Page", icon: "doc.text", action: .createPage, section: "Inline", keywords: ["subpage", "new page", "child"]), + SlashCommand(name: "Link to Page", icon: "link", action: .linkToPage, section: "Inline", keywords: ["wiki", "reference", "mention"]), + SlashCommand(name: "Database", icon: "tablecells", action: .blockType(.databaseEmbed, headingLevel: 0), section: "Inline", keywords: ["table", "spreadsheet", "kanban", "board"]), ] var filteredSlashCommands: [SlashCommand] { if slashMenuFilter.isEmpty { return Self.slashCommands } - return Self.slashCommands.filter { $0.name.localizedStandardContains(slashMenuFilter) } + return Self.slashCommands.filter { $0.matches(slashMenuFilter) } } func executeSlashCommand() { @@ -831,12 +869,37 @@ class BlockDocument { dismissSlashMenu() return + case .meetingNotes: + if let createDb = onCreateMeetingDatabase, + let dbPath = createDb() { + updateBlockProperty(id: blockId) { block in + block.type = .databaseEmbed + block.databasePath = dbPath + } + } + dismissSlashMenu() + return + + case .meeting: + saveUndo() + updateBlockProperty(id: blockId) { block in + block.type = .meeting + block.meetingState = .ready + block.meetingTranscript = "" + block.meetingSummary = "" + block.meetingActionItems = "" + block.meetingTitle = "" + block.meetingNotes = "" + } + dismissSlashMenu() + return + case let .blockType(type, headingLevel): // Database command needs special handling — creates files via callback if type == .databaseEmbed { if blockLocation(for: blockId) != nil, let createDb = onCreateDatabase, - let dbPath = createDb("Untitled Database") { + let dbPath = createDb("") { updateBlockProperty(id: blockId) { block in block.type = .databaseEmbed block.databasePath = dbPath @@ -870,6 +933,15 @@ class BlockDocument { dismissPagePicker() } + /// Insert a page link block at a specific index (used for sidebar drag-drop). + func insertPageLinkBlock(at index: Int, name: String) { + saveUndo() + let block = Block(type: .pageLink, pageLinkName: name) + let clampedIndex = min(index, blocks.count) + blocks.insert(block, at: clampedIndex) + focusedBlockId = block.id + } + @ObservationIgnored private var _pagePickerCache: (search: String, entries: [FileEntry])? var filteredPagePickerEntries: [FileEntry] { @@ -972,6 +1044,16 @@ class BlockDocument { return MarkdownBlockParser.serialize(selectedBlocks) } + func selectedBlockContextItems() -> [AiContextItem] { + guard let indices = selectedBlockIndices() else { return [] } + return indices.map { idx in + let block = blocks[idx] + let markdown = MarkdownBlockParser.serialize([block]) + let plainPreview = block.text.trimmingCharacters(in: .whitespacesAndNewlines) + return AiContextItem.block(id: block.id, preview: plainPreview, markdown: markdown) + } + } + /// Replaces the selected blocks with AI-generated content. func replaceSelectedBlocks(markdown: String) { guard let indices = selectedBlockIndices(), !indices.isEmpty else { return } @@ -1079,6 +1161,17 @@ class BlockDocument { focusedBlockId = imageBlock.id } + /// Returns true if the payload string looks like a sidebar page file path. + static func isSidebarPagePath(_ payload: String) -> Bool { + payload.hasPrefix("/") && payload.hasSuffix(".md") + } + + /// Extracts the page display name from a sidebar file path. + static func pageNameFromPath(_ path: String) -> String { + let filename = (path as NSString).lastPathComponent + return filename.hasSuffix(".md") ? String(filename.dropLast(3)) : filename + } + /// Saves raw image data to the workspace `_assets/` directory and returns the absolute path. func saveImageDataToAssets(_ data: Data, fileExtension: String = "png") -> String? { guard let workspace = workspacePath else { return nil } @@ -1476,4 +1569,5 @@ class BlockDocument { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } + } diff --git a/Sources/Bugbook/Models/CanvasDocument.swift b/Sources/Bugbook/Models/CanvasDocument.swift deleted file mode 100644 index c230be5..0000000 --- a/Sources/Bugbook/Models/CanvasDocument.swift +++ /dev/null @@ -1,420 +0,0 @@ -import Foundation -import SwiftUI -import os -import Sentry - -// MARK: - Canvas JSON Structs - -struct CanvasViewport: Codable { - var x: CGFloat - var y: CGFloat - var zoom: CGFloat -} - -struct CanvasNodeMeta: Codable, Identifiable { - let id: String - var type: CanvasNodeType - var x: CGFloat - var y: CGFloat - var width: CGFloat - var height: CGFloat - var file: String? // relative path for file nodes - var color: String? -} - -enum CanvasNodeType: String, Codable { - case text - case file - case image -} - -struct CanvasEdgeMeta: Codable, Identifiable { - let id: String - var fromNode: String - var toNode: String - var fromSide: String? - var toSide: String? - var toEnd: String? - var label: String? - var color: String? -} - -struct CanvasFileMeta: Codable { - var id: String - var name: String - var version: Int - var viewport: CanvasViewport - var nodes: [CanvasNodeMeta] - var edges: [CanvasEdgeMeta] -} - -enum CanvasLoadResult { - case loaded - case newCanvas - case corrupted(String) -} - -// MARK: - CanvasDocument - -@MainActor -@Observable -class CanvasDocument { - var nodes: [CanvasNodeMeta] = [] - var edges: [CanvasEdgeMeta] = [] - var nodeTexts: [String: String] = [:] // node_id → markdown content - var viewport: CanvasViewport = CanvasViewport(x: 0, y: 0, zoom: 1.0) - var selectedNodeIds: Set = [] - var selectedEdgeId: String? - var editingNodeId: String? - - /// Convenience: returns the single selected node ID (nil if 0 or 2+ selected) - var selectedNodeId: String? { - get { selectedNodeIds.count == 1 ? selectedNodeIds.first : nil } - set { - if let id = newValue { - selectedNodeIds = [id] - } else { - selectedNodeIds.removeAll() - } - } - } - var isDirty: Bool = false - var loadResult: CanvasLoadResult = .newCanvas - - @ObservationIgnored private(set) var canvasPath: String = "" - @ObservationIgnored private(set) var canvasName: String = "" - @ObservationIgnored private var canvasId: String = "" - - @ObservationIgnored private var undoStack: [CanvasState] = [] - @ObservationIgnored private var redoStack: [CanvasState] = [] - - private struct CanvasState { - let nodes: [CanvasNodeMeta] - let edges: [CanvasEdgeMeta] - let nodeTexts: [String: String] - } - - // MARK: - Load / Save - - func load(from folderPath: String) { - canvasPath = folderPath - let metaPath = (folderPath as NSString).appendingPathComponent("_canvas.json") - - // Distinguish "no file" (new canvas) from "corrupted JSON" - guard FileManager.default.fileExists(atPath: metaPath) else { - canvasName = (folderPath as NSString).lastPathComponent - canvasId = "canvas_\(UUID().uuidString.prefix(8).lowercased())" - loadResult = .newCanvas - return - } - - do { - let data = try Data(contentsOf: URL(fileURLWithPath: metaPath)) - let meta = try JSONDecoder().decode(CanvasFileMeta.self, from: data) - - canvasId = meta.id - canvasName = meta.name - viewport = meta.viewport - nodes = meta.nodes - edges = meta.edges - - // Load text content for text nodes - for node in nodes where node.type == .text { - let mdPath = (folderPath as NSString).appendingPathComponent("\(node.id).md") - if let content = try? String(contentsOfFile: mdPath, encoding: .utf8) { - nodeTexts[node.id] = content - } else { - nodeTexts[node.id] = "" - } - } - - loadResult = .loaded - isDirty = false - SentrySDK.addBreadcrumb(Breadcrumb(level: .info, category: "canvas.load")) - } catch { - canvasName = (folderPath as NSString).lastPathComponent - canvasId = "" - loadResult = .corrupted(error.localizedDescription) - } - } - - func save() { - guard !canvasPath.isEmpty else { return } - if case .corrupted = loadResult { return } - let metaPath = (canvasPath as NSString).appendingPathComponent("_canvas.json") - - let meta = CanvasFileMeta( - id: canvasId, - name: canvasName, - version: 1, - viewport: viewport, - nodes: nodes, - edges: edges - ) - - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(meta) - try data.write(to: URL(fileURLWithPath: metaPath), options: .atomic) - - for node in nodes where node.type == .text { - let mdPath = (canvasPath as NSString).appendingPathComponent("\(node.id).md") - let content = nodeTexts[node.id] ?? "" - try content.write(toFile: mdPath, atomically: true, encoding: .utf8) - } - - isDirty = false - SentrySDK.addBreadcrumb(Breadcrumb(level: .info, category: "canvas.save")) - } catch { - Log.canvas.error("Save failed: \(error.localizedDescription)") - SentrySDK.capture(error: error) - } - } - - // MARK: - ID Generation - - private func generateId(prefix: String) -> String { - let chars = "abcdefghijklmnopqrstuvwxyz0123456789" - let suffix = String((0..<6).map { _ in chars.randomElement()! }) - return "\(prefix)_\(suffix)" - } - - // MARK: - Node CRUD - - func addTextNode(at position: CGPoint) { - saveUndo() - let id = generateId(prefix: "node") - let node = CanvasNodeMeta( - id: id, - type: .text, - x: position.x, - y: position.y, - width: 300, - height: 200 - ) - nodes.append(node) - nodeTexts[id] = "" - selectedNodeId = id - editingNodeId = id - isDirty = true - } - - func addFileNode(at position: CGPoint, filePath: String) { - saveUndo() - let id = generateId(prefix: "node") - let relativePath = Self.relativePath(from: canvasPath, to: filePath) - let node = CanvasNodeMeta( - id: id, - type: .file, - x: position.x, - y: position.y, - width: 300, - height: 80, - file: relativePath - ) - nodes.append(node) - selectedNodeId = id - isDirty = true - } - - func addImageNode(at position: CGPoint, image: NSImage) { - saveUndo() - let id = generateId(prefix: "node") - let filename = "\(id).png" - let imagePath = (canvasPath as NSString).appendingPathComponent(filename) - - // Save image as PNG to canvas folder - // Use CGImage path for broader format support (HEIC, etc.) - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - Log.canvas.error("Failed to get CGImage from NSImage") - return - } - let bitmap = NSBitmapImageRep(cgImage: cgImage) - guard let pngData = bitmap.representation(using: .png, properties: [:]) else { - Log.canvas.error("Failed to convert image to PNG") - return - } - do { - try pngData.write(to: URL(fileURLWithPath: imagePath), options: .atomic) - } catch { - Log.canvas.error("Failed to write image: \(error.localizedDescription)") - return - } - - // Size the node proportionally, capping width at 400 - let maxWidth: CGFloat = 400 - let scale = image.size.width > maxWidth ? maxWidth / image.size.width : 1.0 - let width = image.size.width * scale - let height = image.size.height * scale - - let node = CanvasNodeMeta( - id: id, - type: .image, - x: position.x, - y: position.y, - width: max(120, width), - height: max(60, height), - file: filename - ) - nodes.append(node) - selectedNodeId = id - isDirty = true - } - - func removeNode(id: String) { - saveUndo() - let removedNode = nodes.first { $0.id == id } - nodes.removeAll { $0.id == id } - edges.removeAll { $0.fromNode == id || $0.toNode == id } - nodeTexts.removeValue(forKey: id) - // Delete associated file for text/image nodes - if let node = removedNode { - if node.type == .text { - let mdPath = (canvasPath as NSString).appendingPathComponent("\(id).md") - try? FileManager.default.removeItem(atPath: mdPath) - } else if node.type == .image, let file = node.file { - let imgPath = (canvasPath as NSString).appendingPathComponent(file) - try? FileManager.default.removeItem(atPath: imgPath) - } - } - selectedNodeIds.remove(id) - isDirty = true - } - - func moveNode(id: String, to position: CGPoint) { - guard let idx = nodes.firstIndex(where: { $0.id == id }) else { return } - nodes[idx].x = position.x - nodes[idx].y = position.y - isDirty = true - } - - func resizeNode(id: String, width: CGFloat, height: CGFloat) { - guard let idx = nodes.firstIndex(where: { $0.id == id }) else { return } - nodes[idx].width = max(120, width) - nodes[idx].height = max(60, height) - isDirty = true - } - - func updateNodeText(id: String, text: String) { - nodeTexts[id] = text - isDirty = true - } - - // MARK: - Edge CRUD - - func addEdge(from: String, to: String, fromSide: String? = nil, toSide: String? = nil) { - guard from != to else { return } - // Don't add duplicate edges - if edges.contains(where: { $0.fromNode == from && $0.toNode == to }) { return } - saveUndo() - let id = generateId(prefix: "edge") - let edge = CanvasEdgeMeta( - id: id, - fromNode: from, - toNode: to, - fromSide: fromSide, - toSide: toSide, - toEnd: "arrow" - ) - edges.append(edge) - isDirty = true - } - - func removeEdge(id: String) { - saveUndo() - edges.removeAll { $0.id == id } - if selectedEdgeId == id { selectedEdgeId = nil } - isDirty = true - } - - // MARK: - Selection - - func clearSelection() { - selectedNodeIds.removeAll() - selectedEdgeId = nil - editingNodeId = nil - } - - func toggleNodeSelection(_ id: String) { - if selectedNodeIds.contains(id) { - selectedNodeIds.remove(id) - } else { - selectedNodeIds.insert(id) - } - selectedEdgeId = nil - } - - func deleteSelection() { - if !selectedNodeIds.isEmpty { - for nodeId in selectedNodeIds { - removeNode(id: nodeId) - } - } else if let edgeId = selectedEdgeId { - removeEdge(id: edgeId) - } - } - - // MARK: - Undo/Redo - - private func saveUndo() { - undoStack.append(CanvasState(nodes: nodes, edges: edges, nodeTexts: nodeTexts)) - redoStack.removeAll() - } - - func undo() { - guard let prev = undoStack.popLast() else { return } - redoStack.append(CanvasState(nodes: nodes, edges: edges, nodeTexts: nodeTexts)) - nodes = prev.nodes - edges = prev.edges - nodeTexts = prev.nodeTexts - isDirty = true - } - - func redo() { - guard let next = redoStack.popLast() else { return } - undoStack.append(CanvasState(nodes: nodes, edges: edges, nodeTexts: nodeTexts)) - nodes = next.nodes - edges = next.edges - nodeTexts = next.nodeTexts - isDirty = true - } - - // MARK: - File Node Resolution - - /// Resolve a file node's relative path to an absolute path - func resolveFilePath(for node: CanvasNodeMeta) -> String? { - guard let file = node.file else { return nil } - if file.hasPrefix("/") { return file } - // Relative paths are stored relative to the canvas folder itself - let resolved = (canvasPath as NSString).appendingPathComponent(file) - return URL(fileURLWithPath: resolved).standardizedFileURL.path - } - - /// Get display name for a file node - func fileNodeDisplayName(for node: CanvasNodeMeta) -> String { - guard let file = node.file else { return "Unknown" } - let name = (file as NSString).lastPathComponent - return name.hasSuffix(".md") ? String(name.dropLast(3)) : name - } - - /// Compute a relative path from the canvas folder to a target file. - static func relativePath(from canvasFolder: String, to filePath: String) -> String { - let canvasComponents = canvasFolder.components(separatedBy: "/").filter { !$0.isEmpty } - let fileComponents = filePath.components(separatedBy: "/").filter { !$0.isEmpty } - - // Find common prefix length - var commonLength = 0 - while commonLength < canvasComponents.count && commonLength < fileComponents.count - && canvasComponents[commonLength] == fileComponents[commonLength] { - commonLength += 1 - } - - // Number of ".." to go up from canvas folder to common ancestor - let ups = canvasComponents.count - commonLength - var parts = Array(repeating: "..", count: ups) - // Append remaining file path components - parts.append(contentsOf: fileComponents[commonLength...]) - return parts.joined(separator: "/") - } -} diff --git a/Sources/Bugbook/Models/ChatMessage.swift b/Sources/Bugbook/Models/ChatMessage.swift index 3604ee4..674ca8f 100644 --- a/Sources/Bugbook/Models/ChatMessage.swift +++ b/Sources/Bugbook/Models/ChatMessage.swift @@ -6,6 +6,7 @@ struct ChatMessage: Identifiable { let content: String let timestamp: Date var isReverted: Bool = false + var changeSummary: String? enum Role { case user diff --git a/Sources/Bugbook/Models/FileEntry.swift b/Sources/Bugbook/Models/FileEntry.swift index 1b50219..726aaa4 100644 --- a/Sources/Bugbook/Models/FileEntry.swift +++ b/Sources/Bugbook/Models/FileEntry.swift @@ -3,13 +3,13 @@ import Foundation enum TabKind: Equatable, Hashable { case page case database - case canvas case calendar + case meetings case databaseRow(dbPath: String, rowId: String) var isDatabase: Bool { self == .database } - var isCanvas: Bool { self == .canvas } var isCalendar: Bool { self == .calendar } + var isMeetings: Bool { self == .meetings } var isDatabaseRow: Bool { if case .databaseRow = self { return true }; return false } var databasePath: String? { if case .databaseRow(let p, _) = self { return p }; return nil } var databaseRowId: String? { if case .databaseRow(_, let r) = self { return r }; return nil } @@ -27,7 +27,6 @@ struct FileEntry: Identifiable, Hashable { // Shims forwarding to kind for incremental migration var isDatabase: Bool { kind.isDatabase } - var isCanvas: Bool { kind.isCanvas } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Models/OpenFile.swift b/Sources/Bugbook/Models/OpenFile.swift index 5c523af..62dc2de 100644 --- a/Sources/Bugbook/Models/OpenFile.swift +++ b/Sources/Bugbook/Models/OpenFile.swift @@ -15,8 +15,8 @@ struct OpenFile: Identifiable, Equatable { // Shims forwarding to kind for incremental migration var isDatabase: Bool { kind.isDatabase } - var isCanvas: Bool { kind.isCanvas } var isCalendar: Bool { kind.isCalendar } + var isMeetings: Bool { kind.isMeetings } var isDatabaseRow: Bool { kind.isDatabaseRow } var databasePath: String? { kind.databasePath } var databaseRowId: String? { kind.databaseRowId } diff --git a/Sources/Bugbook/Models/SidebarReferenceDragPayload.swift b/Sources/Bugbook/Models/SidebarReferenceDragPayload.swift index de9c5d9..3c7cb83 100644 --- a/Sources/Bugbook/Models/SidebarReferenceDragPayload.swift +++ b/Sources/Bugbook/Models/SidebarReferenceDragPayload.swift @@ -1,7 +1,14 @@ import CoreTransferable import UniformTypeIdentifiers -struct SidebarReferenceDragPayload: Codable, Transferable { +extension UTType { + /// Custom type for editor-to-sidebar page/database reference drags. + /// Uses a custom identifier so FileTreeView's .onDrop(of: [.text]) doesn't intercept it + /// (UTType.json conforms to .text, which caused the drag to be swallowed). + static let sidebarReference = UTType(exportedAs: "com.bugbook.sidebar-reference") +} + +struct SidebarReferenceDragPayload: Codable, Transferable, Equatable { let path: String let kind: String @@ -14,10 +21,6 @@ struct SidebarReferenceDragPayload: Codable, Transferable { } static var transferRepresentation: some TransferRepresentation { - CodableRepresentation(contentType: .bugbookSidebarReference) + CodableRepresentation(contentType: .sidebarReference) } } - -extension UTType { - static let bugbookSidebarReference = UTType(exportedAs: "com.bugbook.sidebar-reference") -} diff --git a/Sources/Bugbook/Services/AiService.swift b/Sources/Bugbook/Services/AiService.swift index 5354b2f..16a128d 100644 --- a/Sources/Bugbook/Services/AiService.swift +++ b/Sources/Bugbook/Services/AiService.swift @@ -48,8 +48,27 @@ Formatting rules: - For collapsed toggles: instead of NEVER use HTML tags like
, , , etc. This app does NOT render HTML. + +NEVER produce empty blocks or consecutive blank lines. Every block must contain visible content. Use at most one blank line between sections. """ + /// Strip excessive blank lines and trailing whitespace from AI output. + static func sanitizeResponse(_ text: String) -> String { + var result = text + // Collapse 3+ consecutive newlines down to 2 (one blank line) + while result.contains("\n\n\n") { + result = result.replacingOccurrences(of: "\n\n\n", with: "\n\n") + } + // Trim trailing whitespace per line + result = result + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression) } + .joined(separator: "\n") + // Trim leading/trailing whitespace on the whole string + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + return result + } + // MARK: - Engine Detection func detectEngines() async { @@ -80,14 +99,14 @@ NEVER use HTML tags like
, , , etc. This app does NOT // MARK: - Chat - func chatWithNotes(engine: PreferredAIEngine, workspacePath: String, question: String, apiKey: String = "") async throws -> String { + func chatWithNotes(engine: PreferredAIEngine, workspacePath: String, question: String, apiKey: String = "", model: AnthropicModel = .sonnet) async throws -> String { if engine == .claudeAPI { guard !apiKey.isEmpty else { throw AiError.noEngineAvailable } isRunning = true error = nil defer { isRunning = false } do { - return try await callAPI(apiKey: apiKey, userPrompt: question) + return try await callAPI(apiKey: apiKey, userPrompt: question, model: model) } catch { self.error = error.localizedDescription throw error @@ -130,7 +149,7 @@ NEVER use HTML tags like
, , , etc. This app does NOT } } - private func callAPI(apiKey: String, systemPrompt: String? = nil, userPrompt: String, maxTokens: Int = 1024) async throws -> String { + private func callAPI(apiKey: String, systemPrompt: String? = nil, userPrompt: String, maxTokens: Int = 1024, model: AnthropicModel = .sonnet) async throws -> String { let url = URL(string: "https://api.anthropic.com/v1/messages")! var request = URLRequest(url: url) request.httpMethod = "POST" @@ -139,7 +158,7 @@ NEVER use HTML tags like
, , , etc. This app does NOT request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") var body: [String: Any] = [ - "model": "claude-haiku-4-5-20251001", + "model": model.rawValue, "max_tokens": maxTokens, "messages": [["role": "user", "content": userPrompt]] ] @@ -163,9 +182,53 @@ NEVER use HTML tags like
, , , etc. This app does NOT return text } + // MARK: - Transcript Summarization + + struct TranscriptSummary { + let summary: String + let actionItems: String + } + + func summarizeTranscript(_ transcript: String, apiKey: String) async throws -> TranscriptSummary { + guard !apiKey.isEmpty else { throw AiError.noEngineAvailable } + + let systemPrompt = """ + You are summarizing a meeting transcript. Return ONLY markdown with two sections, no extra commentary: + + ## Summary + <2-5 bullet points covering key discussion points, decisions made, and outcomes> + + ## Action Items + + + If the transcript is too short or unclear to extract meaningful content, write a brief summary of what was discussed and leave action items empty. + """ + + let result = try await callAPI( + apiKey: apiKey, + systemPrompt: systemPrompt, + userPrompt: "Summarize this meeting transcript:\n\n\(transcript)", + maxTokens: 2048 + ) + + // Split the AI response into summary and action items sections + let parts = result.components(separatedBy: "## Action Items") + let summaryPart = parts[0] + .replacingOccurrences(of: "## Summary", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let actionItemsPart = parts.count > 1 + ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + : "- [ ] " + + return TranscriptSummary( + summary: summaryPart.isEmpty ? "Meeting recorded." : summaryPart, + actionItems: actionItemsPart.isEmpty ? "- [ ] " : actionItemsPart + ) + } + // MARK: - Content Generation - func generateContent(engine: PreferredAIEngine, workspacePath: String, prompt: String, pageContext: String = "", apiKey: String = "") async throws -> String { + func generateContent(engine: PreferredAIEngine, workspacePath: String, prompt: String, pageContext: String = "", apiKey: String = "", model: AnthropicModel = .sonnet) async throws -> String { var fullPrompt = Self.systemInstruction + "\n\n" if !pageContext.isEmpty { fullPrompt += "Current page context:\n\(pageContext)\n\n" @@ -178,7 +241,7 @@ NEVER use HTML tags like
, , , etc. This app does NOT error = nil defer { isRunning = false } do { - return try await callAPI(apiKey: apiKey, systemPrompt: Self.systemInstruction, userPrompt: pageContext.isEmpty ? prompt : "Current page context:\n\(pageContext)\n\nUser request: \(prompt)", maxTokens: 2048) + return try await callAPI(apiKey: apiKey, systemPrompt: Self.systemInstruction, userPrompt: pageContext.isEmpty ? prompt : "Current page context:\n\(pageContext)\n\nUser request: \(prompt)", maxTokens: 2048, model: model) } catch { self.error = error.localizedDescription throw error @@ -226,6 +289,34 @@ NEVER use HTML tags like
, , , etc. This app does NOT try await runCommand("bugbook \(command)") } + // MARK: - Transcript Summarization + + func summarizeTranscript(_ transcript: String, apiKey: String, model: AnthropicModel = .sonnet) async throws -> String { + guard !apiKey.isEmpty else { throw AiError.noEngineAvailable } + isRunning = true + error = nil + defer { isRunning = false } + let systemPrompt = """ + You are a meeting assistant. Given a transcript, produce a concise meeting summary in markdown with these sections: + ## Summary + A brief overview of what was discussed. + + ## Key Points + - Bullet list of main topics and decisions + + ## Action Items + - [ ] Task items identified in the meeting + + Return ONLY the markdown. No explanations or code fences. + """ + do { + return try await callAPI(apiKey: apiKey, systemPrompt: systemPrompt, userPrompt: transcript, maxTokens: 2048, model: model) + } catch { + self.error = error.localizedDescription + throw error + } + } + // MARK: - Pre-warming func prewarmSession() async { diff --git a/Sources/Bugbook/Services/BacklinkService.swift b/Sources/Bugbook/Services/BacklinkService.swift index eddb418..fb8695a 100644 --- a/Sources/Bugbook/Services/BacklinkService.swift +++ b/Sources/Bugbook/Services/BacklinkService.swift @@ -2,6 +2,7 @@ import Foundation import BugbookCore private let backlinkLinkPattern = #"\[\[([^\]]+)\]\]"# +private let backlinkRegex = try? NSRegularExpression(pattern: backlinkLinkPattern) struct Backlink: Identifiable { let sourcePath: String @@ -14,6 +15,8 @@ struct Backlink: Identifiable { class BacklinkService { /// Maps page name (lowercased) → list of backlinks private var index: [String: [Backlink]] = [:] + /// Reverse index: source path → set of keys it contributed to + @ObservationIgnored private var sourceToKeys: [String: Set] = [:] @ObservationIgnored private var indexedWorkspace: String? @ObservationIgnored private var rebuildingWorkspace: String? @ObservationIgnored private var rebuildTask: Task? @@ -29,6 +32,7 @@ class BacklinkService { rebuildTask?.cancel() if indexedWorkspace != workspace { index = [:] + sourceToKeys = [:] indexedWorkspace = nil } @@ -40,6 +44,7 @@ class BacklinkService { guard !Task.isCancelled else { return } index = newIndex + sourceToKeys = Self.buildReverseIndex(from: newIndex) indexedWorkspace = workspace rebuildingWorkspace = nil rebuildTask = nil @@ -64,11 +69,13 @@ class BacklinkService { guard filename.hasSuffix(".md") else { return } let sourceName = String(filename.dropLast(3)) - // Remove old entries from this source - for key in index.keys { - index[key]?.removeAll { $0.sourcePath == path } - if index[key]?.isEmpty == true { - index.removeValue(forKey: key) + // Remove old entries using reverse index (O(affected keys) instead of O(all keys)) + if let oldKeys = sourceToKeys.removeValue(forKey: path) { + for key in oldKeys { + index[key]?.removeAll { $0.sourcePath == path } + if index[key]?.isEmpty == true { + index.removeValue(forKey: key) + } } } @@ -76,14 +83,16 @@ class BacklinkService { guard FileManager.default.fileExists(atPath: path), let content = try? String(contentsOfFile: path, encoding: .utf8) else { return } - guard let regex = try? NSRegularExpression(pattern: backlinkLinkPattern) else { return } + guard let regex = backlinkRegex else { return } let range = NSRange(content.startIndex..., in: content) let matches = regex.matches(in: content, range: range) + var newKeys = Set() for match in matches { if let linkRange = Range(match.range(at: 1), in: content) { let linkedPage = String(content[linkRange]) let key = linkedPage.lowercased() + newKeys.insert(key) var existing = index[key] ?? [] if !existing.contains(where: { $0.sourcePath == path }) { existing.append(Backlink(sourcePath: path, sourceName: sourceName)) @@ -91,6 +100,9 @@ class BacklinkService { index[key] = existing } } + if !newKeys.isEmpty { + sourceToKeys[path] = newKeys + } } // MARK: - Background I/O @@ -98,7 +110,7 @@ class BacklinkService { private nonisolated static func buildIndex(workspace: String) -> [String: [Backlink]] { let fm = FileManager.default var newIndex: [String: [Backlink]] = [:] - guard let regex = try? NSRegularExpression(pattern: backlinkLinkPattern) else { return [:] } + guard let regex = backlinkRegex else { return [:] } guard let enumerator = fm.enumerator(atPath: workspace) else { return [:] } while let relativePath = enumerator.nextObject() as? String { @@ -130,4 +142,14 @@ class BacklinkService { return newIndex } + + private static func buildReverseIndex(from index: [String: [Backlink]]) -> [String: Set] { + var reverse: [String: Set] = [:] + for (key, backlinks) in index { + for backlink in backlinks { + reverse[backlink.sourcePath, default: []].insert(key) + } + } + return reverse + } } diff --git a/Sources/Bugbook/Services/CalendarService.swift b/Sources/Bugbook/Services/CalendarService.swift index d474ed3..4680de5 100644 --- a/Sources/Bugbook/Services/CalendarService.swift +++ b/Sources/Bugbook/Services/CalendarService.swift @@ -1,16 +1,19 @@ import Foundation +import AuthenticationServices import BugbookCore enum CalendarError: LocalizedError { case notAuthenticated case apiError(String) case tokenRefreshFailed + case oauthFailed(String) var errorDescription: String? { switch self { - case .notAuthenticated: return "Not signed in to Google Calendar. Add your OAuth credentials in Settings > Calendar." + case .notAuthenticated: return "Not signed in to Google Calendar." case .apiError(let msg): return msg case .tokenRefreshFailed: return "Failed to refresh Google Calendar token. Try signing in again." + case .oauthFailed(let msg): return "Google sign-in failed: \(msg)" } } } @@ -21,12 +24,122 @@ struct GoogleOAuthToken: Codable { var accessToken: String var refreshToken: String var expiresAt: Date - var clientId: String - var clientSecret: String var isExpired: Bool { Date() >= expiresAt } } +// MARK: - Google OAuth Browser Flow + +struct GoogleOAuthResult { + var accessToken: String + var refreshToken: String + var expiresAt: Date + var email: String +} + +enum GoogleOAuthFlow { + // Register a "Desktop app" OAuth client in Google Cloud Console with the Calendar API enabled. + // For installed/desktop apps, Google documents that the client ID and secret are not truly secret. + // Replace these with your registered credentials. + static let clientID = "YOUR_CLIENT_ID_HERE" + static let clientSecret = "YOUR_CLIENT_SECRET_HERE" + private static let redirectURI = "http://127.0.0.1" + private static let scopes = "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/userinfo.email" + + @MainActor + static func signIn() async throws -> GoogleOAuthResult { + let authCode = try await requestAuthCode() + let tokenResult = try await exchangeCode(authCode) + let email = try await fetchUserEmail(accessToken: tokenResult.0) + return GoogleOAuthResult( + accessToken: tokenResult.0, + refreshToken: tokenResult.1, + expiresAt: tokenResult.2, + email: email + ) + } + + @MainActor + private static func requestAuthCode() async throws -> String { + var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")! + components.queryItems = [ + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "scope", value: scopes), + URLQueryItem(name: "access_type", value: "offline"), + URLQueryItem(name: "prompt", value: "consent"), + ] + return try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: components.url!, + callbackURLScheme: "http" + ) { callbackURL, error in + if let error { + continuation.resume(throwing: CalendarError.oauthFailed(error.localizedDescription)) + return + } + guard let callbackURL, + let items = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems, + let code = items.first(where: { $0.name == "code" })?.value else { + continuation.resume(throwing: CalendarError.oauthFailed("No authorization code received.")) + return + } + continuation.resume(returning: code) + } + session.prefersEphemeralWebBrowserSession = true + session.presentationContextProvider = OAuthPresentationContext.shared + session.start() + } + } + + private static func exchangeCode(_ code: String) async throws -> (String, String, Date) { + var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "code", value: code), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "client_secret", value: clientSecret), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "grant_type", value: "authorization_code"), + ] + request.httpBody = body.query?.data(using: .utf8) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let msg = String(data: data, encoding: .utf8) ?? "" + throw CalendarError.oauthFailed("Token exchange failed: \(msg)") + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String, + let expiresIn = json["expires_in"] as? Int else { + throw CalendarError.oauthFailed("Unexpected token response format.") + } + return (accessToken, refreshToken, Date().addingTimeInterval(TimeInterval(expiresIn))) + } + + private static func fetchUserEmail(accessToken: String) async throws -> String { + var request = URLRequest(url: URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")!) + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let email = json["email"] as? String else { + return "" + } + return email + } +} + +private class OAuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { + static let shared = OAuthPresentationContext() + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + NSApplication.shared.keyWindow ?? ASPresentationAnchor() + } +} + // MARK: - Cached Formatters private enum CalendarFormatters { @@ -79,7 +192,6 @@ class CalendarService { var currentToken = token if currentToken.isExpired { currentToken = try await refreshToken(currentToken) - // Save refreshed token back (caller should persist) } let syncToken = store.loadSyncToken(in: workspace) @@ -90,13 +202,10 @@ class CalendarService { try store.saveSyncToken(newSyncToken, in: workspace) } - // Reload from store events = store.loadEvents(in: workspace) lastSyncDate = Date() - // Sync calendar list let calendars = try await fetchGoogleCalendarList(token: currentToken) - // Merge with existing visibility preferences let existingSources = store.loadSources(in: workspace) let existingVisibility: [String: Bool] = Dictionary( existingSources.map { ($0.id, $0.isVisible) }, @@ -120,6 +229,7 @@ class CalendarService { // MARK: - Database Overlay Items func loadDatabaseOverlayItems(workspace: String) async { + let start = CFAbsoluteTimeGetCurrent() var items: [CalendarDatabaseItem] = [] let dbStore = DatabaseStore() let visibleOverlays = overlays.filter(\.isVisible) @@ -160,6 +270,10 @@ class CalendarService { } databaseItems = items + let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000 + if elapsed > 500 { + print("[Perf] loadDatabaseOverlayItems took \(Int(elapsed))ms (\(items.count) items)") + } } // MARK: - Overlay Management @@ -206,7 +320,6 @@ class CalendarService { var components = URLComponents(string: "https://www.googleapis.com/calendar/v3/calendars/\(calendarId)/events")! var queryItems: [URLQueryItem] = [] if let syncToken { - // syncToken is incompatible with orderBy/singleEvents per Google API docs queryItems.append(URLQueryItem(name: "syncToken", value: syncToken)) } else { queryItems.append(contentsOf: [ @@ -214,7 +327,6 @@ class CalendarService { URLQueryItem(name: "orderBy", value: "startTime"), URLQueryItem(name: "maxResults", value: "250"), ]) - // Initial sync: fetch 30 days back, 90 days forward let now = Date() queryItems.append(URLQueryItem(name: "timeMin", value: CalendarFormatters.isoFallback.string(from: now.addingTimeInterval(-30 * 86400)))) queryItems.append(URLQueryItem(name: "timeMax", value: CalendarFormatters.isoFallback.string(from: now.addingTimeInterval(90 * 86400)))) @@ -230,7 +342,6 @@ class CalendarService { } if http.statusCode == 410 { - // Sync token expired — do a full sync return try await fetchGoogleEvents(token: token, syncToken: nil) } @@ -246,7 +357,6 @@ class CalendarService { let items = json["items"] as? [[String: Any]] ?? [] var events = items.compactMap { parseGoogleEvent($0, calendarId: calendarId) } - // Handle pagination if let nextPageToken = json["nextPageToken"] as? String { var pageComponents = components var pageQueryItems = queryItems @@ -260,7 +370,6 @@ class CalendarService { return FetchResult(events: events, nextSyncToken: nextSyncToken) } - /// Fetch additional pages of events (handles nextPageToken recursion) private func fetchGoogleEventsPage(url: URL, token: GoogleOAuthToken, calendarId: String, queryItems: [URLQueryItem], baseComponents: URLComponents) async throws -> [CalendarEvent] { var request = URLRequest(url: url) request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") @@ -368,8 +477,8 @@ class CalendarService { private func refreshToken(_ token: GoogleOAuthToken) async throws -> GoogleOAuthToken { var components = URLComponents(string: "https://oauth2.googleapis.com/token")! components.queryItems = [ - URLQueryItem(name: "client_id", value: token.clientId), - URLQueryItem(name: "client_secret", value: token.clientSecret), + URLQueryItem(name: "client_id", value: GoogleOAuthFlow.clientID), + URLQueryItem(name: "client_secret", value: GoogleOAuthFlow.clientSecret), URLQueryItem(name: "refresh_token", value: token.refreshToken), URLQueryItem(name: "grant_type", value: "refresh_token"), ] @@ -393,9 +502,7 @@ class CalendarService { return GoogleOAuthToken( accessToken: accessToken, refreshToken: token.refreshToken, - expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)), - clientId: token.clientId, - clientSecret: token.clientSecret + expiresAt: Date().addingTimeInterval(TimeInterval(expiresIn)) ) } diff --git a/Sources/Bugbook/Services/DatabaseService.swift b/Sources/Bugbook/Services/DatabaseService.swift index 83d4718..2f85300 100644 --- a/Sources/Bugbook/Services/DatabaseService.swift +++ b/Sources/Bugbook/Services/DatabaseService.swift @@ -64,8 +64,7 @@ class DatabaseService { updatedAt: now ) try rowStore.saveRow(row, schema: schema, dbPath: dbPath) - let allRows = rowStore.loadAllRows(in: dbPath, schema: schema, skipBody: true) - try updateIndex(rows: allRows, schema: schema, at: dbPath) + try incrementalIndexInsert(row: row, schema: schema, at: dbPath) return row } @@ -250,75 +249,128 @@ class DatabaseService { try indexManager.saveIndex(index, at: dbPath) } - // MARK: - Private: Load Rows (with legacy repair) + // MARK: - Incremental Index Updates - private func loadRows(in dbPath: String, schema: DatabaseSchema) throws -> [DatabaseRow] { - guard let contents = try? fileManager.contentsOfDirectory(atPath: dbPath) else { return [] } + /// Insert a single row into the existing index without a full reload. + private func incrementalIndexInsert(row: DatabaseRow, schema: DatabaseSchema, at dbPath: String) throws { + try mutateIndex(at: dbPath) { rowsMap, indexes in + rowsMap[row.id] = indexManager.buildRowEntry(row: row, schema: schema, dbPath: dbPath) + addToReverseIndexes(row: row, schema: schema, indexes: &indexes) + } + } - // Track best row per ID and filenames to detect duplicates. - var bestByID: [String: (row: DatabaseRow, filename: String, repaired: Bool)] = [:] - var duplicateFiles: [String] = [] + /// Update a single row in the existing index without a full reload. + func incrementalIndexUpdate(row: DatabaseRow, schema: DatabaseSchema, at dbPath: String) throws { + try mutateIndex(at: dbPath) { rowsMap, indexes in + removeFromReverseIndexes(rowId: row.id, indexes: &indexes) + rowsMap[row.id] = indexManager.buildRowEntry(row: row, schema: schema, dbPath: dbPath) + addToReverseIndexes(row: row, schema: schema, indexes: &indexes) + } + } - for name in contents { - guard name.hasSuffix(".md"), !name.hasPrefix("_") else { continue } - let filePath = (dbPath as NSString).appendingPathComponent(name) - guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { continue } - guard let result = RowSerializer.parseDetailed(content: content, schema: schema, skipBody: true) else { continue } + /// Remove a single row from the existing index without a full reload. + func incrementalIndexDelete(rowId: String, schema: DatabaseSchema, at dbPath: String) throws { + try mutateIndex(at: dbPath) { rowsMap, indexes in + rowsMap.removeValue(forKey: rowId) + removeFromReverseIndexes(rowId: rowId, indexes: &indexes) + } + } - let repairedProperties = repairLegacyProperties(in: result.row.properties, rawProperties: result.rawProperties, schema: schema) - let row = DatabaseRow( - id: result.row.id, - properties: repairedProperties.properties, - body: result.row.body, - createdAt: result.row.createdAt, - updatedAt: result.row.updatedAt - ) + /// Load the index, apply a mutation to its rows and indexes, then save. + private func mutateIndex( + at dbPath: String, + body: (inout [String: Any], inout [String: [String: [String]]]) -> Void + ) throws { + var index = indexManager.loadIndex(at: dbPath) ?? [:] + var rowsMap = index["rows"] as? [String: Any] ?? [:] + var indexes = index["indexes"] as? [String: [String: [String]]] ?? [:] + + body(&rowsMap, &indexes) + + index["rows"] = rowsMap + index["indexes"] = indexes + index["updated_at"] = Self.isoFormatter.string(from: Date()) + index["version"] = 1 + try indexManager.saveIndex(index, at: dbPath) + } - let rowId = row.id - if let existing = bestByID[rowId] { - // Keep the one whose filename matches the canonical suffix pattern. - let suffix = RowStore.extractIdSuffix(from: rowId) - let existingIsCanonical = existing.filename.contains("(\(suffix))") - let newIsCanonical = name.contains("(\(suffix))") - - if newIsCanonical && !existingIsCanonical { - duplicateFiles.append(existing.filename) - bestByID[rowId] = (row, name, repairedProperties.repaired) - } else if !newIsCanonical && existingIsCanonical { - duplicateFiles.append(name) - } else { - // Both canonical or both non-canonical — keep newer - if row.updatedAt > existing.row.updatedAt { - duplicateFiles.append(existing.filename) - bestByID[rowId] = (row, name, repairedProperties.repaired) - } else { - duplicateFiles.append(name) - } + private func addToReverseIndexes(row: DatabaseRow, schema: DatabaseSchema, indexes: inout [String: [String: [String]]]) { + let indexedTypes: Set = [.select, .multiSelect, .relation, .checkbox] + for prop in schema.properties where indexedTypes.contains(prop.type) { + guard let val = row.properties[prop.id] else { continue } + switch val { + case .select(let optId): + indexes[prop.id, default: [:]][optId, default: []].append(row.id) + case .multiSelect(let optIds): + for optId in optIds { + indexes[prop.id, default: [:]][optId, default: []].append(row.id) } - } else { - bestByID[rowId] = (row, name, repairedProperties.repaired) + case .relation(let rid): + indexes[prop.id, default: [:]][rid, default: []].append(row.id) + case .relationMany(let rids): + for rid in rids { + indexes[prop.id, default: [:]][rid, default: []].append(row.id) + } + case .checkbox(let b): + indexes[prop.id, default: [:]][b ? "true" : "false", default: []].append(row.id) + default: + break } } + } - // Clean up orphan duplicate files. - for filename in duplicateFiles { - let filePath = (dbPath as NSString).appendingPathComponent(filename) - try? fileManager.removeItem(atPath: filePath) + private func removeFromReverseIndexes(rowId: String, indexes: inout [String: [String: [String]]]) { + for (propId, propIndex) in indexes { + var updated = propIndex + for (key, rowIds) in propIndex { + let filtered = rowIds.filter { $0 != rowId } + if filtered.isEmpty { + updated.removeValue(forKey: key) + } else if filtered.count != rowIds.count { + updated[key] = filtered + } + } + indexes[propId] = updated } + } + + // MARK: - Private: Load Rows (with legacy repair) - let rows = bestByID.values.map(\.row) - let repairedRows = bestByID.values.filter(\.repaired).map(\.row) - let sortedRows = rows.sorted { $0.createdAt < $1.createdAt } + private func loadRows(in dbPath: String, schema: DatabaseSchema) throws -> [DatabaseRow] { + // Delegate to RowStore for loading and duplicate cleanup, then apply legacy repair. + let detailed = rowStore.loadAllRowsDetailed(in: dbPath, schema: schema) + + var rows: [DatabaseRow] = [] + var repairedRows: [DatabaseRow] = [] + + for entry in detailed { + let repaired = repairLegacyProperties( + in: entry.row.properties, + rawProperties: entry.rawProperties, + schema: schema + ) + let row = DatabaseRow( + id: entry.row.id, + properties: repaired.properties, + body: entry.row.body, + createdAt: entry.row.createdAt, + updatedAt: entry.row.updatedAt + ) + rows.append(row) + if repaired.repaired { + repairedRows.append(row) + } + } // If we repaired legacy properties during load, persist the mapped values. if !repairedRows.isEmpty { for row in repairedRows { try? rowStore.saveRow(row, schema: schema, dbPath: dbPath) } - try? updateIndex(rows: sortedRows, schema: schema, at: dbPath) + try? updateIndex(rows: rows, schema: schema, at: dbPath) } - return sortedRows + return rows } // MARK: - Legacy Property Repair diff --git a/Sources/Bugbook/Services/FileSystemService.swift b/Sources/Bugbook/Services/FileSystemService.swift index 4b2c684..05eb7b8 100644 --- a/Sources/Bugbook/Services/FileSystemService.swift +++ b/Sources/Bugbook/Services/FileSystemService.swift @@ -49,13 +49,14 @@ class FileSystemService { // MARK: - File Tree Building - func buildFileTree(at path: String, depth: Int = 0) -> [FileEntry] { + nonisolated func buildFileTree(at path: String, depth: Int = 0) -> [FileEntry] { let state = depth == 0 ? Log.signpost.beginInterval("buildFileTree") : nil defer { if let state { Log.signpost.endInterval("buildFileTree", state) } } + let fm = FileManager.default guard depth < 5 else { return [] } - guard let contents = try? fileManager.contentsOfDirectory(atPath: path) else { + guard let contents = try? fm.contentsOfDirectory(atPath: path) else { return [] } @@ -73,7 +74,7 @@ class FileSystemService { let fullPath = (path as NSString).appendingPathComponent(name) if WorkspacePathRules.shouldIgnoreAbsolutePath(fullPath) { continue } var isDir: ObjCBool = false - guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDir) else { continue } + guard fm.fileExists(atPath: fullPath, isDirectory: &isDir) else { continue } if isDir.boolValue { if isDatabaseFolder(at: fullPath) { @@ -93,22 +94,6 @@ class FileSystemService { isDirectory: false, kind: .database )) - } else if isCanvasFolder(at: fullPath) { - // Canvas folder - read display name from _canvas.json - var canvasName = name - let metaPath = (fullPath as NSString).appendingPathComponent("_canvas.json") - if let data = try? Data(contentsOf: URL(fileURLWithPath: metaPath)), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let n = json["name"] as? String { - canvasName = n - } - folders.append(FileEntry( - id: fullPath, - name: canvasName, - path: fullPath, - isDirectory: false, - kind: .canvas - )) } else if isCompanionFolder(name, siblings: siblingNames) { // Companion folder - skip, its contents are handled by the parent .md file continue @@ -130,7 +115,7 @@ class FileSystemService { // Check for companion folder children let companionPath = companionFolderPath(for: fullPath) var children: [FileEntry]? - if fileManager.fileExists(atPath: companionPath) { + if fm.fileExists(atPath: companionPath) { children = buildFileTree(at: companionPath, depth: depth + 1) } @@ -238,8 +223,6 @@ class FileSystemService { let displayName = (newPath as NSString).lastPathComponent if isDatabaseFolder(at: newPath) { try? updateDatabaseDisplayName(at: newPath, name: displayName) - } else if isCanvasFolder(at: newPath) { - try? updateCanvasDisplayName(at: newPath, name: displayName) } return newPath } @@ -580,7 +563,8 @@ class FileSystemService { return defaultSortedEntries(entries) } - let orderMap = Dictionary(uniqueKeysWithValues: order.enumerated().map { ($1, $0) }) + // Use last occurrence to handle duplicate names in saved order (prevents crash) + let orderMap = Dictionary(order.enumerated().map { ($1, $0) }, uniquingKeysWith: { _, last in last }) return entries.sorted { a, b in let idxA = orderMap[a.name] ?? Int.max let idxB = orderMap[b.name] ?? Int.max @@ -704,7 +688,7 @@ class FileSystemService { let fullPath = (trashDir as NSString).appendingPathComponent(name) var isDir: ObjCBool = false if fileManager.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue, - !isDatabaseFolder(at: fullPath), !isCanvasFolder(at: fullPath) { + !isDatabaseFolder(at: fullPath) { return nil } @@ -808,23 +792,18 @@ class FileSystemService { return fileManager.fileExists(atPath: path) ? path : nil } - private func companionFolderPath(for mdPath: String) -> String { + nonisolated private func companionFolderPath(for mdPath: String) -> String { guard mdPath.hasSuffix(".md") else { return mdPath } return String(mdPath.dropLast(3)) } - private func isCompanionFolder(_ folderName: String, siblings: Set) -> Bool { + nonisolated private func isCompanionFolder(_ folderName: String, siblings: Set) -> Bool { siblings.contains("\(folderName).md") } - func isDatabaseFolder(at path: String) -> Bool { + nonisolated func isDatabaseFolder(at path: String) -> Bool { let schemaPath = (path as NSString).appendingPathComponent("_schema.json") - return fileManager.fileExists(atPath: schemaPath) - } - - func isCanvasFolder(at path: String) -> Bool { - let canvasPath = (path as NSString).appendingPathComponent("_canvas.json") - return fileManager.fileExists(atPath: canvasPath) + return FileManager.default.fileExists(atPath: schemaPath) } func updateDatabaseDisplayName(at path: String, name: String) throws { @@ -839,46 +818,17 @@ class FileSystemService { try updated.write(to: URL(fileURLWithPath: schemaPath), options: .atomic) } - func updateCanvasDisplayName(at path: String, name: String) throws { - let canvasPath = (path as NSString).appendingPathComponent("_canvas.json") - let data = try Data(contentsOf: URL(fileURLWithPath: canvasPath)) - var meta = try JSONDecoder().decode(CanvasFileMeta.self, from: data) - meta.name = name - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let updated = try encoder.encode(meta) - try updated.write(to: URL(fileURLWithPath: canvasPath), options: .atomic) - } - - func createCanvas(in directory: String, name: String) throws -> String { - let sanitized = name.replacingOccurrences(of: "[/\\\\?%*:|\"<>]", with: "-", options: .regularExpression) - let folderName = sanitized.isEmpty ? "Untitled Canvas" : sanitized - let folderPath = uniqueDirectoryPath(in: directory, base: folderName) - try fileManager.createDirectory(atPath: folderPath, withIntermediateDirectories: true) - - let canvasId = "canvas_\(UUID().uuidString.prefix(8).lowercased())" - let meta = CanvasFileMeta( - id: canvasId, - name: folderName, - version: 1, - viewport: CanvasViewport(x: 0, y: 0, zoom: 1.0), - nodes: [], - edges: [] - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(meta) - let metaPath = (folderPath as NSString).appendingPathComponent("_canvas.json") - try data.write(to: URL(fileURLWithPath: metaPath), options: .atomic) - - return folderPath - } - - private func parseIconFromFile(at path: String) -> String? { - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } - return parseIcon(from: content) + nonisolated private func parseIconFromFile(at path: String) -> String? { + guard let fh = FileHandle(forReadingAtPath: path) else { return nil } + defer { fh.closeFile() } + let data = fh.readData(ofLength: 256) + guard let head = String(data: data, encoding: .utf8) else { return nil } + guard let range = head.range(of: "", options: .regularExpression) else { + return nil + } + let match = String(head[range]) + let inner = match.dropFirst(10).dropLast(4).trimmingCharacters(in: .whitespaces) + return inner.isEmpty ? nil : inner } private func loadRecentWorkspaces() { diff --git a/Sources/Bugbook/Services/Logger.swift b/Sources/Bugbook/Services/Logger.swift index f58d9d7..e3bf1d9 100644 --- a/Sources/Bugbook/Services/Logger.swift +++ b/Sources/Bugbook/Services/Logger.swift @@ -18,8 +18,8 @@ enum Log { static let navigation = Logger(subsystem: subsystem, category: "Navigation") /// Agent hub (tasks, runs, events) static let agent = Logger(subsystem: subsystem, category: "Agent") - /// Canvas operations - static let canvas = Logger(subsystem: subsystem, category: "Canvas") + /// Audio capture and transcription + static let transcription = Logger(subsystem: subsystem, category: "Transcription") /// General app lifecycle static let app = Logger(subsystem: subsystem, category: "App") diff --git a/Sources/Bugbook/Services/MeetingNoteService.swift b/Sources/Bugbook/Services/MeetingNoteService.swift index 10416d0..1bb79ff 100644 --- a/Sources/Bugbook/Services/MeetingNoteService.swift +++ b/Sources/Bugbook/Services/MeetingNoteService.swift @@ -1,10 +1,18 @@ 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 @@ -62,6 +70,193 @@ 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. + /// Transcribes the file, generates an AI summary (if API key available), and writes the note. + /// Returns the file path to navigate to. + func importRecording( + fileURL: URL, + workspace: String, + transcriptionService: TranscriptionService, + aiService: AiService, + apiKey: String, + model: AnthropicModel + ) async -> String? { + isCreating = true + defer { isCreating = false } + + do { + let segments = try await transcriptionService.transcribe(fileURL: fileURL) + let transcript = TranscriptionService.markdownFromSegments(segments) + + // Build filename from recording name + let baseName = (fileURL.lastPathComponent as NSString).deletingPathExtension + let dateStr = formatDateForFilename(Date()) + let pageName = "\(dateStr) — \(Self.sanitize(baseName))" + let pagePath = (workspace as NSString).appendingPathComponent("\(pageName).md") + + // Build content + var content = buildImportedRecordingContent( + title: baseName, + segments: segments, + transcript: transcript + ) + + // Try AI summary if API key available + if !apiKey.isEmpty { + let plainTranscript = segments.map { $0.text }.joined(separator: " ") + if let summary = try? await aiService.summarizeTranscript(plainTranscript, apiKey: apiKey, model: model) { + content = content.replacingOccurrences(of: "## Summary\n\n_AI summary will appear here when an API key is configured._", with: "## Summary\n\n\(summary)") + } + } + + try content.write(toFile: pagePath, atomically: true, encoding: .utf8) + return pagePath + } catch { + self.error = error.localizedDescription + return nil + } + } + + /// 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("date: \(Self.isoDateFormatter.string(from: Date()))") + if let last = segments.last { + let duration = Int(last.timestamp) / 60 + lines.append("duration: \(duration)m") + } + let speakers = Set(segments.map(\.speaker)).sorted() + if !speakers.isEmpty { + lines.append("participants:") + for speaker in speakers { + lines.append(" - \(speaker)") + } + } + lines.append("type: meeting") + lines.append("source: recording") + lines.append("---") + lines.append("") + + lines.append("# \(title)") + lines.append("") + lines.append("## Summary") + lines.append("") + lines.append("_AI summary will appear here when an API key is configured._") + lines.append("") + lines.append("## Notes") + lines.append("") + lines.append("") + lines.append("## Action Items") + lines.append("") + lines.append("- [ ] ") + lines.append("") + lines.append(transcript) + + return lines.joined(separator: "\n") + } // MARK: - Cached Formatters private static let longDateFormatter: DateFormatter = { @@ -70,6 +265,11 @@ class MeetingNoteService { private static let shortTimeFormatter: DateFormatter = { let df = DateFormatter(); df.dateFormat = "h:mm a"; return df }() + private static let isoDateFormatter: ISO8601DateFormatter = { + let df = ISO8601DateFormatter() + df.formatOptions = [.withInternetDateTime] + return df + }() private static let filenameDateFormatter: DateFormatter = { let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"; return df }() @@ -79,6 +279,25 @@ class MeetingNoteService { private func buildMeetingNoteContent(for event: CalendarEvent) -> String { var lines: [String] = [] + // YAML frontmatter + lines.append("---") + lines.append("title: \(yamlEscape(event.title))") + lines.append("date: \(Self.isoDateFormatter.string(from: event.startDate))") + if !event.isAllDay { + let duration = Int(event.endDate.timeIntervalSince(event.startDate) / 60) + lines.append("duration: \(duration)m") + } + if !event.attendees.isEmpty { + lines.append("participants:") + for attendee in event.attendees { + let name = attendee.displayName ?? attendee.email + lines.append(" - \(yamlEscape(name))") + } + } + lines.append("type: meeting") + lines.append("---") + lines.append("") + // Title lines.append("# \(event.title)") lines.append("") @@ -133,6 +352,76 @@ 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) { @@ -180,6 +469,15 @@ class MeetingNoteService { Self.filenameDateFormatter.string(from: date) } + /// 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("[") + if needsQuoting { + return "\"\(value.replacingOccurrences(of: "\"", with: "\\\""))\"" + } + return value + } + private func attendeeStatusIcon(_ status: Attendee.ResponseStatus) -> String { switch status { case .accepted: return "✓" diff --git a/Sources/Bugbook/Services/OnboardingService.swift b/Sources/Bugbook/Services/OnboardingService.swift index 400165e..600041c 100644 --- a/Sources/Bugbook/Services/OnboardingService.swift +++ b/Sources/Bugbook/Services/OnboardingService.swift @@ -32,7 +32,6 @@ enum OnboardingService { **Sidebar** — Browse and organize your pages in the file tree. Drag to reorder. **Databases** — Create structured tables with properties, filters, and views. - **Canvas** — Spatial workspace for arranging cards and connections. **Graph View** — Visualize links between your pages. **Daily Notes** — Cmd+P, then search "daily" to create today's note. **Templates** — Add .md files to a Templates/ folder to reuse them. diff --git a/Sources/Bugbook/Services/QmdService.swift b/Sources/Bugbook/Services/QmdService.swift index 05d8ff3..cf6be76 100644 --- a/Sources/Bugbook/Services/QmdService.swift +++ b/Sources/Bugbook/Services/QmdService.swift @@ -1,4 +1,5 @@ import Foundation +import BugbookCore enum QmdStatus: Equatable { case unknown @@ -26,6 +27,14 @@ enum QmdSearchMode: String, Codable, CaseIterable { } } + var cliCommand: String { + switch self { + case .bm25: return "search" + case .semantic: return "vsearch" + case .hybrid: return "query" + } + } + var detail: String { switch self { case .bm25: return "Fast keyword search. No models needed." @@ -33,6 +42,7 @@ enum QmdSearchMode: String, Codable, CaseIterable { case .hybrid: return "BM25 + semantic + re-ranking. Best quality. Keeps models loaded in background." } } + } enum QmdError: Error, LocalizedError { @@ -43,21 +53,44 @@ enum QmdError: Error, LocalizedError { } } +struct QmdIndexStatus { + var totalFiles: Int + var totalVectors: Int + var indexSize: String +} + @MainActor @Observable final class QmdService { var status: QmdStatus = .unknown var collectionReady: Bool = false + var indexStatus: QmdIndexStatus? // MARK: - Public + nonisolated private static let cachedPathKey = "QmdService.cachedBinaryPath" + func detect() async { status = .unknown + + // Try cached path first (fast filesystem check) + if let cached = UserDefaults.standard.string(forKey: Self.cachedPathKey), + !cached.isEmpty, + FileManager.default.fileExists(atPath: cached) { + let raw = try? await runShell("\"\(cached)\" --version") + let version = raw?.components(separatedBy: "\n").first ?? "unknown" + status = .installed(version: version, path: cached) + return + } + + // Fall back to shell lookup if let path = try? await runShell("which qmd"), !path.isEmpty { + UserDefaults.standard.set(path, forKey: Self.cachedPathKey) let raw = try? await runShell("\"\(path)\" --version") let version = raw?.components(separatedBy: "\n").first ?? "unknown" status = .installed(version: version, path: path) } else { + UserDefaults.standard.removeObject(forKey: Self.cachedPathKey) status = .notInstalled } } @@ -79,12 +112,29 @@ final class QmdService { func ensureCollection(workspace: String) async { guard case .installed(_, let path) = status else { return } - let name = collectionName(for: workspace) - _ = try? await runBinary(path, args: ["collection", "add", workspace, "--name", name]) + // v2: collection name derived from directory, no --name flag needed + _ = try? await runBinary(path, args: ["collection", "add", workspace]) _ = try? await runBinary(path, args: ["update"]) + await registerContext(path: path, workspace: workspace) collectionReady = true } + func fetchIndexStatus() async { + guard case .installed(_, let path) = status else { return } + do { + let output = try await runShell("\"\(path)\" collection status --json") + if let data = output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let files = json["totalFiles"] as? Int ?? json["total_files"] as? Int ?? 0 + let vectors = json["totalVectors"] as? Int ?? json["total_vectors"] as? Int ?? 0 + let size = json["indexSize"] as? String ?? json["index_size"] as? String ?? "" + indexStatus = QmdIndexStatus(totalFiles: files, totalVectors: vectors, indexSize: size) + } + } catch { + // leave indexStatus as nil so the UI shows loading state rather than fake zeros + } + } + /// Start the qmd HTTP daemon in the background if hybrid mode is selected and it isn't already running. /// Returns immediately — model loading takes ~30s and happens asynchronously. nonisolated static func prewarmDaemonIfNeeded(mode: QmdSearchMode) { @@ -116,7 +166,6 @@ final class QmdService { nonisolated static func registerCollectionInBackground(workspace: String) { Task.detached(priority: .background) { guard let path = Self.findBinaryPath() else { return } - let name = Self.collectionNameFor(workspace) func run(_ args: [String]) { let task = Process() task.executableURL = URL(fileURLWithPath: path) @@ -126,14 +175,23 @@ final class QmdService { try? task.run() task.waitUntilExit() } - run(["collection", "add", workspace, "--name", name]) + // v2: collection name derived from directory, no --name flag needed + run(["collection", "add", workspace]) run(["update"]) + Self.registerContextSync(binary: path, workspace: workspace) } } // MARK: - Path resolution (nonisolated so Task.detached can call them) nonisolated static func findBinaryPath() -> String? { + // Try cached path first (fast filesystem check) + if let cached = UserDefaults.standard.string(forKey: cachedPathKey), + !cached.isEmpty, + FileManager.default.fileExists(atPath: cached) { + return cached + } + // Login shell PATH lookup — respects nvm, bun, npm global configs let task = Process() task.executableURL = URL(fileURLWithPath: "/bin/zsh") @@ -146,7 +204,10 @@ final class QmdService { if task.terminationStatus == 0 { let p = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !p.isEmpty { return p } + if !p.isEmpty { + UserDefaults.standard.set(p, forKey: cachedPathKey) + return p + } } } // Fallback: check common install dirs without a login shell @@ -158,17 +219,81 @@ final class QmdService { "/usr/local/bin/qmd", "/opt/homebrew/bin/qmd", ] where FileManager.default.fileExists(atPath: p) { + UserDefaults.standard.set(p, forKey: cachedPathKey) return p } return nil } - // MARK: - Private + // MARK: - Context Registration - private func collectionName(for workspace: String) -> String { - Self.collectionNameFor(workspace) + /// Build the list of (uri, description) context entries for a workspace. + /// Pure computation — no side effects. Used by both async and sync registration paths. + nonisolated private static func contextEntries(workspace: String) -> [(uri: String, description: String)] { + let collection = collectionNameFor(workspace) + var entries: [(String, String)] = [ + ("qmd://\(collection)", "Bugbook personal knowledge base — pages, databases, and meeting notes"), + ] + let store = DatabaseStore() + for db in store.listDatabases(in: workspace) { + let relativePath = relativeToWorkspace(db.path, workspace: workspace) + if let schema = try? store.loadSchema(at: db.path) { + let propNames = schema.properties.map(\.name).joined(separator: ", ") + entries.append(("qmd://\(collection)/\(relativePath)", "\(db.name) database — \(propNames)")) + } + } + return entries + } + + /// Check whether the set of databases has changed since last context registration. + nonisolated private static func isContextStale(workspace: String) -> (stale: Bool, key: String) { + let store = DatabaseStore() + let currentKey = store.listDatabases(in: workspace).map(\.name).sorted().joined(separator: ",") + let markerPath = (workspace as NSString).appendingPathComponent(".qmd-context-marker") + if let existing = try? String(contentsOfFile: markerPath, encoding: .utf8), existing == currentKey { + return (false, currentKey) + } + return (true, currentKey) + } + + nonisolated private static func writeContextMarker(workspace: String, key: String) { + let markerPath = (workspace as NSString).appendingPathComponent(".qmd-context-marker") + try? key.write(toFile: markerPath, atomically: true, encoding: .utf8) + } + + /// Register context with qmd (async path, used by ensureCollection). + private func registerContext(path: String, workspace: String) async { + let (stale, key) = Self.isContextStale(workspace: workspace) + guard stale else { return } + for entry in Self.contextEntries(workspace: workspace) { + _ = try? await runBinary(path, args: ["context", "add", entry.uri, entry.description]) + } + Self.writeContextMarker(workspace: workspace, key: key) + } + + /// Register context with qmd (sync path, used by registerCollectionInBackground). + nonisolated static func registerContextSync(binary: String, workspace: String) { + let (stale, key) = isContextStale(workspace: workspace) + guard stale else { return } + for entry in contextEntries(workspace: workspace) { + let task = Process() + task.executableURL = URL(fileURLWithPath: binary) + task.arguments = ["context", "add", entry.uri, entry.description] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + } + writeContextMarker(workspace: workspace, key: key) + } + + nonisolated private static func relativeToWorkspace(_ path: String, workspace: String) -> String { + guard path.hasPrefix(workspace) else { return path } + return String(path.dropFirst(workspace.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) } + // MARK: - Private + nonisolated private static func collectionNameFor(_ workspace: String) -> String { let name = URL(fileURLWithPath: workspace).lastPathComponent return name.isEmpty ? "bugbook" : name diff --git a/Sources/Bugbook/Services/TranscriptionService.swift b/Sources/Bugbook/Services/TranscriptionService.swift new file mode 100644 index 0000000..2417a3a --- /dev/null +++ b/Sources/Bugbook/Services/TranscriptionService.swift @@ -0,0 +1,243 @@ +import Foundation +import AVFoundation +#if canImport(FluidAudio) +import FluidAudio +#endif + +/// A segment of transcribed speech attributed to a speaker. +struct TranscriptSegment: Identifiable { + let id = UUID() + let speaker: String // e.g. "Speaker 1" + let text: String + let timestamp: TimeInterval // seconds from start +} + +@MainActor +@Observable +class TranscriptionService { + // MARK: - Live Recording State + var currentTranscript: String = "" + var confirmedSegments: [String] = [] + var volatileText: String = "" + var audioLevel: Float = 0 + var isRecording: Bool = false + var error: String? + + // MARK: - File Transcription State + var isTranscribing = false + var progress: String = "" + + @ObservationIgnored private var audioEngine: AVAudioEngine? + #if canImport(FluidAudio) + @ObservationIgnored private var streamingManager: StreamingAsrManager? + #endif + @ObservationIgnored private var updateTask: Task? + + private static let supportedExtensions: Set = ["m4a", "mp3", "wav", "caf", "aac", "aiff"] + + static func isSupportedAudioFile(_ url: URL) -> Bool { + supportedExtensions.contains(url.pathExtension.lowercased()) + } + + // MARK: - Permissions + + private func requestMicPermission() async -> Bool { + await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { granted in + continuation.resume(returning: granted) + } + } + } + + // MARK: - Live Recording (FluidAudio / Whisper) + + func startRecording() async { + guard !isRecording else { return } + + let micGranted = await requestMicPermission() + guard micGranted else { + error = "Microphone access denied. Enable in System Settings > Privacy > Microphone." + return + } + + error = nil + currentTranscript = "" + confirmedSegments = [] + volatileText = "" + audioLevel = 0 + + #if canImport(FluidAudio) + let manager = StreamingAsrManager(config: StreamingAsrConfig( + chunkSeconds: 11.0, + hypothesisChunkSeconds: 1.0, + leftContextSeconds: 2.0, + rightContextSeconds: 2.0, + minContextForConfirmation: 3.0, + confirmationThreshold: 0.40 + )) + self.streamingManager = manager + + do { + try await manager.start(source: .microphone) + } catch { + self.error = "Failed to start speech recognition: \(error.localizedDescription)" + return + } + #else + self.error = "Speech recognition unavailable (FluidAudio not linked)" + return + #endif + + let engine = AVAudioEngine() + let inputNode = engine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + + inputNode.installTap(onBus: 0, bufferSize: 4096, format: recordingFormat) { [weak self] buffer, _ in + #if canImport(FluidAudio) + Task { [weak self] in + await self?.streamingManager?.streamAudio(buffer) + } + #endif + + guard let channelData = buffer.floatChannelData?[0] else { return } + let frameCount = Int(buffer.frameLength) + var sum: Float = 0 + for i in 0.. String { + guard isRecording else { return currentTranscript } + + audioEngine?.inputNode.removeTap(onBus: 0) + audioEngine?.stop() + audioEngine = nil + isRecording = false + audioLevel = 0 + + updateTask?.cancel() + updateTask = nil + + // Include any volatile text that never got confirmed + if !volatileText.isEmpty { + confirmedSegments.append(volatileText) + currentTranscript = confirmedSegments.joined(separator: " ") + volatileText = "" + } + + #if canImport(FluidAudio) + let manager = streamingManager + streamingManager = nil + // Fire-and-forget finalization — transcript already captured above + Task { + let finalText = try? await manager?.finish() + await MainActor.run { [weak self] in + if let finalText, !finalText.isEmpty { + self?.currentTranscript = finalText + } + } + } + #endif + return currentTranscript + } + + // MARK: - Transcribe Audio File (FluidAudio batch) + + func transcribe(fileURL: URL) async throws -> [TranscriptSegment] { + guard Self.isSupportedAudioFile(fileURL) else { + throw TranscriptionError.unsupportedFormat(fileURL.pathExtension) + } + + isTranscribing = true + progress = "Loading model..." + error = nil + defer { + isTranscribing = false + progress = "" + } + + #if canImport(FluidAudio) + let models = try await AsrModels.downloadAndLoad() + let asr = AsrManager(config: .default) + try await asr.initialize(models: models) + + progress = "Transcribing..." + let result = try await asr.transcribe(fileURL, source: .system) + return [TranscriptSegment(speaker: "Speaker 1", text: result.text, timestamp: 0)] + #else + throw TranscriptionError.transcriptionFailed("FluidAudio not available — re-enable when upstream fixes macOS 26 SDK compatibility") + #endif + } + + // MARK: - Format for Markdown + + static func markdownFromSegments(_ segments: [TranscriptSegment]) -> String { + var lines: [String] = ["## Transcript", ""] + for segment in segments { + let minutes = Int(segment.timestamp) / 60 + let seconds = Int(segment.timestamp) % 60 + let ts = String(format: "%02d:%02d", minutes, seconds) + lines.append("**[\(ts)] \(segment.speaker):** \(segment.text)") + lines.append("") + } + return lines.joined(separator: "\n") + } +} + +enum TranscriptionError: LocalizedError { + case unsupportedFormat(String) + case modelLoadFailed + case transcriptionFailed(String) + + var errorDescription: String? { + switch self { + case .unsupportedFormat(let ext): + return "Unsupported audio format: .\(ext). Use M4A, MP3, WAV, or CAF." + case .modelLoadFailed: + return "Failed to load Whisper model. Check your internet connection for first-time download." + case .transcriptionFailed(let reason): + return "Transcription failed: \(reason)" + } + } +} diff --git a/Sources/Bugbook/Services/WorkspaceWatcher.swift b/Sources/Bugbook/Services/WorkspaceWatcher.swift index e1abb4f..318309a 100644 --- a/Sources/Bugbook/Services/WorkspaceWatcher.swift +++ b/Sources/Bugbook/Services/WorkspaceWatcher.swift @@ -12,6 +12,7 @@ final class WorkspaceWatcher { private var debounceItem: DispatchWorkItem? private let debounceInterval: TimeInterval = 2.0 private let onChange: () -> Void + private let eventQueue = DispatchQueue(label: "com.bugbook.fsevent-watcher") init(onChange: @escaping () -> Void) { self.onChange = onChange @@ -45,7 +46,7 @@ final class WorkspaceWatcher { } self.stream = stream - FSEventStreamSetDispatchQueue(stream, .main) + FSEventStreamSetDispatchQueue(stream, eventQueue) FSEventStreamStart(stream) } @@ -68,7 +69,7 @@ final class WorkspaceWatcher { } } debounceItem = item - DispatchQueue.main.asyncAfter( + eventQueue.asyncAfter( deadline: .now() + debounceInterval, execute: item ) diff --git a/Sources/Bugbook/ViewModels/CalendarViewModel.swift b/Sources/Bugbook/ViewModels/CalendarViewModel.swift index c3cd1d7..b8af12c 100644 --- a/Sources/Bugbook/ViewModels/CalendarViewModel.swift +++ b/Sources/Bugbook/ViewModels/CalendarViewModel.swift @@ -22,6 +22,7 @@ final class CalendarViewModel { var selectedDate: Date = Date() var selectedEvent: CalendarEvent? var showSourcePicker = false + var showRecordMeetingPopover = false // Computed from CalendarService data @ObservationIgnored private let calendar = Calendar.current diff --git a/Sources/Bugbook/ViewModels/MeetingsViewModel.swift b/Sources/Bugbook/ViewModels/MeetingsViewModel.swift new file mode 100644 index 0000000..1d0d25b --- /dev/null +++ b/Sources/Bugbook/ViewModels/MeetingsViewModel.swift @@ -0,0 +1,192 @@ +import Foundation +import BugbookCore + +/// Represents a single meeting entry in the Meetings tab list. +struct MeetingItem: Identifiable, Hashable { + let id: String + let title: String + let date: Date + let endDate: Date? + let attendees: [String] + /// File path to navigate to when tapped (nil for calendar-only events with no linked page) + let pagePath: String? + /// Calendar event source, if any + let calendarEventId: String? + let isAllDay: Bool + + var attendeeSummary: String { + guard !attendees.isEmpty else { return "" } + if attendees.count <= 3 { + return attendees.joined(separator: ", ") + } + return attendees.prefix(2).joined(separator: ", ") + " +\(attendees.count - 2)" + } +} + +@MainActor +@Observable +final class MeetingsViewModel { + var upcomingByDay: [(date: Date, items: [MeetingItem])] = [] + var pastByDay: [(date: Date, items: [MeetingItem])] = [] + var searchText: String = "" + var isLoading = false + + @ObservationIgnored private let calendar = Calendar.current + @ObservationIgnored private let fm = FileManager.default + + // MARK: - Cached Formatters + + @ObservationIgnored private static let meetingFilenameRegex: NSRegularExpression? = { + try? NSRegularExpression(pattern: #"^\d{4}-\d{2}-\d{2}\s*[—–-]\s*"#) + }() + + @ObservationIgnored private static let dateParser: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd" + return df + }() + + // MARK: - Load + + func load(workspace: String, calendarEvents: [CalendarEvent]) { + isLoading = true + defer { isLoading = false } + + var all: [MeetingItem] = [] + + // 1. Calendar events (with or without linked pages) + for event in calendarEvents { + let names = event.attendees.compactMap { $0.displayName ?? $0.email.components(separatedBy: "@").first } + all.append(MeetingItem( + id: "cal_\(event.id)", + title: event.title, + date: event.startDate, + endDate: event.endDate, + attendees: names, + pagePath: event.linkedPagePath, + calendarEventId: event.id, + isAllDay: event.isAllDay + )) + } + + // 2. Meeting note pages (YYYY-MM-DD — *.md) that aren't already linked + let linkedPaths = Set(calendarEvents.compactMap(\.linkedPagePath)) + discoverMeetingNotePages(workspace: workspace, linkedPaths: linkedPaths, into: &all) + + // Partition into upcoming vs past + let now = Date() + let upcoming = all.filter { $0.date >= calendar.startOfDay(for: now) } + .sorted { $0.date < $1.date } + let past = all.filter { $0.date < calendar.startOfDay(for: now) } + .sorted { $0.date > $1.date } + + // Group by day + upcomingByDay = groupByDay(upcoming) + pastByDay = groupByDay(past) + } + + // MARK: - Filtering + + var filteredUpcoming: [(date: Date, items: [MeetingItem])] { + guard !searchText.isEmpty else { return upcomingByDay } + return filterGroups(upcomingByDay) + } + + var filteredPast: [(date: Date, items: [MeetingItem])] { + guard !searchText.isEmpty else { return pastByDay } + return filterGroups(pastByDay) + } + + private func filterGroups(_ groups: [(date: Date, items: [MeetingItem])]) -> [(date: Date, items: [MeetingItem])] { + let query = searchText.lowercased() + return groups.compactMap { group in + let filtered = group.items.filter { + $0.title.lowercased().contains(query) || + $0.attendees.contains(where: { $0.lowercased().contains(query) }) + } + return filtered.isEmpty ? nil : (group.date, filtered) + } + } + + // MARK: - Meeting Note Discovery + + private func discoverMeetingNotePages(workspace: String, linkedPaths: Set, into items: inout [MeetingItem]) { + guard let contents = try? fm.contentsOfDirectory(atPath: workspace) else { return } + let regex = Self.meetingFilenameRegex + + for filename in contents where filename.hasSuffix(".md") { + let fullPath = (workspace as NSString).appendingPathComponent(filename) + guard !linkedPaths.contains(fullPath) else { continue } + + let basename = (filename as NSString).deletingPathExtension + // Check if filename matches YYYY-MM-DD — Title pattern + guard let regex else { continue } + let range = NSRange(basename.startIndex..., in: basename) + guard regex.firstMatch(in: basename, range: range) != nil else { continue } + + let dateStr = String(basename.prefix(10)) + guard let date = Self.dateParser.date(from: dateStr) else { continue } + + // Extract title after the separator + let titleStart = basename.index(basename.startIndex, offsetBy: min(basename.count, 13)) + let title = String(basename[titleStart...]).trimmingCharacters(in: .whitespaces) + + items.append(MeetingItem( + id: "file_\(fullPath)", + title: title.isEmpty ? basename : title, + date: date, + endDate: nil, + attendees: [], + pagePath: fullPath, + calendarEventId: nil, + isAllDay: true + )) + } + } + + // MARK: - Grouping + + private func groupByDay(_ items: [MeetingItem]) -> [(date: Date, items: [MeetingItem])] { + var dict: [Date: [MeetingItem]] = [:] + for item in items { + let day = calendar.startOfDay(for: item.date) + dict[day, default: []].append(item) + } + return dict.sorted { $0.key < $1.key }.map { ($0.key, $0.value) } + } + + // MARK: - Date Labels + + func relativeLabel(for date: Date) -> String { + let now = Date() + let startOfToday = calendar.startOfDay(for: now) + let day = calendar.startOfDay(for: date) + + if day == startOfToday { return "Today" } + + let diff = calendar.dateComponents([.day], from: startOfToday, to: day).day ?? 0 + if diff == 1 { return "Tomorrow" } + if diff == -1 { return "Yesterday" } + if diff > 1 && diff <= 6 { return dayFormatter.string(from: date) } + + return fullDateFormatter.string(from: date) + } + + @ObservationIgnored private let dayFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "EEEE" + return df + }() + + @ObservationIgnored private let fullDateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "EEEE, MMM d" + return df + }() + + @ObservationIgnored let timeFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "h:mm a" + return df + }() +} diff --git a/Sources/Bugbook/Views/AI/AiSidePanelView.swift b/Sources/Bugbook/Views/AI/AiSidePanelView.swift index 0a2c819..88fd0fe 100644 --- a/Sources/Bugbook/Views/AI/AiSidePanelView.swift +++ b/Sources/Bugbook/Views/AI/AiSidePanelView.swift @@ -7,131 +7,291 @@ struct AiSidePanelView: View { @State private var messages: [ChatMessage] = [] @State private var inputText: String = "" @State private var activeTask: Task? + @State private var statusPhase: String = "Thinking..." + @State private var referencedItems: [AiContextItem] = [] + @State private var showPagePicker = false + @State private var pagePickerSearch = "" @FocusState private var inputFocused: Bool + @FocusState private var pickerSearchFocused: Bool + @State private var pickerSelectedIndex: Int = 0 + @State private var hoveredMessageId: UUID? + + private var threadLabel: String { + guard let first = messages.first(where: { $0.role == .user }) else { return "New AI Chat" } + let text = first.content.trimmingCharacters(in: .whitespacesAndNewlines) + return text.count > 30 ? String(text.prefix(30)) + "..." : text + } var body: some View { VStack(spacing: 0) { - // Header - HStack(spacing: 8) { - Image("BugbookAI") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 22, height: 22) - .clipShape(RoundedRectangle(cornerRadius: 5)) - - Text("Ask AI") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Color.fallbackTextPrimary) + header + if messages.isEmpty { Spacer() + } else { + messageList + } - Button(action: openFullChat) { - Label("Expand", systemImage: "arrow.up.left.and.arrow.down.right") - .labelStyle(.iconOnly) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + inputArea + } + .frame(width: 380) + .background(Color.fallbackEditorBg) + .task { + inputFocused = true + // Ingest any referenced items passed via appState + let incoming = appState.aiReferencedItems + if !incoming.isEmpty { + let existing = Set(referencedItems.map(\.id)) + referencedItems += incoming.filter { !existing.contains($0.id) } + appState.aiReferencedItems.removeAll() + } + if let prompt = appState.aiInitialPrompt, !prompt.isEmpty { + inputText = prompt + appState.aiInitialPrompt = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + sendMessage() } - .buttonStyle(.borderless) - .help("Expand to full chat") + } + } + .onChange(of: appState.aiReferencedItems) { _, newItems in + guard !newItems.isEmpty else { return } + let existing = Set(referencedItems.map(\.id)) + referencedItems += newItems.filter { !existing.contains($0.id) } + appState.aiReferencedItems.removeAll() + } + } - Button(action: closePanel) { - Label("Close", systemImage: "xmark") - .labelStyle(.iconOnly) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - .buttonStyle(.borderless) - .help("Close") + // MARK: - Header + + private var header: some View { + HStack(spacing: 8) { + Image("BugbookAI") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 22, height: 22) + .clipShape(RoundedRectangle(cornerRadius: 5)) + + Text(threadLabel) + .font(.system(size: Typography.body, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + .lineLimit(1) + + Spacer() + + Button(action: openFullChat) { + Label("Expand", systemImage: "arrow.up.left.and.arrow.down.right") + .labelStyle(.iconOnly) + .font(.system(size: 12)) + .foregroundStyle(.secondary) } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .buttonStyle(.borderless) + .help("Expand to full chat") + + Button(action: closePanel) { + Label("Collapse", systemImage: "chevron.right.2") + .labelStyle(.iconOnly) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help("Collapse sidebar") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } - Divider() + // MARK: - Command Suggestions - // Messages - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(messages) { message in - messageBubble(message) - .id(message.id) - } + private var commandSuggestions: some View { + VStack(alignment: .leading, spacing: 0) { + suggestionRow("Summarize this page", prompt: "Summarize this page") + suggestionRow("Organize this page", prompt: "Organize this page into clear sections") + suggestionRow("Rewrite this section", prompt: "Rewrite this page for clarity") + suggestionRow("Extract action items", prompt: "Extract action items from this page") + } + } - if aiService.isRunning { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Thinking...") - .font(.system(size: 13)) - .foregroundStyle(Color.fallbackTextSecondary) - Spacer() - Button("Cancel") { - cancelGeneration() - } - .font(.system(size: 12)) + @State private var hoveredSuggestion: String? + + private func suggestionRow(_ label: String, prompt: String) -> some View { + Button { + inputText = prompt + sendMessage() + } label: { + Text(label) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(hoveredSuggestion == label ? Color.fallbackTextPrimary : Color.fallbackTextSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(hoveredSuggestion == label ? Color.primary.opacity(Opacity.subtle) : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredSuggestion = hovering ? label : nil } + } + + // MARK: - Message List + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(messages) { message in + messageBubble(message) + .id(message.id) + } + + if aiService.isRunning { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(statusPhase) + .font(.system(size: Typography.bodySmall)) .foregroundStyle(Color.fallbackTextSecondary) - .buttonStyle(.borderless) + Spacer() + Button("Cancel") { + cancelGeneration() } - .padding(.horizontal, 16) - .id("loading") + .font(.system(size: Typography.caption)) + .foregroundStyle(Color.fallbackTextSecondary) + .buttonStyle(.borderless) } + .padding(.horizontal, 16) + .id("loading") } - .padding(.vertical, 12) } - .onChange(of: messages.count) { _, _ in - if let last = messages.last { - proxy.scrollTo(last.id, anchor: .bottom) - } + .padding(.vertical, 12) + } + .onChange(of: messages.count) { _, _ in + if let last = messages.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + .onChange(of: aiService.isRunning) { _, running in + if running { + proxy.scrollTo("loading", anchor: .bottom) } - .onChange(of: aiService.isRunning) { _, running in - if running { - proxy.scrollTo("loading", anchor: .bottom) + } + } + } + + // MARK: - Context Chips + + private var contextChipsView: some View { + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(referencedItems) { item in + HStack(spacing: 5) { + Image(systemName: item.iconName) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + + Text(item.displayLabel) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + + Button { + referencedItems.removeAll { $0.id == item.id } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(Color.fallbackBadgeBg) + .clipShape(.capsule) } } + } + .scrollIndicators(.hidden) + } - Divider() + // MARK: - Page Reference Picker - // Input area - HStack(alignment: .bottom, spacing: 10) { - TextField("Ask about your notes...", text: $inputText, axis: .vertical) + private var pickerVisiblePages: [FileEntry] { + Array(filteredPages.prefix(50)) + } + + private var pageReferencePickerView: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + TextField("Search pages...", text: $pagePickerSearch) .textFieldStyle(.plain) - .font(.system(size: 14)) - .lineLimit(1...20) - .frame(minHeight: 24) - .fixedSize(horizontal: false, vertical: true) - .focused($inputFocused) + .font(.system(size: Typography.bodySmall)) + .focused($pickerSearchFocused) .onSubmit { - sendMessage() + let pages = pickerVisiblePages + if !pages.isEmpty, pickerSelectedIndex < pages.count { + addPageReference(pages[pickerSelectedIndex]) + } } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() - Button(action: sendMessage) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 22)) - .foregroundStyle( - inputText.trimmingCharacters(in: .whitespaces).isEmpty - ? Color.fallbackTextMuted - : Brand.primary - ) + if filteredPages.isEmpty { + Text("No pages found") + .font(.system(size: Typography.caption)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(pickerVisiblePages.enumerated()), id: \.element.path) { index, entry in + PageReferenceRow( + entry: entry, + displayName: displayName(for: entry.name), + index: index, + isSelected: index == pickerSelectedIndex, + onHoverIndex: { pickerSelectedIndex = $0 } + ) { + addPageReference(entry) + } + .id(entry.path) + } + } + .padding(.vertical, 4) + } + .onChange(of: pickerSelectedIndex) { _, newIndex in + let pages = pickerVisiblePages + if newIndex < pages.count { + proxy.scrollTo(pages[newIndex].path, anchor: .center) + } + } } - .buttonStyle(.borderless) - .disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty || aiService.isRunning) } - .padding(.horizontal, 16) - .padding(.vertical, 14) } - .frame(width: 380) - .background(Color.fallbackEditorBg) - .task { - inputFocused = true - if let prompt = appState.aiInitialPrompt, !prompt.isEmpty { - inputText = prompt - appState.aiInitialPrompt = nil - // Auto-send the prompt from inline AI trigger - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - sendMessage() - } - } + .frame(width: 280) + .frame(maxHeight: 300) + .popoverSurface() + .onAppear { + pickerSearchFocused = true + pickerSelectedIndex = 0 + } + .onDisappear { pagePickerSearch = "" } + .onChange(of: pagePickerSearch) { _, _ in + pickerSelectedIndex = 0 + } + .onKeyPress(.upArrow) { + if pickerSelectedIndex > 0 { pickerSelectedIndex -= 1 } + return .handled + } + .onKeyPress(.downArrow) { + let count = pickerVisiblePages.count + if pickerSelectedIndex < count - 1 { pickerSelectedIndex += 1 } + return .handled } } @@ -145,15 +305,44 @@ struct AiSidePanelView: View { if message.role == .applied { appliedBubble(message) - } else { + } else if message.role == .user { + // User: dark rounded bubble with white text Text(message.content) - .font(.system(size: 14)) - .foregroundStyle(message.role == .error ? .red : Color.fallbackTextPrimary) + .font(.system(size: Typography.body)) + .foregroundStyle(.white) .textSelection(.enabled) .padding(.horizontal, 12) .padding(.vertical, 8) - .background(bubbleBackground(for: message.role)) - .clipShape(.rect(cornerRadius: Radius.lg)) + .background(Color(light: Color(hex: "1f1f1f"), dark: Color(hex: "e0e0e0"))) + .clipShape(.rect(cornerRadius: Radius.xl)) + } else { + // Assistant / error: plain text, no bubble + Text(message.content) + .font(.system(size: Typography.body)) + .foregroundStyle(message.role == .error ? .red : Color.fallbackTextPrimary) + .textSelection(.enabled) + .padding(.horizontal, 4) + .padding(.vertical, 4) + .overlay(alignment: .topTrailing) { + if message.role == .assistant && hoveredMessageId == message.id { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message.content, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + .foregroundStyle(Color.fallbackTextSecondary) + .padding(4) + .background(Color.fallbackBgTertiary) + .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) + } + .buttonStyle(.borderless) + .offset(x: 4, y: -4) + } + } + .onHover { hovering in + hoveredMessageId = hovering ? message.id : nil + } } if message.role != .user { Spacer(minLength: 40) } @@ -165,17 +354,24 @@ struct AiSidePanelView: View { @ViewBuilder private func appliedBubble(_ message: ChatMessage) -> some View { VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 13)) - .foregroundStyle(.green) - Text("Done — what do you think?") - .font(.system(size: 14)) - .foregroundStyle(Color.fallbackTextPrimary) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(.green) + Text("Done — what do you think?") + .font(.system(size: Typography.body)) + .foregroundStyle(Color.fallbackTextPrimary) + } + if let summary = message.changeSummary, !summary.isEmpty { + Text(summary) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(Color.primary.opacity(Opacity.subtle)) + .background(Color.green.opacity(Opacity.light)) .clipShape(.rect(cornerRadius: Radius.lg)) if activeDocument != nil { @@ -186,7 +382,6 @@ struct AiSidePanelView: View { } else { activeDocument?.undo() } - // Toggle the reverted state if let idx = messages.firstIndex(where: { $0.id == message.id }) { messages[idx].isReverted.toggle() } @@ -209,19 +404,146 @@ struct AiSidePanelView: View { } } - private func bubbleBackground(for role: ChatMessage.Role) -> Color { - switch role { - case .user: - return Color.fallbackAccent.opacity(Opacity.medium) - case .assistant, .applied: - return Color.primary.opacity(Opacity.subtle) - case .error: - return Color.red.opacity(0.1) + // MARK: - Input Area + + private var inputArea: some View { + VStack(spacing: 6) { + // Command suggestions (shown when no messages or always above input) + if messages.isEmpty { + commandSuggestions + .padding(.horizontal, 12) + } + + VStack(spacing: 0) { + if !referencedItems.isEmpty { + contextChipsView + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) + } + + // Context chips (page context) + if let doc = activeDocument, let path = doc.filePath { + let pageName = ((path as NSString).lastPathComponent as NSString).deletingPathExtension + HStack(spacing: 6) { + HStack(spacing: 4) { + Image(systemName: "doc.text") + .font(.system(size: 10)) + Text(pageName) + .font(.system(size: Typography.caption2)) + .lineLimit(1) + } + .foregroundStyle(Color.fallbackTextSecondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) + } + + // Text field + buttons + HStack(alignment: .bottom, spacing: 8) { + Button { + showPagePicker.toggle() + } label: { + Image(systemName: "paperclip") + .font(.system(size: 14)) + .foregroundStyle(Color.fallbackTextSecondary) + } + .buttonStyle(.borderless) + .help("Reference a page") + .floatingPopover(isPresented: $showPagePicker, arrowEdge: .top, becomesKey: true) { + pageReferencePickerView + } + + TextField("Ask about your notes...", text: $inputText, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: Typography.body)) + .lineLimit(1...20) + .frame(minHeight: 24) + .fixedSize(horizontal: false, vertical: true) + .focused($inputFocused) + .onChange(of: inputText) { _, value in + if value.hasSuffix("@") { + showPagePicker = true + } + } + .onSubmit { + sendMessage() + } + + if aiService.isRunning { + Button(action: cancelGeneration) { + Image(systemName: "stop.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + } else { + Button(action: sendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 22)) + .foregroundStyle( + canSend + ? Color.fallbackTextPrimary + : Color.fallbackTextMuted + ) + } + .buttonStyle(.borderless) + .disabled(!canSend) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .background( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + inputFocused ? Color(hex: "6366f1") : Color.fallbackBorderColor, + lineWidth: inputFocused ? 2 : 1 + ) + ) } + .padding(.horizontal, 12) + .padding(.vertical, 10) } // MARK: - Actions + private var canSend: Bool { + !inputText.trimmingCharacters(in: .whitespaces).isEmpty && !aiService.isRunning + } + + private func buildContext( + references: [AiContextItem], + selectionContext: String? + ) -> String { + if !references.isEmpty { + var sections: [String] = [] + if let selectionContext { + sections.append("Selected text:\n\(selectionContext)") + } else if let doc = activeDocument { + sections.append("Current page:\n\(MarkdownBlockParser.serialize(doc.blocks))") + } + for ref in references { + sections.append("\(ref.contextHeading):\n\(ref.contextMarkdown)") + } + return sections.joined(separator: "\n\n---\n\n") + } + if let selectionContext { + return selectionContext + } + if let doc = activeDocument { + return MarkdownBlockParser.serialize(doc.blocks) + } + return "" + } + private func sendMessage() { let trimmed = inputText.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !aiService.isRunning else { return } @@ -230,24 +552,27 @@ struct AiSidePanelView: View { messages.append(userMessage) inputText = "" + // Snapshot referenced items for this message and clear them + let currentReferences = referencedItems + referencedItems.removeAll() + // Capture selection context and block range before it gets cleared let selectionContext = appState.aiSelectionContext let hasSelection = selectionContext != nil let blockRange = activeDocument?.selectedBlockPathRange() let pagePath = activeDocument?.filePath - // Build context - let pageContext: String - if let selectionContext { - pageContext = selectionContext - } else if let doc = activeDocument { - pageContext = MarkdownBlockParser.serialize(doc.blocks) - } else { - pageContext = "" - } + statusPhase = "Reading page..." + let blockCountBefore = activeDocument?.blocks.count ?? 0 let task = Task { + // Build context off main thread (contextMarkdown may read files) + let pageContext = buildContext( + references: currentReferences, + selectionContext: selectionContext + ) do { + statusPhase = "Generating..." let workspacePath = appState.workspacePath ?? "" let response: String if activeDocument != nil { @@ -256,25 +581,31 @@ struct AiSidePanelView: View { workspacePath: workspacePath, prompt: trimmed, pageContext: pageContext, - apiKey: appState.settings.anthropicApiKey + apiKey: appState.settings.anthropicApiKey, + model: appState.settings.anthropicModel ) } else { response = try await aiService.chatWithNotes( engine: appState.settings.preferredAIEngine, workspacePath: workspacePath, question: trimmed, - apiKey: appState.settings.anthropicApiKey + apiKey: appState.settings.anthropicApiKey, + model: appState.settings.anthropicModel ) } guard !Task.isCancelled else { return } + // Sanitize AI output: strip empty blocks and excessive whitespace + let sanitized = AiService.sanitizeResponse(response) + + statusPhase = "Applying changes..." // Apply changes via CLI for precision, fallback to in-memory if let pagePath, let doc = activeDocument { let pageName = ((pagePath as NSString).lastPathComponent as NSString).deletingPathExtension let applied = await applyViaCLI( pageName: pageName, - response: response, + response: sanitized, hasSelection: hasSelection, blockRange: blockRange ) @@ -282,13 +613,24 @@ struct AiSidePanelView: View { doc.reloadFromDisk() } else { if hasSelection { - doc.replaceSelectedBlocks(markdown: response) + doc.replaceSelectedBlocks(markdown: sanitized) } else { - doc.applyAiResponse(markdown: response) + doc.applyAiResponse(markdown: sanitized) } } - // Show clean confirmation instead of raw markdown - let appliedMessage = ChatMessage(role: .applied, content: response, timestamp: Date()) + // Build change summary + let blockCountAfter = doc.blocks.count + let blockDelta = blockCountAfter - blockCountBefore + let summary: String + if blockDelta > 0 { + summary = "Added \(blockDelta) block\(blockDelta == 1 ? "" : "s"), ~\(response.count) chars" + } else if blockDelta < 0 { + summary = "Removed \(abs(blockDelta)) block\(abs(blockDelta) == 1 ? "" : "s"), ~\(response.count) chars" + } else { + summary = "Modified content, ~\(response.count) chars" + } + var appliedMessage = ChatMessage(role: .applied, content: response, timestamp: Date()) + appliedMessage.changeSummary = summary messages.append(appliedMessage) } else { // No active doc — show the response as plain chat @@ -312,28 +654,19 @@ struct AiSidePanelView: View { let escapedPage = pageName.replacingOccurrences(of: "'", with: "'\"'\"'") if hasSelection, let range = blockRange { - // Replace the first selected block with the AI response, - // then delete the remaining selected blocks let tempFile = NSTemporaryDirectory() + "bugbook-ai-\(UUID().uuidString).md" do { try response.write(toFile: tempFile, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(atPath: tempFile) } - // Replace the first block with the full AI response _ = try await aiService.executeBugbookCommand( "block replace '\(escapedPage)' \(range.first) --content-file '\(tempFile)'" ) - // Delete remaining original blocks (they're now shifted, delete from last to first+1) if range.first != range.last { - // After replacing the first block, the indices shift. - // The safest approach: get fresh block count, delete by original range let firstIdx = Int(range.first.replacingOccurrences(of: "path:", with: "")) ?? 0 let lastIdx = Int(range.last.replacingOccurrences(of: "path:", with: "")) ?? 0 if lastIdx > firstIdx { - // Delete from last to first+1 (reverse order to keep indices stable) - // But after replace, the new content may span multiple blocks. - // Count how many blocks the AI response creates let newBlockCount = MarkdownBlockParser.parse(response).count let deleteStart = firstIdx + newBlockCount let deleteEnd = lastIdx + newBlockCount - 1 @@ -350,7 +683,6 @@ struct AiSidePanelView: View { return false } } else { - // Full page update via CLI let tempFile = NSTemporaryDirectory() + "bugbook-ai-\(UUID().uuidString).md" do { try response.write(toFile: tempFile, atomically: true, encoding: .utf8) @@ -379,4 +711,110 @@ struct AiSidePanelView: View { private func openFullChat() { appState.openNotesChat() } + + // MARK: - Page Reference Helpers + + private var allPages: [FileEntry] { + var files: [FileEntry] = [] + flattenFiles(appState.fileTree, into: &files) + let unique = Dictionary(files.map { ($0.path, $0) }, uniquingKeysWith: { first, _ in first }) + return unique.values + .filter { !$0.isDirectory && !$0.isDatabase } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private var filteredPages: [FileEntry] { + let existingPaths = Set(referencedItems.compactMap { item -> String? in + if case .page(let path, _) = item { return path } + return nil + }) + let query = pagePickerSearch.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return allPages.filter { entry in + guard !existingPaths.contains(entry.path) else { return false } + if query.isEmpty { return true } + return entry.name.lowercased().contains(query) || relativePath(for: entry.path).lowercased().contains(query) + } + } + + private func flattenFiles(_ entries: [FileEntry], into result: inout [FileEntry]) { + for entry in entries { + result.append(entry) + if let children = entry.children { + flattenFiles(children, into: &result) + } + } + } + + private func addPageReference(_ entry: FileEntry) { + let item = AiContextItem.page(path: entry.path, name: entry.name) + guard !referencedItems.contains(where: { $0.id == item.id }) else { return } + referencedItems.append(item) + if inputText.hasSuffix("@") { + inputText.removeLast() + } + showPagePicker = false + pagePickerSearch = "" + inputFocused = true + } + + private func relativePath(for path: String) -> String { + guard let workspace = appState.workspacePath, path.hasPrefix(workspace) else { return path } + let relative = path.dropFirst(workspace.count) + return relative.hasPrefix("/") ? String(relative.dropFirst()) : String(relative) + } + + private func displayName(for name: String) -> String { + name.hasSuffix(".md") ? String(name.dropLast(3)) : name + } +} + +// MARK: - Page Reference Row + +private struct PageReferenceRow: View { + let entry: FileEntry + let displayName: String + let index: Int + var isSelected: Bool = false + let onHoverIndex: (Int) -> Void + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + pageIcon + Text(displayName) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected || isHovered ? Color.primary.opacity(Opacity.light) : .clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovered = hovering + if hovering { onHoverIndex(index) } + } + } + + @ViewBuilder + private var pageIcon: some View { + if let icon = entry.icon, !icon.isEmpty { + if icon.unicodeScalars.first?.properties.isEmoji == true { + Text(icon).font(.system(size: 13)) + } else { + Image(systemName: entry.isDatabase ? "tablecells" : "doc.text") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } else { + Image(systemName: entry.isDatabase ? "tablecells" : "doc.text") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } } diff --git a/Sources/Bugbook/Views/AI/NotesChatView.swift b/Sources/Bugbook/Views/AI/NotesChatView.swift index 12b1899..eed9144 100644 --- a/Sources/Bugbook/Views/AI/NotesChatView.swift +++ b/Sources/Bugbook/Views/AI/NotesChatView.swift @@ -35,21 +35,16 @@ struct NotesChatView: View { // MARK: - Sections private var header: some View { - HStack(spacing: 12) { - Image("BugbookLogo") + HStack(spacing: 8) { + Image("BugbookAI") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 7)) + .frame(width: 22, height: 22) + .clipShape(RoundedRectangle(cornerRadius: 5)) - VStack(alignment: .leading, spacing: 1) { - Text("Bugbook") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) - Text("Chat with Notes") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(Color.fallbackTextPrimary) - } + Text("Chat with Notes") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) Spacer() @@ -58,7 +53,7 @@ struct NotesChatView: View { Button(action: clearChat) { Label("Clear Chat", systemImage: "trash") .labelStyle(.iconOnly) - .font(.system(size: 14)) + .font(.system(size: 12)) .foregroundStyle(.secondary) } .buttonStyle(.borderless) @@ -68,16 +63,14 @@ struct NotesChatView: View { Button(action: closeChat) { Label("Close", systemImage: "xmark") .labelStyle(.iconOnly) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 12)) .foregroundStyle(.secondary) - .frame(width: 24, height: 24) - .contentShape(Rectangle()) } .buttonStyle(.borderless) .help("Close chat") } - .padding(.horizontal, 28) - .padding(.vertical, 14) + .padding(.horizontal, 16) + .padding(.vertical, 12) } @ViewBuilder @@ -85,7 +78,7 @@ struct NotesChatView: View { if messages.isEmpty && !aiService.isRunning { Spacer() VStack(spacing: 16) { - Image("BugbookLogo") + Image("BugbookAI") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 56, height: 56) @@ -104,9 +97,9 @@ struct NotesChatView: View { } else { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { + LazyVStack(alignment: .leading, spacing: 12) { ForEach(messages) { message in - messageRow(message) + messageBubble(message) .id(message.id) } @@ -115,15 +108,15 @@ struct NotesChatView: View { ProgressView() .controlSize(.small) Text("Thinking...") - .font(.system(size: 14)) - .foregroundStyle(.secondary) + .font(.system(size: 13)) + .foregroundStyle(Color.fallbackTextSecondary) + Spacer() } - .padding(.horizontal, 24) + .padding(.horizontal, 16) .id("loading") } } - .padding(.horizontal, 24) - .padding(.vertical, 20) + .padding(.vertical, 12) .frame(maxWidth: 980) .frame(maxWidth: .infinity) } @@ -171,14 +164,14 @@ struct NotesChatView: View { .scrollIndicators(.hidden) } - HStack(alignment: .bottom, spacing: 12) { + HStack(alignment: .bottom, spacing: 10) { Button { showFileReferencePicker.toggle() } label: { Image(systemName: "at") - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.secondary) - .frame(width: 30, height: 30) + .frame(width: 26, height: 26) .background(Color.fallbackBadgeBg) .clipShape(Circle()) } @@ -190,8 +183,10 @@ struct NotesChatView: View { TextField("Ask about your notes...", text: $inputText, axis: .vertical) .textFieldStyle(.plain) - .font(.system(size: 16)) - .lineLimit(1...8) + .font(.system(size: 14)) + .lineLimit(1...20) + .frame(minHeight: 24) + .fixedSize(horizontal: false, vertical: true) .focused($inputFocused) .onChange(of: inputText) { _, value in if value.last == "@" { @@ -203,26 +198,19 @@ struct NotesChatView: View { } Button(action: sendMessage) { - Label("Send", systemImage: "arrow.up.circle.fill") - .labelStyle(.iconOnly) - .font(.system(size: 28)) - .foregroundStyle(canSend ? Color.accentColor : Color.secondary) + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 22)) + .foregroundStyle( + canSend + ? Color.accentColor + : Color.fallbackTextMuted + ) } .buttonStyle(.borderless) .disabled(!canSend) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.fallbackSurfaceSubtle) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.fallbackBorderColor, lineWidth: 1) - ) } - .padding(.horizontal, 24) + .padding(.horizontal, 16) .padding(.vertical, 14) } @@ -310,85 +298,52 @@ struct NotesChatView: View { } } - // MARK: - Message Row + // MARK: - Message Bubble @ViewBuilder - private func messageRow(_ message: ChatMessage) -> some View { - switch message.role { - case .user: + private func messageBubble(_ message: ChatMessage) -> some View { + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 4) { HStack { - Spacer(minLength: 80) - Text(message.content) - .font(.system(size: 16)) - .lineSpacing(3) - .foregroundStyle(.white) - .textSelection(.enabled) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.fallbackAccent) - ) - .frame(maxWidth: 720, alignment: .leading) - } + if message.role == .user { Spacer(minLength: 40) } + + if message.role == .applied { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(.green) + Text("Done — what do you think?") + .font(.system(size: 14)) + .foregroundStyle(Color.fallbackTextPrimary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(.rect(cornerRadius: Radius.lg)) + } else { + Text(message.content) + .font(.system(size: 14)) + .foregroundStyle(message.role == .error ? .red : Color.fallbackTextPrimary) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bubbleBackground(for: message.role)) + .clipShape(.rect(cornerRadius: Radius.lg)) + } - case .assistant: - HStack { - Text(message.content) - .font(.system(size: 16)) - .lineSpacing(4) - .foregroundStyle(.primary) - .textSelection(.enabled) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.fallbackSurfaceSubtle) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.fallbackBorderColor.opacity(0.8), lineWidth: 1) - ) - .frame(maxWidth: 760, alignment: .leading) - Spacer(minLength: 80) + if message.role != .user { Spacer(minLength: 40) } } + } + .padding(.horizontal, 16) + } + private func bubbleBackground(for role: ChatMessage.Role) -> Color { + switch role { + case .user: + return Color.fallbackAccent.opacity(Opacity.medium) + case .assistant, .applied: + return Color.primary.opacity(Opacity.subtle) case .error: - HStack { - Text(message.content) - .font(.system(size: 15)) - .foregroundStyle(.red) - .textSelection(.enabled) - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color.red.opacity(0.1)) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.red.opacity(0.25), lineWidth: 1) - ) - Spacer(minLength: 80) - } - - case .applied: - HStack { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Done — what do you think?") - .font(.system(size: 16)) - .foregroundStyle(.primary) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.fallbackSurfaceSubtle) - ) - Spacer(minLength: 80) - } + return Color.red.opacity(0.1) } } @@ -427,7 +382,8 @@ struct NotesChatView: View { engine: selectedEngine, workspacePath: workspacePath, question: prompt, - apiKey: appState.settings.anthropicApiKey + apiKey: appState.settings.anthropicApiKey, + model: appState.settings.anthropicModel ) SentrySDK.addBreadcrumb(Breadcrumb(level: .info, category: "ai.receive")) let assistantMessage = ChatMessage(role: .assistant, content: response, timestamp: Date()) diff --git a/Sources/Bugbook/Views/Calendar/CalendarDayView.swift b/Sources/Bugbook/Views/Calendar/CalendarDayView.swift index c78b6d7..c8a29dd 100644 --- a/Sources/Bugbook/Views/Calendar/CalendarDayView.swift +++ b/Sources/Bugbook/Views/Calendar/CalendarDayView.swift @@ -89,6 +89,7 @@ struct CalendarDayView: View { // Database items let dbItems = calendarVM.databaseItems(for: date, from: databaseItems) + .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ForEach(dbItems, id: \.id) { item in let y = calendarVM.yPosition(for: item.date, hourHeight: hourHeight) let color = TagColor.color(for: item.color) diff --git a/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift b/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift index a3c4050..cf3ecb4 100644 --- a/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift +++ b/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift @@ -59,6 +59,7 @@ struct CalendarMonthView: View { let isCurrentMonth = calendar.component(.month, from: day) == calendar.component(.month, from: selectedDate) let dayEvents = calendarVM.events(for: day, from: events) let dayDbItems = calendarVM.databaseItems(for: day, from: databaseItems) + .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return VStack(alignment: .leading, spacing: 2) { // Day number @@ -66,7 +67,7 @@ struct CalendarMonthView: View { Text("\(calendar.component(.day, from: day))") .font(.system(size: Typography.bodySmall, weight: calendar.isDateInToday(day) ? .bold : .regular)) .foregroundStyle( - calendar.isDateInToday(day) ? Color.white : + calendar.isDateInToday(day) ? Color.fallbackAccentFg : isCurrentMonth ? Color.primary : Color.secondary.opacity(0.5) ) .frame(width: 24, height: 24) diff --git a/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift b/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift index 3f6cdf9..19664e0 100644 --- a/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift +++ b/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift @@ -46,7 +46,6 @@ struct CalendarWeekView: View { } } } - .ignoresSafeArea(.container, edges: .top) } // MARK: - Day Headers (Notion style: "Sun 15" inline) @@ -125,16 +124,15 @@ struct CalendarWeekView: View { // MARK: - Time Grid - /// Hours too close to the current time get hidden (within ~20min) + /// Hours too close to the current time indicator get hidden to avoid overlap. private func shouldHideHourLabel(_ hour: Int) -> Bool { guard days.contains(where: { isToday($0) }) else { return false } let nowHour = Calendar.current.component(.hour, from: Date()) let nowMinute = Calendar.current.component(.minute, from: Date()) - if hour == nowHour { return true } + // Hide current hour when indicator is far from the top of the cell + if hour == nowHour && nowMinute > 20 { return true } // Hide next hour if current time is within 20min of it if hour == nowHour + 1 && nowMinute >= 40 { return true } - // Hide previous hour if current time is within 20min of it - if hour == nowHour && nowMinute <= 20 { return true } return false } @@ -205,6 +203,7 @@ struct CalendarWeekView: View { } let dbItems = calendarVM.databaseItems(for: day, from: databaseItems) + .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ForEach(dbItems, id: \.id) { item in databaseItemBlock(item) } @@ -294,7 +293,6 @@ struct CalendarWeekView: View { let now = Date() if days.contains(where: { isToday($0) }) { let y = calendarVM.yPosition(for: now, hourHeight: hourHeight) - let todayIndex = days.firstIndex(where: { isToday($0) }) ?? 0 let nowColor = StatusColor.error // Time label in gutter (single line, e.g. "6:38 PM") @@ -315,28 +313,16 @@ struct CalendarWeekView: View { .allowsHitTesting(false) // Thin red line from gutter across all columns, thicker on today - HStack(spacing: 0) { - // Dot at the gutter edge - Color.clear.frame(width: timeGutterWidth - 4) - Circle() - .fill(nowColor) - .frame(width: 8, height: 8) - .offset(y: y - 4) - - GeometryReader { geo in - let colWidth = geo.size.width / CGFloat(days.count) - - // Thin line across all columns - Rectangle() - .fill(nowColor.opacity(0.3)) - .frame(width: geo.size.width, height: 1) - .offset(y: y) + HStack(alignment: .top, spacing: 0) { + Color.clear.frame(width: timeGutterWidth) - // Thicker line on today's column only + // Per-column lines using same layout as event overlays + ForEach(Array(days.enumerated()), id: \.offset) { index, day in Rectangle() - .fill(nowColor) - .frame(width: colWidth, height: 2) - .offset(x: CGFloat(todayIndex) * colWidth, y: y - 0.5) + .fill(isToday(day) ? nowColor : nowColor.opacity(0.3)) + .frame(height: isToday(day) ? 2 : 1) + .frame(maxWidth: .infinity) + .offset(y: isToday(day) ? y - 0.5 : y) } } .allowsHitTesting(false) diff --git a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift index 0f0b7c0..bf84aa6 100644 --- a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift +++ b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift @@ -1,13 +1,18 @@ import SwiftUI import BugbookCore +import UniformTypeIdentifiers struct WorkspaceCalendarView: View { var appState: AppState var calendarService: CalendarService @Bindable var calendarVM: CalendarViewModel var meetingNoteService: MeetingNoteService + var aiService: AiService var onNavigateToFile: (String) -> Void + @State private var transcriptionService = TranscriptionService() + @State private var showImportRecording = false + var body: some View { VStack(spacing: 0) { calendarHeader @@ -140,6 +145,28 @@ struct WorkspaceCalendarView: View { ) } + // Import recording button + Button(action: { showImportRecording = true }) { + Image(systemName: "waveform.badge.plus") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Import audio recording") + .disabled(transcriptionService.isTranscribing) + .fileImporter( + isPresented: $showImportRecording, + allowedContentTypes: [ + UTType.audio, + UTType(filenameExtension: "m4a") ?? .audio, + UTType(filenameExtension: "mp3") ?? .audio, + UTType.wav, + ], + allowsMultipleSelection: false + ) { result in + handleImportedRecording(result) + } + // Sync button Button(action: syncCalendar) { Image(systemName: "arrow.clockwise") @@ -182,6 +209,28 @@ struct WorkspaceCalendarView: View { onNavigateToFile(path) } + private func handleImportedRecording(_ result: Result<[URL], Error>) { + guard let workspace = appState.workspacePath else { return } + guard case .success(let urls) = result, let fileURL = urls.first else { return } + + // Ensure we have access to the file + guard fileURL.startAccessingSecurityScopedResource() else { return } + defer { fileURL.stopAccessingSecurityScopedResource() } + + Task { + if let pagePath = await meetingNoteService.importRecording( + fileURL: fileURL, + workspace: workspace, + transcriptionService: transcriptionService, + aiService: aiService, + apiKey: appState.settings.anthropicApiKey, + model: appState.settings.anthropicModel + ) { + onNavigateToFile(pagePath) + } + } + } + private func syncCalendar() { guard let workspace = appState.workspacePath else { return } let token = loadGoogleToken() @@ -197,14 +246,11 @@ struct WorkspaceCalendarView: View { private func loadGoogleToken() -> GoogleOAuthToken? { let settings = appState.settings - guard !settings.googleCalendarClientId.isEmpty, - !settings.googleCalendarRefreshToken.isEmpty else { return nil } + guard !settings.googleCalendarRefreshToken.isEmpty else { return nil } return GoogleOAuthToken( accessToken: settings.googleCalendarAccessToken, refreshToken: settings.googleCalendarRefreshToken, - expiresAt: Date(timeIntervalSince1970: settings.googleCalendarTokenExpiry), - clientId: settings.googleCalendarClientId, - clientSecret: settings.googleCalendarClientSecret + expiresAt: Date(timeIntervalSince1970: settings.googleCalendarTokenExpiry) ) } } diff --git a/Sources/Bugbook/Views/Canvas/CanvasCardView.swift b/Sources/Bugbook/Views/Canvas/CanvasCardView.swift deleted file mode 100644 index 33283d6..0000000 --- a/Sources/Bugbook/Views/Canvas/CanvasCardView.swift +++ /dev/null @@ -1,300 +0,0 @@ -import SwiftUI - -struct CanvasCardView: View { - var document: CanvasDocument - let node: CanvasNodeMeta - let zoom: CGFloat - var onNavigateToFile: ((String) -> Void)? - - @State private var isDragging = false - @State private var isResizing = false - @State private var dragStart: CGPoint = .zero - @State private var resizeStart: CGSize = .zero - - private var isSelected: Bool { document.selectedNodeIds.contains(node.id) } - private var isEditing: Bool { document.editingNodeId == node.id } - - var body: some View { - ZStack(alignment: .bottomTrailing) { - cardContent - .frame(width: node.width, height: node.height) - .background(cardBackground) - .clipShape(.rect(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.2), lineWidth: isSelected ? 2 : 1) - ) - .shadow(color: .black.opacity(0.08), radius: 4, y: 2) - - // Resize handle (single-select only) - if isSelected && document.selectedNodeIds.count == 1 { - resizeHandle - } - - // Anchor dots for edge creation (single-select only) - if isSelected && document.selectedNodeIds.count == 1 { - ForEach(["top", "right", "bottom", "left"], id: \.self) { side in - anchorDot(side: side) - } - } - } - .position(x: node.x + node.width / 2, y: node.y + node.height / 2) - .onTapGesture { - document.selectedEdgeId = nil - if NSEvent.modifierFlags.contains(.shift) { - document.toggleNodeSelection(node.id) - } else { - document.selectedNodeId = node.id - } - } - .simultaneousGesture( - TapGesture(count: 2).onEnded { - switch node.type { - case .text: - document.editingNodeId = node.id - case .file: - if let path = document.resolveFilePath(for: node) { - onNavigateToFile?(path) - } - case .image: - break - } - } - ) - .gesture(nodeDragGesture) - } - - // MARK: - Card Content - - @ViewBuilder - private var cardContent: some View { - switch node.type { - case .text: - textCardContent - case .file: - fileCardContent - case .image: - imageCardContent - } - } - - @ViewBuilder - private var textCardContent: some View { - let text = document.nodeTexts[node.id] ?? "" - if isEditing { - TextEditorWrapper( - text: Binding( - get: { document.nodeTexts[node.id] ?? "" }, - set: { document.updateNodeText(id: node.id, text: $0) } - ), - onCommit: { document.editingNodeId = nil } - ) - .padding(12) - } else { - VStack(alignment: .leading, spacing: 0) { - if text.isEmpty { - Text("Double-click to edit") - .font(.system(size: 14)) - .foregroundStyle(.secondary.opacity(0.5)) - .padding(12) - } else { - Text(text) - .font(.system(size: 14)) - .foregroundStyle(.primary) - .lineLimit(nil) - .padding(12) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - @ViewBuilder - private var fileCardContent: some View { - HStack(spacing: 10) { - Image(systemName: "doc.text") - .font(.system(size: 20)) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(document.fileNodeDisplayName(for: node)) - .font(.system(size: 14, weight: .medium)) - .lineLimit(1) - if let file = node.file { - Text(file) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Spacer() - Image(systemName: "arrow.right") - .font(.system(size: 12)) - .foregroundStyle(.secondary.opacity(0.5)) - } - .padding(12) - } - - @ViewBuilder - private var imageCardContent: some View { - if let file = node.file { - let imagePath = (document.canvasPath as NSString).appendingPathComponent(file) - if let nsImage = NSImage(contentsOfFile: imagePath) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fill) - .contentShape(Rectangle()) - } else { - VStack(spacing: 4) { - Image(systemName: "photo") - .font(.system(size: 24)) - .foregroundStyle(.secondary) - Text("Image not found") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - } - } - } - - // MARK: - Style - - private var cardBackground: Color { - if let colorName = node.color { - return canvasColor(colorName).opacity(0.15) - } - return Color.fallbackBgPrimary - } - - private func canvasColor(_ name: String) -> Color { - switch name { - case "red": return .red - case "orange": return .orange - case "yellow": return .yellow - case "green": return .green - case "blue": return .blue - case "purple": return .purple - default: return .gray - } - } - - // MARK: - Resize Handle - - private var resizeHandle: some View { - Rectangle() - .fill(Color.accentColor.opacity(0.3)) - .frame(width: 12, height: 12) - .clipShape(.rect(cornerRadius: 2)) - .padding(4) - .contentShape(Rectangle().size(width: 20, height: 20)) - .gesture( - DragGesture() - .onChanged { value in - if !isResizing { - isResizing = true - resizeStart = CGSize(width: node.width, height: node.height) - } - let newWidth = resizeStart.width + value.translation.width / zoom - let newHeight = resizeStart.height + value.translation.height / zoom - document.resizeNode(id: node.id, width: newWidth, height: newHeight) - } - .onEnded { _ in - isResizing = false - } - ) - } - - // MARK: - Anchor Dots - - @ViewBuilder - private func anchorDot(side: String) -> some View { - let dotSize: CGFloat = 8 - Circle() - .fill(Color.accentColor) - .frame(width: dotSize, height: dotSize) - .position(anchorPosition(side: side)) - .gesture( - DragGesture(minimumDistance: 5) - .onEnded { value in - // Find target node at drop location - let dropPoint = CGPoint( - x: node.x + node.width / 2 + value.translation.width / zoom, - y: node.y + node.height / 2 + value.translation.height / zoom - ) - if let targetId = hitTestNode(at: dropPoint, excluding: node.id) { - let toSide = oppositeSide(side) - document.addEdge(from: node.id, to: targetId, fromSide: side, toSide: toSide) - } - } - ) - } - - private func anchorPosition(side: String) -> CGPoint { - switch side { - case "top": return CGPoint(x: node.width / 2, y: -4) - case "right": return CGPoint(x: node.width + 4, y: node.height / 2) - case "bottom": return CGPoint(x: node.width / 2, y: node.height + 4) - case "left": return CGPoint(x: -4, y: node.height / 2) - default: return .zero - } - } - - private func oppositeSide(_ side: String) -> String { - switch side { - case "top": return "bottom" - case "bottom": return "top" - case "left": return "right" - case "right": return "left" - default: return "left" - } - } - - private func hitTestNode(at point: CGPoint, excluding nodeId: String) -> String? { - for n in document.nodes where n.id != nodeId { - let rect = CGRect(x: n.x, y: n.y, width: n.width, height: n.height) - if rect.contains(point) { - return n.id - } - } - return nil - } - - // MARK: - Drag Gesture - - private var nodeDragGesture: some Gesture { - DragGesture() - .onChanged { value in - if !isDragging { - isDragging = true - dragStart = CGPoint(x: node.x, y: node.y) - } - let newX = dragStart.x + value.translation.width / zoom - let newY = dragStart.y + value.translation.height / zoom - document.moveNode(id: node.id, to: CGPoint(x: newX, y: newY)) - } - .onEnded { _ in - isDragging = false - } - } -} - -// MARK: - TextEditor Wrapper - -private struct TextEditorWrapper: View { - @Binding var text: String - var onCommit: () -> Void - @FocusState private var isFocused: Bool - - var body: some View { - TextEditor(text: $text) - .font(.system(size: 14)) - .scrollContentBackground(.hidden) - .background(Color.clear) - .focused($isFocused) - .onAppear { isFocused = true } - .onKeyPress(.escape) { - onCommit() - return .handled - } - } -} diff --git a/Sources/Bugbook/Views/Canvas/CanvasToolbar.swift b/Sources/Bugbook/Views/Canvas/CanvasToolbar.swift deleted file mode 100644 index acdf418..0000000 --- a/Sources/Bugbook/Views/Canvas/CanvasToolbar.swift +++ /dev/null @@ -1,103 +0,0 @@ -import SwiftUI - -struct CanvasToolbar: View { - var document: CanvasDocument - var visibleCenter: CGPoint - var onAddFilePicker: () -> Void - var onAddImage: () -> Void - - var body: some View { - HStack(spacing: 12) { - Button(action: addTextNode) { - Label("Text", systemImage: "text.alignleft") - .font(.system(size: 13, weight: .medium)) - } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.primary.opacity(0.06)) - .clipShape(.rect(cornerRadius: 6)) - - Button(action: onAddFilePicker) { - Label("Page", systemImage: "doc.text") - .font(.system(size: 13, weight: .medium)) - } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.primary.opacity(0.06)) - .clipShape(.rect(cornerRadius: 6)) - - Button(action: onAddImage) { - Label("Image", systemImage: "photo") - .font(.system(size: 13, weight: .medium)) - } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.primary.opacity(0.06)) - .clipShape(.rect(cornerRadius: 6)) - - if !document.selectedNodeIds.isEmpty || document.selectedEdgeId != nil { - Divider().frame(height: 20) - Button(action: deleteSelected) { - Label("Delete", systemImage: "trash") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.red) - } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.red.opacity(0.06)) - .clipShape(.rect(cornerRadius: 6)) - } - - Spacer() - - // Zoom controls - HStack(spacing: 4) { - Button(action: zoomOut) { - Image(systemName: "minus.magnifyingglass") - .font(.system(size: 13)) - } - .buttonStyle(.plain) - - Text("\(Int(document.viewport.zoom * 100))%") - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 40) - - Button(action: zoomIn) { - Image(systemName: "plus.magnifyingglass") - .font(.system(size: 13)) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.primary.opacity(0.04)) - .clipShape(.rect(cornerRadius: 6)) - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.ultraThinMaterial) - .clipShape(.rect(cornerRadius: 10)) - .shadow(color: .black.opacity(0.1), radius: 8, y: 2) - } - - private func addTextNode() { - document.addTextNode(at: visibleCenter) - } - - private func deleteSelected() { - document.deleteSelection() - } - - private func zoomIn() { - document.viewport.zoom = min(3.0, document.viewport.zoom + 0.1) - } - - private func zoomOut() { - document.viewport.zoom = max(0.3, document.viewport.zoom - 0.1) - } -} diff --git a/Sources/Bugbook/Views/Canvas/CanvasView.swift b/Sources/Bugbook/Views/Canvas/CanvasView.swift deleted file mode 100644 index 5a62d07..0000000 --- a/Sources/Bugbook/Views/Canvas/CanvasView.swift +++ /dev/null @@ -1,487 +0,0 @@ -import SwiftUI -import AppKit -import UniformTypeIdentifiers - -struct CanvasView: View { - var document: CanvasDocument - var onNavigateToFile: ((String) -> Void)? - var availablePages: [FileEntry] = [] - - @State private var panOffset: CGSize = .zero - @State private var isPanning = false - @State private var showFilePicker = false - @State private var baseZoom: CGFloat = 1.0 - @State private var dropTargetActive = false - @State private var canvasSize: CGSize = CGSize(width: 800, height: 600) - @State private var lastMouseLocation: CGPoint = CGPoint(x: 400, y: 300) - - private var zoom: CGFloat { document.viewport.zoom } - - private var visibleCenter: CGPoint { - CGPoint( - x: (canvasSize.width / 2 - document.viewport.x) / zoom, - y: (canvasSize.height / 2 - document.viewport.y) / zoom - ) - } - - var body: some View { - ZStack { - // Canvas background - canvasBackground - - // Viewport-transformed content - canvasContent - .scaleEffect(zoom) - .offset( - x: document.viewport.x + panOffset.width, - y: document.viewport.y + panOffset.height - ) - - // Error overlay for corrupted canvas - if case .corrupted(let message) = document.loadResult { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 32)) - .foregroundStyle(.orange) - Text("Canvas data is corrupted") - .font(.system(size: 15, weight: .semibold)) - Text(message) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(3) - Text("The original file has been preserved.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - .padding(24) - .background(.ultraThinMaterial, in: .rect(cornerRadius: 12)) - } - - // Floating toolbar - VStack { - Spacer() - CanvasToolbar( - document: document, - visibleCenter: visibleCenter, - onAddFilePicker: { showFilePicker = true }, - onAddImage: { pickImageFromDisk() } - ) - .padding(.horizontal, 40) - .padding(.bottom, 16) - } - } - .clipped() - .onGeometryChange(for: CGSize.self) { $0.size } action: { canvasSize = $0 } - .onContinuousHover { phase in - if case .active(let location) = phase { - lastMouseLocation = location - } - } - .overlay(CanvasScrollZoomView(document: document, baseZoom: $baseZoom)) - .onKeyPress(.delete) { - deleteSelected() - return .handled - } - .onKeyPress(.init(Character(UnicodeScalar(127)))) { // backspace - deleteSelected() - return .handled - } - .focusable() - .focusEffectDisabled() - .onCommand(#selector(UndoManager.undo)) { document.undo() } - .onCommand(#selector(UndoManager.redo)) { document.redo() } - .onPasteCommand(of: [UTType.png, UTType.tiff, UTType.image]) { providers in - for provider in providers { - provider.loadDataRepresentation(forTypeIdentifier: UTType.image.identifier) { data, _ in - guard let data = data, let image = NSImage(data: data) else { return } - Task { @MainActor in - document.addImageNode(at: visibleCenter, image: image) - } - } - } - } - .task { baseZoom = document.viewport.zoom } - .task(id: document.isDirty) { - guard document.isDirty else { return } - try? await Task.sleep(for: .seconds(1)) - document.save() - } - .onDisappear { - if document.isDirty { document.save() } - } - .sheet(isPresented: $showFilePicker) { - CanvasFilePickerView( - pages: availablePages, - onSelect: { entry in - document.addFileNode(at: visibleCenter, filePath: entry.path) - showFilePicker = false - }, - onDismiss: { showFilePicker = false } - ) - } - } - - // MARK: - Background - - private var canvasBackground: some View { - ZStack { - Color.fallbackEditorBg - - // Dot grid pattern - Canvas { context, size in - let spacing: CGFloat = 24 * zoom - guard spacing > 6 else { return } // hide dots when too zoomed out - let offsetX = document.viewport.x.truncatingRemainder(dividingBy: spacing) + panOffset.width.truncatingRemainder(dividingBy: spacing) - let offsetY = document.viewport.y.truncatingRemainder(dividingBy: spacing) + panOffset.height.truncatingRemainder(dividingBy: spacing) - let dotSize: CGFloat = max(1.0, 1.5 * zoom) - let cols = Int(size.width / spacing) + 2 - let rows = Int(size.height / spacing) + 2 - for col in 0..= -spacing, x <= size.width + spacing, - y >= -spacing, y <= size.height + spacing else { continue } - context.fill( - Path(ellipseIn: CGRect(x: x - dotSize / 2, y: y - dotSize / 2, width: dotSize, height: dotSize)), - with: .color(.secondary.opacity(0.15)) - ) - } - } - } - .allowsHitTesting(false) - - // Empty state hint - if document.nodes.isEmpty { - VStack(spacing: 8) { - Text("Drag from below or double click") - Text("Space + Drag to pan") - Text("\u{2318} + Scroll to zoom") - } - .font(.system(size: 15)) - .foregroundStyle(.secondary.opacity(0.5)) - .allowsHitTesting(false) - } - } - .contentShape(Rectangle()) - .onTapGesture { - document.clearSelection() - } - .simultaneousGesture( - TapGesture(count: 2).onEnded { - // Double-click creates a text node at the location - document.addTextNode(at: visibleCenter) - } - ) - .gesture(backgroundPanGesture) - .gesture(zoomGesture) - .onDrop(of: [.fileURL, .image, .png, .tiff, .jpeg], isTargeted: $dropTargetActive) { providers, location in - let dropPoint = CGPoint( - x: (location.x - document.viewport.x) / document.viewport.zoom, - y: (location.y - document.viewport.y) / document.viewport.zoom - ) - for provider in providers { - // Try loading as a file URL first (drag from Finder) - if provider.hasItemConformingToTypeIdentifier("public.file-url") { - provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { item, _ in - guard let data = item as? Data, - let url = URL(dataRepresentation: data, relativeTo: nil), - let image = NSImage(contentsOf: url) else { return } - Task { @MainActor in - document.addImageNode(at: dropPoint, image: image) - } - } - return true - } - // Try loading as image data directly - provider.loadDataRepresentation(forTypeIdentifier: "public.image") { data, _ in - guard let data = data, let image = NSImage(data: data) else { return } - Task { @MainActor in - document.addImageNode(at: dropPoint, image: image) - } - } - } - return true - } - } - - // MARK: - Canvas Content - - private var canvasContent: some View { - ZStack { - // Edge layer - Canvas { context, size in - for edge in document.edges { - guard let fromNode = document.nodes.first(where: { $0.id == edge.fromNode }), - let toNode = document.nodes.first(where: { $0.id == edge.toNode }) else { continue } - - let isSelected = document.selectedEdgeId == edge.id - let fromPoint = anchorPoint(node: fromNode, side: edge.fromSide ?? "right") - let toPoint = anchorPoint(node: toNode, side: edge.toSide ?? "left") - - var path = Path() - path.move(to: fromPoint) - path.addLine(to: toPoint) - - context.stroke( - path, - with: .color(isSelected ? .accentColor : .secondary.opacity(0.4)), - lineWidth: isSelected ? 2 : 1.5 - ) - - // Arrow head - if edge.toEnd == "arrow" { - drawArrowHead(context: &context, from: fromPoint, to: toPoint, isSelected: isSelected) - } - - // Label - if let label = edge.label, !label.isEmpty { - let midPoint = CGPoint( - x: (fromPoint.x + toPoint.x) / 2, - y: (fromPoint.y + toPoint.y) / 2 - 12 - ) - let text = Text(label) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - context.draw(context.resolve(text), at: midPoint, anchor: .center) - } - } - } - .allowsHitTesting(false) - - // Edge hit test overlay (invisible wider lines for easier clicking) - ForEach(document.edges) { edge in - if let fromNode = document.nodes.first(where: { $0.id == edge.fromNode }), - let toNode = document.nodes.first(where: { $0.id == edge.toNode }) { - let fromPoint = anchorPoint(node: fromNode, side: edge.fromSide ?? "right") - let toPoint = anchorPoint(node: toNode, side: edge.toSide ?? "left") - Path { path in - path.move(to: fromPoint) - path.addLine(to: toPoint) - } - .stroke(Color.clear, lineWidth: 10) - .contentShape( - Path { path in - path.move(to: fromPoint) - path.addLine(to: toPoint) - }.strokedPath(StrokeStyle(lineWidth: 10)) - ) - .onTapGesture { - document.selectedNodeId = nil - document.selectedEdgeId = edge.id - } - } - } - - // Node layer - ForEach(document.nodes) { node in - CanvasCardView( - document: document, - node: node, - zoom: zoom, - onNavigateToFile: onNavigateToFile - ) - } - } - } - - // MARK: - Gestures - - private var backgroundPanGesture: some Gesture { - DragGesture() - .onChanged { value in - panOffset = value.translation - } - .onEnded { value in - document.viewport.x += value.translation.width - document.viewport.y += value.translation.height - panOffset = .zero - } - } - - private var zoomGesture: some Gesture { - MagnifyGesture() - .onChanged { value in - let oldZoom = document.viewport.zoom - guard oldZoom > 0 else { return } - let newZoom = max(0.3, min(3.0, baseZoom * value.magnification)) - let pivot = lastMouseLocation - document.viewport.x += pivot.x * (1 - newZoom / oldZoom) - document.viewport.y += pivot.y * (1 - newZoom / oldZoom) - document.viewport.zoom = newZoom - } - .onEnded { _ in - baseZoom = document.viewport.zoom - } - } - - // MARK: - Helpers - - private func anchorPoint(node: CanvasNodeMeta, side: String) -> CGPoint { - switch side { - case "top": return CGPoint(x: node.x + node.width / 2, y: node.y) - case "right": return CGPoint(x: node.x + node.width, y: node.y + node.height / 2) - case "bottom": return CGPoint(x: node.x + node.width / 2, y: node.y + node.height) - case "left": return CGPoint(x: node.x, y: node.y + node.height / 2) - default: return CGPoint(x: node.x + node.width / 2, y: node.y + node.height / 2) - } - } - - private func drawArrowHead(context: inout GraphicsContext, from: CGPoint, to: CGPoint, isSelected: Bool) { - let arrowLength: CGFloat = 10 - let arrowAngle: CGFloat = .pi / 6 - let angle = atan2(to.y - from.y, to.x - from.x) - - let p1 = CGPoint( - x: to.x - arrowLength * cos(angle - arrowAngle), - y: to.y - arrowLength * sin(angle - arrowAngle) - ) - let p2 = CGPoint( - x: to.x - arrowLength * cos(angle + arrowAngle), - y: to.y - arrowLength * sin(angle + arrowAngle) - ) - - var arrowPath = Path() - arrowPath.move(to: to) - arrowPath.addLine(to: p1) - arrowPath.addLine(to: p2) - arrowPath.closeSubpath() - - context.fill( - arrowPath, - with: .color(isSelected ? .accentColor : .secondary.opacity(0.4)) - ) - } - - private func pickImageFromDisk() { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.png, .jpeg, .tiff, .gif, .bmp, .heic] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - guard panel.runModal() == .OK, let url = panel.url else { return } - let accessing = url.startAccessingSecurityScopedResource() - defer { if accessing { url.stopAccessingSecurityScopedResource() } } - guard let image = NSImage(contentsOf: url) else { return } - document.addImageNode(at: visibleCenter, image: image) - } - - private func pasteFromClipboard() { - let pb = NSPasteboard.general - // Check for image data on the pasteboard - guard let imageType = pb.availableType(from: [.tiff, .png]), - let data = pb.data(forType: imageType), - let image = NSImage(data: data) else { return } - document.addImageNode(at: visibleCenter, image: image) - } - - private func deleteSelected() { - document.deleteSelection() - } -} - -// MARK: - File Picker for Canvas - -struct CanvasFilePickerView: View { - let pages: [FileEntry] - var onSelect: (FileEntry) -> Void - var onDismiss: () -> Void - - @State private var searchText = "" - - private var filteredPages: [FileEntry] { - let flat = flattenEntries(pages) - if searchText.isEmpty { return flat } - return flat.filter { $0.name.localizedStandardContains(searchText) } - } - - var body: some View { - VStack(spacing: 0) { - HStack { - Text("Link a Page") - .font(.system(size: 15, weight: .semibold)) - Spacer() - Button("Cancel") { onDismiss() } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - } - .padding() - - TextField("Search pages...", text: $searchText) - .textFieldStyle(.roundedBorder) - .padding(.horizontal) - - List(filteredPages, id: \.id) { entry in - Button(action: { onSelect(entry) }) { - HStack(spacing: 8) { - Image(systemName: "doc.text") - .foregroundStyle(.secondary) - Text(entry.name.replacingOccurrences(of: ".md", with: "")) - } - } - .buttonStyle(.plain) - } - .frame(height: 300) - } - .frame(width: 400) - .padding(.bottom) - } - - private func flattenEntries(_ entries: [FileEntry]) -> [FileEntry] { - var result: [FileEntry] = [] - for entry in entries { - if !entry.isDirectory && !entry.isDatabase && !entry.isCanvas { - result.append(entry) - } - if let children = entry.children { - result.append(contentsOf: flattenEntries(children)) - } - } - return result - } -} - -// MARK: - Scroll Wheel Zoom (Cmd+Scroll) - -private struct CanvasScrollZoomView: NSViewRepresentable { - var document: CanvasDocument - @Binding var baseZoom: CGFloat - - private var zoomHandler: (CGFloat, CGPoint) -> Void { - { [document, baseZoom = _baseZoom] deltaY, mouseLocation in - let sensitivity: CGFloat = 0.01 - let oldZoom = document.viewport.zoom - guard oldZoom > 0 else { return } - let newZoom = max(0.3, min(3.0, oldZoom + deltaY * sensitivity)) - // Pivot zoom around the mouse location - document.viewport.x += mouseLocation.x * (1 - newZoom / oldZoom) - document.viewport.y += mouseLocation.y * (1 - newZoom / oldZoom) - document.viewport.zoom = newZoom - baseZoom.wrappedValue = newZoom - } - } - - func makeNSView(context: Context) -> CanvasScrollCaptureNSView { - let view = CanvasScrollCaptureNSView() - view.onCmdScroll = zoomHandler - return view - } - - func updateNSView(_ nsView: CanvasScrollCaptureNSView, context: Context) { - nsView.onCmdScroll = zoomHandler - } -} - -private class CanvasScrollCaptureNSView: NSView { - var onCmdScroll: ((CGFloat, CGPoint) -> Void)? - - override var isFlipped: Bool { true } - - override func scrollWheel(with event: NSEvent) { - if event.modifierFlags.contains(.command) { - let locationInView = convert(event.locationInWindow, from: nil) - onCmdScroll?(event.scrollingDeltaY, locationInView) - } else { - super.scrollWheel(with: event) - } - } -} diff --git a/Sources/Bugbook/Views/Components/CommandPaletteView.swift b/Sources/Bugbook/Views/Components/CommandPaletteView.swift index 59146ac..9ebef2f 100644 --- a/Sources/Bugbook/Views/Components/CommandPaletteView.swift +++ b/Sources/Bugbook/Views/Components/CommandPaletteView.swift @@ -82,6 +82,11 @@ struct CommandPaletteView: View { var onSelectContentMatch: ((FileEntry, String) -> Void)? var body: some View { + let items = allItems + let indexMap = Dictionary(items.enumerated().map { ($0.element.id, $0.offset) }, + uniquingKeysWith: { first, _ in first }) + let sections = groupedSections(items) + VStack(spacing: 0) { // Search field HStack { @@ -101,9 +106,6 @@ struct CommandPaletteView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 1) { - let items = allItems - let sections = groupedSections(items) - if items.isEmpty && !searchText.isEmpty { Text("No results") .foregroundStyle(.secondary) @@ -114,8 +116,8 @@ struct CommandPaletteView: View { ForEach(sections, id: \.title) { section in SectionHeader(title: section.title) - ForEach(section.items.enumerated(), id: \.element.id) { _, item in - let idx = globalIndex(of: item, in: items) + ForEach(section.items) { item in + let idx = indexMap[item.id] ?? 0 paletteRow(item: item, index: idx) .id(item.id) } @@ -125,7 +127,6 @@ struct CommandPaletteView: View { } .frame(maxHeight: 350) .onChange(of: selectedIndex) { _, newIndex in - let items = allItems if newIndex >= 0, newIndex < items.count { proxy.scrollTo(items[newIndex].id, anchor: .center) } @@ -139,7 +140,7 @@ struct CommandPaletteView: View { return .handled } .onKeyPress(.downArrow) { - selectedIndex = min(allItems.count - 1, selectedIndex + 1) + selectedIndex = min(items.count - 1, selectedIndex + 1) return .handled } .onKeyPress(.escape) { @@ -413,9 +414,6 @@ struct CommandPaletteView: View { PaletteCommand(id: "new_database", name: "New Database", icon: "tablecells.badge.ellipsis", shortcut: nil) { NotificationCenter.default.post(name: .newDatabase, object: nil) }, - PaletteCommand(id: "new_canvas", name: "New Canvas", icon: "rectangle.on.rectangle.angled", shortcut: nil) { - NotificationCenter.default.post(name: .newCanvas, object: nil) - }, PaletteCommand(id: "open_settings", name: "Open Settings", icon: "gear", shortcut: "Cmd+,") { NotificationCenter.default.post(name: .openSettings, object: nil) }, @@ -437,7 +435,7 @@ struct CommandPaletteView: View { private func flattenFileTree(_ entries: [FileEntry]) -> [FileEntry] { var result: [FileEntry] = [] for entry in entries { - if (!entry.isDirectory || entry.isDatabase || entry.isCanvas) { + if (!entry.isDirectory || entry.isDatabase) { result.append(entry) } if let children = entry.children { @@ -511,29 +509,37 @@ struct CommandPaletteView: View { let fm = FileManager.default guard let enumerator = fm.enumerator(atPath: workspace) else { return [IndexedContentLine]() } + // Single pass: collect all relative paths, building excludedDirs on the fly var excludedDirs: Set = [] - if let scanner = fm.enumerator(atPath: workspace) { - while let rel = scanner.nextObject() as? String { - guard !Task.isCancelled else { return [] } - let filename = (rel as NSString).lastPathComponent - if filename == "_schema.json" || filename == "_canvas.json" { - let dir = (rel as NSString).deletingLastPathComponent - excludedDirs.insert(dir) - } - } - } - - var indexed: [IndexedContentLine] = [] - let maxLineLength = 160 + var mdFiles: [(relativePath: String, filename: String)] = [] while let relativePath = enumerator.nextObject() as? String { - guard !Task.isCancelled else { break } + guard !Task.isCancelled else { return [] } if WorkspacePathRules.shouldIgnoreRelativePath(relativePath) { continue } let components = relativePath.components(separatedBy: "/") if components.contains(where: { $0.hasPrefix(".") }) { continue } let filename = (relativePath as NSString).lastPathComponent - guard filename.hasSuffix(".md") else { continue } + + // Track database directories + if filename == "_schema.json" { + let dir = (relativePath as NSString).deletingLastPathComponent + excludedDirs.insert(dir) + continue + } + + if filename.hasSuffix(".md") { + mdFiles.append((relativePath, filename)) + } + } + + guard !Task.isCancelled else { return [] } + + var indexed: [IndexedContentLine] = [] + let maxLineLength = 160 + + for (relativePath, filename) in mdFiles { + guard !Task.isCancelled else { break } let parentDir = (relativePath as NSString).deletingLastPathComponent if excludedDirs.contains(parentDir) { continue } @@ -642,7 +648,7 @@ struct CommandPaletteView: View { let task = Process() task.executableURL = URL(fileURLWithPath: binary) task.arguments = [mode == "bm25" ? "search" : mode == "semantic" ? "vsearch" : "query", - query, "--json", "-n", "20", "-c", collection] + query, "--json", "-n", "20", "-c", collection, "--min-score", "0.3"] let pipe = Pipe() task.standardOutput = pipe task.standardError = FileHandle.nullDevice @@ -701,10 +707,6 @@ struct CommandPaletteView: View { // MARK: - Selection - private func globalIndex(of item: PaletteItem, in items: [PaletteItem]) -> Int { - items.firstIndex(where: { $0.id == item.id }) ?? 0 - } - private func selectCurrent() { let items = allItems guard selectedIndex >= 0, selectedIndex < items.count else { return } @@ -785,14 +787,8 @@ struct CommandPaletteView: View { @ViewBuilder private func defaultFileIcon(for entry: FileEntry) -> some View { - if entry.isCanvas { - Image(systemName: "rectangle.on.rectangle.angled") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } else { - Image(systemName: entry.isDatabase ? "tablecells" : "doc.text") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } + Image(systemName: entry.isDatabase ? "tablecells" : "doc.text") + .font(.system(size: 13)) + .foregroundStyle(.secondary) } } diff --git a/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift new file mode 100644 index 0000000..1f7748a --- /dev/null +++ b/Sources/Bugbook/Views/Components/FloatingRecordingPill.swift @@ -0,0 +1,196 @@ +import AppKit +import SwiftUI + +// MARK: - Floating Recording Pill Panel + +/// A small always-on-top pill that appears when a meeting is recording and Bugbook +/// loses focus. Shows animated green audio bars inside a dark capsule. +/// Clicking it brings Bugbook back to the front. +final class FloatingRecordingPillPanel: NSPanel { + private let hostingView: NSHostingView + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + init() { + self.hostingView = NSHostingView(rootView: RecordingPillView()) + + super.init( + contentRect: NSRect(x: 0, y: 0, width: 60, height: 30), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + isOpaque = false + backgroundColor = .clear + hasShadow = true + level = .floating + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + isMovableByWindowBackground = true + hidesOnDeactivate = false + contentView = hostingView + } + + func showPill() { + hostingView.rootView = RecordingPillView(isAnimating: true) + + // Re-evaluate size and position each show (handles display changes) + let size = hostingView.fittingSize + setContentSize(size) + if let screen = NSScreen.main { + let screenFrame = screen.visibleFrame + let x = screenFrame.midX - size.width / 2 + let y = screenFrame.maxY - size.height - 12 + setFrameOrigin(NSPoint(x: x, y: y)) + } + + orderFront(nil) + } + + func hidePill() { + hostingView.rootView = RecordingPillView(isAnimating: false) + orderOut(nil) + } +} + +// MARK: - Recording Pill SwiftUI View + +private struct RecordingPillView: View { + var isAnimating: Bool = true + + var body: some View { + HStack(spacing: 6) { + // App icon (small ladybug) + Image(systemName: "ladybug.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.9)) + + // Animated audio bars + AudioBarsView(isAnimating: isAnimating) + .frame(width: 16, height: 14) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color(hex: "1a1a1a")) + .shadow(color: .black.opacity(0.3), radius: 4, y: 2) + ) + .contentShape(Capsule()) + .onTapGesture { + NSApplication.shared.activate(ignoringOtherApps: true) + } + } +} + +// MARK: - Animated Audio Bars + +private struct AudioBarsView: View { + var isAnimating: Bool + + private static let barCount = 3 + private static let spacing: CGFloat = 1.5 + + var body: some View { + TimelineView(.animation(minimumInterval: 0.15, paused: !isAnimating)) { timeline in + HStack(spacing: Self.spacing) { + ForEach(0.. CGFloat { + let t = date.timeIntervalSinceReferenceDate + // Different frequency per bar so they don't sync up + let freq = 2.5 + Double(seed) * 1.3 + let raw = (sin(t * freq) + 1) / 2 // 0...1 + let jitter = sin(t * freq * 2.7) * 0.15 // small wobble + return max(minFraction, min(1.0, raw + jitter)) + } +} + +// MARK: - Controller + +/// Manages the lifecycle of the floating recording pill. +/// Owns the panel and responds to app activation / recording state changes. +@MainActor +final class FloatingRecordingPillController { + private var panel: FloatingRecordingPillPanel? + private var activateObserver: NSObjectProtocol? + private var resignObserver: NSObjectProtocol? + + /// Whether recording is active. Set from outside; the controller handles show/hide. + var isRecording: Bool = false { + didSet { + guard isRecording != oldValue else { return } + updateVisibility() + } + } + + init() { + activateObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.updateVisibility() } + } + + resignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.updateVisibility() } + } + } + + /// Tear down the panel and notification observers. Call from `.onDisappear` + /// so cleanup runs on MainActor (deinit is nonisolated and can't do this safely). + func cleanup() { + if let o = activateObserver { NotificationCenter.default.removeObserver(o) } + if let o = resignObserver { NotificationCenter.default.removeObserver(o) } + activateObserver = nil + resignObserver = nil + panel?.orderOut(nil) + panel = nil + } + + private func updateVisibility() { + let shouldShow = isRecording && !NSApplication.shared.isActive + if shouldShow { + if panel == nil { + panel = FloatingRecordingPillPanel() + } + panel?.showPill() + } else { + panel?.hidePill() + } + } +} diff --git a/Sources/Bugbook/Views/Components/MovePagePickerView.swift b/Sources/Bugbook/Views/Components/MovePagePickerView.swift index af40675..1fb672d 100644 --- a/Sources/Bugbook/Views/Components/MovePagePickerView.swift +++ b/Sources/Bugbook/Views/Components/MovePagePickerView.swift @@ -65,7 +65,7 @@ struct MovePagePickerView: View { .padding(.horizontal, 12) .padding(.vertical, 16) } else { - ForEach(items.enumerated(), id: \.element.id) { index, dest in + ForEach(Array(items.enumerated()), id: \.element.id) { index, dest in destinationRow(dest, index: index) .id(dest.id) } @@ -213,8 +213,6 @@ struct MovePagePickerView: View { )) } else if entry.isDatabase { // Skip databases as move targets - } else if entry.isCanvas { - // Skip canvases as move targets } if let children = entry.children { diff --git a/Sources/Bugbook/Views/Components/SidebarDragPreview.swift b/Sources/Bugbook/Views/Components/SidebarDragPreview.swift new file mode 100644 index 0000000..5fd94e9 --- /dev/null +++ b/Sources/Bugbook/Views/Components/SidebarDragPreview.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Pill-shaped drag preview used when dragging pages/databases to the sidebar. +struct SidebarDragPreview: View { + let systemImage: String + let title: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} diff --git a/Sources/Bugbook/Views/Components/TemplatePickerView.swift b/Sources/Bugbook/Views/Components/TemplatePickerView.swift index 5db06c0..7f21d23 100644 --- a/Sources/Bugbook/Views/Components/TemplatePickerView.swift +++ b/Sources/Bugbook/Views/Components/TemplatePickerView.swift @@ -36,7 +36,7 @@ struct TemplatePickerView: View { } else { ScrollView { VStack(spacing: 2) { - ForEach(templates.enumerated(), id: \.element.id) { index, template in + ForEach(Array(templates.enumerated()), id: \.element.id) { index, template in let displayName = template.name.hasSuffix(".md") ? String(template.name.dropLast(3)) : template.name diff --git a/Sources/Bugbook/Views/Components/WelcomeView.swift b/Sources/Bugbook/Views/Components/WelcomeView.swift index 585780e..3039ef4 100644 --- a/Sources/Bugbook/Views/Components/WelcomeView.swift +++ b/Sources/Bugbook/Views/Components/WelcomeView.swift @@ -11,8 +11,7 @@ struct WelcomeView: View { Image("BugbookLogo") .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 100, height: 100) - .offset(x: -6) + .frame(width: 56, height: 56) VStack(spacing: 6) { Text("Bugbook") diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift index 952b086..7204856 100644 --- a/Sources/Bugbook/Views/ContentView.swift +++ b/Sources/Bugbook/Views/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI import AppKit import os import Sentry +import BugbookCore struct ContentView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -14,12 +15,11 @@ struct ContentView: View { @State private var calendarService = CalendarService() @State private var calendarVM = CalendarViewModel() @State private var meetingNoteService = MeetingNoteService() + @State private var transcriptionService = TranscriptionService() @State private var backlinkService = BacklinkService() @State private var blockDocuments: [UUID: BlockDocument] = [:] - @State private var flashcardCards: [FlashcardItem] = [] - @State private var canvasDocuments: [UUID: CanvasDocument] = [:] + @State private var saveTask: Task? - @State private var canvasSaveTask: Task? @State private var sidebarPeek = SidebarPeekState() @State private var editorUI = EditorUIState() @State private var themeToast: ThemeMode? @@ -29,6 +29,7 @@ struct ContentView: View { @State private var aiInitCompleted = false @State private var workspaceWatcher: WorkspaceWatcher? @State private var lastTrashPurgeWorkspace: String? + @State private var recordingPillController = FloatingRecordingPillController() @AppStorage(EditorTypography.zoomScaleKey) private var editorZoomScale = Double(EditorTypography.defaultZoomScale) // Database row peek / modal @@ -65,7 +66,7 @@ struct ContentView: View { sidebarToggleOverlay sidebarPeekOverlay commandPaletteOverlay - flashcardReviewOverlay + movePageOverlay themeToastOverlay editorZoomOverlay @@ -102,8 +103,8 @@ struct ContentView: View { guard oldValue != clamped else { return } editorUI.showZoomHud() } - .onChange(of: appState.settings.qmdSearchMode) { _, mode in - QmdService.prewarmDaemonIfNeeded(mode: mode) + .onChange(of: appState.settings.qmdSearchMode) { _, _ in + // v2: no daemon needed, qmd query runs locally } .onChange(of: appState.sidebarOpen) { _, _ in sidebarPeek.sync(eligible: sidebarPeekEligible, reduceMotion: reduceMotion) @@ -148,6 +149,9 @@ struct ContentView: View { ensureAiInitializedIfNeeded() } } + .onChange(of: appState.isRecording) { _, recording in + recordingPillController.isRecording = recording + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willResignActiveNotification)) { _ in flushDirtyTabs() } @@ -161,6 +165,7 @@ struct ContentView: View { editorUI.cleanUp() sidebarPeek.cleanUp() workspaceWatcher?.stop() + recordingPillController.cleanup() } .onReceive(NotificationCenter.default.publisher(for: .fileDeleted)) { notification in if let path = notification.object as? String { @@ -191,7 +196,9 @@ struct ContentView: View { if let info = notification.userInfo, let sourcePath = info["sourcePath"] as? String, let destDir = info["destDir"] as? String { - performMovePage(from: sourcePath, toDirectory: destDir) + let insertIndex = info["insertIndex"] as? Int + let siblingNames = info["siblings"] as? [String] + performMovePage(from: sourcePath, toDirectory: destDir, insertIndex: insertIndex, siblingNames: siblingNames) } } } @@ -224,10 +231,12 @@ struct ContentView: View { handleSidebarToggleRequest() } .onReceive(NotificationCenter.default.publisher(for: .quickOpen)) { _ in + flushDirtyTabContent() appState.commandPaletteMode = .search appState.commandPaletteOpen.toggle() } .onReceive(NotificationCenter.default.publisher(for: .quickOpenNewTab)) { _ in + flushDirtyTabContent() appState.commandPaletteMode = .newTab appState.commandPaletteOpen = true } @@ -250,16 +259,13 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .openCalendar)) { _ in appState.openCalendar() } - .onReceive(NotificationCenter.default.publisher(for: .reviewFlashcards)) { _ in - flashcardCards = collectFlashcards() - appState.flashcardReviewOpen = true + .onReceive(NotificationCenter.default.publisher(for: .openMeetings)) { _ in + appState.openMeetings() } + .onReceive(NotificationCenter.default.publisher(for: .newDatabase)) { _ in createNewDatabase() } - .onReceive(NotificationCenter.default.publisher(for: .newCanvas)) { _ in - createNewCanvas() - } .onReceive(NotificationCenter.default.publisher(for: .navigateBack)) { _ in navigateBackInActiveTab() } @@ -474,23 +480,6 @@ struct ContentView: View { } } - @ViewBuilder - private var flashcardReviewOverlay: some View { - if appState.flashcardReviewOpen { - FlashcardReviewView( - cards: flashcardCards, - onDismiss: { appState.flashcardReviewOpen = false } - ) - .zIndex(100) - } - } - - private func collectFlashcards() -> [FlashcardItem] { - guard let tab = appState.activeTab, - let doc = blockDocuments[tab.id] else { return [] } - let name = tab.displayName ?? (tab.path as NSString).lastPathComponent - return FlashcardScanner.scan(document: doc, pageName: name) - } @ViewBuilder private func sidebarChromeButton( @@ -568,11 +557,10 @@ struct ContentView: View { } } - private func performMovePage(from sourcePath: String, toDirectory destDir: String) { + private func performMovePage(from sourcePath: String, toDirectory destDir: String, insertIndex: Int? = nil, siblingNames: [String]? = nil) { do { let newPath = try fileSystem.movePage(at: sourcePath, toDirectory: destDir) let oldPath = sourcePath - let movingDatabase = fileSystem.isDatabaseFolder(at: newPath) // Update any open tabs pointing to the old path for tab in appState.openTabs { @@ -610,26 +598,12 @@ struct ContentView: View { } } - // Insert a page link in the parent page's content - let parentPagePath = destDir + ".md" - if !movingDatabase, FileManager.default.fileExists(atPath: parentPagePath) { - let pageName = (newPath as NSString).lastPathComponent - .replacingOccurrences(of: ".md", with: "") - let linkLine = "[[\(pageName)]]" - if var parentContent = try? fileSystem.loadFile(at: parentPagePath) { - // Only add if not already linked - if !parentContent.contains(linkLine) { - if !parentContent.hasSuffix("\n") { parentContent += "\n" } - parentContent += "\n\(linkLine)\n" - try? fileSystem.saveFile(at: parentPagePath, content: parentContent) - - // Reload the parent page if it's open - if let parentTab = appState.openTabs.first(where: { $0.path == parentPagePath }), - let parentDoc = blockDocuments[parentTab.id] { - parentDoc.blocks = MarkdownBlockParser.parse(parentContent) - } - } - } + // Apply reorder if a target position was specified (cross-parent .above drop) + if let insertIndex, let siblingNames { + let movedName = (newPath as NSString).lastPathComponent + var names = siblingNames + names.insert(movedName, at: min(insertIndex, names.count)) + fileSystem.saveCustomOrder(names, for: destDir) } appState.movePagePath = nil @@ -662,22 +636,6 @@ struct ContentView: View { } } - // Remove the page link from the parent page - let parentPagePath = destDir + ".md" - if !movingDatabase, FileManager.default.fileExists(atPath: parentPagePath) { - let pageName = (newPath as NSString).lastPathComponent - .replacingOccurrences(of: ".md", with: "") - let linkLine = "[[\(pageName)]]" - if var parentContent = try? fs.loadFile(at: parentPagePath) { - parentContent = parentContent.replacingOccurrences(of: "\n\(linkLine)\n", with: "\n") - try? fs.saveFile(at: parentPagePath, content: parentContent) - if let parentTab = self.appState.openTabs.first(where: { $0.path == parentPagePath }), - let parentDoc = self.blockDocuments[parentTab.id] { - parentDoc.blocks = MarkdownBlockParser.parse(parentContent) - } - } - } - self.refreshFileTree() } catch { Log.fileSystem.error("Undo move failed: \(error.localizedDescription)") @@ -695,6 +653,34 @@ struct ContentView: View { } } + /// Handle a page path dropped from the sidebar into the editor. + /// Inserts a page link block and moves the source file into the current page's companion folder. + private func handleSidebarPageDrop(sourcePath: String, into document: BlockDocument, at insertIndex: Int) { + guard let tab = appState.activeTab, + sourcePath != tab.path, + FileManager.default.fileExists(atPath: sourcePath) else { return } + + let pageName = ((sourcePath as NSString).lastPathComponent as NSString).deletingPathExtension + + let targetCompanionDir: String + if tab.path.hasSuffix(".md") { + targetCompanionDir = String(tab.path.dropLast(3)) + } else { + targetCompanionDir = tab.path + } + + guard !tab.path.hasPrefix(sourcePath.hasSuffix(".md") ? String(sourcePath.dropLast(3)) + "/" : sourcePath + "/") else { return } + + performMovePage(from: sourcePath, toDirectory: targetCompanionDir) + + let alreadyLinked = document.blocks.contains { $0.type == .pageLink && $0.pageLinkName == pageName } + if !alreadyLinked { + document.insertPageLinkBlock(at: insertIndex, name: pageName) + } + + scheduleSave() + } + /// Rewrite absolute paths inside a single .md file (e.g. database embed paths). private static func rewritePathsInFile(at filePath: String, oldBase: String, newBase: String) { guard filePath.hasSuffix(".md"), @@ -782,7 +768,8 @@ struct ContentView: View { private var activeTabLeadingPadding: CGFloat { let isCalendar = appState.activeTab?.isCalendar ?? false - if isCalendar { return 0 } + let isMeetings = appState.activeTab?.isMeetings ?? false + if isCalendar || isMeetings { return 0 } return appState.sidebarOpen ? ShellZoomMetrics.size(8) : ShellZoomMetrics.size(78) } @@ -798,7 +785,7 @@ struct ContentView: View { .opacity(editorUI.focusModeActive ? 0.0 : 1.0) VStack(spacing: 0) { - if let tab = appState.activeTab, !tab.isEmptyTab, !tab.isCalendar { + if let tab = appState.activeTab, !tab.isEmptyTab, !tab.isCalendar, !tab.isMeetings { HStack { BreadcrumbView( items: breadcrumbs(for: tab), @@ -808,7 +795,7 @@ struct ContentView: View { Spacer() - if !tab.isEmptyTab && !tab.isCanvas && !tab.isDatabase { + if !tab.isEmptyTab && !tab.isDatabase { Button { showPageOptionsMenu.toggle() } label: { @@ -899,7 +886,11 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .inlineDatabaseRowPeek)) { notification in guard let dbPath = notification.databasePath, let rowId = notification.databaseRowId else { return } - peekTarget = RowTarget(dbPath: dbPath, rowId: rowId) + if peekTarget?.dbPath == dbPath && peekTarget?.rowId == rowId { + closePeekPanel() + } else { + peekTarget = RowTarget(dbPath: dbPath, rowId: rowId) + } } .onReceive(NotificationCenter.default.publisher(for: .databaseRowModalRequested)) { notification in guard let dbPath = notification.databasePath, @@ -918,8 +909,6 @@ struct ContentView: View { onOpenFolder: { Task { await openWorkspace() } } ) .onAppear { openDefaultPageIfConfigured() } - } else if tab.isCanvas { - canvasEditor(for: tab) } else if tab.isDatabaseRow, let dbPath = tab.databasePath, let rowId = tab.databaseRowId { DatabaseRowFullPageView( dbPath: dbPath, @@ -936,6 +925,16 @@ struct ContentView: View { calendarService: calendarService, calendarVM: calendarVM, meetingNoteService: meetingNoteService, + aiService: aiService, + onNavigateToFile: { path in + navigateToFilePath(path) + } + ) + } else if tab.isMeetings { + MeetingsView( + appState: appState, + calendarService: calendarService, + aiService: aiService, onNavigateToFile: { path in navigateToFilePath(path) } @@ -1001,23 +1000,27 @@ struct ContentView: View { .padding(.trailing, 52) .padding(.top, 8) } - - BlockEditorView( - document: document, - onTextChange: { - guard appState.activeTabIndex < appState.openTabs.count else { return } - if !appState.openTabs[appState.activeTabIndex].isDirty { - appState.openTabs[appState.activeTabIndex].isDirty = true - } - syncTitle(from: document) - scheduleSave() - }, - onTyping: { triggerFocusMode() } - ) } .frame(maxWidth: document.fullWidth ? .infinity : 860) Spacer(minLength: 0) } + + BlockEditorView( + document: document, + onTextChange: { + guard appState.activeTabIndex < appState.openTabs.count else { return } + if !appState.openTabs[appState.activeTabIndex].isDirty { + appState.openTabs[appState.activeTabIndex].isDirty = true + } + syncTitle(from: document) + scheduleSave() + }, + onTyping: { triggerFocusMode() }, + onPagePathDrop: { sourcePath, insertIndex in + handleSidebarPageDrop(sourcePath: sourcePath, into: document, at: insertIndex) + }, + contentColumnMaxWidth: document.fullWidth ? nil : 860 + ) } } .background(Color.fallbackEditorBg) @@ -1075,6 +1078,12 @@ struct ContentView: View { if path != nil { refreshFileTree() } return path } + doc.onCreateMeetingDatabase = { [weak appState] in + guard let workspace = appState?.workspacePath else { return nil } + let path = findOrCreateMeetingsDatabase(in: workspace) + if path != nil { refreshFileTree() } + return path + } doc.onCreateSubPage = { [weak appState] name in guard let tab = appState?.activeTab else { return nil } let path = try? fileSystem.createSubPage(under: tab.path, name: name) @@ -1143,6 +1152,172 @@ struct ContentView: View { doc.onCancelAiPrompt = { [weak doc] in doc?.dismissAiPrompt() } + let ts = transcriptionService + doc.transcriptionService = ts + doc.onStartMeeting = { [weak doc] blockId in + Task { + await ts.startRecording() + // Poll confirmed segments and audio level after recording starts + var lastSegmentCount = 0 + var lastVolatile = "" + var lastLevel: Float = -1 + while ts.isRecording { + let level = ts.audioLevel + if level != lastLevel { + lastLevel = level + doc?.meetingAudioLevel = level + } + + let segments = ts.confirmedSegments + let volatile = ts.volatileText + let segmentsChanged = segments.count != lastSegmentCount + let volatileChanged = volatile != lastVolatile + if segmentsChanged || volatileChanged { + lastSegmentCount = segments.count + lastVolatile = volatile + var entries = segments + if !volatile.isEmpty { entries.append(volatile) } + doc?.updateBlockProperty(id: blockId) { block in + block.transcriptEntries = entries + block.meetingTranscript = entries.joined(separator: " ") + } + doc?.meetingVolatileText = volatile + } + + try? await Task.sleep(for: .milliseconds(100)) + } + doc?.meetingAudioLevel = 0 + doc?.meetingVolatileText = "" + } + } + doc.onStopMeeting = { [weak doc] blockId in + _ = ts.stopRecording() + guard let doc else { return } + let transcript = ts.currentTranscript + doc.updateBlockProperty(id: blockId) { block in + block.meetingState = .complete + block.meetingTranscript = transcript + } + } + doc.onDropPageFromSidebar = { [weak appState, weak doc] sourcePath, insertionIndex in + guard let appState, let doc else { return } + guard let tab = appState.activeTab else { return } + // Don't drop a page onto itself + guard sourcePath != tab.path else { return } + + let pageName = ((sourcePath as NSString).lastPathComponent as NSString) + .deletingPathExtension + + // Insert the page link block at the drop location + doc.insertPageLinkBlock(at: insertionIndex, name: pageName) + + // Mark dirty and save immediately so performMovePage sees the link + // already in the file and doesn't append a duplicate at the bottom + if let tabIdx = appState.openTabs.firstIndex(where: { $0.id == tab.id }) { + appState.openTabs[tabIdx].isDirty = true + } + performSave(tabId: tab.id) + + // Move the file into this page's companion folder + let companionDir = tab.path.hasSuffix(".md") ? String(tab.path.dropLast(3)) : tab.path + performMovePage(from: sourcePath, toDirectory: companionDir) + } + } + + // MARK: - Meeting Finalization + + private static let meetingTitleDateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "MMM d, yyyy" + return df + }() + + private func finalizeMeeting(doc: BlockDocument, blockId: UUID, transcript: String, appState: AppState?) async { + let fallbackTitle = "Meeting \(Self.meetingTitleDateFormatter.string(from: Date()))" + + guard !transcript.isEmpty else { + doc.updateBlockProperty(id: blockId) { block in + block.meetingState = .complete + if block.meetingTitle.isEmpty { block.meetingTitle = fallbackTitle } + block.meetingSummary = "No audio was captured." + block.meetingActionItems = "" + } + return + } + + // Include user notes if they wrote any during the meeting + let userNotes = doc.blocks.first(where: { $0.id == blockId })?.meetingNotes ?? "" + + let prompt = """ + You are a meeting notes assistant. Produce clean, structured notes like a skilled executive assistant would. + + Output format (use EXACTLY): + + TITLE: + + ### + - **Bold key entity** followed by concise detail + - Supporting specifics (numbers, names, decisions) as sub-bullets + - Another key point + 1. Use numbered sub-items for sequential steps or features + + ### Action Items + - [ ] Owner: specific action item with deadline if mentioned + + Style rules: + - **Bold** speaker names, project names, and key terms on first mention + - ### for section headings (topic-based, not "Summary") + - Top-level bullets: one key point each, specific and factual + - Sub-bullets: only for supporting details that add real information + - Numbered sub-lists for features, steps, or ordered items + - NO meta-commentary ("participants discussed", "the team talked about") — state facts directly + - NO filler or padding — every bullet should carry information + - Keep total output under 30 bullet points. For long meetings, prioritize: decisions > action items > key facts > discussion details + - If nothing actionable, omit Action Items entirely + \(userNotes.isEmpty ? "" : "\nUser's notes during the meeting (integrate into relevant sections):\n\(userNotes)\n") + Transcript: + \(transcript) + """ + + let engine = appState?.settings.preferredAIEngine ?? .auto + let apiKey = appState?.settings.anthropicApiKey ?? "" + let workspace = appState?.workspacePath ?? "" + + do { + let result = try await aiService.generateContent( + engine: engine, + workspacePath: workspace, + prompt: prompt, + apiKey: apiKey + ) + + // Extract title from first line if present + var title = fallbackTitle + var body = result + if let titleLine = result.components(separatedBy: "\n").first, + titleLine.hasPrefix("TITLE:") { + title = titleLine.replacingOccurrences(of: "TITLE:", with: "").trimmingCharacters(in: .whitespaces) + body = result.components(separatedBy: "\n").dropFirst().joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + doc.updateBlockProperty(id: blockId) { block in + block.meetingState = .complete + // Only override title if user didn't set one manually + if block.meetingTitle.isEmpty || block.meetingTitle == "New Meeting" { + block.meetingTitle = title + } + block.meetingSummary = body + // Store full structured output for the summary view parser + block.language = body + } + } catch { + doc.updateBlockProperty(id: blockId) { block in + block.meetingState = .complete + if block.meetingTitle.isEmpty { block.meetingTitle = fallbackTitle } + block.meetingSummary = "AI summary unavailable: \(error.localizedDescription)" + block.meetingActionItems = "" + } + } } // MARK: - Theme @@ -1189,8 +1364,9 @@ struct ContentView: View { let doc = blockDocuments[tab.id], let selectedMarkdown = doc.selectedBlocksMarkdown() else { return } hideFormattingPanel() + let blockItems = doc.selectedBlockContextItems() appState.aiSelectionContext = selectedMarkdown - appState.openAiPanel() + appState.openAiPanel(referencedItems: blockItems) } ) panel.show(above: rect) @@ -1227,8 +1403,9 @@ struct ContentView: View { let doc = blockDocuments[tab.id], let selectedMarkdown = doc.selectedBlocksMarkdown() else { return } hideFormattingPanel() + let blockItems = doc.selectedBlockContextItems() appState.aiSelectionContext = selectedMarkdown - appState.openAiPanel() + appState.openAiPanel(referencedItems: blockItems) } ) panel.show(above: blockRect) @@ -1311,14 +1488,12 @@ struct ContentView: View { let name = (defaultPage as NSString).lastPathComponent let schemaPath = (defaultPage as NSString).appendingPathComponent("_schema.json") let isDatabase = FileManager.default.fileExists(atPath: schemaPath) - let canvasPath = (defaultPage as NSString).appendingPathComponent("_canvas.json") - let isCanvas = FileManager.default.fileExists(atPath: canvasPath) - let kind: TabKind = isDatabase ? .database : isCanvas ? .canvas : .page + let kind: TabKind = isDatabase ? .database : .page let entry = FileEntry( id: defaultPage, name: name, path: defaultPage, - isDirectory: isDatabase || isCanvas, + isDirectory: isDatabase, kind: kind ) navigateToEntry(entry, preferExistingTab: false) @@ -1355,13 +1530,12 @@ struct ContentView: View { } let isDatabase = isDatabaseFolderPath(targetPath) - let isCanvas = isCanvasFolderPath(targetPath) - let kind: TabKind = isDatabase ? .database : isCanvas ? .canvas : .page + let kind: TabKind = isDatabase ? .database : .page let entry = FileEntry( id: targetPath, name: item.name, path: targetPath, - isDirectory: isDatabase || isCanvas, + isDirectory: isDatabase, kind: kind, icon: item.icon ) @@ -1370,7 +1544,6 @@ struct ContentView: View { private func isOpenableBreadcrumbPath(_ path: String) -> Bool { if isDatabaseFolderPath(path) { return true } - if isCanvasFolderPath(path) { return true } var isDir: ObjCBool = false guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { return false } return !isDir.boolValue @@ -1381,11 +1554,6 @@ struct ContentView: View { return FileManager.default.fileExists(atPath: schemaPath) } - private func isCanvasFolderPath(_ path: String) -> Bool { - let canvasPath = (path as NSString).appendingPathComponent("_canvas.json") - return FileManager.default.fileExists(atPath: canvasPath) - } - private func initializeWorkspace() { // Restore the most recently used workspace, falling back to the default let restoredPath = fileSystem.recentWorkspaces.first(where: { @@ -1401,8 +1569,6 @@ struct ContentView: View { // Register workspace as a qmd collection in the background (no-op if qmd not installed) QmdService.registerCollectionInBackground(workspace: workspacePath) - // Pre-warm the daemon now if hybrid mode is already selected - QmdService.prewarmDaemonIfNeeded(mode: appState.settings.qmdSearchMode) // Create onboarding file for empty workspaces before building the file tree if let onboardingPath = OnboardingService.ensureOnboarding(workspacePath: workspacePath) { @@ -1433,10 +1599,12 @@ struct ContentView: View { let watcher = WorkspaceWatcher { [weak appState] in guard let appState = appState, let workspace = appState.workspacePath else { return } - let tree = fileSystem.buildFileTree(at: workspace) - Task { @MainActor in - self.appState.fileTree = tree - refreshSidebarReferences(using: tree) + Task.detached { + let tree = fileSystem.buildFileTree(at: workspace) + await MainActor.run { + appState.fileTree = tree + self.refreshSidebarReferences(using: tree) + } } } watcher.watch(path: path) @@ -1445,9 +1613,14 @@ struct ContentView: View { private func refreshFileTree() { guard let path = appState.workspacePath else { return } - let tree = fileSystem.buildFileTree(at: path) - appState.fileTree = tree - refreshSidebarReferences(using: tree) + let fileSystem = self.fileSystem + Task.detached { + let tree = fileSystem.buildFileTree(at: path) + await MainActor.run { + self.appState.fileTree = tree + self.refreshSidebarReferences(using: tree) + } + } } private func syncAvailablePages(_ pages: [FileEntry]) { @@ -1497,9 +1670,6 @@ struct ContentView: View { if isDatabaseFolderPath(path) { kind = .database name = databaseDisplayName(at: path) ?? (path as NSString).lastPathComponent - } else if isCanvasFolderPath(path) { - kind = .canvas - name = (path as NSString).lastPathComponent } else { kind = .page name = (path as NSString).lastPathComponent @@ -1524,6 +1694,29 @@ struct ContentView: View { return json["name"] as? String } + /// Handles a page dragged from the sidebar into the editor at a specific block index. + /// Creates a pageLink block at the drop position and moves the file to be a sub-page. + private func handleSidebarPageDropIntoEditor(sourcePath: String, insertIndex: Int, document: BlockDocument) { + guard let tab = appState.activeTab else { return } + let currentPagePath = tab.path + // Don't drop a page onto itself + guard sourcePath != currentPagePath else { return } + // Don't drop a page that's already a sub-page of the current page + let currentCompanion = currentPagePath.hasSuffix(".md") ? String(currentPagePath.dropLast(3)) : currentPagePath + guard !(sourcePath as NSString).deletingLastPathComponent.hasPrefix(currentCompanion) else { return } + + let pageName = (sourcePath as NSString).lastPathComponent.replacingOccurrences(of: ".md", with: "") + + // 1. Insert the pageLink block at the drop position + document.insertPageLinkBlock(at: insertIndex, name: pageName) + + // 2. Save the current document so the link is persisted before move + performSave(tabId: tab.id) + + // 3. Move the file to be a sub-page of the current page + performMovePage(from: sourcePath, toDirectory: currentCompanion) + } + private func addSidebarReference(_ payload: SidebarReferenceDragPayload) { guard let workspace = appState.workspacePath else { return } @@ -1537,7 +1730,7 @@ struct ContentView: View { } private func loadFileContent(for entry: FileEntry) { - guard !entry.isDatabase, !entry.isCanvas, !entry.isDatabaseRow else { return } + guard !entry.isDatabase, !entry.isDatabaseRow else { return } let signpostState = Log.signpost.beginInterval("loadFileContent") defer { Log.signpost.endInterval("loadFileContent", signpostState) } formattingPanel?.hidePanel() @@ -1917,61 +2110,9 @@ struct ContentView: View { } } - // MARK: - Canvas - - @ViewBuilder - private func canvasEditor(for tab: OpenFile) -> some View { - if let doc = canvasDocuments[tab.id] { - CanvasView( - document: doc, - onNavigateToFile: { path in navigateToFilePath(path) }, - availablePages: appState.fileTree - ) - .onChange(of: doc.isDirty) { _, dirty in - if dirty { scheduleCanvasSave(tabId: tab.id) } - } - } else { - Color.fallbackEditorBg - .onAppear { loadCanvasContent(for: tab) } - } - } - - private func loadCanvasContent(for tab: OpenFile) { - guard tab.isCanvas else { return } - let doc = CanvasDocument() - doc.load(from: tab.path) - canvasDocuments[tab.id] = doc - } - - private func scheduleCanvasSave(tabId: UUID) { - let docs = self.canvasDocuments - canvasSaveTask?.cancel() - canvasSaveTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_000_000_000) - guard !Task.isCancelled else { return } - docs[tabId]?.save() - } - } - - private func createNewCanvas() { - guard let workspace = appState.workspacePath else { return } - do { - let path = try fileSystem.createCanvas(in: workspace, name: "Untitled Canvas") - let displayName = (path as NSString).lastPathComponent - let entry = FileEntry(id: path, name: displayName, path: path, isDirectory: false, kind: .canvas) - appState.openFile(entry) - if let tab = appState.activeTab { - loadCanvasContent(for: tab) - } - refreshFileTree() - } catch { - Log.canvas.error("Failed to create canvas: \(error.localizedDescription)") - } - } - private func createNewDatabase() { do { - let path = try createDatabasePath(name: "Untitled Database") + let path = try createDatabasePath(name: "") let displayName = (path as NSString).lastPathComponent let entry = FileEntry(id: path, name: displayName, path: path, isDirectory: false, kind: .database) appState.openFile(entry) @@ -1998,6 +2139,23 @@ struct ContentView: View { return try fileSystem.createDatabase(in: workspace, name: name) } + private func findOrCreateMeetingsDatabase(in workspace: String) -> String? { + // Look for an existing "Meetings" database at the workspace root + if let contents = try? FileManager.default.contentsOfDirectory(atPath: workspace) { + for name in contents where !name.hasPrefix(".") { + let fullPath = (workspace as NSString).appendingPathComponent(name) + guard fileSystem.isDatabaseFolder(at: fullPath) else { continue } + let schemaPath = (fullPath as NSString).appendingPathComponent("_schema.json") + guard let data = try? Data(contentsOf: URL(fileURLWithPath: schemaPath)), + let schema = try? JSONDecoder().decode(DatabaseSchema.self, from: data), + schema.name.lowercased().contains("meeting") else { continue } + return fullPath + } + } + + return try? fileSystem.createDatabase(in: workspace, name: "Meetings") + } + private func activePagePathForDatabaseCreation() -> String? { guard let tab = appState.activeTab, tab.kind == .page, @@ -2111,6 +2269,18 @@ struct ContentView: View { } } + /// Syncs in-memory BlockDocument content into openTabs[].content for every + /// dirty tab so the command palette's content index sees the latest edits, + /// even if the 1-second save debounce hasn't fired yet. + private func flushDirtyTabContent() { + for i in appState.openTabs.indices where appState.openTabs[i].isDirty { + let tabId = appState.openTabs[i].id + if let doc = blockDocuments[tabId] { + appState.openTabs[i].content = doc.markdown + } + } + } + private func scheduleSave() { guard let tab = appState.activeTab, !tab.path.isEmpty else { return } let tabId = tab.id @@ -2163,7 +2333,6 @@ struct ContentView: View { .filter { $0.isDirty && !$0.path.isEmpty - && !$0.isCanvas && !$0.isDatabase && !$0.isDatabaseRow } @@ -2234,7 +2403,6 @@ struct ContentView: View { /// Removes any databaseEmbed blocks referencing `dbPath` from all currently open BlockDocuments. private func cleanupTabDocuments(_ tabId: UUID) { blockDocuments.removeValue(forKey: tabId) - canvasDocuments.removeValue(forKey: tabId) databaseRowFullWidth.removeValue(forKey: tabId) } @@ -2401,9 +2569,11 @@ struct ContentView: View { } let dbService = DatabaseService() + // Load schema before deleting so we can do an incremental index removal + let schemaForIndex = try? dbService.loadDatabase(at: dbPath).0 try? dbService.deleteRow(rowId, in: dbPath) - if let (schema, rows) = try? dbService.loadDatabase(at: dbPath) { - try? dbService.updateIndex(rows: rows, schema: schema, at: dbPath) + if let schema = schemaForIndex { + try? dbService.incrementalIndexDelete(rowId: rowId, schema: schema, at: dbPath) } NotificationCenter.default.post( @@ -2615,8 +2785,7 @@ struct ContentView: View { } let name = (path as NSString).lastPathComponent let isDatabase = isDatabaseFolderPath(path) - let isCanvas = isCanvasFolderPath(path) - let kind: TabKind = isDatabase ? .database : isCanvas ? .canvas : .page + let kind: TabKind = isDatabase ? .database : .page let entry = FileEntry(id: path, name: name, path: path, isDirectory: false, kind: kind) navigateToEntry(entry, preferExistingTab: true) } diff --git a/Sources/Bugbook/Views/Database/CalendarView.swift b/Sources/Bugbook/Views/Database/CalendarView.swift index e759c99..8d37278 100644 --- a/Sources/Bugbook/Views/Database/CalendarView.swift +++ b/Sources/Bugbook/Views/Database/CalendarView.swift @@ -213,7 +213,7 @@ struct CalendarView: View { Text("\(cell.day)") .font(.caption) .fontWeight(cell.isToday ? .bold : .medium) - .foregroundStyle(cell.isToday ? Color.white : Color.primary) + .foregroundStyle(cell.isToday ? Color.fallbackAccentFg : Color.primary) .frame(width: 22, height: 22) .background(cell.isToday ? Circle().fill(Color.accentColor) : Circle().fill(Color.clear)) Spacer() diff --git a/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift b/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift index 434f9ab..befca03 100644 --- a/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift +++ b/Sources/Bugbook/Views/Database/DatabaseFullPageView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers import BugbookCore extension Notification.Name { @@ -39,6 +40,10 @@ struct DatabaseFullPageView: View { @State private var renamingPropertyId: String? = nil @State private var renamingPropertyName: String = "" @State private var initialPeekHandled = false + @State private var draggedViewTabId: String? + @State private var viewTabDropTargetId: String? + @State private var showTemplatePicker = false + @State private var editingTemplate: DatabaseTemplate? = nil init(dbPath: String, initialRowId: String? = nil) { self.dbPath = dbPath @@ -112,6 +117,59 @@ struct DatabaseFullPageView: View { ) } } + .overlay { + if showTemplatePicker { + Color.black.opacity(0.2) + .ignoresSafeArea() + .onTapGesture { showTemplatePicker = false } + + DatabaseTemplatePickerView( + templates: state.templates, + onSelectEmpty: { + showTemplatePicker = false + createEmptyRow() + }, + onSelectTemplate: { template in + showTemplatePicker = false + createRowFromTemplate(template) + }, + onNewTemplate: { + showTemplatePicker = false + let newTemplate = state.createTemplate(name: "Untitled") + editingTemplate = newTemplate + }, + onDismiss: { showTemplatePicker = false } + ) + } + } + .overlay { + if editingTemplate != nil, let schema = state.schema { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + if let t = editingTemplate { state.updateTemplate(t) } + editingTemplate = nil + } + + DatabaseTemplateEditorModal( + dbPath: dbPath, + schema: schema, + template: Binding( + get: { editingTemplate! }, + set: { editingTemplate = $0 } + ), + onSave: { updated in state.updateTemplate(updated) }, + onDelete: { templateId in + state.deleteTemplate(templateId) + editingTemplate = nil + }, + onClose: { + if let t = editingTemplate { state.updateTemplate(t) } + editingTemplate = nil + } + ) + } + } .task { state.loadData { if let targetId = initialRowId, @@ -137,7 +195,7 @@ struct DatabaseFullPageView: View { private func dbHeader(schema: DatabaseSchema) -> some View { HStack(spacing: 8) { - TextField("Database Name", text: $state.editingTitle, axis: .vertical) + TextField("New database", text: $state.editingTitle, axis: .vertical) .lineLimit(1...10) .onSubmit { state.persistTitle() } .onChange(of: state.editingTitle) { _, _ in state.scheduleTitleSave() } @@ -175,48 +233,76 @@ struct DatabaseFullPageView: View { // MARK: - View Tabs + @State private var isHoveringTabs = false + private func viewTabs(schema: DatabaseSchema) -> some View { HStack(spacing: 4) { ForEach(schema.views) { view in - Button { - state.activeViewId = view.id - state.persistActiveView(view.id) - } label: { - HStack(spacing: 4) { - Image(systemName: iconForViewType(view.type)) - Text(view.name) - } - .font(DatabaseZoomMetrics.font(12)) - .padding(.horizontal, DatabaseZoomMetrics.size(8)) - .padding(.vertical, DatabaseZoomMetrics.size(4)) - .background(view.id == state.activeViewId ? Color.primary.opacity(0.1) : Color.clear) - .clipShape(.rect(cornerRadius: DatabaseZoomMetrics.size(4))) - } - .buttonStyle(.plain) + viewTabButton(view: view) } - Menu { - ForEach(ViewType.allCases, id: \.rawValue) { type in - Button { state.addView(type: type) } label: { - Label(type.rawValue.capitalized, systemImage: iconForViewType(type)) + if isHoveringTabs { + Menu { + ForEach(ViewType.allCases, id: \.rawValue) { type in + Button { state.addView(type: type) } label: { + Label(type.rawValue.capitalized, systemImage: iconForViewType(type)) + } } + } label: { + Image(systemName: "plus") + .font(DatabaseZoomMetrics.font(11)) + .foregroundStyle(.secondary) + .frame(width: DatabaseZoomMetrics.size(20), height: DatabaseZoomMetrics.size(20)) + .contentShape(Rectangle()) } - } label: { - Image(systemName: "plus") - .font(DatabaseZoomMetrics.font(11)) - .foregroundStyle(.secondary) - .frame(width: DatabaseZoomMetrics.size(20), height: DatabaseZoomMetrics.size(20)) - .contentShape(Rectangle()) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Add a new view") } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() Spacer() } .padding(.leading, DatabaseZoomMetrics.size(4)) .padding(.trailing, DatabaseZoomMetrics.size(12)) .padding(.vertical, DatabaseZoomMetrics.size(4)) + .onHover { isHoveringTabs = $0 } + } + + private func viewTabButton(view: ViewConfig) -> some View { + Button { + draggedViewTabId = nil + viewTabDropTargetId = nil + state.activeViewId = view.id + state.persistActiveView(view.id) + } label: { + HStack(spacing: 4) { + if viewTabDropTargetId == view.id { + Capsule() + .fill(Color.accentColor) + .frame(width: 2, height: DatabaseZoomMetrics.size(14)) + } + Image(systemName: iconForViewType(view.type)) + Text(view.name) + } + .font(DatabaseZoomMetrics.font(12)) + .padding(.horizontal, DatabaseZoomMetrics.size(8)) + .padding(.vertical, DatabaseZoomMetrics.size(4)) + .background(view.id == state.activeViewId ? Color.primary.opacity(0.1) : Color.clear) + .clipShape(.rect(cornerRadius: DatabaseZoomMetrics.size(4))) + .opacity(draggedViewTabId == view.id ? 0.4 : 1.0) + } + .buttonStyle(.plain) + .onDrag { + draggedViewTabId = view.id + return NSItemProvider(object: view.id as NSString) + } + .onDrop(of: [.text], delegate: ViewTabDropDelegate( + targetId: view.id, + state: state, + draggedId: $draggedViewTabId, + dropTargetId: $viewTabDropTargetId + )) } // MARK: - Settings Popover @@ -629,7 +715,8 @@ struct DatabaseFullPageView: View { onClearSorts: { state.clearSorts() }, onNewRow: { createNewRow() }, showVerticalLines: showVerticalLines, - usesInnerScroll: false + usesInnerScroll: false, + containerWidth: geo.size.width ) .frame( minWidth: geo.size.width, @@ -648,6 +735,7 @@ struct DatabaseFullPageView: View { onOpenRow: { row in openRow(row) }, onSave: { row in state.saveRow(row) }, onUpdateGroupBy: { propId in state.updateGroupBy(propId) }, + onUpdateSubGroupBy: { propId in state.updateSubGroupBy(propId) }, onAddSelectOption: { propId, option in state.addSelectOption(propId, option: option) }, onDelete: { row in state.deleteRow(row) }, onReorderRows: { draggedId, targetId in @@ -695,6 +783,14 @@ struct DatabaseFullPageView: View { // MARK: - View-Specific Operations private func createNewRow() { + if !state.templates.isEmpty { + showTemplatePicker = true + } else { + createEmptyRow() + } + } + + private func createEmptyRow() { Task { do { let newRow = try state.createRow() @@ -705,6 +801,15 @@ struct DatabaseFullPageView: View { } } + private func createRowFromTemplate(_ template: DatabaseTemplate) { + do { + let newRow = try state.createRowFromTemplate(template) + openRow(newRow) + } catch { + state.error = error.localizedDescription + } + } + private func openRow(_ row: DatabaseRow) { postInlineRowPeek(rowId: row.id) } diff --git a/Sources/Bugbook/Views/Database/DatabaseInlineEmbedView.swift b/Sources/Bugbook/Views/Database/DatabaseInlineEmbedView.swift index 9918ece..d56e58b 100644 --- a/Sources/Bugbook/Views/Database/DatabaseInlineEmbedView.swift +++ b/Sources/Bugbook/Views/Database/DatabaseInlineEmbedView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers import BugbookCore /// Compact database embed for rendering inside a markdown page. @@ -15,12 +16,18 @@ struct DatabaseInlineEmbedView: View { @State private var showSettings: Bool = false @State private var searchText: String = "" @State private var hasStartedLoading = false - @State private var isHoveringHeader = false + @State private var isHoveringTitle = false + @State private var isHoveringTabs = false @State private var isDeleted = false @State private var isEditingTitle: Bool = false @FocusState private var isTitleFocused: Bool @FocusState private var isSearchFocused: Bool @State private var newRowScrollId: String? = nil + @State private var draggedViewTabId: String? + @State private var viewTabDropTargetId: String? + @State private var tableContainerWidth: CGFloat = 0 + @State private var showTemplatePicker = false + @State private var editingTemplate: DatabaseTemplate? = nil init(dbPath: String, onOpenRow: ((DatabaseRow) -> Void)? = nil, onOpenDatabase: (() -> Void)? = nil) { self.dbPath = dbPath @@ -78,6 +85,59 @@ struct DatabaseInlineEmbedView: View { .padding(8) } } + .overlay { + if showTemplatePicker { + Color.black.opacity(0.2) + .ignoresSafeArea() + .onTapGesture { showTemplatePicker = false } + + DatabaseTemplatePickerView( + templates: state.templates, + onSelectEmpty: { + showTemplatePicker = false + addEmptyRow() + }, + onSelectTemplate: { template in + showTemplatePicker = false + addRowFromTemplate(template) + }, + onNewTemplate: { + showTemplatePicker = false + let newTemplate = state.createTemplate(name: "Untitled") + editingTemplate = newTemplate + }, + onDismiss: { showTemplatePicker = false } + ) + } + } + .overlay { + if editingTemplate != nil, let schema = state.schema { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + if let t = editingTemplate { state.updateTemplate(t) } + editingTemplate = nil + } + + DatabaseTemplateEditorModal( + dbPath: dbPath, + schema: schema, + template: Binding( + get: { editingTemplate! }, + set: { editingTemplate = $0 } + ), + onSave: { updated in state.updateTemplate(updated) }, + onDelete: { templateId in + state.deleteTemplate(templateId) + editingTemplate = nil + }, + onClose: { + if let t = editingTemplate { state.updateTemplate(t) } + editingTemplate = nil + } + ) + } + } .task { guard !hasStartedLoading else { return } hasStartedLoading = true @@ -106,8 +166,8 @@ struct DatabaseInlineEmbedView: View { HStack(spacing: 8) { // Title if isEditingTitle { - TextField("Untitled Database", text: $state.editingTitle) - .font(.system(size: EditorTypography.bodyFontSize, weight: .semibold)) + TextField("", text: $state.editingTitle) + .font(.system(size: EditorTypography.scaled(20), weight: .semibold)) .foregroundStyle(.primary) .textFieldStyle(.plain) .focused($isTitleFocused) @@ -123,33 +183,15 @@ struct DatabaseInlineEmbedView: View { } } else { Button { isEditingTitle = true } label: { - Text(state.editingTitle.isEmpty ? "Untitled Database" : state.editingTitle) - .font(.system(size: EditorTypography.bodyFontSize, weight: .semibold)) - .foregroundStyle(.primary) + Text(state.editingTitle.isEmpty ? "New database" : state.editingTitle) + .font(.system(size: EditorTypography.scaled(20), weight: .semibold)) + .foregroundStyle(state.editingTitle.isEmpty ? .tertiary : .primary) } .buttonStyle(.plain) } - // Add view — always visible next to title - Menu { - ForEach([ViewType.table, .list, .kanban, .calendar], id: \.rawValue) { type in - Button { state.addView(type: type) } label: { - Label(type.rawValue.capitalized, systemImage: iconForViewType(type)) - } - } - } label: { - Image(systemName: "plus") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .frame(width: 18, height: 18) - .contentShape(Rectangle()) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - - // Open full page — next to title controls, visible on hover - if isHoveringHeader { + // Open full page — visible on hover over title + if isHoveringTitle { Button { onOpenDatabase?() } label: { Image(systemName: "arrow.up.right") .font(.system(size: 12)) @@ -217,7 +259,7 @@ struct DatabaseInlineEmbedView: View { .padding(.bottom, 4) .onHover { hovering in withAnimation(.easeInOut(duration: 0.12)) { - isHoveringHeader = hovering + isHoveringTitle = hovering } } } @@ -227,34 +269,76 @@ struct DatabaseInlineEmbedView: View { private func viewTabsStrip(schema: DatabaseSchema) -> some View { HStack(spacing: 4) { ForEach(schema.views) { view in - Button { - state.activeViewId = view.id - } label: { - HStack(spacing: 4) { - Image(systemName: iconForViewType(view.type)) - Text(view.name) - } - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(view.id == state.activeViewId ? Color.primary.opacity(0.1) : Color.clear) - .clipShape(.rect(cornerRadius: 4)) - .foregroundStyle(view.id == state.activeViewId ? .primary : .secondary) - } - .buttonStyle(.plain) - .contextMenu { - Button(role: .destructive) { - state.deleteView(view) - } label: { - Label("Delete View", systemImage: "trash") + inlineViewTabButton(view: view) + } + if isHoveringTabs { + Menu { + ForEach([ViewType.table, .list, .kanban, .calendar], id: \.rawValue) { type in + Button { state.addView(type: type) } label: { + Label(type.rawValue.capitalized, systemImage: iconForViewType(type)) + } } + } label: { + Image(systemName: "plus") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + .contentShape(Rectangle()) } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Add a new view") } Spacer() } .padding(.leading, 12) .padding(.trailing, 12) .padding(.vertical, 4) + .onHover { isHoveringTabs = $0 } + } + + private func inlineViewTabButton(view: ViewConfig) -> some View { + Button { + draggedViewTabId = nil + viewTabDropTargetId = nil + state.activeViewId = view.id + } label: { + HStack(spacing: 4) { + if viewTabDropTargetId == view.id { + Capsule() + .fill(Color.accentColor) + .frame(width: 2, height: 14) + } + Image(systemName: iconForViewType(view.type)) + Text(view.name) + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(view.id == state.activeViewId ? Color.primary.opacity(0.1) : Color.clear) + .clipShape(.rect(cornerRadius: 4)) + .foregroundStyle(view.id == state.activeViewId ? .primary : .secondary) + .opacity(draggedViewTabId == view.id ? 0.4 : 1.0) + } + .buttonStyle(.plain) + .contextMenu { + Button(role: .destructive) { + state.deleteView(view) + } label: { + Label("Delete View", systemImage: "trash") + } + } + .onDrag { + draggedViewTabId = view.id + return NSItemProvider(object: view.id as NSString) + } + .onDrop(of: [.text], delegate: ViewTabDropDelegate( + targetId: view.id, + state: state, + draggedId: $draggedViewTabId, + dropTargetId: $viewTabDropTargetId + )) } // MARK: - Settings Popover @@ -620,6 +704,11 @@ struct DatabaseInlineEmbedView: View { switch state.activeView?.type ?? .table { case .table: + // For large databases, use an inner scroll context so LazyVStack + // can be truly lazy instead of forcing all rows to lay out for the + // parent page's ScrollView. Small databases keep the flat layout. + let useInnerScroll = filtered.count > 20 + let controlsInset = DatabaseZoomMetrics.size(TableView.rowControlsInset) ScrollView(.horizontal) { TableView( schema: schema, @@ -646,10 +735,20 @@ struct DatabaseInlineEmbedView: View { onClearSorts: { state.clearSorts() }, onNewRow: { addNewRow() }, scrollToRowId: newRowScrollId, - usesInnerScroll: false + usesInnerScroll: useInnerScroll, + containerWidth: tableContainerWidth ) } + .scrollClipDisabled() .scrollIndicators(.visible) + .padding(.leading, -controlsInset) + .frame(height: useInnerScroll ? 400 : nil) + .background { + GeometryReader { geo in + Color.clear.onAppear { tableContainerWidth = geo.size.width } + .onChange(of: geo.size.width) { _, w in tableContainerWidth = w } + } + } case .kanban: KanbanView( schema: schema, @@ -658,6 +757,7 @@ struct DatabaseInlineEmbedView: View { onOpenRow: { row in openRow(row) }, onSave: { row in state.saveRow(row) }, onUpdateGroupBy: { propId in state.updateGroupBy(propId) }, + onUpdateSubGroupBy: { propId in state.updateSubGroupBy(propId) }, onAddSelectOption: { propId, option in state.addSelectOption(propId, option: option) }, onDelete: { row in state.deleteRow(row) }, onReorderRows: { draggedId, targetId in @@ -674,7 +774,7 @@ struct DatabaseInlineEmbedView: View { state.hideKanbanColumn(propertyId: propId, optionId: optionId) } ) - .frame(height: 360) + .frame(height: 600) case .list: ListView( schema: schema, @@ -700,6 +800,14 @@ struct DatabaseInlineEmbedView: View { // MARK: - View-Specific Operations private func addNewRow() { + if !state.templates.isEmpty { + showTemplatePicker = true + } else { + addEmptyRow() + } + } + + private func addEmptyRow() { do { let newRow = try state.createRow() newRowScrollId = newRow.id @@ -708,6 +816,15 @@ struct DatabaseInlineEmbedView: View { } } + private func addRowFromTemplate(_ template: DatabaseTemplate) { + do { + let newRow = try state.createRowFromTemplate(template) + newRowScrollId = newRow.id + } catch { + state.error = error.localizedDescription + } + } + private func createRowWithDate(_ dateStr: String, propertyId: String?) { do { let newRow = try state.createRowWithDate(dateStr, propertyId: propertyId) diff --git a/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift b/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift index 0b59ba5..03b71df 100644 --- a/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift +++ b/Sources/Bugbook/Views/Database/DatabaseRowFullPageView.swift @@ -23,7 +23,14 @@ struct DatabaseRowFullPageView: View { if let error = vm.error { RowLoadErrorView(message: error) { vm.loadData(rowId: rowId) } } else if vm.schema != nil, vm.row != nil { - vm.rowPageView(fullWidth: fullWidth, workspacePath: workspacePath) + vm.rowPageView( + fullWidth: fullWidth, + workspacePath: workspacePath, + templates: vm.schema?.templates ?? [], + onApplyTemplate: { template in + applyTemplate(template) + } + ) } else { ProgressView("Loading row...") .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -44,4 +51,13 @@ struct DatabaseRowFullPageView: View { } } } + + private func applyTemplate(_ template: DatabaseTemplate) { + guard var currentRow = vm.row, let schema = vm.schema else { return } + for (key, value) in template.defaultProperties { + currentRow.properties[key] = value + } + currentRow.body = template.body + vm.debouncedSave(currentRow, schema: schema) + } } diff --git a/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift b/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift index ce9eaad..226250a 100644 --- a/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift +++ b/Sources/Bugbook/Views/Database/DatabaseRowModalView.swift @@ -51,7 +51,15 @@ struct DatabaseRowModalView: View { if let error = vm.error { RowLoadErrorView(message: error, buttonLabel: "Close") { onClose() } } else if vm.schema != nil, vm.row != nil { - vm.rowPageView(onBack: { onClose() }, autoFocusTitle: autoFocusTitle, workspacePath: workspacePath) + vm.rowPageView( + onBack: { onClose() }, + autoFocusTitle: autoFocusTitle, + workspacePath: workspacePath, + templates: vm.schema?.templates ?? [], + onApplyTemplate: { template in + applyTemplate(template) + } + ) } else { VStack { Spacer() @@ -90,4 +98,13 @@ struct DatabaseRowModalView: View { } } } + + private func applyTemplate(_ template: DatabaseTemplate) { + guard var currentRow = vm.row, let schema = vm.schema else { return } + for (key, value) in template.defaultProperties { + currentRow.properties[key] = value + } + currentRow.body = template.body + vm.debouncedSave(currentRow, schema: schema) + } } diff --git a/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift b/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift index 53c57c9..d5f6d54 100644 --- a/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift +++ b/Sources/Bugbook/Views/Database/DatabaseRowViewModel.swift @@ -123,6 +123,7 @@ final class DatabaseRowViewModel { guard !Task.isCancelled, !deletedRowIds.contains(row.id) else { return } do { try dbService.saveRow(row, schema: schema, at: dbPath) + try? dbService.incrementalIndexUpdate(row: row, schema: schema, at: dbPath) draftStore.clearRowBodyDraft(dbPath: dbPath, rowId: row.id) NotificationCenter.default.post( name: .databaseDidChange, @@ -145,6 +146,7 @@ final class DatabaseRowViewModel { !deletedRowIds.contains(currentRow.id) { do { try dbService.saveRow(currentRow, schema: currentSchema, at: dbPath) + try? dbService.incrementalIndexUpdate(row: currentRow, schema: currentSchema, at: dbPath) draftStore.clearRowBodyDraft(dbPath: dbPath, rowId: currentRow.id) } catch { return @@ -296,10 +298,9 @@ final class DatabaseRowViewModel { deletedRowIds.insert(rowId) draftStore.clearRowBodyDraft(dbPath: dbPath, rowId: rowId) try? dbService.deleteRow(rowId, in: dbPath) - // Rebuild the index so the deleted row is no longer referenced + // Remove the deleted row from the index incrementally if let schema = schema { - let (_, remainingRows) = (try? dbService.loadDatabase(at: dbPath)) ?? (schema, []) - try? dbService.updateIndex(rows: remainingRows, schema: schema, at: dbPath) + try? dbService.incrementalIndexDelete(rowId: rowId, schema: schema, at: dbPath) } // Notify all views so stale saves for this row are cancelled NotificationCenter.default.post( @@ -311,7 +312,7 @@ final class DatabaseRowViewModel { } @ViewBuilder - func rowPageView(onBack: @escaping () -> Void = {}, autoFocusTitle: Bool = false, fullWidth: Bool = false, workspacePath: String? = nil) -> some View { + func rowPageView(onBack: @escaping () -> Void = {}, autoFocusTitle: Bool = false, fullWidth: Bool = false, workspacePath: String? = nil, templates: [DatabaseTemplate] = [], onApplyTemplate: ((DatabaseTemplate) -> Void)? = nil, onNewTemplate: (() -> Void)? = nil) -> some View { if let schema = schema, row != nil { RowPageView( schema: schema, @@ -334,7 +335,10 @@ final class DatabaseRowViewModel { showBreadcrumb: false, autoFocusTitle: autoFocusTitle, fullWidth: fullWidth, - dbPath: dbPath + dbPath: dbPath, + templates: templates, + onApplyTemplate: onApplyTemplate, + onNewTemplate: onNewTemplate ) } } diff --git a/Sources/Bugbook/Views/Database/DatabaseTemplateEditorModal.swift b/Sources/Bugbook/Views/Database/DatabaseTemplateEditorModal.swift new file mode 100644 index 0000000..e6949c5 --- /dev/null +++ b/Sources/Bugbook/Views/Database/DatabaseTemplateEditorModal.swift @@ -0,0 +1,190 @@ +import SwiftUI +import BugbookCore + +/// Modal for editing a database row template. Follows the same pattern as DatabaseRowModalView +/// but shows a banner identifying it as a template editor. +struct DatabaseTemplateEditorModal: View { + let dbPath: String + let schema: DatabaseSchema + @Binding var template: DatabaseTemplate + var onSave: (DatabaseTemplate) -> Void + var onDelete: ((String) -> Void)? + var onClose: () -> Void + + @State private var editingTitle: String = "" + @State private var bodyDocument: BlockDocument? + @FocusState private var isTitleFocused: Bool + @State private var showAddPropertyMenu = false + + @Environment(\.workspacePath) private var workspacePath + + private var propertyLabelColumnWidth: CGFloat { + let longestName = schema.properties + .filter { $0.type != .title } + .map(\.name.count) + .max() ?? 0 + let estimatedWidth = CGFloat(longestName) * 8.5 + 16 + return min(max(100, estimatedWidth), 180) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Toolbar + HStack(spacing: 8) { + if onDelete != nil { + Button { + onDelete?(template.id) + onClose() + } label: { + Label("Delete template", systemImage: "trash") + .labelStyle(.iconOnly) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + Spacer() + Button { onClose() } label: { + Label("Close", systemImage: "xmark") + .labelStyle(.iconOnly) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + // Template banner + HStack(spacing: 8) { + Image(systemName: "doc.text") + .font(.system(size: 12)) + Text("You're editing a template in \(schema.name)") + .font(.system(size: 13, weight: .medium)) + } + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor) + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + // Template name (acts as title) + TextField("Template name", text: $editingTitle, axis: .vertical) + .lineLimit(1...3) + .font(.system(size: EditorTypography.scaled(34), weight: .bold)) + .textFieldStyle(.plain) + .focused($isTitleFocused) + .onChange(of: editingTitle) { _, newValue in + template.name = newValue + onSave(template) + } + + // Property defaults + VStack(alignment: .leading, spacing: 0) { + ForEach(schema.properties.filter({ $0.type != .title })) { prop in + TemplatePropertyRow( + prop: prop, + value: Binding( + get: { template.defaultProperties[prop.id] ?? .empty }, + set: { newVal in + template.defaultProperties[prop.id] = newVal + onSave(template) + } + ), + propertyLabelColumnWidth: propertyLabelColumnWidth + ) + } + } + .padding(.vertical, 8) + + Divider() + } + .padding(.horizontal, 48) + .padding(.top, 24) + + // Body editor + if let bodyDocument { + BlockEditorView( + document: bodyDocument, + onTextChange: { + template.body = bodyDocument.markdown + onSave(template) + }, + horizontalPadding: 20 + ) + } + } + .frame(maxWidth: 720) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .frame(maxWidth: 880, maxHeight: 700) + .background(Elevation.popoverBg) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay { + RoundedRectangle(cornerRadius: 18) + .stroke(Elevation.popoverBorder, lineWidth: 0.5) + .allowsHitTesting(false) + } + .shadow( + color: Elevation.shadowColor.opacity(0.18), + radius: 24, + y: Elevation.shadowY * 2 + ) + .onTapGesture { } + .onExitCommand { onClose() } + .task { + editingTitle = template.name + initializeBodyDocument() + isTitleFocused = true + } + } + + private func initializeBodyDocument() { + let doc = BlockDocument(markdown: template.body) + if let ws = workspacePath, !ws.isEmpty { + doc.workspacePath = ws + } else if !dbPath.isEmpty { + doc.workspacePath = (dbPath as NSString).deletingLastPathComponent + } + bodyDocument = doc + } +} + +// MARK: - Template Property Row (simplified, no rename/delete) + +private struct TemplatePropertyRow: View { + let prop: PropertyDefinition + @Binding var value: PropertyValue + let propertyLabelColumnWidth: CGFloat + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Text(prop.name) + .font(.body) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: propertyLabelColumnWidth, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 7) + + PropertyEditorView( + definition: prop, + value: $value, + compact: false + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 7) + } + } +} diff --git a/Sources/Bugbook/Views/Database/DatabaseTemplatePickerView.swift b/Sources/Bugbook/Views/Database/DatabaseTemplatePickerView.swift new file mode 100644 index 0000000..4895ca9 --- /dev/null +++ b/Sources/Bugbook/Views/Database/DatabaseTemplatePickerView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import BugbookCore + +/// Template picker shown when creating a new row in a database that has templates. +/// Displays "Empty" and available templates, plus a "New template" option. +struct DatabaseTemplatePickerView: View { + let templates: [DatabaseTemplate] + let onSelectEmpty: () -> Void + let onSelectTemplate: (DatabaseTemplate) -> Void + let onNewTemplate: () -> Void + let onDismiss: () -> Void + + @State private var hoveredId: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + Text("Press \u{23CE} to continue with an empty page, or pick a template") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() + + ScrollView { + VStack(spacing: 2) { + // Empty option + templateButton( + id: "_empty", + icon: "doc", + label: "Empty", + action: onSelectEmpty + ) + + // Template list + ForEach(templates) { template in + templateButton( + id: template.id, + icon: template.icon, + label: template.name, + action: { onSelectTemplate(template) } + ) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + } + .frame(maxHeight: 200) + + Divider() + + // New template + Button(action: onNewTemplate) { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + Text("New template") + .font(.system(size: 12)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(hoveredId == "_new" ? Color.primary.opacity(0.06) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredId = hovering ? "_new" : nil } + } + .frame(width: 280) + .popoverSurface(cornerRadius: Radius.lg) + } + + private func templateButton(id: String, icon: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(width: 16) + Text(label) + .font(.system(size: 13)) + .foregroundStyle(.primary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(hoveredId == id ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: Radius.sm)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in hoveredId = hovering ? id : nil } + } +} diff --git a/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift b/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift index 099fcd1..0a65351 100644 --- a/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift +++ b/Sources/Bugbook/Views/Database/DatabaseViewHelpers.swift @@ -38,6 +38,29 @@ func reorderedManualRowOrder( } func matchesFilter(_ value: PropertyValue, filter: FilterConfig) -> Bool { + // Checkbox-specific operators + if case .checkbox(let checked) = value { + switch filter.op { + case "is_checked": return checked + case "is_not_checked": return !checked + default: break + } + } + + // Date comparisons use sortable keys for correct ordering + if case .date(let raw) = value { + let sortKey = DatabaseDateValue.decode(from: raw)?.sortKey ?? raw + switch filter.op { + case "equals": return sortKey == filter.value + case "not_equals": return sortKey != filter.value + case "greater_than": return sortKey > filter.value + case "less_than": return sortKey < filter.value + case "greater_than_or_equal": return sortKey >= filter.value + case "less_than_or_equal": return sortKey <= filter.value + default: break + } + } + let stringVal = stringFromValue(value) switch filter.op { case "equals": return stringVal == filter.value @@ -46,13 +69,25 @@ func matchesFilter(_ value: PropertyValue, filter: FilterConfig) -> Bool { case "not_contains": return !stringVal.localizedCaseInsensitiveContains(filter.value) case "is_empty": return stringVal.isEmpty case "is_not_empty": return !stringVal.isEmpty - case "greater_than": return stringVal > filter.value - case "less_than": return stringVal < filter.value + case "greater_than": + if let lhs = Double(stringVal), let rhs = Double(filter.value) { return lhs > rhs } + return stringVal > filter.value + case "less_than": + if let lhs = Double(stringVal), let rhs = Double(filter.value) { return lhs < rhs } + return stringVal < filter.value + case "less_than_or_equal": + if let lhs = Double(stringVal), let rhs = Double(filter.value) { return lhs <= rhs } + return stringVal <= filter.value default: return true } } func compareValues(_ a: PropertyValue, _ b: PropertyValue) -> ComparisonResult { + if case .number(let aNum) = a, case .number(let bNum) = b { + if aNum < bNum { return .orderedAscending } + if aNum > bNum { return .orderedDescending } + return .orderedSame + } if case .date(let aRaw) = a, case .date(let bRaw) = b { let aKey = DatabaseDateValue.decode(from: aRaw)?.sortKey ?? aRaw let bKey = DatabaseDateValue.decode(from: bRaw)?.sortKey ?? bRaw @@ -164,6 +199,42 @@ func defaultDatabaseViewConfig() -> ViewConfig { ViewConfig(id: "default", name: "Table", type: .table, sorts: [], filters: []) } +// MARK: - View Tab Drop Delegate + +struct ViewTabDropDelegate: DropDelegate { + let targetId: String + let state: DatabaseViewState + @Binding var draggedId: String? + @Binding var dropTargetId: String? + + func dropEntered(info: DropInfo) { + guard let draggedId, draggedId != targetId else { return } + dropTargetId = targetId + } + + func dropExited(info: DropInfo) { + if dropTargetId == targetId { + dropTargetId = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard let draggedId, draggedId != targetId else { return false } + state.reorderViews(sourceId: draggedId, beforeId: targetId) + self.draggedId = nil + dropTargetId = nil + return true + } + + func validateDrop(info: DropInfo) -> Bool { + draggedId != nil && draggedId != targetId + } +} + func uniqueDatePropertyName(in schema: DatabaseSchema) -> String { let existingNames = Set(schema.properties.map(\.name)) if !existingNames.contains("Date") { diff --git a/Sources/Bugbook/Views/Database/DatabaseViewState.swift b/Sources/Bugbook/Views/Database/DatabaseViewState.swift index c63e4d2..bd37262 100644 --- a/Sources/Bugbook/Views/Database/DatabaseViewState.swift +++ b/Sources/Bugbook/Views/Database/DatabaseViewState.swift @@ -170,6 +170,7 @@ final class DatabaseViewState { Task { [weak self] in for row in rowsToPersist { try? service.saveRow(row, schema: currentSchema, at: path) + try? service.incrementalIndexUpdate(row: row, schema: currentSchema, at: path) } self?.postChangeNotification() } @@ -184,6 +185,7 @@ final class DatabaseViewState { guard !rowsToPersist.isEmpty else { return } for row in rowsToPersist { try? dbService.saveRow(row, schema: currentSchema, at: dbPath) + try? dbService.incrementalIndexUpdate(row: row, schema: currentSchema, at: dbPath) } postChangeNotification() } @@ -195,7 +197,7 @@ final class DatabaseViewState { rows.removeAll { $0.id == row.id } // Synchronous to prevent race with loadData reintroducing deleted rows try? dbService.deleteRow(row.id, in: dbPath) - try? dbService.updateIndex(rows: rows, schema: schema, at: dbPath) + try? dbService.incrementalIndexDelete(rowId: row.id, schema: schema, at: dbPath) // Notify all views so stale saves for this row are cancelled NotificationCenter.default.post( name: .databaseRowDeleted, @@ -378,6 +380,19 @@ final class DatabaseViewState { func updateGroupBy(_ propertyId: String) { guard var s = schema, var view = activeView else { return } view.groupBy = propertyId + // Clear sub-group if it now matches the primary group + if view.subGroupBy == propertyId { + view.subGroupBy = nil + } + Task { + try? dbService.updateView(view, in: &s, at: dbPath) + schema = s + } + } + + func updateSubGroupBy(_ propertyId: String?) { + guard var s = schema, var view = activeView else { return } + view.subGroupBy = propertyId Task { try? dbService.updateView(view, in: &s, at: dbPath) schema = s @@ -451,6 +466,21 @@ final class DatabaseViewState { return "\(baseName) \(counter)" } + func reorderViews(sourceId: String, beforeId: String?) { + guard var s = schema else { return } + guard let sourceIdx = s.views.firstIndex(where: { $0.id == sourceId }) else { return } + let view = s.views.remove(at: sourceIdx) + if let beforeId, let targetIdx = s.views.firstIndex(where: { $0.id == beforeId }) { + s.views.insert(view, at: targetIdx) + } else { + s.views.append(view) + } + schema = s + Task { + try? dbService.saveSchema(s, at: dbPath) + } + } + func deleteView(_ view: ViewConfig) { guard var s = schema, s.views.count > 1 else { return } s.views.removeAll { $0.id == view.id } @@ -659,6 +689,70 @@ final class DatabaseViewState { } } + // MARK: - Templates + + var templates: [DatabaseTemplate] { + schema?.templates ?? [] + } + + func createTemplate(name: String) -> DatabaseTemplate { + let template = DatabaseTemplate( + id: "tmpl_\(UUID().uuidString.prefix(8).lowercased())", + name: name, + icon: "doc.text" + ) + if schema?.templates == nil { + schema?.templates = [] + } + schema?.templates?.append(template) + persistSchema() + return template + } + + func updateTemplate(_ template: DatabaseTemplate) { + guard let idx = schema?.templates?.firstIndex(where: { $0.id == template.id }) else { return } + schema?.templates?[idx] = template + persistSchema() + } + + func deleteTemplate(_ templateId: String) { + schema?.templates?.removeAll { $0.id == templateId } + if schema?.templates?.isEmpty == true { + schema?.templates = nil + } + persistSchema() + } + + @discardableResult + func createRowFromTemplate(_ template: DatabaseTemplate) throws -> DatabaseRow { + guard let s = schema else { + throw NSError(domain: "Bugbook.Database", code: 1, userInfo: [NSLocalizedDescriptionKey: "Schema unavailable"]) + } + var newRow = try dbService.createRow(in: dbPath, schema: s) + // Apply template default properties + for (key, value) in template.defaultProperties { + newRow.properties[key] = value + } + // Apply template body + newRow.body = template.body + try dbService.saveRow(newRow, schema: s, at: dbPath) + if let idx = rows.firstIndex(where: { $0.id == newRow.id }) { + rows[idx] = newRow + } else { + rows.append(newRow) + } + postChangeNotification() + return newRow + } + + private func persistSchema() { + guard let s = schema else { return } + Task { + try? dbService.saveSchema(s, at: dbPath) + postChangeNotification() + } + } + // MARK: - Lifecycle func cancelAll() { diff --git a/Sources/Bugbook/Views/Database/KanbanView.swift b/Sources/Bugbook/Views/Database/KanbanView.swift index 59f9415..81a9567 100644 --- a/Sources/Bugbook/Views/Database/KanbanView.swift +++ b/Sources/Bugbook/Views/Database/KanbanView.swift @@ -10,6 +10,7 @@ struct KanbanView: View { var onOpenRow: (DatabaseRow) -> Void var onSave: (DatabaseRow) -> Void var onUpdateGroupBy: ((String) -> Void)? + var onUpdateSubGroupBy: ((String?) -> Void)? var onAddSelectOption: ((String, SelectOption) -> Void)? var onDelete: ((DatabaseRow) -> Void)? var onReorderRows: ((String, String?) -> Void)? @@ -23,6 +24,7 @@ struct KanbanView: View { @State private var showColumnPopover: String? = nil @State private var showCardPopover: String? = nil @State private var editingColumnName: String = "" + @State private var collapsedSubGroups: Set = [] // Custom drag state @State private var draggingRowId: String? = nil @@ -35,6 +37,13 @@ struct KanbanView: View { schema.properties.filter { $0.type == .select } } + /// Properties eligible for sub-grouping: select and relation types, excluding the primary group-by. + private var subGroupableProperties: [PropertyDefinition] { + schema.properties.filter { prop in + (prop.type == .select || prop.type == .relation) && prop.id != groupProperty?.id + } + } + private var groupProperty: PropertyDefinition? { guard let groupId = viewConfig.groupBy else { return schema.properties.first(where: { $0.type == .select }) @@ -42,6 +51,11 @@ struct KanbanView: View { return schema.properties.first(where: { $0.id == groupId }) } + private var subGroupProperty: PropertyDefinition? { + guard let subGroupId = viewConfig.subGroupBy else { return nil } + return schema.properties.first(where: { $0.id == subGroupId }) + } + private var columns: [(id: String, name: String, color: String)] { guard let prop = groupProperty else { return [] } var cols: [(id: String, name: String, color: String)] = [("__none__", "No \(prop.name)", "gray")] @@ -82,37 +96,131 @@ struct KanbanView: View { } } + // MARK: - Sub-Grouping + + /// Extract the sub-group key from a row's property value for the sub-group property. + private func subGroupKey(for row: DatabaseRow) -> String { + guard let prop = subGroupProperty, + let val = row.properties[prop.id] else { return "__none__" } + switch val { + case .select(let s): return s.isEmpty ? "__none__" : s + case .relation(let s): return s.isEmpty ? "__none__" : s + case .empty: return "__none__" + default: return "__none__" + } + } + + /// Display name for a sub-group key value. + private func subGroupDisplayName(for key: String) -> String { + guard let prop = subGroupProperty else { return key } + if key == "__none__" { return "No \(prop.name)" } + if prop.type == .select, let options = prop.options { + return options.first(where: { $0.id == key })?.name ?? key + } + // For relations, the key is a row id — just show it as-is + return key + } + + /// Partition column rows into ordered sub-groups. "__none__" group is placed last. + private func subGroups(for columnRows: [DatabaseRow]) -> [(key: String, name: String, rows: [DatabaseRow])] { + guard subGroupProperty != nil else { return [] } + + var grouped: [String: [DatabaseRow]] = [:] + var keyOrder: [String] = [] + for row in columnRows { + let key = subGroupKey(for: row) + if grouped[key] == nil { keyOrder.append(key) } + grouped[key, default: []].append(row) + } + + // Move "__none__" to end + if let noneIdx = keyOrder.firstIndex(of: "__none__") { + keyOrder.remove(at: noneIdx) + keyOrder.append("__none__") + } + + return keyOrder.map { key in + (key: key, name: subGroupDisplayName(for: key), rows: grouped[key] ?? []) + } + } + + /// Unique key for tracking collapsed state of a sub-group within a column. + private func subGroupCollapseKey(column: String, subGroup: String) -> String { + "\(column)::\(subGroup)" + } + var body: some View { VStack(spacing: 0) { - // GroupBy selector - if selectProperties.count > 1 { + // GroupBy / Sub-group by selectors + if selectProperties.count > 1 || !subGroupableProperties.isEmpty { HStack(spacing: 8) { - Text("Group by:") - .font(DatabaseZoomMetrics.font(12)) - .foregroundStyle(.secondary) - Menu { - ForEach(selectProperties) { prop in + if selectProperties.count > 1 { + Text("Group by:") + .font(DatabaseZoomMetrics.font(12)) + .foregroundStyle(.secondary) + Menu { + ForEach(selectProperties) { prop in + Button { + onUpdateGroupBy?(prop.id) + } label: { + HStack { + Text(prop.name) + if prop.id == groupProperty?.id { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(groupProperty?.name ?? "Select property") + .font(DatabaseZoomMetrics.font(12)) + Image(systemName: "chevron.down") + .font(DatabaseZoomMetrics.font(11)) + } + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + if !subGroupableProperties.isEmpty { + Text("Sub-group by:") + .font(DatabaseZoomMetrics.font(12)) + .foregroundStyle(.secondary) + Menu { Button { - onUpdateGroupBy?(prop.id) + onUpdateSubGroupBy?(nil) } label: { HStack { - Text(prop.name) - if prop.id == groupProperty?.id { + Text("None") + if viewConfig.subGroupBy == nil { Image(systemName: "checkmark") } } } + ForEach(subGroupableProperties) { prop in + Button { + onUpdateSubGroupBy?(prop.id) + } label: { + HStack { + Text(prop.name) + if prop.id == viewConfig.subGroupBy { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(subGroupProperty?.name ?? "None") + .font(DatabaseZoomMetrics.font(12)) + Image(systemName: "chevron.down") + .font(DatabaseZoomMetrics.font(11)) + } } - } label: { - HStack(spacing: 4) { - Text(groupProperty?.name ?? "Select property") - .font(DatabaseZoomMetrics.font(12)) - Image(systemName: "chevron.down") - .font(DatabaseZoomMetrics.font(11)) - } + .menuStyle(.borderlessButton) + .fixedSize() } - .menuStyle(.borderlessButton) - .fixedSize() Spacer() } @@ -155,7 +263,7 @@ struct KanbanView: View { private func dragPreview(_ title: String) -> some View { Text(title.isEmpty ? "Untitled" : title) - .font(DatabaseZoomMetrics.font(17)) + .font(DatabaseZoomMetrics.font(14)) .fontWeight(.medium) .lineLimit(1) .padding(.horizontal, DatabaseZoomMetrics.size(12)) @@ -375,6 +483,86 @@ struct KanbanView: View { .popoverSurface() } + // MARK: - Sub-Group Section Header + + @ViewBuilder + private func subGroupHeader(name: String, count: Int, collapseKey: String) -> some View { + let isCollapsed = collapsedSubGroups.contains(collapseKey) + Button { + withAnimation(.easeInOut(duration: 0.15)) { + if isCollapsed { + collapsedSubGroups.remove(collapseKey) + } else { + collapsedSubGroups.insert(collapseKey) + } + } + } label: { + HStack(spacing: DatabaseZoomMetrics.size(4)) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(DatabaseZoomMetrics.font(9)) + .foregroundStyle(.tertiary) + Text(name) + .font(DatabaseZoomMetrics.font(11)) + .fontWeight(.medium) + .foregroundStyle(.secondary) + Text("\(count)") + .font(DatabaseZoomMetrics.font(10)) + .foregroundStyle(.tertiary) + .padding(.horizontal, DatabaseZoomMetrics.size(4)) + .padding(.vertical, DatabaseZoomMetrics.size(1)) + .background(Color.fallbackBadgeBg) + .clipShape(.rect(cornerRadius: DatabaseZoomMetrics.size(3))) + Spacer() + } + .padding(.horizontal, DatabaseZoomMetrics.size(8)) + .padding(.vertical, DatabaseZoomMetrics.size(4)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: - Column Card Content + + /// Renders cards for a column, either flat or sub-grouped. + @ViewBuilder + private func columnCardContent(columnId: String, columnColor: Color) -> some View { + let columnRows = rowsForColumn(columnId) + + if subGroupProperty != nil { + let groups = subGroups(for: columnRows) + ForEach(groups, id: \.key) { group in + let collapseKey = subGroupCollapseKey(column: columnId, subGroup: group.key) + subGroupHeader(name: group.name, count: group.rows.count, collapseKey: collapseKey) + + if !collapsedSubGroups.contains(collapseKey) { + ForEach(group.rows) { row in + draggableCard(row, columnColor: columnColor) + } + } + } + } else { + ForEach(columnRows) { row in + draggableCard(row, columnColor: columnColor) + } + } + } + + @ViewBuilder + private func draggableCard(_ row: DatabaseRow, columnColor: Color) -> some View { + let title = row.title(schema: schema) + kanbanCard(row, title: title, columnColor: columnColor) + .opacity(draggingRowId == row.id ? 0.2 : 1) + .gesture( + DragGesture(coordinateSpace: .named(Self.coordinateSpaceName)) + .onChanged { value in + updateDrag(for: row, at: value.location) + } + .onEnded { value in + endDrag(for: row, at: value.location) + } + ) + } + // MARK: - Kanban Column private func kanbanColumn(_ column: (id: String, name: String, color: String), index: Int, availableHeight: CGFloat) -> some View { @@ -408,22 +596,7 @@ struct KanbanView: View { // Cards — scroll vertically within column ScrollView(.vertical) { LazyVStack(spacing: DatabaseZoomMetrics.size(6)) { - let columnRows = rowsForColumn(column.id) - - ForEach(columnRows) { row in - let title = row.title(schema: schema) - kanbanCard(row, title: title, columnColor: columnColor) - .opacity(draggingRowId == row.id ? 0.2 : 1) - .gesture( - DragGesture(coordinateSpace: .named(Self.coordinateSpaceName)) - .onChanged { value in - updateDrag(for: row, at: value.location) - } - .onEnded { value in - endDrag(for: row, at: value.location) - } - ) - } + columnCardContent(columnId: column.id, columnColor: columnColor) // + New page button at bottom, colored like Notion Button { @@ -473,6 +646,27 @@ struct KanbanView: View { onSave(savedRow) } + /// Update the sub-group property value when a card is dragged to a different sub-group. + private func moveCardSubGroup(_ rowId: String, toSubGroup subGroupKey: String) { + guard let prop = subGroupProperty else { return } + guard let sourceIdx = rows.firstIndex(where: { $0.id == rowId }) else { return } + let newValue: PropertyValue + if subGroupKey == "__none__" { + newValue = .empty + } else { + switch prop.type { + case .select: newValue = .select(subGroupKey) + case .relation: newValue = .relation(subGroupKey) + default: return + } + } + var updated = rows + updated[sourceIdx].properties[prop.id] = newValue + let savedRow = updated[sourceIdx] + rows = updated + onSave(savedRow) + } + private func addCardInColumn(_ columnId: String) { let now = Date() var properties: [String: PropertyValue] = [:] @@ -498,7 +692,7 @@ struct KanbanView: View { private func kanbanCard(_ row: DatabaseRow, title: String, columnColor: Color) -> some View { Text(title.isEmpty ? "Untitled" : title) - .font(DatabaseZoomMetrics.font(17)) + .font(DatabaseZoomMetrics.font(14)) .fontWeight(.medium) .lineLimit(2) .foregroundStyle(.primary) @@ -546,7 +740,7 @@ struct KanbanView: View { private var kanbanInsertionIndicator: some View { Rectangle() - .fill(Color.accentColor.opacity(0.9)) + .fill(Color.dragIndicator) .frame(height: 2) .padding(.horizontal, DatabaseZoomMetrics.size(6)) } @@ -571,6 +765,7 @@ struct KanbanView: View { dragLocation = location let target = reorderTarget(for: location) let targetColumn = targetColumnId(at: location) + let sourceSubGroup = subGroupProperty != nil ? subGroupKey(for: row) : nil draggingRowId = nil dragTargetColumn = nil reorderTarget = nil @@ -578,6 +773,16 @@ struct KanbanView: View { if let targetColumn { moveCard(row.id, toColumn: targetColumn) } + + // Determine the target sub-group from the drop target row + if subGroupProperty != nil, let target, let targetRowId = target.rowId, + let targetRow = rows.first(where: { $0.id == targetRowId }) { + let destSubGroup = subGroupKey(for: targetRow) + if destSubGroup != sourceSubGroup { + moveCardSubGroup(row.id, toSubGroup: destSubGroup) + } + } + guard let target else { return } onReorderRows?(row.id, beforeId(for: target)) } diff --git a/Sources/Bugbook/Views/Database/PropertyEditorView.swift b/Sources/Bugbook/Views/Database/PropertyEditorView.swift index 13c2bfd..28ef2fe 100644 --- a/Sources/Bugbook/Views/Database/PropertyEditorView.swift +++ b/Sources/Bugbook/Views/Database/PropertyEditorView.swift @@ -29,32 +29,42 @@ struct PropertyEditorView: View { /// Callback to set the target database for a relation property. var onSetRelationTarget: ((String, String) -> Void)? // (propertyId, targetDbPath) - /// Consistent cell font matching editor body text (17pt scaled). - private var cellFont: Font { DatabaseZoomMetrics.font(17) } + /// Consistent cell font matching table text (14pt scaled). + private var cellFont: Font { DatabaseZoomMetrics.font(14) } + + /// Whether this property type uses option editing popovers (select/multiSelect only). + private var usesOptionEditing: Bool { + definition.type == .select || definition.type == .multiSelect + } var body: some View { - mainEditor - .databasePointerCursor() - .floatingPopover(item: $editingOptionId) { optId in - editOptionPopover(optionId: optId) - } - .alert("Delete Option", isPresented: $showDeleteAlert) { - Button("Cancel", role: .cancel) { showDeleteConfirm = nil } - Button("Delete", role: .destructive) { - if let optId = showDeleteConfirm { - onDeleteOption?(definition.id, optId) + if usesOptionEditing { + mainEditor + .databasePointerCursor() + .floatingPopover(item: $editingOptionId) { optId in + editOptionPopover(optionId: optId) + } + .alert("Delete Option", isPresented: $showDeleteAlert) { + Button("Cancel", role: .cancel) { showDeleteConfirm = nil } + Button("Delete", role: .destructive) { + if let optId = showDeleteConfirm { + onDeleteOption?(definition.id, optId) + } + showDeleteConfirm = nil } - showDeleteConfirm = nil + } message: { + Text("This will remove the option from all rows that use it.") } - } message: { - Text("This will remove the option from all rows that use it.") - } - .onChange(of: showDeleteConfirm) { _, val in - showDeleteAlert = (val != nil) - } - .onChange(of: showDeleteAlert) { _, show in - if !show { showDeleteConfirm = nil } - } + .onChange(of: showDeleteConfirm) { _, val in + showDeleteAlert = (val != nil) + } + .onChange(of: showDeleteAlert) { _, show in + if !show { showDeleteConfirm = nil } + } + } else { + mainEditor + .databasePointerCursor() + } } @ViewBuilder @@ -643,7 +653,7 @@ struct PropertyEditorView: View { } label: { Image(systemName: "plus") .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(Color.fallbackAccentFg) .frame(width: 20, height: 20) .background(Color.accentColor) .clipShape(Circle()) @@ -1097,7 +1107,7 @@ private struct DatePropertyPopover: View { Text("\(calendar.component(.day, from: cell.date))") .font(.callout) .fontWeight(cell.isSelected ? .semibold : .regular) - .foregroundStyle(cell.isSelected ? Color.white : (cell.isCurrentMonth ? Color.primary : Color.secondary)) + .foregroundStyle(cell.isSelected ? Color.fallbackAccentFg : (cell.isCurrentMonth ? Color.primary : Color.secondary)) .frame(maxWidth: .infinity) .frame(height: 38) .background( diff --git a/Sources/Bugbook/Views/Database/RowPageView.swift b/Sources/Bugbook/Views/Database/RowPageView.swift index 4192806..628b51e 100644 --- a/Sources/Bugbook/Views/Database/RowPageView.swift +++ b/Sources/Bugbook/Views/Database/RowPageView.swift @@ -20,6 +20,9 @@ struct RowPageView: View { var autoFocusTitle: Bool = false var fullWidth: Bool = false var dbPath: String = "" + var templates: [DatabaseTemplate] = [] + var onApplyTemplate: ((DatabaseTemplate) -> Void)? + var onNewTemplate: (() -> Void)? @Environment(\.workspacePath) private var workspacePath @State private var editingTitle: String = "" @@ -52,6 +55,20 @@ struct RowPageView: View { .system(size: EditorTypography.scaled(34), weight: .bold) } + /// Whether the row is empty (no title, no non-empty properties, no body). + private var isRowEmpty: Bool { + let titleEmpty = storedTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let bodyEmpty = row.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let propsEmpty = row.properties.values.allSatisfy { val in + switch val { + case .empty: return true + case .text(let s): return s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + default: return false + } + } + return titleEmpty && bodyEmpty && propsEmpty + } + var body: some View { VStack(alignment: .leading, spacing: 0) { if showBreadcrumb { @@ -121,6 +138,10 @@ struct RowPageView: View { if onAddProperty != nil { addPropertyRow } + + if !templates.isEmpty, isRowEmpty { + templateSection + } } .padding(.vertical, 8) @@ -253,6 +274,63 @@ struct RowPageView: View { .popoverSurface() } } + + @State private var templateHoveredId: String? + + private var templateSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Press \u{23CE} to continue with an empty page, or pick a template") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.horizontal, 8) + .padding(.top, 8) + + ForEach(templates) { template in + Button { + onApplyTemplate?(template) + } label: { + HStack(spacing: 8) { + Image(systemName: template.icon) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(width: 16) + Text(template.name) + .font(.callout) + .foregroundStyle(.primary) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(templateHoveredId == template.id ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: Radius.xs)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in templateHoveredId = hovering ? template.id : nil } + } + + if onNewTemplate != nil { + Button { + onNewTemplate?() + } label: { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + Text("New template") + .font(.caption) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(templateHoveredId == "_new" ? Color.primary.opacity(0.06) : Color.clear) + .clipShape(.rect(cornerRadius: Radius.xs)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in templateHoveredId = hovering ? "_new" : nil } + } + } + } } // MARK: - Property Row with split hover zones @@ -312,7 +390,20 @@ private struct PropertyRowView: View { onSave(row) } ) - PropertyEditorView(definition: prop, value: propValue, compact: false, onAddOption: onAddOption, onUpdateOption: onUpdateOption, onDeleteOption: onDeleteOption, onLoadRelationRows: prop.type == .relation ? { onLoadRelationRows?(prop) ?? [] } : nil, onListDatabases: prop.type == .relation ? { onListDatabases?() ?? [] } : nil, onSetRelationTarget: prop.type == .relation ? onSetRelationTarget : nil) + PropertyEditorView( + definition: prop, + value: propValue, + compact: false, + onAddOption: onAddOption, + onUpdateOption: onUpdateOption, + onDeleteOption: onDeleteOption, + onLoadRelationRows: prop.type == .relation + ? { onLoadRelationRows?(prop) ?? [] } : nil, + onListDatabases: prop.type == .relation + ? { onListDatabases?() ?? [] } : nil, + onSetRelationTarget: prop.type == .relation + ? onSetRelationTarget : nil + ) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 7) diff --git a/Sources/Bugbook/Views/Database/TableView.swift b/Sources/Bugbook/Views/Database/TableView.swift index 257d4ec..a8afbdf 100644 --- a/Sources/Bugbook/Views/Database/TableView.swift +++ b/Sources/Bugbook/Views/Database/TableView.swift @@ -1,6 +1,10 @@ import SwiftUI import BugbookCore +private enum TableViewLayoutMetrics { + static let compactHeaderHeight: CGFloat = 32 +} + struct TableView: View { static let rowControlsInset: CGFloat = 32 private static let reorderCoordinateSpace = "table-reorder" @@ -29,6 +33,7 @@ struct TableView: View { var scrollToRowId: String? = nil var showVerticalLines: Bool = true var usesInnerScroll: Bool = true + var containerWidth: CGFloat? = nil @State private var dragWidths: [String: CGFloat] = [:] @State private var hoveredResizeKey: String? @@ -43,6 +48,8 @@ struct TableView: View { @State private var dragLocation: CGPoint = .zero @State private var rowFrames: [String: CGRect] = [:] @State private var reorderTarget: TableReorderTarget? + @State private var hoveredEmptyRow: Int? + @State private var focusedCellId: String? private let titleColumnKey = "__title__" private let topAnchorKey = "__table_top__" @@ -57,17 +64,34 @@ struct TableView: View { } private var titleColumnWidth: CGFloat { - dragWidths[titleColumnKey] ?? viewConfig.columnWidths?[titleColumnKey] ?? DatabaseZoomMetrics.size(320) + dragWidths[titleColumnKey] ?? viewConfig.columnWidths?[titleColumnKey] ?? DatabaseZoomMetrics.size(240) } private var wrapCellText: Bool { viewConfig.wrapCellText ?? false } + /// Minimum width the table content needs (columns + controls + padding). + private var contentMinWidth: CGFloat { + let columnsWidth = titleColumnWidth + visibleProperties.reduce(0) { $0 + columnWidth(for: $1) } + // row controls + horizontal padding on row HStack + approx "Add property" button + let extras = scaledRowControlsInset + DatabaseZoomMetrics.size(8) + DatabaseZoomMetrics.size(120) + return columnsWidth + extras + } + + /// The effective minimum width: at least as wide as column content OR the container. + private var effectiveMinWidth: CGFloat { + max(contentMinWidth, containerWidth ?? 0) + } + private var canReorderRows: Bool { viewConfig.sorts.isEmpty } + private var compactHeaderHeight: CGFloat { + DatabaseZoomMetrics.size(TableViewLayoutMetrics.compactHeaderHeight) + } + private var draggingRow: DatabaseRow? { guard let draggingRowId else { return nil } return rows.first(where: { $0.id == draggingRowId }) @@ -79,16 +103,15 @@ struct TableView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Header + // Header with selection bar overlay headerRow + .overlay(alignment: .leading) { + if !selectedRowIds.isEmpty { + selectionBar + } + } tableDivider - // Selection toolbar - if !selectedRowIds.isEmpty { - selectionBar - tableDivider - } - rowsRegion } .overlay { @@ -99,6 +122,7 @@ struct TableView: View { } } .frame( + minWidth: effectiveMinWidth, maxWidth: .infinity, maxHeight: usesInnerScroll ? .infinity : nil, alignment: .topLeading @@ -115,77 +139,65 @@ struct TableView: View { // MARK: - Selection Bar private var selectionBar: some View { - HStack(spacing: 0) { - HStack(spacing: 16) { + HStack(spacing: 12) { + Button { + selectedRowIds.removeAll() + } label: { Text("\(selectedRowIds.count) selected") - .font(DatabaseZoomMetrics.font(15)) + .font(DatabaseZoomMetrics.font(13)) .fontWeight(.medium) .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) - Button { - let toDelete = rows.filter { selectedRowIds.contains($0.id) } - selectedRowIds.removeAll() - toDelete.forEach { onDelete?($0) } - } label: { - HStack(spacing: 4) { - Image(systemName: "trash") - .font(DatabaseZoomMetrics.font(11)) - Text("Delete") - .font(DatabaseZoomMetrics.font(15)) - } + Button { + let toDelete = rows.filter { selectedRowIds.contains($0.id) } + selectedRowIds.removeAll() + toDelete.forEach { onDelete?($0) } + } label: { + Image(systemName: "trash") + .font(DatabaseZoomMetrics.font(13)) .foregroundStyle(.red) - .padding(.horizontal, DatabaseZoomMetrics.size(10)) - .padding(.vertical, DatabaseZoomMetrics.size(4)) - .background( - RoundedRectangle(cornerRadius: DatabaseZoomMetrics.size(6)) - .fill(Color.red.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: DatabaseZoomMetrics.size(6)) - .stroke(Color.red.opacity(0.15), lineWidth: 1) - ) - ) - } - .buttonStyle(.plain) - - Spacer() - - Button { - selectedRowIds.removeAll() - } label: { - Text("Deselect all") - .font(DatabaseZoomMetrics.font(15)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) } - .padding(.horizontal, DatabaseZoomMetrics.size(8)) + .buttonStyle(.plain) } - .padding(.vertical, DatabaseZoomMetrics.size(8)) - .background(Color.accentColor.opacity(0.03)) + .padding(.horizontal, DatabaseZoomMetrics.size(10)) + .padding(.vertical, DatabaseZoomMetrics.size(4)) + .background( + RoundedRectangle(cornerRadius: DatabaseZoomMetrics.size(6)) + .fill(Color(nsColor: .windowBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: DatabaseZoomMetrics.size(6)) + .stroke(Color.fallbackBorderColor.opacity(0.9), lineWidth: 1) + ) + ) + .padding(.leading, scaledRowControlsInset + DatabaseZoomMetrics.size(4)) } // MARK: - Header private var headerRow: some View { HStack(spacing: 0) { - Color.clear - .frame(width: scaledRowControlsInset) + // Leading spacer matching row controls width + Color.clear.frame(width: scaledRowControlsInset, height: 1) // Title column header TitleColumnHeaderCell( name: schema.titleProperty?.name ?? "Name", propertyId: schema.titleProperty?.id, + height: compactHeaderHeight, onRename: onRenameProperty ) .frame(width: titleColumnWidth) .overlay(alignment: .trailing) { - resizeHandle(key: titleColumnKey, baseWidth: viewConfig.columnWidths?[titleColumnKey] ?? 320) + resizeHandle(key: titleColumnKey, baseWidth: viewConfig.columnWidths?[titleColumnKey] ?? 240) } // Property column headers ForEach(visibleProperties) { prop in ColumnHeaderCell( prop: prop, + height: compactHeaderHeight, onRename: onRenameProperty, onChangeType: onChangePropertyType, onToggleColumn: onToggleColumn, @@ -213,17 +225,48 @@ struct TableView: View { Image(systemName: "plus") .font(DatabaseZoomMetrics.font(11)) Text("Add property") - .font(DatabaseZoomMetrics.font(15)) + .font(DatabaseZoomMetrics.font(13)) + .lineLimit(1) + .truncationMode(.tail) } .foregroundStyle(.secondary) .padding(.horizontal, DatabaseZoomMetrics.size(8)) .padding(.vertical, DatabaseZoomMetrics.size(4)) + .frame(height: compactHeaderHeight) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() } .padding(.horizontal, DatabaseZoomMetrics.size(4)) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: compactHeaderHeight) + .overlay(alignment: .leading) { + if !selectedRowIds.isEmpty { + headerCheckbox + .offset(x: -scaledRowControlsInset + rowHandleWidth + rowControlsSpacing) + } + } + } + + @ViewBuilder + private var headerCheckbox: some View { + let visibleCount = min(displayedRowCount, rows.count) + let allSelected = !rows.isEmpty && selectedRowIds.count == visibleCount + let someSelected = !selectedRowIds.isEmpty && !allSelected + + Button { + if allSelected { + selectedRowIds.removeAll() + } else { + selectedRowIds = Set(rows.prefix(visibleCount).map(\.id)) + } + } label: { + Image(systemName: allSelected ? "checkmark.square.fill" : someSelected ? "minus.square.fill" : "square") + .font(DatabaseZoomMetrics.font(13)) + .foregroundStyle(allSelected || someSelected ? Color.dragIndicator : .secondary) + } + .buttonStyle(.plain) } // MARK: - Resize Handle (overlaid on column trailing edge) @@ -235,7 +278,7 @@ struct TableView: View { .frame(width: hitWidth) .overlay { Rectangle() - .fill(isActive ? Color.accentColor : Color.clear) + .fill(isActive ? Color.dragIndicator : Color.clear) .frame(width: isActive ? 2 : 1) .padding(.vertical, -8) } @@ -268,6 +311,7 @@ struct TableView: View { dragStartWidths.removeValue(forKey: key) dragStartX.removeValue(forKey: key) draggingResizeKey = nil + hoveredResizeKey = nil NSCursor.pop() } ) @@ -278,20 +322,26 @@ struct TableView: View { // MARK: - Data Row private func dataRow(_ row: Binding) -> some View { - HoverRow { isHovered in + let isSelected = selectedRowIds.contains(row.wrappedValue.id) + return HoverRow { isHovered in HStack(alignment: .center, spacing: 0) { + // Controls in gutter — no hover background rowControls(for: row.wrappedValue, isHovered: isHovered) - .frame(width: scaledRowControlsInset, alignment: .trailing) + .frame(width: scaledRowControlsInset, alignment: .center) - HStack(alignment: .top, spacing: 0) { + // Content cells — hover/selection background only here + HStack(alignment: .center, spacing: 0) { + let titleCellId = "\(row.wrappedValue.id)__title" titleCell(row, isHovered: isHovered) .padding(.horizontal, DatabaseZoomMetrics.size(8)) - .padding(.vertical, DatabaseZoomMetrics.size(14)) .frame(width: titleColumnWidth, alignment: .leading) + .background(focusedCellId == titleCellId ? Color.accentColor.opacity(0.06) : Color.clear) .contentShape(Rectangle()) .databasePointerCursor() + .simultaneousGesture(TapGesture().onEnded { focusedCellId = titleCellId }) ForEach(visibleProperties) { prop in + let cellId = "\(row.wrappedValue.id)_\(prop.id)" PropertyEditorView( definition: prop, value: propertyBinding(row: row, propertyId: prop.id), @@ -305,35 +355,46 @@ struct TableView: View { onSetRelationTarget: prop.type == .relation ? onSetRelationTarget : nil ) .padding(.horizontal, DatabaseZoomMetrics.size(8)) - .padding(.vertical, DatabaseZoomMetrics.size(14)) .frame(width: columnWidth(for: prop), alignment: .leading) + .background(focusedCellId == cellId ? Color.accentColor.opacity(0.06) : Color.clear) .contentShape(Rectangle()) .databasePointerCursor() + .simultaneousGesture(TapGesture().onEnded { focusedCellId = cellId }) } } + .frame(height: compactHeaderHeight) .padding(.horizontal, DatabaseZoomMetrics.size(4)) .background( RoundedRectangle(cornerRadius: DatabaseZoomMetrics.size(4)) - .fill(isHovered ? Color.primary.opacity(0.04) : Color.clear) + .fill( + isSelected + ? Color.accentColor.opacity(0.08) + : isHovered ? Color.primary.opacity(0.04) : Color.clear + ) ) .overlay { columnDividers().allowsHitTesting(false) } } + .frame(maxWidth: .infinity, alignment: .leading) .overlay(alignment: .topLeading) { - if showsInsertionIndicator(for: row.wrappedValue.id, placement: .before) { + if draggingRowId != nil, + showsInsertionIndicator(for: row.wrappedValue.id, placement: .before) { insertionIndicator } } .overlay(alignment: .bottomLeading) { - if showsInsertionIndicator(for: row.wrappedValue.id, placement: .after) { + if draggingRowId != nil, + showsInsertionIndicator(for: row.wrappedValue.id, placement: .after) { insertionIndicator } } .background { - GeometryReader { proxy in - Color.clear.preference( - key: TableRowFramePreferenceKey.self, - value: [row.wrappedValue.id: proxy.frame(in: .named(Self.reorderCoordinateSpace))] - ) + if draggingRowId != nil { + GeometryReader { proxy in + Color.clear.preference( + key: TableRowFramePreferenceKey.self, + value: [row.wrappedValue.id: proxy.frame(in: .named(Self.reorderCoordinateSpace))] + ) + } } } } @@ -341,17 +402,30 @@ struct TableView: View { // MARK: - Column Dividers (row-level overlay) + /// Pre-compute divider x-offsets so each row draws a single Canvas instead of N+1 view pairs. + private var columnDividerOffsets: [CGFloat] { + guard showVerticalLines else { return [] } + var offsets: [CGFloat] = [] + var x = DatabaseZoomMetrics.size(4) + titleColumnWidth + offsets.append(x) + for prop in visibleProperties { + x += columnWidth(for: prop) + offsets.append(x) + } + return offsets + } + @ViewBuilder private func columnDividers() -> some View { if showVerticalLines { - HStack(spacing: 0) { - Color.clear.frame(width: DatabaseZoomMetrics.size(4) + titleColumnWidth) - Rectangle().fill(Color.gray.opacity(0.15)).frame(width: 1) - ForEach(visibleProperties) { prop in - Color.clear.frame(width: columnWidth(for: prop)) - Rectangle().fill(Color.gray.opacity(0.15)).frame(width: 1) + let offsets = columnDividerOffsets + Canvas { context, size in + for x in offsets { + context.fill( + Path(CGRect(x: x, y: 0, width: 1, height: size.height)), + with: .color(.gray.opacity(0.15)) + ) } - Spacer(minLength: 0) } } } @@ -359,11 +433,11 @@ struct TableView: View { // MARK: - Title Cell private func titleCell(_ row: Binding, isHovered: Bool) -> some View { - let openPillSize = CGSize(width: DatabaseZoomMetrics.size(74), height: DatabaseZoomMetrics.size(24)) + let openPillSize = CGSize(width: DatabaseZoomMetrics.size(60), height: DatabaseZoomMetrics.size(20)) let titleBinding = titleBinding(row: row) return titleTextField(titleBinding) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .databasePointerCursor() .overlay(alignment: .trailing) { if isHovered { @@ -388,7 +462,7 @@ struct TableView: View { ) ) } - .buttonStyle(.plain) + .buttonStyle(NoFeedbackButtonStyle()) .fixedSize() .help("Open in side peek") .padding(.trailing, DatabaseZoomMetrics.size(4)) @@ -396,45 +470,72 @@ struct TableView: View { } } - // MARK: - Phantom Row + // MARK: - Filler Row & New Page Button - private func phantomRow(isFirst: Bool) -> some View { - Button { onNewRow?() } label: { - HStack(spacing: 0) { - Color.clear - .frame(width: scaledRowControlsInset) - .overlay(alignment: .trailing) { - if isFirst { - Image(systemName: "plus") - .font(DatabaseZoomMetrics.font(11)) - .foregroundStyle(Color.primary.opacity(0.25)) - } - } + private var fillerRow: some View { + HStack(spacing: 0) { + Color.clear.frame(width: scaledRowControlsInset + titleColumnWidth) + ForEach(visibleProperties) { prop in + Color.clear.frame(width: columnWidth(for: prop)) + } + } + .padding(.horizontal, DatabaseZoomMetrics.size(4)) + .frame(height: compactHeaderHeight) + .overlay { columnDividers().allowsHitTesting(false) } + .frame(maxWidth: .infinity, alignment: .leading) + } - HStack(spacing: 0) { - TextField(isFirst ? "New page" : "", text: .constant("")) - .textFieldStyle(.plain) - .font(DatabaseZoomMetrics.font(17)) - .foregroundStyle(Color.primary.opacity(0.25)) - .disabled(true) - .allowsHitTesting(false) - .padding(.horizontal, DatabaseZoomMetrics.size(8)) - .frame(width: titleColumnWidth, alignment: .leading) + private func emptyTableRow(index: Int) -> some View { + let isHovered = hoveredEmptyRow == index + // Show "+ New page" on the hovered row, or on row 0 if nothing is hovered + let showLabel = isHovered || (hoveredEmptyRow == nil && index == 0) - ForEach(visibleProperties) { prop in - TextField("", text: .constant("")) - .textFieldStyle(.plain) - .disabled(true) - .allowsHitTesting(false) - .padding(.horizontal, DatabaseZoomMetrics.size(8)) - .frame(width: columnWidth(for: prop), alignment: .leading) + return Button { onNewRow?() } label: { + HStack(spacing: 0) { + Color.clear.frame(width: scaledRowControlsInset, height: 1) + HStack(spacing: DatabaseZoomMetrics.size(4)) { + if showLabel { + Image(systemName: "plus") + .font(DatabaseZoomMetrics.font(11)) + .foregroundStyle(Color.primary.opacity(0.25)) + Text("New page") + .font(DatabaseZoomMetrics.font(13)) + .foregroundStyle(Color.primary.opacity(0.25)) } + Spacer(minLength: 0) + } + .padding(.horizontal, DatabaseZoomMetrics.size(8)) + .frame(width: titleColumnWidth, alignment: .leading) + + ForEach(visibleProperties) { prop in + Color.clear.frame(width: columnWidth(for: prop)) } - .padding(.horizontal, DatabaseZoomMetrics.size(4)) - .padding(.vertical, DatabaseZoomMetrics.size(14)) } - .contentShape(Rectangle()) + .padding(.horizontal, DatabaseZoomMetrics.size(4)) + .frame(height: compactHeaderHeight) .overlay { columnDividers().allowsHitTesting(false) } + .frame(maxWidth: .infinity, alignment: .leading) + .background(isHovered ? Color.primary.opacity(0.04) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { inside in + hoveredEmptyRow = inside ? index : nil + } + } + + private var newPageButton: some View { + Button { onNewRow?() } label: { + HStack(spacing: DatabaseZoomMetrics.size(4)) { + Image(systemName: "plus") + .font(DatabaseZoomMetrics.font(11)) + Text("New page") + .font(DatabaseZoomMetrics.font(13)) + } + .foregroundStyle(Color.primary.opacity(0.25)) + .padding(.leading, scaledRowControlsInset + DatabaseZoomMetrics.size(8)) + .padding(.trailing, DatabaseZoomMetrics.size(8)) + .padding(.vertical, DatabaseZoomMetrics.size(6)) } .buttonStyle(.plain) } @@ -479,10 +580,12 @@ struct TableView: View { @ViewBuilder private var rowsRegion: some View { if usesInnerScroll { - ScrollViewReader { proxy in - ScrollView { - rowsStack - } + GeometryReader { geo in + ScrollViewReader { proxy in + ScrollView { + rowsStack + .frame(minWidth: geo.size.width) + } .onAppear { guard !didInitialScroll else { return } didInitialScroll = true @@ -496,6 +599,7 @@ struct TableView: View { } } } + } } else { rowsStack } @@ -533,9 +637,17 @@ struct TableView: View { .buttonStyle(.plain) } - ForEach(0.. some View { HStack(spacing: rowControlsSpacing) { dragHandle(for: row, isHovered: isHovered) - .frame(width: rowHandleWidth, height: DatabaseZoomMetrics.size(18)) checkbox(for: row.id, isHovered: isHovered) .frame(width: checkboxWidth, height: DatabaseZoomMetrics.size(18)) @@ -585,7 +697,7 @@ struct TableView: View { } label: { Image(systemName: isSelected ? "checkmark.square.fill" : "square") .font(DatabaseZoomMetrics.font(13)) - .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .foregroundStyle(isSelected ? Color.dragIndicator : Color.secondary) } .buttonStyle(.plain) .accessibilityLabel(isSelected ? "Deselect row" : "Select row") @@ -598,7 +710,7 @@ struct TableView: View { let isVisible = isHovered || selectedRowIds.contains(row.id) || draggingRowId == row.id return RowDragHandleDots() - .foregroundStyle(Color.secondary.opacity(isVisible ? 0.8 : 0)) + .opacity(isVisible ? 1 : 0) .contentShape(Rectangle()) .allowsHitTesting(isVisible) .help(canReorderRows ? "Drag to reorder row" : "Drag to reorder (will clear sort)") @@ -640,7 +752,7 @@ struct TableView: View { if wrapCellText { TextField("New Page", text: text, axis: .vertical) .textFieldStyle(.plain) - .font(DatabaseZoomMetrics.font(17)) + .font(DatabaseZoomMetrics.font(14)) .foregroundStyle(.primary) .lineLimit(1...4) .multilineTextAlignment(.leading) @@ -649,7 +761,7 @@ struct TableView: View { } else { TextField("New Page", text: text) .textFieldStyle(.plain) - .font(DatabaseZoomMetrics.font(17)) + .font(DatabaseZoomMetrics.font(14)) .foregroundStyle(.primary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -659,7 +771,7 @@ struct TableView: View { private var insertionIndicator: some View { Rectangle() - .fill(Color.accentColor.opacity(0.9)) + .fill(Color.dragIndicator) .frame(height: 2) } @@ -764,7 +876,7 @@ private struct HoverRow: View { private struct RowDragHandleDots: View { var body: some View { GripDotsView() - .frame(width: DatabaseZoomMetrics.size(12), height: DatabaseZoomMetrics.size(20)) + .fixedSize() } } @@ -790,6 +902,7 @@ private struct TableRowFramePreferenceKey: PreferenceKey { private struct ColumnHeaderCell: View { let prop: PropertyDefinition + let height: CGFloat var onRename: ((String, String) -> Void)? var onChangeType: ((String, PropertyType) -> Void)? var onToggleColumn: ((String) -> Void)? @@ -809,19 +922,21 @@ private struct ColumnHeaderCell: View { .font(DatabaseZoomMetrics.font(11)) .foregroundStyle(.secondary) Text(prop.name) - .font(DatabaseZoomMetrics.font(15)) + .font(DatabaseZoomMetrics.font(13)) .fontWeight(.medium) .foregroundStyle(.secondary) .lineLimit(1) + .truncationMode(.tail) Spacer(minLength: 0) } .padding(.horizontal, DatabaseZoomMetrics.size(8)) - .padding(.vertical, DatabaseZoomMetrics.size(10)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(.vertical, DatabaseZoomMetrics.size(6)) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: height, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) - .frame(maxHeight: .infinity) + .frame(height: height) .background(isHovered || showPopover ? Color.gray.opacity(0.08) : Color.clear) .contentShape(Rectangle()) .onHover { inside in @@ -836,7 +951,7 @@ private struct ColumnHeaderCell: View { VStack(alignment: .leading, spacing: 8) { TextField("Property name", text: $editingName) .textFieldStyle(.roundedBorder) - .font(DatabaseZoomMetrics.font(15)) + .font(DatabaseZoomMetrics.font(13)) .focusEffectDisabled() .onSubmit { let trimmed = editingName.trimmingCharacters(in: .whitespaces) @@ -919,6 +1034,7 @@ private struct ColumnHeaderCell: View { private struct TitleColumnHeaderCell: View { let name: String let propertyId: String? + let height: CGFloat var onRename: ((String, String) -> Void)? @State private var isHovered = false @@ -935,19 +1051,21 @@ private struct TitleColumnHeaderCell: View { .font(DatabaseZoomMetrics.font(11)) .foregroundStyle(.secondary) Text(name) - .font(DatabaseZoomMetrics.font(15)) + .font(DatabaseZoomMetrics.font(13)) .fontWeight(.medium) .foregroundStyle(.secondary) .lineLimit(1) + .truncationMode(.tail) Spacer(minLength: 0) } .padding(.horizontal, DatabaseZoomMetrics.size(8)) - .padding(.vertical, DatabaseZoomMetrics.size(10)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(.vertical, DatabaseZoomMetrics.size(6)) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: height, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) - .frame(maxHeight: .infinity) + .frame(height: height) .background(isHovered || showPopover ? Color.gray.opacity(0.08) : Color.clear) .contentShape(Rectangle()) .onHover { isHovered = $0 } @@ -955,7 +1073,7 @@ private struct TitleColumnHeaderCell: View { VStack(alignment: .leading, spacing: 8) { TextField("Column name", text: $editingName) .textFieldStyle(.roundedBorder) - .font(DatabaseZoomMetrics.font(15)) + .font(DatabaseZoomMetrics.font(13)) .focusEffectDisabled() .onSubmit { let trimmed = editingName.trimmingCharacters(in: .whitespaces) @@ -995,3 +1113,9 @@ private extension View { } } } + +private struct NoFeedbackButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + } +} diff --git a/Sources/Bugbook/Views/Editor/BlockCellView.swift b/Sources/Bugbook/Views/Editor/BlockCellView.swift index fc94136..285ff19 100644 --- a/Sources/Bugbook/Views/Editor/BlockCellView.swift +++ b/Sources/Bugbook/Views/Editor/BlockCellView.swift @@ -36,9 +36,7 @@ struct BlockCellView: View { blockShell .overlay( RoundedRectangle(cornerRadius: 4) - .fill(Color.accentColor.opacity( - isBlockHighlighted ? 0.15 : 0 - )) + .fill(isBlockHighlighted ? Color.selectionHighlight : Color.clear) .allowsHitTesting(false) ) } @@ -72,7 +70,7 @@ struct BlockCellView: View { private var blockUsesOwnInteractions: Bool { switch block.type { - case .databaseEmbed, .image, .pageLink: + case .databaseEmbed, .image, .pageLink, .meeting: true default: false @@ -83,7 +81,7 @@ struct BlockCellView: View { isRowHovering || isHandleHovering || isHandleDragging - || document.blockMenuBlockId == block.id + || showBlockMenu } private var isBlockHighlighted: Bool { @@ -219,7 +217,7 @@ struct BlockCellView: View { @ViewBuilder private var blockContent: some View { switch block.type { - case .paragraph, .heading, .bulletListItem, .numberedListItem, .taskItem, .blockquote: + case .paragraph, .heading, .bulletListItem, .numberedListItem, .taskItem, .blockquote, .headingToggle: TextBlockView(document: document, block: block, onTyping: onTyping) case .codeBlock: @@ -233,7 +231,6 @@ struct BlockCellView: View { case .databaseEmbed: DatabaseEmbedBlockView( - block: block, dbPath: resolvedDatabasePath ?? block.databasePath, onOpenDatabaseTab: document.onOpenDatabaseTab, sidebarReferencePayload: databaseSidebarReferencePayload @@ -252,10 +249,21 @@ struct BlockCellView: View { case .column: ColumnBlockView(document: document, block: block, onTyping: onTyping) + + case .meeting: + MeetingBlockView( + document: document, + block: block + ) } } } +/// Attaches floating popovers only to the block that currently needs them. +/// Instead of adding 4 NSViewRepresentable anchors (one per popover type) to +/// every block, we check the document's active IDs and only attach popovers +/// when this block is the target. This reduces per-block overhead from 4 +/// hidden NSViews + 8 onChange handlers to zero for non-active blocks. private struct PopoverSyncModifier: ViewModifier { var document: BlockDocument let block: Block @@ -264,54 +272,19 @@ private struct PopoverSyncModifier: ViewModifier { @Binding var showPagePicker: Bool @Binding var showAiPrompt: Bool - func body(content: Content) -> some View { - popoverLayer(content) - .modifier(PopoverChangeTracker( - document: document, - block: block, - showSlashMenu: $showSlashMenu, - showBlockMenu: $showBlockMenu, - showPagePicker: $showPagePicker, - showAiPrompt: $showAiPrompt - )) - } - - @ViewBuilder - private func popoverLayer(_ content: Content) -> some View { - content - .floatingPopover(isPresented: $showSlashMenu, arrowEdge: .bottom) { - SlashCommandMenu(document: document) - } - .floatingPopover(isPresented: $showBlockMenu, arrowEdge: .leading, onDelete: { - document.dismissBlockMenu() - document.deleteBlock(id: block.id) - }) { - BlockMenuView(document: document, blockId: block.id) - } - .floatingPopover(isPresented: $showPagePicker, arrowEdge: .bottom) { - PagePickerView(document: document) - } - .floatingPopover(isPresented: $showAiPrompt, arrowEdge: .bottom) { - AiPromptView(document: document) - } - } -} - -private struct PopoverChangeTracker: ViewModifier { - var document: BlockDocument - let block: Block - @Binding var showSlashMenu: Bool - @Binding var showBlockMenu: Bool - @Binding var showPagePicker: Bool - @Binding var showAiPrompt: Bool + /// Whether this block is the target of any popover right now. + private var isSlashTarget: Bool { document.slashMenuBlockId == block.id } + private var isBlockMenuTarget: Bool { document.blockMenuBlockId == block.id } + private var isPagePickerTarget: Bool { document.showPagePicker && document.pagePickerBlockId == block.id } + private var isAiPromptTarget: Bool { document.aiPromptBlockId == block.id } func body(content: Content) -> some View { - content + popoverLayer(content) .onAppear { - showSlashMenu = (document.slashMenuBlockId == block.id) - showBlockMenu = (document.blockMenuBlockId == block.id) - showPagePicker = document.showPagePicker && document.pagePickerBlockId == block.id - showAiPrompt = (document.aiPromptBlockId == block.id) + showSlashMenu = isSlashTarget + showBlockMenu = isBlockMenuTarget + showPagePicker = isPagePickerTarget + showAiPrompt = isAiPromptTarget } .onChange(of: document.slashMenuBlockId) { _, newVal in let shouldShow = (newVal == block.id) @@ -350,7 +323,6 @@ private struct PopoverChangeTracker: ViewModifier { } .onChange(of: showAiPrompt) { _, show in if !show && document.aiPromptBlockId == block.id { - // Don't dismiss while generating — keep the popover alive if document.isAiGenerating { showAiPrompt = true } else { @@ -359,6 +331,45 @@ private struct PopoverChangeTracker: ViewModifier { } } } + + @ViewBuilder + private func popoverLayer(_ content: Content) -> some View { + // Only attach the floatingPopover modifier (which creates an NSViewRepresentable + // anchor) when this block is the active target for that popover type. + // Non-target blocks get zero hidden NSViews from popovers. + content + .background { + if isSlashTarget { + Color.clear.floatingPopover(isPresented: $showSlashMenu, arrowEdge: .bottom) { + SlashCommandMenu(document: document) + } + } + } + .background { + if isBlockMenuTarget { + Color.clear.floatingPopover(isPresented: $showBlockMenu, arrowEdge: .leading, onDelete: { + document.dismissBlockMenu() + document.deleteBlock(id: block.id) + }) { + BlockMenuView(document: document, blockId: block.id) + } + } + } + .background { + if isPagePickerTarget { + Color.clear.floatingPopover(isPresented: $showPagePicker, arrowEdge: .bottom) { + PagePickerView(document: document) + } + } + } + .background { + if isAiPromptTarget { + Color.clear.floatingPopover(isPresented: $showAiPrompt, arrowEdge: .bottom) { + AiPromptView(document: document) + } + } + } + } } /// Inline AI prompt popover for generating content at the cursor position. @@ -444,7 +455,7 @@ private struct AiPromptView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) .padding(.vertical, 10) - .background(Brand.subtle) + .background(Color.fallbackSurfaceSubtle) } private var aiPromptHints: some View { diff --git a/Sources/Bugbook/Views/Editor/BlockEditorView.swift b/Sources/Bugbook/Views/Editor/BlockEditorView.swift index 69e5521..2c49ddc 100644 --- a/Sources/Bugbook/Views/Editor/BlockEditorView.swift +++ b/Sources/Bugbook/Views/Editor/BlockEditorView.swift @@ -21,6 +21,8 @@ struct BlockEditorView: View { var document: BlockDocument var onTextChange: (() -> Void)? var onTyping: (() -> Void)? + /// Called when a page file path is dropped from the sidebar. Parameters: (filePath, insertIndex). + var onPagePathDrop: ((String, Int) -> Void)? var contentColumnMaxWidth: CGFloat? = nil var horizontalPadding: CGFloat = 48 @State private var activeDropIndex: Int? @@ -32,6 +34,8 @@ struct BlockEditorView: View { @State private var marqueeDragState: MarqueeDragState? @State private var blockMoveDragState: BlockMoveDragState? @State private var autoScrollTimer: Timer? + @State private var autoScrollSpeed: CGFloat = 0 + @FocusState private var isEditorFocused: Bool var body: some View { // Skip the title block (first heading-1) — it's rendered separately above @@ -58,10 +62,38 @@ struct BlockEditorView: View { EditorFrameReporter(frameInWindow: $editorFrameInWindow, window: $editorWindow) ) .simultaneousGesture(marqueeSelectionGesture) + .onTapGesture { + if !document.selectedBlockIds.isEmpty { + document.clearBlockSelection() + document.clearMultiBlockTextSelection() + } + } .editorTextCursor() + .focusable() + .focusEffectDisabled() + .focused($isEditorFocused) + .onKeyPress(.delete) { + guard !document.selectedBlockIds.isEmpty, + document.focusedBlockId == nil else { return .ignored } + document.deleteSelectedBlocks() + return .handled + } + .onKeyPress(.init(Character(UnicodeScalar(127)))) { // backspace + guard !document.selectedBlockIds.isEmpty, + document.focusedBlockId == nil else { return .ignored } + document.deleteSelectedBlocks() + return .handled + } .dropDestination(for: URL.self) { urls, _ in handleImageFileDrop(urls) } isTargeted: { _ in } + .dropDestination(for: String.self) { items, _ in + guard let payload = items.first else { return false } + // Only handle file paths from sidebar drag (not block UUIDs) + guard payload.hasPrefix("/") && payload.hasSuffix(".md") else { return false } + handlePagePathDrop(payload, at: insertionIndexAtFocus) + return true + } isTargeted: { _ in } .onDisappear { stopAutoScroll() } .onChange(of: document.contentVersion) { _, _ in onTextChange?() @@ -70,7 +102,16 @@ struct BlockEditorView: View { @ViewBuilder private func editorSurface(startIndex: Int) -> some View { - editorContent(startIndex: startIndex) + if let maxWidth = contentColumnMaxWidth { + HStack(spacing: 0) { + Spacer(minLength: 0) + editorContent(startIndex: startIndex) + .frame(maxWidth: maxWidth) + Spacer(minLength: 0) + } + } else { + editorContent(startIndex: startIndex) + } } private func editorContent(startIndex: Int) -> some View { @@ -82,6 +123,8 @@ struct BlockEditorView: View { activeDropIndex = targeted ? startIndex : (activeDropIndex == startIndex ? nil : activeDropIndex) } onImageDrop: { urls in handleImageDrop(urls, at: startIndex) + } onPagePathDrop: { path in + handlePagePathDrop(path, at: startIndex) } ForEach(Array(document.blocks.enumerated()).dropFirst(startIndex), id: \.element.id) { index, block in @@ -93,7 +136,7 @@ struct BlockEditorView: View { // After an image block use a slimmer drop zone since ImageBlockView // already provides a generous 44pt tap region internally. let dropZoneAfterImage = block.type == .image - let dropZoneHeight: CGFloat = dropZoneAfterImage ? 4 : (useTallDropZone ? 24 : 6) + let dropZoneHeight: CGFloat = dropZoneAfterImage ? 4 : (useTallDropZone ? 24 : 12) BlockCellView( document: document, @@ -131,20 +174,23 @@ struct BlockEditorView: View { activeDropIndex = targeted ? idx : (activeDropIndex == idx ? nil : activeDropIndex) } onImageDrop: { urls in handleImageDrop(urls, at: index + 1) + } onPagePathDrop: { path in + handlePagePathDrop(path, at: index + 1) } .overlay { Button { if document.consumePendingEditorTapAfterBlockSelection() { return } + document.clearBlockSelection() document.clearMultiBlockTextSelection() - // After an image block, always focus or insert an empty paragraph - if block.type == .image { + // After a non-editable block, always focus or insert an empty paragraph + if block.type == .image || block.type == .pageLink || block.type == .databaseEmbed || block.type == .horizontalRule { document.focusOrInsertParagraphAfter(blockId: block.id) } else if index + 1 < document.blocks.count { let next = document.blocks[index + 1] - // If next block is non-editable (image, etc.), insert a paragraph between - if next.type == .image || next.type == .databaseEmbed { + // If next block is non-editable, insert a paragraph between + if next.type == .image || next.type == .databaseEmbed || next.type == .pageLink || next.type == .horizontalRule { document.focusOrInsertParagraphAfter(blockId: block.id) } else { document.focusedBlockId = next.id @@ -162,29 +208,35 @@ struct BlockEditorView: View { } } - // Click target after last block — always visible, creates new block + // Click target after last block — focuses trailing empty paragraph Button { if document.consumePendingEditorTapAfterBlockSelection() { return } + document.clearBlockSelection() document.clearMultiBlockTextSelection() - if let lastBlock = document.blocks.last, - lastBlock.text.isEmpty, - lastBlock.type != .databaseEmbed { + document.ensureTrailingParagraph() + if let lastBlock = document.blocks.last { document.focusedBlockId = lastBlock.id document.cursorPosition = 0 - } else { - document.appendEmptyBlock() } } label: { - Rectangle() - .fill(Color.white.opacity(0.001)) + Color.clear .frame(maxWidth: .infinity) .frame(minHeight: 300) .contentShape(Rectangle()) } .buttonStyle(.plain) .editorTextCursor() + .dropDestination(for: String.self) { items, _ in + guard let payload = items.first else { return false } + let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/"), trimmed.hasSuffix(".md") else { return false } + let filename = (trimmed as NSString).lastPathComponent + let pageName = String(filename.dropLast(3)) + document.insertPageLinkBlock(at: document.blocks.count, name: pageName) + return true + } isTargeted: { _ in } } .padding(.horizontal, horizontalPadding) .padding(.vertical, 20) @@ -224,14 +276,26 @@ struct BlockEditorView: View { return true } - /// Fallback for drops that land on blocks (not between them). - private func handleImageFileDrop(_ urls: [URL]) -> Bool { - var insertIndex = document.blocks.count + @discardableResult + private func handlePagePathDrop(_ path: String, at index: Int) -> Bool { + guard onPagePathDrop != nil else { return false } + onPagePathDrop?(path, index) + activeDropIndex = nil + return true + } + + /// Index after the focused block, or end of document. + private var insertionIndexAtFocus: Int { if let focusedId = document.focusedBlockId, let idx = document.blocks.firstIndex(where: { $0.id == focusedId }) { - insertIndex = idx + 1 + return idx + 1 } - return handleImageDrop(urls, at: insertIndex) + return document.blocks.count + } + + /// Fallback for drops that land on blocks (not between them). + private func handleImageFileDrop(_ urls: [URL]) -> Bool { + handleImageDrop(urls, at: insertionIndexAtFocus) } private var marqueeSelectionGesture: some Gesture { @@ -305,6 +369,9 @@ struct BlockEditorView: View { if marqueeDragState?.isActive == true { document.endMarqueeBlockSelection() + if !document.selectedBlockIds.isEmpty { + isEditorFocused = true + } } } @@ -328,14 +395,15 @@ struct BlockEditorView: View { } private func startAutoScroll(speed: CGFloat) { - // Only start if not already running + autoScrollSpeed = speed + // If timer is already running, speed update above is sufficient guard autoScrollTimer == nil else { return } autoScrollTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [self] _ in guard let sv = marqueeDragState?.scrollView, let docView = sv.documentView else { return } let clipView = sv.contentView var origin = clipView.bounds.origin - origin.y += speed + origin.y += autoScrollSpeed origin.y = max(0, min(origin.y, docView.frame.height - clipView.bounds.height)) clipView.setBoundsOrigin(origin) sv.reflectScrolledClipView(clipView) @@ -551,10 +619,10 @@ private struct MarqueeSelectionOverlay: View { var body: some View { Rectangle() - .fill(Color.accentColor.opacity(0.14)) + .fill(Color.selectionHighlight.opacity(0.5)) .overlay { Rectangle() - .stroke(Color.accentColor.opacity(0.9), lineWidth: 1) + .stroke(Color(hex: "B4D7FF"), lineWidth: 1) } .frame(width: max(rect.width, 1), height: max(rect.height, 1)) .offset(x: rect.minX, y: rect.minY) @@ -651,7 +719,7 @@ private extension Block { return "Image" case .databaseEmbed: return "Database" - case .horizontalRule: +case .horizontalRule: return "Divider" case .column: return "Column" @@ -681,7 +749,7 @@ private extension Block { return "tablecells" case .toggle: return "chevron.right" - case .horizontalRule: +case .horizontalRule: return "minus" case .column: return "rectangle.split.2x1" @@ -772,15 +840,17 @@ final class EditorFrameReporterView: NSView { } } -/// Thin drop zone between blocks that shows a blue line when a drag hovers over it. +/// Thin drop zone between blocks that shows a line when a drag hovers over it. /// Height is constant to prevent layout shifts that cause flickering. -/// Accepts both block UUID drops (reorder) and image URL drops (insert image). +/// Accepts block UUID drops (reorder), image URL drops (insert image), +/// and sidebar page drops (file path strings that create page links). struct DropZoneView: View { let isActive: Bool - var height: CGFloat = 4 + var height: CGFloat = 12 let onDrop: ([UUID]) -> Void let onTargetChanged: (Bool) -> Void var onImageDrop: (([URL]) -> Bool)? + var onPagePathDrop: ((String) -> Bool)? @State private var imageDropTargeted = false @@ -791,7 +861,7 @@ struct DropZoneView: View { .frame(maxWidth: .infinity) .overlay { Rectangle() - .fill(Color.accentColor) + .fill(Color.dragIndicator) .frame(height: 2) .opacity(isActive || imageDropTargeted ? 1 : 0) } @@ -799,9 +869,15 @@ struct DropZoneView: View { .dropDestination(for: String.self) { items, _ in guard let payload = items.first else { return false } let droppedIds = BlockDocument.draggedBlockIds(from: payload) - guard !droppedIds.isEmpty else { return false } - onDrop(droppedIds) - return true + if !droppedIds.isEmpty { + onDrop(droppedIds) + return true + } + // Not block UUIDs — check if it's a page file path from the sidebar + if payload.hasSuffix(".md"), FileManager.default.fileExists(atPath: payload) { + return onPagePathDrop?(payload) ?? false + } + return false } isTargeted: { targeted in onTargetChanged(targeted) } @@ -821,7 +897,7 @@ struct DropZoneView: View { } } -/// Right-edge drop zone that shows a vertical blue line for column creation. +/// Right-edge drop zone that shows a vertical line for column creation. struct ColumnDropZoneView: View { let isActive: Bool let onDrop: ([UUID]) -> Void @@ -834,7 +910,7 @@ struct ColumnDropZoneView: View { .frame(maxHeight: .infinity) .overlay(alignment: .trailing) { Rectangle() - .fill(Color.accentColor) + .fill(Color.dragIndicator) .frame(width: 2) .opacity(isActive ? 1 : 0) } @@ -850,3 +926,4 @@ struct ColumnDropZoneView: View { } } } + diff --git a/Sources/Bugbook/Views/Editor/BlockTextView.swift b/Sources/Bugbook/Views/Editor/BlockTextView.swift index ddb8c44..4b14779 100644 --- a/Sources/Bugbook/Views/Editor/BlockTextView.swift +++ b/Sources/Bugbook/Views/Editor/BlockTextView.swift @@ -6,7 +6,7 @@ enum EditorTypography { static let zoomScaleKey = "editorZoomScale" static let minZoomScale: CGFloat = 0.8 static let maxZoomScale: CGFloat = 2.4 - static let defaultZoomScale: CGFloat = 1.1 + static let defaultZoomScale: CGFloat = 1.0 static var zoomScale: CGFloat { let stored = UserDefaults.standard.double(forKey: zoomScaleKey) @@ -25,7 +25,7 @@ enum EditorTypography { enum EditorSelectionStyle { static var backgroundColor: NSColor { - NSColor.controlAccentColor.withAlphaComponent(0.28) + NSColor(red: 0.706, green: 0.843, blue: 1.0, alpha: 0.45) // #B4D7FF at 45% } static var foregroundColor: NSColor { @@ -43,6 +43,7 @@ struct BlockTextView: NSViewRepresentable { var isMultiline: Bool = false var font: NSFont = .systemFont(ofSize: EditorTypography.bodyFontSize) var textColor: NSColor = .labelColor + var strikethrough: Bool = false var placeholder: String? = nil var onTextChange: (() -> Void)? = nil @Binding var textHeight: CGFloat @@ -154,6 +155,14 @@ struct BlockTextView: NSViewRepresentable { guard let coordinator = coordinator else { return false } return coordinator.handleImagePaste() } + textView.onPageLinkDrop = { [weak coordinator] pageName in + guard let coordinator = coordinator else { return } + let doc = coordinator.parent.document + let blockId = coordinator.parent.blockId + if let idx = doc.blocks.firstIndex(where: { $0.id == blockId }) { + doc.insertPageLinkBlock(at: idx + 1, name: pageName) + } + } textView.copySelectionAction = { [weak coordinator] in coordinator?.handleCopySelection() ?? false } @@ -193,13 +202,18 @@ struct BlockTextView: NSViewRepresentable { .foregroundColor: EditorSelectionStyle.foregroundColor ] - // Re-apply foreground color to existing text when textColor changes + // Re-apply foreground color and strikethrough to existing text when textColor changes if textColor != context.coordinator.lastTextColor { context.coordinator.lastTextColor = textColor let fullRange = NSRange(location: 0, length: textView.textStorage?.length ?? 0) if fullRange.length > 0 { context.coordinator.withProgrammaticViewUpdate { textView.textStorage?.addAttribute(.foregroundColor, value: textColor, range: fullRange) + textView.textStorage?.addAttribute( + .strikethroughStyle, + value: strikethrough ? NSUnderlineStyle.single.rawValue : 0, + range: fullRange + ) } } } @@ -716,6 +730,10 @@ struct BlockTextView: NSViewRepresentable { } parent.document.slashMenuFilter = String(textView.string.dropFirst(1)) parent.document.slashMenuSelectedIndex = 0 + // Dismiss if no commands match — let the user keep typing + if parent.document.filteredSlashCommands.isEmpty { + parent.document.dismissSlashMenu() + } } else if parent.document.slashMenuBlockId == parent.blockId { parent.document.dismissSlashMenu() } @@ -1203,6 +1221,7 @@ class BlockNSTextView: NSTextView { var onMultiBlockSelectionEnd: (() -> Void)? var onShiftClick: (() -> Void)? var onPasteImage: (() -> Bool)? + var onPageLinkDrop: ((String) -> Void)? var copySelectionAction: (() -> Bool)? var cutSelectionAction: (() -> Bool)? var onFrameWidthChanged: (() -> Void)? @@ -1282,13 +1301,28 @@ class BlockNSTextView: NSTextView { } } - // Reject all external drops to prevent UUID string insertion from drag-and-drop + // Accept page path drops (sidebar page drags) but reject everything else + // to prevent UUID string insertion from block reorder drag-and-drop. override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - return false + guard let pageName = pageNameFromDrag(sender) else { return false } + onPageLinkDrop?(pageName) + return true } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - return [] + return pageNameFromDrag(sender) != nil ? .copy : [] + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + return pageNameFromDrag(sender) != nil ? .copy : [] + } + + private func pageNameFromDrag(_ sender: NSDraggingInfo) -> String? { + guard let str = sender.draggingPasteboard.string(forType: .string) else { return nil } + let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/"), trimmed.hasSuffix(".md") else { return nil } + let filename = (trimmed as NSString).lastPathComponent + return String(filename.dropLast(3)) } override func paste(_ sender: Any?) { diff --git a/Sources/Bugbook/Views/Editor/BlockViews.swift b/Sources/Bugbook/Views/Editor/BlockViews.swift index 32434d7..c8eb7fa 100644 --- a/Sources/Bugbook/Views/Editor/BlockViews.swift +++ b/Sources/Bugbook/Views/Editor/BlockViews.swift @@ -1,4 +1,44 @@ import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Shared block-deletion keyboard modifier + +/// Makes a non-text block focusable and deletable via Delete/Backspace when selected. +private struct BlockDeletableModifier: ViewModifier { + var document: BlockDocument + let blockId: UUID + @FocusState private var isKeyboardFocused: Bool + + private var isSelected: Bool { + document.selectedBlockIds.contains(blockId) + } + + private static let deleteKeys: Set = [ + .delete, + .init(Character(UnicodeScalar(127))), // backspace + ] + + func body(content: Content) -> some View { + content + .focusable() + .focusEffectDisabled() + .focused($isKeyboardFocused) + .onKeyPress(keys: Self.deleteKeys) { _ in + guard isSelected else { return .ignored } + document.deleteSelectedBlocks() + return .handled + } + .onChange(of: isSelected) { _, selected in + isKeyboardFocused = selected + } + } +} + +extension View { + func blockDeletable(document: BlockDocument, blockId: UUID) -> some View { + modifier(BlockDeletableModifier(document: document, blockId: blockId)) + } +} /// Horizontal rule block. struct HorizontalRuleView: View { @@ -40,16 +80,10 @@ struct ImageBlockView: View { @State private var isResizing = false @State private var resizeStartWidth: CGFloat? @State private var transientWidth: CGFloat? - @FocusState private var isKeyboardFocused: Bool - private var isLocalImage: Bool { block.imageSource.hasPrefix("/") || block.imageSource.hasPrefix("file://") } - private var isSelected: Bool { - document.selectedBlockIds.contains(block.id) - } - private var currentWidth: CGFloat? { block.imageWidth.map { CGFloat($0) } } @@ -77,7 +111,6 @@ struct ImageBlockView: View { document.clearBlockSelection() document.selectedBlockIds = [block.id] document.focusedBlockId = nil - isKeyboardFocused = true } .draggable(document.dragPayload(for: block.id)) { imageDragPreview @@ -107,18 +140,7 @@ struct ImageBlockView: View { .buttonStyle(.plain) .appCursor(.iBeam) } - .focusable() - .focused($isKeyboardFocused) - .onKeyPress(.delete) { - guard isSelected else { return .ignored } - document.deleteSelectedBlocks() - return .handled - } - .onKeyPress(.init(Character(UnicodeScalar(127)))) { // backspace - guard isSelected else { return .ignored } - document.deleteSelectedBlocks() - return .handled - } + .blockDeletable(document: document, blockId: block.id) .task(id: block.imageSource) { guard isLocalImage else { return } let source = block.imageSource @@ -127,11 +149,6 @@ struct ImageBlockView: View { : URL(fileURLWithPath: source) cachedImage = NSImage(contentsOf: fileURL) } - .onChange(of: isSelected) { _, selected in - if !selected { - isKeyboardFocused = false - } - } } private var showsResizeBars: Bool { @@ -252,15 +269,18 @@ struct ImageBlockView: View { /// Database embed block — wraps existing DatabaseInlineEmbedView. struct DatabaseEmbedBlockView: View { - let block: Block let dbPath: String var onOpenDatabaseTab: ((String) -> Void)? var sidebarReferencePayload: SidebarReferenceDragPayload? + @State private var isHovered = false var body: some View { if let sidebarReferencePayload { databaseEmbedView - .draggable(sidebarReferencePayload) + .onDrag { + let data = (try? JSONEncoder().encode(sidebarReferencePayload)) ?? Data() + return NSItemProvider(item: data as NSData, typeIdentifier: UTType.sidebarReference.identifier) + } } else { databaseEmbedView } @@ -273,4 +293,31 @@ struct DatabaseEmbedBlockView: View { ) .padding(.vertical, 4) } + + private func sidebarDragHandle(payload: SidebarReferenceDragPayload) -> some View { + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 22, height: 22) + .background(.ultraThinMaterial) + .clipShape(.rect(cornerRadius: 4)) + .contentShape(Rectangle()) + .draggable(payload) { + HStack(spacing: 4) { + Image(systemName: "tablecells") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(dbPath.components(separatedBy: "/").last ?? "Database") + .font(.system(size: EditorTypography.bodyFontSize)) + .foregroundStyle(.primary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial) + .clipShape(.rect(cornerRadius: 6)) + } + .appCursor(.openHand) + .padding(6) + .help("Drag to sidebar to pin") + } } diff --git a/Sources/Bugbook/Views/Editor/ColumnBlockView.swift b/Sources/Bugbook/Views/Editor/ColumnBlockView.swift index d1b14c8..4926af4 100644 --- a/Sources/Bugbook/Views/Editor/ColumnBlockView.swift +++ b/Sources/Bugbook/Views/Editor/ColumnBlockView.swift @@ -66,7 +66,7 @@ struct ColumnBlockView: View { } } -/// Thin drop zone within a column that shows a horizontal blue line. +/// Thin drop zone within a column that shows a horizontal line. struct InColumnDropZone: View { let isActive: Bool let onDrop: (UUID) -> Void @@ -75,11 +75,11 @@ struct InColumnDropZone: View { var body: some View { Rectangle() .fill(Color.clear) - .frame(height: 8) + .frame(height: 12) .frame(maxWidth: .infinity) .overlay { Rectangle() - .fill(Color.accentColor) + .fill(Color.dragIndicator) .frame(height: 2) .opacity(isActive ? 1 : 0) } diff --git a/Sources/Bugbook/Views/Editor/FlashcardReviewView.swift b/Sources/Bugbook/Views/Editor/FlashcardReviewView.swift deleted file mode 100644 index de4471c..0000000 --- a/Sources/Bugbook/Views/Editor/FlashcardReviewView.swift +++ /dev/null @@ -1,268 +0,0 @@ -import SwiftUI - -struct FlashcardItem: Identifiable { - let id = UUID() - let front: String - let back: String - let pageName: String -} - -struct FlashcardReviewView: View { - let cards: [FlashcardItem] - let onDismiss: () -> Void - - @State private var currentIndex: Int = 0 - @State private var revealed: Bool = false - @State private var correctCount: Int = 0 - @State private var reviewedCount: Int = 0 - @FocusState private var isFocused: Bool - - var body: some View { - if cards.isEmpty { - emptyState - } else if reviewedCount >= cards.count { - summaryState - } else { - cardView - } - } - - private var card: FlashcardItem { - cards[currentIndex] - } - - private var emptyState: some View { - VStack(spacing: 16) { - Text("No flashcards found") - .font(.title2) - .foregroundStyle(.secondary) - Text("Write cards inline with the == separator") - .font(.body) - .foregroundStyle(.tertiary) - Text("What is hello in Russian? == Привет") - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.tertiary) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color.primary.opacity(0.05)) - .cornerRadius(6) - Button("Close") { onDismiss() } - .keyboardShortcut(.escape, modifiers: []) - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .padding(.top, 8) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.ultraThinMaterial) - } - - private var summaryState: some View { - VStack(spacing: 20) { - Text("Review Complete") - .font(.title) - .fontWeight(.semibold) - Text("\(correctCount) / \(cards.count) correct") - .font(.title2) - .foregroundStyle(.secondary) - Button("Done") { onDismiss() } - .keyboardShortcut(.escape, modifiers: []) - .keyboardShortcut(.return, modifiers: []) - .buttonStyle(.plain) - .font(.body) - .foregroundStyle(.secondary) - .padding(.top, 8) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.ultraThinMaterial) - } - - private var cardView: some View { - VStack(spacing: 0) { - // Header - HStack { - Text(card.pageName) - .font(.caption) - .foregroundStyle(.tertiary) - Spacer() - Text("\(currentIndex + 1) / \(cards.count)") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - } - .padding(.horizontal, 40) - .padding(.top, 32) - - Spacer() - - // Card - VStack(spacing: 24) { - Text(card.front) - .font(.title2) - .fontWeight(.medium) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - - if revealed { - Divider() - .frame(maxWidth: 200) - .padding(.vertical, 4) - - Text(card.back) - .font(.title3) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .padding(40) - .frame(maxWidth: 500) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(nsColor: .controlBackgroundColor)) - .shadow(color: .black.opacity(0.15), radius: 20, y: 8) - ) - .overlay { - RoundedRectangle(cornerRadius: 16) - .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) - } - - Spacer() - - // Controls - if !revealed { - Text("Press Space to reveal") - .font(.caption) - .foregroundStyle(.tertiary) - .padding(.bottom, 32) - } else { - HStack(spacing: 32) { - Button { - advance(correct: false) - } label: { - VStack(spacing: 4) { - Image(systemName: "xmark") - .font(.title3) - Text("Missed") - .font(.caption) - } - .foregroundStyle(.red.opacity(0.7)) - .frame(width: 80, height: 50) - } - .buttonStyle(.plain) - - Button { - advance(correct: true) - } label: { - VStack(spacing: 4) { - Image(systemName: "checkmark") - .font(.title3) - Text("Got it") - .font(.caption) - } - .foregroundStyle(.green.opacity(0.8)) - .frame(width: 80, height: 50) - } - .buttonStyle(.plain) - } - .padding(.bottom, 32) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.ultraThinMaterial) - .focusable() - .focused($isFocused) - .onAppear { isFocused = true } - .onKeyPress(.space) { - if !revealed { - withAnimation(.easeOut(duration: 0.15)) { revealed = true } - return .handled - } - return .ignored - } - .onKeyPress(.rightArrow) { - if revealed { advance(correct: true); return .handled } - return .ignored - } - .onKeyPress(.leftArrow) { - if revealed { advance(correct: false); return .handled } - return .ignored - } - .onKeyPress(.return) { - if revealed { advance(correct: true); return .handled } - return .ignored - } - .onKeyPress(.escape) { - onDismiss() - return .handled - } - } - - private func advance(correct: Bool) { - if correct { correctCount += 1 } - reviewedCount += 1 - if currentIndex + 1 < cards.count { - withAnimation(.easeInOut(duration: 0.15)) { - revealed = false - currentIndex += 1 - } - } - } -} - -// MARK: - Flashcard Scanner - -@MainActor -enum FlashcardScanner { - - /// Extract flashcard items from a BlockDocument. - static func scan(document: BlockDocument, pageName: String) -> [FlashcardItem] { - var items: [FlashcardItem] = [] - for block in document.blocks { - items.append(contentsOf: scanBlock(block, pageName: pageName)) - } - return items - } - - private static func scanBlock(_ block: Block, pageName: String) -> [FlashcardItem] { - var items: [FlashcardItem] = [] - // Skip code blocks - if block.type == .codeBlock { return items } - if let item = parseFlashcardLine(block.text, pageName: pageName) { - items.append(item) - } - for child in block.children { - items.append(contentsOf: scanBlock(child, pageName: pageName)) - } - return items - } - - private static func parseFlashcardLine(_ text: String, pageName: String) -> FlashcardItem? { - // Match " == " with spaces to avoid code equality operators - guard let range = text.range(of: " == ") else { return nil } - let front = String(text[text.startIndex..) - let cleanFront = stripMarkdownPrefix(front) - guard !cleanFront.isEmpty, !back.isEmpty else { return nil } - return FlashcardItem(front: cleanFront, back: back, pageName: pageName) - } - - private static func stripMarkdownPrefix(_ text: String) -> String { - var s = text - // Checkbox: - [ ] or - [x] - if let match = s.range(of: #"^- \[[ xX]\] "#, options: .regularExpression) { - s = String(s[match.upperBound...]) - } - // Bullet: - or * - else if s.hasPrefix("- ") { s = String(s.dropFirst(2)) } - else if s.hasPrefix("* ") { s = String(s.dropFirst(2)) } - // Numbered: 1. 2. etc - else if let match = s.range(of: #"^\d+\. "#, options: .regularExpression) { - s = String(s[match.upperBound...]) - } - // Blockquote - else if s.hasPrefix("> ") { s = String(s.dropFirst(2)) } - return s.trimmingCharacters(in: .whitespaces) - } -} diff --git a/Sources/Bugbook/Views/Editor/HeadingToggleBlockView.swift b/Sources/Bugbook/Views/Editor/HeadingToggleBlockView.swift new file mode 100644 index 0000000..1f66e51 --- /dev/null +++ b/Sources/Bugbook/Views/Editor/HeadingToggleBlockView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// Collapsible heading toggle block — heading-sized title with a chevron and nested child blocks. +struct HeadingToggleBlockView: View { + var document: BlockDocument + let block: Block + var onTyping: (() -> Void)? = nil + @State private var textHeight: CGFloat = 30 + + private var headingFont: NSFont { + switch block.headingLevel { + case 1: return .systemFont(ofSize: EditorTypography.scaled(30), weight: .bold) + case 2: return .systemFont(ofSize: EditorTypography.scaled(24), weight: .semibold) + case 3: return .systemFont(ofSize: EditorTypography.scaled(20), weight: .semibold) + default: return .systemFont(ofSize: EditorTypography.scaled(20), weight: .semibold) + } + } + + private var chevronSize: CGFloat { + switch block.headingLevel { + case 1: return 14 + case 2: return 13 + default: return 12 + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header: chevron + heading title + HStack(alignment: .top, spacing: 4) { + Button("Toggle", systemImage: "chevron.right") { + withAnimation(.easeInOut(duration: 0.12)) { + if let idx = document.index(for: block.id) { + document.blocks[idx].isExpanded.toggle() + } + } + } + .labelStyle(.iconOnly) + .font(.system(size: chevronSize, weight: .medium)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(block.isExpanded ? 90 : 0)) + .frame(width: 20, height: textHeight > 0 ? min(textHeight, 36) : 30) + .contentShape(Rectangle()) + .buttonStyle(.plain) + + BlockTextView( + document: document, + blockId: block.id, + selectionVersion: document.selectionVersion, + font: headingFont, + textColor: .labelColor, + placeholder: "Heading", + onTextChange: onTyping, + textHeight: $textHeight + ) + .frame(height: textHeight) + } + + // Children (when expanded) + if block.isExpanded { + VStack(alignment: .leading, spacing: 0) { + if block.children.isEmpty { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 24) + .contentShape(Rectangle()) + .overlay { + Button { addChild() } label: { Color.clear } + .buttonStyle(.plain) + } + } else { + ForEach(block.children) { child in + BlockCellView(document: document, block: child, onTyping: onTyping) + .padding(.vertical, 1) + } + } + } + .padding(.leading, 0) + } + } + } + + private func addChild() { + guard let idx = document.index(for: block.id) else { return } + let newChild = Block(type: .paragraph) + document.blocks[idx].children.append(newChild) + document.focusedBlockId = newChild.id + document.cursorPosition = 0 + } +} diff --git a/Sources/Bugbook/Views/Editor/MeetingBlockView.swift b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift new file mode 100644 index 0000000..c6f26bc --- /dev/null +++ b/Sources/Bugbook/Views/Editor/MeetingBlockView.swift @@ -0,0 +1,920 @@ +import SwiftUI +import AppKit + +/// Meeting block view with three states: ready (before recording), recording (during), +/// and complete (after). Uses the same card shell across all states. Preserves dev's +/// AI summary generation, transcript sheet, and structured output parsing. +struct MeetingBlockView: View { + var document: BlockDocument + let block: Block + + @State private var title: String + @State private var notes: String + @State private var isTranscriptOpen = false + @State private var isSummaryExpanded = false + @State private var activeTab: MeetingTab = .summary + @State private var isHovered = false + + @State private var processingStatus = "" + + enum MeetingTab { + case summary + case notes + } + + init(document: BlockDocument, block: Block) { + self.document = document + self.block = block + _title = State(initialValue: block.meetingTitle) + _notes = State(initialValue: block.meetingNotes) + } + + var body: some View { + VStack(spacing: 0) { + switch block.meetingState { + case .ready: + beforeStateView + case .recording: + duringStateView + case .processing: + processingStateView + case .complete: + afterStateView + } + } + .background { + RoundedRectangle(cornerRadius: Radius.lg) + .fill(Color.fallbackCardBg) + } + .overlay { + RoundedRectangle(cornerRadius: Radius.lg) + .strokeBorder(Color.fallbackBorderColor, lineWidth: 1) + } + .onHover { isHovered = $0 } + .padding(.vertical, 4) + } + + // MARK: - Before State (Ready) + + private var beforeStateView: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + TextField("New Meeting", text: $title) + .textFieldStyle(.plain) + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + .onChange(of: title) { _, newVal in + document.updateMeetingTitle(blockId: block.id, title: newVal) + } + + Spacer() + + Button(action: startRecording) { + HStack(spacing: 5) { + Image(systemName: "waveform") + .font(.system(size: 10)) + Text("Start Transcribing") + .font(.system(size: Typography.bodySmall, weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accentColor.opacity(Opacity.medium)) + .foregroundStyle(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + + MeetingNotesEditor(text: $notes) + .frame(minHeight: 80) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .onChange(of: notes) { _, newVal in + document.updateMeetingNotes(blockId: block.id, notes: newVal) + } + } + } + + // MARK: - During State (Recording) + + private var duringStateView: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + PulsingDot() + + TextField("New Meeting", text: $title) + .textFieldStyle(.plain) + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + .onChange(of: title) { _, newVal in + document.updateMeetingTitle(blockId: block.id, title: newVal) + } + + Spacer() + + ladybugButton + + Button(action: stopRecording) { + HStack(spacing: 5) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .frame(width: 8, height: 8) + Text("Stop") + .font(.system(size: Typography.bodySmall, weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + + MeetingNotesEditor(text: $notes) + .frame(minHeight: 160) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .onChange(of: notes) { _, newVal in + document.updateMeetingNotes(blockId: block.id, notes: newVal) + } + + Divider() + + bottomBar(showWaveform: true) + + if isTranscriptOpen { + transcriptDrawer + } + } + } + + // MARK: - Processing State + + private var processingStateView: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Text(block.meetingTitle.isEmpty ? "Meeting" : block.meetingTitle) + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(processingStatus.isEmpty ? "Processing..." : processingStatus) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextSecondary) + } + .padding(.vertical, 20) + } + } + + // MARK: - After State (Complete) + + private var afterStateView: some View { + let sections = parseSections(block.language) + return VStack(spacing: 0) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(block.meetingTitle.isEmpty ? "Meeting" : block.meetingTitle) + .font(.system(size: Typography.title3, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + } + + Spacer() + + ladybugButton + + // Generate summary button (only when no summary exists) + if sections.isEmpty && block.meetingActionItems.isEmpty && block.meetingSummary.isEmpty && (!block.meetingTranscript.isEmpty || !block.meetingNotes.isEmpty) { + Button { + Task { await generateSummary() } + } label: { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 10)) + Text("Generate") + .font(.system(size: Typography.caption, weight: .medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) + } + .buttonStyle(.borderless) + } + + // Expand button (hover only) + if isHovered { + Button(action: { withAnimation(.easeInOut(duration: 0.25)) { isSummaryExpanded.toggle() } }) { + Image(systemName: isSummaryExpanded ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .font(.system(size: 11)) + .foregroundStyle(Color.fallbackTextSecondary) + .frame(width: 24, height: 24) + .background(Color.primary.opacity(Opacity.subtle)) + .clipShape(RoundedRectangle(cornerRadius: Radius.xs)) + } + .buttonStyle(.borderless) + .transition(.opacity) + } + + // Summary/Notes tab picker + Picker("", selection: $activeTab) { + Text("Summary").tag(MeetingTab.summary) + Text("Notes").tag(MeetingTab.notes) + } + .pickerStyle(.segmented) + .frame(width: 140) + + Button(action: resumeRecording) { + HStack(spacing: 5) { + Image(systemName: "waveform") + .font(.system(size: 10)) + Text("Resume") + .font(.system(size: Typography.bodySmall, weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accentColor.opacity(Opacity.medium)) + .foregroundStyle(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: Radius.sm)) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + + // Content area: Summary or Notes + switch activeTab { + case .summary: + summaryContent(sections: sections) + case .notes: + notesView + } + + Divider() + + bottomBar(showWaveform: false) + + if isTranscriptOpen { + transcriptDrawer + } + } + } + + // MARK: - Summary View + + private func summaryContent(sections: [MeetingSection]) -> some View { + ZStack(alignment: .bottom) { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if !sections.isEmpty { + ForEach(Array(sections.enumerated()), id: \.offset) { _, section in + VStack(alignment: .leading, spacing: 4) { + if !section.heading.isEmpty { + Text(section.heading) + .font(.system(size: Typography.bodySmall, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + } + ForEach(Array(section.items.enumerated()), id: \.offset) { _, item in + if item.isActionItem { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "square") + .font(.system(size: 12)) + .foregroundStyle(Color.fallbackTextSecondary) + .padding(.top, 2) + Text(item.text) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextPrimary) + } + } else if item.isUserNote { + Text(item.text) + .font(.system(size: Typography.bodySmall).italic()) + .foregroundStyle(Color.accentColor) + .padding(.leading, 8) + } else if item.isSummaryText { + Text(item.text) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextSecondary) + } else { + HStack(alignment: .top, spacing: 6) { + Text("\u{2022}") + .foregroundStyle(Color.fallbackTextSecondary) + Text(item.text) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextPrimary) + } + } + } + } + } + } + + // Show dedicated action items only when there's no structured content + if sections.isEmpty && !block.meetingActionItems.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Action Items") + .font(.system(size: Typography.bodySmall, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + + ForEach(parseActionItems(block.meetingActionItems), id: \.self) { item in + HStack(alignment: .top, spacing: 6) { + Image(systemName: "square") + .font(.system(size: 12)) + .foregroundStyle(Color.fallbackTextSecondary) + .padding(.top, 2) + Text(item) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextPrimary) + } + } + } + } + + if sections.isEmpty && block.meetingActionItems.isEmpty && block.meetingSummary.isEmpty { + Text("No summary yet") + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextMuted) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 20) + } + } + .padding(14) + } + .frame(maxHeight: isSummaryExpanded ? nil : 200) + .clipped() + + if !isSummaryExpanded { + LinearGradient( + colors: [Color.fallbackCardBg.opacity(0), Color.fallbackCardBg], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 40) + .allowsHitTesting(false) + } + } + .animation(.easeInOut(duration: 0.25), value: isSummaryExpanded) + } + + // MARK: - Notes View + + private var notesView: some View { + VStack(alignment: .leading, spacing: 0) { + if block.meetingNotes.isEmpty { + Text("No notes recorded.") + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextMuted) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 20) + } else { + Text(block.meetingNotes) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextPrimary) + .textSelection(.enabled) + .padding(14) + } + } + } + + // MARK: - Bottom Bar + + private func bottomBar(showWaveform: Bool) -> some View { + Button(action: { + withAnimation(.easeInOut(duration: 0.25)) { + isTranscriptOpen.toggle() + } + }) { + HStack(spacing: 8) { + if showWaveform { + WaveformView(audioLevel: document.meetingAudioLevel) + .frame(width: 40, height: 16) + } else { + Text("Transcript") + .font(.system(size: Typography.caption, weight: .medium)) + .foregroundStyle(Color.fallbackTextSecondary) + } + + Spacer() + + + Image(systemName: isTranscriptOpen ? "chevron.down" : "chevron.up") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color.fallbackTextSecondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Color.primary.opacity(Opacity.subtle)) + } + + // MARK: - Transcript Drawer + + private static let transcriptTimeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "h:mm" + return f + }() + + private var transcriptDrawer: some View { + VStack(spacing: 0) { + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + let entries = !block.transcriptEntries.isEmpty + ? block.transcriptEntries + : splitTranscriptIntoBubbles(block.meetingTranscript) + + ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in + HStack { + Spacer() + Text(entry) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(Color.fallbackTextPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.primary.opacity(Opacity.light)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .textSelection(.enabled) + } + .id(index) + } + + // Volatile (in-progress) text with pulsing dot + if block.meetingState == .recording { + let volatile = document.meetingVolatileText + if !volatile.isEmpty { + HStack { + Spacer() + HStack(spacing: 4) { + Text(volatile) + .font(.system(size: Typography.bodySmall)) + .foregroundStyle(.secondary) + Circle() + .fill(Color.accentColor) + .frame(width: 4, height: 4) + .opacity(0.6) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.03)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .id("volatile") + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .frame(maxHeight: 800) + .onChange(of: block.transcriptEntries.count) { _, count in + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(count - 1, anchor: .bottom) + } + } + .onChange(of: document.meetingVolatileText) { _, _ in + if block.meetingState == .recording { + proxy.scrollTo("volatile", anchor: .bottom) + } + } + } + } + } + + // MARK: - Ladybug AI Button + + private var ladybugButton: some View { + Button(action: openAiWithContext) { + Image("BugbookAI") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.borderless) + .help("Ask AI about this meeting") + } + + // MARK: - Actions + + private func startRecording() { + document.updateMeetingState(blockId: block.id, state: .recording) + document.onStartMeeting?(block.id) + } + + private func stopRecording() { + document.onStopMeeting?(block.id) + } + + private func resumeRecording() { + document.updateMeetingState(blockId: block.id, state: .recording) + document.onStartMeeting?(block.id) + } + + private func openAiWithContext() { + NotificationCenter.default.post(name: .openAIPanel, object: nil) + } + + // MARK: - Helpers + + /// Splits a continuous transcript string into sentence-grouped bubbles (2-3 sentences each). + /// Falls back to newline splitting if the text already has line breaks. + private func splitTranscriptIntoBubbles(_ text: String) -> [String] { + let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty } + // If transcript already has multiple lines, use them + if lines.count > 1 { return lines } + // Otherwise split on sentence-ending punctuation followed by a space + guard !text.isEmpty else { return [] } + var sentences: [String] = [] + var current = "" + let chars = Array(text) + for (i, char) in chars.enumerated() { + current.append(char) + if (char == "." || char == "?" || char == "!"), + i + 1 < chars.count, chars[i + 1] == " " { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { sentences.append(trimmed) } + current = "" + } + } + let remainder = current.trimmingCharacters(in: .whitespaces) + if !remainder.isEmpty { sentences.append(remainder) } + // Group into bubbles of 2-3 sentences + var bubbles: [String] = [] + let chunkSize = 2 + for i in stride(from: 0, to: sentences.count, by: chunkSize) { + let end = min(i + chunkSize, sentences.count) + bubbles.append(sentences[i.. [String] { + raw.components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .map { line in + // Strip common prefixes like "- [ ] ", "- ", "[] " + var s = line + if s.hasPrefix("- [ ] ") { s = String(s.dropFirst(6)) } + else if s.hasPrefix("- ") { s = String(s.dropFirst(2)) } + return s + } + .filter { !$0.isEmpty } + } + + // MARK: - AI Summary Generation (from dev) + + private func generateSummary() async { + let transcript = block.meetingTranscript + let userNotes = block.meetingNotes + + document.updateMeetingState(blockId: block.id, state: .processing) + + if !transcript.isEmpty { + processingStatus = "Cleaning transcript..." + let cleanedTranscript = await cleanTranscript(transcript) + let cleaned = cleanedTranscript ?? transcript + document.updateBlockText(id: block.id, text: cleaned) + + processingStatus = "Extracting meeting sections..." + let structured = await extractStructuredSections(transcript: cleaned, notes: userNotes) + if let structured { + document.updateMeetingSummary(blockId: block.id, summary: structured) + } + } else if !userNotes.isEmpty { + processingStatus = "Generating summary from notes..." + let structured = await extractStructuredSections(transcript: "", notes: userNotes) + if let structured { + document.updateMeetingSummary(blockId: block.id, summary: structured) + } + } + + processingStatus = "" + document.updateMeetingState(blockId: block.id, state: .complete) + } + + private func cleanTranscript(_ raw: String) async -> String? { + let prompt = "Clean up this transcript: remove filler words (uh, um, like, you know), fix punctuation, add sentence breaks. Output only cleaned text:\n\n\(raw)" + return await runClaude(prompt: prompt) + } + + private func extractStructuredSections(transcript: String, notes: String) async -> String? { + var prompt = """ + You are a meeting notes assistant. Produce structured, concise meeting notes. + + Output format (use EXACTLY): + + TITLE: + + ## + - Key point as a concise bullet + - Supporting detail if needed + - Another key point + + ## Action Items + - [ ] Specific action item with owner if mentioned + + Rules: + - Title should reflect actual content (e.g. "Q2 Planning Review", "Design Sprint Kickoff") + - Group by topic, not "Summary" — use what was actually discussed as headings + - Bullets: specific facts, decisions, details. No meta-commentary ("participants discussed...") + - Sub-bullets only when they add real information + - If nothing actionable, omit Action Items entirely + """ + + if !notes.isEmpty { + prompt += "\n\nUser's notes during the meeting:\n\(notes)" + } + + if !transcript.isEmpty { + prompt += "\n\nTranscript:\n\(transcript)" + } + + guard let result = await runClaude(prompt: prompt) else { return nil } + + // Extract title and update it + if let titleLine = result.components(separatedBy: "\n").first, + titleLine.hasPrefix("TITLE:") { + let title = titleLine.replacingOccurrences(of: "TITLE:", with: "").trimmingCharacters(in: .whitespaces) + if !title.isEmpty { + document.updateMeetingTitle(blockId: block.id, title: title) + } + return result.components(separatedBy: "\n").dropFirst().joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + return result + } + + private func runClaude(prompt: String) async -> String? { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + let escaped = prompt.replacingOccurrences(of: "'", with: "'\"'\"'") + process.arguments = ["-l", "-c", "claude --model haiku --print '\(escaped)'"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + continuation.resume(returning: process.terminationStatus == 0 ? output : nil) + } catch { + continuation.resume(returning: nil) + } + } + } + } + + // MARK: - Section Parsing (from dev) + + private struct MeetingSection { + var heading: String + var items: [MeetingItem] + } + + private struct MeetingItem { + var text: String + var isActionItem: Bool + var isUserNote: Bool + var isSummaryText: Bool + } + + private func parseSections(_ raw: String) -> [MeetingSection] { + guard !raw.isEmpty else { return [] } + var sections: [MeetingSection] = [] + var currentHeading = "" + var currentItems: [MeetingItem] = [] + + for line in raw.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("") { + continue + } + + if trimmed.hasPrefix("## ") || trimmed.hasPrefix("### ") { + if !currentHeading.isEmpty || !currentItems.isEmpty { + sections.append(MeetingSection(heading: currentHeading, items: currentItems)) + currentItems = [] + } + currentHeading = trimmed + .replacingOccurrences(of: "### ", with: "") + .replacingOccurrences(of: "## ", with: "") + } else if trimmed.hasPrefix("- [ ] ") { + let text = String(trimmed.dropFirst(6)) + currentItems.append(MeetingItem(text: text, isActionItem: true, isUserNote: false, isSummaryText: false)) + } else if trimmed.hasPrefix("[NOTE]") { + let text = trimmed.replacingOccurrences(of: "[NOTE] ", with: "") + .replacingOccurrences(of: "[NOTE]", with: "") + currentItems.append(MeetingItem(text: text, isActionItem: false, isUserNote: true, isSummaryText: false)) + } else if trimmed.hasPrefix("- ") { + let text = String(trimmed.dropFirst(2)) + let isNote = text.hasPrefix("[NOTE]") + let cleanText = isNote + ? text.replacingOccurrences(of: "[NOTE] ", with: "").replacingOccurrences(of: "[NOTE]", with: "") + : text + currentItems.append(MeetingItem(text: cleanText, isActionItem: false, isUserNote: isNote, isSummaryText: false)) + } else if !trimmed.isEmpty { + currentItems.append(MeetingItem(text: trimmed, isActionItem: false, isUserNote: false, isSummaryText: true)) + } + } + if !currentHeading.isEmpty || !currentItems.isEmpty { + sections.append(MeetingSection(heading: currentHeading, items: currentItems)) + } + return sections.filter { $0.heading != "Title" && $0.heading != "Title:" } + } +} + +// MARK: - Pulsing Red Dot + +private struct PulsingDot: View { + @State private var isPulsing = false + + var body: some View { + Circle() + .fill(Color.accentColor) + .frame(width: 8, height: 8) + .opacity(isPulsing ? 0.4 : 1.0) + .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: isPulsing) + .onAppear { isPulsing = true } + } +} + +// MARK: - Waveform Animation + +private struct WaveformView: View { + var audioLevel: Float + private let barCount = 5 + + var body: some View { + HStack(spacing: 2) { + ForEach(0.. 0.02 ? Color.accentColor : Color.fallbackTextMuted) + .frame(width: 3, height: barHeight(for: i)) + .animation(.easeOut(duration: 0.15), value: audioLevel) + } + } + } + + private func barHeight(for index: Int) -> CGFloat { + let level = CGFloat(audioLevel) + guard level > 0.02 else { return 3 } + // Each bar gets a slightly different height for visual variety + let offsets: [CGFloat] = [0.7, 1.0, 0.5, 0.85, 0.6] + let scale = offsets[index] * level + return max(3, 3 + scale * 13) + } +} + +// MARK: - Chat-Style Transcript Viewer (from dev) + +struct TranscriptBubbleView: View { + let transcript: String + let meetingNotes: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Transcript") + .font(.system(size: 18, weight: .semibold)) + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + + Divider() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + let utterances = splitIntoUtterances(transcript) + let noteBubbles = splitIntoNoteBubbles(meetingNotes) + let merged = mergeUtterancesAndNotes(utterances: utterances, notes: noteBubbles) + + ForEach(Array(merged.enumerated()), id: \.offset) { _, bubble in + if bubble.isNote { + HStack { + Spacer(minLength: 60) + Text(bubble.text) + .font(.system(size: EditorTypography.bodyFontSize)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } else { + HStack { + Text(bubble.text) + .font(.system(size: EditorTypography.bodyFontSize)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + Spacer(minLength: 60) + } + } + } + } + .padding(20) + } + } + .frame(minWidth: 500, minHeight: 400) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private struct Bubble { + var text: String + var isNote: Bool + } + + private func splitIntoUtterances(_ text: String) -> [String] { + guard !text.isEmpty else { return [] } + let paragraphs = text.components(separatedBy: "\n\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if paragraphs.count > 1 { + return paragraphs.flatMap { splitParagraphIntoSentenceGroups($0) } + } + return splitParagraphIntoSentenceGroups(text) + } + + private func splitParagraphIntoSentenceGroups(_ paragraph: String) -> [String] { + var sentences: [String] = [] + var current = "" + for char in paragraph { + current.append(char) + if char == "." || char == "?" || char == "!" { + let trimmed = current.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { sentences.append(trimmed) } + current = "" + } + } + let remainder = current.trimmingCharacters(in: .whitespaces) + if !remainder.isEmpty { sentences.append(remainder) } + + var groups: [String] = [] + let chunkSize = 3 + for i in stride(from: 0, to: sentences.count, by: chunkSize) { + let end = min(i + chunkSize, sentences.count) + groups.append(sentences[i.. [String] { + guard !notes.isEmpty else { return [] } + return notes.components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + private func mergeUtterancesAndNotes(utterances: [String], notes: [String]) -> [Bubble] { + guard !notes.isEmpty, !utterances.isEmpty else { + return utterances.map { Bubble(text: $0, isNote: false) } + + notes.map { Bubble(text: $0, isNote: true) } + } + var result: [Bubble] = [] + let interval = max(1, utterances.count / (notes.count + 1)) + var noteIndex = 0 + for (i, utterance) in utterances.enumerated() { + result.append(Bubble(text: utterance, isNote: false)) + if noteIndex < notes.count && (i + 1) % interval == 0 { + result.append(Bubble(text: notes[noteIndex], isNote: true)) + noteIndex += 1 + } + } + while noteIndex < notes.count { + result.append(Bubble(text: notes[noteIndex], isNote: true)) + noteIndex += 1 + } + return result + } +} diff --git a/Sources/Bugbook/Views/Editor/MeetingNotesEditor.swift b/Sources/Bugbook/Views/Editor/MeetingNotesEditor.swift new file mode 100644 index 0000000..2e17abf --- /dev/null +++ b/Sources/Bugbook/Views/Editor/MeetingNotesEditor.swift @@ -0,0 +1,300 @@ +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/SlashCommandMenu.swift b/Sources/Bugbook/Views/Editor/SlashCommandMenu.swift index c2867bd..7846af1 100644 --- a/Sources/Bugbook/Views/Editor/SlashCommandMenu.swift +++ b/Sources/Bugbook/Views/Editor/SlashCommandMenu.swift @@ -3,44 +3,108 @@ import SwiftUI /// Slash command popup menu for block type conversion. struct SlashCommandMenu: View { var document: BlockDocument + @State private var hoverActive = false + + private var sections: [(name: String, commands: [(index: Int, command: BlockDocument.SlashCommand)])] { + let commands = document.filteredSlashCommands + var sectionOrder: [String] = [] + var grouped: [String: [(index: Int, command: BlockDocument.SlashCommand)]] = [:] + for (index, command) in commands.enumerated() { + if grouped[command.section] == nil { + sectionOrder.append(command.section) + } + grouped[command.section, default: []].append((index, command)) + } + return sectionOrder.compactMap { name in + guard let items = grouped[name] else { return nil } + return (name, items) + } + } var body: some View { let commands = document.filteredSlashCommands - VStack(alignment: .leading, spacing: 0) { - if commands.isEmpty { - Text("No results") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - } else { - ForEach(Array(commands.enumerated()), id: \.offset) { index, command in - Button { - document.slashMenuSelectedIndex = index - document.executeSlashCommand() - } label: { - HStack(spacing: 8) { - Image(systemName: command.icon) - .frame(width: 20) - .foregroundStyle(.secondary) - Text(command.name) - .foregroundStyle(.primary) + if commands.isEmpty { + Text("No results") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 220) + .popoverSurface() + } else { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(sections, id: \.name) { section in + Text(section.name) + .font(.system(size: Typography.caption2, weight: .medium)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 12) + .padding(.top, section.name == sections.first?.name ? 6 : 10) + .padding(.bottom, 4) + + ForEach(section.commands, id: \.index) { item in + SlashCommandRow( + command: item.command, + isSelected: item.index == document.slashMenuSelectedIndex + ) { + document.slashMenuSelectedIndex = item.index + document.executeSlashCommand() + } onHover: { + hoverActive = true + document.slashMenuSelectedIndex = item.index + } + .id(item.index) + } } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - index == document.slashMenuSelectedIndex - ? Color.accentColor.opacity(0.1) - : Color.clear - ) } - .buttonStyle(.plain) + .padding(.bottom, 4) + } + .scrollIndicators(.automatic) + .onChange(of: document.slashMenuSelectedIndex) { _, newIndex in + if !hoverActive { + proxy.scrollTo(newIndex, anchor: .center) + } + hoverActive = false } } + .frame(width: 220, height: min(CGFloat(commands.count) * 30 + CGFloat(sections.count) * 24, 320)) + .popoverSurface() + } + } +} + +private struct SlashCommandRow: View { + let command: BlockDocument.SlashCommand + let isSelected: Bool + let action: () -> Void + let onHover: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: command.icon) + .frame(width: 20) + .foregroundStyle(.secondary) + Text(command.name) + .foregroundStyle(.primary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected || isHovered + ? Color.accentColor.opacity(0.1) + : Color.clear + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovered = hovering + if hovering { onHover() } } - .frame(width: 200) - .popoverSurface() } } diff --git a/Sources/Bugbook/Views/Editor/TextBlockView.swift b/Sources/Bugbook/Views/Editor/TextBlockView.swift index c7eed7b..9488bd8 100644 --- a/Sources/Bugbook/Views/Editor/TextBlockView.swift +++ b/Sources/Bugbook/Views/Editor/TextBlockView.swift @@ -24,6 +24,7 @@ struct TextBlockView: View { isMultiline: false, font: nsFont, textColor: nsTextColor, + strikethrough: block.type == .taskItem && block.isChecked, placeholder: nil, onTextChange: onTyping, textHeight: $textHeight @@ -66,7 +67,7 @@ struct TextBlockView: View { } label: { Image(systemName: block.isChecked ? "checkmark.square.fill" : "square") .font(.system(size: 15)) - .foregroundStyle(block.isChecked ? Color.accentColor : Color.secondary) + .foregroundStyle(block.isChecked ? Color.dragIndicator : Color.secondary) } .buttonStyle(.plain) .frame(width: 20) diff --git a/Sources/Bugbook/Views/Editor/WikiLinkView.swift b/Sources/Bugbook/Views/Editor/WikiLinkView.swift index d331c10..e7cc9b8 100644 --- a/Sources/Bugbook/Views/Editor/WikiLinkView.swift +++ b/Sources/Bugbook/Views/Editor/WikiLinkView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers struct WikiLinkView: View { let pageName: String @@ -8,25 +9,31 @@ struct WikiLinkView: View { var body: some View { if let sidebarReferencePayload { - linkButton - .draggable(sidebarReferencePayload) + linkContent + .onDrag { + let data = (try? JSONEncoder().encode(sidebarReferencePayload)) ?? Data() + return NSItemProvider(item: data as NSData, typeIdentifier: UTType.sidebarReference.identifier) + } } else { - linkButton + linkContent } } - @ViewBuilder - private var linkButton: some View { - Button(action: onNavigate) { - HStack(spacing: 4) { - iconView - Text(pageName) - .font(.system(size: EditorTypography.bodyFontSize)) - .foregroundStyle(.primary) - .underline() - } + private var linkContent: some View { + HStack(spacing: 4) { + iconView + Text(pageName) + .font(.system(size: EditorTypography.bodyFontSize)) + .foregroundStyle(.primary) + .underline() } - .buttonStyle(.plain) + .contentShape(Rectangle()) + .onTapGesture(perform: onNavigate) + .appCursor(.pointingHand) + } + + private var dragPreview: some View { + SidebarDragPreview(systemImage: "doc.text", title: pageName) } @ViewBuilder diff --git a/Sources/Bugbook/Views/Graph/GraphView.swift b/Sources/Bugbook/Views/Graph/GraphView.swift index 83b064c..b528c23 100644 --- a/Sources/Bugbook/Views/Graph/GraphView.swift +++ b/Sources/Bugbook/Views/Graph/GraphView.swift @@ -18,39 +18,25 @@ struct GraphEdge: Identifiable { var id: String { "\(source)→\(target)\(isParentChild ? ":pc" : "")" } } -// MARK: - Force Simulation +// MARK: - Background Simulation Actor -@MainActor -class ForceSimulation: ObservableObject { - @Published var nodes: [GraphNode] = [] - @Published var edges: [GraphEdge] = [] - - private var timer: Timer? +/// Runs O(n²) force simulation on a background thread, isolated from the main actor. +private actor SimulationEngine { + private var nodes: [GraphNode] = [] + private var edges: [GraphEdge] = [] private var settledFrames = 0 private let settleThreshold: CGFloat = 0.3 private let maxSettledFrames = 60 - func start() { - settledFrames = 0 - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - DispatchQueue.main.async { - self?.tick() - } - } + func setGraph(nodes: [GraphNode], edges: [GraphEdge]) { + self.nodes = nodes + self.edges = edges + self.settledFrames = 0 } - func stop() { - timer?.invalidate() - timer = nil - } - - deinit { - timer?.invalidate() - } - - private func tick() { - guard !nodes.isEmpty else { return } + /// Returns updated node positions, or nil if the simulation has settled and should stop. + func tick() -> [GraphNode]? { + guard !nodes.isEmpty else { return nil } let damping: CGFloat = 0.9 let repulsionStrength: CGFloat = 8000 @@ -64,7 +50,6 @@ class ForceSimulation: ObservableObject { nodeIndex[nodes[i].id] = i } - // Compute center let center = CGPoint(x: 0, y: 0) // Repulsion: all pairs push apart @@ -109,15 +94,10 @@ class ForceSimulation: ObservableObject { // Center gravity + apply velocity + damping var maxVel: CGFloat = 0 for i in nodes.indices { - // Gravity toward center nodes[i].velocity.x += (center.x - nodes[i].position.x) * centerGravity nodes[i].velocity.y += (center.y - nodes[i].position.y) * centerGravity - - // Damping nodes[i].velocity.x *= damping nodes[i].velocity.y *= damping - - // Apply nodes[i].position.x += nodes[i].velocity.x nodes[i].position.y += nodes[i].velocity.y @@ -125,15 +105,54 @@ class ForceSimulation: ObservableObject { maxVel = max(maxVel, vel) } - // Stop simulation when settled + // Check if settled if maxVel < settleThreshold { settledFrames += 1 if settledFrames >= maxSettledFrames { - stop() + return nil // signal to stop } } else { settledFrames = 0 } + + return nodes + } +} + +// MARK: - Force Simulation (Main Actor Publisher) + +@MainActor +class ForceSimulation: ObservableObject { + @Published var nodes: [GraphNode] = [] + @Published var edges: [GraphEdge] = [] + + private let engine = SimulationEngine() + private var simulationTask: Task? + + func start() { + simulationTask?.cancel() + let engineRef = engine + Task { await engineRef.setGraph(nodes: nodes, edges: edges) } + simulationTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + let updated = await engineRef.tick() + guard !Task.isCancelled else { return } + if let updated { + self.nodes = updated + } else { + // Simulation settled + return + } + // Yield to next frame (~60fps) + try? await Task.sleep(nanoseconds: 16_666_667) + } + } + } + + func stop() { + simulationTask?.cancel() + simulationTask = nil } } @@ -335,12 +354,12 @@ struct GraphView: View { let filePaths: [String] = await Task.detached { let fm = FileManager.default guard let enumerator = fm.enumerator(atPath: workspace) else { return [String]() } - // Pre-scan for database folders (contain _schema.json) and canvas folders (contain _canvas.json) + // Pre-scan for database folders (contain _schema.json) var excludedDirs: Set = [] if let scanner = fm.enumerator(atPath: workspace) { while let rel = scanner.nextObject() as? String { let filename = (rel as NSString).lastPathComponent - if filename == "_schema.json" || filename == "_canvas.json" { + if filename == "_schema.json" { let dir = (workspace as NSString).appendingPathComponent( (rel as NSString).deletingLastPathComponent ) @@ -358,7 +377,7 @@ struct GraphView: View { let filename = (relativePath as NSString).lastPathComponent guard filename.hasSuffix(".md") else { continue } let fullPath = (workspace as NSString).appendingPathComponent(relativePath) - // Skip database row files and canvas node files + // Skip database row files let parentDir = (fullPath as NSString).deletingLastPathComponent if excludedDirs.contains(parentDir) { continue } paths.append(fullPath) diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift new file mode 100644 index 0000000..f91ff88 --- /dev/null +++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift @@ -0,0 +1,269 @@ +import SwiftUI +import BugbookCore + +struct MeetingsView: View { + var appState: AppState + var calendarService: CalendarService + var aiService: AiService + var onNavigateToFile: (String) -> Void + + @State private var vm = MeetingsViewModel() + @State private var chatInput: String = "" + @State private var chatResponse: String = "" + @State private var isChatLoading = false + @FocusState private var chatFocused: Bool + + var body: some View { + VStack(spacing: 0) { + header + Divider() + searchBar + meetingsList + Divider() + chatBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.fallbackEditorBg) + .onAppear { loadMeetings() } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "person.2") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.secondary) + + Text("Meetings") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color.fallbackTextPrimary) + + Spacer() + } + .padding(.horizontal, 28) + .padding(.vertical, 14) + .padding(.top, 36) + } + + // MARK: - Search + + private var searchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + .foregroundStyle(.tertiary) + TextField("Filter meetings...", text: $vm.searchText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.primary.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, 28) + .padding(.bottom, 8) + } + + // MARK: - Meetings List + + private var meetingsList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + let upcoming = vm.filteredUpcoming + let past = vm.filteredPast + + if !upcoming.isEmpty { + sectionHeader("Coming Up") + ForEach(upcoming, id: \.date) { group in + dayHeader(vm.relativeLabel(for: group.date)) + ForEach(group.items) { item in + meetingRow(item) + } + } + } + + if !past.isEmpty { + sectionHeader("Past Meetings") + ForEach(past, id: \.date) { group in + dayHeader(vm.relativeLabel(for: group.date)) + ForEach(group.items) { item in + meetingRow(item) + } + } + } + + if upcoming.isEmpty && past.isEmpty { + emptyState + } + } + .padding(.horizontal, 28) + .padding(.vertical, 8) + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.top, 16) + .padding(.bottom, 4) + } + + private func dayHeader(_ label: String) -> some View { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.tertiary) + .padding(.top, 10) + .padding(.bottom, 2) + } + + private func meetingRow(_ item: MeetingItem) -> some View { + Button(action: { handleTap(item) }) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color.fallbackTextPrimary) + .lineLimit(1) + + HStack(spacing: 6) { + if !item.isAllDay, let end = item.endDate { + Text("\(vm.timeFormatter.string(from: item.date)) - \(vm.timeFormatter.string(from: end))") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } else if item.isAllDay { + Text("All day") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + if !item.attendeeSummary.isEmpty { + Text(item.attendeeSummary) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + } + + Spacer() + + if item.pagePath != nil { + Image(systemName: "doc.text") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color.primary.opacity(0.03)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.vertical, 1) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: "person.2.slash") + .font(.system(size: 28)) + .foregroundStyle(.quaternary) + Text(vm.searchText.isEmpty ? "No meetings found" : "No matching meetings") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + if vm.searchText.isEmpty { + Text("Sync your calendar in Settings to see upcoming meetings.") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + // MARK: - Chat Bar + + private var chatBar: some View { + VStack(spacing: 6) { + if !chatResponse.isEmpty { + ScrollView { + Text(chatResponse) + .font(.system(size: 13)) + .foregroundStyle(Color.fallbackTextPrimary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(maxHeight: 160) + + Divider() + } + + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + + TextField("Ask about your meetings...", text: $chatInput) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($chatFocused) + .onSubmit { sendChat() } + + if isChatLoading { + ProgressView() + .controlSize(.small) + } else { + Button(action: sendChat) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(chatInput.isEmpty ? Color.secondary.opacity(0.4) : .accentColor) + } + .buttonStyle(.plain) + .disabled(chatInput.isEmpty) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + } + + // MARK: - Actions + + private func loadMeetings() { + guard let workspace = appState.workspacePath else { return } + calendarService.loadCachedData(workspace: workspace) + vm.load(workspace: workspace, calendarEvents: calendarService.events) + } + + private func handleTap(_ item: MeetingItem) { + if let path = item.pagePath { + onNavigateToFile(path) + } + } + + private func sendChat() { + let question = chatInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !question.isEmpty, let workspace = appState.workspacePath else { return } + chatInput = "" + isChatLoading = true + + Task { + do { + let response = try await aiService.chatWithNotes( + engine: appState.settings.preferredAIEngine, + workspacePath: workspace, + question: question, + apiKey: appState.settings.anthropicApiKey + ) + chatResponse = response + } catch { + chatResponse = "Error: \(error.localizedDescription)" + } + isChatLoading = false + } + } +} diff --git a/Sources/Bugbook/Views/Settings/AISettingsView.swift b/Sources/Bugbook/Views/Settings/AISettingsView.swift index ef0d80d..06672be 100644 --- a/Sources/Bugbook/Views/Settings/AISettingsView.swift +++ b/Sources/Bugbook/Views/Settings/AISettingsView.swift @@ -24,6 +24,19 @@ struct AISettingsView: View { } if appState.settings.preferredAIEngine == .claudeAPI { + SettingsSection("AI Model") { + Picker("Model", selection: $appState.settings.anthropicModel) { + ForEach(AnthropicModel.allCases, id: \.self) { model in + Text(model.displayName).tag(model) + } + } + .pickerStyle(.segmented) + .labelsHidden() + Text("Sonnet produces higher-quality summaries. Haiku is faster and cheaper.") + .font(.caption) + .foregroundStyle(.secondary) + } + SettingsSection("Anthropic API Key") { HStack(spacing: 8) { Group { diff --git a/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift b/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift index abafa31..f2fe8d3 100644 --- a/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift +++ b/Sources/Bugbook/Views/Settings/CalendarSettingsView.swift @@ -3,53 +3,62 @@ import BugbookCore struct CalendarSettingsView: View { @Bindable var appState: AppState - @State private var showClientSecret = false @State private var overlays: [CalendarOverlay] = [] + @State private var isSigningIn = false + @State private var signInError: String? private let store = CalendarEventStore() var body: some View { VStack(alignment: .leading, spacing: 24) { SettingsSection("Google Calendar") { - VStack(alignment: .leading, spacing: 12) { - TextField("Client ID", text: $appState.settings.googleCalendarClientId) - .textFieldStyle(.roundedBorder) - .font(.system(size: 13, design: .monospaced)) + if isConnected { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16)) - HStack(spacing: 8) { - Group { - if showClientSecret { - TextField("Client Secret", text: $appState.settings.googleCalendarClientSecret) - } else { - SecureField("Client Secret", text: $appState.settings.googleCalendarClientSecret) + VStack(alignment: .leading, spacing: 2) { + Text("Connected to Google Calendar") + .font(.system(size: 13, weight: .medium)) + if !appState.settings.googleCalendarConnectedEmail.isEmpty { + Text(appState.settings.googleCalendarConnectedEmail) + .font(.system(size: 12)) + .foregroundStyle(.secondary) } } - .textFieldStyle(.roundedBorder) - .font(.system(size: 13, design: .monospaced)) - Button { - showClientSecret.toggle() - } label: { - Image(systemName: showClientSecret ? "eye.slash" : "eye") - .foregroundStyle(.secondary) + Spacer() + + Button("Disconnect") { + disconnect() } .buttonStyle(.borderless) + .foregroundStyle(.red) + .font(.system(size: 13)) } + } else { + VStack(alignment: .leading, spacing: 8) { + Button { + Task { await signIn() } + } label: { + HStack(spacing: 8) { + if isSigningIn { + ProgressView().controlSize(.small) + } else { + Image(systemName: "person.badge.key") + } + Text("Sign in with Google") + } + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .disabled(isSigningIn) - TextField("Refresh Token", text: $appState.settings.googleCalendarRefreshToken) - .textFieldStyle(.roundedBorder) - .font(.system(size: 13, design: .monospaced)) - - Text("Create OAuth credentials in the Google Cloud Console with the Calendar API scope. Use the OAuth Playground to get a refresh token.") - .font(.caption) - .foregroundStyle(.secondary) - - if isConfigured { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Credentials configured") - .font(.system(size: 13)) + if let signInError { + Text(signInError) + .font(.caption) + .foregroundStyle(.red) } } } @@ -87,9 +96,31 @@ struct CalendarSettingsView: View { } } - private var isConfigured: Bool { - !appState.settings.googleCalendarClientId.isEmpty && - !appState.settings.googleCalendarClientSecret.isEmpty && + private func signIn() async { + isSigningIn = true + signInError = nil + defer { isSigningIn = false } + + do { + let result = try await GoogleOAuthFlow.signIn() + appState.settings.googleCalendarAccessToken = result.accessToken + appState.settings.googleCalendarRefreshToken = result.refreshToken + appState.settings.googleCalendarTokenExpiry = result.expiresAt.timeIntervalSince1970 + appState.settings.googleCalendarConnectedEmail = result.email + appState.settings.googleCalendarBannerDismissed = false + } catch { + signInError = error.localizedDescription + } + } + + private func disconnect() { + appState.settings.googleCalendarAccessToken = "" + appState.settings.googleCalendarRefreshToken = "" + appState.settings.googleCalendarTokenExpiry = 0 + appState.settings.googleCalendarConnectedEmail = "" + } + + private var isConnected: Bool { !appState.settings.googleCalendarRefreshToken.isEmpty } } diff --git a/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift b/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift index 91c5437..e012e78 100644 --- a/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift +++ b/Sources/Bugbook/Views/Settings/GeneralSettingsView.swift @@ -111,7 +111,7 @@ struct GeneralSettingsView: View { private func flatPages(from entries: [FileEntry]) -> [FileEntry] { var result: [FileEntry] = [] for entry in entries { - if !entry.isDirectory || entry.isDatabase || entry.isCanvas { + if !entry.isDirectory || entry.isDatabase { result.append(entry) } if let children = entry.children { diff --git a/Sources/Bugbook/Views/Settings/SearchSettingsView.swift b/Sources/Bugbook/Views/Settings/SearchSettingsView.swift index a28fc19..fa7d160 100644 --- a/Sources/Bugbook/Views/Settings/SearchSettingsView.swift +++ b/Sources/Bugbook/Views/Settings/SearchSettingsView.swift @@ -10,6 +10,7 @@ struct SearchSettingsView: View { if case .installed = qmdService.status { modeSection + indexSection mcpSection } } @@ -17,6 +18,7 @@ struct SearchSettingsView: View { await qmdService.detect() if let workspace = appState.workspacePath, qmdService.status.isInstalled { await qmdService.ensureCollection(workspace: workspace) + await qmdService.fetchIndexStatus() } } } @@ -29,7 +31,7 @@ struct SearchSettingsView: View { case .unknown: statusRow { ProgressView().scaleEffect(0.7) - Text("Detecting…").foregroundStyle(.secondary) + Text("Detecting...").foregroundStyle(.secondary) } case .notInstalled: @@ -38,7 +40,7 @@ struct SearchSettingsView: View { case .installing: statusRow { ProgressView().scaleEffect(0.7) - Text("Installing qmd…").foregroundStyle(.secondary) + Text("Installing qmd...").foregroundStyle(.secondary) } case .installed(let version, _): @@ -55,7 +57,7 @@ struct SearchSettingsView: View { HStack { Image(systemName: "xmark.circle") .foregroundStyle(.secondary) - Text("qmd — Not Installed") + Text("qmd -- Not Installed") .font(.system(size: 14)) Spacer() Button("Install") { @@ -64,7 +66,7 @@ struct SearchSettingsView: View { .buttonStyle(.borderedProminent) .controlSize(.small) } - Text("qmd adds BM25, semantic, and hybrid search to Bugbook. It also works with Claude Code and any other markdown directory — it's yours to keep.") + Text("qmd adds BM25, semantic, and hybrid search to Bugbook. v2 includes auto query expansion and reranking.") .font(.system(size: 12)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -88,7 +90,7 @@ struct SearchSettingsView: View { } else { HStack(spacing: 4) { ProgressView().scaleEffect(0.55) - Text("Indexing…") + Text("Indexing...") .font(.system(size: 12)) .foregroundStyle(.secondary) } @@ -146,9 +148,18 @@ struct SearchSettingsView: View { .font(.system(size: 14)) .padding(.top, 1) VStack(alignment: .leading, spacing: 2) { - Text(mode.label) - .font(.system(size: 14)) - .foregroundStyle(.primary) + HStack(spacing: 6) { + Text(mode.label) + .font(.system(size: 14)) + .foregroundStyle(.primary) + Text(mode.cliCommand) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.1)) + .clipShape(.rect(cornerRadius: 3)) + } Text(mode.detail) .font(.system(size: 12)) .foregroundStyle(.secondary) @@ -161,6 +172,53 @@ struct SearchSettingsView: View { .buttonStyle(.plain) } + // MARK: - Index Health + + private var indexSection: some View { + SettingsSection("Index") { + if let status = qmdService.indexStatus { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 16) { + indexStat(label: "Files", value: "\(status.totalFiles)") + indexStat(label: "Vectors", value: "\(status.totalVectors)") + if !status.indexSize.isEmpty { + indexStat(label: "Size", value: status.indexSize) + } + } + HStack { + Spacer() + Button("Re-index") { + Task { + if let workspace = appState.workspacePath { + await qmdService.ensureCollection(workspace: workspace) + await qmdService.fetchIndexStatus() + } + } + } + .controlSize(.small) + } + } + } else { + HStack(spacing: 4) { + ProgressView().scaleEffect(0.55) + Text("Loading index status...") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + } + } + + private func indexStat(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 14, weight: .medium, design: .monospaced)) + } + } + // MARK: - MCP tip private var mcpSection: some View { diff --git a/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift b/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift index d59dcff..9a4cb07 100644 --- a/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift +++ b/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ImageIO import os struct FileTreeItemView: View { @@ -9,14 +10,17 @@ struct FileTreeItemView: View { var onSelectFile: (FileEntry) -> Void var onRefreshTree: () -> Void var isSidebarReference: Bool = false + @Binding var expandedFolders: Set + var parentPath: String = "" - @State private var isExpanded: Bool = false + private var isExpanded: Bool { expandedFolders.contains(entry.path) } @State private var isHovering: Bool = false @State private var isRenaming: Bool = false @State private var renameName: String = "" @State private var showDeleteConfirmation: Bool = false @State private var showContextMenu: Bool = false @State private var hoveredMenuItem: String? + @State private var cachedIconImage: NSImage? private static let expandedFoldersKey = "expandedFolders" @@ -45,12 +49,13 @@ struct FileTreeItemView: View { workspacePath: workspacePath, parentPath: childParentPath, onSelectFile: onSelectFile, - onRefreshTree: onRefreshTree + onRefreshTree: onRefreshTree, + expandedFolders: $expandedFolders ) .padding(.leading, ShellZoomMetrics.size(12)) } } - .onAppear { loadExpandedState() } + .task(id: entry.icon) { await loadIconImage() } .alert("Delete \"\(displayName)\"?", isPresented: $showDeleteConfirmation) { Button("Move to Trash", role: .destructive) { performDelete() } Button("Cancel", role: .cancel) {} @@ -107,14 +112,54 @@ struct FileTreeItemView: View { .onHover { hovering in isHovering = hovering } } + /// Path to load as a file-based icon, or nil if the icon is not a file path. + private var iconFilePath: String? { + guard let icon = entry.icon, !icon.isEmpty else { return nil } + if icon.hasPrefix("custom:") { + return String(icon.dropFirst(7)) + } else if icon.hasPrefix("sf:") || icon.unicodeScalars.first?.properties.isEmoji == true { + return nil + } else if FileManager.default.fileExists(atPath: icon) { + return icon + } + return nil + } + + private func loadIconImage() async { + guard let path = iconFilePath else { + cachedIconImage = nil + return + } + let loaded = await Task.detached(priority: .utility) { + Self.downsampledImage(at: path, maxPixelSize: 32) + }.value + if !Task.isCancelled { + cachedIconImage = loaded + } + } + + nonisolated private static func downsampledImage(at path: String, maxPixelSize: Int) -> NSImage? { + let url = URL(fileURLWithPath: path) + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } + @ViewBuilder private var iconView: some View { if let icon = entry.icon, !icon.isEmpty { - if icon.hasPrefix("custom:") { - // Custom uploaded icon image (custom:/path/to/image) - let path = String(icon.dropFirst(7)) - if let nsImage = NSImage(contentsOfFile: path) { - Image(nsImage: nsImage) + if icon.hasPrefix("custom:") || iconFilePath != nil { + // File-based icon — uses async-loaded cachedIconImage + if let cached = cachedIconImage { + Image(nsImage: cached) .resizable() .aspectRatio(contentMode: .fit) .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16)) @@ -133,17 +178,6 @@ struct FileTreeItemView: View { .font(ShellZoomMetrics.font(13)) .minimumScaleFactor(0.5) .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16)) - } else if FileManager.default.fileExists(atPath: icon) { - // Raw file path (legacy) - if let nsImage = NSImage(contentsOfFile: icon) { - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16)) - .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(3))) - } else { - defaultIcon - } } else { defaultIcon } @@ -154,12 +188,7 @@ struct FileTreeItemView: View { @ViewBuilder private var defaultIcon: some View { - if entry.isCanvas { - Image(systemName: "rectangle.on.rectangle.angled") - .font(ShellZoomMetrics.font(Typography.bodySmall)) - .foregroundStyle(.secondary) - .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16)) - } else if entry.isDatabase { + if entry.isDatabase { Image(systemName: "tablecells") .font(ShellZoomMetrics.font(Typography.bodySmall)) .foregroundStyle(.secondary) @@ -207,29 +236,12 @@ struct FileTreeItemView: View { // MARK: - Expanded State Persistence private func toggleExpanded() { - isExpanded.toggle() - saveExpandedState() - } - - private func loadExpandedState() { - guard isExpandable else { return } - let expanded = expandedFolders() - isExpanded = expanded.contains(entry.path) - } - - private func saveExpandedState() { - var expanded = expandedFolders() - if isExpanded { - expanded.insert(entry.path) + if expandedFolders.contains(entry.path) { + expandedFolders.remove(entry.path) } else { - expanded.remove(entry.path) + expandedFolders.insert(entry.path) } - UserDefaults.standard.set(Array(expanded), forKey: Self.expandedFoldersKey) - } - - private func expandedFolders() -> Set { - let arr = UserDefaults.standard.stringArray(forKey: Self.expandedFoldersKey) ?? [] - return Set(arr) + UserDefaults.standard.set(Array(expandedFolders), forKey: Self.expandedFoldersKey) } // MARK: - Context Menu @@ -242,10 +254,6 @@ struct FileTreeItemView: View { ctxButton(id: "new-db", icon: "tablecells", label: "New Database") { showContextMenu = false; performCreateDatabase() } - ctxButton(id: "new-canvas", icon: "rectangle.on.rectangle.angled", label: "New Canvas") { - showContextMenu = false; performCreateCanvas() - } - ctxDivider ctxButton(id: "rename", icon: "pencil", label: "Rename") { @@ -322,14 +330,12 @@ struct FileTreeItemView: View { guard !trimmed.isEmpty, trimmed != displayName else { return } let dir = (entry.path as NSString).deletingLastPathComponent - let ext = (entry.isDatabase || entry.isCanvas || entry.isDirectory) ? "" : ".md" + let ext = (entry.isDatabase || entry.isDirectory) ? "" : ".md" let newPath = (dir as NSString).appendingPathComponent("\(trimmed)\(ext)") try? fileSystem.renameFile(from: entry.path, to: newPath) if entry.isDatabase { try? fileSystem.updateDatabaseDisplayName(at: newPath, name: trimmed) - } else if entry.isCanvas { - try? fileSystem.updateCanvasDisplayName(at: newPath, name: trimmed) } onRefreshTree() } @@ -419,16 +425,4 @@ struct FileTreeItemView: View { NotificationCenter.default.post(name: .movePage, object: entry.path) } - private func performCreateCanvas() { - let dir = entry.isDirectory ? entry.path : (entry.path as NSString).deletingLastPathComponent - if let path = try? fileSystem.createCanvas(in: dir, name: "Untitled Canvas") { - onRefreshTree() - let displayName = "Untitled Canvas" - let canvas = FileEntry( - id: path, name: displayName, - path: path, isDirectory: false, kind: .canvas - ) - onSelectFile(canvas) - } - } } diff --git a/Sources/Bugbook/Views/Sidebar/FileTreeView.swift b/Sources/Bugbook/Views/Sidebar/FileTreeView.swift index c3ced86..0584af1 100644 --- a/Sources/Bugbook/Views/Sidebar/FileTreeView.swift +++ b/Sources/Bugbook/Views/Sidebar/FileTreeView.swift @@ -36,6 +36,7 @@ struct FileTreeView: View { var parentPath: String? var onSelectFile: (FileEntry) -> Void var onRefreshTree: () -> Void + @Binding var expandedFolders: Set @StateObject private var dropState = DropIndicatorState() @State private var cachedEntries: [FileEntry] = [] @@ -49,7 +50,8 @@ struct FileTreeView: View { fileSystem: fileSystem, workspacePath: workspacePath, onSelectFile: onSelectFile, - onRefreshTree: onRefreshTree + onRefreshTree: onRefreshTree, + expandedFolders: $expandedFolders ) // Use overlays instead of conditional views to avoid layout shifts .overlay(alignment: .top) { @@ -132,10 +134,10 @@ struct FileTreeDropDelegate: DropDelegate { var onDidReorder: () -> Void let onRefreshTree: () -> Void - /// Whether the target entry can accept children (pages can, databases/canvases cannot). + /// Whether the target entry can accept children (pages can, databases cannot). private var targetAcceptsChildren: Bool { guard let entry = targetEntry else { return false } - if entry.isDatabase || entry.isCanvas { return false } + if entry.isDatabase { return false } return entry.name.hasSuffix(".md") || entry.isDirectory } @@ -154,7 +156,7 @@ struct FileTreeDropDelegate: DropDelegate { private func updateDropMode(info: DropInfo) { guard targetAcceptsChildren else { - // Split into top/bottom halves so items can be placed below canvas/database + // Split into top/bottom halves so items can be placed below database let y = info.location.y let rowHeight = ShellZoomMetrics.size(28) let fraction = y / rowHeight diff --git a/Sources/Bugbook/Views/Sidebar/SidebarView.swift b/Sources/Bugbook/Views/Sidebar/SidebarView.swift index 83fdf22..15cc3aa 100644 --- a/Sources/Bugbook/Views/Sidebar/SidebarView.swift +++ b/Sources/Bugbook/Views/Sidebar/SidebarView.swift @@ -18,6 +18,10 @@ struct SidebarView: View { @State private var isFullScreen: Bool = false @State private var localTrashPopoverPresented: Bool = false @State private var isSidebarReferenceDropTargeted = false + @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"), @@ -239,6 +243,25 @@ struct SidebarView: View { } .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 } } .padding(.horizontal, sectionHorizontalPadding) } @@ -267,7 +290,8 @@ struct SidebarView: View { workspacePath: appState.workspacePath, onSelectFile: onSelectFile, onRefreshTree: refreshTree, - isSidebarReference: true + isSidebarReference: true, + expandedFolders: $expandedFolders ) } } @@ -279,7 +303,8 @@ struct SidebarView: View { fileSystem: fileSystem, workspacePath: appState.workspacePath, onSelectFile: onSelectFile, - onRefreshTree: refreshTree + onRefreshTree: refreshTree, + expandedFolders: $expandedFolders ) } .padding(.horizontal, sectionHorizontalPadding) @@ -457,35 +482,14 @@ struct SidebarView: View { } } - private func createCanvas() { - invokeAction { - NotificationCenter.default.post(name: .newCanvas, object: nil) - } - } - private var newPageMenuButton: some View { - Menu { - Button { - createFile() - } label: { - Label("New Page", systemImage: "doc") - } - Button { - createCanvas() - } label: { - Label("New Canvas", systemImage: "rectangle.on.rectangle.angled") - } - } label: { + 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)) - } primaryAction: { - createFile() } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() + .buttonStyle(.borderless) .help("New Page") } diff --git a/Sources/BugbookCLI/Commands/QueryCommand.swift b/Sources/BugbookCLI/Commands/QueryCommand.swift index 217ea8d..e399b75 100644 --- a/Sources/BugbookCLI/Commands/QueryCommand.swift +++ b/Sources/BugbookCLI/Commands/QueryCommand.swift @@ -37,82 +37,105 @@ struct QueryCmd: ParsableCommand { func run() throws { let (dbPath, schema) = try resolveDatabase(db, workspace: options.resolvedWorkspace) - // Parse filters - var filters: [Filter] = [] - for expr in filter { - filters.append(try parseFilter(expr, schema: schema)) - } + let filters: [Filter] = try filter.map { try parseFilter($0, schema: schema) } + let sorts: [Sort] = try sort.map { try parseSort($0, schema: schema) } + let fieldList = try parseFieldList(schema: schema) + + let indexData = try loadOrRebuildIndex(dbPath: dbPath, schema: schema) - // Parse sorts - var sorts: [Sort] = [] - for expr in sort { - sorts.append(try parseSort(expr, schema: schema)) + guard let rowsMap = indexData["rows"] as? [String: [String: Any]] else { + try outputJSON(["rows": [] as [Any], "total_count": 0, "has_more": false]) + return } - let fieldList = try fields? + let matchingIds = applyFiltersAndSorts(filters: filters, sorts: sorts, rowsMap: rowsMap, schema: schema) + let paginatedResult = paginate(matchingIds) + + let outputRows = buildOutputRows( + ids: paginatedResult.ids, rowsMap: rowsMap, dbPath: dbPath, schema: schema, fieldList: fieldList + ) + + try outputJSON([ + "rows": outputRows, + "total_count": paginatedResult.totalCount, + "has_more": paginatedResult.hasMore, + ]) + } + + // MARK: - Helpers + + private func parseFieldList(schema: DatabaseSchema) throws -> [String]? { + try fields? .components(separatedBy: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .map { try resolveSchemaPropertyID($0, schema: schema) } + } - // Load index for querying + private func loadOrRebuildIndex(dbPath: String, schema: DatabaseSchema) throws -> [String: Any] { let indexManager = IndexManager() let rowStore = RowStore() - // Load or rebuild index - let indexData: [String: Any] if let existing = indexManager.loadIndex(at: dbPath), !indexManager.isStale(indexData: existing, dbPath: dbPath) { - indexData = existing - } else { - let allRows = rowStore.loadAllRows(in: dbPath, schema: schema) - let rebuilt = indexManager.rebuild(dbPath: dbPath, schema: schema, rows: allRows) - try indexManager.saveIndex(rebuilt, at: dbPath) - indexData = rebuilt + return existing } - guard let rowsMap = indexData["rows"] as? [String: [String: Any]] else { - try outputJSON(["rows": [] as [Any], "total_count": 0, "has_more": false]) - return - } + let allRows = rowStore.loadAllRows(in: dbPath, schema: schema) + let rebuilt = indexManager.rebuild(dbPath: dbPath, schema: schema, rows: allRows) + try indexManager.saveIndex(rebuilt, at: dbPath) + return rebuilt + } - // Apply filters in-memory against the index rows map - var matchingIds = Array(rowsMap.keys) + private func applyFiltersAndSorts( + filters: [Filter], sorts: [Sort], rowsMap: [String: [String: Any]], schema: DatabaseSchema + ) -> [String] { + var ids = Array(rowsMap.keys) - for f in filters { - matchingIds = matchingIds.filter { rowId in + for activeFilter in filters { + ids = ids.filter { rowId in guard let rowData = rowsMap[rowId], let props = rowData["properties"] as? [String: Any] else { return false } - return matchesFilter(f, properties: props, schema: schema) + return matchesFilter(activeFilter, properties: props, schema: schema) } } - // Sort - for s in sorts.reversed() { - matchingIds.sort { id1, id2 in + for sortExpr in sorts.reversed() { + ids.sort { id1, id2 in let props1 = (rowsMap[id1]?["properties"] as? [String: Any]) ?? [:] let props2 = (rowsMap[id2]?["properties"] as? [String: Any]) ?? [:] - let v1 = props1[s.property] - let v2 = props2[s.property] - let result = compareValues(v1, v2) - return s.ascending ? result < 0 : result > 0 + let lhs = props1[sortExpr.property] + let rhs = props2[sortExpr.property] + let result = compareValues(lhs, rhs) + return sortExpr.ascending ? result < 0 : result > 0 } } - let totalCount = matchingIds.count + return ids + } + + private func paginate(_ ids: [String]) -> (ids: [String], totalCount: Int, hasMore: Bool) { + var result = ids + let totalCount = result.count - // Apply offset/limit if let offset = offset, offset > 0 { - matchingIds = Array(matchingIds.dropFirst(offset)) + result = Array(result.dropFirst(offset)) } if let limit = limit, limit > 0 { - matchingIds = Array(matchingIds.prefix(limit)) + result = Array(result.prefix(limit)) } - let hasMore = totalCount > (offset ?? 0) + matchingIds.count + let hasMore = totalCount > (offset ?? 0) + result.count + return (result, totalCount, hasMore) + } - // Build output rows + private func buildOutputRows( + ids: [String], rowsMap: [String: [String: Any]], dbPath: String, + schema: DatabaseSchema, fieldList: [String]? + ) -> [[String: Any]] { + let rowStore = RowStore() var outputRows: [[String: Any]] = [] - for rowId in matchingIds { + + for rowId in ids { guard let rowData = rowsMap[rowId], let props = rowData["properties"] as? [String: Any] else { continue } @@ -123,33 +146,23 @@ struct QueryCmd: ParsableCommand { ] let propertyOutput = presentedQueryProperties( - props, - schema: schema, - fields: fieldList, - includeRawProperties: rawProperties + props, schema: schema, fields: fieldList, includeRawProperties: rawProperties ) for (key, value) in propertyOutput { row[key] = value } - if body { - // Load the actual row file to get the body - if let filename = rowData["filename"] as? String { - let filePath = (dbPath as NSString).appendingPathComponent("\(filename).md") - if let dbRow = rowStore.loadRow(at: filePath, schema: schema) { - row["body"] = dbRow.body - } + if body, let filename = rowData["filename"] as? String { + let filePath = (dbPath as NSString).appendingPathComponent("\(filename).md") + if let dbRow = rowStore.loadRow(at: filePath, schema: schema) { + row["body"] = dbRow.body } } outputRows.append(row) } - try outputJSON([ - "rows": outputRows, - "total_count": totalCount, - "has_more": hasMore, - ]) + return outputRows } } @@ -186,36 +199,24 @@ private func matchesFilter(_ filter: Filter, properties: [String: Any], schema: } } +// MARK: - Property Value Comparison + private func comparePropertyValue(_ raw: Any?, to value: PropertyValue) -> Int { guard let raw = raw else { return value == .empty ? 0 : -1 } switch value { - case .text(let s): - if let rawStr = raw as? String { return rawStr.compare(s).rawValue } - case .number(let n): - if let rawNum = raw as? Double { - if rawNum < n { return -1 } - if rawNum > n { return 1 } - return 0 - } - if let rawInt = raw as? Int { - let d = Double(rawInt) - if d < n { return -1 } - if d > n { return 1 } - return 0 - } - case .select(let s): - if let rawStr = raw as? String { return rawStr == s ? 0 : (rawStr < s ? -1 : 1) } - case .date(let s): - if let rawStr = raw as? String { - let rawKey = DatabaseDateValue.decode(from: rawStr)?.sortKey ?? rawStr - let compareKey = DatabaseDateValue.decode(from: s)?.sortKey ?? s - return rawKey.compare(compareKey).rawValue - } - case .checkbox(let b): - if let rawBool = raw as? Bool { return rawBool == b ? 0 : -1 } - case .relation(let s): - if let rawStr = raw as? String { return rawStr == s ? 0 : -1 } + case .text(let text): + return compareRawToString(raw, expected: text) + case .number(let num): + return compareRawToNumber(raw, expected: num) + case .select(let sel): + return compareRawToString(raw, expected: sel) + case .date(let dateStr): + return compareRawToDate(raw, expected: dateStr) + case .checkbox(let flag): + if let rawBool = raw as? Bool { return rawBool == flag ? 0 : -1 } + case .relation(let rel): + if let rawStr = raw as? String { return rawStr == rel ? 0 : -1 } case .empty: return isPropertyEmpty(raw) ? 0 : 1 default: @@ -224,6 +225,33 @@ private func comparePropertyValue(_ raw: Any?, to value: PropertyValue) -> Int { return -1 } +private func compareRawToString(_ raw: Any, expected: String) -> Int { + guard let rawStr = raw as? String else { return -1 } + return rawStr.compare(expected).rawValue +} + +private func compareRawToNumber(_ raw: Any, expected: Double) -> Int { + if let rawNum = raw as? Double { + if rawNum < expected { return -1 } + if rawNum > expected { return 1 } + return 0 + } + if let rawInt = raw as? Int { + let asDouble = Double(rawInt) + if asDouble < expected { return -1 } + if asDouble > expected { return 1 } + return 0 + } + return -1 +} + +private func compareRawToDate(_ raw: Any, expected: String) -> Int { + guard let rawStr = raw as? String else { return -1 } + let rawKey = DatabaseDateValue.decode(from: rawStr)?.sortKey ?? rawStr + let compareKey = DatabaseDateValue.decode(from: expected)?.sortKey ?? expected + return rawKey.compare(compareKey).rawValue +} + private func propertyContains(_ raw: Any?, value: PropertyValue) -> Bool { guard let raw = raw else { return false } let searchStr = value.stringValue @@ -245,27 +273,37 @@ private func isPropertyEmpty(_ raw: Any?) -> Bool { return false } -private func compareValues(_ v1: Any?, _ v2: Any?) -> Int { - if v1 == nil && v2 == nil { return 0 } - if v1 == nil { return -1 } - if v2 == nil { return 1 } +// MARK: - Generic Value Comparison - if let s1 = v1 as? String, let s2 = v2 as? String { - return s1.compare(s2).rawValue +private func compareValues(_ lhs: Any?, _ rhs: Any?) -> Int { + if lhs == nil && rhs == nil { return 0 } + if lhs == nil { return -1 } + if rhs == nil { return 1 } + + if let lhsStr = lhs as? String, let rhsStr = rhs as? String { + return lhsStr.compare(rhsStr).rawValue } - if let n1 = v1 as? Double, let n2 = v2 as? Double { - if n1 < n2 { return -1 } - if n1 > n2 { return 1 } - return 0 + if let lhsDbl = lhs as? Double, let rhsDbl = rhs as? Double { + return compareDoubles(lhsDbl, rhsDbl) } - if let n1 = v1 as? Int, let n2 = v2 as? Int { - if n1 < n2 { return -1 } - if n1 > n2 { return 1 } - return 0 + if let lhsInt = lhs as? Int, let rhsInt = rhs as? Int { + return compareInts(lhsInt, rhsInt) } - if let b1 = v1 as? Bool, let b2 = v2 as? Bool { - if b1 == b2 { return 0 } - return b1 ? 1 : -1 + if let lhsBool = lhs as? Bool, let rhsBool = rhs as? Bool { + if lhsBool == rhsBool { return 0 } + return lhsBool ? 1 : -1 } return 0 } + +private func compareDoubles(_ lhs: Double, _ rhs: Double) -> Int { + if lhs < rhs { return -1 } + if lhs > rhs { return 1 } + return 0 +} + +private func compareInts(_ lhs: Int, _ rhs: Int) -> Int { + if lhs < rhs { return -1 } + if lhs > rhs { return 1 } + return 0 +} diff --git a/Sources/BugbookCLI/Commands/SearchCommand.swift b/Sources/BugbookCLI/Commands/SearchCommand.swift index 025598c..4387cca 100644 --- a/Sources/BugbookCLI/Commands/SearchCommand.swift +++ b/Sources/BugbookCLI/Commands/SearchCommand.swift @@ -25,6 +25,9 @@ struct Search: ParsableCommand { @Option(name: .long, help: "Search mode: bm25 (keyword, default), semantic, hybrid") var mode: String = "bm25" + @Option(name: .long, help: "Minimum relevance score (0.0-1.0) to include in results") + var minScore: Double? + @Option(name: .long, help: "Force search engine: qmd (default) or local") var engine: String = "qmd" @@ -40,7 +43,7 @@ struct Search: ParsableCommand { backend.ensureCollection() let results = try backend.search( query: query, limit: limit, mode: mode, - filesOnly: filesOnly, tag: tag + filesOnly: filesOnly, tag: tag, minScore: minScore ) if !results.isEmpty { try outputJSON([ @@ -76,11 +79,12 @@ private struct QmdBackend { return name.isEmpty ? "bugbook" : name } - /// Locate the qmd binary, checking PATH and common install locations. + /// Locate the qmd binary, checking PATH (via login shell) and common install locations. static func find() -> String? { + // Login shell lookup — respects nvm, bun, npm global configs let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = ["which", "qmd"] + task.executableURL = URL(fileURLWithPath: "/bin/zsh") + task.arguments = ["-l", "-c", "which qmd"] let pipe = Pipe() task.standardOutput = pipe task.standardError = FileHandle.nullDevice @@ -105,8 +109,7 @@ private struct QmdBackend { return nil } - /// Register workspace as a qmd collection and build the FTS index. - /// collection add only registers the path; update actually indexes the files. + /// Register workspace as a qmd collection, build the FTS index, and register context metadata. func ensureCollection() { func run(_ args: [String]) { let task = Process() @@ -117,21 +120,59 @@ private struct QmdBackend { try? task.run() task.waitUntilExit() } - run(["collection", "add", workspace, "--name", collectionName]) + // v2: collection name derived from directory, no --name flag + run(["collection", "add", workspace]) run(["update"]) + ensureContextRegistered(run: run) + } + + /// Register context only when the set of databases has changed since last registration. + private func ensureContextRegistered(run: ([String]) -> Void) { + let store = DatabaseStore() + let databases = store.listDatabases(in: workspace) + let currentKey = databases.map(\.name).sorted().joined(separator: ",") + + let markerPath = (workspace as NSString).appendingPathComponent(".qmd-context-marker") + if let existing = try? String(contentsOfFile: markerPath, encoding: .utf8), existing == currentKey { + return + } + + run(["context", "add", "qmd://\(collectionName)", + "Bugbook personal knowledge base — pages, databases, and meeting notes"]) + for db in databases { + let relativePath = relativeToWorkspace(db.path) + if let schema = try? store.loadSchema(at: db.path) { + let propNames = schema.properties.map(\.name).joined(separator: ", ") + run(["context", "add", "qmd://\(collectionName)/\(relativePath)", + "\(db.name) database — \(propNames)"]) + } + } + + try? currentKey.write(toFile: markerPath, atomically: true, encoding: .utf8) + } + + private func relativeToWorkspace(_ path: String) -> String { + guard path.hasPrefix(workspace) else { return path } + return String(path.dropFirst(workspace.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) } - func search(query: String, limit: Int, mode: String, filesOnly: Bool, tag: String?) throws -> [[String: Any]] { - var results: [[String: Any]] - switch mode { - case "semantic": - results = try runCLISearch(tool: "vsearch", query: query, limit: limit) - case "hybrid": - results = try runHybridSearch(query: query, limit: limit) - default: // bm25 - results = try runCLISearch(tool: "search", query: query, limit: limit) + func search(query: String, limit: Int, mode: String, filesOnly: Bool, tag: String?, minScore: Double? = nil) throws -> [[String: Any]] { + let tool = mode == "semantic" ? "vsearch" : mode == "hybrid" ? "query" : "search" + + if filesOnly { + if let fileResults = try? runCLIFilesSearch(tool: tool, query: query, limit: limit, minScore: minScore) { + var filtered = fileResults.filter { result in + guard let file = result["file"] as? String else { return false } + return !WorkspacePathRules.shouldIgnoreRelativePath(file) + } + if let tag = tag { filtered = applyTagFilter(filtered, tag: tag) } + return Array(filtered.prefix(limit)) + } + fputs("Warning: qmd --files failed, falling back to --json with dedup\n", stderr) } + var results = try runCLISearch(tool: tool, query: query, limit: limit, minScore: minScore) + if let tag = tag { results = applyTagFilter(results, tag: tag) } @@ -152,135 +193,52 @@ private struct QmdBackend { return Array(results.prefix(limit)) } - // MARK: CLI search (bm25 / semantic) + // MARK: CLI search - private func runCLISearch(tool: String, query: String, limit: Int) throws -> [[String: Any]] { + /// Run a qmd search command and return raw stdout data. + private func runQmd(tool: String, query: String, limit: Int, minScore: Double? = nil, extraFlags: [String] = []) throws -> Data { let task = Process() task.executableURL = URL(fileURLWithPath: binary) - task.arguments = [tool, query, "--json", "-n", "\(limit)", "-c", collectionName] + var args = [tool, query] + extraFlags + ["-n", "\(limit)", "-c", collectionName] + if let minScore = minScore { args += ["--min-score", "\(minScore)"] } + task.arguments = args let pipe = Pipe() task.standardOutput = pipe task.standardError = FileHandle.nullDevice try task.run() task.waitUntilExit() - guard task.terminationStatus == 0 else { throw CLIError.invalidInput("qmd \(tool) exited \(task.terminationStatus)") } - return parseQmdData(pipe.fileHandleForReading.readDataToEndOfFile()) - } - - // MARK: Hybrid search via HTTP daemon - - private func runHybridSearch(query: String, limit: Int) throws -> [[String: Any]] { - try ensureDaemon() - return try callMCPQuery(query: query, limit: limit) - } - - private func ensureDaemon() throws { - if isDaemonHealthy() { return } - - let task = Process() - task.executableURL = URL(fileURLWithPath: binary) - task.arguments = ["mcp", "--http", "--daemon"] - task.standardOutput = FileHandle.nullDevice - task.standardError = FileHandle.nullDevice - try task.run() - - fputs("Starting qmd daemon (loading models, up to 120s)…\n", stderr) - let deadline = Date().addingTimeInterval(120) - while Date() < deadline { - Thread.sleep(forTimeInterval: 2) - if isDaemonHealthy() { return } - } - throw CLIError.invalidInput("qmd daemon did not start within 120s") + return pipe.fileHandleForReading.readDataToEndOfFile() } - private func isDaemonHealthy() -> Bool { - guard let url = URL(string: "http://localhost:8181/health") else { return false } - var req = URLRequest(url: url, timeoutInterval: 2) - req.httpMethod = "GET" - var healthy = false - let sema = DispatchSemaphore(value: 0) - URLSession.shared.dataTask(with: req) { _, resp, _ in - if let http = resp as? HTTPURLResponse, http.statusCode == 200 { healthy = true } - sema.signal() - }.resume() - sema.wait() - return healthy + private func runCLISearch(tool: String, query: String, limit: Int, minScore: Double? = nil) throws -> [[String: Any]] { + let data = try runQmd(tool: tool, query: query, limit: limit, minScore: minScore, extraFlags: ["--json"]) + return parseQmdData(data) } - /// Call the MCP `query` tool with lex + vec + hyde sub-searches for full hybrid. - private func callMCPQuery(query: String, limit: Int) throws -> [[String: Any]] { - guard let url = URL(string: "http://localhost:8181/mcp") else { - throw CLIError.invalidInput("invalid qmd daemon URL") - } - var req = URLRequest(url: url, timeoutInterval: 120) - req.httpMethod = "POST" - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue("application/json", forHTTPHeaderField: "Accept") - - let body: [String: Any] = [ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": [ - "name": "query", - "arguments": [ - "searches": [ - ["type": "lex", "query": query], - ["type": "vec", "query": query], - ["type": "hyde", "query": query], - ], - "limit": limit, - "collections": [collectionName], - ] as [String: Any], - ] as [String: Any], - ] - req.httpBody = try JSONSerialization.data(withJSONObject: body) - - var responseData: Data? - var responseError: Error? - let sema = DispatchSemaphore(value: 0) - URLSession.shared.dataTask(with: req) { data, _, error in - responseData = data; responseError = error - sema.signal() - }.resume() - sema.wait() - - if let error = responseError { throw error } - guard let data = responseData else { return [] } - return parseMCPResponse(data) + /// Run qmd with --files flag for file-only results. + private func runCLIFilesSearch(tool: String, query: String, limit: Int, minScore: Double? = nil) throws -> [[String: Any]] { + let data = try runQmd(tool: tool, query: query, limit: limit, minScore: minScore, extraFlags: ["--files"]) + // --files returns CSV lines: #docid,score,qmd://collection/path,"context" + let output = String(data: data, encoding: .utf8) ?? "" + var seen = Set() + return output.components(separatedBy: "\n") + .compactMap { line -> [String: Any]? in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + guard let qmdRange = trimmed.range(of: "qmd://") else { return nil } + let afterQmd = trimmed[qmdRange.lowerBound...] + let pathEnd = afterQmd.firstIndex(of: ",") ?? afterQmd.endIndex + let rel = normalizedRelativePath(String(afterQmd[.. [[String: Any]] { - guard - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let result = json["result"] as? [String: Any], - let content = result["content"] as? [[String: Any]] - else { return [] } - - // Look for structuredContent first, then fall back to parsing the text blob - if let structured = result["structuredContent"] as? [String: Any], - let raw = structured["results"] as? [[String: Any]] { - return raw.compactMap { mapResult($0) } - } - - // Find the JSON content block (type == "text" containing a JSON object) - for block in content { - guard block["type"] as? String == "text", - let text = block["text"] as? String, - let textData = text.data(using: .utf8), - let parsed = try? JSONSerialization.jsonObject(with: textData) as? [String: Any], - let raw = parsed["results"] as? [[String: Any]] - else { continue } - return raw.compactMap { mapResult($0) } - } - return [] - } - // qmd CLI outputs a JSON array; MCP response wraps in {"results": [...]} private func parseQmdData(_ data: Data) -> [[String: Any]] { let parsed = try? JSONSerialization.jsonObject(with: data) diff --git a/Sources/BugbookCLI/NoteHelpers.swift b/Sources/BugbookCLI/NoteHelpers.swift index d02beb1..4e25033 100644 --- a/Sources/BugbookCLI/NoteHelpers.swift +++ b/Sources/BugbookCLI/NoteHelpers.swift @@ -1071,7 +1071,7 @@ private func excludedContentDirectories(in workspace: String, fileManager: FileM while let relativePath = enumerator.nextObject() as? String { if WorkspacePathRules.shouldIgnoreRelativePath(relativePath) { continue } let filename = (relativePath as NSString).lastPathComponent - if filename == "_schema.json" || filename == "_canvas.json" { + if filename == "_schema.json" { excluded.insert((relativePath as NSString).deletingLastPathComponent) } } diff --git a/Sources/BugbookCLI/PageBlockHelpers.swift b/Sources/BugbookCLI/PageBlockHelpers.swift index ed45479..3372fd1 100644 --- a/Sources/BugbookCLI/PageBlockHelpers.swift +++ b/Sources/BugbookCLI/PageBlockHelpers.swift @@ -91,6 +91,7 @@ private enum ParsedPageBlockType: String { case pageLink = "page_link" case column case toggle + case meeting } private struct ParsedPageBlock { @@ -113,6 +114,7 @@ private struct ParsedPageBlock { var children: [ParsedPageBlock] = [] var columnIndex: Int = 0 var isExpanded: Bool = true + var meetingNotes: String = "" } private struct ParsedPageDocumentMetadata { @@ -378,6 +380,42 @@ private enum PageBlockParser { continue } + if trimmed == "" { + index += 1 + var transcript = "" + var notes = "" + var section = "transcript" + while index < lines.count { + let meetLine = lines[index].trimmingCharacters(in: .whitespaces) + if meetLine == "" { + index += 1 + break + } + if meetLine == "" { + section = "notes" + index += 1 + continue + } + if meetLine == "" { + section = "transcript" + index += 1 + continue + } + if section == "notes" { + if !notes.isEmpty { notes += "\n" } + notes += lines[index] + } else { + if !transcript.isEmpty { transcript += "\n" } + transcript += lines[index] + } + index += 1 + } + var block = makeBlock(type: .meeting, text: transcript) + block.meetingNotes = notes + blocks.append(block) + continue + } + if trimmed == "" || trimmed == "" { let collapsed = trimmed.contains("collapsed") index += 1 @@ -600,7 +638,7 @@ private enum PageBlockParser { } let hasColor = block.textColor != nil || block.backgroundColor != nil - if hasColor, style == .bugbook, block.type != .column, block.type != .toggle { + if hasColor, style == .bugbook, block.type != .column, block.type != .toggle, block.type != .meeting { var parts: [String] = [] if let textColor = block.textColor, !textColor.isEmpty { parts.append("color:\(textColor)") @@ -703,6 +741,18 @@ private enum PageBlockParser { lines.append("
") } + case .meeting: + lines.append("") + if !block.meetingNotes.isEmpty { + lines.append("") + lines.append(block.meetingNotes) + } + if !block.text.isEmpty { + lines.append("") + lines.append(block.text) + } + lines.append("") + case .column: let maxColumn = block.children.map(\.columnIndex).max() ?? 0 switch style { @@ -742,6 +792,7 @@ private enum PageBlockParser { emittedColumn = true } } + } return lines @@ -846,6 +897,10 @@ private enum PageBlockParser { || trimmed == "" || trimmed == "" || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" } private static func parseHeading(_ line: String) -> (Int, String)? { @@ -1821,7 +1876,7 @@ private func parsedPageBlockSupportsTextMutation(_ block: ParsedPageBlock) -> Bo switch block.type { case .paragraph, .heading, .bulletListItem, .numberedListItem, .taskItem, .codeBlock, .blockquote, .toggle: return true - case .horizontalRule, .image, .databaseEmbed, .pageLink, .column: + case .horizontalRule, .image, .databaseEmbed, .pageLink, .column, .meeting: return false } } @@ -2005,6 +2060,9 @@ private func parsedPageBlockJSON( if block.type == .toggle { json["expanded"] = block.isExpanded } + if !block.meetingNotes.isEmpty { + json["meeting_notes"] = block.meetingNotes + } if !block.children.isEmpty { json["children"] = block.children.enumerated().map { offset, child in parsedPageBlockJSON( diff --git a/Sources/BugbookCore/Engine/QueryEngine.swift b/Sources/BugbookCore/Engine/QueryEngine.swift index 4a82217..8da623e 100644 --- a/Sources/BugbookCore/Engine/QueryEngine.swift +++ b/Sources/BugbookCore/Engine/QueryEngine.swift @@ -4,15 +4,24 @@ public struct QueryEngine { /// Execute a query against a set of rows: filter, sort, paginate. public static func execute(query: Query, schema: DatabaseSchema, rows: [DatabaseRow]) -> QueryResult { - // 1. Apply all filters (ANDed) - var filtered = rows - for filter in query.filters { - filtered = filtered.filter { row in matches(row: row, filter: filter) } + // 1. Apply all filters in a single pass (ANDed) + let filtered: [DatabaseRow] + if query.filters.isEmpty { + filtered = rows + } else { + let filters = query.filters + filtered = rows.filter { row in + for filter in filters { + if !matches(row: row, filter: filter) { return false } + } + return true + } } + var sorted = filtered // 2. Sort if !query.sorts.isEmpty { - filtered.sort { a, b in + sorted.sort { a, b in for sort in query.sorts { let aVal = a.properties[sort.property] let bVal = b.properties[sort.property] @@ -26,15 +35,15 @@ public struct QueryEngine { } // 3. Total count before pagination - let totalCount = filtered.count + let totalCount = sorted.count // 4. Apply offset/limit let offset = query.offset ?? 0 if offset > 0 { - filtered = Array(filtered.dropFirst(offset)) + sorted = Array(sorted.dropFirst(offset)) } if let limit = query.limit { - filtered = Array(filtered.prefix(limit)) + sorted = Array(sorted.prefix(limit)) } let hasMore: Bool @@ -44,7 +53,7 @@ public struct QueryEngine { hasMore = false } - return QueryResult(rows: filtered, totalCount: totalCount, hasMore: hasMore) + return QueryResult(rows: sorted, totalCount: totalCount, hasMore: hasMore) } // MARK: - Filter Matching diff --git a/Sources/BugbookCore/Model/Schema.swift b/Sources/BugbookCore/Model/Schema.swift index bed87f8..163190f 100644 --- a/Sources/BugbookCore/Model/Schema.swift +++ b/Sources/BugbookCore/Model/Schema.swift @@ -85,6 +85,29 @@ public struct PropertyDefinition: Identifiable, Codable, Sendable { } } +// MARK: - Database Template + +public struct DatabaseTemplate: Identifiable, Codable, Sendable { + public let id: String + public var name: String + public var icon: String + public var defaultProperties: [String: PropertyValue] + public var body: String + + public init(id: String, name: String, icon: String = "doc.text", defaultProperties: [String: PropertyValue] = [:], body: String = "") { + self.id = id + self.name = name + self.icon = icon + self.defaultProperties = defaultProperties + self.body = body + } + + enum CodingKeys: String, CodingKey { + case id, name, icon, body + case defaultProperties = "default_properties" + } +} + // MARK: - Database Schema public struct DatabaseSchema: Codable, Identifiable, Sendable { @@ -95,9 +118,11 @@ public struct DatabaseSchema: Codable, Identifiable, Sendable { public var views: [ViewConfig] public var defaultView: String public var createdAt: String + public var templates: [DatabaseTemplate]? public init(id: String, name: String, version: Int = 1, properties: [PropertyDefinition], - views: [ViewConfig], defaultView: String, createdAt: String) { + views: [ViewConfig], defaultView: String, createdAt: String, + templates: [DatabaseTemplate]? = nil) { self.id = id self.name = name self.version = version @@ -105,10 +130,11 @@ public struct DatabaseSchema: Codable, Identifiable, Sendable { self.views = views self.defaultView = defaultView self.createdAt = createdAt + self.templates = templates } enum CodingKeys: String, CodingKey { - case id, name, version, properties, views + case id, name, version, properties, views, templates case defaultView = "default_view" case createdAt = "created_at" } diff --git a/Sources/BugbookCore/Model/View.swift b/Sources/BugbookCore/Model/View.swift index 62f8971..ca2d6ce 100644 --- a/Sources/BugbookCore/Model/View.swift +++ b/Sources/BugbookCore/Model/View.swift @@ -64,12 +64,13 @@ public struct ViewConfig: Identifiable, Codable, Sendable { public var groupBy: String? public var dateProperty: String? public var manualRowOrder: [String]? + public var subGroupBy: String? public init(id: String, name: String, type: ViewType, sorts: [SortConfig] = [], filters: [FilterConfig] = [], columnWidths: [String: Double]? = nil, hiddenColumns: [String]? = nil, wrapCellText: Bool? = nil, groupBy: String? = nil, dateProperty: String? = nil, - manualRowOrder: [String]? = nil) { + manualRowOrder: [String]? = nil, subGroupBy: String? = nil) { self.id = id self.name = name self.type = type @@ -81,6 +82,7 @@ public struct ViewConfig: Identifiable, Codable, Sendable { self.groupBy = groupBy self.dateProperty = dateProperty self.manualRowOrder = manualRowOrder + self.subGroupBy = subGroupBy } enum CodingKeys: String, CodingKey { @@ -91,5 +93,6 @@ public struct ViewConfig: Identifiable, Codable, Sendable { case groupBy = "group_by" case dateProperty = "date_property" case manualRowOrder = "manual_row_order" + case subGroupBy = "sub_group_by" } } diff --git a/Sources/BugbookCore/Storage/IndexManager.swift b/Sources/BugbookCore/Storage/IndexManager.swift index 84b0edc..e3e643d 100644 --- a/Sources/BugbookCore/Storage/IndexManager.swift +++ b/Sources/BugbookCore/Storage/IndexManager.swift @@ -2,6 +2,11 @@ import Foundation public class IndexManager { private let fm = FileManager.default + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() public init() {} @@ -47,33 +52,7 @@ public class IndexManager { public func rebuild(dbPath: String, schema: DatabaseSchema, rows: [DatabaseRow]) -> [String: Any] { var rowsMap: [String: Any] = [:] for row in rows { - let title = row.title(schema: schema) - let suffix = RowStore.extractIdSuffix(from: row.id) - - var props: [String: Any] = [:] - for prop in schema.properties { - if let val = row.properties[prop.id] { - props[prop.id] = RowSerializer.serializeValueForIndex(val) - } - } - - let filename = RowStore.rowFilename(title: title, suffix: suffix).replacingOccurrences(of: ".md", with: "") - let filePath = (dbPath as NSString).appendingPathComponent("\(filename).md") - let mtime: Int - if let attrs = try? fm.attributesOfItem(atPath: filePath), - let modDate = attrs[.modificationDate] as? Date { - mtime = Int(modDate.timeIntervalSince1970 * 1000) - } else { - mtime = Int(row.updatedAt.timeIntervalSince1970 * 1000) - } - - rowsMap[row.id] = [ - "properties": props, - "created_at": iso8601String(from: row.createdAt), - "updated_at": iso8601String(from: row.updatedAt), - "filename": filename, - "mtime": mtime - ] as [String: Any] + rowsMap[row.id] = buildRowEntry(row: row, schema: schema, dbPath: dbPath) } // Build reverse indexes @@ -107,17 +86,46 @@ public class IndexManager { } } - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return [ "version": 1, - "updated_at": formatter.string(from: Date()), + "updated_at": Self.isoFormatter.string(from: Date()), "rows": rowsMap, "indexes": indexes ] } + // MARK: - Single Row Entry + + /// Build the index entry dictionary for a single row (used by incremental updates). + public func buildRowEntry(row: DatabaseRow, schema: DatabaseSchema, dbPath: String) -> [String: Any] { + var props: [String: Any] = [:] + for prop in schema.properties { + if let val = row.properties[prop.id] { + props[prop.id] = RowSerializer.serializeValueForIndex(val) + } + } + + let title = row.title(schema: schema) + let suffix = RowStore.extractIdSuffix(from: row.id) + let filename = RowStore.rowFilename(title: title, suffix: suffix).replacingOccurrences(of: ".md", with: "") + let filePath = (dbPath as NSString).appendingPathComponent("\(filename).md") + let mtime: Int + if let attrs = try? fm.attributesOfItem(atPath: filePath), + let modDate = attrs[.modificationDate] as? Date { + mtime = Int(modDate.timeIntervalSince1970 * 1000) + } else { + mtime = Int(row.updatedAt.timeIntervalSince1970 * 1000) + } + + return [ + "properties": props, + "created_at": iso8601String(from: row.createdAt), + "updated_at": iso8601String(from: row.updatedAt), + "filename": filename, + "mtime": mtime + ] + } + // MARK: - Save public func saveIndex(_ index: [String: Any], at dbPath: String) throws { @@ -129,8 +137,6 @@ public class IndexManager { // MARK: - Private private func iso8601String(from date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return formatter.string(from: date) + Self.isoFormatter.string(from: date) } } diff --git a/Sources/BugbookCore/Storage/RowSerializer.swift b/Sources/BugbookCore/Storage/RowSerializer.swift index 794d775..02d22fb 100644 --- a/Sources/BugbookCore/Storage/RowSerializer.swift +++ b/Sources/BugbookCore/Storage/RowSerializer.swift @@ -2,13 +2,36 @@ import Foundation public struct RowSerializer { + // MARK: - Cached Formatters + + private static let sharedISOFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let sharedDateOnlyFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = TimeZone(identifier: "UTC") + return f + }() + // MARK: - Serialize public static func serialize(row: DatabaseRow, schema: DatabaseSchema) -> String { - var fm = "---\n" - fm += "id: \(row.id)\n" - fm += "created_at: \(iso8601String(from: row.createdAt))\n" - fm += "updated_at: \(iso8601String(from: row.updatedAt))\n" + // Estimate capacity: ~80 bytes header + ~40 bytes per property + body + let estimatedSize = 80 + row.properties.count * 40 + row.body.count + var fm = String() + fm.reserveCapacity(estimatedSize) + + fm += "---\nid: " + fm += row.id + fm += "\ncreated_at: " + fm += sharedISOFormatter.string(from: row.createdAt) + fm += "\nupdated_at: " + fm += sharedISOFormatter.string(from: row.updatedAt) + fm += "\n" if !row.properties.isEmpty { fm += "properties:\n" @@ -16,7 +39,11 @@ public struct RowSerializer { if let value = row.properties[prop.id] { let s = serializeValue(value) if !s.isEmpty { - fm += " \(prop.id): \(s)\n" + fm += " " + fm += prop.id + fm += ": " + fm += s + fm += "\n" } } } @@ -27,7 +54,8 @@ public struct RowSerializer { if row.body.isEmpty { fm += "\n" } else { - fm += "\n\(row.body)" + fm += "\n" + fm += row.body } return fm @@ -51,7 +79,7 @@ public struct RowSerializer { let afterMarker = content.index(content.startIndex, offsetBy: 3) guard let endRange = content.range(of: "\n---", range: afterMarker.. String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return formatter.string(from: date) + /// Fast manual ISO 8601 date parser. Falls back to cached formatters. + /// Expected format: "2024-01-15T09:30:00Z" (20 chars minimum) + private static func parseISO8601Date(_ s: String) -> Date { + // Fast path: try manual parsing for standard ISO 8601 format + if s.count >= 20, s.hasSuffix("Z") { + let chars = Array(s.utf8) + // yyyy-MM-ddTHH:mm:ssZ + if chars.count >= 20, + chars[4] == UInt8(ascii: "-"), chars[7] == UInt8(ascii: "-"), + chars[10] == UInt8(ascii: "T"), chars[13] == UInt8(ascii: ":"), + chars[16] == UInt8(ascii: ":") { + let year = asciiDigits4(chars, at: 0) + let month = asciiDigits2(chars, at: 5) + let day = asciiDigits2(chars, at: 8) + let hour = asciiDigits2(chars, at: 11) + let minute = asciiDigits2(chars, at: 14) + let second = asciiDigits2(chars, at: 17) + + if let year, let month, let day, let hour, let minute, let second, + month >= 1, month <= 12, day >= 1, day <= 31, + hour >= 0, hour <= 23, minute >= 0, minute <= 59, second >= 0, second <= 59 { + var comps = DateComponents() + comps.year = year + comps.month = month + comps.day = day + comps.hour = hour + comps.minute = minute + comps.second = second + comps.timeZone = TimeZone(identifier: "UTC") + if let date = utcCalendar.date(from: comps) { + return date + } + } + } + } + // Slow fallback: use cached formatters + return sharedISOFormatter.date(from: s) ?? sharedDateOnlyFormatter.date(from: s) ?? Date() + } + + private static let utcCalendar: Calendar = { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + return cal + }() + + @inline(__always) + private static func asciiDigits2(_ bytes: [UInt8], at offset: Int) -> Int? { + let d0 = Int(bytes[offset]) - 48 + let d1 = Int(bytes[offset + 1]) - 48 + guard d0 >= 0, d0 <= 9, d1 >= 0, d1 <= 9 else { return nil } + return d0 * 10 + d1 + } + + @inline(__always) + private static func asciiDigits4(_ bytes: [UInt8], at offset: Int) -> Int? { + let d0 = Int(bytes[offset]) - 48 + let d1 = Int(bytes[offset + 1]) - 48 + let d2 = Int(bytes[offset + 2]) - 48 + let d3 = Int(bytes[offset + 3]) - 48 + guard d0 >= 0, d0 <= 9, d1 >= 0, d1 <= 9, + d2 >= 0, d2 <= 9, d3 >= 0, d3 <= 9 else { return nil } + return d0 * 1000 + d1 * 100 + d2 * 10 + d3 } } diff --git a/Sources/BugbookCore/Storage/RowStore.swift b/Sources/BugbookCore/Storage/RowStore.swift index ca315b8..fcf1f7a 100644 --- a/Sources/BugbookCore/Storage/RowStore.swift +++ b/Sources/BugbookCore/Storage/RowStore.swift @@ -15,10 +15,23 @@ public class RowStore { } public static func rowFilename(title: String, suffix: String) -> String { - let sanitized = title - .replacingOccurrences(of: "[/\\\\?%*:|\"<>]", with: "-", options: .regularExpression) - .prefix(80) - return "\(sanitized) (\(suffix)).md" + var sanitized = String() + sanitized.reserveCapacity(min(title.count, 80) + suffix.count + 6) + var count = 0 + for c in title { + guard count < 80 else { break } + switch c { + case "/", "\\", "?", "%", "*", ":", "|", "\"", "<", ">": + sanitized.append("-") + default: + sanitized.append(c) + } + count += 1 + } + sanitized.append(" (") + sanitized.append(suffix) + sanitized.append(").md") + return sanitized } public static func extractIdSuffix(from rowId: String) -> String { @@ -36,14 +49,17 @@ public class RowStore { } public func loadAllRows(in dbPath: String, schema: DatabaseSchema, skipBody: Bool = false) -> [DatabaseRow] { + let start = CFAbsoluteTimeGetCurrent() guard let contents = try? fm.contentsOfDirectory(atPath: dbPath) else { return [] } + let mdFiles = contents.filter { $0.hasSuffix(".md") && !$0.hasPrefix("_") } + // Track best row per ID to detect and clean up duplicates. var bestByID: [String: (row: DatabaseRow, filename: String)] = [:] + bestByID.reserveCapacity(mdFiles.count) var duplicateFiles: [String] = [] - for name in contents { - guard name.hasSuffix(".md"), !name.hasPrefix("_") else { continue } + for name in mdFiles { let filePath = (dbPath as NSString).appendingPathComponent(name) guard let row = loadRow(at: filePath, schema: schema, skipBody: skipBody) else { continue } @@ -78,7 +94,67 @@ public class RowStore { try? fm.removeItem(atPath: filePath) } - return bestByID.values.map(\.row).sorted { $0.createdAt < $1.createdAt } + let rows = bestByID.values.map(\.row).sorted { $0.createdAt < $1.createdAt } + let elapsed = (CFAbsoluteTimeGetCurrent() - start) * 1000 + if elapsed > 100 { + print("[RowStore] loadAllRows: \(rows.count) rows in \(Int(elapsed))ms") + } + return rows + } + + /// Result of a detailed row load that includes raw property strings for legacy repair. + public struct DetailedLoadResult { + public let row: DatabaseRow + public let filename: String + public let rawProperties: [String: String] + } + + /// Load all rows with detailed parse results (raw properties for legacy repair). + /// Handles duplicate detection and cleanup just like loadAllRows. + public func loadAllRowsDetailed(in dbPath: String, schema: DatabaseSchema) -> [DetailedLoadResult] { + guard let contents = try? fm.contentsOfDirectory(atPath: dbPath) else { return [] } + + var bestByID: [String: DetailedLoadResult] = [:] + var duplicateFiles: [String] = [] + + for name in contents { + guard name.hasSuffix(".md"), !name.hasPrefix("_") else { continue } + let filePath = (dbPath as NSString).appendingPathComponent(name) + guard let content = try? String(contentsOfFile: filePath, encoding: .utf8) else { continue } + guard let parsed = RowSerializer.parseDetailed(content: content, schema: schema, skipBody: true) else { continue } + + let detail = DetailedLoadResult(row: parsed.row, filename: name, rawProperties: parsed.rawProperties) + let rowId = parsed.row.id + + if let existing = bestByID[rowId] { + let suffix = Self.extractIdSuffix(from: rowId) + let existingIsCanonical = existing.filename.contains("(\(suffix))") + let newIsCanonical = name.contains("(\(suffix))") + + if newIsCanonical && !existingIsCanonical { + duplicateFiles.append(existing.filename) + bestByID[rowId] = detail + } else if !newIsCanonical && existingIsCanonical { + duplicateFiles.append(name) + } else { + if parsed.row.updatedAt > existing.row.updatedAt { + duplicateFiles.append(existing.filename) + bestByID[rowId] = detail + } else { + duplicateFiles.append(name) + } + } + } else { + bestByID[rowId] = detail + } + } + + for filename in duplicateFiles { + let filePath = (dbPath as NSString).appendingPathComponent(filename) + try? fm.removeItem(atPath: filePath) + } + + return bestByID.values.sorted { $0.row.createdAt < $1.row.createdAt } } /// Load just the body content for a row by ID. diff --git a/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift b/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift index 8def133..bade916 100644 --- a/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift +++ b/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift @@ -64,7 +64,36 @@ bugbook backlinks "Bugbook Strategy" bugbook search "local-first agent notes" ``` -`bugbook page get --raw` prints clean markdown by default; add `--include-internal-comments` for the literal stored file. `bugbook page get --blocks` returns parsed markdown blocks plus document metadata. `bugbook page get --block-id` narrows reads to one block by stable UUID or `path:` selector. `bugbook page headings` lists headings with levels and line numbers. `bugbook page format --style bugbook|commonmark` rewrites a page using either Bugbook's dense block format or a CommonMark-style layout with structural blank lines. `bugbook page format --style commonmark` strips persisted block IDs and converts Bugbook-only block syntax into portable approximations: toggles become `
`, columns are flattened sequentially with thematic breaks, database embeds become labeled text, and page-link blocks become relative markdown links when they resolve uniquely in the workspace or plain text when they do not. `bugbook page format --report` adds `warning_count` plus structured `warnings` so agents can see which page links were downgraded during commonmark export. `bugbook page format --fail-on-warnings` turns those portability warnings into a non-zero exit and skips the write, which is useful for agent and CI gating. `bugbook page compact` is the shortcut for `bugbook page format --style bugbook`, removes empty paragraph gaps, and both commands report `empty_paragraphs_removed` in their mutation payloads. `bugbook page ensure-block-ids` persists unique stable block IDs and repairs duplicate persisted IDs, while `bugbook page strip-block-ids` removes those internal comments again. `bugbook page get --section` or `--section-line` narrows reads to a single heading section. `bugbook page update` supports either a full replacement or prepend/append edits per command, `--section` or `--section-line` scopes those edits to a heading body, `--block-id` scopes them to one block without polluting a clean note, `--text-file` preserves the selected block's markdown type, `--create-section` appends a missing section safely, `--dry-run` previews the resulting page plus structured line changes before writing, and `--output summary` returns a compact mutation payload. `bugbook block list`, `block get`, `block replace`, `block update-text`, `block insert`, `block move`, and `block delete` provide a dedicated block-level surface. `bugbook get` and `bugbook query --fields` return friendly property names and display values by default; add `--raw-properties` when you also need schema IDs and stored option IDs. +`bugbook page get --raw` prints clean markdown by default; add \ +`--include-internal-comments` for the literal stored file. \ +`bugbook page get --blocks` returns parsed markdown blocks plus document metadata. \ +`bugbook page get --block-id` narrows reads to one block by stable UUID or `path:` selector. \ +`bugbook page headings` lists headings with levels and line numbers. \ +`bugbook page format --style bugbook|commonmark` rewrites a page using either Bugbook's dense \ +block format or a CommonMark-style layout with structural blank lines. \ +`bugbook page format --style commonmark` strips persisted block IDs and converts Bugbook-only \ +block syntax into portable approximations: toggles become `
`, columns are flattened \ +sequentially with thematic breaks, database embeds become labeled text, and page-link blocks \ +become relative markdown links when they resolve uniquely in the workspace or plain text when \ +they do not. `bugbook page format --report` adds `warning_count` plus structured `warnings` \ +so agents can see which page links were downgraded during commonmark export. \ +`bugbook page format --fail-on-warnings` turns those portability warnings into a non-zero exit \ +and skips the write, which is useful for agent and CI gating. \ +`bugbook page compact` is the shortcut for `bugbook page format --style bugbook`, removes \ +empty paragraph gaps, and both commands report `empty_paragraphs_removed` in their mutation \ +payloads. `bugbook page ensure-block-ids` persists unique stable block IDs and repairs \ +duplicate persisted IDs, while `bugbook page strip-block-ids` removes those internal comments \ +again. `bugbook page get --section` or `--section-line` narrows reads to a single heading \ +section. `bugbook page update` supports either a full replacement or prepend/append edits per \ +command, `--section` or `--section-line` scopes those edits to a heading body, `--block-id` \ +scopes them to one block without polluting a clean note, `--text-file` preserves the selected \ +block's markdown type, `--create-section` appends a missing section safely, `--dry-run` \ +previews the resulting page plus structured line changes before writing, and \ +`--output summary` returns a compact mutation payload. `bugbook block list`, `block get`, \ +`block replace`, `block update-text`, `block insert`, `block move`, and `block delete` \ +provide a dedicated block-level surface. `bugbook get` and `bugbook query --fields` return \ +friendly property names and display values by default; add `--raw-properties` when you also \ +need schema IDs and stored option IDs. ## Boards And Databases ```bash @@ -82,7 +111,11 @@ bugbook db view add "Bugbook Strategy Board" --type calendar --name "Calendar" - bugbook db view set-default "Bugbook Strategy Board" "Calendar" ``` -`bugbook db list` includes `relative_path` and, when applicable, `parent_page` metadata so agents can see where a database actually lives. `bugbook db move --page` reparents a database into a page companion folder, retargets stale embed markers, and supports `--dry-run` so agents can preview the change before writing. You can write rows using either schema IDs or friendly property/option names, but inspect the schema first when you need exact field coverage. +`bugbook db list` includes `relative_path` and, when applicable, `parent_page` metadata so \ +agents can see where a database actually lives. `bugbook db move --page` reparents a database \ +into a page companion folder, retargets stale embed markers, and supports `--dry-run` so \ +agents can preview the change before writing. You can write rows using either schema IDs or \ +friendly property/option names, but inspect the schema first when you need exact field coverage. ## Skills ```bash diff --git a/Sources/BugbookMobile/Models/MobileNoteFile.swift b/Sources/BugbookMobile/Models/MobileNoteFile.swift index 29e6dcd..cf06454 100644 --- a/Sources/BugbookMobile/Models/MobileNoteFile.swift +++ b/Sources/BugbookMobile/Models/MobileNoteFile.swift @@ -6,7 +6,6 @@ struct MobileNoteFile: Identifiable, Hashable { let name: String var isDirectory: Bool = false var isDatabase: Bool = false - var isCanvas: Bool = false var children: [MobileNoteFile]? = nil var icon: String? = nil var modifiedAt: Date? = nil diff --git a/Sources/BugbookMobile/Services/MobileWorkspaceService.swift b/Sources/BugbookMobile/Services/MobileWorkspaceService.swift index 31bc48b..7daa916 100644 --- a/Sources/BugbookMobile/Services/MobileWorkspaceService.swift +++ b/Sources/BugbookMobile/Services/MobileWorkspaceService.swift @@ -9,6 +9,7 @@ import BugbookCore var isICloudAvailable: Bool = false private let fileManager = FileManager.default + private let maxTreeDepth = 10 init() { let path = resolveWorkspacePath() @@ -51,7 +52,7 @@ import BugbookCore } private func buildTree(at path: String, preserveFolders: Bool, depth: Int) -> [MobileNoteFile] { - guard depth < 5 else { return [] } + guard depth < maxTreeDepth else { return [] } guard let contents = try? fileManager.contentsOfDirectory(atPath: path) else { return [] } let siblingNames = Set(contents) @@ -83,19 +84,6 @@ import BugbookCore name: dbName, isDatabase: true )) - } else if isCanvasFolder(at: fullPath) { - var canvasName = name - let metaPath = (fullPath as NSString).appendingPathComponent("_canvas.json") - if let data = try? Data(contentsOf: URL(fileURLWithPath: metaPath)), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let n = json["name"] as? String { - canvasName = n - } - folders.append(MobileNoteFile( - path: fullPath, - name: canvasName, - isCanvas: true - )) } else if isCompanionFolder(name, siblings: siblingNames) { continue } else { @@ -109,7 +97,7 @@ import BugbookCore )) } else { for child in children { - if child.isDatabase || child.isCanvas { + if child.isDatabase { folders.append(child) } else { noteFiles.append(child) @@ -254,11 +242,6 @@ import BugbookCore return fileManager.fileExists(atPath: schemaPath) } - private func isCanvasFolder(at path: String) -> Bool { - let canvasPath = (path as NSString).appendingPathComponent("_canvas.json") - return fileManager.fileExists(atPath: canvasPath) - } - private func isCompanionFolder(_ folderName: String, siblings: Set) -> Bool { siblings.contains("\(folderName).md") } diff --git a/Sources/BugbookMobile/Views/MobileNotesView.swift b/Sources/BugbookMobile/Views/MobileNotesView.swift index 334a186..f74280e 100644 --- a/Sources/BugbookMobile/Views/MobileNotesView.swift +++ b/Sources/BugbookMobile/Views/MobileNotesView.swift @@ -100,7 +100,7 @@ private struct FileTreeRow: View { @State private var isExpanded = false private var hasExpandableChildren: Bool { - if node.isDirectory && !node.isDatabase && !node.isCanvas { return true } + if node.isDirectory && !node.isDatabase { return true } if let children = node.children, !children.isEmpty, !node.isDatabase { return true } return false } @@ -142,10 +142,6 @@ private struct FileTreeRow: View { } label: { fileLabel } - } else if node.isCanvas { - Label(node.name, systemImage: iconName) - .font(.body) - .foregroundStyle(.secondary) } else { NavigationLink { MobilePageEditorView(note: node, workspace: workspace) @@ -179,7 +175,6 @@ private struct FileTreeRow: View { private var iconName: String { if node.isDatabase { return "tablecells" } - if node.isCanvas { return "rectangle.3.group" } if node.isDirectory { return "folder" } return "doc.text" } diff --git a/Tests/BugbookCoreTests/PerformanceTests.swift b/Tests/BugbookCoreTests/PerformanceTests.swift new file mode 100644 index 0000000..4e0fee7 --- /dev/null +++ b/Tests/BugbookCoreTests/PerformanceTests.swift @@ -0,0 +1,262 @@ +import XCTest +@testable import BugbookCore + +final class PerformanceTests: XCTestCase { + + // MARK: - Test Data Helpers + + private func makeSampleSchema(propertyCount: Int = 5) -> DatabaseSchema { + var props: [PropertyDefinition] = [ + PropertyDefinition(id: "prop_title", name: "Title", type: .title), + ] + for i in 1.. DatabaseRow { + var properties: [String: PropertyValue] = [:] + for prop in schema.properties { + switch prop.type { + case .title: + properties[prop.id] = .text("Row Title \(index)") + case .text: + properties[prop.id] = .text("Some text content for row \(index) that is moderately long") + case .select: + properties[prop.id] = .select("option_\(index % 5)") + case .number: + properties[prop.id] = .number(Double(index) * 1.5) + case .checkbox: + properties[prop.id] = .checkbox(index % 2 == 0) + default: + break + } + } + let baseDate = Date(timeIntervalSince1970: 1700000000 + Double(index) * 3600) + return DatabaseRow( + id: "row_\(String(format: "%06d", index))", + properties: properties, + body: "This is the body content for row \(index).\nIt has multiple lines.\nLine 3 here.", + createdAt: baseDate, + updatedAt: baseDate.addingTimeInterval(86400) + ) + } + + private func makeSampleContent(index: Int, schema: DatabaseSchema) -> String { + let row = makeSampleRow(index: index, schema: schema) + return RowSerializer.serialize(row: row, schema: schema) + } + + // MARK: - Row Serialization Performance + + func testSerializePerformance_100rows() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<100).map { makeSampleRow(index: $0, schema: schema) } + + measure { + for row in rows { + _ = RowSerializer.serialize(row: row, schema: schema) + } + } + } + + func testSerializePerformance_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<1000).map { makeSampleRow(index: $0, schema: schema) } + + measure { + for row in rows { + _ = RowSerializer.serialize(row: row, schema: schema) + } + } + } + + // MARK: - Row Parse Performance + + func testParsePerformance_100rows() { + let schema = makeSampleSchema(propertyCount: 8) + let contents = (0..<100).map { makeSampleContent(index: $0, schema: schema) } + + measure { + for content in contents { + _ = RowSerializer.parse(content: content, schema: schema) + } + } + } + + func testParsePerformance_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let contents = (0..<1000).map { makeSampleContent(index: $0, schema: schema) } + + measure { + for content in contents { + _ = RowSerializer.parse(content: content, schema: schema) + } + } + } + + func testParsePerformance_skipBody_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let contents = (0..<1000).map { makeSampleContent(index: $0, schema: schema) } + + measure { + for content in contents { + _ = RowSerializer.parse(content: content, schema: schema, skipBody: true) + } + } + } + + // MARK: - Round Trip Performance + + func testRoundTripPerformance_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<1000).map { makeSampleRow(index: $0, schema: schema) } + + measure { + for row in rows { + let content = RowSerializer.serialize(row: row, schema: schema) + _ = RowSerializer.parse(content: content, schema: schema) + } + } + } + + // MARK: - Query Engine Performance + + func testQueryFilterPerformance_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<1000).map { makeSampleRow(index: $0, schema: schema) } + let query = Query( + databaseId: "db_perf", + filters: [ + .equals(property: "prop_1", value: .select("option_2")), + .isNotEmpty(property: "prop_title"), + ] + ) + + measure { + _ = QueryEngine.execute(query: query, schema: schema, rows: rows) + } + } + + func testQuerySortPerformance_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<1000).map { makeSampleRow(index: $0, schema: schema) } + let query = Query( + databaseId: "db_perf", + sorts: [Sort(property: "prop_2", ascending: false)] + ) + + measure { + _ = QueryEngine.execute(query: query, schema: schema, rows: rows) + } + } + + func testQueryFilterAndSortPerformance_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<1000).map { makeSampleRow(index: $0, schema: schema) } + let query = Query( + databaseId: "db_perf", + filters: [ + .equals(property: "prop_1", value: .select("option_2")), + ], + sorts: [Sort(property: "prop_2", ascending: true)], + limit: 50 + ) + + measure { + _ = QueryEngine.execute(query: query, schema: schema, rows: rows) + } + } + + // MARK: - Index Rebuild Performance + + func testIndexRebuildPerformance_100rows() throws { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<100).map { makeSampleRow(index: $0, schema: schema) } + + let tmpDir = NSTemporaryDirectory() + "bugbook_perf_\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: tmpDir) } + + let indexManager = IndexManager() + + measure { + _ = indexManager.rebuild(dbPath: tmpDir, schema: schema, rows: rows) + } + } + + func testIndexRebuildPerformance_500rows() throws { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<500).map { makeSampleRow(index: $0, schema: schema) } + + let tmpDir = NSTemporaryDirectory() + "bugbook_perf_\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: tmpDir) } + + let indexManager = IndexManager() + + measure { + _ = indexManager.rebuild(dbPath: tmpDir, schema: schema, rows: rows) + } + } + + // MARK: - Schema Validation Performance + + func testSchemaValidationPerformance_1000rows() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<1000).map { makeSampleRow(index: $0, schema: schema) } + + measure { + for row in rows { + _ = SchemaValidator.validate(properties: row.properties, schema: schema, requireTitle: true) + } + } + } + + // MARK: - Correctness Sanity Checks + + func testSerializeParseRoundTrip() { + let schema = makeSampleSchema(propertyCount: 8) + let row = makeSampleRow(index: 42, schema: schema) + + let content = RowSerializer.serialize(row: row, schema: schema) + let parsed = RowSerializer.parse(content: content, schema: schema) + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed?.id, row.id) + XCTAssertEqual(parsed?.properties.count, row.properties.count) + XCTAssertEqual(parsed?.body, row.body) + } + + func testQueryFilterCorrectness() { + let schema = makeSampleSchema(propertyCount: 8) + let rows = (0..<100).map { makeSampleRow(index: $0, schema: schema) } + let query = Query( + databaseId: "db_perf", + filters: [.equals(property: "prop_1", value: .select("option_2"))] + ) + + let result = QueryEngine.execute(query: query, schema: schema, rows: rows) + // Every 5th row has option_2 (index % 5 == 2) + XCTAssertEqual(result.totalCount, 20) + for row in result.rows { + XCTAssertEqual(row.properties["prop_1"], .select("option_2")) + } + } +} diff --git a/Tests/BugbookTests/BugbookTests.swift b/Tests/BugbookTests/BugbookTests.swift index c46a148..06dd6d9 100644 --- a/Tests/BugbookTests/BugbookTests.swift +++ b/Tests/BugbookTests/BugbookTests.swift @@ -1,307 +1,6 @@ import XCTest @testable import Bugbook -// MARK: - CanvasDocument Tests - -@MainActor -final class CanvasDocumentTests: XCTestCase { - - private func makeDocument() -> CanvasDocument { - let doc = CanvasDocument() - return doc - } - - // MARK: Node CRUD - - func testAddTextNodeCreatesNode() { - let doc = makeDocument() - doc.addTextNode(at: CGPoint(x: 100, y: 200)) - XCTAssertEqual(doc.nodes.count, 1) - XCTAssertEqual(doc.nodes[0].type, .text) - XCTAssertEqual(doc.nodes[0].x, 100) - XCTAssertEqual(doc.nodes[0].y, 200) - XCTAssertEqual(doc.nodes[0].width, 300) - XCTAssertEqual(doc.nodes[0].height, 200) - XCTAssertTrue(doc.isDirty) - } - - func testAddTextNodeSelectsIt() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - XCTAssertEqual(doc.selectedNodeId, doc.nodes[0].id) - } - - func testAddTextNodeCreatesEmptyText() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - XCTAssertEqual(doc.nodeTexts[doc.nodes[0].id], "") - } - - func testAddFileNodeCreatesNode() { - let doc = makeDocument() - doc.addFileNode(at: CGPoint(x: 50, y: 75), filePath: "/Users/test/notes/page.md") - XCTAssertEqual(doc.nodes.count, 1) - XCTAssertEqual(doc.nodes[0].type, .file) - XCTAssertEqual(doc.nodes[0].x, 50) - XCTAssertEqual(doc.nodes[0].height, 80) - } - - func testRemoveNodeDeletesNode() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let id = doc.nodes[0].id - doc.removeNode(id: id) - XCTAssertTrue(doc.nodes.isEmpty) - XCTAssertNil(doc.nodeTexts[id]) - } - - func testRemoveNodeClearsSelection() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let id = doc.nodes[0].id - doc.selectedNodeId = id - doc.removeNode(id: id) - XCTAssertNil(doc.selectedNodeId) - } - - func testRemoveNodeRemovesConnectedEdges() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - let id1 = doc.nodes[0].id - let id2 = doc.nodes[1].id - doc.addEdge(from: id1, to: id2) - XCTAssertEqual(doc.edges.count, 1) - doc.removeNode(id: id1) - XCTAssertTrue(doc.edges.isEmpty) - } - - func testMoveNode() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let id = doc.nodes[0].id - doc.moveNode(id: id, to: CGPoint(x: 500, y: 300)) - XCTAssertEqual(doc.nodes[0].x, 500) - XCTAssertEqual(doc.nodes[0].y, 300) - } - - func testResizeNodeClampsMinimum() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let id = doc.nodes[0].id - doc.resizeNode(id: id, width: 50, height: 30) - XCTAssertEqual(doc.nodes[0].width, 120) // min width - XCTAssertEqual(doc.nodes[0].height, 60) // min height - } - - func testResizeNodeAllowsLargeValues() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let id = doc.nodes[0].id - doc.resizeNode(id: id, width: 800, height: 600) - XCTAssertEqual(doc.nodes[0].width, 800) - XCTAssertEqual(doc.nodes[0].height, 600) - } - - func testUpdateNodeText() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let id = doc.nodes[0].id - doc.updateNodeText(id: id, text: "Hello World") - XCTAssertEqual(doc.nodeTexts[id], "Hello World") - XCTAssertTrue(doc.isDirty) - } - - // MARK: Edge CRUD - - func testAddEdgeCreatesEdge() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - let id1 = doc.nodes[0].id - let id2 = doc.nodes[1].id - doc.addEdge(from: id1, to: id2) - XCTAssertEqual(doc.edges.count, 1) - XCTAssertEqual(doc.edges[0].fromNode, id1) - XCTAssertEqual(doc.edges[0].toNode, id2) - XCTAssertEqual(doc.edges[0].toEnd, "arrow") - } - - func testAddEdgePreventssSelfLoop() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let id = doc.nodes[0].id - doc.addEdge(from: id, to: id) - XCTAssertTrue(doc.edges.isEmpty) - } - - func testAddEdgePreventsDuplicate() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - let id1 = doc.nodes[0].id - let id2 = doc.nodes[1].id - doc.addEdge(from: id1, to: id2) - doc.addEdge(from: id1, to: id2) - XCTAssertEqual(doc.edges.count, 1) - } - - func testAddEdgeWithSides() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - let id1 = doc.nodes[0].id - let id2 = doc.nodes[1].id - doc.addEdge(from: id1, to: id2, fromSide: "right", toSide: "left") - XCTAssertEqual(doc.edges[0].fromSide, "right") - XCTAssertEqual(doc.edges[0].toSide, "left") - } - - func testRemoveEdge() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - doc.addEdge(from: doc.nodes[0].id, to: doc.nodes[1].id) - let edgeId = doc.edges[0].id - doc.selectedEdgeId = edgeId - doc.removeEdge(id: edgeId) - XCTAssertTrue(doc.edges.isEmpty) - XCTAssertNil(doc.selectedEdgeId) - } - - // MARK: Selection - - func testClearSelection() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.selectedNodeId = doc.nodes[0].id - doc.editingNodeId = doc.nodes[0].id - doc.clearSelection() - XCTAssertNil(doc.selectedNodeId) - XCTAssertNil(doc.editingNodeId) - XCTAssertTrue(doc.selectedNodeIds.isEmpty) - } - - func testToggleNodeSelection() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - let id1 = doc.nodes[0].id - let id2 = doc.nodes[1].id - // After second addTextNode, selectedNodeIds = [id2] - doc.clearSelection() - // Start fresh - doc.toggleNodeSelection(id1) - XCTAssertEqual(doc.selectedNodeIds, [id1]) - doc.toggleNodeSelection(id2) - XCTAssertEqual(doc.selectedNodeIds.count, 2) - XCTAssertTrue(doc.selectedNodeIds.contains(id1)) - XCTAssertTrue(doc.selectedNodeIds.contains(id2)) - doc.toggleNodeSelection(id1) - XCTAssertEqual(doc.selectedNodeIds, [id2]) - } - - func testSelectedNodeIdSingleSelect() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - doc.selectedNodeId = doc.nodes[0].id - XCTAssertEqual(doc.selectedNodeId, doc.nodes[0].id) - XCTAssertEqual(doc.selectedNodeIds.count, 1) - } - - func testSelectedNodeIdNilWhenMultiple() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - doc.selectedNodeIds = [doc.nodes[0].id, doc.nodes[1].id] - XCTAssertNil(doc.selectedNodeId) // nil when multiple selected - } - - // MARK: Undo/Redo - - func testUndoRestoresPreviousState() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - XCTAssertEqual(doc.nodes.count, 1) - doc.undo() - XCTAssertTrue(doc.nodes.isEmpty) - } - - func testRedoRestoresUndoneState() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - let nodeId = doc.nodes[0].id - doc.undo() - XCTAssertTrue(doc.nodes.isEmpty) - doc.redo() - XCTAssertEqual(doc.nodes.count, 1) - XCTAssertEqual(doc.nodes[0].id, nodeId) - } - - func testUndoOnEmptyStackDoesNothing() { - let doc = makeDocument() - doc.undo() // should not crash - XCTAssertTrue(doc.nodes.isEmpty) - } - - func testRedoOnEmptyStackDoesNothing() { - let doc = makeDocument() - doc.redo() // should not crash - XCTAssertTrue(doc.nodes.isEmpty) - } - - func testMultipleUndos() { - let doc = makeDocument() - doc.addTextNode(at: .zero) - doc.addTextNode(at: CGPoint(x: 400, y: 0)) - doc.addTextNode(at: CGPoint(x: 800, y: 0)) - XCTAssertEqual(doc.nodes.count, 3) - doc.undo() - XCTAssertEqual(doc.nodes.count, 2) - doc.undo() - XCTAssertEqual(doc.nodes.count, 1) - doc.undo() - XCTAssertEqual(doc.nodes.count, 0) - } - - // MARK: Relative Path - - func testRelativePathSameDirectory() { - let result = CanvasDocument.relativePath(from: "/notes/canvas", to: "/notes/canvas/file.md") - XCTAssertEqual(result, "file.md") - } - - func testRelativePathSiblingDirectory() { - let result = CanvasDocument.relativePath(from: "/notes/canvas", to: "/notes/pages/file.md") - XCTAssertEqual(result, "../pages/file.md") - } - - func testRelativePathDeeplyNested() { - let result = CanvasDocument.relativePath(from: "/a/b/c", to: "/a/x/y/z.md") - XCTAssertEqual(result, "../../x/y/z.md") - } - - // MARK: File Node Display Name - - func testFileNodeDisplayNameMarkdown() { - let node = CanvasNodeMeta(id: "n1", type: .file, x: 0, y: 0, width: 300, height: 80, file: "path/to/My Note.md") - let doc = makeDocument() - XCTAssertEqual(doc.fileNodeDisplayName(for: node), "My Note") - } - - func testFileNodeDisplayNameNonMarkdown() { - let node = CanvasNodeMeta(id: "n1", type: .file, x: 0, y: 0, width: 300, height: 80, file: "image.png") - let doc = makeDocument() - XCTAssertEqual(doc.fileNodeDisplayName(for: node), "image.png") - } - - func testFileNodeDisplayNameNilFile() { - let node = CanvasNodeMeta(id: "n1", type: .file, x: 0, y: 0, width: 300, height: 80) - let doc = makeDocument() - XCTAssertEqual(doc.fileNodeDisplayName(for: node), "Unknown") - } -} // MARK: - BlockDocument Tests @@ -714,12 +413,6 @@ final class AppStateTests: XCTestCase { XCTAssertTrue(state.openTabs[0].isDatabase) } - func testOpenCanvasTab() { - let state = AppState() - let entry = makeEntry(name: "Canvas", path: "/test/Canvas", kind: .canvas) - state.openFile(entry) - XCTAssertTrue(state.openTabs[0].isCanvas) - } func testDatabaseRowNavigationPathRoundTrips() { let path = DatabaseRowNavigationPath.make(dbPath: "/test/Tasks", rowId: "row_123") @@ -767,78 +460,6 @@ final class AppStateTests: XCTestCase { } } -// MARK: - Canvas Model Tests - -final class CanvasModelTests: XCTestCase { - - func testCanvasViewportCodable() throws { - let viewport = CanvasViewport(x: 100, y: -200, zoom: 1.5) - let data = try JSONEncoder().encode(viewport) - let decoded = try JSONDecoder().decode(CanvasViewport.self, from: data) - XCTAssertEqual(decoded.x, 100) - XCTAssertEqual(decoded.y, -200) - XCTAssertEqual(decoded.zoom, 1.5) - } - - func testCanvasNodeMetaCodable() throws { - let node = CanvasNodeMeta( - id: "test_node", - type: .text, - x: 50, y: 75, - width: 300, height: 200, - file: nil, - color: "blue" - ) - let data = try JSONEncoder().encode(node) - let decoded = try JSONDecoder().decode(CanvasNodeMeta.self, from: data) - XCTAssertEqual(decoded.id, "test_node") - XCTAssertEqual(decoded.type, .text) - XCTAssertEqual(decoded.color, "blue") - } - - func testCanvasEdgeMetaCodable() throws { - let edge = CanvasEdgeMeta( - id: "edge_1", - fromNode: "n1", - toNode: "n2", - fromSide: "right", - toSide: "left", - toEnd: "arrow", - label: "connects to", - color: "red" - ) - let data = try JSONEncoder().encode(edge) - let decoded = try JSONDecoder().decode(CanvasEdgeMeta.self, from: data) - XCTAssertEqual(decoded.id, "edge_1") - XCTAssertEqual(decoded.fromSide, "right") - XCTAssertEqual(decoded.label, "connects to") - } - - func testCanvasFileMetaCodable() throws { - let meta = CanvasFileMeta( - id: "canvas_1", - name: "My Canvas", - version: 1, - viewport: CanvasViewport(x: 0, y: 0, zoom: 1.0), - nodes: [ - CanvasNodeMeta(id: "n1", type: .text, x: 0, y: 0, width: 300, height: 200) - ], - edges: [] - ) - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let data = try encoder.encode(meta) - let decoded = try JSONDecoder().decode(CanvasFileMeta.self, from: data) - XCTAssertEqual(decoded.name, "My Canvas") - XCTAssertEqual(decoded.nodes.count, 1) - } - - func testCanvasNodeTypeRawValues() { - XCTAssertEqual(CanvasNodeType.text.rawValue, "text") - XCTAssertEqual(CanvasNodeType.file.rawValue, "file") - XCTAssertEqual(CanvasNodeType.image.rawValue, "image") - } -} // MARK: - Block Model Tests diff --git a/Tests/BugbookTests/PerformanceTests.swift b/Tests/BugbookTests/PerformanceTests.swift new file mode 100644 index 0000000..664247b --- /dev/null +++ b/Tests/BugbookTests/PerformanceTests.swift @@ -0,0 +1,324 @@ +import XCTest +@testable import Bugbook +@testable import BugbookCore + +// MARK: - Baseline TSV Helper + +/// Reads/writes performance baselines to a TSV file next to the test sources. +private enum PerfBaseline { + static let tsvPath: String = { + // Place the TSV next to the test file itself + let thisFile = #filePath + let dir = (thisFile as NSString).deletingLastPathComponent + return (dir as NSString).appendingPathComponent("perf_baseline.tsv") + }() + + struct Entry { + let testName: String + let metric: String + let value: Double + let timestamp: String + } + + static func load() -> [String: Entry] { + guard let text = try? String(contentsOfFile: tsvPath, encoding: .utf8) else { return [:] } + var entries: [String: Entry] = [:] + for line in text.components(separatedBy: "\n").dropFirst() { // skip header + let cols = line.components(separatedBy: "\t") + guard cols.count >= 4, let val = Double(cols[2]) else { continue } + entries[cols[0]] = Entry(testName: cols[0], metric: cols[1], value: val, timestamp: cols[3]) + } + return entries + } + + static func save(_ entries: [String: Entry]) { + var lines = ["test_name\tmetric\tvalue\ttimestamp"] + for key in entries.keys.sorted() { + let e = entries[key]! + lines.append("\(e.testName)\t\(e.metric)\t\(String(format: "%.3f", e.value))\t\(e.timestamp)") + } + try? lines.joined(separator: "\n").write(toFile: tsvPath, atomically: true, encoding: .utf8) + } + + static func record(testName: String, metric: String, value: Double) { + var entries = load() + let ts = ISO8601DateFormatter().string(from: Date()) + let entry = Entry(testName: testName, metric: metric, value: value, timestamp: ts) + + // Compare to existing baseline if present + if let baseline = entries[testName] { + let pctChange = ((value - baseline.value) / baseline.value) * 100 + let direction = pctChange > 0 ? "slower" : "faster" + let symbol = pctChange > 20 ? "REGRESSION" : "ok" + print(String(format: " %@: %.1fms -> %.1fms (%.0f%% %@) %@", + testName, baseline.value, value, abs(pctChange), direction, symbol)) + } else { + print(String(format: " %@: %.1fms (baseline)", testName, value)) + } + + entries[testName] = entry + save(entries) + } +} + +// MARK: - Test Data Generators + +private enum TestData { + + /// Build a schema with several property types for realistic serialization. + static func makeSchema() -> DatabaseSchema { + DatabaseSchema( + id: "db_perf_test", + name: "PerfTest", + properties: [ + PropertyDefinition(id: "prop_title", name: "Title", type: .title), + PropertyDefinition(id: "prop_status", name: "Status", type: .select), + PropertyDefinition(id: "prop_priority", name: "Priority", type: .number), + PropertyDefinition(id: "prop_tags", name: "Tags", type: .multiSelect), + PropertyDefinition(id: "prop_done", name: "Done", type: .checkbox), + PropertyDefinition(id: "prop_due", name: "Due", type: .date), + PropertyDefinition(id: "prop_url", name: "URL", type: .url), + ], + views: [ViewConfig(id: "view_table", name: "Table", type: .table)], + defaultView: "view_table", + createdAt: "2025-01-01T00:00:00Z" + ) + } + + /// Create a row with all properties populated. + static func makeRow(index: Int) -> DatabaseRow { + DatabaseRow( + id: "row_\(String(format: "%06d", index))", + properties: [ + "prop_title": .text("Task number \(index) with a reasonably long title for realism"), + "prop_status": .select("In Progress"), + "prop_priority": .number(Double(index % 5)), + "prop_tags": .multiSelect(["backend", "urgent", "sprint-\(index % 10)"]), + "prop_done": .checkbox(index % 3 == 0), + "prop_due": .date("2025-06-\(String(format: "%02d", (index % 28) + 1))"), + "prop_url": .url("https://example.com/task/\(index)"), + ], + body: "This is the body of row \(index).\nIt has multiple lines.\n\nAnd a blank line too.", + createdAt: Date(timeIntervalSinceReferenceDate: Double(index * 86400)), + updatedAt: Date() + ) + } + + /// Generate a 500-line markdown document with varied block types. + static func makeMarkdown(lineCount: Int) -> String { + var lines: [String] = [] + lines.append("# Performance Test Document") + lines.append("") + var i = 2 + while i < lineCount { + let mod = i % 20 + switch mod { + case 0: + lines.append("## Section \(i / 20)") + case 1, 2, 3: + lines.append("This is paragraph \(i). It contains some **bold** and *italic* text, plus a [[wiki link]] and `inline code`.") + case 4: + lines.append("- Bullet item \(i)") + case 5: + lines.append(" - Nested bullet item \(i)") + case 6: + lines.append("- [ ] Task item \(i)") + case 7: + lines.append("- [x] Completed task \(i)") + case 8: + lines.append("1. Numbered item \(i)") + case 9: + lines.append("> Blockquote text at line \(i)") + case 10: + lines.append("```swift") + lines.append("let x = \(i)") + lines.append("print(x)") + lines.append("```") + i += 3 + case 11: + lines.append("---") + case 12: + lines.append("### Heading Three \(i)") + case 13: + lines.append("[[Page Link \(i)]]") + case 14, 15, 16, 17, 18, 19: + lines.append("Regular paragraph line \(i) with some content to parse.") + default: + lines.append("") + } + i += 1 + } + return lines.joined(separator: "\n") + } + + /// Generate markdown for N blocks (paragraphs, headings, lists). + static func makeBlockMarkdown(blockCount: Int) -> String { + var lines: [String] = [] + for j in 0.. Quote \(j)") + default: lines.append("") + } + } + return lines.joined(separator: "\n") + } +} + +// MARK: - Performance Tests + +@MainActor +final class PerformanceTests: XCTestCase { + + /// Run a block 10 times, return the median duration in milliseconds. + /// Separate from XCTest's `measure` because its results aren't programmatically accessible. + private func timed(_ block: () -> Void) -> Double { + var samples: [Double] = [] + for _ in 0..<10 { + let start = CFAbsoluteTimeGetCurrent() + block() + samples.append((CFAbsoluteTimeGetCurrent() - start) * 1000) + } + samples.sort() + return samples[samples.count / 2] // median + } + + // MARK: 1. RowSerializer: serialize/deserialize 100 rows + + func testRowSerialize100() { + let schema = TestData.makeSchema() + let rows = (0..<100).map { TestData.makeRow(index: $0) } + + measure { + for row in rows { + _ = RowSerializer.serialize(row: row, schema: schema) + } + } + + let ms = timed { + for row in rows { _ = RowSerializer.serialize(row: row, schema: schema) } + } + PerfBaseline.record(testName: "row_serialize_100", metric: "ms", value: ms) + } + + func testRowDeserialize100() { + let schema = TestData.makeSchema() + let serialized = (0..<100).map { RowSerializer.serialize(row: TestData.makeRow(index: $0), schema: schema) } + + measure { + for content in serialized { + _ = RowSerializer.parse(content: content, schema: schema) + } + } + + let ms = timed { + for content in serialized { _ = RowSerializer.parse(content: content, schema: schema) } + } + PerfBaseline.record(testName: "row_deserialize_100", metric: "ms", value: ms) + } + + // MARK: 2. MarkdownBlockParser: parse 500-line document + + func testMarkdownParse500Lines() { + let markdown = TestData.makeMarkdown(lineCount: 500) + + measure { _ = MarkdownBlockParser.parse(markdown) } + + let ms = timed { _ = MarkdownBlockParser.parse(markdown) } + PerfBaseline.record(testName: "markdown_parse_500", metric: "ms", value: ms) + } + + func testMarkdownSerialize500Lines() { + let markdown = TestData.makeMarkdown(lineCount: 500) + let blocks = MarkdownBlockParser.parse(markdown) + + measure { _ = MarkdownBlockParser.serialize(blocks) } + + let ms = timed { _ = MarkdownBlockParser.serialize(blocks) } + PerfBaseline.record(testName: "markdown_serialize_500", metric: "ms", value: ms) + } + + // MARK: 3. DatabaseStore: load schema + 100 rows from disk + + func testDatabaseStoreLoad100Rows() throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("BugbookPerfTest-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let dbPath = tmpDir.path + let schema = TestData.makeSchema() + + // Write schema + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let schemaData = try encoder.encode(schema) + try schemaData.write(to: tmpDir.appendingPathComponent("_schema.json")) + + // Write 100 row files + let rowStore = RowStore() + for i in 0..<100 { + let row = TestData.makeRow(index: i) + try rowStore.saveRow(row, schema: schema, dbPath: dbPath) + } + + let store = DatabaseStore() + + measure { + let s = try! store.loadSchema(at: dbPath) + _ = rowStore.loadAllRows(in: dbPath, schema: s) + } + + let ms = timed { + let s = try! store.loadSchema(at: dbPath) + _ = rowStore.loadAllRows(in: dbPath, schema: s) + } + PerfBaseline.record(testName: "database_load_100", metric: "ms", value: ms) + } + + // MARK: 4. BlockDocument: init with 50 blocks + + func testBlockDocumentInit50Blocks() { + let markdown = TestData.makeBlockMarkdown(blockCount: 50) + + measure { _ = BlockDocument(markdown: markdown) } + + let ms = timed { _ = BlockDocument(markdown: markdown) } + PerfBaseline.record(testName: "block_document_init_50", metric: "ms", value: ms) + } + + // MARK: 5. QmdService: binary path detection + + func testQmdFindBinaryPath() { + measure { _ = QmdService.findBinaryPath() } + + let ms = timed { _ = QmdService.findBinaryPath() } + PerfBaseline.record(testName: "qmd_find_binary", metric: "ms", value: ms) + } + + // MARK: 6. FileSystemService: build file tree for 100 files + + func testFileSystemBuildTree100Files() throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("BugbookPerfTree-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + // Create 100 .md files + for i in 0..<100 { + let filename = "Page \(String(format: "%03d", i)).md" + let filePath = tmpDir.appendingPathComponent(filename) + try "# Page \(i)\n\nSome content here.\n".write(to: filePath, atomically: true, encoding: .utf8) + } + + let service = FileSystemService() + + measure { _ = service.buildFileTree(at: tmpDir.path) } + + let ms = timed { _ = service.buildFileTree(at: tmpDir.path) } + PerfBaseline.record(testName: "filesystem_tree_100", metric: "ms", value: ms) + } +} diff --git a/Tests/BugbookTests/perf_baseline.tsv b/Tests/BugbookTests/perf_baseline.tsv new file mode 100644 index 0000000..e0cf3cf --- /dev/null +++ b/Tests/BugbookTests/perf_baseline.tsv @@ -0,0 +1,9 @@ +test_name metric value timestamp +block_document_init_50 ms 0.769 2026-03-21T16:05:07Z +database_load_100 ms 8.023 2026-03-21T16:05:08Z +filesystem_tree_100 ms 5.693 2026-03-21T16:05:08Z +markdown_parse_500 ms 3.440 2026-03-21T16:05:08Z +markdown_serialize_500 ms 3.064 2026-03-21T16:05:09Z +qmd_find_binary ms 68.501 2026-03-21T16:05:10Z +row_deserialize_100 ms 4.577 2026-03-21T16:05:11Z +row_serialize_100 ms 1.603 2026-03-21T16:05:11Z \ No newline at end of file diff --git a/macos/App/Assets.xcassets/AccentColor.colorset/Contents.json b/macos/App/Assets.xcassets/AccentColor.colorset/Contents.json index dc881da..750d81a 100644 --- a/macos/App/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/macos/App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -4,9 +4,27 @@ "color" : { "color-space" : "srgb", "components" : { - "red" : "0.831", - "green" : "0.263", - "blue" : "0.196", + "red" : "0.176", + "green" : "0.176", + "blue" : "0.176", + "alpha" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.690", + "green" : "0.690", + "blue" : "0.690", "alpha" : "1.000" } }, diff --git a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png index 8ef968f..704d89b 100644 Binary files a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png and b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png differ diff --git a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png index a89eff2..693d896 100644 Binary files a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png index b7ab69d..fe8f128 100644 Binary files a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png index 280cbe2..a153cb1 100644 Binary files a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png index ff9b408..a1e12ad 100644 Binary files a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png index 8919dec..85f37c7 100644 Binary files a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_64x64.png b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_64x64.png index a8db589..210cc6e 100644 Binary files a/macos/App/Assets.xcassets/AppIcon.appiconset/icon_64x64.png and b/macos/App/Assets.xcassets/AppIcon.appiconset/icon_64x64.png differ diff --git a/macos/App/Assets.xcassets/BugbookAI.imageset/bugbook-ai.svg b/macos/App/Assets.xcassets/BugbookAI.imageset/bugbook-ai.svg index 787ec11..62033b5 100644 --- a/macos/App/Assets.xcassets/BugbookAI.imageset/bugbook-ai.svg +++ b/macos/App/Assets.xcassets/BugbookAI.imageset/bugbook-ai.svg @@ -1,80 +1,98 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/App/Assets.xcassets/BugbookLogo.imageset/Contents.json b/macos/App/Assets.xcassets/BugbookLogo.imageset/Contents.json index e5d9dc2..b23a4f9 100644 --- a/macos/App/Assets.xcassets/BugbookLogo.imageset/Contents.json +++ b/macos/App/Assets.xcassets/BugbookLogo.imageset/Contents.json @@ -1,15 +1,18 @@ { "images" : [ { - "filename" : "bugbook.svg", - "idiom" : "universal" + "filename" : "bugbook.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bugbook@2x.png", + "idiom" : "universal", + "scale" : "2x" } ], "info" : { "author" : "xcode", "version" : 1 - }, - "properties" : { - "preserves-vector-representation" : true } } diff --git a/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook.png b/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook.png index 4646fe5..04abe53 100644 Binary files a/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook.png and b/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook.png differ diff --git a/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook.svg b/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook.svg deleted file mode 100644 index 3cb72ac..0000000 --- a/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook@2x.png b/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook@2x.png index ad1ed93..f4f8e45 100644 Binary files a/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook@2x.png and b/macos/App/Assets.xcassets/BugbookLogo.imageset/bugbook@2x.png differ diff --git a/macos/App/Bugbook.entitlements b/macos/App/Bugbook.entitlements index 53fe8d7..9463a8d 100644 --- a/macos/App/Bugbook.entitlements +++ b/macos/App/Bugbook.entitlements @@ -12,5 +12,7 @@ com.apple.security.network.client + com.apple.security.device.audio-input + diff --git a/macos/App/Info.plist b/macos/App/Info.plist index 4213828..bcf8d9b 100644 --- a/macos/App/Info.plist +++ b/macos/App/Info.plist @@ -28,5 +28,24 @@ SUPublicEDKey + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.bugbook.sidebar-reference + UTTypeDescription + Bugbook Sidebar Reference + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + + + NSMicrophoneUsageDescription + Bugbook needs microphone access to record meeting audio for live transcription. + NSSpeechRecognitionUsageDescription + Bugbook uses speech recognition to transcribe meeting recordings in real-time. diff --git a/macos/Bugbook.xcodeproj/project.pbxproj b/macos/Bugbook.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b8fc8ed --- /dev/null +++ b/macos/Bugbook.xcodeproj/project.pbxproj @@ -0,0 +1,1320 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0044A6D4562A8D96730E1E8E /* AgentsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BA5285DC6913D9E172BA99 /* AgentsSettingsView.swift */; }; + 0150805DFE2AF45CF4B0E7DC /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 093BE9A2C3A2769A90DE0579 /* Sentry */; }; + A1B2C3D4E5F60718A9B0C1D2 /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = D2C1B0A918076F5E4D3C2B1A /* FluidAudio */; }; + 045B1A2FD850725900D6FC22 /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72383D5865CC78156A21463 /* GeneralSettingsView.swift */; }; + 09871C22EFFDCF1EEF0A47AF /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968A3076C851ABC04C18EC51 /* ChatMessage.swift */; }; + 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */; }; + 5E8D2F63A1C04B79D2370E18 /* MeetingNotesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A7E91B3F5D82A0E6B10C47 /* MeetingNotesEditor.swift */; }; + 0ECCD65D875F55E2148FF871 /* EditorDraftStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */; }; + 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */; }; + 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */; }; + 1364B73C31B34B4A59420743 /* Row.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47EF03A6E9A932800D5A95E /* Row.swift */; }; + 1A69CBC3E525B070D8FCC902 /* CalendarEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2262869710B320C9D232D7B /* CalendarEventStore.swift */; }; + 1C60B08005D500C4166E9E80 /* GraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED677E977CBBB33D0ECA74CE /* GraphView.swift */; }; + 1E82C25E8134A1038E2D96A6 /* TabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C17D091E88E0D5554BE1F38 /* TabBarView.swift */; }; + 1F53E22405A92296FC9615E2 /* HeadingToggleBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F20EDB7EB07349C54B1DB8 /* HeadingToggleBlockView.swift */; }; + 203CE38A21215FE7D2ED90C2 /* FloatingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC605E52D91C41E419FBF35 /* FloatingPopover.swift */; }; + 208879A6F607FAA57195F686 /* ShellZoomMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746E6FCF54D009487CB47D71 /* ShellZoomMetrics.swift */; }; + 209D01AA836D71692841F67F /* BlockEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74D38DE480A02B3136213525 /* BlockEditorView.swift */; }; + 240EB5675B13CEE875BD0E72 /* Query.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED7530043DEEF28753C2CD6C /* Query.swift */; }; + 264D0B95D6702BC01DDA5B34 /* SidebarReferenceDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAE175C836C7455A3A8153B2 /* SidebarReferenceDragPayload.swift */; }; + 26885DC83160B601DCBB61BB /* DatabaseInlineEmbedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF7FE7EFE7F26CAA875F2754 /* DatabaseInlineEmbedView.swift */; }; + 26EDC26A3C6E5D61E5852773 /* BlockDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E86B6CFB16AAD4303BA786E /* BlockDocument.swift */; }; + 279A6420F93CE53298E2AA56 /* SidebarDragPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */; }; + 28CD0ADFAEBE907873EAE00B /* GripDotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D74F02ED2A5522822B783A /* GripDotsView.swift */; }; + 290D6457E202795F776249B1 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E66496064A29930C3F3B17 /* AppSettings.swift */; }; + 2AD1B1200C4938F37EFF82CA /* ViewModePickerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */; }; + 2BDEEDC610523F8472D8FF1A /* DatabaseRowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F257D6BA8D9A6DCAA4EB896 /* DatabaseRowViewModel.swift */; }; + 30CE7A2C7065E84D29BA2E59 /* FloatingRecordingPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E2720BDB254FE084FC3EFBF /* FloatingRecordingPill.swift */; }; + 31319D254BD21B3105E098D8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF3076E2CD78CCA495DCDCB /* Logger.swift */; }; + 316D6517CC93BFFDDAC5F7A6 /* MutationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */; }; + 349CC754CCA1FEBE8A961431 /* Color+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27DF0CADED8EC505FAAC6252 /* Color+Theme.swift */; }; + 38F21365F5B64137505F3CFB /* DatabasePointerCursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */; }; + 3AA74BAA18B9F57D4266402F /* AgentHubViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */; }; + 3AAA59CAFE542617EFFD989E /* NotesChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */; }; + 3D9B4F3DBA52CA2B67CFBBB8 /* MarkdownBlockParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74982BB99A95A4D3C5FDDFB /* MarkdownBlockParser.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 */; }; + 4C7AB83E38D1649A967CFF40 /* CalendarWeekView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */; }; + 4E4724D2134A490B31D2441E /* AttributedStringConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */; }; + 4F1134DFB5BF17CC4D9E671A /* ColumnBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 780A13E34CC19B07D99EFF46 /* ColumnBlockView.swift */; }; + 5071CA6077BA5B0723C3AFE1 /* FormattingToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9365350F95628047D144D9F7 /* FormattingToolbar.swift */; }; + 51AB2AEE8B0AD275F03073F6 /* KanbanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2421DB7DD227294C11A1041 /* KanbanView.swift */; }; + 5475FA131B72603118B5EE61 /* BugbookApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47A85AD47A48232C52EB55B /* BugbookApp.swift */; }; + 55300AEF3CA2B3AABA0FF66D /* DatabaseZoomMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50668C95CB17560B1073D973 /* DatabaseZoomMetrics.swift */; }; + 55AED705FAC0BD8F729640FD /* FileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 189FFD8CFB64645025DB5876 /* FileEntry.swift */; }; + 569B127B96923692E102B939 /* CalendarMonthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B1E2BE4DD4A5DBE8AAD786 /* CalendarMonthView.swift */; }; + 5AC81CC4AEA7FE4D4424DEE4 /* RowSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79DDB549EBE9DC5DAE9FB8CE /* RowSerializer.swift */; }; + 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 */; }; + 65A641EC216A6E2ABF031321 /* BugbookCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 99E3355E0062163300B5893F /* BugbookCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 6653858C5D746784F2AFC62B /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247B9DED846DAF0568FF163E /* CommandPaletteView.swift */; }; + 694EBF21623BCEA74940150D /* AgentWorkspaceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5A24662D72186FD809E326 /* AgentWorkspaceTemplate.swift */; }; + 6B4F10DF5DC543A9054FC61E /* QmdService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF0F24D7EAF03362E0B0441 /* QmdService.swift */; }; + 70A9F55EB761E74043D75928 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E2AE42817B3F7E154211B1 /* DesignTokens.swift */; }; + 71150606FE80AB392A835F21 /* OpenFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F534031257A5C2CBDA600E6 /* OpenFile.swift */; }; + 72E6B7E574BFFAB11C8E0EE9 /* DatabaseFullPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 704F620630C8D38A78C3DA37 /* DatabaseFullPageView.swift */; }; + 736679A853BAC31DAE966F2D /* WorkspaceWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */; }; + 73733159AA52E171C502A90A /* Agent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D16799A267ED55B558F74BD /* Agent.swift */; }; + 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 */; }; + 79B94AC24F342F11305894B8 /* CalendarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 257DD331DD4F07DC2016F504 /* CalendarViewModel.swift */; }; + 7C3131421C95964975D5B5FB /* FileTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2C165B4FFDB916884D4293C /* FileTreeView.swift */; }; + 7C8D7191B8B1FF31105C4608 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99B66C38F452E8D9E75E81F /* AppState.swift */; }; + 7DDC7E5E799945BE79D9434F /* WikiLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28DEFDD7A7DE801DEDBF0890 /* WikiLinkView.swift */; }; + 7E22DC9B531139E72F7A045C /* DatabaseViewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B306A8D5E45215DD58F3FF0D /* DatabaseViewHelpers.swift */; }; + 7ED64068786FC4125769B869 /* AiSidePanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */; }; + 7EDA4964EE97D79FFE09EF59 /* MeetingNoteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */; }; + 7EF33F80D6BB6B2F0A6CBD74 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF7A98A5ECBDDCDEA159DDBE /* Assets.xcassets */; }; + 7F1F65616C0C82D89BEA6B5E /* InlineStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71023CB4011C219BC7514109 /* InlineStyle.swift */; }; + 80083188D4B8A228121759CD /* DatabaseEmbedPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E08AB3843737425588F4B7 /* DatabaseEmbedPathResolver.swift */; }; + 84F54BA37F46B7DD22EC20D9 /* TextBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E33F7472EFFA78D901306D /* TextBlockView.swift */; }; + 85B4001C6EF6E27F2017560E /* BugbookUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */; }; + 85EC0FBC8780F03F55A760F1 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E42F6C36FA91D0AC7918622 /* CalendarView.swift */; }; + 868E88F7B10FE2B5E3928632 /* BlockColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA9558FB1FA2DA5139D1590 /* BlockColor.swift */; }; + 8C96674A0DB8992FC7B22444 /* BreadcrumbItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A438D1B1F3111A465125F0E /* BreadcrumbItem.swift */; }; + 8EC461617F98B3FDD8069B3E /* DatabaseRowNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */; }; + 90DDA2F7FC61E7CCE3304A2A /* FileTreeItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CF30FE4A58B2212047CA84 /* FileTreeItemView.swift */; }; + 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 */; }; + A11F532663FB6DCE02C0C4ED /* WorkspacePathRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 579A2C2A019CFEAC7344FE9C /* WorkspacePathRules.swift */; }; + A5C00BCC3313FC7DADD70BFA /* TranscriptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */; }; + A73CCA52E1763E46BA738B35 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52CFB20317770DA9DB4D732 /* View.swift */; }; + A86D81D4F21E32011176CB04 /* DatabaseViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F67AB5BB131FE8C947973D2 /* DatabaseViewState.swift */; }; + AA252EC507F381DB0A35F976 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661BA33DC8D61D2884B32072 /* SettingsView.swift */; }; + AA958FA1B1E64E24C676E3B8 /* ToggleBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E6CAEA3C923DDA24C7C36A /* ToggleBlockView.swift */; }; + AAACB1E47FC69E8DFD7D3D27 /* EditorUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82603F77EB0544EA989F4BE7 /* EditorUIState.swift */; }; + AB8B8319C66ACD0F141B3EEE /* BlockViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A9D73232AA7B54B9A6C8C58 /* BlockViews.swift */; }; + ABA26E21B59748870EB509CE /* AgentWorkspaceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CC5A6C9F18F716799F484C /* AgentWorkspaceStore.swift */; }; + AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99E3355E0062163300B5893F /* BugbookCore.framework */; }; + + B62BD30B7E98CA7476B89377 /* WorkspaceCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808CF46B8BE4372BB9E904FC /* WorkspaceCalendarView.swift */; }; + B77203F53F96C299D954EC1F /* UpdaterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */; }; + B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */; }; + BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */; }; + BB63147ADC84BB1CB310FAD6 /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D7150DDE83E7ED425899B1 /* TableView.swift */; }; + BD8320F85A870BCF0237959A /* AISettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6639981D84F86643DBB91CD /* AISettingsView.swift */; }; + BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */; }; + C145A3D5D629947B2A390937 /* CalendarDayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */; }; + C3D3D68199CF617D60D3A773 /* BlockTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE400CA9718CD5697976941B /* BlockTextView.swift */; }; + C615A4810265456A1BFD7665 /* AiContextItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3E007AA9E5194AC13E45B4A /* AiContextItem.swift */; }; + C967897AA5001A491AF24AC9 /* FileSystemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */; }; + CBB52CB1DEEB63DEF5B95C42 /* CalendarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59394152B4415173A37EE498 /* CalendarService.swift */; }; + CBDA6D4C212A797378E7C062 /* OnboardingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22439903C988ACDC2263371A /* OnboardingService.swift */; }; + CE6A8D6D7B08D23CE62776BE /* TemplatePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1075BF7C8AE8B706A1799531 /* TemplatePickerView.swift */; }; + D029F4E3DFFC61628D54BE90 /* PageHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */; }; + C96F12200797FBD9B89C526F /* MeetingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA1EF8A52B349B7B059D761 /* MeetingsView.swift */; }; + 6E9339DB84F7B0318CDEAC63 /* MeetingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCDA7C2E3A7A7713D6F17B7 /* MeetingsViewModel.swift */; }; + 7D310A6D0ECBC4B528D28F51 /* DatabaseTemplatePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14550A7FA261AC8F2B6D85F3 /* DatabaseTemplatePickerView.swift */; }; + B832DFBA333107769C264F8E /* DatabaseTemplateEditorModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39AFADB4D74E4EB502810F64 /* DatabaseTemplateEditorModal.swift */; }; + D1C4FAB8A0DA90CAD2C9409B /* DatabaseRowModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7E110D3A8E8C645366B20E /* DatabaseRowModalView.swift */; }; + D231B9D2E5E28A22F7DC5659 /* MovePagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.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 */; }; + DA7A9136A777D4A1E7E649A8 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37262953A2E00C21609C6836 /* SearchSettingsView.swift */; }; + DBF41C1912A5B93FD20BDE90 /* DatabaseRowFullPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B116D36FE355D650ADC6B0F6 /* DatabaseRowFullPageView.swift */; }; + DC399F897D16862221AE55FF /* RowPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C89D0872A52B79E2B456963C /* RowPageView.swift */; }; + 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 */; }; + E70E530BB9DFC76494536EAB /* FullEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C864B6F1244A0E7F07EE260A /* FullEmojiPickerView.swift */; }; + E89317C6440E0357A8F3D179 /* BlockCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B1D94EABC044FCDB416EC80 /* BlockCellView.swift */; }; + EAC88178F0063A660612B990 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */; }; + EF9C6DDF179DD89F6FADB062 /* PropertyEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFF32AEDA72E376BBC436395 /* PropertyEditorView.swift */; }; + 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 */; }; + F989CBE88DBF74238D485E14 /* PageIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B384938321E8648F5447AE4D /* PageIcon.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2253322A3195258138D98182 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = E1B57C400F29488DA7523D56; + remoteInfo = BugbookCore; + }; + 700FC8BAD6EC52AFAA8DE8DB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 748DCF6AC60831FC7058F9CD /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8DD6EDE4722CA92DDB665594; + remoteInfo = BugbookApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + F27336E2ABFB64ABFE05709A /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 65A641EC216A6E2ABF031321 /* BugbookCore.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 00F20EDB7EB07349C54B1DB8 /* HeadingToggleBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadingToggleBlockView.swift; sourceTree = ""; }; + 01B1E2BE4DD4A5DBE8AAD786 /* CalendarMonthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarMonthView.swift; sourceTree = ""; }; + 0E2720BDB254FE084FC3EFBF /* FloatingRecordingPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingRecordingPill.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 = ""; }; + 12E66496064A29930C3F3B17 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHubViewModel.swift; sourceTree = ""; }; + 189FFD8CFB64645025DB5876 /* FileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEntry.swift; sourceTree = ""; }; + 1A49740409C913AF7DE543D6 /* QueryEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryEngine.swift; sourceTree = ""; }; + 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeaderView.swift; sourceTree = ""; }; + 21D7150DDE83E7ED425899B1 /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; + 22439903C988ACDC2263371A /* OnboardingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingService.swift; sourceTree = ""; }; + 247B9DED846DAF0568FF163E /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = ""; }; + 257DD331DD4F07DC2016F504 /* CalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModel.swift; sourceTree = ""; }; + 27404EBA915A3E0AEE0D7483 /* SchemaValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemaValidator.swift; sourceTree = ""; }; + 27DF0CADED8EC505FAAC6252 /* Color+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Theme.swift"; sourceTree = ""; }; + 28DEFDD7A7DE801DEDBF0890 /* WikiLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WikiLinkView.swift; sourceTree = ""; }; + 29502EEA0A700D17E1A1E809 /* BlockMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockMenuView.swift; sourceTree = ""; }; + 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringConverter.swift; sourceTree = ""; }; + 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceWatcher.swift; sourceTree = ""; }; + 2CA9558FB1FA2DA5139D1590 /* BlockColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockColor.swift; sourceTree = ""; }; + 2E42F6C36FA91D0AC7918622 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; + 2E86B6CFB16AAD4303BA786E /* BlockDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockDocument.swift; sourceTree = ""; }; + 2F67AB5BB131FE8C947973D2 /* DatabaseViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseViewState.swift; sourceTree = ""; }; + 33262B570A72E09F2E88ECF2 /* TrashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashView.swift; sourceTree = ""; }; + 37262953A2E00C21609C6836 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = ""; }; + 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingBlockView.swift; sourceTree = ""; }; + C4A7E91B3F5D82A0E6B10C47 /* MeetingNotesEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesEditor.swift; sourceTree = ""; }; + 398B466A4F5F82ACEA4874F6 /* Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Block.swift; sourceTree = ""; }; + 3A9D73232AA7B54B9A6C8C58 /* BlockViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockViews.swift; sourceTree = ""; }; + 3B1D94EABC044FCDB416EC80 /* BlockCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockCellView.swift; sourceTree = ""; }; + 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationResolver.swift; sourceTree = ""; }; + 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutationEngine.swift; sourceTree = ""; }; + 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModePickerButton.swift; sourceTree = ""; }; + 47CC5A6C9F18F716799F484C /* AgentWorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkspaceStore.swift; sourceTree = ""; }; + 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesChatView.swift; sourceTree = ""; }; + 4CF3076E2CD78CCA495DCDCB /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowNavigation.swift; sourceTree = ""; }; + 50668C95CB17560B1073D973 /* DatabaseZoomMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseZoomMetrics.swift; sourceTree = ""; }; + 50E2AE42817B3F7E154211B1 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; + 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 = ""; }; + 5C6265630F0923203C1F9764 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 5D16799A267ED55B558F74BD /* Agent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Agent.swift; sourceTree = ""; }; + 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineRowPeekPanel.swift; sourceTree = ""; }; + 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsSettingsView.swift; sourceTree = ""; }; + 661BA33DC8D61D2884B32072 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugbookUITests.swift; sourceTree = ""; }; + 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterService.swift; sourceTree = ""; }; + 704F620630C8D38A78C3DA37 /* DatabaseFullPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFullPageView.swift; sourceTree = ""; }; + 70DE6960E273C11B104C1DD9 /* IndexManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexManager.swift; sourceTree = ""; }; + 71023CB4011C219BC7514109 /* InlineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineStyle.swift; sourceTree = ""; }; + 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorDraftStore.swift; sourceTree = ""; }; + 746E6FCF54D009487CB47D71 /* ShellZoomMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShellZoomMetrics.swift; sourceTree = ""; }; + 74D38DE480A02B3136213525 /* BlockEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockEditorView.swift; sourceTree = ""; }; + 753ED053F39EBB80B7270496 /* CodeBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeBlockView.swift; sourceTree = ""; }; + 77D74F02ED2A5522822B783A /* GripDotsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GripDotsView.swift; sourceTree = ""; }; + 780A13E34CC19B07D99EFF46 /* ColumnBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColumnBlockView.swift; sourceTree = ""; }; + 79DDB549EBE9DC5DAE9FB8CE /* RowSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowSerializer.swift; sourceTree = ""; }; + 7A438D1B1F3111A465125F0E /* BreadcrumbItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItem.swift; sourceTree = ""; }; + 7C17D091E88E0D5554BE1F38 /* TabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarView.swift; sourceTree = ""; }; + 7EC5A7963942301B2BBEAAEF /* RenderLoopDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderLoopDetector.swift; sourceTree = ""; }; + 7F257D6BA8D9A6DCAA4EB896 /* DatabaseRowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowViewModel.swift; sourceTree = ""; }; + 808CF46B8BE4372BB9E904FC /* WorkspaceCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCalendarView.swift; sourceTree = ""; }; + 82603F77EB0544EA989F4BE7 /* EditorUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorUIState.swift; sourceTree = ""; }; + 82D1A2D4E326152C4B324139 /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = ""; }; + 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragPreview.swift; sourceTree = ""; }; + 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPeekState.swift; sourceTree = ""; }; + 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettingsView.swift; sourceTree = ""; }; + 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarWeekView.swift; sourceTree = ""; }; + 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptionService.swift; sourceTree = ""; }; + 9365350F95628047D144D9F7 /* FormattingToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbar.swift; sourceTree = ""; }; + 968A3076C851ABC04C18EC51 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; + 99E3355E0062163300B5893F /* BugbookCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = BugbookCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + A52CFB20317770DA9DB4D732 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; + A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseService.swift; sourceTree = ""; }; + A7712E15B025309AF9EAF67D /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; + A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabasePointerCursor.swift; sourceTree = ""; }; + AE400CA9718CD5697976941B /* BlockTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockTextView.swift; sourceTree = ""; }; + AF7A98A5ECBDDCDEA159DDBE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AF7FE7EFE7F26CAA875F2754 /* DatabaseInlineEmbedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseInlineEmbedView.swift; sourceTree = ""; }; + B116D36FE355D650ADC6B0F6 /* DatabaseRowFullPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowFullPageView.swift; sourceTree = ""; }; + B1FDBEE7FFDAA06C8410D140 /* DatabaseDateValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseDateValue.swift; sourceTree = ""; }; + B2421DB7DD227294C11A1041 /* KanbanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KanbanView.swift; sourceTree = ""; }; + B306A8D5E45215DD58F3FF0D /* DatabaseViewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseViewHelpers.swift; sourceTree = ""; }; + B384938321E8648F5447AE4D /* PageIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageIcon.swift; sourceTree = ""; }; + B553FF577D3D25B20DA5DAC5 /* FormattingToolbarPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingToolbarPanel.swift; sourceTree = ""; }; + B6639981D84F86643DBB91CD /* AISettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISettingsView.swift; sourceTree = ""; }; + BAB082A1507F6A50D99EEF49 /* DatabaseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseStore.swift; sourceTree = ""; }; + BF65B82523BECFB622E38CCA /* BreadcrumbView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbView.swift; sourceTree = ""; }; + C2262869710B320C9D232D7B /* CalendarEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventStore.swift; sourceTree = ""; }; + C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNoteService.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 = ""; }; + C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = ""; }; + C74982BB99A95A4D3C5FDDFB /* MarkdownBlockParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBlockParser.swift; sourceTree = ""; }; + C7935F81E8213A92201CFAA6 /* BugbookApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BugbookApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C864B6F1244A0E7F07EE260A /* FullEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullEmojiPickerView.swift; sourceTree = ""; }; + C89D0872A52B79E2B456963C /* RowPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowPageView.swift; sourceTree = ""; }; + C8A5DE98CE38F83FE2B092CF /* CoverPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverPickerView.swift; sourceTree = ""; }; + CFF32AEDA72E376BBC436395 /* PropertyEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertyEditorView.swift; sourceTree = ""; }; + 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; }; + DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayView.swift; sourceTree = ""; }; + DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiSidePanelView.swift; sourceTree = ""; }; + E071A97E9E6B0892FA767898 /* BacklinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BacklinkService.swift; sourceTree = ""; }; + E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlashCommandMenu.swift; sourceTree = ""; }; + E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystemService.swift; sourceTree = ""; }; + E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovePagePickerView.swift; sourceTree = ""; }; + E5CF30FE4A58B2212047CA84 /* FileTreeItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileTreeItemView.swift; sourceTree = ""; }; + E5E33F7472EFFA78D901306D /* TextBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBlockView.swift; sourceTree = ""; }; + E99B66C38F452E8D9E75E81F /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.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 = ""; }; + EFF0F24D7EAF03362E0B0441 /* QmdService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QmdService.swift; sourceTree = ""; }; + F0E08AB3843737425588F4B7 /* DatabaseEmbedPathResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseEmbedPathResolver.swift; sourceTree = ""; }; + F3E007AA9E5194AC13E45B4A /* AiContextItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiContextItem.swift; sourceTree = ""; }; + F47A85AD47A48232C52EB55B /* BugbookApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugbookApp.swift; sourceTree = ""; }; + F47EF03A6E9A932800D5A95E /* Row.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Row.swift; sourceTree = ""; }; + F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEvent.swift; sourceTree = ""; }; + F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagePickerView.swift; sourceTree = ""; }; + + CEA1EF8A52B349B7B059D761 /* MeetingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsView.swift; sourceTree = ""; }; + 9CCDA7C2E3A7A7713D6F17B7 /* MeetingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingsViewModel.swift; sourceTree = ""; }; + 14550A7FA261AC8F2B6D85F3 /* DatabaseTemplatePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTemplatePickerView.swift; sourceTree = ""; }; + 39AFADB4D74E4EB502810F64 /* DatabaseTemplateEditorModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTemplateEditorModal.swift; sourceTree = ""; }; + FA7E110D3A8E8C645366B20E /* DatabaseRowModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseRowModalView.swift; sourceTree = ""; }; + FC5A24662D72186FD809E326 /* AgentWorkspaceTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentWorkspaceTemplate.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1B95CDF35EF986ABC011DFCE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AF07B36AE2F3E64AE73487EB /* BugbookCore.framework in Frameworks */, + DCFE62E13519F6558F86E4D4 /* Sparkle in Frameworks */, + 0150805DFE2AF45CF4B0E7DC /* Sentry in Frameworks */, + A1B2C3D4E5F60718A9B0C1D2 /* FluidAudio in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0E0596AE897FCCE0A018DAA8 /* Bugbook */ = { + isa = PBXGroup; + children = ( + 99A769FA75817DC84A883F15 /* App */, + D0C085F6BB0BCE9846714A8A /* Debug */, + 755E726FCF5D458D5AC786CD /* Extensions */, + 241CF1EFDA2CCA146EE8F121 /* Lib */, + 1B4C22DE16CEBDAEF95DE127 /* Models */, + 916BED7AF73337FAFDA118D6 /* Services */, + 809E72A7F64540EC6AD611BF /* ViewModels */, + 702E30834E8870C340C4D3C0 /* Views */, + ); + name = Bugbook; + path = ../Sources/Bugbook; + sourceTree = ""; + }; + 1B4C22DE16CEBDAEF95DE127 /* Models */ = { + isa = PBXGroup; + children = ( + F3E007AA9E5194AC13E45B4A /* AiContextItem.swift */, + 12E66496064A29930C3F3B17 /* AppSettings.swift */, + 398B466A4F5F82ACEA4874F6 /* Block.swift */, + 2CA9558FB1FA2DA5139D1590 /* BlockColor.swift */, + 2E86B6CFB16AAD4303BA786E /* BlockDocument.swift */, + 7A438D1B1F3111A465125F0E /* BreadcrumbItem.swift */, + 968A3076C851ABC04C18EC51 /* ChatMessage.swift */, + 4F75C5D5B9E8CBB44ACDA63D /* DatabaseRowNavigation.swift */, + 189FFD8CFB64645025DB5876 /* FileEntry.swift */, + 71023CB4011C219BC7514109 /* InlineStyle.swift */, + 0F534031257A5C2CBDA600E6 /* OpenFile.swift */, + B384938321E8648F5447AE4D /* PageIcon.swift */, + EAE175C836C7455A3A8153B2 /* SidebarReferenceDragPayload.swift */, + ); + path = Models; + sourceTree = ""; + }; + 1ED7ED05A5E6C3A9F7EC74AE /* Storage */ = { + isa = PBXGroup; + children = ( + 47CC5A6C9F18F716799F484C /* AgentWorkspaceStore.swift */, + C2262869710B320C9D232D7B /* CalendarEventStore.swift */, + BAB082A1507F6A50D99EEF49 /* DatabaseStore.swift */, + 70DE6960E273C11B104C1DD9 /* IndexManager.swift */, + 79DDB549EBE9DC5DAE9FB8CE /* RowSerializer.swift */, + 9FC779B217122291C509E482 /* RowStore.swift */, + ); + path = Storage; + sourceTree = ""; + }; + 241CF1EFDA2CCA146EE8F121 /* Lib */ = { + isa = PBXGroup; + children = ( + 2A24DB9620D6BC27A171BE48 /* AttributedStringConverter.swift */, + F0E08AB3843737425588F4B7 /* DatabaseEmbedPathResolver.swift */, + C74982BB99A95A4D3C5FDDFB /* MarkdownBlockParser.swift */, + A436514B7FF9F95D479AF4FF /* MarkdownParser.swift */, + ); + path = Lib; + sourceTree = ""; + }; + 2579331778CC5EC6DB286690 /* Editor */ = { + isa = PBXGroup; + children = ( + 3B1D94EABC044FCDB416EC80 /* BlockCellView.swift */, + 74D38DE480A02B3136213525 /* BlockEditorView.swift */, + 29502EEA0A700D17E1A1E809 /* BlockMenuView.swift */, + AE400CA9718CD5697976941B /* BlockTextView.swift */, + 3A9D73232AA7B54B9A6C8C58 /* BlockViews.swift */, + 753ED053F39EBB80B7270496 /* CodeBlockView.swift */, + 780A13E34CC19B07D99EFF46 /* ColumnBlockView.swift */, + + 9365350F95628047D144D9F7 /* FormattingToolbar.swift */, + B553FF577D3D25B20DA5DAC5 /* FormattingToolbarPanel.swift */, + 77D74F02ED2A5522822B783A /* GripDotsView.swift */, + 00F20EDB7EB07349C54B1DB8 /* HeadingToggleBlockView.swift */, + 38503CD6DD60C57D352CA45A /* MeetingBlockView.swift */, + C4A7E91B3F5D82A0E6B10C47 /* MeetingNotesEditor.swift */, + 1DA108034A6C3859C95A72DA /* PageHeaderView.swift */, + F772F851E9A873C8CC70B6C0 /* PagePickerView.swift */, + E14D46F5252C474875D2AE94 /* SlashCommandMenu.swift */, + E5E33F7472EFFA78D901306D /* TextBlockView.swift */, + A2E6CAEA3C923DDA24C7C36A /* ToggleBlockView.swift */, + 28DEFDD7A7DE801DEDBF0890 /* WikiLinkView.swift */, + ); + path = Editor; + sourceTree = ""; + }; + 2754E5B93E59EFBAE07F52F4 /* AI */ = { + isa = PBXGroup; + children = ( + DE522C3AF53714E545B79D65 /* AiSidePanelView.swift */, + 4BF275F9F3243851AD6E2B97 /* NotesChatView.swift */, + ); + path = AI; + sourceTree = ""; + }; + 2CCC42D8987196F88C0F0468 /* Agent */ = { + isa = PBXGroup; + children = ( + EC7A3DE49ABD6E0CA5243482 /* AgentHubView.swift */, + ); + path = Agent; + sourceTree = ""; + }; + 2D9FAA880F06271E0BDB3FD8 /* Calendar */ = { + isa = PBXGroup; + children = ( + DBE7800E7CF8238041F8CE22 /* CalendarDayView.swift */, + 01B1E2BE4DD4A5DBE8AAD786 /* CalendarMonthView.swift */, + 8F96FFF4F172FE5AAB4E44BC /* CalendarWeekView.swift */, + 425B08EB298C262A170FC229 /* ViewModePickerButton.swift */, + 808CF46B8BE4372BB9E904FC /* WorkspaceCalendarView.swift */, + ); + path = Calendar; + sourceTree = ""; + }; + 742387093FAE0CDF04685116 /* Meetings */ = { + isa = PBXGroup; + children = ( + CEA1EF8A52B349B7B059D761 /* MeetingsView.swift */, + ); + path = Meetings; + sourceTree = ""; + }; + 3586EF5D600822B9681D1866 /* Components */ = { + isa = PBXGroup; + children = ( + BF65B82523BECFB622E38CCA /* BreadcrumbView.swift */, + 247B9DED846DAF0568FF163E /* CommandPaletteView.swift */, + C8A5DE98CE38F83FE2B092CF /* CoverPickerView.swift */, + 0E2720BDB254FE084FC3EFBF /* FloatingRecordingPill.swift */, + C864B6F1244A0E7F07EE260A /* FullEmojiPickerView.swift */, + E3F3E4BACD1A0D354E4A9DAA /* MovePagePickerView.swift */, + 746E6FCF54D009487CB47D71 /* ShellZoomMetrics.swift */, + 83ED16F6D4CB9ADE91631B42 /* SidebarDragPreview.swift */, + 7C17D091E88E0D5554BE1F38 /* TabBarView.swift */, + 1075BF7C8AE8B706A1799531 /* TemplatePickerView.swift */, + A7712E15B025309AF9EAF67D /* WelcomeView.swift */, + ); + path = Components; + sourceTree = ""; + }; + 3B7E87FA2AD5CC2E60AC7EF4 /* Settings */ = { + isa = PBXGroup; + children = ( + 57BA5285DC6913D9E172BA99 /* AgentsSettingsView.swift */, + B6639981D84F86643DBB91CD /* AISettingsView.swift */, + C73A60E01C701F79EC3BE8EF /* AppearanceSettingsView.swift */, + 8CCBC4202AE20197A13962CD /* CalendarSettingsView.swift */, + C72383D5865CC78156A21463 /* GeneralSettingsView.swift */, + 37262953A2E00C21609C6836 /* SearchSettingsView.swift */, + 661BA33DC8D61D2884B32072 /* SettingsView.swift */, + 61DF2C9164D855838AD0F272 /* ShortcutsSettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 469727F1AEE18B6B51A4914F /* Workspace */ = { + isa = PBXGroup; + children = ( + FC5A24662D72186FD809E326 /* AgentWorkspaceTemplate.swift */, + 579A2C2A019CFEAC7344FE9C /* WorkspacePathRules.swift */, + ); + path = Workspace; + sourceTree = ""; + }; + 6C04871C6062C414F232ACF2 /* App */ = { + isa = PBXGroup; + children = ( + AF7A98A5ECBDDCDEA159DDBE /* Assets.xcassets */, + ); + path = App; + sourceTree = ""; + }; + 702E30834E8870C340C4D3C0 /* Views */ = { + isa = PBXGroup; + children = ( + 5C6265630F0923203C1F9764 /* ContentView.swift */, + 2CCC42D8987196F88C0F0468 /* Agent */, + 2754E5B93E59EFBAE07F52F4 /* AI */, + 2D9FAA880F06271E0BDB3FD8 /* Calendar */, + 742387093FAE0CDF04685116 /* Meetings */, + 3586EF5D600822B9681D1866 /* Components */, + B16E9F553D2EDB723B3CDFF8 /* Database */, + 2579331778CC5EC6DB286690 /* Editor */, + C575E14DD4F6A30BEA81C1B4 /* Graph */, + 3B7E87FA2AD5CC2E60AC7EF4 /* Settings */, + 9FAA7A86768FCC279708E6D2 /* Sidebar */, + ); + path = Views; + sourceTree = ""; + }; + 755E726FCF5D458D5AC786CD /* Extensions */ = { + isa = PBXGroup; + children = ( + 27DF0CADED8EC505FAAC6252 /* Color+Theme.swift */, + 50E2AE42817B3F7E154211B1 /* DesignTokens.swift */, + EBC605E52D91C41E419FBF35 /* FloatingPopover.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 809E72A7F64540EC6AD611BF /* ViewModels */ = { + isa = PBXGroup; + children = ( + 15F843B79C76EBFBEB1F6757 /* AgentHubViewModel.swift */, + 257DD331DD4F07DC2016F504 /* CalendarViewModel.swift */, + 82603F77EB0544EA989F4BE7 /* EditorUIState.swift */, + 9CCDA7C2E3A7A7713D6F17B7 /* MeetingsViewModel.swift */, + 85F0C9E480C148703802DED8 /* SidebarPeekState.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 81AEC0B0CCBE4BD108764522 /* Engine */ = { + isa = PBXGroup; + children = ( + 40FCF6F845DD2446799C49B5 /* MutationEngine.swift */, + 1A49740409C913AF7DE543D6 /* QueryEngine.swift */, + 3DFE4267C5991F2C8E05CB7D /* RelationResolver.swift */, + 27404EBA915A3E0AEE0D7483 /* SchemaValidator.swift */, + ); + path = Engine; + sourceTree = ""; + }; + 916BED7AF73337FAFDA118D6 /* Services */ = { + isa = PBXGroup; + children = ( + C68748E44202E7C8D638C24B /* AiService.swift */, + E071A97E9E6B0892FA767898 /* BacklinkService.swift */, + 59394152B4415173A37EE498 /* CalendarService.swift */, + A5E14D6DCB62FFAE7B309EF8 /* DatabaseService.swift */, + 727128AD995E5A86B60ADCBE /* EditorDraftStore.swift */, + E2B315BD3D01519D8E6333A3 /* FileSystemService.swift */, + 4CF3076E2CD78CCA495DCDCB /* Logger.swift */, + C2AE3B0EB300B57FD895F994 /* MeetingNoteService.swift */, + 22439903C988ACDC2263371A /* OnboardingService.swift */, + EFF0F24D7EAF03362E0B0441 /* QmdService.swift */, + 9274E48D0A2FC7763D010CE0 /* TranscriptionService.swift */, + 6BCEABF05B6D00E71C8FA23D /* UpdaterService.swift */, + 2B24F4424F4FA89F1B070108 /* WorkspaceWatcher.swift */, + ); + path = Services; + sourceTree = ""; + }; + 99A769FA75817DC84A883F15 /* App */ = { + isa = PBXGroup; + children = ( + E99B66C38F452E8D9E75E81F /* AppState.swift */, + F47A85AD47A48232C52EB55B /* BugbookApp.swift */, + ); + path = App; + sourceTree = ""; + }; + 9FAA7A86768FCC279708E6D2 /* Sidebar */ = { + isa = PBXGroup; + children = ( + E5CF30FE4A58B2212047CA84 /* FileTreeItemView.swift */, + A2C165B4FFDB916884D4293C /* FileTreeView.swift */, + 1036E491194645E064E4D08B /* SidebarView.swift */, + 33262B570A72E09F2E88ECF2 /* TrashView.swift */, + ); + path = Sidebar; + sourceTree = ""; + }; + B16E9F553D2EDB723B3CDFF8 /* Database */ = { + isa = PBXGroup; + children = ( + 2E42F6C36FA91D0AC7918622 /* CalendarView.swift */, + 704F620630C8D38A78C3DA37 /* DatabaseFullPageView.swift */, + AF7FE7EFE7F26CAA875F2754 /* DatabaseInlineEmbedView.swift */, + A8372AADDC80570EC6777DEF /* DatabasePointerCursor.swift */, + B116D36FE355D650ADC6B0F6 /* DatabaseRowFullPageView.swift */, + FA7E110D3A8E8C645366B20E /* DatabaseRowModalView.swift */, + 14550A7FA261AC8F2B6D85F3 /* DatabaseTemplatePickerView.swift */, + 39AFADB4D74E4EB502810F64 /* DatabaseTemplateEditorModal.swift */, + 7F257D6BA8D9A6DCAA4EB896 /* DatabaseRowViewModel.swift */, + B306A8D5E45215DD58F3FF0D /* DatabaseViewHelpers.swift */, + 2F67AB5BB131FE8C947973D2 /* DatabaseViewState.swift */, + 50668C95CB17560B1073D973 /* DatabaseZoomMetrics.swift */, + 61811B37BBD5AE6F10496772 /* InlineRowPeekPanel.swift */, + B2421DB7DD227294C11A1041 /* KanbanView.swift */, + 82D1A2D4E326152C4B324139 /* ListView.swift */, + CFF32AEDA72E376BBC436395 /* PropertyEditorView.swift */, + C89D0872A52B79E2B456963C /* RowPageView.swift */, + 21D7150DDE83E7ED425899B1 /* TableView.swift */, + ); + path = Database; + sourceTree = ""; + }; + B9ACAF37F4AD4DE19AB6BDF4 /* BugbookCore */ = { + isa = PBXGroup; + children = ( + 81AEC0B0CCBE4BD108764522 /* Engine */, + C4FFFDABC2D6E5FBD57F6EB8 /* Model */, + 1ED7ED05A5E6C3A9F7EC74AE /* Storage */, + 469727F1AEE18B6B51A4914F /* Workspace */, + ); + name = BugbookCore; + path = ../Sources/BugbookCore; + sourceTree = ""; + }; + C4FFFDABC2D6E5FBD57F6EB8 /* Model */ = { + isa = PBXGroup; + children = ( + 5D16799A267ED55B558F74BD /* Agent.swift */, + F6B580BF5FABDE8C0C3CF4D6 /* CalendarEvent.swift */, + B1FDBEE7FFDAA06C8410D140 /* DatabaseDateValue.swift */, + ED7530043DEEF28753C2CD6C /* Query.swift */, + F47EF03A6E9A932800D5A95E /* Row.swift */, + D399795586ADE5448AB72D1C /* Schema.swift */, + A52CFB20317770DA9DB4D732 /* View.swift */, + ); + path = Model; + sourceTree = ""; + }; + C575E14DD4F6A30BEA81C1B4 /* Graph */ = { + isa = PBXGroup; + children = ( + ED677E977CBBB33D0ECA74CE /* GraphView.swift */, + ); + path = Graph; + sourceTree = ""; + }; + C62E41F6C7F9C9F59BB7D2C2 = { + isa = PBXGroup; + children = ( + 6C04871C6062C414F232ACF2 /* App */, + 0E0596AE897FCCE0A018DAA8 /* Bugbook */, + B9ACAF37F4AD4DE19AB6BDF4 /* BugbookCore */, + E790AFC40FB904B5B41C0C85 /* BugbookUITests */, + D0BD45773DE8E09C582B07E9 /* Products */, + ); + sourceTree = ""; + }; + D0BD45773DE8E09C582B07E9 /* Products */ = { + isa = PBXGroup; + children = ( + C7935F81E8213A92201CFAA6 /* BugbookApp.app */, + 99E3355E0062163300B5893F /* BugbookCore.framework */, + D3F977D14DD3400DC5FF3583 /* BugbookUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + D0C085F6BB0BCE9846714A8A /* Debug */ = { + isa = PBXGroup; + children = ( + 7EC5A7963942301B2BBEAAEF /* RenderLoopDetector.swift */, + ); + path = Debug; + sourceTree = ""; + }; + E790AFC40FB904B5B41C0C85 /* BugbookUITests */ = { + isa = PBXGroup; + children = ( + 669FD71E1F644E8CB4034449 /* BugbookUITests.swift */, + ); + name = BugbookUITests; + path = ../Tests/BugbookUITests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2B3782ED2239243A34FB86B2 /* BugbookUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D4D99567B77C37D82D16832D /* Build configuration list for PBXNativeTarget "BugbookUITests" */; + buildPhases = ( + 31E886E7EF944325944FAB5B /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 901EE8084F9554105AB8B922 /* PBXTargetDependency */, + ); + name = BugbookUITests; + packageProductDependencies = ( + ); + productName = BugbookUITests; + productReference = D3F977D14DD3400DC5FF3583 /* BugbookUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 8DD6EDE4722CA92DDB665594 /* BugbookApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6E50D827484861D24575A01E /* Build configuration list for PBXNativeTarget "BugbookApp" */; + buildPhases = ( + 8571754AABFF38B6826DF33A /* Sources */, + B50CDE78BBB41AD95526BB61 /* Resources */, + 1B95CDF35EF986ABC011DFCE /* Frameworks */, + F27336E2ABFB64ABFE05709A /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 90A8427D78C82A57D97C22FC /* PBXTargetDependency */, + ); + name = BugbookApp; + packageProductDependencies = ( + 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */, + 093BE9A2C3A2769A90DE0579 /* Sentry */, + D2C1B0A918076F5E4D3C2B1A /* FluidAudio */, + ); + productName = BugbookApp; + productReference = C7935F81E8213A92201CFAA6 /* BugbookApp.app */; + productType = "com.apple.product-type.application"; + }; + E1B57C400F29488DA7523D56 /* BugbookCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8931F00580AE1D1C63E8EEED /* Build configuration list for PBXNativeTarget "BugbookCore" */; + buildPhases = ( + 7AE63D94A750D80B2F28D8CF /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BugbookCore; + packageProductDependencies = ( + ); + productName = BugbookCore; + productReference = 99E3355E0062163300B5893F /* BugbookCore.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 748DCF6AC60831FC7058F9CD /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 2B3782ED2239243A34FB86B2 = { + TestTargetID = 8DD6EDE4722CA92DDB665594; + }; + 8DD6EDE4722CA92DDB665594 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = FFCF0AB159C599DECC637737 /* Build configuration list for PBXProject "Bugbook" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = C62E41F6C7F9C9F59BB7D2C2; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */, + E7F8A9B0C1D2E3F4A5B6C7D8 /* XCRemoteSwiftPackageReference "FluidAudio" */, + ); + preferredProjectObjectVersion = 77; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8DD6EDE4722CA92DDB665594 /* BugbookApp */, + E1B57C400F29488DA7523D56 /* BugbookCore */, + 2B3782ED2239243A34FB86B2 /* BugbookUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B50CDE78BBB41AD95526BB61 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7EF33F80D6BB6B2F0A6CBD74 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 31E886E7EF944325944FAB5B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 85B4001C6EF6E27F2017560E /* BugbookUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7AE63D94A750D80B2F28D8CF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 73733159AA52E171C502A90A /* Agent.swift in Sources */, + ABA26E21B59748870EB509CE /* AgentWorkspaceStore.swift in Sources */, + 694EBF21623BCEA74940150D /* AgentWorkspaceTemplate.swift in Sources */, + BE90067291DE6CF6F3B58A69 /* CalendarEvent.swift in Sources */, + 1A69CBC3E525B070D8FCC902 /* CalendarEventStore.swift in Sources */, + 7599744149050661CCF8FB27 /* DatabaseDateValue.swift in Sources */, + D5A7B7D7582EC21C39092E60 /* DatabaseStore.swift in Sources */, + 9E7E6AC9222DBAAC08FAD6BF /* IndexManager.swift in Sources */, + 316D6517CC93BFFDDAC5F7A6 /* MutationEngine.swift in Sources */, + 240EB5675B13CEE875BD0E72 /* Query.swift in Sources */, + 99B81B2510FEA9BB0B092324 /* QueryEngine.swift in Sources */, + F7250EE969D2D3094CD9172E /* RelationResolver.swift in Sources */, + 1364B73C31B34B4A59420743 /* Row.swift in Sources */, + 5AC81CC4AEA7FE4D4424DEE4 /* RowSerializer.swift in Sources */, + F6196917751CF468444068C8 /* RowStore.swift in Sources */, + 76139D5A678EE0329AD1280B /* Schema.swift in Sources */, + D33327707F2E0FFCE3EDFB12 /* SchemaValidator.swift in Sources */, + A73CCA52E1763E46BA738B35 /* View.swift in Sources */, + A11F532663FB6DCE02C0C4ED /* WorkspacePathRules.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8571754AABFF38B6826DF33A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BD8320F85A870BCF0237959A /* AISettingsView.swift in Sources */, + 74D36D5BBF4F6AD3BBF4076D /* AgentHubView.swift in Sources */, + 3AA74BAA18B9F57D4266402F /* AgentHubViewModel.swift in Sources */, + 0044A6D4562A8D96730E1E8E /* AgentsSettingsView.swift in Sources */, + C615A4810265456A1BFD7665 /* AiContextItem.swift in Sources */, + D809F8300B119795D5F00A8B /* AiService.swift in Sources */, + 7ED64068786FC4125769B869 /* AiSidePanelView.swift in Sources */, + 290D6457E202795F776249B1 /* AppSettings.swift in Sources */, + 7C8D7191B8B1FF31105C4608 /* AppState.swift in Sources */, + EAC88178F0063A660612B990 /* AppearanceSettingsView.swift in Sources */, + 4E4724D2134A490B31D2441E /* AttributedStringConverter.swift in Sources */, + 609E0123537ADDC449A1027F /* BacklinkService.swift in Sources */, + 60473B1C3084B649F5458D8D /* Block.swift in Sources */, + E89317C6440E0357A8F3D179 /* BlockCellView.swift in Sources */, + 868E88F7B10FE2B5E3928632 /* BlockColor.swift in Sources */, + 26EDC26A3C6E5D61E5852773 /* BlockDocument.swift in Sources */, + 209D01AA836D71692841F67F /* BlockEditorView.swift in Sources */, + E39A529A05BAE723F9AB5BB4 /* BlockMenuView.swift in Sources */, + C3D3D68199CF617D60D3A773 /* BlockTextView.swift in Sources */, + AB8B8319C66ACD0F141B3EEE /* BlockViews.swift in Sources */, + 8C96674A0DB8992FC7B22444 /* BreadcrumbItem.swift in Sources */, + E3BB767C73688C6F648CD743 /* BreadcrumbView.swift in Sources */, + 5475FA131B72603118B5EE61 /* BugbookApp.swift in Sources */, + C145A3D5D629947B2A390937 /* CalendarDayView.swift in Sources */, + 569B127B96923692E102B939 /* CalendarMonthView.swift in Sources */, + CBB52CB1DEEB63DEF5B95C42 /* CalendarService.swift in Sources */, + 10C4018F30AC23F772A1ACC8 /* CalendarSettingsView.swift in Sources */, + 85EC0FBC8780F03F55A760F1 /* CalendarView.swift in Sources */, + 79B94AC24F342F11305894B8 /* CalendarViewModel.swift in Sources */, + 4C7AB83E38D1649A967CFF40 /* CalendarWeekView.swift in Sources */, + 09871C22EFFDCF1EEF0A47AF /* ChatMessage.swift in Sources */, + 640881FBAEA115C91FA7BABF /* CodeBlockView.swift in Sources */, + 349CC754CCA1FEBE8A961431 /* Color+Theme.swift in Sources */, + 4F1134DFB5BF17CC4D9E671A /* ColumnBlockView.swift in Sources */, + 6653858C5D746784F2AFC62B /* CommandPaletteView.swift in Sources */, + F65C1F27B0D5F2963A1BAAB7 /* ContentView.swift in Sources */, + DF60FC019C9D04629FF291AA /* CoverPickerView.swift in Sources */, + 80083188D4B8A228121759CD /* DatabaseEmbedPathResolver.swift in Sources */, + 72E6B7E574BFFAB11C8E0EE9 /* DatabaseFullPageView.swift in Sources */, + 26885DC83160B601DCBB61BB /* DatabaseInlineEmbedView.swift in Sources */, + 38F21365F5B64137505F3CFB /* DatabasePointerCursor.swift in Sources */, + DBF41C1912A5B93FD20BDE90 /* DatabaseRowFullPageView.swift in Sources */, + D1C4FAB8A0DA90CAD2C9409B /* DatabaseRowModalView.swift in Sources */, + C96F12200797FBD9B89C526F /* MeetingsView.swift in Sources */, + 6E9339DB84F7B0318CDEAC63 /* MeetingsViewModel.swift in Sources */, + 7D310A6D0ECBC4B528D28F51 /* DatabaseTemplatePickerView.swift in Sources */, + B832DFBA333107769C264F8E /* DatabaseTemplateEditorModal.swift in Sources */, + 8EC461617F98B3FDD8069B3E /* DatabaseRowNavigation.swift in Sources */, + 2BDEEDC610523F8472D8FF1A /* DatabaseRowViewModel.swift in Sources */, + E45345FAE65412E3DFFB0272 /* DatabaseService.swift in Sources */, + 7E22DC9B531139E72F7A045C /* DatabaseViewHelpers.swift in Sources */, + A86D81D4F21E32011176CB04 /* DatabaseViewState.swift in Sources */, + 55300AEF3CA2B3AABA0FF66D /* DatabaseZoomMetrics.swift in Sources */, + 70A9F55EB761E74043D75928 /* DesignTokens.swift in Sources */, + 0ECCD65D875F55E2148FF871 /* EditorDraftStore.swift in Sources */, + AAACB1E47FC69E8DFD7D3D27 /* EditorUIState.swift in Sources */, + 55AED705FAC0BD8F729640FD /* FileEntry.swift in Sources */, + C967897AA5001A491AF24AC9 /* FileSystemService.swift in Sources */, + 90DDA2F7FC61E7CCE3304A2A /* FileTreeItemView.swift in Sources */, + 7C3131421C95964975D5B5FB /* FileTreeView.swift in Sources */, + + 203CE38A21215FE7D2ED90C2 /* FloatingPopover.swift in Sources */, + 30CE7A2C7065E84D29BA2E59 /* FloatingRecordingPill.swift in Sources */, + 5071CA6077BA5B0723C3AFE1 /* FormattingToolbar.swift in Sources */, + F63BE3F6A864D6E246D47F8B /* FormattingToolbarPanel.swift in Sources */, + E70E530BB9DFC76494536EAB /* FullEmojiPickerView.swift in Sources */, + 045B1A2FD850725900D6FC22 /* GeneralSettingsView.swift in Sources */, + 1C60B08005D500C4166E9E80 /* GraphView.swift in Sources */, + 28CD0ADFAEBE907873EAE00B /* GripDotsView.swift in Sources */, + 1F53E22405A92296FC9615E2 /* HeadingToggleBlockView.swift in Sources */, + B7A63C757BE15766F127FAB6 /* InlineRowPeekPanel.swift in Sources */, + 7F1F65616C0C82D89BEA6B5E /* InlineStyle.swift in Sources */, + 51AB2AEE8B0AD275F03073F6 /* KanbanView.swift in Sources */, + 41093BBBDD10E3C59B63F7F0 /* ListView.swift in Sources */, + 31319D254BD21B3105E098D8 /* Logger.swift in Sources */, + 3D9B4F3DBA52CA2B67CFBBB8 /* MarkdownBlockParser.swift in Sources */, + BAC17E1581B72C2E679063F4 /* MarkdownParser.swift in Sources */, + 0D60BAED04EE7FA14331418C /* MeetingBlockView.swift in Sources */, + 5E8D2F63A1C04B79D2370E18 /* MeetingNotesEditor.swift in Sources */, + 7EDA4964EE97D79FFE09EF59 /* MeetingNoteService.swift in Sources */, + D231B9D2E5E28A22F7DC5659 /* MovePagePickerView.swift in Sources */, + 3AAA59CAFE542617EFFD989E /* NotesChatView.swift in Sources */, + CBDA6D4C212A797378E7C062 /* OnboardingService.swift in Sources */, + 71150606FE80AB392A835F21 /* OpenFile.swift in Sources */, + D029F4E3DFFC61628D54BE90 /* PageHeaderView.swift in Sources */, + F989CBE88DBF74238D485E14 /* PageIcon.swift in Sources */, + A017A5DD5FB8198FEEBAC1D1 /* PagePickerView.swift in Sources */, + EF9C6DDF179DD89F6FADB062 /* PropertyEditorView.swift in Sources */, + 6B4F10DF5DC543A9054FC61E /* QmdService.swift in Sources */, + D76DEEE7E4AF655BCC15E804 /* RenderLoopDetector.swift in Sources */, + DC399F897D16862221AE55FF /* RowPageView.swift in Sources */, + DA7A9136A777D4A1E7E649A8 /* SearchSettingsView.swift in Sources */, + AA252EC507F381DB0A35F976 /* SettingsView.swift in Sources */, + 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 */, + 0FF49E848A57B76FEB3FF655 /* SlashCommandMenu.swift in Sources */, + 1E82C25E8134A1038E2D96A6 /* TabBarView.swift in Sources */, + BB63147ADC84BB1CB310FAD6 /* TableView.swift in Sources */, + CE6A8D6D7B08D23CE62776BE /* TemplatePickerView.swift in Sources */, + 84F54BA37F46B7DD22EC20D9 /* TextBlockView.swift in Sources */, + AA958FA1B1E64E24C676E3B8 /* ToggleBlockView.swift in Sources */, + A5C00BCC3313FC7DADD70BFA /* TranscriptionService.swift in Sources */, + 76E3F554517581E83297A297 /* TrashView.swift in Sources */, + B77203F53F96C299D954EC1F /* UpdaterService.swift in Sources */, + 2AD1B1200C4938F37EFF82CA /* ViewModePickerButton.swift in Sources */, + D3925CE4995D456C8362FD41 /* WelcomeView.swift in Sources */, + 7DDC7E5E799945BE79D9434F /* WikiLinkView.swift in Sources */, + B62BD30B7E98CA7476B89377 /* WorkspaceCalendarView.swift in Sources */, + 736679A853BAC31DAE966F2D /* WorkspaceWatcher.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 901EE8084F9554105AB8B922 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8DD6EDE4722CA92DDB665594 /* BugbookApp */; + targetProxy = 700FC8BAD6EC52AFAA8DE8DB /* PBXContainerItemProxy */; + }; + 90A8427D78C82A57D97C22FC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E1B57C400F29488DA7523D56 /* BugbookCore */; + targetProxy = 2253322A3195258138D98182 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 16EFF6D86D8F5C5A404E7A48 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "App/BugbookCore-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.BugbookCore; + PRODUCT_MODULE_NAME = BugbookCore; + PRODUCT_NAME = BugbookCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; + 8784C01F0774A3E0FD0C564F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 93540112F2D4595B56B8365F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.BugbookUITests; + SDKROOT = macosx; + TEST_TARGET_NAME = BugbookApp; + }; + name = Debug; + }; + 98EB10397FF1AE8162D35EA8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = App/Bugbook.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = App/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; + PRODUCT_NAME = Bugbook; + SDKROOT = macosx; + }; + name = Release; + }; + 9CA3F5856A1DA5B3E351134A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = App/Bugbook.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + INFOPLIST_FILE = App/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.Bugbook; + PRODUCT_NAME = Bugbook; + SDKROOT = macosx; + }; + name = Debug; + }; + AB311D69E382ACC316590664 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + D17F42D236B57521FE00540F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.BugbookUITests; + SDKROOT = macosx; + TEST_TARGET_NAME = BugbookApp; + }; + name = Release; + }; + E572D9F85F30C586A6807A30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = ""; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = "App/BugbookCore-Info.plist"; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.maxforsey.BugbookCore; + PRODUCT_MODULE_NAME = BugbookCore; + PRODUCT_NAME = BugbookCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 6E50D827484861D24575A01E /* Build configuration list for PBXNativeTarget "BugbookApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9CA3F5856A1DA5B3E351134A /* Debug */, + 98EB10397FF1AE8162D35EA8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 8931F00580AE1D1C63E8EEED /* Build configuration list for PBXNativeTarget "BugbookCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E572D9F85F30C586A6807A30 /* Debug */, + 16EFF6D86D8F5C5A404E7A48 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D4D99567B77C37D82D16832D /* Build configuration list for PBXNativeTarget "BugbookUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 93540112F2D4595B56B8365F /* Debug */, + D17F42D236B57521FE00540F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + FFCF0AB159C599DECC637737 /* Build configuration list for PBXProject "Bugbook" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8784C01F0774A3E0FD0C564F /* Debug */, + AB311D69E382ACC316590664 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.6.0; + }; + }; + CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/getsentry/sentry-cocoa"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.40.0; + }; + }; + E7F8A9B0C1D2E3F4A5B6C7D8 /* XCRemoteSwiftPackageReference "FluidAudio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/FluidInference/FluidAudio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.7.9; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 093BE9A2C3A2769A90DE0579 /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = CC287E7EEA871293A456C2C1 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; + 1CDECA1DBDA6CAE3AC8E4CDA /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 368C986EAAE39D176CF27DDA /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; + D2C1B0A918076F5E4D3C2B1A /* FluidAudio */ = { + isa = XCSwiftPackageProductDependency; + package = E7F8A9B0C1D2E3F4A5B6C7D8 /* XCRemoteSwiftPackageReference "FluidAudio" */; + productName = FluidAudio; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 748DCF6AC60831FC7058F9CD /* Project object */; +} diff --git a/macos/project.yml b/macos/project.yml index d97c9f1..b6a3505 100644 --- a/macos/project.yml +++ b/macos/project.yml @@ -60,6 +60,7 @@ targets: com.apple.security.cs.allow-unsigned-executable-memory: true com.apple.security.cs.disable-library-validation: true com.apple.security.network.client: true + com.apple.security.device.audio-input: true settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.maxforsey.Bugbook diff --git a/perf_baseline.tsv b/perf_baseline.tsv new file mode 100644 index 0000000..d0b13c2 --- /dev/null +++ b/perf_baseline.tsv @@ -0,0 +1,13 @@ +test_name avg_seconds date notes +testSerializePerformance_100rows 0.001 2026-03-19 cached ISO formatter + reserveCapacity +testSerializePerformance_1000rows 0.010 2026-03-19 cached ISO formatter + reserveCapacity +testParsePerformance_100rows 0.004 2026-03-19 manual line iter + dict lookup + fast date parse +testParsePerformance_1000rows 0.041 2026-03-19 manual line iter + dict lookup + fast date parse +testParsePerformance_skipBody_1000rows 0.039 2026-03-19 manual line iter + dict lookup + fast date parse +testRoundTripPerformance_1000rows 0.050 2026-03-19 combined serialize+parse optimizations +testQueryFilterPerformance_1000rows 0.001 2026-03-19 single-pass filter +testQuerySortPerformance_1000rows 0.001 2026-03-19 baseline +testQueryFilterAndSortPerformance_1000rows 0.001 2026-03-19 single-pass filter + sort + limit +testIndexRebuildPerformance_100rows 0.005 2026-03-19 cached formatter + single-pass indexes +testIndexRebuildPerformance_500rows 0.014 2026-03-19 cached formatter + single-pass indexes +testSchemaValidationPerformance_1000rows 0.005 2026-03-19 baseline diff --git a/scripts/perf-compare.sh b/scripts/perf-compare.sh new file mode 100755 index 0000000..73a0596 --- /dev/null +++ b/scripts/perf-compare.sh @@ -0,0 +1,74 @@ +#!/bin/zsh +# perf-compare.sh — Compare current performance test results against baseline. +# Usage: ./scripts/perf-compare.sh [baseline_tsv] +# +# Reads the baseline TSV (default: Tests/BugbookTests/perf_baseline.tsv), +# runs performance tests, and reports regressions. +# Uses zsh for associative array support on macOS (bash 3.x lacks it). + +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +PROJECT_ROOT="${SCRIPT_DIR:h}" +BASELINE="${1:-$PROJECT_ROOT/Tests/BugbookTests/perf_baseline.tsv}" + +if [ ! -f "$BASELINE" ]; then + echo "No baseline found at $BASELINE" + echo "Running tests to generate initial baseline..." + cd "$PROJECT_ROOT" + swift test --filter Performance 2>&1 | tail -30 + echo "" + echo "Baseline generated at $BASELINE" + exit 0 +fi + +# Snapshot current baseline +typeset -A OLD_VALUES +while IFS=$'\t' read -r name metric value timestamp; do + [ "$name" = "test_name" ] && continue + OLD_VALUES[$name]="$value" +done < "$BASELINE" + +echo "Running performance tests..." +cd "$PROJECT_ROOT" +swift test --filter Performance 2>&1 | tail -30 + +echo "" +echo "=== Performance Comparison ===" +echo "" + +REGRESSIONS=0 +while IFS=$'\t' read -r name metric value timestamp; do + [ "$name" = "test_name" ] && continue + old="${OLD_VALUES[$name]:-}" + if [ -z "$old" ]; then + printf " %-30s %8sms (new)\n" "$name" "$value" + continue + fi + + pct=$(awk "BEGIN { printf \"%.0f\", (($value - $old) / $old) * 100 }") + if [ "$pct" -gt 0 ]; then + direction="slower" + else + direction="faster" + pct=$(( -pct )) + fi + + if [ "$pct" -gt 20 ] && [ "$direction" = "slower" ]; then + symbol="REGRESSION" + REGRESSIONS=$((REGRESSIONS + 1)) + else + symbol="ok" + fi + + printf " %-30s %8sms -> %8sms (%d%% %s) %s\n" "$name" "$old" "$value" "$pct" "$direction" "$symbol" +done < "$BASELINE" + +echo "" +if [ "$REGRESSIONS" -gt 0 ]; then + echo "Found $REGRESSIONS regression(s)." + exit 1 +else + echo "All benchmarks within tolerance." + exit 0 +fi diff --git a/ticket-neutralize-colors.md b/ticket-neutralize-colors.md new file mode 100644 index 0000000..ff1a9ca --- /dev/null +++ b/ticket-neutralize-colors.md @@ -0,0 +1,70 @@ +# Neutralize UI Colors — Strip to Grayscale, Reserve Red for Brand Only + +## Context + +The app currently uses blue (`#2383e2`) as the primary accent color for all interactive elements (buttons, selections, focus states, drop targets) and red appears in both error/destructive states and as the brand color. The blue feels generic (default Apple blue) and the red brand color conflicts with error semantics. The goal is to go fully neutral first, then selectively reintroduce color with intention. + +**Design reference**: YouTube's approach — brand color (red) lives only in the logo/icon, while the entire UI runs on black, white, and gray. Color becomes memorable through scarcity. + +## Scope + +### Phase 1: Neutralize all accent/blue usage + +Replace `Color.fallbackAccent` / `appAccent` (`#2383e2` / `#528bcc`) with a neutral dark tone across all interactive elements. + +**Files to change:** +- `Sources/Bugbook/Extensions/Color+Theme.swift` — Redefine `fallbackAccent` and `fallbackAccentLight` to neutral values (e.g., dark charcoal `#2d2d2d` light / soft gray `#b0b0b0` dark mode) +- `Sources/Bugbook/Extensions/DesignTokens.swift` — Update `StatusColor.info` and `StatusColor.active` away from blue +- `BugbookApp.swift` — Review the `.tint(Color.fallbackAccent)` modifier +- `macos/App/Assets.xcassets/AccentColor.colorset/Contents.json` — Update the macOS system accent color asset (currently `#D43D32`) + +**UI areas affected (~162 usages of accentColor):** +- Button fills and borders (primary CTAs like "New Note", "Open Folder") +- Selection highlights (table rows, sidebar items, calendar dates) +- Active tab/state indicators +- Drag-and-drop target highlights +- Focus rings and form states +- Database cell selection states + +**Target palette for interactive elements:** +- Light mode: Black/dark charcoal fills with white text for primary actions; medium gray borders/outlines for secondary +- Dark mode: White/light gray fills with dark text for primary actions; medium gray for secondary +- Hover/pressed states: Slightly lighter/darker variants of the above + +### Phase 2: Isolate brand red to logo only + +Ensure `Brand.primary` (`#e8453c`) is only used for the app icon/logo and not in UI chrome. + +**Files to change:** +- `Sources/Bugbook/Extensions/DesignTokens.swift` — Keep `Brand.primary` defined but audit all usage +- Any views currently using `Brand.primary` or `Brand.subtle` for non-logo purposes (e.g., `AiSidePanelView.swift` uses `Brand.primary` for the AI send button, `BlockCellView.swift` uses `Brand.subtle` as a background) +- Replace these usages with neutral alternatives + +### Phase 3: Preserve functional color meanings + +Keep existing color semantics for things that genuinely need color: +- `StatusColor.error` (red) — keep for actual error/destructive states, but consider shifting to a less alarming tone +- `StatusColor.success` (green) — keep +- `StatusColor.warning` (yellow/amber) — keep +- `BlockColor` palette — keep the full 10-color palette for user-applied text/background colors (these are content colors, not UI chrome) +- `TagColor` palette — keep for kanban columns and database select options + +### Phase 4 (future): Reintroduce intentional accent color + +After living with neutral for a while, selectively add back a "burnt red" / oxide accent for a small number of high-signal UI moments. This is a separate ticket — don't do it in this pass. + +## Acceptance Criteria + +- [ ] No blue accent color remains in the app UI +- [ ] All primary interactive elements (buttons, selections, focus states) use neutral black/gray tones +- [ ] `Brand.primary` red only appears in the app icon — not in interactive UI elements +- [ ] Functional status colors (error, success, warning) still work and are visually distinct +- [ ] Block and tag color palettes are untouched +- [ ] Light mode and dark mode both look intentional and consistent +- [ ] No accessibility regressions — contrast ratios still meet WCAG AA for all interactive elements + +## Notes + +- This is a "reset to neutral" pass. Resist the urge to pick a new accent color during this work. +- When in doubt, look at how YouTube, Linear, or iA Writer handle neutral UI with a colored logo. +- Test with both light and dark mode at every step.