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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,13 @@ jobs:
fi

if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
SWIFT_FILES="$(find Sources -name '*.swift' -print | sort)"
SWIFT_FILES="$(find Sources Tests -name '*.swift' -print | sort)"
else
SWIFT_FILES="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | grep '^Sources/.*\.swift$' || true)"
SWIFT_FILES="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | grep '^\(Sources\|Tests\)/.*\.swift$' || true)"
fi

if [ -z "$SWIFT_FILES" ]; then
echo "No changed Swift source files to lint."
echo "No changed Swift files to lint."
exit 0
fi

Expand All @@ -105,6 +105,41 @@ jobs:
swiftlint lint --config .swiftlint-ci.yml "$file"
done

- name: Run swift-format
run: |
set -euo pipefail

if ! swift format lint --help >/dev/null 2>&1; then
echo "swift format is not available in the selected toolchain"
exit 1
fi

if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
else
BASE_SHA="${{ github.event.before }}"
HEAD_SHA="${{ github.sha }}"
fi

if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
SWIFT_FILES="$(find Sources Tests -name '*.swift' -print | sort)"
else
SWIFT_FILES="$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" | grep '^\(Sources\|Tests\)/.*\.swift$' || true)"
fi

if [ -z "$SWIFT_FILES" ]; then
echo "No changed Swift files to format-check."
exit 0
fi

FILE_COUNT="$(printf '%s\n' "$SWIFT_FILES" | sed '/^$/d' | wc -l | tr -d ' ')"
printf 'Format-checking %s changed Swift files\n' "$FILE_COUNT"
printf '%s\n' "$SWIFT_FILES" | while IFS= read -r file; do
[ -n "$file" ] || continue
swift format lint "$file"
done

- name: Run tests
run: swift test

Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ let package = Package(
path: "Sources/Bugbook",
exclude: ["MCP"],
swiftSettings: [
.define("BUGBOOK_BROWSER_WEBKIT")
.define("BUGBOOK_BROWSER_CHROMIUM")
],
linkerSettings: [
.linkedFramework("AppKit"),
Expand Down
4 changes: 4 additions & 0 deletions Sources/Bugbook/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ enum SidebarContextType: Equatable {

var isRecording: Bool = false
var recordingBlockId: UUID?
/// Active meeting page recording session (independent of pane).
var activeMeetingSession: ActiveMeetingSession?
/// If set, the next meeting page loaded at this path should auto-start recording.
var pendingAutoRecordPath: String?
var flashcardReviewOpen: Bool = false
var showShortcutOverlay: Bool = false
@ObservationIgnored lazy var aiThreadStore = AiThreadStore()
Expand Down
155 changes: 135 additions & 20 deletions Sources/Bugbook/App/BugbookApp.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import SwiftUI
import Sentry
import os
#if BUGBOOK_BROWSER_CHROMIUM
import UserNotifications
#if BUGBOOK_BROWSER_CHROMIUM && canImport(ChromiumBridge)
import ChromiumBridge
#endif

Expand All @@ -12,22 +13,7 @@ struct BugbookApp: App {

var body: some Scene {
WindowGroup {
ContentView()
.tint(Color.fallbackAccent)
.overlay(alignment: .topTrailing) {
if AppEnvironment.isDev {
Text("DEV")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.85))
.clipShape(.capsule)
.padding(.top, 4)
.padding(.trailing, 72)
.allowsHitTesting(false)
}
}
BugbookWindowRootView()
}
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 1100, height: 700)
Expand Down Expand Up @@ -262,8 +248,94 @@ struct BugbookApp: App {
}
}

class AppDelegate: NSObject, NSApplicationDelegate {
private struct BugbookWindowRootView: View {
let bootstrap: ContentViewBootstrap?

init(bootstrap: ContentViewBootstrap? = nil) {
self.bootstrap = bootstrap
}

var body: some View {
ContentView(bootstrap: bootstrap)
.tint(Color.fallbackAccent)
.overlay(alignment: .topTrailing) {
if AppEnvironment.isDev {
Text("DEV")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.85))
.clipShape(.capsule)
.padding(.top, 4)
.padding(.trailing, 72)
.allowsHitTesting(false)
}
}
}
}

@MainActor
final class DetachedWindowManager {
static let shared = DetachedWindowManager()

private var windows: [UUID: NSWindow] = [:]
private var delegates: [UUID: DetachedWindowDelegate] = [:]

func openWindow(
title: String,
bootstrap: ContentViewBootstrap,
size: CGSize = CGSize(width: 1100, height: 700)
) {
let windowID = UUID()
let contentView = BugbookWindowRootView(bootstrap: bootstrap)
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: size),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.identifier = NSUserInterfaceItemIdentifier(windowID.uuidString)
window.title = title
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.toolbarStyle = .unifiedCompact
window.isReleasedWhenClosed = false
window.contentViewController = NSHostingController(rootView: contentView)
let delegate = DetachedWindowDelegate { [weak self] in
self?.closeWindow(id: windowID)
}
window.delegate = delegate
window.center()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)

windows[windowID] = window
delegates[windowID] = delegate
}

private func closeWindow(id: UUID) {
windows.removeValue(forKey: id)
delegates.removeValue(forKey: id)
}
}

@MainActor
private final class DetachedWindowDelegate: NSObject, NSWindowDelegate {
private let onClose: () -> Void

init(onClose: @escaping () -> Void) {
self.onClose = onClose
}

func windowWillClose(_ notification: Notification) {
onClose()
}
}

class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
UNUserNotificationCenter.current().delegate = self
NSApplication.shared.setActivationPolicy(.regular)
NSApplication.shared.activate(ignoringOtherApps: true)
Log.app.info("Bugbook launching")
Expand Down Expand Up @@ -442,6 +514,48 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }

// MARK: - UNUserNotificationCenterDelegate

/// Show notifications even when app is in foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound])
}

/// Handle notification action buttons.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
let eventId = userInfo["eventId"] as? String ?? ""
let eventTitle = userInfo["eventTitle"] as? String ?? "Meeting"

switch response.actionIdentifier {
case MeetingNotificationService.recordActionIdentifier:
NotificationCenter.default.post(
name: .meetingNotificationRecord,
object: nil,
userInfo: ["eventId": eventId, "eventTitle": eventTitle]
)
case MeetingNotificationService.openNotesActionIdentifier,
UNNotificationDefaultActionIdentifier:
NotificationCenter.default.post(
name: .meetingNotificationOpenNotes,
object: nil,
userInfo: ["eventId": eventId, "eventTitle": eventTitle]
)
default:
break
}

completionHandler()
}
}

extension Notification.Name {
Expand All @@ -454,7 +568,6 @@ extension Notification.Name {
static let quickOpenNewTab = Notification.Name("quickOpenNewTab")
static let openSettings = Notification.Name("openSettings")
static let openAIPanel = Notification.Name("openAIPanel")
static let openFullChat = Notification.Name("openFullChat")
static let askAI = Notification.Name("askAI")
static let toggleTheme = Notification.Name("toggleTheme")
static let newDatabase = Notification.Name("newDatabase")
Expand All @@ -464,7 +577,6 @@ extension Notification.Name {
static let openDailyNote = Notification.Name("openDailyNote")
static let openGraphView = Notification.Name("openGraphView")
static let openMail = Notification.Name("openMail")
static let openMessages = Notification.Name("openMessages")
static let editorZoomIn = Notification.Name("editorZoomIn")
static let editorZoomOut = Notification.Name("editorZoomOut")
static let editorZoomReset = Notification.Name("editorZoomReset")
Expand All @@ -480,6 +592,9 @@ extension Notification.Name {
static let movePageToDir = Notification.Name("movePageToDir")
static let addToSidebar = Notification.Name("addToSidebar")

static let stopMeetingRecording = Notification.Name("stopMeetingRecording")
static let meetingNotificationRecord = Notification.Name("meetingNotificationRecord")
static let meetingNotificationOpenNotes = Notification.Name("meetingNotificationOpenNotes")
static let findInPage = Notification.Name("findInPage")
static let findInPane = Notification.Name("findInPane")

Expand Down
37 changes: 37 additions & 0 deletions Sources/Bugbook/Lib/MarkdownBlockParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,43 @@ enum MarkdownBlockParser {
var fullWidth: Bool = false
}

/// Strip YAML frontmatter (between `---` delimiters) from the top of a markdown string.
/// Returns the raw frontmatter string (without delimiters) and the remaining content.
static func stripYAMLFrontmatter(_ markdown: String) -> (yamlFrontmatter: String, content: String) {
let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false)
guard lines.first?.trimmingCharacters(in: .whitespaces) == "---" else {
return ("", markdown)
}
// Find closing ---
for i in 1..<lines.count {
if lines[i].trimmingCharacters(in: .whitespaces) == "---" {
let yaml = lines[1..<i].joined(separator: "\n")
let rest = lines[(i + 1)...].joined(separator: "\n")
// Trim leading blank line from content
let trimmedRest = rest.hasPrefix("\n") ? String(rest.dropFirst()) : rest
return (yaml, trimmedRest)
}
}
// No closing --- found, treat as regular content
return ("", markdown)
}

/// Parse a value from raw YAML frontmatter by key (simple single-line values only).
static func yamlValue(for key: String, in yaml: String) -> String? {
for line in yaml.split(separator: "\n", omittingEmptySubsequences: false) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("\(key):") {
let value = trimmed.dropFirst(key.count + 1).trimmingCharacters(in: .whitespaces)
// Strip surrounding quotes
if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 {
return String(value.dropFirst().dropLast())
}
return value.isEmpty ? nil : value
}
}
return nil
}

/// Parse file-level metadata comments from the top of the markdown string.
/// Returns the metadata and the remaining markdown content after metadata lines.
static func parseMetadata(_ markdown: String) -> (Metadata, String) {
Expand Down
Loading