import SwiftUI import AppKit import UniformTypeIdentifiers struct PanelView: View { @ObservedObject var store: Store @ObservedObject private var sync = SyncService.shared @State private var composer = "" @State private var composerFocused = false @State private var composerHeight: CGFloat = ComposerView.oneLineHeight @State private var search = "" @State private var showSearch = false @State private var showSectionSwitcher = false @State private var showNewWorkspace = false @State private var newWorkspaceName = "" @State private var showSync = false @State private var joinCode = "" @State private var selection = Set() @State private var lastSelectedID: UUID? @State private var editingItem: PromptItem? @State private var editText = "" @State private var editFocused = false @State private var editHeight: CGFloat = ComposerView.oneLineHeight @State private var copiedID: UUID? @State private var accessibilityGranted = AXIsProcessTrusted() private enum Tab { case workspace, all } @State private var tab: Tab = .workspace private enum Field: Hashable { case search, newWorkspace } @FocusState private var focusedField: Field? var body: some View { VStack(spacing: 0) { header if !accessibilityGranted { permissionBanner } tabPicker if showSearch { searchField } Divider() content Divider() if !selection.isEmpty { selectionBar } composerField } .frame(minWidth: 300) .background(Theme.backgroundSwiftUI) .background(shortcutButtons) .overlay(sectionSwitcher) .overlay(syncOverlay) .overlay(newWorkspaceModal) .overlay(editModal) .onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop) .onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in composerFocused = true accessibilityGranted = AXIsProcessTrusted() } .onAppear { composerFocused = true } // Selection is scoped to the current view: switching workspace, tab, // or leaving search clears it. Otherwise a selection made in one // workspace kept acting (copy, delete) on items you can no longer see. .onChange(of: store.activeSectionID) { clearSelection() } .onChange(of: tab) { clearSelection() } } // MARK: - Header private var header: some View { HStack(spacing: 10) { Button { withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() } } label: { HStack(spacing: 4) { Text(store.activeSection.name.uppercased()) .font(.system(size: 12, weight: .semibold)) .tracking(1) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .bold)) } .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Switch workspace (Cmd+K)") Spacer() Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: { SyncDot() } .buttonStyle(.plain).help("Sync") Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") } .buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)") Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") } .buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos") } .padding(.horizontal, 12) .padding(.vertical, 10) } // MARK: - Tabs /// The global view: every prompt across every workspace, each labeled. private var tabPicker: some View { Picker("View", selection: $tab) { Text(store.activeSection.name).tag(Tab.workspace) Text("All").tag(Tab.all) } .pickerStyle(.segmented) .labelsHidden() .padding(.horizontal, 12) .padding(.vertical, 6) } private var permissionBanner: some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) VStack(alignment: .leading, spacing: 2) { Text("Shift-tap capture needs Accessibility access.") .font(.caption) Text("Meanwhile: Cmd+Shift+Space toggles this panel.") .font(.caption2) .foregroundStyle(.secondary) } Spacer() Button("Grant…") { let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) } .font(.caption) } .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color.yellow.opacity(0.1)) } private var searchField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("Search all workspaces", text: $search) .textFieldStyle(.plain) .focused($focusedField, equals: .search) .onExitCommand { search = "" showSearch = false focusedField = nil clearSelection() composerFocused = true } if !search.isEmpty { Button { search = "" } label: { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain).foregroundStyle(.secondary) } } .padding(.horizontal, 12) .padding(.vertical, 8) } // MARK: - Content /// Every item across every workspace, each labeled with its workspace. private var allLabeledItems: [(section: String, item: PromptItem)] { store.sections.flatMap { section in section.items.map { (section.name, $0) } } } private var displayedItems: [(section: String, item: PromptItem)] { if !search.isEmpty { let query = search.lowercased() return allLabeledItems.filter { entry in if case .text(let text) = entry.item.content { return text.lowercased().contains(query) } return false } } if tab == .all { return allLabeledItems } return store.activeSection.items.map { ("", $0) } } private var content: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 6) { if displayedItems.isEmpty { emptyState } ForEach(displayedItems, id: \.item.id) { entry in row(for: entry.item, sectionName: entry.section) .id(entry.item.id) } } .padding(10) } .onChange(of: store.activeSection.items.count) { if let last = store.activeSection.items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } } private var emptyState: some View { VStack(spacing: 10) { Image(systemName: "tray") .font(.system(size: 28)) .foregroundStyle(.quaternary) Text(search.isEmpty ? "Nothing here yet" : "No matches") .font(.callout) .foregroundStyle(.secondary) if search.isEmpty { Text("Select text anywhere and tap Shift twice,\nor drop photos into this panel.") .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) } } .frame(maxWidth: .infinity) .padding(.vertical, 48) } /// Rows carry a checkbox that *selects* the item, so a selection can be /// copied or deleted from the context menu. The row cannot be a Button /// because the checkbox is one, and nested buttons do not receive clicks. private func row(for item: PromptItem, sectionName: String) -> some View { HStack(alignment: .top, spacing: 8) { Button { toggleSelection(item.id) } label: { Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle") .font(.system(size: 15)) .foregroundStyle(selection.contains(item.id) ? Color.accentColor : Color.secondary.opacity(0.55)) } .buttonStyle(.plain) .help(selection.contains(item.id) ? "Deselect" : "Select for copy or delete") VStack(alignment: .leading, spacing: 6) { if !sectionName.isEmpty { Text(sectionName.uppercased()) .font(.system(size: 9, weight: .semibold)) .tracking(1) .foregroundStyle(.tertiary) } itemContent(item) HStack(spacing: 6) { if let sourceApp = item.sourceApp { Text(sourceApp) } Text(item.createdAt, style: .time) } .font(.caption2) .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background(rowBackground(item)) .cornerRadius(8) .overlay(alignment: .topTrailing) { if copiedID == item.id { Label("Copied", systemImage: "checkmark.circle.fill") .font(.caption2) .foregroundStyle(.green) .padding(6) .transition(.opacity) } } .contentShape(Rectangle()) .onTapGesture { handleClick(item) } .help("Click to select · Shift+click for a range · right-click to copy, delete and more") .contextMenu { contextMenu(for: item) } } private func rowBackground(_ item: PromptItem) -> Color { if selection.contains(item.id) { return Color.accentColor.opacity(0.25) } return Theme.rowSwiftUI } @ViewBuilder private func itemContent(_ item: PromptItem) -> some View { switch item.content { case .text(let text): Text(markdown(text)) .font(.callout) .strikethrough(item.done) .opacity(item.done ? 0.45 : 1) .lineLimit(8) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) case .image(let filename): if let image = store.image(for: filename) { Image(nsImage: image) .resizable() .scaledToFit() .frame(maxHeight: 160) .cornerRadius(6) .opacity(item.done ? 0.4 : 1) } else { Label("Missing image", systemImage: "photo") .foregroundStyle(.secondary) } } } private func contextMenu(for item: PromptItem) -> some View { let ids = effectiveIDs(for: item) let multiple = ids.count > 1 return Group { Button("Copy") { copy(item) } Button(multiple ? "Copy \(ids.count) as List" : "Copy as List") { store.copyAsList(ids: ids) flashCopied(item.id) } Button(multiple ? "Copy \(ids.count) as Lines" : "Copy as Lines") { store.copyAsLines(ids: ids) flashCopied(item.id) } Divider() Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.setDone(!item.done, ids: ids) } if case .text(let text) = item.content, !multiple { Button("Edit…") { editText = text editHeight = ComposerView.oneLineHeight editingItem = item editFocused = true } } if case .image(let filename) = item.content, !multiple { Button("Open") { NSWorkspace.shared.open(store.imageURL(for: filename)) } } if multiple { Button("Merge Notes") { store.merge(ids: ids) clearSelection() } } Menu("Move To") { ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in Button(section.name) { store.move(ids: ids, to: section.id) if multiple { clearSelection() } } } } Divider() Button(multiple ? "Delete \(ids.count) Items" : "Delete", role: .destructive) { store.delete(ids: ids) selection.subtract(ids) lastSelectedID = nil } } } // MARK: - Section switcher @ViewBuilder private var sectionSwitcher: some View { if showSectionSwitcher { VStack(spacing: 0) { Color.black.opacity(0.001) .onTapGesture { showSectionSwitcher = false } } .frame(maxWidth: .infinity, maxHeight: .infinity) VStack(spacing: 0) { ForEach(Array(store.sections.enumerated()), id: \.element.id) { index, section in HStack(spacing: 6) { Text(section.name) .font(.callout) Spacer() Text("\(section.items.count)") .font(.caption) .foregroundStyle(.tertiary) if section.id == store.activeSection.id { Image(systemName: "checkmark") .font(.caption) .foregroundStyle(Color.accentColor) } // Reorder the workspace list; the boundary rows are // disabled rather than hidden so the list does not jump. HStack(spacing: 0) { Button { store.moveSection(section.id, by: -1) } label: { Image(systemName: "chevron.up") } .disabled(index == 0) Button { store.moveSection(section.id, by: 1) } label: { Image(systemName: "chevron.down") } .disabled(index == store.sections.count - 1) } .buttonStyle(.plain) .font(.system(size: 9, weight: .bold)) .foregroundStyle(.tertiary) } .padding(.horizontal, 14) .padding(.vertical, 8) .contentShape(Rectangle()) .onTapGesture { store.activeSectionID = section.id tab = .workspace showSectionSwitcher = false } } Divider() Button { showSectionSwitcher = false newWorkspaceName = "" showNewWorkspace = true focusedField = .newWorkspace } label: { HStack { Image(systemName: "plus") Text("New Workspace") Spacer() } .padding(.horizontal, 14) .padding(.vertical, 8) .contentShape(Rectangle()) } .buttonStyle(.plain) } .background(.regularMaterial) .cornerRadius(10) .shadow(radius: 12) .padding(.horizontal, 24) .frame(maxHeight: .infinity, alignment: .top) .padding(.top, 44) .transition(.opacity) } } // MARK: - Sync @ViewBuilder private var syncOverlay: some View { if showSync { VStack(spacing: 0) { Color.black.opacity(0.001) .onTapGesture { showSync = false } } .frame(maxWidth: .infinity, maxHeight: .infinity) VStack(alignment: .leading, spacing: 10) { HStack { Text("Sync").font(.headline) Spacer() if sync.isSyncing { ProgressView().controlSize(.small) } } TextField("Server URL", text: $sync.serverURL) .textFieldStyle(.roundedBorder) .font(.callout) if let code = sync.code { Divider() Text("Your code").font(.caption).foregroundStyle(.secondary) HStack { Text(code) .font(.system(size: 20, weight: .medium, design: .monospaced)) .textSelection(.enabled) Spacer() Button { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(code, forType: .string) } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.plain) .help("Copy code") } Text("Enter this code on your other Mac to sync.") .font(.caption) .foregroundStyle(.secondary) statusLine Divider() Button("Disconnect", role: .destructive) { sync.disconnect() } .font(.callout) } else { Divider() Button("Generate Code") { sync.generateCode() } .disabled(sync.isSyncing) Text("Creates a new sync group and uploads what's here.") .font(.caption) .foregroundStyle(.secondary) Divider() Text("Or join an existing group").font(.caption).foregroundStyle(.secondary) HStack { TextField("1234-5678", text: $joinCode) .textFieldStyle(.roundedBorder) .font(.system(.callout, design: .monospaced)) .onExitCommand { showSync = false } Button("Join") { sync.join(rawCode: joinCode) joinCode = "" } .disabled(sync.isSyncing || joinCode.isEmpty) } Text("Joining replaces what's on this Mac with the group's notes.") .font(.caption) .foregroundStyle(.secondary) } Divider() Toggle("Launch at Login", isOn: Binding( get: { LoginItem.isEnabled }, set: { _ in _ = LoginItem.toggle() } )) .font(.callout) if let error = sync.lastError { Text(error) .font(.caption) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } } .padding(14) .background(.regularMaterial) .cornerRadius(10) .shadow(radius: 12) .padding(.horizontal, 20) .frame(maxHeight: .infinity, alignment: .top) .padding(.top, 44) .transition(.opacity) } } @ViewBuilder private var statusLine: some View { if sync.isSyncing { Text("Syncing…").font(.caption).foregroundStyle(.secondary) } else if let last = sync.lastSyncedAt { Text("Last synced \(last, style: .relative) ago") .font(.caption) .foregroundStyle(.secondary) } else { Text("Not synced yet").font(.caption).foregroundStyle(.secondary) } } // MARK: - Edit /// The editor is a ComposerView too, so it behaves exactly like the main /// input: Return saves, Shift+Return inserts a newline, Esc cancels, and /// pasting goes through the same working path. @ViewBuilder private var editModal: some View { if let item = editingItem { ZStack { Color.black.opacity(0.25) .onTapGesture { dismissEditor() } VStack(alignment: .leading, spacing: 12) { Text("Edit") .font(.headline) ComposerView( text: $editText, isFocused: $editFocused, height: $editHeight, placeholder: "", onSubmit: { saveEdit(item) }, onEscape: { dismissEditor() } ) .frame(height: editHeight) .padding(8) .background(Theme.rowSwiftUI) .cornerRadius(8) HStack { Spacer() Button("Cancel") { dismissEditor() } .keyboardShortcut(.cancelAction) Button("Save") { saveEdit(item) } .keyboardShortcut("s", modifiers: .command) .disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .padding(16) .frame(maxWidth: 320) .background(Theme.backgroundSwiftUI) .cornerRadius(12) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.primary.opacity(0.12), lineWidth: 1) ) .shadow(radius: 18) } .transition(.opacity) } } private func saveEdit(_ item: PromptItem) { let text = editText.trimmingCharacters(in: .whitespacesAndNewlines) if !text.isEmpty { store.updateText(item.id, newText: text) } dismissEditor() } private func dismissEditor() { editingItem = nil editText = "" editFocused = false composerFocused = true } // MARK: - New workspace @ViewBuilder private var newWorkspaceModal: some View { if showNewWorkspace { ZStack { Color.black.opacity(0.25) .onTapGesture { dismissNewWorkspace() } VStack(alignment: .leading, spacing: 12) { Text("New Workspace") .font(.headline) TextField("Name", text: $newWorkspaceName) .textFieldStyle(.roundedBorder) .focused($focusedField, equals: .newWorkspace) .onSubmit(createWorkspace) .onExitCommand { dismissNewWorkspace() } HStack { Spacer() Button("Cancel") { dismissNewWorkspace() } Button("Create") { createWorkspace() } .keyboardShortcut(.defaultAction) .disabled(newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty) } } .padding(16) .frame(width: 260) .background(.regularMaterial) .cornerRadius(12) .shadow(radius: 18) } .transition(.opacity) } } private func createWorkspace() { let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return } store.createSection(named: name) dismissNewWorkspace() } private func dismissNewWorkspace() { showNewWorkspace = false newWorkspaceName = "" focusedField = nil composerFocused = true } // MARK: - Selection private var selectionBar: some View { HStack(spacing: 12) { Text("\(selection.count) selected") .font(.caption) .foregroundStyle(.secondary) Spacer() if selection.count == 1, let id = selection.first, let item = store.item(id) { Button("Copy") { copy(item) } .font(.caption) .buttonStyle(.plain) .foregroundStyle(Color.accentColor) } Button("Copy as List") { copySelection(asList: true) } .font(.caption) .buttonStyle(.plain) .foregroundStyle(Color.accentColor) Button("Copy as Lines") { copySelection(asList: false) } .font(.caption) .buttonStyle(.plain) .foregroundStyle(Color.accentColor) Button { deleteSelection() } label: { Image(systemName: "trash") } .buttonStyle(.plain) .foregroundStyle(.red) .help("Delete selected") Button { clearSelection() } label: { Image(systemName: "xmark.circle.fill") } .buttonStyle(.plain) .foregroundStyle(.tertiary) .help("Clear selection (Esc)") } .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.accentColor.opacity(0.12)) } private func deleteSelection() { guard !selection.isEmpty else { return } store.delete(ids: selection) clearSelection() } private func copySelection(asList: Bool) { guard !selection.isEmpty else { return } if asList { store.copyAsList(ids: selection) } else { store.copyAsLines(ids: selection) } if let id = selection.first { flashCopied(id) } } private func clearSelection() { selection.removeAll() lastSelectedID = nil } // MARK: - Composer private var composerField: some View { HStack(alignment: .bottom, spacing: 8) { ComposerView( text: $composer, isFocused: $composerFocused, height: $composerHeight, placeholder: "Add a note or prompt (\(store.activeSection.name))", // A modal field (search, edit, new workspace) or the sync // overlay owns the keyboard; don't steal focus back mid-edit. canStealFocus: focusedField == nil && !showSync && editingItem == nil, onSubmit: submit, onEscape: { if selection.isEmpty { PanelController.shared.hide() } else { clearSelection() } } ) .frame(maxWidth: .infinity) .frame(height: composerHeight) if !composer.isEmpty { Button(action: submit) { Image(systemName: "arrow.up.circle.fill") } .buttonStyle(.plain) .foregroundStyle(Color.accentColor) .help("Add (Return; Shift+Return for a new line)") } } .padding(.horizontal, 12) .padding(.vertical, 8) } private func submit() { let text = composer.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } if text.hasPrefix("#") { let name = String(text.dropFirst()).trimmingCharacters(in: .whitespaces) if !name.isEmpty { store.createSection(named: name) } } else { store.addText(text) } composer = "" } // MARK: - Shortcuts // Only Cmd-modified shortcuts live here: bare keys (Space, Delete, Cmd+C) // would be swallowed as key equivalents and break typing in the composer. private var shortcutButtons: some View { VStack { Button("") { toggleSearch() } .keyboardShortcut("f", modifiers: .command) Button("") { withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() } } .keyboardShortcut("k", modifiers: .command) Button("") { composerFocused = true } .keyboardShortcut("n", modifiers: .command) Button("") { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } .keyboardShortcut(",", modifiers: .command) Button("") { copySelection(asList: true) } .keyboardShortcut("c", modifiers: [.command, .shift]) Button("") { copySelection(asList: false) } .keyboardShortcut("c", modifiers: [.command, .option]) } .opacity(0) .frame(width: 0, height: 0) .allowsHitTesting(false) } // MARK: - Actions /// A click anywhere on the card selects it (Shift+click selects a range). /// Copying is then an action on the selection — from the selection bar or /// the right-click menu — rather than the side effect of a click. private func handleClick(_ item: PromptItem) { let modifiers = NSApp.currentEvent?.modifierFlags ?? [] if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id { selectRange(from: anchor, to: item.id) return } toggleSelection(item.id) } private func toggleSelection(_ id: UUID) { if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } lastSelectedID = id } private func selectRange(from anchor: UUID, to target: UUID) { let ids = displayedItems.map(\.item.id) guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return } let range = start <= end ? start...end : end...start selection = Set(ids[range]) lastSelectedID = target } private func copy(_ item: PromptItem) { store.copy(item) flashCopied(item.id) } private func flashCopied(_ id: UUID) { withAnimation { copiedID = id } DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { if copiedID == id { withAnimation { copiedID = nil } } } } private func effectiveIDs(for item: PromptItem) -> Set { if selection.contains(item.id), selection.count > 1 { return selection } return [item.id] } private func toggleSearch() { showSearch.toggle() if showSearch { focusedField = .search } else { search = "" focusedField = nil clearSelection() composerFocused = true } } private func addPhotos() { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] panel.allowsMultipleSelection = true guard panel.runModal() == .OK else { return } for url in panel.urls { if let image = NSImage(contentsOf: url) { store.addImage(image) } } } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers { if provider.canLoadObject(ofClass: NSImage.self) { handled = true _ = provider.loadObject(ofClass: NSImage.self) { object, _ in if let image = object as? NSImage { Task { @MainActor in Store.shared.addImage(image) } } } } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { handled = true provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil), let image = NSImage(contentsOf: url) else { return } Task { @MainActor in Store.shared.addImage(image) } } } } return handled } private func markdown(_ string: String) -> AttributedString { let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string) } } /// Sync status lives in its own view so a `SyncService` publish cannot /// invalidate the panel's list. Watching it from `PanelView`'s body meant every /// sync event — including the 5-second poll — re-rendered every row. private struct SyncDot: View { @ObservedObject private var sync = SyncService.shared var body: some View { Image(systemName: "arrow.triangle.2.circlepath") .foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor) } }