Files
prompt/Sources/Prompt/PanelView.swift
T
TZGyn 9907db373c Add Copy & Delete to the context menu
Copies the item (or the selection, as plain lines) and removes it in one
step — the 'take this out' gesture. No shortcut: Cmd+X belongs to the text
fields, and a panel binding would steal it.
2026-10-27 21:31:30 +08:00

955 lines
36 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 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<UUID>()
@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)
}
Button(multiple ? "Copy & Delete \(ids.count) Items" : "Copy & Delete") {
copyAndDelete(item)
}
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
private var trimmedWorkspaceName: String {
newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Case-insensitive, like the store's dedupe. The modal blocks creation
/// with a hint instead of silently switching to the existing workspace.
private var workspaceNameExists: Bool {
let name = trimmedWorkspaceName
return !name.isEmpty && store.sections.contains {
$0.name.caseInsensitiveCompare(name) == .orderedSame
}
}
@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() }
if workspaceNameExists {
Text("A workspace named “\(trimmedWorkspaceName)” already exists.")
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel") { dismissNewWorkspace() }
Button("Create") { createWorkspace() }
.keyboardShortcut(.defaultAction)
.disabled(trimmedWorkspaceName.isEmpty || workspaceNameExists)
}
}
.padding(16)
.frame(width: 260)
.background(.regularMaterial)
.cornerRadius(12)
.shadow(radius: 18)
}
.transition(.opacity)
}
}
private func createWorkspace() {
let name = trimmedWorkspaceName
guard !name.isEmpty, !workspaceNameExists 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)
}
/// One gesture for "take this out": copy, then delete. A multi-selection
/// is copied as plain lines, same as Copy as Lines. No shortcut — Cmd+X
/// belongs to the composer/editor text fields, and a panel binding would
/// steal Cut from them.
private func copyAndDelete(_ item: PromptItem) {
let ids = effectiveIDs(for: item)
if ids.count > 1 {
store.copyAsLines(ids: ids)
} else {
store.copy(item)
}
store.delete(ids: ids)
selection.subtract(ids)
lastSelectedID = nil
}
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<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 = 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)
}
}