Files
prompt/Sources/Prompt/PanelView.swift
T
TZGyn 90890186c0 Tell the user when Accessibility is missing
Without it the Shift-Shift gestures fail silently, so hiding the panel
left no way to bring it back and the app looked broken. The app now says
so on launch and points at Cmd+Shift+Space, which needs no permission,
and --diag reports the permission state alongside the status item's
placement so a lockout can be diagnosed instead of guessed at.
2026-10-08 10:45:50 +08:00

603 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("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
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 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("") {
withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() }
}
.keyboardShortcut(",", 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)
}
}