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..