Files
prompt/Sources/Prompt/PanelView.swift
T
TZGyn 15ad9f355c Add optional sync between Macs with a shared 8-digit code
Notes and photos were stuck on one machine. A small Go/Postgres backend
stores each group's payload under a code that doubles as the shared
secret, so a second Mac just needs the code — no account or login.

Applying a remote change must not push it straight back, otherwise the
two Macs would bump the version back and forth forever, so payloads are
fingerprinted and identical ones are skipped.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
2026-10-08 09:56:08 +08:00

593 lines
22 KiB
Swift

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 search = ""
@State private var showSearch = false
@State private var showSectionSwitcher = false
@State private var showSync = false
@State private var joinCode = ""
@State private var selection = Set<UUID>()
@State private var copiedID: UUID?
@State private var accessibilityGranted = AXIsProcessTrusted()
private enum Field: Hashable { case composer, search }
@FocusState private var focusedField: Field?
var body: some View {
VStack(spacing: 0) {
header
if !accessibilityGranted {
permissionBanner
}
if showSearch {
searchField
}
Divider()
content
Divider()
composerField
}
.frame(minWidth: 300)
.background(shortcutButtons)
.overlay(sectionSwitcher)
.overlay(syncOverlay)
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
focusedField = .composer
accessibilityGranted = AXIsProcessTrusted()
}
.onAppear { focusedField = .composer }
}
// 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 section (Cmd+K)")
Spacer()
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
}
.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")
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("Close (Esc)")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
private var permissionBanner: some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text("Shift-tap capture needs Accessibility access.")
.font(.caption)
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 sections", text: $search)
.textFieldStyle(.plain)
.focused($focusedField, equals: .search)
.onExitCommand {
search = ""
showSearch = false
focusedField = .composer
}
if !search.isEmpty {
Button { search = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
// MARK: - Content
private var displayedItems: [(section: String, item: PromptItem)] {
if search.isEmpty {
return store.activeSection.items.map { ("", $0) }
}
let query = search.lowercased()
return store.sections.flatMap { section in
section.items.compactMap { item in
switch item.content {
case .text(let text) where text.lowercased().contains(query):
return (section.name, item)
default:
return nil
}
}
}
}
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)
}
private func row(for item: PromptItem, sectionName: String) -> some View {
Button { handleClick(item) } label: {
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)
if item.done {
Image(systemName: "checkmark")
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
}
.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)
}
}
}
.buttonStyle(.plain)
.contextMenu { contextMenu(for: item) }
}
private func rowBackground(_ item: PromptItem) -> Color {
if selection.contains(item.id) { return Color.accentColor.opacity(0.25) }
return Color.primary.opacity(0.05)
}
@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 {
Group {
Button("Copy") { copy(item) }
Button("Copy as List") {
store.copyAsList(ids: effectiveIDs(for: item))
flashCopied(item)
}
Divider()
Button(item.done ? "Mark as Not Done" : "Mark as Done") { store.toggleDone(item.id) }
if case .text(let text) = item.content {
Button("Edit…") {
PanelController.shared.editController.edit(itemID: item.id, text: text)
}
}
if case .image(let filename) = item.content {
Button("Open") {
NSWorkspace.shared.open(store.imageURL(for: filename))
}
}
if selection.count > 1, selection.contains(item.id) {
Button("Merge Notes") {
store.merge(ids: selection)
selection.removeAll()
}
}
Menu("Move To") {
ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in
Button(section.name) { store.move(item.id, to: section.id) }
}
}
Divider()
Button("Delete", role: .destructive) {
selection.remove(item.id)
store.delete(item.id)
}
}
}
// 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(store.sections) { section in
Button {
store.activeSectionID = section.id
showSectionSwitcher = false
} label: {
HStack {
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)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Divider()
Button {
composer = "# "
showSectionSwitcher = false
focusedField = .composer
} label: {
HStack {
Image(systemName: "plus")
Text("New Section")
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)
}
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: - Composer
private var composerField: some View {
HStack(spacing: 8) {
TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer)
.textFieldStyle(.plain)
.focused($focusedField, equals: .composer)
.onSubmit(submit)
.onExitCommand { PanelController.shared.hide() }
if !composer.isEmpty {
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
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("") { focusedField = .composer }
.keyboardShortcut("n", modifiers: .command)
Button("") {
guard !selection.isEmpty else { return }
store.copyAsList(ids: selection)
if let id = selection.first { flashCopied(PromptItem(id: id, content: .text(""))) }
}
.keyboardShortcut("c", modifiers: [.command, .shift])
}
.opacity(0)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
}
// MARK: - Actions
private func handleClick(_ item: PromptItem) {
if NSApp.currentEvent?.modifierFlags.contains(.command) == true {
if selection.contains(item.id) {
selection.remove(item.id)
} else {
selection.insert(item.id)
}
return
}
copy(item)
}
private func copy(_ item: PromptItem) {
store.copy(item)
flashCopied(item)
}
private func flashCopied(_ item: PromptItem) {
withAnimation { copiedID = item.id }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
if copiedID == item.id {
withAnimation { copiedID = nil }
}
}
}
private func effectiveIDs(for item: PromptItem) -> Set<UUID> {
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 = .composer
}
}
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)
}
}