Feature: Favourites

This commit is contained in:
Hariharan Mudaliar 2025-08-12 14:52:03 +05:30
commit 5321352184
7 changed files with 627 additions and 32 deletions

View file

@ -445,38 +445,45 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Only open clipboard if the feature is enabled
guard Defaults[.enableClipboardManager] else { return }
// Find the appropriate view model based on mouse location
let mouseLocation = NSEvent.mouseLocation
var viewModel = self.vm
if Defaults[.showOnAllDisplays] {
for screen in NSScreen.screens {
if screen.frame.contains(mouseLocation) {
if let screenViewModel = self.viewModels[screen] {
viewModel = screenViewModel
break
}
}
}
}
// Open the notch if it's closed
if viewModel.notchState == .closed {
viewModel.open()
// Wait a bit for the notch to open before toggling clipboard
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.coordinator.toggleClipboardPopover()
}
} else {
// If notch is already open, toggle immediately
self.coordinator.toggleClipboardPopover()
}
// Start clipboard monitoring if not already running
if !ClipboardManager.shared.isMonitoring {
ClipboardManager.shared.startMonitoring()
}
// Check if window mode is enabled
if Defaults[.clipboardWindowMode] {
// Use window interface
ClipboardWindowManager.shared.toggleClipboardWindow()
} else {
// Use traditional popover interface
// Find the appropriate view model based on mouse location
let mouseLocation = NSEvent.mouseLocation
var viewModel = self.vm
if Defaults[.showOnAllDisplays] {
for screen in NSScreen.screens {
if screen.frame.contains(mouseLocation) {
if let screenViewModel = self.viewModels[screen] {
viewModel = screenViewModel
break
}
}
}
}
// Open the notch if it's closed
if viewModel.notchState == .closed {
viewModel.open()
// Wait a bit for the notch to open before toggling clipboard
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.coordinator.toggleClipboardPopover()
}
} else {
// If notch is already open, toggle immediately
self.coordinator.toggleClipboardPopover()
}
}
}
if !Defaults[.showOnAllDisplays] {

View file

@ -271,9 +271,15 @@
},
"Clear Clipboard History" : {
},
"Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." : {
},
"Clear Data" : {
},
"Clear Pinned Items" : {
},
"Clipboard" : {
@ -783,9 +789,15 @@
},
"No extension installed" : {
},
"No favorites" : {
},
"No Graphs Enabled" : {
},
"No results found" : {
},
"Non-notch display height" : {
@ -852,6 +864,12 @@
}
}
}
},
"Pin items from history to save them here" : {
},
"Pinned Items" : {
},
"Plugged In" : {
@ -920,6 +938,9 @@
},
"Running" : {
},
"Search clipboard..." : {
},
"seconds" : {
"extractionState" : "stale",
@ -1083,15 +1104,9 @@
},
"System features" : {
},
"The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : {
},
"TheDynamicIsland" : {
},
"This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header." : {
},
"Time to Full Charge: %lld min" : {
@ -1122,6 +1137,9 @@
},
"Transform your notch truly yours" : {
},
"Try adjusting your search terms" : {
},
"Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" : {
@ -1178,6 +1196,9 @@
},
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
},
"Window Mode shows clipboard in a moveable window like Raycast with tabs for history and favorites. When disabled, clipboard appears in a popover attached to the Dynamic Island." : {
},
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {

View file

@ -0,0 +1,363 @@
//
// ClipboardWindow.swift
// DynamicIsland
//
// Created by GitHub Copilot on 12/08/25.
//
import SwiftUI
import Defaults
enum ClipboardTab: String, CaseIterable {
case history = "History"
case favorites = "Favorites"
var icon: String {
switch self {
case .history: return "clock"
case .favorites: return "heart.fill"
}
}
}
struct ClipboardWindow: View {
@ObservedObject var clipboardManager = ClipboardManager.shared
@State private var selectedTab: ClipboardTab = .history
@State private var searchText = ""
var body: some View {
VStack(spacing: 0) {
// Header with tabs and search
ClipboardWindowHeader(selectedTab: $selectedTab, searchText: $searchText)
Divider()
.background(Color.gray.opacity(0.3))
// Content area
ClipboardWindowContent(selectedTab: selectedTab, searchText: searchText)
}
.frame(width: 400, height: 300)
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(12)
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
}
}
struct ClipboardWindowHeader: View {
@Binding var selectedTab: ClipboardTab
@Binding var searchText: String
@ObservedObject var clipboardManager = ClipboardManager.shared
var body: some View {
VStack(spacing: 0) {
// Title and controls
HStack {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.primary)
.font(.system(size: 16, weight: .medium))
Text("Clipboard Manager")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.primary)
Spacer()
// Clear button
Button(action: {
if selectedTab == .history {
clipboardManager.clearHistory()
} else {
// Clear favorites
clipboardManager.pinnedItems.removeAll()
clipboardManager.savePinnedItemsToDefaults()
}
}) {
Image(systemName: "trash")
.foregroundColor(.red)
.font(.system(size: 12))
}
.buttonStyle(PlainButtonStyle())
.disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
// Tab selector
HStack(spacing: 0) {
ForEach(ClipboardTab.allCases, id: \.self) { tab in
ClipboardTabButton(tab: tab, isSelected: selectedTab == tab) {
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = tab
}
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.bottom, 8)
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.font(.system(size: 12))
TextField("Search clipboard...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
.font(.system(size: 12))
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
.font(.system(size: 10))
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
}
}
struct ClipboardTabButton: View {
let tab: ClipboardTab
let isSelected: Bool
let action: () -> Void
@ObservedObject var clipboardManager = ClipboardManager.shared
var itemCount: Int {
switch tab {
case .history:
return clipboardManager.regularHistory.count
case .favorites:
return clipboardManager.pinnedItems.count
}
}
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Image(systemName: tab.icon)
.font(.system(size: 11))
Text(tab.rawValue)
.font(.system(size: 11, weight: .medium))
if itemCount > 0 {
Text("\(itemCount)")
.font(.system(size: 9, weight: .bold))
.foregroundColor(isSelected ? .white : .secondary)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(
Capsule()
.fill(isSelected ? Color.white.opacity(0.3) : Color.gray.opacity(0.2))
)
}
}
.foregroundColor(isSelected ? .white : .secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isSelected ? Color.blue : Color.clear)
)
}
.buttonStyle(PlainButtonStyle())
}
}
struct ClipboardWindowContent: View {
let selectedTab: ClipboardTab
let searchText: String
@ObservedObject var clipboardManager = ClipboardManager.shared
var filteredItems: [ClipboardItem] {
let items = selectedTab == .history ? clipboardManager.regularHistory : clipboardManager.pinnedItems
if searchText.isEmpty {
return items
} else {
return items.filter { item in
item.preview.localizedCaseInsensitiveContains(searchText) ||
item.type.displayName.localizedCaseInsensitiveContains(searchText)
}
}
}
var body: some View {
if filteredItems.isEmpty {
ClipboardEmptyState(tab: selectedTab, hasSearch: !searchText.isEmpty)
} else {
ScrollView {
LazyVStack(spacing: 1) {
ForEach(filteredItems) { item in
ClipboardWindowItemRow(item: item, tab: selectedTab)
}
}
.padding(.vertical, 8)
}
}
}
}
struct ClipboardEmptyState: View {
let tab: ClipboardTab
let hasSearch: Bool
var body: some View {
VStack(spacing: 12) {
Image(systemName: hasSearch ? "magnifyingglass" : tab.icon)
.font(.system(size: 32))
.foregroundColor(.secondary)
if hasSearch {
Text("No results found")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
Text("Try adjusting your search terms")
.font(.system(size: 12))
.foregroundColor(.secondary)
} else {
Text(tab == .history ? "No clipboard history" : "No favorites")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.primary)
Text(tab == .history ? "Copy something to get started" : "Pin items from history to save them here")
.font(.system(size: 12))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ClipboardWindowItemRow: View {
let item: ClipboardItem
let tab: ClipboardTab
@ObservedObject var clipboardManager = ClipboardManager.shared
@State private var isHovering = false
var isPinned: Bool {
item.isPinned || clipboardManager.pinnedItems.contains(where: { $0.id == item.id })
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Type icon
Image(systemName: item.type.icon)
.font(.system(size: 14))
.foregroundColor(.blue)
.frame(width: 20)
// Content
VStack(alignment: .leading, spacing: 4) {
Text(item.preview)
.font(.system(size: 12))
.foregroundColor(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)
HStack {
Text(item.type.displayName)
.font(.system(size: 10))
.foregroundColor(.secondary)
Spacer()
Text(timeAgoString(from: item.timestamp))
.font(.system(size: 10))
.foregroundColor(.secondary)
}
}
Spacer()
// Action buttons (shown on hover)
if isHovering {
HStack(spacing: 6) {
// Pin/Unpin button
Button(action: {
clipboardManager.togglePin(for: item)
}) {
Image(systemName: isPinned ? "heart.fill" : "heart")
.font(.system(size: 11))
.foregroundColor(isPinned ? .red : .gray)
}
.buttonStyle(PlainButtonStyle())
// Copy button
Button(action: {
clipboardManager.copyToClipboard(item)
}) {
Image(systemName: "doc.on.doc")
.font(.system(size: 11))
.foregroundColor(.green)
}
.buttonStyle(PlainButtonStyle())
// Delete button
Button(action: {
if isPinned {
clipboardManager.unpinItem(item)
} else {
clipboardManager.deleteItem(item)
}
}) {
Image(systemName: "trash")
.font(.system(size: 11))
.foregroundColor(.red)
}
.buttonStyle(PlainButtonStyle())
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovering ? Color.gray.opacity(0.1) : Color.clear)
)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
.onTapGesture {
clipboardManager.copyToClipboard(item)
}
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}
#Preview {
ClipboardWindow()
.frame(width: 400, height: 300)
.background(Color.gray.opacity(0.3))
}

View file

@ -1589,6 +1589,7 @@ struct ClipboardSettings: View {
@Default(.enableClipboardManager) var enableClipboardManager
@Default(.clipboardHistorySize) var clipboardHistorySize
@Default(.showClipboardIcon) var showClipboardIcon
@Default(.clipboardWindowMode) var clipboardWindowMode
var body: some View {
Form {
@ -1611,6 +1612,8 @@ struct ClipboardSettings: View {
Section {
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
Defaults.Toggle("Window Mode", key: .clipboardWindowMode)
HStack {
Text("History Size")
Spacer()
@ -1631,6 +1634,13 @@ struct ClipboardSettings: View {
.foregroundColor(.secondary)
}
HStack {
Text("Pinned Items")
Spacer()
Text("\(clipboardManager.pinnedItems.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Monitoring Status")
Spacer()
@ -1640,7 +1650,7 @@ struct ClipboardSettings: View {
} header: {
Text("Settings")
} footer: {
Text("The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history.")
Text("Window Mode shows clipboard in a moveable window like Raycast with tabs for history and favorites. When disabled, clipboard appears in a popover attached to the Dynamic Island.")
}
Section {
@ -1649,10 +1659,17 @@ struct ClipboardSettings: View {
}
.foregroundColor(.red)
.disabled(clipboardManager.clipboardHistory.isEmpty)
Button("Clear Pinned Items") {
clipboardManager.pinnedItems.removeAll()
clipboardManager.savePinnedItemsToDefaults()
}
.foregroundColor(.red)
.disabled(clipboardManager.pinnedItems.isEmpty)
} header: {
Text("Actions")
} footer: {
Text("This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header.")
Text("Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent.")
}
if !clipboardManager.clipboardHistory.isEmpty {

View file

@ -18,6 +18,7 @@ struct ClipboardItem: Identifiable, Codable {
let type: ClipboardItemType
let timestamp: Date
let preview: String
var isPinned: Bool = false
// Store different types of data - avoid large binary data in UserDefaults
let stringData: String?
@ -90,6 +91,14 @@ struct ClipboardItem: Identifiable, Codable {
return try? Data(contentsOf: fileURL)
}
// Helper to check if this item has the same content as another
func isSameContent(as other: ClipboardItem) -> Bool {
return stringData == other.stringData &&
imageFileName == other.imageFileName &&
fileURLs == other.fileURLs &&
type == other.type
}
static func generatePreview(stringData: String, type: ClipboardItemType) -> String {
switch type {
case .text:
@ -149,6 +158,7 @@ class ClipboardManager: ObservableObject {
static let shared = ClipboardManager()
@Published var clipboardHistory: [ClipboardItem] = []
@Published var pinnedItems: [ClipboardItem] = []
@Published var isMonitoring: Bool = false
private var timer: Timer?
@ -159,6 +169,15 @@ class ClipboardManager: ObservableObject {
return Defaults[.clipboardHistorySize]
}
// Computed properties for filtered lists
var regularHistory: [ClipboardItem] {
clipboardHistory.filter { !$0.isPinned }
}
var pinnedHistory: [ClipboardItem] {
pinnedItems
}
// Directory for storing clipboard data files
static let clipboardDataDirectory: URL = {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@ -254,6 +273,58 @@ class ClipboardManager: ObservableObject {
saveHistoryToDefaults()
}
func pinItem(_ item: ClipboardItem) {
// Update the item to be pinned
var pinnedItem = item
pinnedItem.isPinned = true
// Remove from regular history if it exists there
clipboardHistory.removeAll { $0.id == item.id }
// Add to pinned items if not already there
if !pinnedItems.contains(where: { $0.id == item.id }) {
pinnedItems.append(pinnedItem)
}
saveHistoryToDefaults()
savePinnedItemsToDefaults()
}
func unpinItem(_ item: ClipboardItem) {
// Remove from pinned items
pinnedItems.removeAll { $0.id == item.id }
// Update the item to be unpinned and add back to regular history
var unpinnedItem = item
unpinnedItem.isPinned = false
// Add back to regular history at the top
clipboardHistory.insert(unpinnedItem, at: 0)
// Maintain history size limit
if clipboardHistory.count > maxHistoryItems {
let itemsToDelete = Array(clipboardHistory.dropFirst(maxHistoryItems))
for oldItem in itemsToDelete {
if let fileName = oldItem.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
clipboardHistory = Array(clipboardHistory.prefix(maxHistoryItems))
}
saveHistoryToDefaults()
savePinnedItemsToDefaults()
}
func togglePin(for item: ClipboardItem) {
if item.isPinned || pinnedItems.contains(where: { $0.id == item.id }) {
unpinItem(item)
} else {
pinItem(item)
}
}
// MARK: - Private Methods
private func checkClipboard() {
@ -438,10 +509,21 @@ class ClipboardManager: ObservableObject {
}
}
func savePinnedItemsToDefaults() {
if let encoded = try? JSONEncoder().encode(pinnedItems) {
UserDefaults.standard.set(encoded, forKey: "ClipboardPinnedItems")
}
}
private func loadHistoryFromDefaults() {
if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"),
let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
clipboardHistory = history
}
if let data = UserDefaults.standard.data(forKey: "ClipboardPinnedItems"),
let pinned = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
pinnedItems = pinned
}
}
}

View file

@ -0,0 +1,104 @@
//
// ClipboardWindowManager.swift
// DynamicIsland
//
// Created by GitHub Copilot on 12/08/25.
//
import SwiftUI
import AppKit
class ClipboardWindowManager: ObservableObject {
static let shared = ClipboardWindowManager()
private var clipboardWindow: NSWindow?
private init() {}
func showClipboardWindow() {
if let existingWindow = clipboardWindow {
existingWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
// Create the window
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Clipboard Manager"
window.isMovableByWindowBackground = true
window.level = .floating
window.isReleasedWhenClosed = false
window.hasShadow = true
window.titlebarAppearsTransparent = true
window.styleMask.insert(.fullSizeContentView)
// Set minimum and maximum sizes
window.minSize = NSSize(width: 350, height: 250)
window.maxSize = NSSize(width: 600, height: 500)
// Center the window
if let screen = NSScreen.main {
let screenFrame = screen.visibleFrame
let windowFrame = window.frame
let x = (screenFrame.width - windowFrame.width) / 2 + screenFrame.minX
let y = (screenFrame.height - windowFrame.height) / 2 + screenFrame.minY
window.setFrameOrigin(NSPoint(x: x, y: y))
}
// Set the content view
let contentView = ClipboardWindow()
let hostingView = NSHostingView(rootView: contentView)
window.contentView = hostingView
// Handle window closing
window.delegate = WindowDelegate { [weak self] in
self?.clipboardWindow = nil
}
self.clipboardWindow = window
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
func hideClipboardWindow() {
clipboardWindow?.close()
}
func toggleClipboardWindow() {
if let window = clipboardWindow, window.isVisible {
hideClipboardWindow()
} else {
showClipboardWindow()
}
}
var isWindowVisible: Bool {
return clipboardWindow?.isVisible ?? false
}
}
private class WindowDelegate: NSObject, NSWindowDelegate {
private let onClose: () -> Void
init(onClose: @escaping () -> Void) {
self.onClose = onClose
super.init()
}
func windowWillClose(_ notification: Notification) {
onClose()
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
// Hide instead of close when user clicks close button
sender.orderOut(nil)
onClose()
return false
}
}

View file

@ -170,6 +170,7 @@ extension Defaults.Keys {
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
static let showClipboardIcon = Key<Bool>("showClipboardIcon", default: true)
static let clipboardWindowMode = Key<Bool>("clipboardWindowMode", default: true)
// MARK: Keyboard Shortcuts
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)