diff --git a/.go/progress.md b/.go/progress.md
index 91047e17..8c5af842 100644
--- a/.go/progress.md
+++ b/.go/progress.md
@@ -1,41 +1,12 @@
-# Go Run — 2026-04-03
-
-Started: 11:00 PM
-Focus: Bugbook iOS app — full parity with desktop
-
-## Completed (9 tasks, all verified via build)
-
-- [x] Rich block editor — headings, lists, tasks, code, blockquotes, images, formatting toolbar
-- [x] Database table view — all field types rendered, swipe to delete, row navigation
-- [x] Database kanban view — horizontal scroll columns by select property, cards with badges
-- [x] Database calendar view — month grid, day detail, date property rows
-- [x] View management — view tabs, sort/filter UI, column visibility, grouping, new view creation
-- [x] Full property editor — relation picker, rich dates, formula/lookup/rollup, URL/email actions
-- [x] Schema management — add/rename/delete properties, select options with colors
-- [x] Quick capture — text, photo library, camera, quick action pills for tasks/lists
-- [x] Navigation polish — 5-tab layout (Today/Notes/Databases/Agents/Settings), settings view
-
-## Files Created
-- `Sources/BugbookMobile/Views/MobileBlockEditorView.swift` — Block editor + toolbar
-- `Sources/BugbookMobile/ViewModels/MobileDatabaseViewState.swift` — Shared database state
-- `Sources/BugbookMobile/Views/MobileFilterSortView.swift` — Filter/sort/view options
-- `Sources/BugbookMobile/Views/MobileSchemaEditorView.swift` — Schema + property editor
-- `Sources/BugbookMobile/Views/MobileSettingsView.swift` — Settings view
-
-## Files Modified
-- `MobileDatabaseView.swift` — Complete rewrite with table/kanban/calendar views
-- `MobileDatabaseRowView.swift` — Full property editor, relation picker, body editor
-- `MobilePageEditorView.swift` — Block editor integration
-- `MobileRootView.swift` — 5-tab navigation with settings
-- `MobileTodayView.swift` — Quick capture with photo/camera, action pills
-- `ios/project.yml` — Camera/photo library permissions
-- `ios/BugbookMobile.xcodeproj/` — Regenerated
-
-## Build Status
-BugbookMobile target: PASSING (0 errors, 0 warnings)
-
-## How to Test
-1. Open `ios/BugbookMobile.xcodeproj` in Xcode
-2. Select an iPhone simulator or physical device
-3. Build and run
-4. Verify iCloud sync by checking if workspace matches desktop app
+# Go Run — 2026-04-07
+Started: 10:43 PM
+Time budget: 8h
+
+## Queue (7 tickets)
+1. Mail: read/unread contrast
+2. Meetings: focus history, shrink record button
+3. Calendar: source color → event color
+4. Calendar: recurring events + time blocking
+5. Browser: remove tabs, full-width URL
+6. Cmd+F in-page search
+7. Modular container system
diff --git a/.go/queue.json b/.go/queue.json
index 866551f2..936808fd 100644
--- a/.go/queue.json
+++ b/.go/queue.json
@@ -1,14 +1,9 @@
[
- {"position": 1, "row_id": "row_wtl5f9", "name": "Ship Bugbook as standalone .app binary", "files": ["macos/Bugbook.xcodeproj/project.pbxproj", "macos/project.yml", "Package.swift", "Sources/Bugbook/App/BugbookApp.swift"], "worker": "claude", "note": "Complex build system work — needs xcode project investigation, GhosttyKit resolution, signing. Claude agent better suited."},
- {"position": 2, "row_id": "row_xsiof2", "name": "Gateway redesign", "files": ["Sources/Bugbook/Views/Gateway/GatewayView.swift", "Sources/Bugbook/Views/ContentView.swift"], "worker": "codex", "note": "Has full design spec in Bugbook. Progressive disclosure, greeting+date, quick nav, todays focus, recent activity."},
- {"position": 3, "row_id": "row_wcsgow", "name": "Rename Meetings → Meetings", "files": ["Sources/Bugbook/Views/Meetings/MeetingsView.swift", "Sources/Bugbook/Views/Sidebar/SidebarView.swift"], "worker": "codex", "note": "Simple rename. Sidebar + view title → Meetings. Keep chat button."},
- {"position": 4, "row_id": "row_k1pfpn", "name": "Meeting padding fix", "files": ["Sources/Bugbook/Views/Editor/MeetingBlockView.swift"], "worker": "codex", "note": "Reduce title-to-notes gap to 4pt max."},
- {"position": 5, "row_id": "row_fnmxx9", "name": "Callout block fix", "files": ["Sources/Bugbook/Views/Editor/CalloutBlockView.swift", "Sources/Bugbook/Views/Editor/BlockCellView.swift"], "worker": "codex", "note": "Remove left border. Just rounded gray bg + icon. Match TOC styling."},
- {"position": 6, "row_id": "row_u9mndd", "name": "Outline/TOC fix", "files": ["Sources/Bugbook/Views/Editor/OutlineBlockView.swift", "Sources/Bugbook/Views/Editor/BlockCellView.swift"], "worker": "codex", "note": "Grey text links not bullets/stars. Match callout container. Click scrolls to heading."},
- {"position": 7, "row_id": "row_b7h2vl", "name": "Heading toggles behavior", "files": ["Sources/Bugbook/Views/Editor/BlockCellView.swift", "Sources/Bugbook/Models/BlockDocument.swift", "Sources/BugbookCLI/PageBlockHelpers.swift"], "worker": "claude", "note": "Complex: Cmd+Shift+Enter toggle, Enter exits, auto-nest smaller headings. Needs multi-file reasoning."},
- {"position": 8, "row_id": "row_srmgse", "name": "TableBlockView fix", "files": ["Sources/Bugbook/Views/Editor/TableBlockView.swift"], "worker": "codex", "note": "Attempt 3. Fix grip dots duplication, alignment, flush with content area."},
- {"position": 9, "row_id": "row_0lsztg", "name": "Kebab menu fix", "files": ["Sources/Bugbook/Views/Database/SelectOptionViews.swift"], "worker": "codex", "note": "Larger hit area, popover positioning beside not on top."},
- {"position": 10, "row_id": "row_qm7iyh", "name": "Chat redesign", "files": ["Sources/Bugbook/Views/AI/AiSidePanelView.swift", "Sources/Bugbook/Views/AI/NotesChatView.swift", "Sources/Bugbook/Views/ContentView.swift", "Sources/Bugbook/Views/Sidebar/SidebarView.swift"], "worker": "codex", "note": "Rename Ask AI → Chat, remove X, Cmd+I opens sidebar panel."},
- {"position": 11, "row_id": "row_dimm5g", "name": "Mention picker link styling", "files": ["Sources/Bugbook/Views/Editor/BlockTextView.swift", "Sources/Bugbook/Views/Editor/WikiLinkView.swift"], "worker": "codex", "note": "Style @[[Page Name]] as visible link (pill or colored text), clickable."},
- {"position": 12, "row_id": "row_uqw8vz", "name": "Cmd+K search navigation fix", "files": ["Sources/Bugbook/Views/ContentView.swift", "Sources/Bugbook/Views/Components/CommandPaletteView.swift"], "worker": "claude", "note": "Attempt 3. navigateToEntryInPane works from sidebar but not command palette. Deep nav system issue."}
+ {"position": 1, "row_id": "row_x1g8d6", "name": "Mail: read/unread contrast", "files": ["Sources/Bugbook/Views/Mail/MailPaneView.swift"]},
+ {"position": 2, "row_id": "row_9d6uuv", "name": "Meetings: focus history, shrink record button", "files": ["Sources/Bugbook/Views/Meetings/MeetingsView.swift"]},
+ {"position": 3, "row_id": "row_zy9ouz", "name": "Calendar: source color → event color", "files": ["Sources/Bugbook/Views/Calendar/CalendarWeekView.swift", "Sources/Bugbook/Views/Calendar/CalendarMonthView.swift", "Sources/Bugbook/Views/Calendar/CalendarDayView.swift", "Sources/Bugbook/Services/CalendarService.swift"]},
+ {"position": 4, "row_id": "row_omf7j6", "name": "Calendar: recurring events + time blocking", "files": ["Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift", "Sources/Bugbook/Services/CalendarService.swift", "Sources/Bugbook/Views/Calendar/CalendarWeekView.swift", "Sources/Bugbook/Views/Calendar/CalendarDayView.swift"]},
+ {"position": 5, "row_id": "row_9fg5sv", "name": "Browser: remove tabs, full-width URL", "files": ["Sources/Bugbook/Views/Browser/BrowserPaneView.swift"]},
+ {"position": 6, "row_id": "row_ncrdg3", "name": "Cmd+F in-page search", "files": ["Sources/Bugbook/Views/ContentView.swift"]},
+ {"position": 7, "row_id": "row_2hvac7", "name": "Modular container system", "files": ["Sources/Bugbook/Views/ContentView.swift", "Sources/Bugbook/Views/Shell/ShellNavigationViews.swift", "Sources/Bugbook/Views/Components/WorkspaceTabBar.swift", "Sources/Bugbook/Views/Browser/BrowserPaneView.swift", "Sources/Bugbook/Views/Panes/PaneTreeView.swift", "Sources/Bugbook/Views/Panes/PaneContentView.swift", "Sources/Bugbook/Extensions/DesignTokens.swift", "Sources/Bugbook/App/BugbookApp.swift"]}
]
diff --git a/.swiftpm/xcode/xcuserdata/maxforsey.xcuserdatad/xcschemes/xcschememanagement.plist b/.swiftpm/xcode/xcuserdata/maxforsey.xcuserdatad/xcschemes/xcschememanagement.plist
index 62af1151..431dadc0 100644
--- a/.swiftpm/xcode/xcuserdata/maxforsey.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/.swiftpm/xcode/xcuserdata/maxforsey.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -22,7 +22,7 @@
BugbookCLITests.xcscheme_^#shared#^_
orderHint
- 4
+ 3
BugbookCore.xcscheme_^#shared#^_
@@ -32,12 +32,12 @@
BugbookCoreTests.xcscheme_^#shared#^_
orderHint
- 1
+ 4
BugbookMCPSpike.xcscheme_^#shared#^_
orderHint
- 3
+ 1
BugbookMobile.xcscheme_^#shared#^_
diff --git a/Sources/Bugbook/App/AppState.swift b/Sources/Bugbook/App/AppState.swift
index 652437f8..53945f33 100644
--- a/Sources/Bugbook/App/AppState.swift
+++ b/Sources/Bugbook/App/AppState.swift
@@ -22,11 +22,35 @@ struct MCPServerInfo: Identifiable {
var id: String { name }
}
+/// What the sidebar's contextual zone should display, derived from the selected pane type.
+enum SidebarContextType: Equatable {
+ case mail
+ case calendar
+ case workspace // editor pages — shows file tree
+ case none // terminal, browser — contextual zone collapses
+
+ static func from(_ content: PaneContent) -> SidebarContextType {
+ switch content {
+ case .terminal: return .none
+ case .document(let file):
+ if file.isMail { return .mail }
+ if file.isCalendar { return .calendar }
+ if file.isBrowser { return .none }
+ return .workspace
+ }
+ }
+}
+
@MainActor
@Observable class AppState {
var openTabs: [OpenFile] = []
var activeTabIndex: Int = 0
- var sidebarOpen: Bool = false
+
+ // Unified sidebar state
+ var sidebarVisible: Bool = true
+ var sidebarWidth: CGFloat = 200
+ /// Contextual zone type — follows the focused pane, always.
+ var sidebarContextType: SidebarContextType = .workspace
var workspacePath: String?
var fileTree: [FileEntry] = []
var sidebarReferences: [FileEntry] = []
diff --git a/Sources/Bugbook/App/BugbookApp.swift b/Sources/Bugbook/App/BugbookApp.swift
index 8b689768..4dd9101b 100644
--- a/Sources/Bugbook/App/BugbookApp.swift
+++ b/Sources/Bugbook/App/BugbookApp.swift
@@ -66,10 +66,10 @@ struct BugbookApp: App {
}
CommandGroup(after: .toolbar) {
- Button("Toggle Rail") {
+ Button("Toggle Sidebar") {
NotificationCenter.default.post(name: .toggleSidebar, object: nil)
}
- .keyboardShortcut("\\", modifiers: .command)
+ .keyboardShortcut(".", modifiers: .command)
Divider()
@@ -84,7 +84,7 @@ struct BugbookApp: App {
.keyboardShortcut("]", modifiers: .command)
Button("Find in Page") {
- NotificationCenter.default.post(name: .findInPane, object: nil)
+ NotificationCenter.default.post(name: .findInPage, object: nil)
}
.keyboardShortcut("f")
@@ -404,6 +404,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NotificationCenter.default.post(name: .focusPaneByIndex, object: index)
return nil
}
+
+ // Cmd+K — intercept before native NSView responders (e.g. Ghostty terminal)
+ // so the command palette always opens regardless of which view has focus.
+ NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ guard flags == .command, event.charactersIgnoringModifiers == "k" else { return event }
+ NotificationCenter.default.post(name: .quickOpen, object: nil)
+ return nil
+ }
+
+ // Cmd+F should route through the app-level notification layer even when
+ // an editor NSTextView or terminal responder currently has focus.
+ NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ guard flags == .command, event.charactersIgnoringModifiers == "f" else { return event }
+ NotificationCenter.default.post(name: .findInPage, object: nil)
+ return nil
+ }
}
@objc private func windowDidBecomeKey(_ notification: Notification) {
@@ -419,6 +437,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
window.title = "Bugbook"
window.styleMask.insert(.fullSizeContentView)
window.isMovableByWindowBackground = true
+ window.backgroundColor = NSColor(Container.groutBg)
}
}
@@ -461,6 +480,7 @@ extension Notification.Name {
static let movePageToDir = Notification.Name("movePageToDir")
static let addToSidebar = Notification.Name("addToSidebar")
+ static let findInPage = Notification.Name("findInPage")
static let findInPane = Notification.Name("findInPane")
// Pane/Workspace system
diff --git a/Sources/Bugbook/Extensions/Color+Theme.swift b/Sources/Bugbook/Extensions/Color+Theme.swift
index bd16df10..684d5cfa 100644
--- a/Sources/Bugbook/Extensions/Color+Theme.swift
+++ b/Sources/Bugbook/Extensions/Color+Theme.swift
@@ -60,8 +60,8 @@ extension Color {
// Chrome — sidebar, tab bar, breadcrumbs
static let fallbackSidebarBg = Color(light: Color(hex: "f8f8f7"), dark: Color(hex: "202020"))
static let fallbackTabBarBg = Color(light: Color(hex: "f8f8f7"), dark: Color(hex: "202020"))
- // Canvas — editor, active tab
- static let fallbackEditorBg = Color(light: .white, dark: Color(hex: "191919"))
+ // Canvas — editor, active tab (matches Container.cardBg)
+ static let fallbackEditorBg = Color(light: .white, dark: Color(hex: "161616"))
// Semantic surfaces
static let fallbackCardBg = Color(light: .white, dark: Color(hex: "202020"))
diff --git a/Sources/Bugbook/Extensions/DesignTokens.swift b/Sources/Bugbook/Extensions/DesignTokens.swift
index b5b89489..d9fd010b 100644
--- a/Sources/Bugbook/Extensions/DesignTokens.swift
+++ b/Sources/Bugbook/Extensions/DesignTokens.swift
@@ -31,6 +31,37 @@ enum Typography {
static let title: CGFloat = 28
}
+// MARK: - Container System (Grout + Card)
+
+enum Container {
+ /// Window/grout background — the surface cards float on
+ static let groutBg = Color(light: Color(hex: "e2e0db"), dark: Color(hex: "232323"))
+
+ /// Content card background
+ static let cardBg = Color(light: .white, dark: Color(hex: "161616"))
+
+ /// Sidebar active item highlight
+ static let sidebarActiveBg = Color(light: Color(hex: "d6d4cf"), dark: Color(hex: "2a2a2a"))
+
+ /// Workspace pill tab — active fill (matches card bg so it feels connected)
+ static let pillActiveBg = Color(light: .white, dark: Color(hex: "161616"))
+
+ /// Workspace pill tab — inactive text
+ static let pillInactiveText = Color(light: Color(hex: "888888"), dark: Color(hex: "555555"))
+
+ /// Browser URL bar background (inside card)
+ static let urlBarBg = Color(light: Color(hex: "f5f3ef"), dark: Color(hex: "2e2e2e"))
+
+ /// Gap between cards and around content area
+ static let groutGap: CGFloat = 6
+
+ /// Card corner radius
+ static let cardRadius: CGFloat = 10
+
+ /// Pill tab corner radius
+ static let pillRadius: CGFloat = 8
+}
+
// MARK: - Corner Radii
enum Radius {
diff --git a/Sources/Bugbook/Lib/MarkdownBlockParser.swift b/Sources/Bugbook/Lib/MarkdownBlockParser.swift
index de27d303..0bfa9caa 100644
--- a/Sources/Bugbook/Lib/MarkdownBlockParser.swift
+++ b/Sources/Bugbook/Lib/MarkdownBlockParser.swift
@@ -79,18 +79,43 @@ enum MarkdownBlockParser {
// MARK: - Parse
+ /// Parse result that includes metadata about the parse.
+ struct ParseOutput {
+ let blocks: [Block]
+ let hasBlockIDs: Bool
+ }
+
// swiftlint:disable:next cyclomatic_complexity function_body_length
static func parse(_ markdown: String) -> [Block] {
+ parseWithFlags(markdown).blocks
+ }
+
+ /// Parse returning blocks + whether the markdown contained block-id comments.
+ /// Avoids a separate `content.contains("" {
+ foundClose = true
i += 1
break
}
- childLines.append(lines[i])
i += 1
}
- let children = childLines.isEmpty ? [] : parse(childLines.joined(separator: "\n"))
+ let childEnd = foundClose ? i - 1 : i
+ let children = childStart < childEnd ? parseLines(lines[childStart.. are children
- var childLines: [Substring] = []
- while i < lines.count {
+ let childStart = i
+ var foundClose = false
+ while i < lines.endIndex {
if lines[i].trimmingCharacters(in: .whitespaces) == "" {
+ foundClose = true
i += 1
break
}
- childLines.append(lines[i])
i += 1
}
- let children = childLines.isEmpty ? [] : parse(childLines.joined(separator: "\n"))
+ let childEnd = foundClose ? i - 1 : i
+ let children = childStart < childEnd ? parseLines(lines[childStart.." {
+ foundClose = true
i += 1
break
}
- childLines.append(String(lines[i]))
i += 1
}
- let children = childLines.isEmpty ? [] : parse(childLines.joined(separator: "\n"))
+ let childEnd = foundClose ? i - 1 : i
+ let children = childStart < childEnd ? parseLines(lines[childStart.." {
var allChildren: [Block] = []
var currentColumnIndex = 0
- var currentColumnLines: [Substring] = []
+ var currentColumnStart = i + 1
i += 1
- while i < lines.count {
+ while i < lines.endIndex {
let colLine = lines[i]
if colLine.trimmingCharacters(in: .whitespaces) == "" {
+ // Parse final column
+ if currentColumnStart < i {
+ var columnBlocks = parseLines(lines[currentColumnStart.." {
// Parse accumulated lines for current column
- if !currentColumnLines.isEmpty {
- let columnContent = currentColumnLines.joined(separator: "\n")
- var columnBlocks = parse(columnContent)
+ if currentColumnStart < i {
+ var columnBlocks = parseLines(lines[currentColumnStart.." {
i += 1
- while i < lines.count {
+ while i < lines.endIndex {
if lines[i].trimmingCharacters(in: .whitespaces) == "" {
i += 1
break
@@ -383,9 +412,9 @@ enum MarkdownBlockParser {
var transcript = ""
var summary = ""
var actionItems = ""
- var noteLines: [String] = []
+ var noteLines: [Substring] = []
var section = ""
- while i < lines.count {
+ while i < lines.endIndex {
let mLine = lines[i].trimmingCharacters(in: .whitespaces)
if mLine == "" {
i += 1
@@ -410,7 +439,7 @@ enum MarkdownBlockParser {
case "transcript":
transcript += (transcript.isEmpty ? "" : "\n") + lines[i]
case "notes":
- noteLines.append(String(lines[i]))
+ noteLines.append(lines[i])
default:
break
}
@@ -427,7 +456,7 @@ enum MarkdownBlockParser {
meetingBlock.meetingNotes = notesStr
if !noteLines.isEmpty {
let trimmedNotes = notesStr.trimmingCharacters(in: .whitespacesAndNewlines)
- meetingBlock.children = trimmedNotes.isEmpty ? [] : parse(trimmedNotes)
+ meetingBlock.children = trimmedNotes.isEmpty ? [] : parseLines(noteLines[...])
}
meetingBlock.meetingState = .complete
blocks.append(meetingBlock)
@@ -442,7 +471,7 @@ enum MarkdownBlockParser {
tableRows.append(parseTableRow(line))
i += 1
// Check for separator row (indicates header)
- if i < lines.count {
+ if i < lines.endIndex {
let nextLine = lines[i].trimmingCharacters(in: .whitespaces)
if isTableSeparator(nextLine) {
hasHeader = true
@@ -450,7 +479,7 @@ enum MarkdownBlockParser {
}
}
// Parse remaining data rows
- while i < lines.count {
+ while i < lines.endIndex {
let rowLine = lines[i].trimmingCharacters(in: .whitespaces)
guard rowLine.hasPrefix("|") && rowLine.hasSuffix("|") else { break }
if isTableSeparator(rowLine) { i += 1; continue }
diff --git a/Sources/Bugbook/Models/AppSettings.swift b/Sources/Bugbook/Models/AppSettings.swift
index e839d8a4..4f4915d5 100644
--- a/Sources/Bugbook/Models/AppSettings.swift
+++ b/Sources/Bugbook/Models/AppSettings.swift
@@ -106,7 +106,6 @@ struct BrowserChromeConfiguration: Codable, Equatable {
struct AppSettings: Codable, Equatable {
var theme: ThemeMode
- var railPinned: Bool
var focusModeOnType: Bool
var preferredAIEngine: PreferredAIEngine
var executionPolicy: ExecutionPolicy
@@ -140,7 +139,6 @@ struct AppSettings: Codable, Equatable {
static let `default` = AppSettings(
theme: .system,
- railPinned: false,
focusModeOnType: false,
preferredAIEngine: .auto,
executionPolicy: .ask,
@@ -176,7 +174,6 @@ struct AppSettings: Codable, Equatable {
private enum CodingKeys: String, CodingKey {
case theme
- case railPinned
case focusModeOnType
case preferredAIEngine
case executionPolicy
@@ -216,7 +213,6 @@ struct AppSettings: Codable, Equatable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
theme = try container.decodeIfPresent(ThemeMode.self, forKey: .theme) ?? .system
- railPinned = try container.decodeIfPresent(Bool.self, forKey: .railPinned) ?? false
focusModeOnType = try container.decodeIfPresent(Bool.self, forKey: .focusModeOnType) ?? false
preferredAIEngine = try container.decodeIfPresent(PreferredAIEngine.self, forKey: .preferredAIEngine) ?? .auto
executionPolicy = try container.decodeIfPresent(ExecutionPolicy.self, forKey: .executionPolicy) ?? .ask
@@ -252,7 +248,6 @@ struct AppSettings: Codable, Equatable {
init(
theme: ThemeMode,
- railPinned: Bool = false,
focusModeOnType: Bool,
preferredAIEngine: PreferredAIEngine,
executionPolicy: ExecutionPolicy,
@@ -282,7 +277,6 @@ struct AppSettings: Codable, Equatable {
googleGrantedScopes: [String] = []
) {
self.theme = theme
- self.railPinned = railPinned
self.focusModeOnType = focusModeOnType
self.preferredAIEngine = preferredAIEngine
self.executionPolicy = executionPolicy
@@ -315,7 +309,6 @@ struct AppSettings: Codable, Equatable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme)
- try container.encode(railPinned, forKey: .railPinned)
try container.encode(focusModeOnType, forKey: .focusModeOnType)
try container.encode(preferredAIEngine, forKey: .preferredAIEngine)
try container.encode(executionPolicy, forKey: .executionPolicy)
diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift
index 0be85d2c..e7605b1e 100644
--- a/Sources/Bugbook/Models/BlockDocument.swift
+++ b/Sources/Bugbook/Models/BlockDocument.swift
@@ -16,6 +16,11 @@ struct MultiBlockTextSelection: Equatable {
var focus: BlockTextSelectionPoint
}
+struct BlockFindSelection: Equatable {
+ let blockId: UUID
+ let range: NSRange
+}
+
@MainActor
@Observable
class BlockDocument {
@@ -53,6 +58,8 @@ class BlockDocument {
var moveBlockId: UUID?
/// Set to a block ID to scroll the editor so that block's top is visible.
var scrollToBlockId: UUID?
+ var findHighlightQuery: String = ""
+ var findSelectedMatch: BlockFindSelection?
var selectionRect: CGRect?
var selectionBlockId: UUID?
var multiBlockTextSelection: MultiBlockTextSelection?
@@ -84,7 +91,6 @@ class BlockDocument {
var meetingVolatileText: String = ""
@ObservationIgnored var onStartMeeting: ((UUID) -> Void)?
@ObservationIgnored var onStopMeeting: ((UUID) -> Void)?
- @ObservationIgnored var onSplitPane: (() -> Void)?
@ObservationIgnored var transcriptionService: TranscriptionService?
@ObservationIgnored var availablePages: [FileEntry] = []
@ObservationIgnored var filePath: String?
@@ -938,7 +944,6 @@ class BlockDocument {
case askAI
case meetingNotes
case meeting
- case splitPane
}
struct SlashCommand {
@@ -978,8 +983,6 @@ class BlockDocument {
SlashCommand(name: "Toggle Heading 3", icon: "chevron.right", action: .blockType(.headingToggle, headingLevel: 3), section: "Basic blocks", keywords: ["toggle h3", "collapsible heading"]),
SlashCommand(name: "Table of Contents", icon: "list.bullet.indent", action: .blockType(.outline, headingLevel: 0), section: "Basic blocks", keywords: ["toc", "outline", "contents", "navigation", "headings"]),
SlashCommand(name: "Callout", icon: "exclamationmark.circle", action: .blockType(.callout, headingLevel: 0), section: "Basic blocks", keywords: ["note", "alert", "warning", "info", "tip", "callout"]),
- // Layout
- SlashCommand(name: "Split Pane", icon: "rectangle.split.2x1", action: .splitPane, section: "Layout", keywords: ["split", "pane", "tile", "open", "side"]),
// 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"]),
@@ -1051,11 +1054,6 @@ class BlockDocument {
dismissSlashMenu()
return
- case .splitPane:
- dismissSlashMenu()
- onSplitPane?()
- return
-
case .meeting:
saveUndo()
updateBlockProperty(id: blockId) { block in
@@ -1859,10 +1857,11 @@ class BlockDocument {
persistsBlockIDs: Bool
) {
let (metadata, content) = MarkdownBlockParser.parseMetadata(markdown)
+ let output = MarkdownBlockParser.parseWithFlags(content)
return (
metadata: metadata,
- blocks: MarkdownBlockParser.parse(content),
- persistsBlockIDs: content.contains("", options: .regularExpression) else {
- return nil
- }
- let match = String(head[range])
- let inner = match.dropFirst(10).dropLast(4).trimmingCharacters(in: .whitespaces)
+ // Manual prefix scan — avoids regex allocation per file
+ guard let startRange = head.range(of: "", range: afterPrefix.. String {
+ let trimmed = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.lowercased().hasPrefix("fwd:") else { return trimmed }
+ return trimmed.isEmpty ? "Fwd:" : "Fwd: \(trimmed)"
+ }
+
func loadMailbox(_ mailbox: MailMailbox, token: GoogleOAuthToken, forceRefresh: Bool = false) async {
if !forceRefresh, let cached = mailboxThreads[mailbox], !cached.isEmpty {
selectedThreadID = selectedThreadID ?? cached.first?.id
diff --git a/Sources/Bugbook/Views/Browser/BrowserPaneView.swift b/Sources/Bugbook/Views/Browser/BrowserPaneView.swift
index ba4e8cb9..0c5d67c9 100644
--- a/Sources/Bugbook/Views/Browser/BrowserPaneView.swift
+++ b/Sources/Bugbook/Views/Browser/BrowserPaneView.swift
@@ -195,8 +195,9 @@ struct BrowserPaneView: View {
}
private var chromeBar: some View {
- VStack(spacing: 8) {
- HStack(spacing: 8) {
+ VStack(spacing: 0) {
+ // Compact tab bar — tabs fill the row, active tab shows URL inline
+ HStack(spacing: 4) {
if chrome.showsBackForwardButtons {
navButton("chevron.left", enabled: activeTab?.canGoBack == true) {
browserManager.goBack(in: paneID)
@@ -206,14 +207,35 @@ struct BrowserPaneView: View {
}
}
- if !chrome.autoHidesTabPills || session.tabs.count > 1 {
- tabStrip
- }
+ // Full-width URL bar
+ HStack(spacing: 8) {
+ Image(systemName: activeTab?.securityIconName ?? "magnifyingglass")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
- addressField
+ TextField("Search or enter URL", text: $omnibarText)
+ .textFieldStyle(.plain)
+ .font(.system(size: 13))
+ .focused($omnibarFocused)
+ .onSubmit {
+ submitOmnibar(omnibarText)
+ }
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(
+ RoundedRectangle(cornerRadius: Radius.md)
+ .fill(Container.urlBarBg)
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.md)
+ .strokeBorder(omnibarFocused ? Color.accentColor.opacity(0.35) : Color.clear, lineWidth: 1)
+ )
+ )
browserActionMenu
}
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
if let saveMessage, !saveMessage.isEmpty {
HStack {
@@ -222,15 +244,85 @@ struct BrowserPaneView: View {
.foregroundStyle(.secondary)
Spacer()
}
+ .padding(.horizontal, 12)
+ .padding(.bottom, 4)
}
}
- .padding(.horizontal, 12)
- .padding(.vertical, 10)
- .background(Color.fallbackTabBarBg)
+ .background(Container.cardBg)
.overlay(alignment: .bottom) {
Rectangle()
- .fill(Color.fallbackChromeBorder)
- .frame(height: 1)
+ .fill(Color.primary.opacity(0.06))
+ .frame(height: 0.5)
+ }
+ }
+
+ private func compactTab(_ tab: BrowserTabState) -> some View {
+ let isSelected = session.selectedTabID == tab.id
+ let isHovered = hoveredTabID == tab.id
+
+ return HStack(spacing: 6) {
+ // Favicon dot
+ Circle()
+ .fill(tabColor(for: tab))
+ .frame(width: 6, height: 6)
+ .padding(.leading, 8)
+
+ if isSelected {
+ // Active tab: show editable URL field inline
+ TextField("Search or enter URL", text: $omnibarText)
+ .textFieldStyle(.plain)
+ .font(.system(size: 12))
+ .focused($omnibarFocused)
+ .onSubmit {
+ submitOmnibar(omnibarText)
+ }
+ } else {
+ // Inactive tab: show title
+ Button {
+ session.selectTab(tab.id)
+ } label: {
+ Text(tab.displayTitle)
+ .font(.system(size: 12))
+ .lineLimit(1)
+ .foregroundStyle(.primary)
+ }
+ .buttonStyle(.plain)
+ }
+
+ Spacer(minLength: 0)
+
+ // Close button on hover
+ if isHovered && session.tabs.count > 1 {
+ Button {
+ session.closeTab(tab.id)
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.tertiary)
+ .frame(width: 16, height: 16)
+ .background(Circle().fill(Color.primary.opacity(0.06)))
+ }
+ .buttonStyle(.plain)
+ .padding(.trailing, 6)
+ .transition(.opacity)
+ } else {
+ Spacer()
+ .frame(width: 8)
+ }
+ }
+ .frame(height: 28)
+ .frame(maxWidth: isSelected ? .infinity : 160)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(isSelected ? Color.primary.opacity(0.06) : (isHovered ? Color.primary.opacity(0.03) : Color.clear))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .strokeBorder(isSelected ? Color.primary.opacity(0.08) : Color.clear, lineWidth: 0.5)
+ )
+ .contentShape(RoundedRectangle(cornerRadius: 8))
+ .onHover { hovering in
+ hoveredTabID = hovering ? tab.id : (hoveredTabID == tab.id ? nil : hoveredTabID)
}
}
@@ -308,77 +400,6 @@ struct BrowserPaneView: View {
}
}
- private var tabStrip: some View {
- ScrollView(.horizontal) {
- HStack(spacing: 6) {
- ForEach(session.tabs) { tab in
- tabPill(tab)
- }
-
- Button(action: createNewTab) {
- Image(systemName: "plus")
- .font(.system(size: 11, weight: .semibold))
- .frame(width: 22, height: 22)
- .background(
- Circle()
- .fill(Color.primary.opacity(0.05))
- )
- }
- .buttonStyle(.plain)
- }
- .padding(.vertical, 2)
- }
- .frame(maxWidth: 260)
- .scrollIndicators(.hidden)
- }
-
- private func tabPill(_ tab: BrowserTabState) -> some View {
- let isSelected = session.selectedTabID == tab.id
- let showClose = hoveredTabID == tab.id
-
- return HStack(spacing: 6) {
- Button {
- session.selectTab(tab.id)
- } label: {
- HStack(spacing: 6) {
- Circle()
- .fill(tabColor(for: tab))
- .frame(width: 6, height: 6)
- Text(tab.displayTitle)
- .font(.system(size: 11, weight: isSelected ? .medium : .regular))
- .lineLimit(1)
- }
- }
- .buttonStyle(.plain)
-
- if showClose {
- Button {
- session.closeTab(tab.id)
- } label: {
- Image(systemName: "xmark")
- .font(.system(size: 9, weight: .semibold))
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- .transition(.opacity)
- }
- }
- .padding(.horizontal, 8)
- .frame(height: 22)
- .frame(maxWidth: 150)
- .background(
- RoundedRectangle(cornerRadius: 11)
- .fill(Color.clear)
- .overlay(
- RoundedRectangle(cornerRadius: 11)
- .strokeBorder(isSelected ? Color.primary.opacity(0.16) : Color.clear, lineWidth: 0.5)
- )
- )
- .contentShape(RoundedRectangle(cornerRadius: 11))
- .onHover { hovering in
- hoveredTabID = hovering ? tab.id : (hoveredTabID == tab.id ? nil : hoveredTabID)
- }
- }
private var browserActionMenu: some View {
Menu {
@@ -467,31 +488,6 @@ struct BrowserPaneView: View {
.fixedSize()
}
- private var addressField: some View {
- HStack(spacing: 8) {
- Image(systemName: activeTab?.securityIconName ?? "magnifyingglass")
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(.secondary)
-
- TextField("Search the web or your notes...", text: $omnibarText)
- .textFieldStyle(.plain)
- .font(.system(size: 13))
- .focused($omnibarFocused)
- .onSubmit {
- submitOmnibar(omnibarText)
- }
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .background(
- RoundedRectangle(cornerRadius: Radius.md)
- .fill(Color.primary.opacity(0.05))
- .overlay(
- RoundedRectangle(cornerRadius: Radius.md)
- .strokeBorder(omnibarFocused ? Color.accentColor.opacity(0.35) : Color.clear, lineWidth: 1)
- )
- )
- }
@ViewBuilder
private var saveSection: some View {
@@ -573,30 +569,6 @@ struct BrowserPaneView: View {
submitOmnibar(newTabSearchText)
}
- if chrome.showsNewTabQuickLaunch, !appState.settings.browserQuickLaunchItems.isEmpty {
- FlowLayout(spacing: 10) {
- ForEach(appState.settings.browserQuickLaunchItems) { item in
- Button {
- guard let url = URL(string: item.url) else { return }
- browserManager.openURL(url, in: paneID, newTab: false)
- } label: {
- HStack(spacing: 8) {
- Image(systemName: item.icon)
- Text(item.title)
- }
- .font(.system(size: 12, weight: .medium))
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .background(
- RoundedRectangle(cornerRadius: Radius.md)
- .fill(Color.primary.opacity(0.05))
- )
- }
- .buttonStyle(.plain)
- }
- }
- .frame(maxWidth: 620)
- }
if chrome.showsNewTabRecentVisits, !session.recentVisits.isEmpty {
VStack(alignment: .leading, spacing: 10) {
diff --git a/Sources/Bugbook/Views/Calendar/CalendarDayView.swift b/Sources/Bugbook/Views/Calendar/CalendarDayView.swift
index 7dde7836..4bb01fc1 100644
--- a/Sources/Bugbook/Views/Calendar/CalendarDayView.swift
+++ b/Sources/Bugbook/Views/Calendar/CalendarDayView.swift
@@ -9,9 +9,14 @@ struct CalendarDayView: View {
let calendarSources: [CalendarSource]
var onEventTapped: (CalendarEvent) -> Void
var onDatabaseItemTapped: (CalendarDatabaseItem) -> Void
+ var onCreateEvent: ((Date, Date) -> Void)?
@State private var hoveredEventId: String?
+ // Drag-to-create state
+ @State private var dragStart: CGFloat?
+ @State private var dragCurrent: CGFloat?
+
private let hourHeight: CGFloat = 48
private let timeGutterWidth: CGFloat = 44
@@ -132,31 +137,145 @@ struct CalendarDayView: View {
}
}
+ // MARK: - Overlap Layout
+
+ private struct EventLayout {
+ let event: CalendarEvent
+ let column: Int
+ let totalColumns: Int
+ }
+
+ private func computeOverlapLayout(_ events: [CalendarEvent]) -> [EventLayout] {
+ let sorted = events.sorted { $0.startDate < $1.startDate }
+ var columns: [Int: CalendarEvent] = [:] // column index -> last event occupying it
+ var layouts: [EventLayout] = []
+
+ for event in sorted {
+ // Free columns whose event ended before this event starts
+ columns = columns.filter { _, occupant in occupant.endDate > event.startDate }
+
+ // Find the lowest available column
+ var col = 0
+ while columns[col] != nil { col += 1 }
+ columns[col] = event
+
+ layouts.append(EventLayout(event: event, column: col, totalColumns: 0))
+ }
+
+ // Second pass: assign totalColumns per overlap group.
+ // For each event, totalColumns = max(col+1) among all events it overlaps with.
+ for i in layouts.indices {
+ var maxCol = layouts[i].column
+ for j in layouts.indices where i != j {
+ let a = layouts[i].event
+ let b = layouts[j].event
+ if a.startDate < b.endDate && b.startDate < a.endDate {
+ maxCol = max(maxCol, layouts[j].column)
+ }
+ }
+ layouts[i] = EventLayout(
+ event: layouts[i].event,
+ column: layouts[i].column,
+ totalColumns: maxCol + 1
+ )
+ }
+
+ return layouts
+ }
+
// MARK: - Event Overlays
private var eventOverlays: some View {
HStack(spacing: 0) {
Color.clear.frame(width: timeGutterWidth)
- ZStack(alignment: .topLeading) {
- ForEach(timedEvents, id: \.id) { event in
- timedEventCard(event)
+ GeometryReader { geo in
+ let layouts = computeOverlapLayout(timedEvents)
+ ZStack(alignment: .topLeading) {
+ ForEach(layouts, id: \.event.id) { layout in
+ timedEventCard(layout.event, column: layout.column, totalColumns: layout.totalColumns, containerWidth: geo.size.width)
+ }
+
+ ForEach(dayDbItems, id: \.id) { item in
+ databaseItemCard(item)
+ }
}
- ForEach(dayDbItems, id: \.id) { item in
- databaseItemCard(item)
+ // Drag-to-create preview
+ if let start = dragStart, let current = dragCurrent {
+ let minY = min(start, current)
+ let height = max(abs(current - start), hourHeight * 0.5)
+ Rectangle()
+ .fill(Color.accentColor.opacity(0.18))
+ .overlay(
+ Rectangle()
+ .fill(Color.accentColor)
+ .frame(width: 3),
+ alignment: .leading
+ )
+ .frame(height: height)
+ .offset(y: minY)
+ .allowsHitTesting(false)
+ .padding(.horizontal, 4)
}
}
.frame(maxWidth: .infinity)
+ .contentShape(Rectangle())
+ .gesture(onCreateEvent != nil ? dragCreateGesture : nil)
}
}
- private func timedEventCard(_ event: CalendarEvent) -> some View {
+ // MARK: - Drag-to-create
+
+ private var dragCreateGesture: some Gesture {
+ DragGesture(minimumDistance: 8, coordinateSpace: .local)
+ .onChanged { value in
+ if dragStart == nil {
+ dragStart = value.startLocation.y
+ }
+ dragCurrent = value.location.y
+ }
+ .onEnded { value in
+ defer {
+ dragStart = nil
+ dragCurrent = nil
+ }
+ guard let start = dragStart else { return }
+ let startY = min(start, value.location.y)
+ let endY = max(start, value.location.y)
+
+ let startTime = timeFromY(startY)
+ var endTime = timeFromY(endY)
+ if endTime <= startTime {
+ endTime = startTime.addingTimeInterval(1800)
+ }
+ onCreateEvent?(startTime, endTime)
+ }
+ }
+
+ private func timeFromY(_ y: CGFloat) -> Date {
+ let totalSeconds = (y / hourHeight) * 3600
+ let hour = Int(totalSeconds / 3600)
+ let minute = Int((totalSeconds.truncatingRemainder(dividingBy: 3600)) / 60)
+ let clampedHour = max(0, min(23, calendarVM.dayStartHour + hour))
+ var comps = Calendar.current.dateComponents([.year, .month, .day], from: date)
+ comps.hour = clampedHour
+ comps.minute = (minute / 15) * 15 // snap to 15 min
+ comps.second = 0
+ return Calendar.current.date(from: comps) ?? date
+ }
+
+ private func timedEventCard(_ event: CalendarEvent, column: Int, totalColumns: Int, containerWidth: CGFloat) -> some View {
let y = calendarVM.yPosition(for: event.startDate, hourHeight: hourHeight)
let h = calendarVM.eventHeight(start: event.startDate, end: event.endDate, hourHeight: hourHeight)
let isHovered = hoveredEventId == event.id
let color = eventColor(for: event)
+ let cols = max(totalColumns, 1)
+ let gutter: CGFloat = 4
+ let colWidth = (containerWidth - gutter * CGFloat(cols + 1)) / CGFloat(cols)
+ let xOffset = gutter + CGFloat(column) * (colWidth + gutter)
+
return Button(action: { onEventTapped(event) }) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
@@ -181,7 +300,7 @@ struct CalendarDayView: View {
.padding(.leading, 10)
.padding(.trailing, 6)
.padding(.vertical, 4)
- .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(width: colWidth, alignment: .leading)
.frame(height: h, alignment: .top)
.foregroundStyle(color)
.background(color.opacity(isHovered ? 0.14 : Opacity.light))
@@ -192,8 +311,7 @@ struct CalendarDayView: View {
}
.buttonStyle(.plain)
.onHover { hovering in hoveredEventId = hovering ? event.id : nil }
- .padding(.horizontal, 4)
- .offset(y: y)
+ .offset(x: xOffset, y: y)
}
private func databaseItemCard(_ item: CalendarDatabaseItem) -> some View {
diff --git a/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift b/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift
index cf3ecb44..f413578e 100644
--- a/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift
+++ b/Sources/Bugbook/Views/Calendar/CalendarMonthView.swift
@@ -6,6 +6,7 @@ struct CalendarMonthView: View {
let events: [CalendarEvent]
let databaseItems: [CalendarDatabaseItem]
let calendarVM: CalendarViewModel
+ let calendarSources: [CalendarSource]
var onEventTapped: (CalendarEvent) -> Void
var onDatabaseItemTapped: (CalendarDatabaseItem) -> Void
@@ -85,9 +86,10 @@ struct CalendarMonthView: View {
VStack(spacing: 1) {
ForEach(Array(dayEvents.prefix(3)), id: \.id) { event in
Button(action: { onEventTapped(event) }) {
+ let color = eventColor(for: event)
HStack(spacing: 3) {
if !event.isAllDay {
- Circle().fill(Color.accentColor).frame(width: 5, height: 5)
+ Circle().fill(color).frame(width: 5, height: 5)
}
Text(event.isAllDay ? event.title : "\(calendarVM.timeString(for: event.startDate)) \(event.title)")
.font(.system(size: Typography.caption2))
@@ -96,7 +98,7 @@ struct CalendarMonthView: View {
.padding(.horizontal, 4)
.padding(.vertical, 1)
.frame(maxWidth: .infinity, alignment: .leading)
- .background(event.isAllDay ? Color.accentColor.opacity(0.12) : Color.clear)
+ .background(event.isAllDay ? color.opacity(0.12) : Color.clear)
.clipShape(.rect(cornerRadius: 2))
}
.buttonStyle(.plain)
@@ -142,4 +144,15 @@ struct CalendarMonthView: View {
)
.onHover { hovering in hoveredDay = hovering ? day : nil }
}
+
+ private func eventColor(for event: CalendarEvent) -> Color {
+ if let source = calendarSources.first(where: { $0.id == event.calendarId }) {
+ let hex = source.color
+ if hex.hasPrefix("#") {
+ return Color(hex: String(hex.dropFirst()))
+ }
+ return TagColor.color(for: hex)
+ }
+ return Color.accentColor
+ }
}
diff --git a/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift b/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift
index 94754ae4..dc44b259 100644
--- a/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift
+++ b/Sources/Bugbook/Views/Calendar/CalendarWeekView.swift
@@ -6,12 +6,19 @@ struct CalendarWeekView: View {
let events: [CalendarEvent]
let databaseItems: [CalendarDatabaseItem]
let calendarVM: CalendarViewModel
+ let calendarSources: [CalendarSource]
var onEventTapped: (CalendarEvent) -> Void
var onDatabaseItemTapped: (CalendarDatabaseItem) -> Void
+ var onCreateEvent: ((Date, Date) -> Void)?
@State private var hoveredEventId: String?
@State private var hoveredDbItemId: String?
+ // Drag-to-create state
+ @State private var dragStart: CGPoint?
+ @State private var dragCurrent: CGPoint?
+ @State private var dragDayIndex: Int?
+
private let hourHeight: CGFloat = 48
private let timeGutterWidth: CGFloat = 58
private let calendar = Calendar.current
@@ -104,7 +111,7 @@ struct CalendarWeekView: View {
}
private func allDayEventChip(_ event: CalendarEvent) -> some View {
- let color = Color.accentColor
+ let color = eventColor(for: event)
return Button(action: { onEventTapped(event) }) {
HStack(spacing: 4) {
Circle().fill(color).frame(width: 6, height: 6)
@@ -182,6 +189,46 @@ struct CalendarWeekView: View {
}
}
+ // MARK: - Overlap Layout
+
+ private struct EventLayout {
+ let event: CalendarEvent
+ let column: Int
+ let totalColumns: Int
+ }
+
+ private func computeOverlapLayout(_ events: [CalendarEvent]) -> [EventLayout] {
+ let sorted = events.sorted { $0.startDate < $1.startDate }
+ var columns: [Int: CalendarEvent] = [:]
+ var layouts: [EventLayout] = []
+
+ for event in sorted {
+ columns = columns.filter { _, occupant in occupant.endDate > event.startDate }
+ var col = 0
+ while columns[col] != nil { col += 1 }
+ columns[col] = event
+ layouts.append(EventLayout(event: event, column: col, totalColumns: 0))
+ }
+
+ for i in layouts.indices {
+ var maxCol = layouts[i].column
+ for j in layouts.indices where i != j {
+ let a = layouts[i].event
+ let b = layouts[j].event
+ if a.startDate < b.endDate && b.startDate < a.endDate {
+ maxCol = max(maxCol, layouts[j].column)
+ }
+ }
+ layouts[i] = EventLayout(
+ event: layouts[i].event,
+ column: layouts[i].column,
+ totalColumns: maxCol + 1
+ )
+ }
+
+ return layouts
+ }
+
// MARK: - Event Overlays
private var eventOverlays: some View {
@@ -189,36 +236,119 @@ struct CalendarWeekView: View {
Color.clear.frame(width: timeGutterWidth)
ForEach(Array(days.enumerated()), id: \.offset) { index, day in
- ZStack(alignment: .topLeading) {
- // Today column background
- if isToday(day) {
- Rectangle()
- .fill(Color.accentColor.opacity(0.02))
- }
+ GeometryReader { geo in
+ ZStack(alignment: .topLeading) {
+ // Today column background
+ if isToday(day) {
+ Rectangle()
+ .fill(Color.accentColor.opacity(0.02))
+ }
- let dayEvents = calendarVM.events(for: day, from: events)
- .filter { !$0.isAllDay }
- ForEach(dayEvents, id: \.id) { event in
- timedEventBlock(event)
+ let dayEvents = calendarVM.events(for: day, from: events)
+ .filter { !$0.isAllDay }
+ let layouts = computeOverlapLayout(dayEvents)
+ ForEach(layouts, id: \.event.id) { layout in
+ timedEventBlock(layout.event, column: layout.column, totalColumns: layout.totalColumns, containerWidth: geo.size.width)
+ }
+
+ let dbItems = calendarVM.databaseItems(for: day, from: databaseItems)
+ .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ ForEach(dbItems, id: \.id) { item in
+ databaseItemBlock(item)
+ }
}
- let dbItems = calendarVM.databaseItems(for: day, from: databaseItems)
- .filter { !$0.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- ForEach(dbItems, id: \.id) { item in
- databaseItemBlock(item)
+ // Drag-to-create preview for this column
+ if let preview = dragPreview, dragDayIndex == index {
+ Rectangle()
+ .fill(Color.accentColor.opacity(0.18))
+ .overlay(
+ Rectangle()
+ .fill(Color.accentColor)
+ .frame(width: 2),
+ alignment: .leading
+ )
+ .frame(height: preview.height)
+ .offset(y: preview.y)
+ .allowsHitTesting(false)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 1)
+ .contentShape(Rectangle())
+ .gesture(
+ onCreateEvent != nil ? dragCreateGesture(dayIndex: index, day: day) : nil
+ )
}
}
}
- private func timedEventBlock(_ event: CalendarEvent) -> some View {
+ // MARK: - Drag-to-create gesture
+
+ private struct DragPreview {
+ let y: CGFloat
+ let height: CGFloat
+ }
+
+ private var dragPreview: DragPreview? {
+ guard let start = dragStart, let current = dragCurrent else { return nil }
+ let minY = min(start.y, current.y)
+ let height = max(abs(current.y - start.y), hourHeight * 0.5)
+ return DragPreview(y: minY, height: height)
+ }
+
+ private func dragCreateGesture(dayIndex: Int, day: Date) -> some Gesture {
+ DragGesture(minimumDistance: 8, coordinateSpace: .local)
+ .onChanged { value in
+ if dragDayIndex == nil {
+ dragDayIndex = dayIndex
+ dragStart = value.startLocation
+ }
+ dragCurrent = value.location
+ }
+ .onEnded { value in
+ defer {
+ dragStart = nil
+ dragCurrent = nil
+ dragDayIndex = nil
+ }
+ guard dragDayIndex == dayIndex,
+ let start = dragStart else { return }
+
+ let startY = min(start.y, value.location.y)
+ let endY = max(start.y, value.location.y)
+
+ let startTime = date(from: startY, on: day)
+ var endTime = date(from: endY, on: day)
+ if endTime <= startTime {
+ endTime = startTime.addingTimeInterval(1800)
+ }
+ onCreateEvent?(startTime, endTime)
+ }
+ }
+
+ private func date(from y: CGFloat, on day: Date) -> Date {
+ let totalSeconds = (y / hourHeight) * 3600
+ let hour = Int(totalSeconds / 3600)
+ let minute = Int((totalSeconds.truncatingRemainder(dividingBy: 3600)) / 60)
+ let clampedHour = max(0, min(23, calendarVM.dayStartHour + hour))
+ var comps = calendar.dateComponents([.year, .month, .day], from: day)
+ comps.hour = clampedHour
+ comps.minute = (minute / 15) * 15 // snap to 15 min
+ comps.second = 0
+ return calendar.date(from: comps) ?? day
+ }
+
+ private func timedEventBlock(_ event: CalendarEvent, column: Int, totalColumns: Int, containerWidth: CGFloat) -> some View {
let y = calendarVM.yPosition(for: event.startDate, hourHeight: hourHeight)
let h = calendarVM.eventHeight(start: event.startDate, end: event.endDate, hourHeight: hourHeight)
let isHovered = hoveredEventId == event.id
- let eventColor = Color.accentColor
+ let eventColor = self.eventColor(for: event)
+
+ let cols = max(totalColumns, 1)
+ let gutter: CGFloat = 1
+ let colWidth = (containerWidth - gutter * CGFloat(cols + 1)) / CGFloat(cols)
+ let xOffset = gutter + CGFloat(column) * (colWidth + gutter)
return Button(action: { onEventTapped(event) }) {
VStack(alignment: .leading, spacing: 1) {
@@ -251,7 +381,7 @@ struct CalendarWeekView: View {
.padding(.leading, 6)
.padding(.trailing, 4)
.padding(.vertical, 2)
- .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(width: colWidth, alignment: .leading)
.frame(height: h, alignment: .top)
.foregroundStyle(eventColor)
.background(eventColor.opacity(isHovered ? 0.14 : 0.08))
@@ -264,7 +394,7 @@ struct CalendarWeekView: View {
}
.buttonStyle(.plain)
.onHover { hovering in hoveredEventId = hovering ? event.id : nil }
- .offset(y: y)
+ .offset(x: xOffset, y: y)
}
private func databaseItemBlock(_ item: CalendarDatabaseItem) -> some View {
@@ -341,4 +471,15 @@ struct CalendarWeekView: View {
private func isToday(_ date: Date) -> Bool {
calendar.isDateInToday(date)
}
+
+ private func eventColor(for event: CalendarEvent) -> Color {
+ if let source = calendarSources.first(where: { $0.id == event.calendarId }) {
+ let hex = source.color
+ if hex.hasPrefix("#") {
+ return Color(hex: String(hex.dropFirst()))
+ }
+ return TagColor.color(for: hex)
+ }
+ return Color.accentColor
+ }
}
diff --git a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift
index d6b8c5bf..964f35d2 100644
--- a/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift
+++ b/Sources/Bugbook/Views/Calendar/WorkspaceCalendarView.swift
@@ -23,7 +23,7 @@ struct WorkspaceCalendarView: View {
)
@State private var isCreatingEvent = false
@State private var createEventError: String?
- @State private var showEventSidebar = false
+ @State private var selectedCalendarEvent: CalendarEvent?
var body: some View {
GeometryReader { geo in
@@ -32,16 +32,8 @@ struct WorkspaceCalendarView: View {
if let error = calendarService.error, !error.isEmpty {
calendarErrorBanner(error)
}
- HStack(spacing: 0) {
- calendarContent
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
-
- if showEventSidebar {
- Divider()
- calendarEventSidebar
- .frame(width: 280)
- }
- }
+ calendarContent
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onChange(of: geo.size.width) { _, newWidth in
@@ -101,7 +93,8 @@ struct WorkspaceCalendarView: View {
calendarVM: calendarVM,
calendarSources: calendarService.sources,
onEventTapped: handleEventTapped,
- onDatabaseItemTapped: handleDatabaseItemTapped
+ onDatabaseItemTapped: handleDatabaseItemTapped,
+ onCreateEvent: handleDragCreateEvent
)
case .week:
CalendarWeekView(
@@ -109,8 +102,10 @@ struct WorkspaceCalendarView: View {
events: visibleEvents,
databaseItems: calendarService.databaseItems,
calendarVM: calendarVM,
+ calendarSources: calendarService.sources,
onEventTapped: handleEventTapped,
- onDatabaseItemTapped: handleDatabaseItemTapped
+ onDatabaseItemTapped: handleDatabaseItemTapped,
+ onCreateEvent: handleDragCreateEvent
)
case .month:
CalendarMonthView(
@@ -118,6 +113,7 @@ struct WorkspaceCalendarView: View {
events: visibleEvents,
databaseItems: calendarService.databaseItems,
calendarVM: calendarVM,
+ calendarSources: calendarService.sources,
onEventTapped: handleEventTapped,
onDatabaseItemTapped: handleDatabaseItemTapped
)
@@ -198,14 +194,6 @@ struct WorkspaceCalendarView: View {
.buttonStyle(.plain)
}
- // Sidebar toggle
- Button(action: { showEventSidebar.toggle() }) {
- Image(systemName: "sidebar.right")
- .font(.system(size: 13))
- .foregroundStyle(showEventSidebar ? Color.accentColor : .secondary)
- }
- .buttonStyle(.plain)
- .help("Toggle event details")
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
@@ -223,31 +211,29 @@ struct WorkspaceCalendarView: View {
}
}
- // MARK: - Event Detail Sidebar
- private var calendarEventSidebar: some View {
- VStack(alignment: .leading, spacing: 14) {
- HStack {
- Text("Event Details")
- .font(.system(size: 14, weight: .semibold))
- Spacer()
- Button(action: { showEventSidebar = false }) {
- Image(systemName: "xmark")
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- }
+ private func eventDateTimeString(_ event: CalendarEvent) -> String {
+ let formatter = DateFormatter()
+ if event.isAllDay {
+ formatter.dateFormat = "EEEE, MMMM d"
+ return formatter.string(from: event.startDate)
+ }
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "EEEE, MMMM d"
+ let startDay = dateFormatter.string(from: event.startDate)
+
+ let timeFormatter = DateFormatter()
+ timeFormatter.dateFormat = "h:mm a"
+ let startTime = timeFormatter.string(from: event.startDate)
+ let endTime = timeFormatter.string(from: event.endDate)
+ return "\(startDay) · \(startTime)–\(endTime)"
+ }
- ContentUnavailableView(
- "Select an event",
- systemImage: "calendar",
- description: Text("Click an event to see its details here.")
- )
- .frame(maxHeight: .infinity)
+ private func calendarSourceColor(_ hex: String) -> Color {
+ if hex.hasPrefix("#") {
+ return Color(hex: String(hex.dropFirst()))
}
- .padding(16)
- .background(Color.fallbackEditorBg)
+ return TagColor.color(for: hex)
}
private func calendarErrorBanner(_ message: String) -> some View {
@@ -294,13 +280,7 @@ struct WorkspaceCalendarView: View {
// MARK: - Actions
private func handleEventTapped(_ event: CalendarEvent) {
- guard let workspace = appState.workspacePath else { return }
- Task {
- if let pagePath = await meetingNoteService.createOrOpenMeetingNote(for: event, workspace: workspace) {
- calendarService.loadCachedData(workspace: workspace)
- onNavigateToFile(pagePath)
- }
- }
+ selectedCalendarEvent = event
}
private func handleDatabaseItemTapped(_ item: CalendarDatabaseItem) {
@@ -345,6 +325,21 @@ struct WorkspaceCalendarView: View {
}
}
+ private func handleDragCreateEvent(startDate: Date, endDate: Date) {
+ guard appState.settings.googleConfigured, appState.settings.googleConnected else {
+ appState.showSettings = true
+ appState.selectedSettingsTab = "google"
+ return
+ }
+ createEventDraft = CalendarEventDraft(
+ startDate: startDate,
+ endDate: endDate,
+ calendarId: "primary"
+ )
+ createEventError = nil
+ showCreateEventSheet = true
+ }
+
private func handleCreateEventButton() {
guard appState.settings.googleConfigured, appState.settings.googleConnected else {
appState.showSettings = true
@@ -544,6 +539,8 @@ struct CalendarEventComposerSheet: View {
DatePicker("Ends", selection: $draft.endDate)
}
+ recurrencePicker
+
VStack(alignment: .leading, spacing: 6) {
Text("Location")
.font(.system(size: 12, weight: .medium))
@@ -606,4 +603,76 @@ struct CalendarEventComposerSheet: View {
draft.endDate = calendar.startOfDay(for: max(draft.startDate, draft.endDate))
}
}
+
+ // MARK: - Recurrence Picker
+
+ @State private var showCustomRrule = false
+ @State private var customRruleText = ""
+
+ @ViewBuilder
+ private var recurrencePicker: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Repeat")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+
+ Picker("Repeat", selection: recurrencePickerBinding) {
+ Text("Does not repeat").tag("none")
+ Divider()
+ ForEach(RecurrenceFrequency.allCases) { freq in
+ Text(freq.label).tag(freq.rawValue)
+ }
+ Divider()
+ Text("Custom…").tag("custom")
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if showCustomRrule {
+ VStack(alignment: .leading, spacing: 4) {
+ TextField("FREQ=WEEKLY;BYDAY=MO,WE,FR", text: $customRruleText)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 12, design: .monospaced))
+ .onChange(of: customRruleText) { _, value in
+ draft.recurrence = .custom(value)
+ }
+ Text("Enter an RRULE value (without the RRULE: prefix).")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ private var recurrencePickerBinding: Binding {
+ Binding(
+ get: {
+ switch draft.recurrence {
+ case .none: return "none"
+ case .preset(let freq): return freq.rawValue
+ case .custom: return "custom"
+ }
+ },
+ set: { newTag in
+ showCustomRrule = false
+ switch newTag {
+ case "none":
+ draft.recurrence = .none
+ case "custom":
+ showCustomRrule = true
+ if case .custom(let raw) = draft.recurrence {
+ customRruleText = raw
+ } else {
+ customRruleText = ""
+ }
+ draft.recurrence = .custom(customRruleText)
+ default:
+ if let freq = RecurrenceFrequency(rawValue: newTag) {
+ draft.recurrence = .preset(freq)
+ }
+ }
+ }
+ )
+ }
}
diff --git a/Sources/Bugbook/Views/Components/CommandPaletteView.swift b/Sources/Bugbook/Views/Components/CommandPaletteView.swift
index 74de0c84..bde89b90 100644
--- a/Sources/Bugbook/Views/Components/CommandPaletteView.swift
+++ b/Sources/Bugbook/Views/Components/CommandPaletteView.swift
@@ -398,7 +398,7 @@ struct CommandPaletteView: View {
private var availableCommands: [PaletteCommand] {
[
- PaletteCommand(id: "toggle_sidebar", name: "Toggle Rail", icon: "sidebar.left", shortcut: "Cmd+\\") {
+ PaletteCommand(id: "toggle_sidebar", name: "Toggle Sidebar", icon: "sidebar.left", shortcut: "Cmd+.") {
NotificationCenter.default.post(name: .toggleSidebar, object: nil)
},
PaletteCommand(id: "new_note", name: "New Note", icon: "doc.badge.plus", shortcut: "Cmd+N") {
diff --git a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift
index 50b73d18..094cae58 100644
--- a/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift
+++ b/Sources/Bugbook/Views/Components/WorkspaceTabBar.swift
@@ -17,84 +17,87 @@ struct WorkspaceTabBar: View {
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
- ScrollView(.horizontal) {
- HStack(alignment: .bottom, spacing: -ShellZoomMetrics.size(8)) {
- ForEach(Array(workspaceManager.workspaces.enumerated()), id: \.element.id) { index, workspace in
- HStack(spacing: 0) {
- if dragOverIndex == index {
- Rectangle()
- .fill(Color.accentColor)
- .frame(width: 2, height: ShellZoomMetrics.size(24))
- .padding(.vertical, ShellZoomMetrics.size(4))
- }
-
- TabItemView(
- title: tabTitle(for: workspace),
- icon: tabIcon(for: workspace),
- isActive: index == workspaceManager.activeWorkspaceIndex,
- onSelect: { workspaceManager.switchWorkspace(to: index) },
- onClose: { workspaceManager.closeWorkspace(at: index) }
- )
- .zIndex(index == workspaceManager.activeWorkspaceIndex ? 1 : 0)
- .opacity(draggingId == workspace.id ? 0.4 : 1.0)
- .onDrag {
- draggingId = workspace.id
- return NSItemProvider(object: workspace.id.uuidString as NSString)
- }
- .onDrop(of: [.text], delegate: TabDropDelegate(
- targetIndex: index,
- workspaceManager: workspaceManager,
- dragOverIndex: $dragOverIndex,
- draggingId: $draggingId
- ))
+ // Sidebar toggle — only visible in tab bar when sidebar is closed
+ if !sidebarOpen {
+ Button {
+ NotificationCenter.default.post(name: .toggleSidebar, object: nil)
+ } label: {
+ Image(systemName: "sidebar.left")
+ .font(ShellZoomMetrics.font(13, weight: .medium))
+ .foregroundStyle(.secondary)
+ .frame(width: ShellZoomMetrics.size(30), height: ShellZoomMetrics.size(30))
+ }
+ .buttonStyle(.plain)
+ .help("Toggle Sidebar")
+ .padding(.leading, ShellZoomMetrics.size(70))
+ }
+
+ HStack(alignment: .bottom, spacing: -ShellZoomMetrics.size(8)) {
+ ForEach(Array(workspaceManager.workspaces.enumerated()), id: \.element.id) { index, workspace in
+ HStack(spacing: 0) {
+ if dragOverIndex == index {
+ Rectangle()
+ .fill(Color.accentColor)
+ .frame(width: 2, height: ShellZoomMetrics.size(24))
+ .padding(.vertical, ShellZoomMetrics.size(4))
}
- }
- if dragOverIndex == workspaceManager.workspaces.count {
- Rectangle()
- .fill(Color.accentColor)
- .frame(width: 2, height: ShellZoomMetrics.size(24))
- .padding(.vertical, ShellZoomMetrics.size(4))
+ TabItemView(
+ title: tabTitle(for: workspace),
+ icon: tabIcon(for: workspace),
+ isActive: index == workspaceManager.activeWorkspaceIndex,
+ onSelect: { workspaceManager.switchWorkspace(to: index) },
+ onClose: { workspaceManager.closeWorkspace(at: index) }
+ )
+ .zIndex(index == workspaceManager.activeWorkspaceIndex ? 1 : 0)
+ .opacity(draggingId == workspace.id ? 0.4 : 1.0)
+ .onDrag {
+ draggingId = workspace.id
+ return NSItemProvider(object: workspace.id.uuidString as NSString)
+ }
+ .onDrop(of: [.text], delegate: TabDropDelegate(
+ targetIndex: index,
+ workspaceManager: workspaceManager,
+ dragOverIndex: $dragOverIndex,
+ draggingId: $draggingId
+ ))
}
+ }
- // + button with content picker
- Button { showNewMenu = true } label: {
- Image(systemName: "plus")
- .font(ShellZoomMetrics.font(Typography.bodySmall))
- .foregroundStyle(.secondary)
- .frame(width: ShellZoomMetrics.size(28), height: ShellZoomMetrics.size(28))
- }
- .buttonStyle(.plain)
- .padding(.leading, ShellZoomMetrics.size(8))
- .padding(.bottom, ShellZoomMetrics.size(2))
- .floatingPopover(isPresented: $showNewMenu) {
- NewPanePopover(workspaceManager: workspaceManager, dismiss: { showNewMenu = false })
- .popoverSurface()
- }
- .onDrop(of: [.text], delegate: TabDropDelegate(
- targetIndex: workspaceManager.workspaces.count,
- workspaceManager: workspaceManager,
- dragOverIndex: $dragOverIndex,
- draggingId: $draggingId
- ))
+ if dragOverIndex == workspaceManager.workspaces.count {
+ Rectangle()
+ .fill(Color.accentColor)
+ .frame(width: 2, height: ShellZoomMetrics.size(24))
+ .padding(.vertical, ShellZoomMetrics.size(4))
+ }
+
+ // + button with content picker
+ Button { showNewMenu = true } label: {
+ Image(systemName: "plus")
+ .font(ShellZoomMetrics.font(Typography.bodySmall))
+ .foregroundStyle(.secondary)
+ .frame(width: ShellZoomMetrics.size(28), height: ShellZoomMetrics.size(28))
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, ShellZoomMetrics.size(8))
+ .padding(.bottom, ShellZoomMetrics.size(2))
+ .floatingPopover(isPresented: $showNewMenu) {
+ NewPanePopover(workspaceManager: workspaceManager, dismiss: { showNewMenu = false })
+ .popoverSurface()
}
- .padding(.leading, ShellZoomMetrics.size(2))
+ .onDrop(of: [.text], delegate: TabDropDelegate(
+ targetIndex: workspaceManager.workspaces.count,
+ workspaceManager: workspaceManager,
+ dragOverIndex: $dragOverIndex,
+ draggingId: $draggingId
+ ))
}
- .scrollIndicators(.hidden)
- .padding(.leading, ShellZoomMetrics.size(8))
- Spacer()
+ .padding(.leading, sidebarOpen ? ShellZoomMetrics.size(4) : 0)
+ Spacer(minLength: 0)
layoutSavedIndicator
}
- .padding(.top, ShellZoomMetrics.size(6))
.frame(height: ShellZoomMetrics.size(36))
- .background(
- ZStack(alignment: .bottom) {
- Color.fallbackTabBarBg
- Rectangle()
- .fill(Color.fallbackChromeBorder)
- .frame(height: 1)
- }
- )
+ .background(Container.groutBg)
.onChange(of: workspaceManager.lastSavedAt) { _, _ in
savedIndicatorTask?.cancel()
withAnimation(.easeIn(duration: 0.15)) { showSavedIndicator = true }
@@ -194,6 +197,10 @@ private struct NewPanePopover: View {
workspaceManager.addWorkspaceWith(content: .browserDocument())
dismiss()
}
+ contentRow(icon: "envelope", label: "Mail") {
+ workspaceManager.addWorkspaceWith(content: .mailDocument())
+ dismiss()
+ }
contentRow(icon: "calendar", label: "Calendar") {
workspaceManager.addWorkspaceWith(content: .calendarDocument())
dismiss()
@@ -258,7 +265,6 @@ private struct TabItemView: View {
@State private var isHovered = false
@State private var isCloseHovered = false
- private var wingRadius: CGFloat { ShellZoomMetrics.size(5) }
var body: some View {
Button(action: onSelect) {
@@ -266,7 +272,8 @@ private struct TabItemView: View {
tabIconView
Text(title)
- .font(ShellZoomMetrics.font(Typography.bodySmall))
+ .font(ShellZoomMetrics.font(Typography.bodySmall, weight: isActive ? .semibold : .regular))
+ .foregroundStyle(isActive ? .primary : Container.pillInactiveText)
.lineLimit(1)
Spacer(minLength: 0)
@@ -275,7 +282,7 @@ private struct TabItemView: View {
Image(systemName: "xmark")
.font(ShellZoomMetrics.font(9, weight: .semibold))
.foregroundStyle(.secondary)
- .frame(width: ShellZoomMetrics.size(20), height: ShellZoomMetrics.size(20))
+ .frame(width: ShellZoomMetrics.size(18), height: ShellZoomMetrics.size(18))
.background(isCloseHovered ? Color.primary.opacity(0.1) : .clear)
.clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.xs)))
}
@@ -283,35 +290,16 @@ private struct TabItemView: View {
.onHover { isCloseHovered = $0 }
.opacity(isHovered ? 1 : 0)
}
- .padding(.leading, ShellZoomMetrics.size(14))
+ .padding(.leading, ShellZoomMetrics.size(12))
.padding(.trailing, ShellZoomMetrics.size(8))
- .frame(width: ShellZoomMetrics.size(190), alignment: .leading)
- .frame(height: ShellZoomMetrics.size(30))
+ .frame(minWidth: ShellZoomMetrics.size(60), maxWidth: ShellZoomMetrics.size(180), alignment: .leading)
+ .frame(height: ShellZoomMetrics.size(28))
.background(
- Group {
- if isActive {
- ZStack(alignment: .bottom) {
- ConnectedTabShape(
- cornerRadius: ShellZoomMetrics.size(Radius.sm),
- wingRadius: wingRadius
- )
- .fill(Color.fallbackEditorBg)
- ConnectedTabShape(
- cornerRadius: ShellZoomMetrics.size(Radius.sm),
- wingRadius: wingRadius
- )
- .stroke(Color.fallbackChromeBorder, lineWidth: 1)
- }
- } else if isHovered {
- RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm))
- .fill(Color.primary.opacity(0.05))
- } else {
- Color.clear
- }
- }
+ RoundedRectangle(cornerRadius: Container.pillRadius)
+ .fill(isActive ? Container.pillActiveBg : (isHovered ? Color.primary.opacity(0.04) : Color.clear))
)
- .padding(.horizontal, ShellZoomMetrics.size(4))
- .contentShape(Rectangle())
+ .padding(.horizontal, ShellZoomMetrics.size(2))
+ .contentShape(RoundedRectangle(cornerRadius: Container.pillRadius))
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
diff --git a/Sources/Bugbook/Views/ContentView.swift b/Sources/Bugbook/Views/ContentView.swift
index 620cb95c..85d80740 100644
--- a/Sources/Bugbook/Views/ContentView.swift
+++ b/Sources/Bugbook/Views/ContentView.swift
@@ -27,12 +27,8 @@ struct ContentView: View {
@State private var workspaceManager = WorkspaceManager()
@State private var terminalManager = TerminalManager()
@State private var browserManager = BrowserManager()
- @State private var railEdgeHovering = false
- @State private var railHovering = false
- @State private var railPinnedOpen = false
@State private var saveTask: Task?
- @State private var sidebarPeek = SidebarPeekState()
@State private var editorUI = EditorUIState()
@State private var themeToast: ThemeMode?
@State private var themeToastTask: Task?
@@ -43,6 +39,7 @@ struct ContentView: View {
@State private var restoredWorkspaceDocuments = false
@State private var lastTrashPurgeWorkspace: String?
@State private var recordingPillController = FloatingRecordingPillController()
+ @State private var sidebarHoverVisible = false
@AppStorage(EditorTypography.zoomScaleKey) private var editorZoomScale = Double(EditorTypography.defaultZoomScale)
// Database row peek / modal
@@ -56,7 +53,7 @@ struct ContentView: View {
@State private var peekWidth: CGFloat = 640
@State private var peekDragStartWidth: CGFloat?
@State private var sidebarHiddenByPeek: Bool = false
- @State private var sidebarWasOpenBeforeSettings: Bool = true
+ @State private var sidebarVisibleBeforePeekHide: Bool = true
@State private var modalTarget: RowTarget?
@State private var showPageOptionsMenu = false
@State private var databaseRowFullWidth: [UUID: Bool] = [:]
@@ -81,18 +78,72 @@ struct ContentView: View {
private var baseLayout: some View {
ZStack(alignment: .leading) {
- // Solid backdrop so the content area's rounded corner reveals rail/sidebar color.
- Color.fallbackSidebarBg
+ Container.groutBg
HStack(spacing: 0) {
- contextualSidebarSection
+ if appState.sidebarVisible {
+ if appState.showSettings {
+ SettingsSidebarView(appState: appState)
+ .frame(width: appState.sidebarWidth)
+ } else {
+ HarborSidebarView(
+ appState: appState,
+ fileSystem: fileSystem,
+ activeFilePath: contextualSidebarActiveFilePath,
+ onSelectEntry: { entry in handleSidebarFileSelect(entry) },
+ onRefreshTree: { refreshFileTree() },
+ onOpenSettings: { openSettingsTab() },
+ contextualLabel: sidebarContextLabel,
+ contextualContent: { sidebarContextualContent }
+ )
+ .frame(width: appState.sidebarWidth)
+ }
+
+ SidebarResizeHandle(width: $appState.sidebarWidth)
+ }
+
mainContentWithAiPanel
}
.animation(.easeInOut(duration: 0.15), value: appState.showSettings)
- .animation(.easeInOut(duration: 0.15), value: contextualSidebarLeaf?.id)
+ .animation(.easeInOut(duration: 0.15), value: appState.sidebarVisible)
+
+ // Hover-reveal sidebar when collapsed
+ if !appState.sidebarVisible && !appState.showSettings {
+ HStack(spacing: 0) {
+ // Invisible hover target at left edge
+ Color.clear
+ .frame(width: 6)
+ .contentShape(Rectangle())
+ .onHover { hovering in
+ if hovering { sidebarHoverVisible = true }
+ }
+
+ if sidebarHoverVisible {
+ HarborSidebarView(
+ appState: appState,
+ fileSystem: fileSystem,
+ activeFilePath: contextualSidebarActiveFilePath,
+ onSelectEntry: { entry in handleSidebarFileSelect(entry) },
+ onRefreshTree: { refreshFileTree() },
+ onOpenSettings: { openSettingsTab() },
+ contextualLabel: sidebarContextLabel,
+ contextualContent: { sidebarContextualContent }
+ )
+ .frame(width: appState.sidebarWidth)
+ .shadow(color: .black.opacity(0.15), radius: 8, x: 2, y: 0)
+ .onHover { hovering in
+ if !hovering { sidebarHoverVisible = false }
+ }
+ .transition(.move(edge: .leading).combined(with: .opacity))
+ }
+
+ Spacer(minLength: 0)
+ }
+ .frame(maxHeight: .infinity)
+ .animation(.easeInOut(duration: 0.15), value: sidebarHoverVisible)
+ .zIndex(10)
+ }
- railEdgeHotZone
- navigationRailOverlay
commandPaletteOverlay
movePageOverlay
@@ -144,18 +195,11 @@ struct ContentView: View {
.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)
- }
.onChange(of: appState.showSettings) { _, showingSettings in
- if showingSettings {
- sidebarPeek.dismiss(immediately: true, reduceMotion: reduceMotion)
- sidebarWasOpenBeforeSettings = appState.sidebarOpen
- appState.sidebarOpen = true
- } else {
- appState.sidebarOpen = sidebarWasOpenBeforeSettings
+ // Ensure sidebar is visible when settings are opened
+ if showingSettings && !appState.sidebarVisible {
+ appState.sidebarVisible = true
}
- sidebarPeek.sync(eligible: sidebarPeekEligible, reduceMotion: reduceMotion)
}
.onChange(of: appState.fileTree) { _, newTree in
syncAvailablePages(newTree)
@@ -167,22 +211,20 @@ struct ContentView: View {
ensureAiInitializedIfNeeded()
}
}
- .onChange(of: editorUI.focusModeActive) { _, _ in
- sidebarPeek.sync(eligible: sidebarPeekEligible, reduceMotion: reduceMotion)
- }
- .onChange(of: sidebarPeek.trashPopoverPresented) { _, _ in
- sidebarPeek.sync(eligible: sidebarPeekEligible, reduceMotion: reduceMotion)
- }
.onChange(of: appState.settings.focusModeOnType) { _, enabled in
editorUI.focusModeEnabled = enabled
}
.onChange(of: workspaceManager.activeWorkspace?.focusedPaneId) { _, _ in
hideFormattingPanel()
closeDatabaseRowModal()
+ updateSidebarContextType()
}
.onChange(of: appState.currentView) { _, newView in
handleCurrentViewChange(newView)
}
+ .onChange(of: workspaceManager.activeWorkspaceIndex) { _, _ in
+ updateSidebarContextType()
+ }
.onChange(of: workspaceManager.workspaces) { _, workspaces in
let paneIDs = Set(workspaces.flatMap { $0.allLeaves.map(\.id) })
browserManager.cleanup(validPaneIDs: paneIDs)
@@ -378,13 +420,21 @@ struct ContentView: View {
}
}
.onReceive(NotificationCenter.default.publisher(for: .toggleSidebar)) { _ in
- handleSidebarToggleRequest()
+ guard !appState.showSettings else { return }
+ withAnimation(.easeInOut(duration: 0.15)) {
+ appState.sidebarVisible.toggle()
+ }
}
.onReceive(NotificationCenter.default.publisher(for: .quickOpen)) { _ in
flushDirtyTabContent()
appState.commandPaletteMode = .splitLauncher
appState.commandPaletteOpen.toggle()
}
+ .onReceive(NotificationCenter.default.publisher(for: .findInPage)) { _ in
+ if !postBrowserCommandIfFocused(.browserFind) {
+ NotificationCenter.default.post(name: .findInPane, object: nil)
+ }
+ }
.onReceive(NotificationCenter.default.publisher(for: .findInPane)) { _ in
_ = postBrowserCommandIfFocused(.browserFind)
}
@@ -499,153 +549,56 @@ struct ContentView: View {
}
}
- // MARK: - Shell Navigation
+ // MARK: - Sidebar
- private var railVisible: Bool {
- appState.settings.railPinned || railPinnedOpen || railEdgeHovering || railHovering
- }
-
- private var navigationRailOverlay: some View {
- NavigationRailView(
- indicatorProvider: railIndicator(for:),
- onSelect: handleRailSelection(_:)
- )
- .offset(x: railVisible ? 0 : -ShellSidebarMetrics.railWidth)
- .animation(.easeInOut(duration: 0.15), value: railVisible)
- .allowsHitTesting(railVisible)
- .onHover { hovering in
- railHovering = hovering
+ private var sidebarContextLabel: String? {
+ switch appState.sidebarContextType {
+ case .mail: return "Mail"
+ case .calendar: return "Calendar"
+ case .workspace: return "Pages"
+ case .none: return nil
}
- .zIndex(3)
}
@ViewBuilder
- private var railEdgeHotZone: some View {
- if !appState.settings.railPinned {
- HStack(spacing: 0) {
- Color.clear
- .frame(width: 6)
- .contentShape(Rectangle())
- .onHover { hovering in
- railEdgeHovering = hovering
- }
- Spacer(minLength: 0)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
- .zIndex(2)
- }
- }
-
- @ViewBuilder
- private var contextualSidebarSection: some View {
- if appState.showSettings {
- SettingsSidebarView(appState: appState)
- .transition(shellSidebarTransition)
- } else if let leaf = contextualSidebarLeaf {
- contextualSidebarView(for: leaf)
- .transition(shellSidebarTransition)
- }
- }
-
- private var shellSidebarTransition: AnyTransition {
- .move(edge: .leading).combined(with: .opacity)
- }
-
- @ViewBuilder
- private var sidebarPeekEdgeHotspot: some View {
- if sidebarPeekEligible {
- HStack(spacing: 0) {
- Color.clear
- .frame(width: 4)
- .contentShape(Rectangle())
- .onHover { hovering in
- sidebarPeek.setEdgeHovering(hovering, eligible: sidebarPeekEligible, reduceMotion: reduceMotion)
- }
- Spacer(minLength: 0)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
- }
- }
-
- @ViewBuilder
- private var sidebarToggleOverlay: some View {
- if sidebarPeekEligible {
- VStack {
- HStack {
- sidebarChromeButton(icon: "sidebar.left", help: "Toggle Sidebar (⌘.)") {
- openSidebarPinned()
- }
- .padding(ShellZoomMetrics.size(4))
- .contentShape(Rectangle())
- .onHover { hovering in
- sidebarPeek.setToggleHovering(hovering, eligible: sidebarPeekEligible, reduceMotion: reduceMotion)
+ private var sidebarContextualContent: some View {
+ switch appState.sidebarContextType {
+ case .mail:
+ MailContextualSidebarView(
+ appState: appState,
+ mailService: mailService,
+ onRefresh: {
+ guard let token = appState.settings.googleToken else { return }
+ Task {
+ await mailService.refreshSelectedMailbox(token: token)
}
- .padding(.leading, ShellZoomMetrics.size(80))
- Spacer()
}
- .padding(.top, ShellZoomMetrics.size(4))
- Spacer()
- }
- .opacity(editorUI.focusModeActive ? 0.0 : 1.0)
- }
- }
-
- @ViewBuilder
- private var sidebarPeekOverlay: some View {
- if sidebarPeekEligible {
- SidebarView(
+ )
+ case .calendar:
+ CalendarContextualSidebarView(
+ calendarVM: calendarVM,
+ calendarService: calendarService,
+ workspacePath: appState.workspacePath
+ )
+ case .workspace:
+ WorkspaceContextualSidebarView(
appState: appState,
fileSystem: fileSystem,
- onSelectFile: { entry in
- handleSidebarFileSelect(entry)
- sidebarPeek.dismiss(immediately: true, reduceMotion: reduceMotion)
- },
- onToggleSidebar: {
- openSidebarPinned()
- },
- onAddSidebarReference: { payload in
- addSidebarReference(payload)
- },
- layoutMode: .compact,
- onActionInvoked: {
- sidebarPeek.dismiss(immediately: true, reduceMotion: reduceMotion)
- },
- trashPopoverOverride: Binding(
- get: { sidebarPeek.trashPopoverPresented },
- set: { sidebarPeek.trashPopoverPresented = $0 }
- )
+ activeFilePath: contextualSidebarActiveFilePath,
+ onSelectWorkspaceEntry: { entry in handleSidebarFileSelect(entry) },
+ onRefreshTree: { refreshFileTree() }
)
- .frame(width: ShellZoomMetrics.size(208))
- .frame(maxHeight: ShellZoomMetrics.size(430), alignment: .topLeading)
- .clipShape(.rect(cornerRadius: ShellZoomMetrics.size(Radius.md)))
- .overlay {
- RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.md))
- .stroke(Color.fallbackChromeBorder, lineWidth: 0.5)
- .allowsHitTesting(false)
- }
- .shadow(color: Color.black.opacity(0.10), radius: 10, x: 4, y: 4)
- .contentShape(Rectangle())
- .offset(x: sidebarPeek.isVisible ? 0 : sidebarPeekHiddenOffset)
- .opacity(sidebarPeek.isVisible ? 1 : 0)
- .allowsHitTesting(sidebarPeek.isVisible)
- .onHover { hovering in
- // Only track overlay hover when peek is actually visible —
- // .onHover fires even with allowsHitTesting(false)
- guard sidebarPeek.isVisible else {
- if sidebarPeek.overlayHovering {
- sidebarPeek.setOverlayHovering(false, eligible: sidebarPeekEligible, reduceMotion: reduceMotion)
- }
- return
- }
- sidebarPeek.setOverlayHovering(hovering, eligible: sidebarPeekEligible, reduceMotion: reduceMotion)
- }
- .padding(.top, ShellZoomMetrics.size(72))
- .padding(.leading, ShellZoomMetrics.size(8))
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .zIndex(1)
+ case .none:
+ EmptyView()
}
}
+ private func updateSidebarContextType() {
+ guard let ws = workspaceManager.activeWorkspace,
+ let focusedLeaf = ws.focusedLeaf else { return }
+ appState.sidebarContextType = SidebarContextType.from(focusedLeaf.content)
+ }
+
@ViewBuilder
private var commandPaletteOverlay: some View {
if appState.commandPaletteOpen {
@@ -763,24 +716,6 @@ struct ContentView: View {
.disabled(!isEnabled)
}
- private var sidebarPeekEligible: Bool {
- !shellShowsSidebarPanel && !editorUI.focusModeActive && !sidebarHiddenByPeek
- }
-
- private var sidebarPeekHiddenOffset: CGFloat {
- reduceMotion ? 0 : -ShellZoomMetrics.size(18)
- }
-
- private func openSidebarPinned() {
- sidebarPeek.dismiss(immediately: true, reduceMotion: reduceMotion)
- appState.sidebarOpen = true
- }
-
- private func handleSidebarToggleRequest() {
- withAnimation(.easeInOut(duration: 0.15)) {
- railPinnedOpen.toggle()
- }
- }
@discardableResult
private func postBrowserCommandIfFocused(_ name: Notification.Name, object: Any? = nil) -> Bool {
@@ -793,19 +728,8 @@ struct ContentView: View {
workspaceManager.activeWorkspace?.allLeaves ?? []
}
- private var contextualSidebarLeaf: PaneNode.Leaf? {
- guard !appState.showSettings,
- let workspace = workspaceManager.activeWorkspace,
- workspace.allLeaves.count == 1,
- let leaf = workspace.focusedLeaf,
- paneUsesContextualSidebar(leaf) else {
- return nil
- }
- return leaf
- }
-
private var shellShowsSidebarPanel: Bool {
- appState.showSettings || contextualSidebarLeaf != nil
+ appState.sidebarVisible
}
private var focusedBrowserPaneID: UUID? {
@@ -824,35 +748,6 @@ struct ContentView: View {
return file.path
}
- private func paneUsesContextualSidebar(_ leaf: PaneNode.Leaf) -> Bool {
- switch leaf.content {
- case .terminal:
- return false
- case .document(let file):
- return file.isMail || file.isCalendar
- }
- }
-
- private func leafMatchesRailItem(_ leaf: PaneNode.Leaf, item: RailItemID) -> Bool {
- switch (item, leaf.content) {
- case (.terminal, .terminal):
- return true
- case (.terminal, .document):
- return false
- case (_, .terminal):
- return false
- case let (.home, .document(file)):
- return file.isGateway
- case let (.mail, .document(file)):
- return file.isMail
- case let (.calendar, .document(file)):
- return file.isCalendar
- case let (.browser, .document(file)):
- return file.isBrowser
- case (.settings, _):
- return appState.showSettings
- }
- }
private func handleViewDisappear() {
flushDirtyTabs()
@@ -863,25 +758,10 @@ struct ContentView: View {
aiInitTask?.cancel()
aiInitTask = nil
editorUI.cleanUp()
- sidebarPeek.cleanUp()
workspaceWatcher?.stop()
recordingPillController.cleanup()
}
- private func railIndicator(for item: RailItemID) -> RailIndicatorState {
- if item == .settings {
- return appState.showSettings ? .focused : .none
- }
- let leaves = activeWorkspaceLeaves
- guard leaves.contains(where: { leafMatchesRailItem($0, item: item) }) else {
- return .none
- }
- if let focusedLeaf = workspaceManager.focusedPane,
- leafMatchesRailItem(focusedLeaf, item: item) {
- return .focused
- }
- return .open
- }
private func presentEditorPane(_ content: PaneContent) {
appState.currentView = .editor
@@ -889,44 +769,12 @@ struct ContentView: View {
openOrFocusPane(content)
}
- private func handleRailSelection(_ item: RailItemID) {
- switch item {
- case .settings:
- openSettingsTab()
- case .home:
- presentEditorPane(.gatewayDocument())
- case .mail:
- presentEditorPane(.mailDocument())
- case .calendar:
- presentEditorPane(.calendarDocument())
- case .browser:
- presentEditorPane(.browserDocument())
- case .terminal:
- presentEditorPane(.terminal)
+ private func handleSidebarToggle() {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ appState.sidebarVisible.toggle()
}
}
- @ViewBuilder
- private func contextualSidebarView(for leaf: PaneNode.Leaf) -> some View {
- if case .document(let file) = leaf.content, file.isMail {
- MailContextualSidebarView(
- appState: appState,
- mailService: mailService,
- onRefresh: {
- guard let token = appState.settings.googleToken else { return }
- Task {
- await mailService.refreshSelectedMailbox(token: token)
- }
- }
- )
- } else if case .document(let file) = leaf.content, file.isCalendar {
- CalendarContextualSidebarView(
- calendarVM: calendarVM,
- calendarService: calendarService,
- workspacePath: appState.workspacePath
- )
- }
- }
// MARK: - Move Page Overlay
@@ -1257,54 +1105,60 @@ struct ContentView: View {
@ViewBuilder
private var mainContentWithAiPanel: some View {
- ZStack(alignment: .trailing) {
- VStack(spacing: 0) {
- if appState.showSettings {
- SettingsView(appState: appState)
- } else if appState.currentView == .graphView {
- if let workspace = appState.workspacePath {
- GraphView(
- backlinkService: backlinkService,
- workspacePath: workspace,
- currentPagePath: appState.activeTab?.path,
- onNavigateToFile: { path in
- navigateToFilePath(path)
- }
- )
+ VStack(spacing: 0) {
+ // Tab bar sits on the grout surface (above the card)
+ if !appState.showSettings && appState.currentView != .graphView {
+ WorkspaceTabBar(
+ workspaceManager: workspaceManager,
+ sidebarOpen: shellShowsSidebarPanel,
+ currentView: appState.currentView
+ )
+ .opacity(editorUI.focusModeActive ? 0.0 : 1.0)
+ }
+
+ // Content card with grout padding
+ ZStack(alignment: .trailing) {
+ VStack(spacing: 0) {
+ if appState.showSettings {
+ SettingsView(appState: appState)
+ .background(Container.cardBg)
+ .clipShape(RoundedRectangle(cornerRadius: Container.cardRadius))
+ } else if appState.currentView == .graphView {
+ if let workspace = appState.workspacePath {
+ GraphView(
+ backlinkService: backlinkService,
+ workspacePath: workspace,
+ currentPagePath: appState.activeTab?.path,
+ onNavigateToFile: { path in
+ navigateToFilePath(path)
+ }
+ )
+ .background(Container.cardBg)
+ .clipShape(RoundedRectangle(cornerRadius: Container.cardRadius))
+ }
+ } else {
+ paneTreeContent
}
- } else {
- editorModeContent
}
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .ignoresSafeArea(.container, edges: .top)
- .background(Color.fallbackEditorBg)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .ignoresSafeArea(.container, edges: .top)
- if appState.aiSidePanelOpen && appState.currentView == .editor {
- AiSidePanelView(
- appState: appState,
- aiService: aiService,
- activeDocument: activeDocumentForAiDrawer,
- drawerContext: aiDrawerContext
- )
- .overlay(alignment: .leading) {
- Rectangle()
- .fill(Color.fallbackChromeBorder)
- .frame(width: 1)
+ if appState.aiSidePanelOpen && appState.currentView == .editor {
+ AiSidePanelView(
+ appState: appState,
+ aiService: aiService,
+ activeDocument: activeDocumentForAiDrawer,
+ drawerContext: aiDrawerContext
+ )
+ .transition(.move(edge: .trailing).combined(with: .opacity))
+ .zIndex(2)
}
- .transition(.move(edge: .trailing).combined(with: .opacity))
- .zIndex(2)
}
+ .padding(.leading, appState.sidebarVisible ? 0 : Container.groutGap)
+ .padding(.trailing, Container.groutGap)
+ .padding(.bottom, Container.groutGap)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .clipShape(
- UnevenRoundedRectangle(
- topLeadingRadius: shellShowsSidebarPanel ? ShellZoomMetrics.size(14) : 0,
- bottomLeadingRadius: 0,
- bottomTrailingRadius: 0,
- topTrailingRadius: 0
- )
- )
}
private var activeTabLeadingPadding: CGFloat {
@@ -1315,14 +1169,7 @@ struct ContentView: View {
}
@ViewBuilder
- private var editorModeContent: some View {
- WorkspaceTabBar(
- workspaceManager: workspaceManager,
- sidebarOpen: shellShowsSidebarPanel,
- currentView: appState.currentView
- )
- .opacity(editorUI.focusModeActive ? 0.0 : 1.0)
-
+ private var paneTreeContent: some View {
Group {
if let ws = workspaceManager.activeWorkspace {
PaneTreeView(
@@ -2544,11 +2391,8 @@ struct ContentView: View {
}
private func initializeWorkspace() {
- // Restore the most recently used workspace, falling back to the default
- let restoredPath = fileSystem.recentWorkspaces.first(where: {
- FileManager.default.fileExists(atPath: $0)
- })
- let workspacePath = restoredPath ?? fileSystem.defaultWorkspacePath()
+ // Use the full iCloud-aware path (scans siblings to pick richest workspace)
+ let workspacePath = WorkspaceResolver.defaultWorkspacePath(allowBlockingICloudLookup: true)
if !FileManager.default.fileExists(atPath: workspacePath) {
try? FileManager.default.createDirectory(atPath: workspacePath, withIntermediateDirectories: true)
}
@@ -2556,17 +2400,13 @@ struct ContentView: View {
scheduleTrashPurgeIfNeeded(for: workspacePath)
appState.workspacePath = workspacePath
- // If we took the default path (no explicit user override), upgrade to the
- // canonical iCloud workspace in the background. The initial local path
- // renders the UI instantly; this repoints us at iCloud once resolved.
- if restoredPath == nil {
- Task { @MainActor in
- if let iCloudPath = await fileSystem.upgradeDefaultToICloudIfAvailable() {
- appState.workspacePath = iCloudPath
- scheduleTrashPurgeIfNeeded(for: iCloudPath)
- refreshFileTree()
- startWorkspaceWatcher(path: iCloudPath)
- }
+ // Upgrade to the canonical iCloud workspace in the background if available.
+ Task { @MainActor in
+ if let iCloudPath = await fileSystem.upgradeDefaultToICloudIfAvailable() {
+ appState.workspacePath = iCloudPath
+ scheduleTrashPurgeIfNeeded(for: iCloudPath)
+ refreshFileTree()
+ startWorkspaceWatcher(path: iCloudPath)
}
}
@@ -3889,10 +3729,11 @@ struct ContentView: View {
// Auto-hide sidebar when peek is wide
if clamped > 600 && !sidebarHiddenByPeek {
sidebarHiddenByPeek = true
- appState.sidebarOpen = false
+ sidebarVisibleBeforePeekHide = appState.sidebarVisible
+ appState.sidebarVisible = false
} else if clamped <= 600 && sidebarHiddenByPeek {
sidebarHiddenByPeek = false
- appState.sidebarOpen = true
+ appState.sidebarVisible = sidebarVisibleBeforePeekHide
}
}
@@ -3900,7 +3741,7 @@ struct ContentView: View {
peekTarget = nil
if sidebarHiddenByPeek {
sidebarHiddenByPeek = false
- appState.sidebarOpen = true
+ appState.sidebarVisible = sidebarVisibleBeforePeekHide
}
}
diff --git a/Sources/Bugbook/Views/Database/TableView.swift b/Sources/Bugbook/Views/Database/TableView.swift
index efcba1af..049b5db6 100644
--- a/Sources/Bugbook/Views/Database/TableView.swift
+++ b/Sources/Bugbook/Views/Database/TableView.swift
@@ -428,7 +428,7 @@ struct TableView: View {
: isHovered ? Color.primary.opacity(0.04) : Color.clear
)
)
- .overlay { columnDividers().allowsHitTesting(false) }
+ .overlay { columnDividers(offsets: columnDividerOffsets).allowsHitTesting(false) }
}
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .topLeading) {
@@ -475,13 +475,25 @@ struct TableView: View {
private func columnDividers() -> some View {
if showVerticalLines {
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))
- )
- }
+ columnDividersCanvas(offsets: offsets)
+ }
+ }
+
+ /// Variant that accepts precomputed offsets to avoid recomputing per row.
+ @ViewBuilder
+ private func columnDividers(offsets: [CGFloat]) -> some View {
+ if !offsets.isEmpty {
+ columnDividersCanvas(offsets: offsets)
+ }
+ }
+
+ private func columnDividersCanvas(offsets: [CGFloat]) -> some View {
+ 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))
+ )
}
}
}
@@ -754,13 +766,13 @@ struct TableView: View {
}
.buttonStyle(.plain)
- // Group rows
+ // Group rows — use Set for O(1) membership test instead of iterating all rows per group
if !isCollapsed {
let groupRowIds = Set(group.rows.map(\.id))
ForEach($rows) { $row in
if groupRowIds.contains(row.id) {
dataRow($row)
- .id($row.wrappedValue.id)
+ .id(row.id)
tableDivider.opacity(0.5)
}
}
diff --git a/Sources/Bugbook/Views/Editor/BlockTextView.swift b/Sources/Bugbook/Views/Editor/BlockTextView.swift
index f195a826..41398881 100644
--- a/Sources/Bugbook/Views/Editor/BlockTextView.swift
+++ b/Sources/Bugbook/Views/Editor/BlockTextView.swift
@@ -33,6 +33,20 @@ enum EditorSelectionStyle {
}
}
+enum EditorFindStyle {
+ static var backgroundColor: NSColor {
+ NSColor.systemYellow.withAlphaComponent(0.35)
+ }
+
+ static var activeBackgroundColor: NSColor {
+ NSColor.systemOrange.withAlphaComponent(0.45)
+ }
+
+ static var foregroundColor: NSColor {
+ NSColor.labelColor
+ }
+}
+
/// NSViewRepresentable wrapping NSTextView for per-block text editing.
/// Handles keyboard intercepts for block splitting, merging, and navigation.
/// Supports rich text with WYSIWYG inline formatting (bold, italic, code, strikethrough, links).
@@ -277,7 +291,12 @@ struct BlockTextView: NSViewRepresentable {
)
let isFirstResponder = textView.window?.firstResponder === textView
let isActivelyDraggingMultiBlockSelection = textView.isInBlockSelection
- context.coordinator.updateTemporaryMultiBlockHighlight(on: textView, range: multiBlockRange)
+ context.coordinator.updateTemporaryHighlights(
+ on: textView,
+ query: document.findHighlightQuery,
+ selectedFindMatch: document.findSelectedMatch,
+ selectionRange: multiBlockRange
+ )
if let range = multiBlockRange, isFirstResponder, !isActivelyDraggingMultiBlockSelection {
context.coordinator.withProgrammaticViewUpdate {
@@ -455,7 +474,12 @@ struct BlockTextView: NSViewRepresentable {
parent.document.cutMultiBlockSelectedText()
}
- func updateTemporaryMultiBlockHighlight(on textView: NSTextView, range: NSRange?) {
+ func updateTemporaryHighlights(
+ on textView: NSTextView,
+ query: String,
+ selectedFindMatch: BlockFindSelection?,
+ selectionRange: NSRange?
+ ) {
guard let layoutManager = textView.layoutManager else { return }
let fullLength = (textView.string as NSString).length
@@ -465,14 +489,16 @@ struct BlockTextView: NSViewRepresentable {
layoutManager.removeTemporaryAttribute(.foregroundColor, forCharacterRange: fullRange)
}
- guard let range,
- range.length > 0,
+ applyFindHighlights(on: textView, query: query, selectedFindMatch: selectedFindMatch)
+
+ guard let selectionRange,
+ selectionRange.length > 0,
textView.window?.firstResponder !== textView else {
return
}
- let location = min(max(0, range.location), fullLength)
- let length = min(max(0, range.length), max(0, fullLength - location))
+ let location = min(max(0, selectionRange.location), fullLength)
+ let length = min(max(0, selectionRange.length), max(0, fullLength - location))
guard length > 0 else { return }
let highlightRange = NSRange(location: location, length: length)
@@ -488,6 +514,46 @@ struct BlockTextView: NSViewRepresentable {
)
}
+ func applyFindHighlights(
+ on textView: NSTextView,
+ query: String,
+ selectedFindMatch: BlockFindSelection?
+ ) {
+ guard let layoutManager = textView.layoutManager else { return }
+
+ let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedQuery.isEmpty else { return }
+
+ let visibleText = textView.string as NSString
+ var searchRange = NSRange(location: 0, length: visibleText.length)
+
+ while searchRange.location < visibleText.length {
+ let matchRange = visibleText.range(
+ of: trimmedQuery,
+ options: [.caseInsensitive, .diacriticInsensitive],
+ range: searchRange
+ )
+ guard matchRange.location != NSNotFound else { break }
+
+ layoutManager.addTemporaryAttribute(
+ .backgroundColor,
+ value: selectedFindMatch == BlockFindSelection(blockId: parent.blockId, range: matchRange)
+ ? EditorFindStyle.activeBackgroundColor
+ : EditorFindStyle.backgroundColor,
+ forCharacterRange: matchRange
+ )
+ layoutManager.addTemporaryAttribute(
+ .foregroundColor,
+ value: EditorFindStyle.foregroundColor,
+ forCharacterRange: matchRange
+ )
+
+ let nextLocation = matchRange.location + max(matchRange.length, 1)
+ guard nextLocation < visibleText.length else { break }
+ searchRange = NSRange(location: nextLocation, length: visibleText.length - nextLocation)
+ }
+ }
+
// MARK: - Image Paste
/// Handles pasting image data from the clipboard. Returns true if an image was handled.
diff --git a/Sources/Bugbook/Views/Editor/BlockViews.swift b/Sources/Bugbook/Views/Editor/BlockViews.swift
index 81995130..359d9f44 100644
--- a/Sources/Bugbook/Views/Editor/BlockViews.swift
+++ b/Sources/Bugbook/Views/Editor/BlockViews.swift
@@ -1,5 +1,6 @@
import SwiftUI
import UniformTypeIdentifiers
+import BugbookCore
// MARK: - Shared block-deletion keyboard modifier
@@ -80,8 +81,22 @@ struct ImageBlockView: View {
@State private var isResizing = false
@State private var resizeStartWidth: CGFloat?
@State private var transientWidth: CGFloat?
- private var isLocalImage: Bool {
- block.imageSource.hasPrefix("/") || block.imageSource.hasPrefix("file://")
+ private var resolvedLocalImagePath: String? {
+ resolveWorkspaceAttachmentPath(
+ block.imageSource,
+ pagePath: document.filePath,
+ workspacePath: document.workspacePath
+ )
+ }
+
+ private var remoteImageURL: URL? {
+ guard let url = URL(string: block.imageSource),
+ let scheme = url.scheme,
+ !scheme.isEmpty,
+ !url.isFileURL else {
+ return nil
+ }
+ return url
}
private var currentWidth: CGFloat? {
@@ -141,23 +156,30 @@ struct ImageBlockView: View {
.appCursor(.iBeam)
}
.blockDeletable(document: document, blockId: block.id)
- .task(id: block.imageSource) {
- guard isLocalImage else { return }
- let source = block.imageSource
- let fileURL = source.hasPrefix("file://")
- ? URL(string: source)!
- : URL(fileURLWithPath: source)
- cachedImage = NSImage(contentsOf: fileURL)
+ .task(id: imageResolutionKey) {
+ guard let resolvedLocalImagePath else {
+ cachedImage = nil
+ return
+ }
+ cachedImage = NSImage(contentsOfFile: resolvedLocalImagePath)
}
}
+ private var imageResolutionKey: String {
+ [
+ document.filePath ?? "",
+ document.workspacePath ?? "",
+ block.imageSource,
+ ].joined(separator: "|")
+ }
+
private var showsResizeBars: Bool {
isHovered || isResizing
}
@ViewBuilder
private var imageContent: some View {
- if isLocalImage {
+ if resolvedLocalImagePath != nil {
if let nsImage = cachedImage {
Image(nsImage: nsImage)
.resizable()
@@ -166,7 +188,7 @@ struct ImageBlockView: View {
} else {
imagePlaceholder
}
- } else if let url = URL(string: block.imageSource) {
+ } else if let url = remoteImageURL {
AsyncImage(url: url) { image in
image
.resizable()
diff --git a/Sources/Bugbook/Views/Mail/MailPaneView.swift b/Sources/Bugbook/Views/Mail/MailPaneView.swift
index e65c146d..ebcd3bab 100644
--- a/Sources/Bugbook/Views/Mail/MailPaneView.swift
+++ b/Sources/Bugbook/Views/Mail/MailPaneView.swift
@@ -23,23 +23,21 @@ struct MailPaneView: View {
title: "Connect Gmail",
message: "Sign in once and Bugbook will use that Google account for both Mail and Calendar."
)
+ } else if mailService.selectedThread != nil || mailService.isLoadingThread {
+ // Full-screen thread view — no filter tabs, just the thread
+ detailPane
} else {
- VStack(spacing: 0) {
- mailFilterTabs
- batchToolbar
- Divider()
-
- if mailService.selectedThread != nil || mailService.isLoadingThread || mailService.isComposing {
- // Split view: thread list + detail
- HSplitView {
- threadList
- .frame(minWidth: 280, idealWidth: 360)
- detailPane
- .frame(minWidth: 300)
- }
- } else {
+ ZStack {
+ VStack(spacing: 0) {
+ mailFilterTabs
+ batchToolbar
+ Divider()
threadList
}
+
+ if mailService.isComposing && mailService.composer.threadId == nil {
+ floatingComposeCard
+ }
}
}
}
@@ -334,11 +332,42 @@ struct MailPaneView: View {
.background(Color.fallbackEditorBg)
}
+ private var floatingComposeCard: some View {
+ VStack(spacing: 0) {
+ // Title bar
+ HStack {
+ Text("New Message")
+ .font(.system(size: 13, weight: .semibold))
+ Spacer()
+ Button {
+ mailService.dismissComposer()
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(Color.primary.opacity(0.04))
+
+ Divider()
+
+ composeView(title: "")
+ }
+ .frame(width: 450, height: 520)
+ .background(Color.fallbackEditorBg)
+ .clipShape(.rect(cornerRadius: 12))
+ .shadow(color: .black.opacity(0.2), radius: 16, x: 0, y: 4)
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.fallbackChromeBorder, lineWidth: 0.5))
+ .padding(16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
+ }
+
@ViewBuilder
private var detailPane: some View {
- if mailService.isComposing && mailService.composer.threadId == nil {
- composeView(title: "New Message")
- } else if let thread = mailService.selectedThread {
+ if let thread = mailService.selectedThread {
VStack(spacing: 0) {
threadToolbar(thread)
Divider()
@@ -349,12 +378,17 @@ struct MailPaneView: View {
}
if mailService.isComposing, mailService.composer.threadId == thread.id {
- composeView(title: mailService.composer.mode == .replyAll ? "Reply All" : "Reply")
+ composeView(title: mailService.composer.mode == .replyAll ? "Reply All" : (mailService.composer.mode == .forward ? "Forward" : "Reply"))
+ } else {
+ threadActionButtons(thread)
}
}
.padding(20)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.fallbackEditorBg)
} else if mailService.isLoadingThread {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -395,6 +429,12 @@ struct MailPaneView: View {
let unread = thread.isUnread
return HStack(spacing: 0) {
+ // Unread indicator dot
+ Circle()
+ .fill(unread ? Color.accentColor : Color.clear)
+ .frame(width: 6, height: 6)
+ .padding(.trailing, 6)
+
// Checkbox
Button {
if isSelected {
@@ -410,40 +450,40 @@ struct MailPaneView: View {
.buttonStyle(.plain)
.padding(.trailing, 10)
- // Sender (brightest tier)
+ // Sender
Text(senderDisplayName(thread.participants.first ?? "Unknown"))
- .font(.system(size: 13, weight: unread ? .bold : .regular))
- .foregroundStyle(unread ? .primary : .secondary)
+ .font(.system(size: 14, weight: unread ? .bold : .medium))
+ .foregroundColor(unread ? Color(nsColor: .labelColor) : Color(nsColor: .secondaryLabelColor))
.lineLimit(1)
.fixedSize()
- .padding(.trailing, 6)
+ .padding(.trailing, 8)
- // Subject (mid tier)
+ // Subject
Text(thread.subject)
.font(.system(size: 13, weight: unread ? .semibold : .regular))
- .foregroundStyle(unread ? Color.primary.opacity(0.8) : Color.primary.opacity(0.45))
+ .foregroundColor(unread ? Color(nsColor: .labelColor) : Color(nsColor: .secondaryLabelColor))
.lineLimit(1)
.layoutPriority(1)
- // Separator + snippet (muted tier)
+ // Separator + snippet
if !thread.snippet.isEmpty {
- Text(" · ")
+ Text(" — ")
.font(.system(size: 13))
- .foregroundStyle(Color.primary.opacity(0.2))
+ .foregroundColor(Color(nsColor: .tertiaryLabelColor))
Text(thread.snippet)
.font(.system(size: 13))
- .foregroundStyle(Color.primary.opacity(0.25))
+ .foregroundColor(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
}
Spacer(minLength: 8)
- // Date (mono for alignment)
+ // Date
if let date = thread.date {
Text(relativeDate(date))
- .font(.system(size: 12, weight: unread ? .medium : .regular, design: .monospaced))
- .foregroundStyle(unread ? Color.primary.opacity(0.6) : Color.primary.opacity(0.25))
+ .font(.system(size: 12, weight: unread ? .medium : .regular))
+ .foregroundColor(Color(nsColor: .secondaryLabelColor))
.fixedSize()
}
}
@@ -489,6 +529,14 @@ struct MailPaneView: View {
private func threadToolbar(_ thread: MailThreadDetail) -> some View {
HStack(spacing: 10) {
+ Button(action: { mailService.selectedThreadID = nil }) {
+ Image(systemName: "chevron.left")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help("Back to inbox")
+
VStack(alignment: .leading, spacing: 4) {
Text(thread.subject)
.font(.system(size: 16, weight: .semibold))
@@ -535,11 +583,38 @@ struct MailPaneView: View {
}
.buttonStyle(.plain)
}
+
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
+ private func threadActionButtons(_ thread: MailThreadDetail) -> some View {
+ HStack(spacing: 10) {
+ Button {
+ mailService.prepareReplyDraft(thread: thread, connectedEmail: appState.settings.googleConnectedEmail, replyAll: false)
+ } label: {
+ Label("Reply", systemImage: "arrowshape.turn.up.left")
+ }
+ .buttonStyle(.bordered)
+
+ Button {
+ mailService.prepareReplyDraft(thread: thread, connectedEmail: appState.settings.googleConnectedEmail, replyAll: true)
+ } label: {
+ Label("Reply all", systemImage: "arrowshape.turn.up.left.2")
+ }
+ .buttonStyle(.bordered)
+
+ Button {
+ mailService.prepareForwardDraft(thread: thread)
+ } label: {
+ Label("Forward", systemImage: "arrowshape.turn.up.right")
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding(.top, 8)
+ }
+
private func messageCard(_ message: MailMessage) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
@@ -569,7 +644,7 @@ struct MailPaneView: View {
if let htmlBody = message.htmlBody,
!htmlBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
MailHTMLView(html: htmlBody)
- .frame(minHeight: 220)
+ .frame(minHeight: 700, maxHeight: .infinity)
.clipShape(.rect(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
@@ -589,14 +664,16 @@ struct MailPaneView: View {
private func composeView(title: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
- HStack {
- Text(title)
- .font(.system(size: 15, weight: .semibold))
- Spacer()
- Button("Discard") {
- mailService.dismissComposer()
+ if !title.isEmpty {
+ HStack {
+ Text(title)
+ .font(.system(size: 15, weight: .semibold))
+ Spacer()
+ Button("Discard") {
+ mailService.dismissComposer()
+ }
+ .buttonStyle(.borderless)
}
- .buttonStyle(.borderless)
}
composeField("To", text: $mailService.composer.to)
@@ -667,6 +744,10 @@ struct MailPaneView: View {
private func openThread(_ thread: MailThreadSummary) {
withMailToken { token in
await mailService.loadThread(id: thread.id, mailbox: thread.mailbox, token: token)
+ // Mark as read when opened
+ if thread.isUnread {
+ await mailService.apply(action: .setUnread(false), to: thread.id, token: token)
+ }
}
}
diff --git a/Sources/Bugbook/Views/Meetings/MeetingsView.swift b/Sources/Bugbook/Views/Meetings/MeetingsView.swift
index b12232f1..8db26e45 100644
--- a/Sources/Bugbook/Views/Meetings/MeetingsView.swift
+++ b/Sources/Bugbook/Views/Meetings/MeetingsView.swift
@@ -18,6 +18,7 @@ struct MeetingsView: View {
@State private var isSaving = false
@State private var showTranscript = false
@State private var notesText = ""
+ @State private var showRecordSetup = false
var body: some View {
VStack(spacing: 0) {
@@ -28,7 +29,10 @@ struct MeetingsView: View {
} else if isSaving {
savingView
} else {
- recorderPrompt
+ if showRecordSetup {
+ recordSetupBar
+ Divider()
+ }
recentRecordings
}
}
@@ -60,54 +64,67 @@ struct MeetingsView: View {
Text("Recording")
.font(.system(size: Typography.caption, weight: .medium))
.foregroundStyle(StatusColor.error)
+ } else if !isSaving {
+ Button(action: {
+ withAnimation(.easeInOut(duration: 0.15)) {
+ showRecordSetup.toggle()
+ }
+ }) {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(showRecordSetup ? StatusColor.error : Color.primary.opacity(0.5))
+ .frame(width: 7, height: 7)
+ Text("Record")
+ .font(.system(size: Typography.caption, weight: .medium))
+ }
+ .foregroundStyle(.primary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color.primary.opacity(showRecordSetup ? Opacity.light : Opacity.subtle))
+ .clipShape(.rect(cornerRadius: Radius.xs))
+ }
+ .buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
- // MARK: - Recorder Prompt (idle state)
-
- private var recorderPrompt: some View {
- VStack(spacing: 16) {
- Spacer()
+ // MARK: - Record Setup Bar (inline, shown when record is toggled)
- // Title field
+ private var recordSetupBar: some View {
+ HStack(spacing: 8) {
TextField("Meeting title (optional)", text: $meetingTitle)
.textFieldStyle(.plain)
- .font(.system(size: Typography.body))
+ .font(.system(size: Typography.bodySmall))
.foregroundStyle(.primary)
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .background(Color.primary.opacity(Opacity.subtle))
- .clipShape(.rect(cornerRadius: Radius.sm))
- .padding(.horizontal, 24)
- // Big record button
- Button(action: startRecording) {
- HStack(spacing: 10) {
+ Button(action: {
+ showRecordSetup = false
+ startRecording()
+ }) {
+ HStack(spacing: 5) {
Circle()
.fill(StatusColor.error)
- .frame(width: 14, height: 14)
- Text("Start recording")
- .font(.system(size: Typography.body, weight: .medium))
+ .frame(width: 7, height: 7)
+ Text("Start")
+ .font(.system(size: Typography.caption, weight: .medium))
}
.foregroundStyle(.primary)
- .padding(.horizontal, 24)
- .padding(.vertical, 14)
- .frame(maxWidth: .infinity)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
.background(Color.primary.opacity(Opacity.light))
- .clipShape(.rect(cornerRadius: Radius.md))
+ .clipShape(.rect(cornerRadius: Radius.xs))
.overlay(
- RoundedRectangle(cornerRadius: Radius.md)
+ RoundedRectangle(cornerRadius: Radius.xs)
.strokeBorder(Color.primary.opacity(Opacity.medium), lineWidth: 1)
)
}
.buttonStyle(.plain)
- .padding(.horizontal, 24)
-
- Spacer()
}
+ .padding(.horizontal, 12)
+ .padding(.vertical, 7)
+ .background(Color.primary.opacity(Opacity.subtle))
}
// MARK: - Recording View (active state)
@@ -248,19 +265,31 @@ struct MeetingsView: View {
.frame(maxWidth: .infinity)
}
- // MARK: - Recent Recordings (compact list below prompt)
+ // MARK: - Recent Recordings (primary content)
@ViewBuilder
private var recentRecordings: some View {
let groups = viewModel.groupedMeetings
- if !groups.isEmpty {
- Divider()
-
+ if groups.isEmpty {
+ VStack(spacing: 6) {
+ Spacer()
+ Text("No meetings yet")
+ .font(.system(size: Typography.bodySmall))
+ .foregroundStyle(.quaternary)
+ Text("Press Record to start one")
+ .font(.system(size: Typography.caption))
+ .foregroundStyle(.quaternary)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ } else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
- sectionDivider("Recent")
- ForEach(groups.flatMap(\.meetings).prefix(8)) { meeting in
- meetingRow(meeting)
+ ForEach(groups, id: \.bucket) { group in
+ sectionDivider(group.bucket.rawValue)
+ ForEach(group.meetings) { meeting in
+ meetingRow(meeting)
+ }
}
}
.padding(.vertical, 4)
diff --git a/Sources/Bugbook/Views/Panes/PaneChromeBar.swift b/Sources/Bugbook/Views/Panes/PaneChromeBar.swift
index c6ee1d6c..69add08a 100644
--- a/Sources/Bugbook/Views/Panes/PaneChromeBar.swift
+++ b/Sources/Bugbook/Views/Panes/PaneChromeBar.swift
@@ -44,24 +44,18 @@ struct PaneChromeBar: View {
.onDrag {
NSItemProvider(object: leaf.id.uuidString as NSString)
}
- .background(isReplaceWarning ? amberWarning.opacity(0.12) : Color.fallbackEditorBg)
+ .background(isReplaceWarning ? amberWarning.opacity(0.12) : Container.cardBg)
.overlay(alignment: .top) {
if isReplaceWarning {
Rectangle()
.fill(amberWarning)
.frame(height: 2)
- } else if isFocused {
+ } else if isFocused && !isOnlyPane {
Rectangle()
.fill(steelBlue)
.frame(height: 2)
}
}
- .overlay(alignment: .bottom) {
- Rectangle()
- .fill(Color.primary.opacity(0.04))
- .frame(height: 0.5)
- .allowsHitTesting(false)
- }
.overlay(alignment: .leading) {
if isReplaceWarning {
EmptyView()
diff --git a/Sources/Bugbook/Views/Panes/PaneContentView.swift b/Sources/Bugbook/Views/Panes/PaneContentView.swift
index d66f9473..34a12c4c 100644
--- a/Sources/Bugbook/Views/Panes/PaneContentView.swift
+++ b/Sources/Bugbook/Views/Panes/PaneContentView.swift
@@ -1,3 +1,4 @@
+import AppKit
import SwiftUI
/// Renders a single pane leaf: chrome bar (30px) + content below.
@@ -21,30 +22,41 @@ struct PaneContentView: View {
// Find-in-page state (per-pane)
@State private var showFindBar = false
@State private var findQuery = ""
- @State private var findCurrentIndex = 0
- @State private var findRefocusTrigger = false
- @State private var findMatchCache: [(blockId: UUID, range: Range)] = []
+ @State private var findCurrentIndex: Int?
+ @State private var findMatchCache: [BlockFindSelection] = []
+ @FocusState private var findFieldFocused: Bool
private var isFocusedPane: Bool {
workspaceManager.activeWorkspace?.focusedPaneId == leaf.id
}
+ private var activeBlockDocument: BlockDocument? {
+ guard case .document = leaf.content else { return nil }
+ return blockDocumentLookup?(leaf.id)
+ }
+
private func recomputeFindMatches() {
guard !findQuery.isEmpty,
- case .document = leaf.content,
- let doc = blockDocumentLookup?(leaf.id) else {
+ let doc = activeBlockDocument else {
findMatchCache = []
return
}
- let needle = findQuery.lowercased()
- var results: [(blockId: UUID, range: Range)] = []
+ var results: [BlockFindSelection] = []
func searchBlocks(_ blocks: [Block]) {
for block in blocks {
- let haystack = block.text.lowercased()
- var searchStart = haystack.startIndex
- while let range = haystack.range(of: needle, range: searchStart..: View {
+ @Bindable var appState: AppState
+ var fileSystem: FileSystemService
+ let activeFilePath: String?
+ let onSelectEntry: (FileEntry) -> Void
+ let onRefreshTree: () -> Void
+ let onOpenSettings: () -> Void
+ let contextualLabel: String?
+ @ViewBuilder let contextualContent: () -> ContextualContent
-struct NavigationRailView: View {
- let indicatorProvider: (RailItemID) -> RailIndicatorState
- let onSelect: (RailItemID) -> Void
+ // All sidebar content shares one horizontal inset so everything aligns.
+ private var inset: CGFloat { ShellSidebarMetrics.sectionHorizontalPadding }
- @State private var hoveredItem: RailItemID?
+ @AppStorage("sidebar_favorites_expanded") private var favoritesExpanded = true
+ @State private var expandedFolders: Set = {
+ let stored = UserDefaults.standard.stringArray(forKey: "expandedFolders") ?? []
+ return Set(stored)
+ }()
var body: some View {
- VStack(spacing: 0) {
- VStack(spacing: ShellSidebarMetrics.railGroupSpacing) {
- railButton(.home)
+ VStack(alignment: .leading, spacing: 0) {
+ // Sidebar toggle — in the title bar band, aligned with tabs
+ HStack {
+ Spacer(minLength: 0)
+ fixedIconButton("sidebar.left", help: "Toggle Sidebar") {
+ NotificationCenter.default.post(name: .toggleSidebar, object: nil)
+ }
}
-
- Divider()
- .padding(.vertical, ShellZoomMetrics.size(10))
- .padding(.horizontal, ShellSidebarMetrics.railInset)
-
- VStack(spacing: ShellSidebarMetrics.railGroupSpacing) {
- railButton(.mail)
- railButton(.calendar)
- railButton(.browser)
- railButton(.terminal)
+ .padding(.top, ShellZoomMetrics.size(6))
+ .padding(.trailing, inset)
+
+ // ── Fixed Zone ──────────────────────────────────
+ // Icon row: Home, Search
+ HStack(spacing: ShellZoomMetrics.size(2)) {
+ fixedIconButton("house", help: "Home") {
+ NotificationCenter.default.post(name: .openGateway, object: nil)
+ }
+ fixedIconButton("magnifyingglass", help: "Search") {
+ NotificationCenter.default.post(name: .quickOpen, object: nil)
+ }
+ Spacer(minLength: 0)
}
+ .padding(.top, ShellZoomMetrics.size(2))
+ .padding(.horizontal, inset)
+ .padding(.bottom, ShellZoomMetrics.size(6))
- Spacer(minLength: ShellZoomMetrics.size(12))
+ // Favorites
+ if !appState.favorites.isEmpty {
+ VStack(alignment: .leading, spacing: ShellZoomMetrics.size(3)) {
+ ShellSidebarSectionHeaderView(title: "Favorites", isExpanded: $favoritesExpanded)
- railButton(.settings)
- .padding(.bottom, ShellZoomMetrics.size(14))
- }
- .padding(.top, ShellSidebarMetrics.windowChromeTopInset)
- .frame(width: ShellSidebarMetrics.railWidth)
- .frame(maxHeight: .infinity)
- .background(Color.fallbackSidebarBg)
- .overlay(alignment: .trailing) {
- Rectangle()
- .fill(Color.fallbackChromeBorder)
- .frame(width: 1)
- }
- }
+ if favoritesExpanded {
+ VStack(spacing: ShellZoomMetrics.size(2)) {
+ ForEach(appState.favorites) { entry in
+ FileTreeItemView(
+ entry: entry,
+ activeFilePath: activeFilePath,
+ fileSystem: fileSystem,
+ workspacePath: appState.workspacePath,
+ onSelectFile: onSelectEntry,
+ onRefreshTree: onRefreshTree,
+ expandedFolders: $expandedFolders
+ )
+ }
+ }
+ }
+ }
+ .padding(.horizontal, inset)
+ .padding(.bottom, ShellZoomMetrics.size(8))
+ }
- private func railButton(_ item: RailItemID) -> some View {
- let indicator = indicatorProvider(item)
- let isHovered = hoveredItem == item
+ // ── Contextual Zone ─────────────────────────────
+ if let label = contextualLabel {
+ Divider()
+ .padding(.horizontal, inset)
- return Button {
- onSelect(item)
- } label: {
- HStack(spacing: 0) {
- indicatorView(indicator)
+ Text(label.uppercased())
+ .font(ShellZoomMetrics.font(Typography.caption, weight: .medium))
+ .foregroundStyle(Color(nsColor: .tertiaryLabelColor))
+ .padding(.horizontal, inset)
+ .padding(.top, ShellZoomMetrics.size(8))
+ .padding(.bottom, ShellZoomMetrics.size(4))
- Spacer(minLength: 0)
+ contextualContent()
+ .transition(.opacity.animation(.easeInOut(duration: 0.15)))
+ }
- Image(systemName: item.icon)
- .font(ShellZoomMetrics.font(14, weight: .medium))
- .foregroundStyle(foregroundColor(for: indicator))
- .frame(width: ShellSidebarMetrics.railButtonSize, height: ShellSidebarMetrics.railButtonSize)
+ Spacer(minLength: 0)
- Spacer(minLength: 0)
+ // Footer — settings
+ Button(action: onOpenSettings) {
+ HStack(spacing: ShellZoomMetrics.size(8)) {
+ Image(systemName: "gearshape")
+ .font(ShellZoomMetrics.font(Typography.bodySmall))
+ Text("Settings")
+ .font(ShellZoomMetrics.font(Typography.body))
+ Spacer(minLength: 0)
+ }
+ .foregroundStyle(.secondary)
+ .padding(.vertical, ShellZoomMetrics.size(10))
}
- .frame(height: ShellSidebarMetrics.railButtonSize)
- .background(
- RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm))
- .fill(isHovered ? Color.primary.opacity(0.05) : Color.clear)
- )
- .padding(.horizontal, ShellSidebarMetrics.railInset)
- }
- .buttonStyle(.plain)
- .help(item.label)
- .onHover { hovering in
- hoveredItem = hovering ? item : nil
- }
- }
-
- @ViewBuilder
- private func indicatorView(_ indicator: RailIndicatorState) -> some View {
- switch indicator {
- case .none:
- Color.clear.frame(width: 2)
- case .open:
- Capsule()
- .fill(Color.secondary.opacity(0.45))
- .frame(width: 2, height: ShellZoomMetrics.size(18))
- case .focused:
- Capsule()
- .fill(Color.accentColor)
- .frame(width: 2, height: ShellZoomMetrics.size(18))
+ .buttonStyle(.plain)
+ .padding(.horizontal, inset)
+ .padding(.bottom, ShellZoomMetrics.size(8))
}
+ .frame(maxHeight: .infinity)
+ .background(Container.groutBg)
+ .clipped()
}
- private func foregroundColor(for indicator: RailIndicatorState) -> Color {
- switch indicator {
- case .none:
- return .secondary
- case .open:
- return .primary
- case .focused:
- return .accentColor
+ private func fixedIconButton(_ icon: String, help: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Image(systemName: icon)
+ .font(ShellZoomMetrics.font(13, weight: .medium))
+ .foregroundStyle(.secondary)
+ .frame(width: ShellZoomMetrics.size(28), height: ShellZoomMetrics.size(28))
+ .contentShape(Rectangle())
}
+ .buttonStyle(.plain)
+ .help(help)
}
}
-private struct ShellSidebarFrame: View {
- @ViewBuilder let content: Content
+// MARK: - Sidebar Resize Handle
+
+struct SidebarResizeHandle: View {
+ @Binding var width: CGFloat
+ @State private var dragStartWidth: CGFloat?
var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- content
- }
- .padding(.top, ShellSidebarMetrics.windowChromeTopInset)
- .frame(width: ShellSidebarMetrics.sidebarWidth)
- .frame(maxHeight: .infinity, alignment: .top)
- .background(Color.fallbackSidebarBg)
- .overlay(alignment: .trailing) {
- Rectangle()
- .fill(Color.fallbackChromeBorder)
- .frame(width: 1)
- }
+ Rectangle()
+ .fill(Color.clear)
+ .frame(width: 6)
+ .contentShape(Rectangle())
+ .onHover { hovering in
+ if hovering {
+ NSCursor.resizeLeftRight.push()
+ } else {
+ NSCursor.pop()
+ }
+ }
+ .gesture(
+ DragGesture(minimumDistance: 1)
+ .onChanged { value in
+ if dragStartWidth == nil { dragStartWidth = width }
+ let proposed = (dragStartWidth ?? width) + value.translation.width
+ width = max(ShellSidebarMetrics.minWidth, min(ShellSidebarMetrics.maxWidth, proposed))
+ }
+ .onEnded { _ in
+ dragStartWidth = nil
+ }
+ )
}
}
+// MARK: - Sidebar Section Header
+
private struct ShellSidebarSectionHeaderView: View {
let title: String
@Binding var isExpanded: Bool
@@ -210,6 +209,8 @@ private struct ShellSidebarSectionHeaderView: View {
}
}
+// MARK: - Sidebar Shortcut Row
+
private struct ShellSidebarShortcutRow: View {
let title: String
let systemImage: String
@@ -245,142 +246,107 @@ private struct ShellSidebarShortcutRow: View {
}
}
-private struct WorkspaceSidebarModuleView: View {
+// MARK: - Workspace Contextual Sidebar (Pages + Agents)
+
+struct WorkspaceContextualSidebarView: View {
@Bindable var appState: AppState
var fileSystem: FileSystemService
let activeFilePath: String?
- let onSelectEntry: (FileEntry) -> Void
+ let onSelectWorkspaceEntry: (FileEntry) -> Void
let onRefreshTree: () -> Void
- @AppStorage("sidebar_favorites_expanded") private var favoritesExpanded = true
- @AppStorage("sidebar_agents_expanded") private var agentsExpanded = true
@AppStorage("sidebar_workspace_expanded") private var workspaceExpanded = true
+ @AppStorage("sidebar_agents_expanded") private var agentsExpanded = true
@State private var expandedFolders: Set = {
let stored = UserDefaults.standard.stringArray(forKey: "expandedFolders") ?? []
return Set(stored)
}()
var body: some View {
- VStack(alignment: .leading, spacing: ShellSidebarMetrics.sectionSpacing) {
- if !appState.favorites.isEmpty {
- VStack(alignment: .leading, spacing: ShellZoomMetrics.size(4)) {
- ShellSidebarSectionHeaderView(title: "Favorites", isExpanded: $favoritesExpanded)
- if favoritesExpanded {
- VStack(spacing: ShellZoomMetrics.size(3)) {
- ForEach(appState.favorites) { entry in
- FileTreeItemView(
- entry: entry,
- activeFilePath: activeFilePath,
- fileSystem: fileSystem,
- workspacePath: appState.workspacePath,
- onSelectFile: onSelectEntry,
- onRefreshTree: onRefreshTree,
- expandedFolders: $expandedFolders
- )
- }
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: ShellSidebarMetrics.sectionSpacing) {
+ // Sidebar references (pinned workspace shortcuts like Today, Graph)
+ if !appState.sidebarReferences.isEmpty {
+ VStack(spacing: ShellZoomMetrics.size(1)) {
+ ForEach(appState.sidebarReferences) { entry in
+ FileTreeItemView(
+ entry: entry,
+ activeFilePath: activeFilePath,
+ fileSystem: fileSystem,
+ workspacePath: appState.workspacePath,
+ onSelectFile: onSelectWorkspaceEntry,
+ onRefreshTree: onRefreshTree,
+ isSidebarReference: true,
+ expandedFolders: $expandedFolders
+ )
}
}
}
- }
-
- VStack(alignment: .leading, spacing: ShellZoomMetrics.size(4)) {
- ShellSidebarSectionHeaderView(title: "Workspace", isExpanded: $workspaceExpanded)
- if workspaceExpanded {
+ // Full file tree
+ FileTreeView(
+ entries: appState.fileTree,
+ activeFilePath: activeFilePath,
+ fileSystem: fileSystem,
+ workspacePath: appState.workspacePath,
+ onSelectFile: onSelectWorkspaceEntry,
+ onRefreshTree: onRefreshTree,
+ expandedFolders: $expandedFolders
+ )
+
+ // Agents section
+ if !appState.agentSkills.isEmpty || !appState.mcpServers.isEmpty {
VStack(alignment: .leading, spacing: ShellZoomMetrics.size(4)) {
- ShellSidebarShortcutRow(title: "Today", systemImage: "calendar") {
- NotificationCenter.default.post(name: .openDailyNote, object: nil)
- }
+ ShellSidebarSectionHeaderView(title: "Agents", isExpanded: $agentsExpanded)
- ShellSidebarShortcutRow(title: "Graph", systemImage: "point.3.connected.trianglepath.dotted") {
- NotificationCenter.default.post(name: .openGraphView, object: nil)
- }
-
- if !appState.sidebarReferences.isEmpty {
- VStack(spacing: ShellZoomMetrics.size(1)) {
- ForEach(appState.sidebarReferences) { entry in
+ if agentsExpanded {
+ VStack(alignment: .leading, spacing: ShellZoomMetrics.size(4)) {
+ ForEach(appState.agentSkills) { entry in
FileTreeItemView(
entry: entry,
activeFilePath: activeFilePath,
fileSystem: fileSystem,
workspacePath: appState.workspacePath,
- onSelectFile: onSelectEntry,
+ onSelectFile: onSelectWorkspaceEntry,
onRefreshTree: onRefreshTree,
- isSidebarReference: true,
expandedFolders: $expandedFolders
)
}
- }
- .padding(.top, ShellZoomMetrics.size(2))
- }
- FileTreeView(
- entries: fileTreeWithoutFavorites,
- activeFilePath: activeFilePath,
- fileSystem: fileSystem,
- workspacePath: appState.workspacePath,
- onSelectFile: onSelectEntry,
- onRefreshTree: onRefreshTree,
- expandedFolders: $expandedFolders
- )
- }
- }
- }
-
- if !appState.agentSkills.isEmpty || !appState.mcpServers.isEmpty {
- VStack(alignment: .leading, spacing: ShellZoomMetrics.size(4)) {
- ShellSidebarSectionHeaderView(title: "Agents", isExpanded: $agentsExpanded)
-
- if agentsExpanded {
- VStack(alignment: .leading, spacing: ShellZoomMetrics.size(4)) {
- ForEach(appState.agentSkills) { entry in
- FileTreeItemView(
- entry: entry,
- activeFilePath: activeFilePath,
- fileSystem: fileSystem,
- workspacePath: appState.workspacePath,
- onSelectFile: onSelectEntry,
- onRefreshTree: onRefreshTree,
- expandedFolders: $expandedFolders
- )
- }
-
- ForEach(appState.mcpServers) { server in
- HStack(spacing: ShellSidebarMetrics.rowSpacing) {
- Image(systemName: "powerplug")
- .font(ShellZoomMetrics.font(Typography.bodySmall))
- .foregroundStyle(.secondary)
- .frame(width: ShellZoomMetrics.size(14))
- VStack(alignment: .leading, spacing: 0) {
- Text(server.name)
- .font(ShellZoomMetrics.font(Typography.body))
- .lineLimit(1)
- Text(server.command)
- .font(ShellZoomMetrics.font(Typography.caption))
+ ForEach(appState.mcpServers) { server in
+ HStack(spacing: ShellSidebarMetrics.rowSpacing) {
+ Image(systemName: "powerplug")
+ .font(ShellZoomMetrics.font(Typography.bodySmall))
.foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
+ .frame(width: ShellZoomMetrics.size(14))
+ VStack(alignment: .leading, spacing: 0) {
+ Text(server.name)
+ .font(ShellZoomMetrics.font(Typography.body))
+ .lineLimit(1)
+ Text(server.command)
+ .font(ShellZoomMetrics.font(Typography.caption))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ Spacer(minLength: 0)
}
- Spacer(minLength: 0)
+ .padding(.horizontal, ShellSidebarMetrics.rowHorizontalPadding)
+ .padding(.vertical, ShellSidebarMetrics.rowVerticalPadding)
}
- .padding(.horizontal, ShellSidebarMetrics.rowHorizontalPadding)
- .padding(.vertical, ShellSidebarMetrics.rowVerticalPadding)
}
}
}
}
}
+ .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
+ .padding(.bottom, ShellZoomMetrics.size(14))
}
- .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
- }
-
- private var fileTreeWithoutFavorites: [FileEntry] {
- let favoritePaths = Set(appState.favorites.map(\.path))
- guard !favoritePaths.isEmpty else { return appState.fileTree }
- return appState.fileTree.filter { !favoritePaths.contains($0.path) }
}
}
+// MARK: - Settings Sidebar
+
struct SettingsSidebarView: View {
@Bindable var appState: AppState
@@ -397,105 +363,119 @@ struct SettingsSidebarView: View {
]
var body: some View {
- ShellSidebarFrame {
- VStack(alignment: .leading, spacing: 0) {
- Text("Settings")
- .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
- .foregroundStyle(.secondary)
- .padding(.horizontal, ShellZoomMetrics.size(12))
- .padding(.top, ShellSidebarMetrics.titleTopPadding)
- .padding(.bottom, ShellSidebarMetrics.titleBottomPadding)
-
- VStack(spacing: ShellZoomMetrics.size(2)) {
- ForEach(tabs, id: \.id) { tab in
- Button {
- appState.selectedSettingsTab = tab.id
- } label: {
- HStack(spacing: ShellZoomMetrics.size(10)) {
- Image(systemName: tab.icon)
- .font(ShellZoomMetrics.font(12))
- .frame(width: ShellZoomMetrics.size(14))
- Text(tab.label)
- .font(ShellZoomMetrics.font(Typography.body, weight: appState.selectedSettingsTab == tab.id ? .medium : .regular))
- Spacer(minLength: 0)
- }
- .foregroundStyle(appState.selectedSettingsTab == tab.id ? Color.accentColor : .primary)
- .padding(.horizontal, ShellSidebarMetrics.rowHorizontalPadding)
- .padding(.vertical, ShellSidebarMetrics.rowVerticalPadding)
- .background(
- RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm))
- .fill(appState.selectedSettingsTab == tab.id ? Color.accentColor.opacity(0.08) : Color.clear)
- )
+ VStack(alignment: .leading, spacing: 0) {
+ Button {
+ appState.showSettings = false
+ } label: {
+ HStack(spacing: ShellZoomMetrics.size(6)) {
+ Image(systemName: "arrow.left")
+ .font(ShellZoomMetrics.font(Typography.bodySmall))
+ Text("Back to app")
+ .font(ShellZoomMetrics.font(Typography.bodySmall))
+ }
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, ShellZoomMetrics.size(12))
+ .padding(.top, ShellSidebarMetrics.windowChromeTopInset + ShellSidebarMetrics.titleTopPadding)
+ .padding(.bottom, ShellZoomMetrics.size(16))
+
+ Text("Settings")
+ .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, ShellZoomMetrics.size(12))
+ .padding(.bottom, ShellSidebarMetrics.titleBottomPadding)
+
+ VStack(spacing: ShellZoomMetrics.size(2)) {
+ ForEach(tabs, id: \.id) { tab in
+ Button {
+ appState.selectedSettingsTab = tab.id
+ } label: {
+ HStack(spacing: ShellZoomMetrics.size(10)) {
+ Image(systemName: tab.icon)
+ .font(ShellZoomMetrics.font(12))
+ .frame(width: ShellZoomMetrics.size(14))
+ Text(tab.label)
+ .font(ShellZoomMetrics.font(Typography.body, weight: appState.selectedSettingsTab == tab.id ? .medium : .regular))
+ Spacer(minLength: 0)
}
- .buttonStyle(.plain)
+ .foregroundStyle(appState.selectedSettingsTab == tab.id ? Color.accentColor : .primary)
+ .padding(.horizontal, ShellSidebarMetrics.rowHorizontalPadding)
+ .padding(.vertical, ShellSidebarMetrics.rowVerticalPadding)
+ .background(
+ RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm))
+ .fill(appState.selectedSettingsTab == tab.id ? Color.accentColor.opacity(0.08) : Color.clear)
+ )
}
+ .buttonStyle(.plain)
}
- .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
-
- Spacer(minLength: 0)
}
+ .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
+
+ Spacer(minLength: 0)
}
+ .frame(maxHeight: .infinity, alignment: .top)
+ .background(Container.groutBg)
}
}
+// MARK: - Mail Contextual Sidebar
+
struct MailContextualSidebarView: View {
@Bindable var appState: AppState
var mailService: MailService
let onRefresh: () -> Void
var body: some View {
- ShellSidebarFrame {
- ScrollView(showsIndicators: false) {
- VStack(alignment: .leading, spacing: ShellSidebarMetrics.sectionSpacing) {
- VStack(alignment: .leading, spacing: 0) {
- HStack {
- Text("Mail")
- .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: ShellSidebarMetrics.sectionSpacing) {
+ VStack(alignment: .leading, spacing: 0) {
+ if !appState.settings.googleConnectedEmail.isEmpty {
+ HStack(spacing: ShellZoomMetrics.size(8)) {
+ Circle()
+ .fill(Color.accentColor)
+ .frame(width: ShellZoomMetrics.size(8), height: ShellZoomMetrics.size(8))
+ Text(appState.settings.googleConnectedEmail)
+ .font(ShellZoomMetrics.font(Typography.caption))
.foregroundStyle(.secondary)
- Spacer(minLength: 0)
- Button(action: onRefresh) {
- Image(systemName: "arrow.clockwise")
- .font(ShellZoomMetrics.font(11, weight: .medium))
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
+ .lineLimit(1)
}
.padding(.horizontal, ShellZoomMetrics.size(12))
- .padding(.top, ShellSidebarMetrics.titleTopPadding)
- .padding(.bottom, ShellZoomMetrics.size(8))
+ .padding(.bottom, ShellZoomMetrics.size(10))
+ }
- if !appState.settings.googleConnectedEmail.isEmpty {
- HStack(spacing: ShellZoomMetrics.size(8)) {
- Circle()
+ Button(action: { mailService.presentNewComposer() }) {
+ Label("Compose", systemImage: "square.and.pencil")
+ .font(ShellZoomMetrics.font(Typography.body, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, ShellZoomMetrics.size(10))
+ .background(
+ RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.md))
.fill(Color.accentColor)
- .frame(width: ShellZoomMetrics.size(8), height: ShellZoomMetrics.size(8))
- Text(appState.settings.googleConnectedEmail)
- .font(ShellZoomMetrics.font(Typography.caption))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- }
- .padding(.horizontal, ShellZoomMetrics.size(12))
- .padding(.bottom, ShellZoomMetrics.size(10))
- }
-
- VStack(spacing: ShellZoomMetrics.size(4)) {
- ForEach(MailMailbox.allCases) { mailbox in
- ShellSidebarShortcutRow(
- title: mailbox.displayName,
- systemImage: mailbox.systemImage,
- trailingText: badgeCount(for: mailbox),
- isSelected: mailService.selectedMailbox == mailbox
- ) {
- mailService.selectMailbox(mailbox)
- onRefresh()
- }
+ )
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
+ .padding(.bottom, ShellZoomMetrics.size(8))
+
+ VStack(spacing: ShellZoomMetrics.size(4)) {
+ ForEach(MailMailbox.allCases) { mailbox in
+ ShellSidebarShortcutRow(
+ title: mailbox.displayName,
+ systemImage: mailbox.systemImage,
+ trailingText: badgeCount(for: mailbox),
+ isSelected: mailService.selectedMailbox == mailbox
+ ) {
+ mailService.selectMailbox(mailbox)
+ onRefresh()
}
}
- .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
}
+ .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
}
- .padding(.bottom, ShellZoomMetrics.size(14))
}
+ .padding(.bottom, ShellZoomMetrics.size(14))
}
}
@@ -505,6 +485,8 @@ struct MailContextualSidebarView: View {
}
}
+// MARK: - Calendar Contextual Sidebar
+
struct CalendarContextualSidebarView: View {
@Bindable var calendarVM: CalendarViewModel
var calendarService: CalendarService
@@ -513,133 +495,122 @@ struct CalendarContextualSidebarView: View {
var body: some View {
let sources = calendarService.sources.map { $0 }
- ShellSidebarFrame {
- ScrollView(showsIndicators: false) {
- VStack(alignment: .leading, spacing: ShellSidebarMetrics.sectionSpacing) {
- VStack(alignment: .leading, spacing: 0) {
- Text("Calendar")
- .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
- .foregroundStyle(.secondary)
- .padding(.horizontal, ShellZoomMetrics.size(12))
- .padding(.top, ShellSidebarMetrics.titleTopPadding)
- .padding(.bottom, ShellSidebarMetrics.titleBottomPadding)
-
- MiniCalendarView(selectedDate: $calendarVM.selectedDate)
- .padding(.horizontal, ShellZoomMetrics.size(10))
- .padding(.bottom, ShellZoomMetrics.size(12))
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: ShellSidebarMetrics.sectionSpacing) {
+ VStack(alignment: .leading, spacing: 0) {
+ MiniCalendarView(selectedDate: $calendarVM.selectedDate)
+ .padding(.horizontal, ShellZoomMetrics.size(10))
+ .padding(.bottom, ShellZoomMetrics.size(12))
- HStack(spacing: ShellZoomMetrics.size(8)) {
- ForEach(CalendarViewMode.allCases, id: \.self) { mode in
- Button {
- calendarVM.viewMode = mode
- } label: {
- Text(mode.rawValue)
- .font(ShellZoomMetrics.font(11, weight: calendarVM.viewMode == mode ? .medium : .regular))
- .foregroundStyle(calendarVM.viewMode == mode ? Color.accentColor : .primary)
- .padding(.horizontal, ShellZoomMetrics.size(8))
- .padding(.vertical, ShellZoomMetrics.size(5))
- .background(
- RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm))
- .fill(calendarVM.viewMode == mode ? Color.accentColor.opacity(0.08) : Color.primary.opacity(0.04))
- )
- }
- .buttonStyle(.plain)
+ HStack(spacing: ShellZoomMetrics.size(8)) {
+ ForEach(CalendarViewMode.allCases, id: \.self) { mode in
+ Button {
+ calendarVM.viewMode = mode
+ } label: {
+ Text(mode.rawValue)
+ .font(ShellZoomMetrics.font(11, weight: calendarVM.viewMode == mode ? .medium : .regular))
+ .foregroundStyle(calendarVM.viewMode == mode ? Color.accentColor : .primary)
+ .padding(.horizontal, ShellZoomMetrics.size(8))
+ .padding(.vertical, ShellZoomMetrics.size(5))
+ .background(
+ RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm))
+ .fill(calendarVM.viewMode == mode ? Color.accentColor.opacity(0.08) : Color.primary.opacity(0.04))
+ )
}
+ .buttonStyle(.plain)
}
- .padding(.horizontal, ShellZoomMetrics.size(12))
- .padding(.bottom, ShellZoomMetrics.size(12))
+ }
+ .padding(.horizontal, ShellZoomMetrics.size(12))
+ .padding(.bottom, ShellZoomMetrics.size(12))
- Text("Calendars")
- .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
- .foregroundStyle(.secondary)
- .padding(.horizontal, ShellZoomMetrics.size(12))
- .padding(.bottom, ShellZoomMetrics.size(8))
+ Text("Calendars")
+ .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, ShellZoomMetrics.size(12))
+ .padding(.bottom, ShellZoomMetrics.size(8))
- CalendarSourceListView(
- sources: sources,
- workspacePath: workspacePath,
- calendarService: calendarService
- )
- .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
- }
+ CalendarSourceListView(
+ sources: sources,
+ workspacePath: workspacePath,
+ calendarService: calendarService
+ )
+ .padding(.horizontal, ShellSidebarMetrics.sectionHorizontalPadding)
}
- .padding(.bottom, ShellZoomMetrics.size(14))
}
+ .padding(.bottom, ShellZoomMetrics.size(14))
}
}
}
-struct WorkspaceContextualSidebarView: View {
- @Bindable var appState: AppState
- var fileSystem: FileSystemService
- let activeFilePath: String?
- var title = "Workspace"
- let onSelectWorkspaceEntry: (FileEntry) -> Void
- let onRefreshTree: () -> Void
+// MARK: - Calendar Source Views
+
+private struct CalendarSourceRow: View {
+ let source: CalendarSource
+ let workspacePath: String?
+ let calendarService: CalendarService
+ @State private var isHovering = false
var body: some View {
- ShellSidebarFrame {
- ScrollView(showsIndicators: false) {
- VStack(alignment: .leading, spacing: 0) {
- Text(title)
- .font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
- .foregroundStyle(.secondary)
- .padding(.horizontal, ShellZoomMetrics.size(12))
- .padding(.top, ShellSidebarMetrics.titleTopPadding)
- .padding(.bottom, ShellSidebarMetrics.titleBottomPadding)
-
- WorkspaceSidebarModuleView(
- appState: appState,
- fileSystem: fileSystem,
- activeFilePath: activeFilePath,
- onSelectEntry: onSelectWorkspaceEntry,
- onRefreshTree: onRefreshTree
- )
+ Button {
+ guard let workspacePath else { return }
+ calendarService.toggleSourceVisibility(id: source.id, workspace: workspacePath)
+ } label: {
+ HStack(spacing: ShellZoomMetrics.size(8)) {
+ Circle()
+ .fill(TagColor.color(for: source.color))
+ .frame(width: ShellZoomMetrics.size(8), height: ShellZoomMetrics.size(8))
+ Text(source.name)
+ .font(ShellZoomMetrics.font(Typography.bodySmall))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ if isHovering || !source.isVisible {
+ Image(systemName: source.isVisible ? "eye" : "eye.slash")
+ .font(ShellZoomMetrics.font(Typography.caption))
+ .foregroundStyle(.tertiary)
}
- .padding(.bottom, ShellZoomMetrics.size(14))
+ }
+ .padding(.horizontal, ShellSidebarMetrics.rowHorizontalPadding)
+ .padding(.vertical, ShellZoomMetrics.size(3))
+ }
+ .buttonStyle(.plain)
+ .onHover { isHovering = $0 }
+ .contextMenu {
+ ForEach(CalendarSourceListView.tagColorNames, id: \.self) { colorName in
+ Button {
+ guard let workspacePath else { return }
+ calendarService.updateSourceColor(id: source.id, color: colorName, workspace: workspacePath)
+ } label: {
+ Label {
+ Text(colorName.capitalized)
+ } icon: {
+ Image(systemName: source.color == colorName ? "checkmark.circle.fill" : "circle.fill")
+ }
+ }
+ .tint(TagColor.color(for: colorName))
}
}
}
}
private struct CalendarSourceListView: View {
+ static let tagColorNames = ["blue", "green", "red", "yellow", "purple", "pink", "orange", "teal", "gray"]
+
let sources: [CalendarSource]
let workspacePath: String?
let calendarService: CalendarService
var body: some View {
- VStack(alignment: .leading, spacing: ShellZoomMetrics.size(6)) {
+ VStack(alignment: .leading, spacing: ShellZoomMetrics.size(1)) {
ForEach(sources, id: \CalendarSource.id) { (source: CalendarSource) in
- Button {
- guard let workspacePath else { return }
- calendarService.toggleSourceVisibility(id: source.id, workspace: workspacePath)
- } label: {
- HStack(spacing: ShellZoomMetrics.size(8)) {
- Circle()
- .fill(TagColor.color(for: source.color))
- .frame(width: ShellZoomMetrics.size(8), height: ShellZoomMetrics.size(8))
- Text(source.name)
- .font(ShellZoomMetrics.font(Typography.bodySmall))
- .foregroundStyle(.primary)
- .lineLimit(1)
- Spacer(minLength: 0)
- Image(systemName: source.isVisible ? "checkmark.circle.fill" : "circle")
- .font(ShellZoomMetrics.font(Typography.bodySmall))
- .foregroundStyle(source.isVisible ? Color.accentColor : .secondary)
- }
- .padding(.horizontal, ShellSidebarMetrics.rowHorizontalPadding)
- .padding(.vertical, ShellSidebarMetrics.rowVerticalPadding)
- .background(
- RoundedRectangle(cornerRadius: ShellZoomMetrics.size(Radius.sm))
- .fill(Color.primary.opacity(0.03))
- )
- }
- .buttonStyle(.plain)
+ CalendarSourceRow(source: source, workspacePath: workspacePath, calendarService: calendarService)
}
}
}
}
+// MARK: - Mini Calendar
+
private struct MiniCalendarView: View {
@Binding var selectedDate: Date
private let calendar = Calendar.current
@@ -669,8 +640,8 @@ private struct MiniCalendarView: View {
.font(ShellZoomMetrics.font(Typography.caption, weight: .semibold))
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: ShellZoomMetrics.size(4)), count: 7), spacing: ShellZoomMetrics.size(4)) {
- ForEach(calendar.shortWeekdaySymbols, id: \.self) { symbol in
- Text(symbol.uppercased())
+ ForEach(calendar.shortWeekdaySymbols.map { String($0.prefix(2)).uppercased() }, id: \.self) { symbol in
+ Text(symbol)
.font(ShellZoomMetrics.font(9, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
diff --git a/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift b/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift
index de46a99b..9e5b1f86 100644
--- a/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift
+++ b/Sources/Bugbook/Views/Sidebar/FileTreeItemView.swift
@@ -188,22 +188,10 @@ struct FileTreeItemView: View {
@ViewBuilder
private var defaultIcon: some View {
- if entry.isDatabase {
- Image(systemName: "tablecells")
- .font(ShellZoomMetrics.font(Typography.bodySmall))
- .foregroundStyle(.secondary)
- .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16))
- } else if entry.isDirectory {
- Image(systemName: "folder")
- .font(ShellZoomMetrics.font(Typography.bodySmall))
- .foregroundStyle(.secondary)
- .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16))
- } else {
- Image(systemName: "doc.text")
- .font(ShellZoomMetrics.font(Typography.bodySmall))
- .foregroundStyle(.secondary)
- .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16))
- }
+ Image(systemName: entry.isDatabase ? "tablecells" : "doc.text")
+ .font(ShellZoomMetrics.font(Typography.bodySmall))
+ .foregroundStyle(.secondary)
+ .frame(width: ShellZoomMetrics.size(16), height: ShellZoomMetrics.size(16))
}
private var displayName: String {
@@ -225,12 +213,7 @@ struct FileTreeItemView: View {
}
private func handleTap() {
- if entry.isDirectory && !entry.isDatabase && entry.kind == .page {
- // Plain directory (not a .md page with children) — toggle expand
- toggleExpanded()
- } else {
- onSelectFile(entry)
- }
+ onSelectFile(entry)
}
// MARK: - Expanded State Persistence
diff --git a/Sources/BugbookCore/Engine/QueryEngine.swift b/Sources/BugbookCore/Engine/QueryEngine.swift
index 8da623eb..eda7f599 100644
--- a/Sources/BugbookCore/Engine/QueryEngine.swift
+++ b/Sources/BugbookCore/Engine/QueryEngine.swift
@@ -5,19 +5,18 @@ 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 in a single pass (ANDed)
- let filtered: [DatabaseRow]
+ var sorted: [DatabaseRow]
if query.filters.isEmpty {
- filtered = rows
+ sorted = rows
} else {
let filters = query.filters
- filtered = rows.filter { row in
+ sorted = 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 {
@@ -34,16 +33,15 @@ public struct QueryEngine {
}
}
- // 3. Total count before pagination
+ // 3. Paginate using slice indices to avoid extra Array allocations
let totalCount = sorted.count
-
- // 4. Apply offset/limit
let offset = query.offset ?? 0
- if offset > 0 {
- sorted = Array(sorted.dropFirst(offset))
- }
+ let startIdx = min(offset, totalCount)
+ let endIdx: Int
if let limit = query.limit {
- sorted = Array(sorted.prefix(limit))
+ endIdx = min(startIdx + limit, totalCount)
+ } else {
+ endIdx = totalCount
}
let hasMore: Bool
@@ -53,7 +51,8 @@ public struct QueryEngine {
hasMore = false
}
- return QueryResult(rows: sorted, totalCount: totalCount, hasMore: hasMore)
+ let page = startIdx < endIdx ? Array(sorted[startIdx..0 if a>b
private static func compareValues(_ a: PropertyValue?, _ b: PropertyValue?) -> Int {
- let aStr = a?.stringValue ?? ""
- let bStr = b?.stringValue ?? ""
-
- // Both empty
- if aStr.isEmpty && bStr.isEmpty { return 0 }
- // Empties sort last
- if aStr.isEmpty { return 1 }
- if bStr.isEmpty { return -1 }
-
- // Try numeric comparison
+ // Fast path: check type-specific comparisons before computing stringValue
if case .number(let an) = a, case .number(let bn) = b {
if an < bn { return -1 }
if an > bn { return 1 }
@@ -150,7 +140,15 @@ public struct QueryEngine {
return aKey.compare(bKey).rawValue
}
- // String comparison (works for dates in YYYY-MM-DD format too)
+ let aStr = a?.stringValue ?? ""
+ let bStr = b?.stringValue ?? ""
+
+ // Both empty
+ if aStr.isEmpty && bStr.isEmpty { return 0 }
+ // Empties sort last
+ if aStr.isEmpty { return 1 }
+ if bStr.isEmpty { return -1 }
+
return aStr.compare(bStr).rawValue
}
}
diff --git a/Sources/BugbookCore/Model/DatabaseDateValue.swift b/Sources/BugbookCore/Model/DatabaseDateValue.swift
index 95bbefad..f7dd37f3 100644
--- a/Sources/BugbookCore/Model/DatabaseDateValue.swift
+++ b/Sources/BugbookCore/Model/DatabaseDateValue.swift
@@ -73,19 +73,71 @@ public struct DatabaseDateValue: Equatable, Codable, Sendable {
try container.encode(dateFormat, forKey: .dateFormat)
}
+ private static nonisolated(unsafe) let sharedJSONDecoder = JSONDecoder()
+
public static func decode(from raw: String) -> DatabaseDateValue? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("{"),
let data = trimmed.data(using: .utf8),
- let decoded = try? JSONDecoder().decode(DatabaseDateValue.self, from: data) {
+ let decoded = try? sharedJSONDecoder.decode(DatabaseDateValue.self, from: data) {
return decoded.normalized()
}
+ // Fast path: if the string is already in canonical format (yyyy-MM-dd or yyyy-MM-ddTHH:mm),
+ // skip the full init → normalizedString → date → canonicalString round-trip.
+ if isCanonicalDate(trimmed) {
+ let hasTime = trimmed.count > 10 && (trimmed[trimmed.index(trimmed.startIndex, offsetBy: 10)] == "T")
+ return DatabaseDateValue(
+ canonicalStart: trimmed,
+ end: nil,
+ includeTime: hasTime,
+ dateFormat: .long
+ )
+ }
+
return DatabaseDateValue(start: trimmed)
}
+ /// Private fast-path init that skips normalization for already-canonical strings.
+ private init(canonicalStart: String, end: String?, includeTime: Bool, dateFormat: DatabaseDateFormat) {
+ self.start = canonicalStart
+ self.end = end
+ self.includeTime = includeTime
+ self.dateFormat = dateFormat
+ }
+
+ /// Check if a string matches yyyy-MM-dd or yyyy-MM-ddTHH:mm format exactly.
+ /// Uses direct UTF8View indexing to avoid heap-allocating a byte array.
+ @inline(__always)
+ private static func isCanonicalDate(_ s: String) -> Bool {
+ let u = s.utf8
+ let count = u.count
+ guard count == 10 || count == 16 else { return false }
+ // All characters must be ASCII (single-byte UTF-8)
+ guard s.count == count else { return false }
+
+ @inline(__always) func byte(_ offset: Int) -> UInt8 {
+ u[u.index(u.startIndex, offsetBy: offset)]
+ }
+ @inline(__always) func isDigit(_ offset: Int) -> Bool {
+ let b = byte(offset); return b >= 0x30 && b <= 0x39
+ }
+
+ // yyyy-MM-dd
+ guard byte(4) == 0x2D, byte(7) == 0x2D else { return false }
+ guard isDigit(0), isDigit(1), isDigit(2), isDigit(3),
+ isDigit(5), isDigit(6), isDigit(8), isDigit(9) else { return false }
+
+ if count == 16 {
+ // yyyy-MM-ddTHH:mm
+ guard byte(10) == 0x54, byte(13) == 0x3A else { return false }
+ guard isDigit(11), isDigit(12), isDigit(14), isDigit(15) else { return false }
+ }
+ return true
+ }
+
public var rawValue: String {
let normalized = normalized()
if normalized.end == nil && normalized.dateFormat == .long {
@@ -102,7 +154,9 @@ public struct DatabaseDateValue: Equatable, Codable, Sendable {
}
public var sortKey: String {
- normalized().start
+ // Fast path: if start is already canonical, skip normalization
+ if Self.isCanonicalDate(start) { return start }
+ return normalized().start
}
public var startDayKey: String {
diff --git a/Sources/BugbookCore/Storage/IndexManager.swift b/Sources/BugbookCore/Storage/IndexManager.swift
index e3e643d5..47294b77 100644
--- a/Sources/BugbookCore/Storage/IndexManager.swift
+++ b/Sources/BugbookCore/Storage/IndexManager.swift
@@ -50,42 +50,50 @@ public class IndexManager {
// MARK: - Rebuild
public func rebuild(dbPath: String, schema: DatabaseSchema, rows: [DatabaseRow]) -> [String: Any] {
- var rowsMap: [String: Any] = [:]
+ // Pre-filter indexed properties once
+ let indexedTypes: Set = [.select, .multiSelect, .relation, .checkbox]
+ let indexedProps = schema.properties.filter { indexedTypes.contains($0.type) }
+
+ // Single pass: build row entries and reverse indexes simultaneously
+ var rowsMap: [String: Any] = Dictionary(minimumCapacity: rows.count)
+ var indexes: [String: [String: [String]]] = Dictionary(minimumCapacity: indexedProps.count)
+ for prop in indexedProps {
+ indexes[prop.id] = [:]
+ }
+
+ // Build local index dicts to avoid repeated hash lookups on `indexes`
+ var localIndexes: [String: [String: [String]]] = Dictionary(minimumCapacity: indexedProps.count)
+ for prop in indexedProps { localIndexes[prop.id] = [:] }
+
for row in rows {
rowsMap[row.id] = buildRowEntry(row: row, schema: schema, dbPath: dbPath)
- }
- // Build reverse indexes
- let indexedTypes: Set = [.select, .multiSelect, .relation, .checkbox]
- var indexes: [String: [String: [String]]] = [:]
- for prop in schema.properties where indexedTypes.contains(prop.type) {
- var propIndex: [String: [String]] = [:]
- for row in rows {
+ // Build reverse indexes in the same pass
+ for prop in indexedProps {
guard let val = row.properties[prop.id] else { continue }
switch val {
case .select(let optId):
- propIndex[optId, default: []].append(row.id)
+ localIndexes[prop.id]![optId, default: []].append(row.id)
case .multiSelect(let optIds):
for optId in optIds {
- propIndex[optId, default: []].append(row.id)
+ localIndexes[prop.id]![optId, default: []].append(row.id)
}
case .relation(let rowId):
- propIndex[rowId, default: []].append(row.id)
+ localIndexes[prop.id]![rowId, default: []].append(row.id)
case .relationMany(let rowIds):
for rid in rowIds {
- propIndex[rid, default: []].append(row.id)
+ localIndexes[prop.id]![rid, default: []].append(row.id)
}
case .checkbox(let b):
- propIndex[b ? "true" : "false", default: []].append(row.id)
+ localIndexes[prop.id]![b ? "true" : "false", default: []].append(row.id)
default:
break
}
}
- if !propIndex.isEmpty {
- indexes[prop.id] = propIndex
- }
}
+ indexes = localIndexes.filter { !$0.value.isEmpty }
+
return [
"version": 1,
"updated_at": Self.isoFormatter.string(from: Date()),
diff --git a/Sources/BugbookCore/Storage/RowSerializer.swift b/Sources/BugbookCore/Storage/RowSerializer.swift
index 6a4eba56..73932fac 100644
--- a/Sources/BugbookCore/Storage/RowSerializer.swift
+++ b/Sources/BugbookCore/Storage/RowSerializer.swift
@@ -110,8 +110,17 @@ public struct RowSerializer {
if line.hasPrefix(" ") {
let propLine = line[line.index(line.startIndex, offsetBy: 2)...]
if let colonIdx = propLine.firstIndex(of: ":") {
- let key = String(propLine[propLine.startIndex.. PropertyValue {
- var value = raw
+ var value: String
// Strip one pair of surrounding quotes
- if value.hasPrefix("\"") && value.hasSuffix("\"") && value.count >= 2 {
- value = String(value.dropFirst().dropLast())
+ if raw.count >= 2 && raw.first == "\"" && raw.last == "\"" {
+ value = String(raw.dropFirst().dropLast())
+ } else {
+ value = raw
+ }
+ // Single-pass unescape: handle \\" and \\" in one scan
+ if value.contains("\\") {
+ value = yamlUnescape(value)
}
- // Unescape backslash sequences
- value = value.replacingOccurrences(of: "\\\"", with: "\"")
- .replacingOccurrences(of: "\\\\", with: "\\")
if value.isEmpty { return .empty }
switch type {
@@ -166,14 +182,7 @@ public struct RowSerializer {
case .select:
return .select(value)
case .multiSelect:
- if value.hasPrefix("[") {
- let items = value.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
- .components(separatedBy: ",")
- .map { $0.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
- .filter { !$0.isEmpty }
- return .multiSelect(items)
- }
- return .multiSelect(value.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) })
+ return .multiSelect(parseArray(value))
case .date:
return .date(value)
case .checkbox:
@@ -183,26 +192,82 @@ public struct RowSerializer {
case .email:
return .email(value)
case .relation:
- if value.hasPrefix("[") {
- let items = value.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
- .components(separatedBy: ",")
- .map { $0.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
- .filter { !$0.isEmpty }
- return .relationMany(items)
+ if value.first == "[" {
+ return .relationMany(parseArray(value))
}
return .relation(value)
case .formula:
- // Formula values are computed at display time, never persisted.
+ // Computed at display time, never persisted
return .empty
case .lookup:
- // Lookup is computed at render time; stored value is treated as text.
+ // Computed at render time; stored value is treated as text
return .text(value)
case .rollup:
- // Rollup is computed at render time; stored value is treated as text.
+ // Computed at render time; stored value is treated as text
return .text(value)
}
}
+ /// Single-pass YAML unescape: \" → " and \\\\ → \\
+ @inline(__always)
+ private static func yamlUnescape(_ s: String) -> String {
+ var result = String()
+ result.reserveCapacity(s.count)
+ var iter = s.makeIterator()
+ while let c = iter.next() {
+ if c == "\\" {
+ if let next = iter.next() {
+ switch next {
+ case "\"": result.append("\"")
+ case "\\": result.append("\\")
+ default:
+ result.append("\\")
+ result.append(next)
+ }
+ } else {
+ result.append("\\")
+ }
+ } else {
+ result.append(c)
+ }
+ }
+ return result
+ }
+
+ /// Parse "[a, b, c]" or "a, b" into array of trimmed, non-empty strings.
+ /// Avoids intermediate array/string allocations from split+trim+filter chains.
+ @inline(__always)
+ private static func parseArray(_ value: String) -> [String] {
+ let s: Substring
+ if value.first == "[" && value.last == "]" {
+ s = value.dropFirst().dropLast()
+ } else {
+ s = value[...]
+ }
+ var items: [String] = []
+ var start = s.startIndex
+ while start < s.endIndex {
+ // Skip leading whitespace
+ while start < s.endIndex && (s[start] == " " || s[start] == "\t") { start = s.index(after: start) }
+ guard start < s.endIndex else { break }
+ // Find comma or end
+ let commaIdx = s[start...].firstIndex(of: ",") ?? s.endIndex
+ // Trim trailing whitespace and quotes
+ var end = commaIdx
+ while end > start && (s[s.index(before: end)] == " " || s[s.index(before: end)] == "\t") { end = s.index(before: end) }
+ // Strip surrounding quotes
+ var itemStart = start
+ var itemEnd = end
+ if itemStart < itemEnd && s[itemStart] == "\"" { itemStart = s.index(after: itemStart) }
+ if itemEnd > itemStart && s[s.index(before: itemEnd)] == "\"" { itemEnd = s.index(before: itemEnd) }
+ if itemStart < itemEnd {
+ items.append(String(s[itemStart.. String {
s.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
diff --git a/Sources/BugbookCore/Storage/RowStore.swift b/Sources/BugbookCore/Storage/RowStore.swift
index fcf1f7ad..e48d1113 100644
--- a/Sources/BugbookCore/Storage/RowStore.swift
+++ b/Sources/BugbookCore/Storage/RowStore.swift
@@ -65,9 +65,10 @@ public class RowStore {
let rowId = row.id
if let existing = bestByID[rowId] {
- let suffix = Self.extractIdSuffix(from: rowId)
- let existingIsCanonical = existing.filename.contains("(\(suffix))")
- let newIsCanonical = name.contains("(\(suffix))")
+ // Build the suffix pattern once per conflict, not per row
+ let suffixPattern = "(\(Self.extractIdSuffix(from: rowId)))"
+ let existingIsCanonical = existing.filename.contains(suffixPattern)
+ let newIsCanonical = name.contains(suffixPattern)
if newIsCanonical && !existingIsCanonical {
duplicateFiles.append(existing.filename)
@@ -75,7 +76,6 @@ public class RowStore {
} 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)
diff --git a/Sources/BugbookCore/Workspace/AttachmentPathResolver.swift b/Sources/BugbookCore/Workspace/AttachmentPathResolver.swift
new file mode 100644
index 00000000..894e98cf
--- /dev/null
+++ b/Sources/BugbookCore/Workspace/AttachmentPathResolver.swift
@@ -0,0 +1,64 @@
+import Foundation
+
+public func resolveWorkspaceAttachmentPath(
+ _ storedPath: String,
+ pagePath: String?,
+ workspacePath: String?,
+ fileManager: FileManager = .default
+) -> String? {
+ let trimmedPath = storedPath.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedPath.isEmpty else { return nil }
+
+ if trimmedPath.hasPrefix("file://"),
+ let url = URL(string: trimmedPath) {
+ return url.path
+ }
+
+ if let url = URL(string: trimmedPath),
+ let scheme = url.scheme,
+ !scheme.isEmpty {
+ return nil
+ }
+
+ let normalizedPath: String
+ if trimmedPath.contains("%"),
+ let decodedPath = trimmedPath.removingPercentEncoding {
+ normalizedPath = decodedPath
+ } else {
+ normalizedPath = trimmedPath
+ }
+
+ if normalizedPath.hasPrefix("/") {
+ return (normalizedPath as NSString).standardizingPath
+ }
+
+ var candidates: [String] = []
+
+ if normalizedPath.hasPrefix("Attachments/"),
+ let workspacePath,
+ !workspacePath.isEmpty {
+ candidates.append((workspacePath as NSString).appendingPathComponent(normalizedPath))
+ }
+
+ if let pagePath,
+ !pagePath.isEmpty {
+ let pageDirectory = (pagePath as NSString).deletingLastPathComponent
+ candidates.append((pageDirectory as NSString).appendingPathComponent(normalizedPath))
+ }
+
+ if let workspacePath,
+ !workspacePath.isEmpty {
+ candidates.append((workspacePath as NSString).appendingPathComponent(normalizedPath))
+ }
+
+ var seen: Set = []
+ let normalizedCandidates = candidates
+ .map { ($0 as NSString).standardizingPath }
+ .filter { seen.insert($0).inserted }
+
+ for candidate in normalizedCandidates where fileManager.fileExists(atPath: candidate) {
+ return candidate
+ }
+
+ return normalizedCandidates.first
+}
diff --git a/Sources/BugbookCore/Workspace/WorkspaceResolver.swift b/Sources/BugbookCore/Workspace/WorkspaceResolver.swift
index 23abb72b..49fb7c6d 100644
--- a/Sources/BugbookCore/Workspace/WorkspaceResolver.swift
+++ b/Sources/BugbookCore/Workspace/WorkspaceResolver.swift
@@ -39,30 +39,86 @@ public enum WorkspaceResolver {
/// Resolves the iCloud Bugbook workspace path. Returns `nil` if iCloud is
/// unavailable, the caller lacks the ubiquity container entitlement, or the user
/// is not signed into iCloud. May block on first use per process.
+ ///
+ /// When the default "Bugbook" folder is mostly empty but a sibling like
+ /// "Bugbook 2" or "Bugbook 3" has real content (iCloud conflict duplication),
+ /// returns the richest sibling instead.
public static func resolveICloudWorkspacePath(createIfMissing: Bool = true) -> String? {
let fm = FileManager.default
guard let containerURL = fm.url(forUbiquityContainerIdentifier: ubiquityContainerID) else {
return nil
}
- let path = containerURL
- .appendingPathComponent("Documents/\(defaultFolderName)")
- .path
- if createIfMissing, !fm.fileExists(atPath: path) {
- try? fm.createDirectory(atPath: path, withIntermediateDirectories: true)
+ let documentsURL = containerURL.appendingPathComponent("Documents")
+ let defaultPath = documentsURL.appendingPathComponent(defaultFolderName).path
+
+ // Scan all "Bugbook*" siblings and pick the one with the most .md files.
+ // Handles iCloud conflict duplication ("Bugbook 2", "Bugbook 3", etc.)
+ if let siblings = try? fm.contentsOfDirectory(atPath: documentsURL.path) {
+ let candidates = siblings
+ .filter { $0.hasPrefix(defaultFolderName) }
+ .map { documentsURL.appendingPathComponent($0).path }
+ .map { (path: $0, count: mdFileCount(at: $0, fm: fm)) }
+ .sorted { $0.count > $1.count }
+
+ if let best = candidates.first, best.count > 0 {
+ return best.path
+ }
+ }
+
+ // Fall back to default (create if needed).
+ if createIfMissing, !fm.fileExists(atPath: defaultPath) {
+ try? fm.createDirectory(atPath: defaultPath, withIntermediateDirectories: true)
+ }
+ return defaultPath
+ }
+
+ /// Count .md files (non-underscore-prefixed) recursively, up to a shallow depth.
+ /// Count user-authored .md files, excluding database rows and underscore-prefixed files.
+ private static func mdFileCount(at path: String, fm: FileManager, depth: Int = 0) -> Int {
+ guard depth < 3, let entries = try? fm.contentsOfDirectory(atPath: path) else { return 0 }
+ var count = 0
+ for name in entries where !name.hasPrefix(".") {
+ if name == "databases" || name == "Daily Notes" || name == "Templates" { continue }
+ let full = (path as NSString).appendingPathComponent(name)
+ var isDir: ObjCBool = false
+ guard fm.fileExists(atPath: full, isDirectory: &isDir) else { continue }
+ if isDir.boolValue {
+ count += mdFileCount(at: full, fm: fm, depth: depth + 1)
+ } else if name.hasSuffix(".md") && !name.hasPrefix("_") {
+ count += 1
+ }
}
- return path
+ return count
}
/// `~/Documents/Bugbook`. Always available. On the canonical setup this is a
/// symlink into the iCloud Bugbook container, so it resolves to the same
/// physical folder as `resolveICloudWorkspacePath()`.
+ ///
+ /// If the default path is a symlink into an iCloud container with richer
+ /// sibling workspaces, picks the richest one (same logic as iCloud resolver).
public static func localFallbackWorkspacePath(createIfMissing: Bool = true) -> String {
let fm = FileManager.default
let documents = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
- let path = documents.appendingPathComponent(defaultFolderName, isDirectory: true).path
- if createIfMissing, !fm.fileExists(atPath: path) {
- try? fm.createDirectory(atPath: path, withIntermediateDirectories: true)
+ let defaultPath = documents.appendingPathComponent(defaultFolderName, isDirectory: true).path
+
+ // Resolve symlink to check for sibling workspaces in the iCloud container
+ let resolved = (defaultPath as NSString).resolvingSymlinksInPath
+ let parentDir = (resolved as NSString).deletingLastPathComponent
+ if let siblings = try? fm.contentsOfDirectory(atPath: parentDir) {
+ let candidates = siblings
+ .filter { $0.hasPrefix(defaultFolderName) }
+ .map { (parentDir as NSString).appendingPathComponent($0) }
+ .map { (path: $0, count: mdFileCount(at: $0, fm: fm)) }
+ .sorted { $0.count > $1.count }
+ if let best = candidates.first, best.count > 0 {
+ return best.path
+ }
+ }
+
+ if createIfMissing, !fm.fileExists(atPath: defaultPath) {
+ try? fm.createDirectory(atPath: defaultPath, withIntermediateDirectories: true)
}
- return path
+ return defaultPath
}
}
diff --git a/Sources/BugbookMobile/Extensions/MobileDesignTokens.swift b/Sources/BugbookMobile/Extensions/MobileDesignTokens.swift
index ebe35857..293f5163 100644
--- a/Sources/BugbookMobile/Extensions/MobileDesignTokens.swift
+++ b/Sources/BugbookMobile/Extensions/MobileDesignTokens.swift
@@ -7,8 +7,8 @@ import UIKit
enum MobileRadius {
static let sm: CGFloat = 6
- static let md: CGFloat = 8
- static let lg: CGFloat = 10
+ static let md: CGFloat = 10
+ static let lg: CGFloat = 12
}
// MARK: - Theme Colors
@@ -17,75 +17,107 @@ extension Color {
#if os(iOS)
static var mobileBgPrimary: Color {
Color(UIColor { traits in
- traits.userInterfaceStyle == .dark ? UIColor(red: 0.098, green: 0.098, blue: 0.098, alpha: 1) : .white
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.098, green: 0.098, blue: 0.098, alpha: 1)
+ : UIColor(red: 0.992, green: 0.988, blue: 0.980, alpha: 1)
})
}
static var mobileBgSecondary: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.125, green: 0.125, blue: 0.125, alpha: 1)
- : UIColor(red: 0.973, green: 0.973, blue: 0.965, alpha: 1)
+ : UIColor(red: 0.976, green: 0.969, blue: 0.953, alpha: 1)
})
}
static var mobileBgTertiary: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.184, green: 0.184, blue: 0.184, alpha: 1)
- : UIColor(red: 0.933, green: 0.933, blue: 0.925, alpha: 1)
+ : UIColor(red: 0.961, green: 0.953, blue: 0.933, alpha: 1)
})
}
static var mobileTextPrimary: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.941, green: 0.937, blue: 0.925, alpha: 1)
- : UIColor(red: 0.122, green: 0.122, blue: 0.122, alpha: 1)
+ : UIColor(red: 0.102, green: 0.102, blue: 0.102, alpha: 1)
})
}
static var mobileTextSecondary: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.608, green: 0.608, blue: 0.608, alpha: 1)
- : UIColor(red: 0.42, green: 0.42, blue: 0.42, alpha: 1)
+ : UIColor(red: 0.533, green: 0.533, blue: 0.533, alpha: 1)
})
}
static var mobileTextMuted: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.216, green: 0.216, blue: 0.216, alpha: 1)
- : UIColor(red: 0.608, green: 0.608, blue: 0.608, alpha: 1)
+ : UIColor(red: 0.600, green: 0.600, blue: 0.600, alpha: 1)
})
}
static var mobileBorder: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.18, green: 0.18, blue: 0.18, alpha: 1)
- : UIColor(red: 0.91, green: 0.91, blue: 0.898, alpha: 1)
+ : UIColor(red: 0.898, green: 0.898, blue: 0.898, alpha: 1)
})
}
static var mobileDivider: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.18, green: 0.18, blue: 0.18, alpha: 1)
- : UIColor(red: 0.933, green: 0.933, blue: 0.925, alpha: 1)
+ : UIColor(red: 0.898, green: 0.898, blue: 0.898, alpha: 1)
})
}
static var mobileCardBg: Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.145, green: 0.145, blue: 0.145, alpha: 1)
- : UIColor(red: 0.973, green: 0.973, blue: 0.965, alpha: 1)
+ : UIColor(red: 0.961, green: 0.953, blue: 0.933, alpha: 1)
+ })
+ }
+ static var mobileWarmAccent: Color {
+ Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.525, green: 0.404, blue: 0.259, alpha: 1)
+ : UIColor(red: 0.749, green: 0.573, blue: 0.357, alpha: 1)
+ })
+ }
+ static var mobileUtilityIcon: Color {
+ Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.670, green: 0.670, blue: 0.670, alpha: 1)
+ : UIColor(red: 0.667, green: 0.667, blue: 0.667, alpha: 1)
+ })
+ }
+ static var mobileFloatingActionBg: Color {
+ Color(UIColor { traits in
+ traits.userInterfaceStyle == .dark
+ ? UIColor(red: 0.208, green: 0.208, blue: 0.208, alpha: 1)
+ : UIColor(red: 0.220, green: 0.220, blue: 0.220, alpha: 1)
+ })
+ }
+ static var mobileActionBlue: Color {
+ Color(UIColor { _ in
+ UIColor(red: 0.0, green: 0.478, blue: 1.0, alpha: 1)
})
}
#else
- static var mobileBgPrimary: Color { .white }
- static var mobileBgSecondary: Color { Color(red: 0.973, green: 0.973, blue: 0.965) }
- static var mobileBgTertiary: Color { Color(red: 0.933, green: 0.933, blue: 0.925) }
- static var mobileTextPrimary: Color { Color(red: 0.122, green: 0.122, blue: 0.122) }
- static var mobileTextSecondary: Color { Color(red: 0.42, green: 0.42, blue: 0.42) }
- static var mobileTextMuted: Color { Color(red: 0.608, green: 0.608, blue: 0.608) }
- static var mobileBorder: Color { Color(red: 0.91, green: 0.91, blue: 0.898) }
- static var mobileDivider: Color { Color(red: 0.933, green: 0.933, blue: 0.925) }
- static var mobileCardBg: Color { Color(red: 0.973, green: 0.973, blue: 0.965) }
+ static var mobileBgPrimary: Color { Color(red: 0.992, green: 0.988, blue: 0.980) }
+ static var mobileBgSecondary: Color { Color(red: 0.976, green: 0.969, blue: 0.953) }
+ static var mobileBgTertiary: Color { Color(red: 0.961, green: 0.953, blue: 0.933) }
+ static var mobileTextPrimary: Color { Color(red: 0.102, green: 0.102, blue: 0.102) }
+ static var mobileTextSecondary: Color { Color(red: 0.533, green: 0.533, blue: 0.533) }
+ static var mobileTextMuted: Color { Color(red: 0.600, green: 0.600, blue: 0.600) }
+ static var mobileBorder: Color { Color(red: 0.898, green: 0.898, blue: 0.898) }
+ static var mobileDivider: Color { Color(red: 0.898, green: 0.898, blue: 0.898) }
+ static var mobileCardBg: Color { Color(red: 0.961, green: 0.953, blue: 0.933) }
+ static var mobileWarmAccent: Color { Color(red: 0.749, green: 0.573, blue: 0.357) }
+ static var mobileUtilityIcon: Color { Color(red: 0.667, green: 0.667, blue: 0.667) }
+ static var mobileFloatingActionBg: Color { Color(red: 0.220, green: 0.220, blue: 0.220) }
+ static var mobileActionBlue: Color { Color(red: 0.0, green: 0.478, blue: 1.0) }
#endif
}
diff --git a/Sources/BugbookMobile/Services/MobileWorkspaceService.swift b/Sources/BugbookMobile/Services/MobileWorkspaceService.swift
index 7daa916e..f491f472 100644
--- a/Sources/BugbookMobile/Services/MobileWorkspaceService.swift
+++ b/Sources/BugbookMobile/Services/MobileWorkspaceService.swift
@@ -10,6 +10,16 @@ import BugbookCore
private let fileManager = FileManager.default
private let maxTreeDepth = 10
+ private static let dailyNoteFilenameFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter
+ }()
+ private static let dailyNoteTitleFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEEE, MMMM d"
+ return formatter
+ }()
init() {
let path = resolveWorkspacePath()
@@ -135,9 +145,7 @@ import BugbookCore
// MARK: - Daily Notes
func dailyNotePath() -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd"
- let filename = formatter.string(from: Date()) + ".md"
+ let filename = Self.dailyNoteFilenameFormatter.string(from: Date()) + ".md"
let folder = (workspacePath as NSString).appendingPathComponent("Daily Notes")
return (folder as NSString).appendingPathComponent(filename)
}
@@ -152,9 +160,7 @@ import BugbookCore
}
if !fileManager.fileExists(atPath: path) {
- let formatter = DateFormatter()
- formatter.dateFormat = "EEEE, MMMM d"
- let title = formatter.string(from: Date())
+ let title = Self.dailyNoteTitleFormatter.string(from: Date())
try? "# \(title)\n\n".write(toFile: path, atomically: true, encoding: .utf8)
}
diff --git a/Sources/BugbookMobile/Views/MobileMarkdownView.swift b/Sources/BugbookMobile/Views/MobileMarkdownView.swift
index f52667e4..e4e27f47 100644
--- a/Sources/BugbookMobile/Views/MobileMarkdownView.swift
+++ b/Sources/BugbookMobile/Views/MobileMarkdownView.swift
@@ -1,4 +1,9 @@
+import Foundation
import SwiftUI
+import BugbookCore
+#if canImport(UIKit)
+import UIKit
+#endif
// MARK: - Block Model
@@ -13,6 +18,7 @@ private enum MdBlockType {
case horizontalRule
case image(alt: String, url: String)
case wikiLink(name: String)
+ case databaseEmbed(path: String)
}
private struct MdBlock: Identifiable {
@@ -39,11 +45,6 @@ private enum MdParser {
}
}
- // Skip HTML comments at the start (e.g. )
- while i < lines.count && lines[i].trimmingCharacters(in: .whitespaces).hasPrefix(""),
+ let marker = trimmed.range(of: "database:") {
+ let pathStart = marker.upperBound
+ let pathEnd = trimmed.index(trimmed.endIndex, offsetBy: -3)
+ guard pathStart < pathEnd else { return nil }
+ let path = String(trimmed[pathStart.. String? {
+ guard line.hasPrefix("["),
+ line.hasSuffix(")"),
+ let split = line.range(of: "](") else { return nil }
+ let urlPart = String(line[split.upperBound.. String? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.lowercased().hasPrefix("database:") else { return nil }
+
+ var target = String(trimmed.dropFirst("database:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
+ if target.isEmpty { return nil }
+
+ if target.lowercased().hasPrefix("file://"), let url = URL(string: target) {
+ target = url.path
+ } else if target.hasPrefix("///") {
+ target = "/" + String(target.dropFirst(3))
+ } else if target.hasPrefix("//") {
+ target = "/" + String(target.dropFirst(2))
+ }
+
+ if target.hasPrefix("~") {
+ target = (target as NSString).expandingTildeInPath
+ }
+ if target.contains("%"), let decoded = target.removingPercentEncoding {
+ target = decoded
+ }
+ if target.hasSuffix("/_schema.json") {
+ target = (target as NSString).deletingLastPathComponent
+ }
+
+ return target
+ }
+
+ private static func isHTMLComment(_ line: String) -> Bool {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.hasPrefix("")
+ }
+
private static func computeNumber(for blocks: [MdBlock], depth: Int) -> Int {
var count = 1
for block in blocks.reversed() {
@@ -399,9 +474,36 @@ private struct BlockquoteBlockView: View {
private struct ImageBlockView: View {
let alt: String
let urlString: String
+ let pagePath: String?
+ let workspacePath: String?
+
+ private var remoteURL: URL? {
+ guard let url = URL(string: urlString),
+ let scheme = url.scheme,
+ !scheme.isEmpty,
+ !url.isFileURL else {
+ return nil
+ }
+ return url
+ }
+
+ private var localImagePath: String? {
+ resolveWorkspaceAttachmentPath(
+ urlString,
+ pagePath: pagePath,
+ workspacePath: workspacePath
+ )
+ }
var body: some View {
- if let url = URL(string: urlString), urlString.hasPrefix("http") {
+ #if canImport(UIKit)
+ if let localImagePath,
+ let image = UIImage(contentsOfFile: localImagePath) {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ } else if let url = remoteURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
@@ -422,7 +524,280 @@ private struct ImageBlockView: View {
Label(alt, systemImage: "photo")
.foregroundStyle(.secondary)
}
+ #else
+ if let url = remoteURL {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let image):
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ case .failure:
+ Label(alt.isEmpty ? "Image failed to load" : alt, systemImage: "photo")
+ .foregroundStyle(.secondary)
+ case .empty:
+ ProgressView()
+ @unknown default:
+ EmptyView()
+ }
+ }
+ } else if !alt.isEmpty {
+ Label(alt, systemImage: "photo")
+ .foregroundStyle(.secondary)
+ }
+ #endif
+ }
+}
+
+private struct DatabaseEmbedSummary {
+ let resolvedPath: String?
+ let title: String
+ let subtitle: String
+}
+
+private struct DatabaseEmbedCardView: View {
+ let storedPath: String
+ let pagePath: String?
+ let workspacePath: String?
+
+ private var summary: DatabaseEmbedSummary {
+ let resolvedPath = resolveMobileDatabaseEmbedPath(
+ storedPath,
+ pagePath: pagePath,
+ workspacePath: workspacePath
+ )
+ let title = resolvedPath.flatMap { mobileDatabaseDisplayName(at: $0) } ?? mobileDatabaseFallbackName(from: storedPath)
+ let subtitle: String
+
+ if let resolvedPath {
+ let rowCount = mobileDatabaseRowCount(at: resolvedPath)
+ subtitle = "\(rowCount) item\(rowCount == 1 ? "" : "s")"
+ } else {
+ subtitle = "Database unavailable"
+ }
+
+ return DatabaseEmbedSummary(
+ resolvedPath: resolvedPath,
+ title: title,
+ subtitle: subtitle
+ )
+ }
+
+ var body: some View {
+ Group {
+ if let resolvedPath = summary.resolvedPath {
+ NavigationLink {
+ MobileDatabaseView(dbPath: resolvedPath)
+ } label: {
+ cardLabel(showChevron: true)
+ }
+ .buttonStyle(.plain)
+ } else {
+ cardLabel(showChevron: false)
+ }
+ }
+ }
+
+ private func cardLabel(showChevron: Bool) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: "tablecells")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(Color.mobileTextSecondary)
+ .frame(width: 36, height: 36)
+ .background(Color.mobileCardBg)
+ .clipShape(.rect(cornerRadius: 10))
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(summary.title)
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(Color.mobileTextPrimary)
+ .lineLimit(1)
+
+ Text(summary.subtitle)
+ .font(.system(size: 13))
+ .foregroundStyle(Color.mobileTextMuted)
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: 12)
+
+ if showChevron {
+ Image(systemName: "chevron.right")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(Color.mobileUtilityIcon)
+ }
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.mobileBgSecondary)
+ .clipShape(.rect(cornerRadius: 14))
+ .overlay {
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(Color.mobileBorder, lineWidth: 0.5)
+ }
+ }
+}
+
+private func resolveMobileDatabaseEmbedPath(
+ _ storedPath: String,
+ pagePath: String?,
+ workspacePath: String?,
+ fileManager: FileManager = .default
+) -> String? {
+ let normalizedStoredPath = normalizeMobileDatabasePath(storedPath)
+ guard !normalizedStoredPath.isEmpty else { return nil }
+
+ if mobileIsDatabaseFolderPath(normalizedStoredPath, fileManager: fileManager) {
+ return normalizedStoredPath
+ }
+
+ let storedName = (normalizedStoredPath as NSString).lastPathComponent
+ var candidates: [String] = []
+
+ if let pagePath {
+ let pageContainer = pagePath.hasSuffix(".md") ? String(pagePath.dropLast(3)) : pagePath
+ if !storedName.isEmpty {
+ candidates.append((pageContainer as NSString).appendingPathComponent(storedName))
+ }
+ candidates.append(contentsOf: mobileMatchingDatabaseChildren(
+ named: storedName,
+ in: pageContainer,
+ fileManager: fileManager
+ ))
+ }
+
+ if let workspacePath, !workspacePath.isEmpty {
+ if !storedName.isEmpty {
+ candidates.append((workspacePath as NSString).appendingPathComponent(storedName))
+ }
+ if let uniqueMatch = mobileFindUniqueDatabasePath(
+ named: storedName,
+ in: workspacePath,
+ fileManager: fileManager
+ ) {
+ candidates.append(uniqueMatch)
+ }
+ }
+
+ var seen: Set = []
+ for candidate in candidates.map({ ($0 as NSString).standardizingPath }) where seen.insert(candidate).inserted {
+ if mobileIsDatabaseFolderPath(candidate, fileManager: fileManager) {
+ return candidate
+ }
+ }
+
+ return nil
+}
+
+private func normalizeMobileDatabasePath(_ path: String) -> String {
+ var normalized = path.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ if normalized.hasPrefix("~") {
+ normalized = (normalized as NSString).expandingTildeInPath
+ }
+ if normalized.contains("%"), let decoded = normalized.removingPercentEncoding {
+ normalized = decoded
+ }
+ if normalized.hasSuffix("/_schema.json") {
+ normalized = (normalized as NSString).deletingLastPathComponent
+ }
+
+ return (normalized as NSString).standardizingPath
+}
+
+private func mobileMatchingDatabaseChildren(
+ named name: String,
+ in directory: String,
+ fileManager: FileManager
+) -> [String] {
+ guard !name.isEmpty,
+ fileManager.fileExists(atPath: directory),
+ let entries = try? fileManager.contentsOfDirectory(atPath: directory) else {
+ return []
+ }
+
+ return entries.compactMap { entryName in
+ let childPath = (directory as NSString).appendingPathComponent(entryName)
+ guard mobileIsDatabaseFolderPath(childPath, fileManager: fileManager) else { return nil }
+
+ let folderNameMatches = entryName.localizedCaseInsensitiveCompare(name) == .orderedSame
+ let schemaNameMatches = mobileDatabaseDisplayName(at: childPath, fileManager: fileManager)?
+ .localizedCaseInsensitiveCompare(name) == .orderedSame
+
+ return (folderNameMatches || schemaNameMatches) ? childPath : nil
+ }
+}
+
+private func mobileFindUniqueDatabasePath(
+ named name: String,
+ in workspacePath: String,
+ fileManager: FileManager
+) -> String? {
+ guard !name.isEmpty,
+ let enumerator = fileManager.enumerator(atPath: workspacePath) else {
+ return nil
+ }
+
+ var matches: [String] = []
+
+ while let relativePath = enumerator.nextObject() as? String {
+ if WorkspacePathRules.shouldIgnoreRelativePath(relativePath) {
+ enumerator.skipDescendants()
+ continue
+ }
+
+ let fullPath = (workspacePath as NSString).appendingPathComponent(relativePath)
+ guard mobileIsDatabaseFolderPath(fullPath, fileManager: fileManager) else { continue }
+
+ let folderName = (fullPath as NSString).lastPathComponent
+ let schemaName = mobileDatabaseDisplayName(at: fullPath, fileManager: fileManager)
+ if folderName.localizedCaseInsensitiveCompare(name) == .orderedSame
+ || schemaName?.localizedCaseInsensitiveCompare(name) == .orderedSame {
+ matches.append(fullPath)
+ if matches.count > 1 {
+ return nil
+ }
+ }
+
+ enumerator.skipDescendants()
+ }
+
+ return matches.first
+}
+
+private func mobileDatabaseDisplayName(
+ at path: String,
+ fileManager: FileManager = .default
+) -> String? {
+ let schemaPath = (path as NSString).appendingPathComponent("_schema.json")
+ guard fileManager.fileExists(atPath: schemaPath),
+ let data = try? Data(contentsOf: URL(fileURLWithPath: schemaPath)),
+ let schema = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let name = schema["name"] as? String,
+ !name.isEmpty else {
+ return nil
}
+ return name
+}
+
+private func mobileDatabaseRowCount(at path: String, fileManager: FileManager = .default) -> Int {
+ guard let contents = try? fileManager.contentsOfDirectory(atPath: path) else { return 0 }
+ return contents.filter { $0.hasSuffix(".md") && !$0.hasPrefix("_") }.count
+}
+
+private func mobileIsDatabaseFolderPath(_ path: String, fileManager: FileManager = .default) -> Bool {
+ let schemaPath = (path as NSString).appendingPathComponent("_schema.json")
+ return fileManager.fileExists(atPath: schemaPath)
+}
+
+private func mobileDatabaseFallbackName(from storedPath: String) -> String {
+ let rawName = ((storedPath as NSString).lastPathComponent as NSString).deletingPathExtension
+ let cleaned = rawName
+ .replacingOccurrences(of: "_", with: " ")
+ .replacingOccurrences(of: "-", with: " ")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return cleaned.isEmpty ? "Database" : cleaned
}
// MARK: - Grouped block helpers
@@ -466,6 +841,14 @@ private func groupBlocks(_ blocks: [MdBlock]) -> [GroupedBlock] {
struct MobileMarkdownView: View {
let content: String
+ let pagePath: String?
+ let workspacePath: String?
+
+ init(content: String, pagePath: String? = nil, workspacePath: String? = nil) {
+ self.content = content
+ self.pagePath = pagePath
+ self.workspacePath = workspacePath
+ }
private var parsedGroups: [GroupedBlock] {
groupBlocks(MdParser.parse(content))
@@ -524,11 +907,23 @@ struct MobileMarkdownView: View {
.padding(.vertical, 4)
case .image(let alt, let url):
- ImageBlockView(alt: alt, urlString: url)
+ ImageBlockView(
+ alt: alt,
+ urlString: url,
+ pagePath: pagePath,
+ workspacePath: workspacePath
+ )
case .wikiLink(let name):
Text(name)
.foregroundStyle(.blue)
+
+ case .databaseEmbed(let path):
+ DatabaseEmbedCardView(
+ storedPath: path,
+ pagePath: pagePath,
+ workspacePath: workspacePath
+ )
}
}
}
diff --git a/Sources/BugbookMobile/Views/MobilePageEditorView.swift b/Sources/BugbookMobile/Views/MobilePageEditorView.swift
index d70d9cc2..f2d25bcf 100644
--- a/Sources/BugbookMobile/Views/MobilePageEditorView.swift
+++ b/Sources/BugbookMobile/Views/MobilePageEditorView.swift
@@ -46,7 +46,11 @@ struct MobilePageEditorView: View {
}
} else {
ScrollView {
- MobileMarkdownView(content: content)
+ MobileMarkdownView(
+ content: content,
+ pagePath: note.path,
+ workspacePath: workspace.workspacePath
+ )
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
diff --git a/Sources/BugbookMobile/Views/MobileSearchView.swift b/Sources/BugbookMobile/Views/MobileSearchView.swift
index 26151162..1648ec2f 100644
--- a/Sources/BugbookMobile/Views/MobileSearchView.swift
+++ b/Sources/BugbookMobile/Views/MobileSearchView.swift
@@ -6,6 +6,7 @@ struct MobileSearchView: View {
var workspace: MobileWorkspaceService
@Environment(\.dismiss) private var dismiss
+ @FocusState private var queryFieldFocused: Bool
@State private var query = ""
@State private var results: [SearchResult] = []
@@ -36,6 +37,7 @@ struct MobileSearchView: View {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search notes...", text: $query)
+ .focused($queryFieldFocused)
.textFieldStyle(.plain)
.autocorrectionDisabled()
#if os(iOS)
@@ -56,6 +58,11 @@ struct MobileSearchView: View {
.onChange(of: query) { _, newValue in
scheduleSearch(newValue)
}
+ .onAppear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+ queryFieldFocused = true
+ }
+ }
}
@ViewBuilder
diff --git a/Sources/BugbookMobile/Views/MobileTodayView.swift b/Sources/BugbookMobile/Views/MobileTodayView.swift
index 9eae49a7..fe4b3c56 100644
--- a/Sources/BugbookMobile/Views/MobileTodayView.swift
+++ b/Sources/BugbookMobile/Views/MobileTodayView.swift
@@ -9,9 +9,7 @@ struct MobileTodayView: View {
@State private var recentNotes: [MobileNoteFile] = []
private var todayDateString: String {
- let formatter = DateFormatter()
- formatter.dateFormat = "EEEE, MMMM d"
- return formatter.string(from: Date())
+ Self.todayFormatter.string(from: Date())
}
var body: some View {
@@ -174,6 +172,11 @@ struct MobileTodayView: View {
f.unitsStyle = .abbreviated
return f
}()
+ private static let todayFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEEE, MMMM d"
+ return formatter
+ }()
private func relativeTime(from date: Date) -> String {
Self.relativeFormatter.localizedString(for: date, relativeTo: Date())
diff --git a/Tests/BugbookCoreTests/AttachmentPathResolverTests.swift b/Tests/BugbookCoreTests/AttachmentPathResolverTests.swift
new file mode 100644
index 00000000..3b46e737
--- /dev/null
+++ b/Tests/BugbookCoreTests/AttachmentPathResolverTests.swift
@@ -0,0 +1,72 @@
+import XCTest
+@testable import BugbookCore
+
+final class AttachmentPathResolverTests: XCTestCase {
+ private func makeTemporaryWorkspace() throws -> String {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("BugbookAttachmentResolver-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url.path
+ }
+
+ func testResolveWorkspaceAttachmentPathPrefersWorkspaceAttachmentsFolder() throws {
+ let workspace = try makeTemporaryWorkspace()
+ defer { try? FileManager.default.removeItem(atPath: workspace) }
+
+ let museumDirectory = (workspace as NSString).appendingPathComponent("Museum")
+ let pagePath = (museumDirectory as NSString).appendingPathComponent("Field Notes.md")
+ let attachmentsDirectory = (workspace as NSString).appendingPathComponent("Attachments")
+ try FileManager.default.createDirectory(atPath: attachmentsDirectory, withIntermediateDirectories: true)
+
+ let attachmentPath = (attachmentsDirectory as NSString).appendingPathComponent("capture.jpg")
+ FileManager.default.createFile(atPath: attachmentPath, contents: Data())
+
+ let resolved = resolveWorkspaceAttachmentPath(
+ "Attachments/capture.jpg",
+ pagePath: pagePath,
+ workspacePath: workspace
+ )
+
+ XCTAssertEqual(resolved, attachmentPath)
+ }
+
+ func testResolveWorkspaceAttachmentPathFallsBackToPageDirectoryForRelativeFiles() throws {
+ let workspace = try makeTemporaryWorkspace()
+ defer { try? FileManager.default.removeItem(atPath: workspace) }
+
+ let pageDirectory = (workspace as NSString).appendingPathComponent("Museum")
+ try FileManager.default.createDirectory(atPath: pageDirectory, withIntermediateDirectories: true)
+
+ let pagePath = (pageDirectory as NSString).appendingPathComponent("Field Notes.md")
+ let imagePath = (pageDirectory as NSString).appendingPathComponent("detail.jpg")
+ FileManager.default.createFile(atPath: imagePath, contents: Data())
+
+ let resolved = resolveWorkspaceAttachmentPath(
+ "detail.jpg",
+ pagePath: pagePath,
+ workspacePath: workspace
+ )
+
+ XCTAssertEqual(resolved, imagePath)
+ }
+
+ func testResolveWorkspaceAttachmentPathDecodesPercentEscapedRelativeFiles() throws {
+ let workspace = try makeTemporaryWorkspace()
+ defer { try? FileManager.default.removeItem(atPath: workspace) }
+
+ let pageDirectory = (workspace as NSString).appendingPathComponent("Museum")
+ try FileManager.default.createDirectory(atPath: pageDirectory, withIntermediateDirectories: true)
+
+ let pagePath = (pageDirectory as NSString).appendingPathComponent("Field Notes.md")
+ let imagePath = (pageDirectory as NSString).appendingPathComponent("detail 01.jpg")
+ FileManager.default.createFile(atPath: imagePath, contents: Data())
+
+ let resolved = resolveWorkspaceAttachmentPath(
+ "detail%2001.jpg",
+ pagePath: pagePath,
+ workspacePath: workspace
+ )
+
+ XCTAssertEqual(resolved, imagePath)
+ }
+}
diff --git a/Tests/BugbookTests/BrowserFeatureTests.swift b/Tests/BugbookTests/BrowserFeatureTests.swift
index 5f1b8cf2..f58cf8ab 100644
--- a/Tests/BugbookTests/BrowserFeatureTests.swift
+++ b/Tests/BugbookTests/BrowserFeatureTests.swift
@@ -30,7 +30,6 @@ final class BrowserFeatureTests: XCTestCase {
func testAppSettingsBrowserFieldsRoundTrip() throws {
var settings = AppSettings.default
- settings.railPinned = true
settings.browserSearchEngine = .kagi
settings.browserChrome.showsBackForwardButtons = true
settings.browserChrome.showsStatusBar = true
@@ -42,7 +41,6 @@ final class BrowserFeatureTests: XCTestCase {
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(AppSettings.self, from: data)
- XCTAssertTrue(decoded.railPinned)
XCTAssertEqual(decoded.browserSearchEngine, .kagi)
XCTAssertTrue(decoded.browserChrome.showsBackForwardButtons)
XCTAssertTrue(decoded.browserChrome.showsStatusBar)
diff --git a/Tests/BugbookTests/FileSystemServiceTests.swift b/Tests/BugbookTests/FileSystemServiceTests.swift
index 4d2749a0..1bad0f2b 100644
--- a/Tests/BugbookTests/FileSystemServiceTests.swift
+++ b/Tests/BugbookTests/FileSystemServiceTests.swift
@@ -78,4 +78,33 @@ final class FileSystemServiceTests: XCTestCase {
XCTAssertTrue(updated.contains(""))
XCTAssertFalse(updated.contains(""))
}
+
+ func testBuildFileTreeHidesCaptureStorageFolders() throws {
+ let service = FileSystemService()
+ let workspace = try makeTemporaryDirectory()
+ defer { try? FileManager.default.removeItem(atPath: workspace) }
+
+ let inboxPath = (workspace as NSString).appendingPathComponent("Inbox")
+ let rawPath = (workspace as NSString).appendingPathComponent("raw")
+ let attachmentsPath = (workspace as NSString).appendingPathComponent("Attachments")
+
+ try FileManager.default.createDirectory(atPath: inboxPath, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(atPath: rawPath, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(atPath: attachmentsPath, withIntermediateDirectories: true)
+
+ let inboxNote = (inboxPath as NSString).appendingPathComponent("Photo 2.03 PM.md")
+ let rawNote = (rawPath as NSString).appendingPathComponent("2026-04-05-photo.md")
+ let rootPage = (workspace as NSString).appendingPathComponent("Museum Notes.md")
+
+ try "Inbox capture".write(toFile: inboxNote, atomically: true, encoding: .utf8)
+ try "Raw capture".write(toFile: rawNote, atomically: true, encoding: .utf8)
+ try "# Museum Notes\n".write(toFile: rootPage, atomically: true, encoding: .utf8)
+
+ let tree = service.buildFileTree(at: workspace)
+ let names = tree.map(\.name)
+
+ XCTAssertTrue(names.contains("Museum Notes.md"))
+ XCTAssertFalse(names.contains("Photo 2.03 PM.md"))
+ XCTAssertFalse(names.contains("2026-04-05-photo.md"))
+ }
}
diff --git a/Tests/BugbookTests/perf_baseline.tsv b/Tests/BugbookTests/perf_baseline.tsv
index 8fe2e1b5..2dea02e1 100644
--- a/Tests/BugbookTests/perf_baseline.tsv
+++ b/Tests/BugbookTests/perf_baseline.tsv
@@ -1,9 +1,9 @@
test_name metric value timestamp
-block_document_init_50 ms 0.717 2026-03-27T17:48:37Z
-database_load_100 ms 6.120 2026-03-27T17:48:38Z
-filesystem_tree_100 ms 4.365 2026-03-27T17:48:38Z
-markdown_parse_500 ms 3.338 2026-03-27T17:48:38Z
-markdown_serialize_500 ms 2.230 2026-03-27T17:48:39Z
-qmd_find_binary ms 0.023 2026-03-27T17:48:39Z
-row_deserialize_100 ms 4.155 2026-03-27T17:48:39Z
-row_serialize_100 ms 1.378 2026-03-27T17:48:40Z
\ No newline at end of file
+block_document_init_50 ms 0.251 2026-04-08T16:55:04Z
+database_load_100 ms 5.993 2026-04-08T16:55:05Z
+filesystem_tree_100 ms 5.022 2026-04-08T16:55:05Z
+markdown_parse_500 ms 3.576 2026-04-08T16:55:06Z
+markdown_serialize_500 ms 2.649 2026-04-08T16:55:06Z
+qmd_find_binary ms 0.015 2026-04-08T16:55:08Z
+row_deserialize_100 ms 3.826 2026-04-08T16:55:10Z
+row_serialize_100 ms 1.201 2026-04-08T16:55:10Z
\ No newline at end of file