diff --git a/AppDelegate.swift b/AppDelegate.swift index 60e8f760..cf0b85cd 100644 --- a/AppDelegate.swift +++ b/AppDelegate.swift @@ -7,8 +7,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var clipboardWatcher: ClipboardWatcher? private var historyWindowController: HistoryWindowController? private var hotkeyManager: HotkeyManager? + private var webLinkWindowController: WebLinkWindowController? let clipboardStore = ClipboardStore() + let webLinkStore = WebLinkStore() func applicationDidFinishLaunching(_ notification: Notification) { // Hide dock icon - we're menu bar only @@ -37,9 +39,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { watcher: clipboardWatcher!, onShowHistory: { [weak self] in self?.showHistoryWindow() + }, + onShowWebLinks: { [weak self] in + self?.showWebLinksWindow() } ) + // Initialize web link store and window controller + webLinkWindowController = WebLinkWindowController(store: webLinkStore) + // Initialize history window controller historyWindowController = HistoryWindowController(store: clipboardStore) @@ -81,4 +89,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func showHistoryWindow() { historyWindowController?.showWindow(nil) } + + private func showWebLinksWindow() { + if let window = webLinkWindowController?.window, window.isVisible { + webLinkWindowController?.close() + } else { + webLinkWindowController?.showWindow(nil) + } + } + } diff --git a/Buffer.xcodeproj/project.pbxproj b/Buffer.xcodeproj/project.pbxproj index 4b17dcbc..fb983b01 100644 --- a/Buffer.xcodeproj/project.pbxproj +++ b/Buffer.xcodeproj/project.pbxproj @@ -28,6 +28,9 @@ AAA00017 /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00019 /* UpdateService.swift */; }; D87736122F8ECC6B00392A0B /* ClickDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87736112F8ECC6B00392A0B /* ClickDetector.swift */; }; AAA00020 /* PasteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00020 /* PasteButton.swift */; }; + AAA00021 /* WebLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00021 /* WebLink.swift */; }; + AAA00022 /* WebLinkStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00022 /* WebLinkStore.swift */; }; + AAA00023 /* WebLinksWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB00023 /* WebLinksWindow.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +68,9 @@ D57AA9E1A5E3E5C6ED5E9CDC /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; D87736112F8ECC6B00392A0B /* ClickDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickDetector.swift; sourceTree = ""; }; BBB00020 /* PasteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteButton.swift; sourceTree = ""; }; + BBB00021 /* WebLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebLink.swift; sourceTree = ""; }; + BBB00022 /* WebLinkStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebLinkStore.swift; sourceTree = ""; }; + BBB00023 /* WebLinksWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebLinksWindow.swift; sourceTree = ""; }; EB5F7ACF8D07ACEDB0B7217E /* BufferTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BufferTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -141,6 +147,7 @@ isa = PBXGroup; children = ( BBB00003 /* ClipboardItem.swift */, + BBB00021 /* WebLink.swift */, ); path = Models; sourceTree = ""; @@ -155,6 +162,7 @@ BBB00015 /* SettingsManager.swift */, BBB00017 /* OCRService.swift */, BBB00019 /* UpdateService.swift */, + BBB00022 /* WebLinkStore.swift */, ); path = Services; sourceTree = ""; @@ -171,6 +179,7 @@ BBB00012 /* StatusBarController.swift */, BBB00016 /* SettingsView.swift */, BBB00020 /* PasteButton.swift */, + BBB00023 /* WebLinksWindow.swift */, ); path = Views; sourceTree = ""; @@ -305,6 +314,9 @@ AAA00016 /* TagChip.swift in Sources */, AAA00017 /* UpdateService.swift in Sources */, AAA00020 /* PasteButton.swift in Sources */, + AAA00021 /* WebLink.swift in Sources */, + AAA00022 /* WebLinkStore.swift in Sources */, + AAA00023 /* WebLinksWindow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Models/WebLink.swift b/Models/WebLink.swift new file mode 100644 index 00000000..c45e0082 --- /dev/null +++ b/Models/WebLink.swift @@ -0,0 +1,42 @@ +import Foundation + +struct WebLink: Identifiable, Codable, Equatable { + let id: UUID + var name: String + var url: String + var sortOrder: Int + var createdAt: Date + var lastOpenedAt: Date? + var visitCount: Int + + init(id: UUID = UUID(), name: String, url: String, sortOrder: Int = 0, createdAt: Date = Date(), lastOpenedAt: Date? = nil, visitCount: Int = 0) { + self.id = id + self.name = name + self.url = url + self.sortOrder = sortOrder + self.createdAt = createdAt + self.lastOpenedAt = lastOpenedAt + self.visitCount = visitCount + } + + var displayURL: String { + if url.hasPrefix("http://") || url.hasPrefix("https://") { + return String(url.dropFirst(url.hasPrefix("https://") ? 8 : 7)) + } + return url + } + + var isValidURL: Bool { + guard !url.isEmpty else { return false } + let lower = url.lowercased() + if lower.hasPrefix("http://") || lower.hasPrefix("https://") { return true } + return url.contains(".") + } + + var resolvedURL: String { + if url.lowercased().hasPrefix("http://") || url.lowercased().hasPrefix("https://") { + return url + } + return "https://" + url + } +} diff --git a/Services/WebLinkStore.swift b/Services/WebLinkStore.swift new file mode 100644 index 00000000..5f57dc70 --- /dev/null +++ b/Services/WebLinkStore.swift @@ -0,0 +1,84 @@ +import Foundation +import Combine + +class WebLinkStore: ObservableObject { + @Published var links: [WebLink] = [] + + private let fileManager = FileManager.default + private let saveQueue = DispatchQueue(label: "com.buffer.weblinks.save", qos: .utility) + + private var storageDirectory: URL { + let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appendingPathComponent("Buffer", isDirectory: true) + } + + private var fileURL: URL { + storageDirectory.appendingPathComponent("weblinks.json") + } + + init() { + ensureDirectoriesExist() + load() + } + + func add(name: String, url: String) { + let maxOrder = links.map(\.sortOrder).max() ?? 0 + let link = WebLink(name: name, url: url, sortOrder: maxOrder + 1) + DispatchQueue.main.async { [weak self] in + self?.links.append(link) + self?.persist() + } + } + + func update(_ link: WebLink) { + DispatchQueue.main.async { [weak self] in + guard let self = self, let index = self.links.firstIndex(where: { $0.id == link.id }) else { return } + self.links[index] = link + self.persist() + } + } + + func delete(_ link: WebLink) { + DispatchQueue.main.async { [weak self] in + self?.links.removeAll { $0.id == link.id } + self?.persist() + } + } + + func recordOpen(_ link: WebLink) { + DispatchQueue.main.async { [weak self] in + guard let self = self, let index = self.links.firstIndex(where: { $0.id == link.id }) else { return } + self.links[index].lastOpenedAt = Date() + self.links[index].visitCount += 1 + self.persist() + } + } + + private func ensureDirectoriesExist() { + try? fileManager.createDirectory(at: storageDirectory, withIntermediateDirectories: true) + } + + private func load() { + guard fileManager.fileExists(atPath: fileURL.path) else { return } + do { + let data = try Data(contentsOf: fileURL) + let loaded = try JSONDecoder().decode([WebLink].self, from: data) + self.links = loaded + } catch { + print("[Buffer] Failed to load web links: \(error)") + } + } + + private func persist() { + let items = links + saveQueue.async { [weak self] in + guard let self = self else { return } + do { + let data = try JSONEncoder().encode(items) + try data.write(to: self.fileURL) + } catch { + print("[Buffer] Failed to save web links: \(error)") + } + } + } +} diff --git a/Views/HistoryWindow.swift b/Views/HistoryWindow.swift index c083596e..c0e6fbb8 100644 --- a/Views/HistoryWindow.swift +++ b/Views/HistoryWindow.swift @@ -189,6 +189,7 @@ struct HistoryContentView: View { @FocusState private var isSearchFocused: Bool @State private var searchText = "" + @State private var statusBarHidden = SettingsManager.shared.hideStatusBar @State private var debouncedSearchText = "" @State private var searchDebounceTask: Task? = nil @State private var selectedIndex = 0 @@ -782,6 +783,14 @@ struct HistoryContentView: View { guard isSearchFocused, searchText.isEmpty, activeTagFilter != nil else { return false } activeTagFilter = nil return true + }, + onSelectAll: { + guard !isEditing else { return } + guard !filteredItems.isEmpty else { return } + selectedIDs = Set(filteredItems.map(\.id)) + selectionAnchor = filteredItems.first?.id + selectedID = filteredItems.first?.id + selectedIndex = 0 } )) } @@ -888,6 +897,7 @@ struct HistoryContentView: View { Text("\(filteredItems.count) items") .font(.system(size: 11, weight: .regular)) .foregroundColor(.secondary.opacity(0.6)) + } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -1561,6 +1571,20 @@ struct HistoryContentView: View { } Spacer() + + // Status bar visibility toggle + Button(action: { + statusBarHidden.toggle() + SettingsManager.shared.hideStatusBar = statusBarHidden + SettingsManager.shared.save() + NotificationCenter.default.post(name: .bufferStatusBarVisibilityChanged, object: nil) + }) { + Text(statusBarHidden ? "šŸ™ˆ" : "šŸ’") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .help(statusBarHidden ? "Show menu bar icon" : "Hide menu bar icon") + .padding(.trailing, 4) PasteButton(action: { if let item = selectedItem { onPaste(item) } }) } @@ -1705,6 +1729,7 @@ struct GlobalKeyMonitor: NSViewRepresentable { let onEdit: () -> Void let onTabComplete: () -> Void let onBackspace: () -> Bool + let onSelectAll: () -> Void func makeNSView(context: Context) -> NSView { let view = NSView() @@ -1800,6 +1825,13 @@ struct GlobalKeyMonitor: NSViewRepresentable { if isEditing { return event } context.coordinator.onTabComplete?() return nil + case 0: // A + if event.modifierFlags.contains(.command) { + if isEditing { return event } + context.coordinator.onSelectAll?() + return nil + } + return event default: return event } @@ -1827,6 +1859,7 @@ struct GlobalKeyMonitor: NSViewRepresentable { context.coordinator.onEdit = onEdit context.coordinator.onTabComplete = onTabComplete context.coordinator.onBackspace = onBackspace + context.coordinator.onSelectAll = onSelectAll } func makeCoordinator() -> Coordinator { @@ -1851,6 +1884,7 @@ struct GlobalKeyMonitor: NSViewRepresentable { var onEdit: (() -> Void)? var onTabComplete: (() -> Void)? var onBackspace: (() -> Bool)? + var onSelectAll: (() -> Void)? deinit { if let monitor = monitor { diff --git a/Views/StatusBarController.swift b/Views/StatusBarController.swift index 6b5d1756..99656167 100644 --- a/Views/StatusBarController.swift +++ b/Views/StatusBarController.swift @@ -7,13 +7,15 @@ class StatusBarController { private let store: ClipboardStore private let watcher: ClipboardWatcher private let onToggleHistory: () -> Void + private let onShowWebLinks: () -> Void private var settingsWindowController: NSWindowController? private var activeAlert: NSAlert? - init(store: ClipboardStore, watcher: ClipboardWatcher, onShowHistory: @escaping () -> Void) { + init(store: ClipboardStore, watcher: ClipboardWatcher, onShowHistory: @escaping () -> Void, onShowWebLinks: @escaping () -> Void) { self.store = store self.watcher = watcher self.onToggleHistory = onShowHistory + self.onShowWebLinks = onShowWebLinks if !SettingsManager.shared.hideStatusBar { createStatusItem() @@ -94,6 +96,13 @@ class StatusBarController { pauseItem.target = self menu.addItem(pauseItem) + menu.addItem(NSMenuItem.separator()) + + // Quick Web Links + let webLinksItem = NSMenuItem(title: "Quick Web Links…", action: #selector(showWebLinks), keyEquivalent: "u") + webLinksItem.target = self + menu.addItem(webLinksItem) + menu.addItem(NSMenuItem.separator()) // Clear History @@ -134,6 +143,10 @@ class StatusBarController { NSApp.activate(ignoringOtherApps: true) } + @objc private func showWebLinks() { + onShowWebLinks() + } + @objc private func togglePause() { if watcher.isPaused { watcher.resume() diff --git a/Views/WebLinksWindow.swift b/Views/WebLinksWindow.swift new file mode 100644 index 00000000..f4bc7c4b --- /dev/null +++ b/Views/WebLinksWindow.swift @@ -0,0 +1,439 @@ +import Cocoa +import SwiftUI + +class WebLinksPanel: NSPanel { + var onClickOutside: (() -> Void)? + + override var canBecomeKey: Bool { true } + + override func resignKey() { + super.resignKey() + onClickOutside?() + } +} + +class WebLinkWindowController: NSWindowController { + private let store: WebLinkStore + + init(store: WebLinkStore) { + self.store = store + + let panel = WebLinksPanel( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 420), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + super.init(window: panel) + + panel.onClickOutside = { [weak self] in + self?.close() + } + + setupPanel(panel) + setupContent() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupPanel(_ panel: NSPanel) { + panel.level = .floating + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = false + panel.hidesOnDeactivate = false + + panel.titlebarAppearsTransparent = true + panel.titleVisibility = .hidden + panel.backgroundColor = NSColor.windowBackgroundColor + panel.isMovableByWindowBackground = true + panel.hasShadow = true + + panel.contentView?.wantsLayer = true + panel.contentView?.layer?.cornerRadius = 10 + panel.contentView?.layer?.masksToBounds = true + + panel.center() + + panel.standardWindowButton(.closeButton)?.isHidden = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + } + + private func setupContent() { + let contentView = WebLinksContentView(store: store, onDismiss: { [weak self] in + self?.close() + }) + window?.contentView = NSHostingView(rootView: contentView) + } + + override func showWindow(_ sender: Any?) { + window?.center() + super.showWindow(sender) + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + window?.makeFirstResponder(window?.contentView) + } +} + +struct WebLinksContentView: View { + @ObservedObject var store: WebLinkStore + let onDismiss: () -> Void + + @State private var searchText = "" + @State private var debouncedSearchText = "" + @State private var selectedIndex = 0 + @FocusState private var isSearchFocused: Bool + @State private var scrollTrigger = false + + @State private var editingLinkID: UUID? + @State private var editName = "" + @State private var editURL = "" + + @State private var showAddRow = false + @State private var addName = "" + @State private var addURL = "" + + private var filteredLinks: [WebLink] { + let query = debouncedSearchText.trimmingCharacters(in: .whitespaces).lowercased() + if query.isEmpty { return store.links } + return store.links.filter { + $0.name.lowercased().contains(query) || $0.url.lowercased().contains(query) + } + } + + var body: some View { + VStack(spacing: 0) { + searchBar + + Divider() + + if filteredLinks.isEmpty { + emptyState + } else { + linkList + } + + Divider() + + actionBar + } + .frame(minWidth: 380, minHeight: 320) + .background(Color(NSColor.windowBackgroundColor)) + .onChange(of: searchText) { newValue in + let query = newValue.trimmingCharacters(in: .whitespaces).lowercased() + if query.isEmpty { + debouncedSearchText = query + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + if self.searchText == newValue { + self.debouncedSearchText = newValue + } + } + } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + isSearchFocused = true + } + } + } + + private var searchBar: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary.opacity(0.7)) + .font(.system(size: 13, weight: .medium)) + + TextField("Search web links…", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused($isSearchFocused) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary.opacity(0.5)) + .font(.system(size: 12)) + } + .buttonStyle(.plain) + } + + Spacer() + + Text("\(store.links.count) link\(store.links.count == 1 ? "" : "s")") + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.6)) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Color(NSColor.controlBackgroundColor) + .overlay( + Rectangle() + .frame(height: 0.5) + .foregroundColor(Color.primary.opacity(0.15)), + alignment: .bottom + ) + ) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Spacer() + if store.links.isEmpty { + Image(systemName: "globe") + .font(.system(size: 32)) + .foregroundColor(.secondary.opacity(0.3)) + Text("No web links yet") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.secondary) + Text("Click \"Add Link\" to get started") + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.6)) + } else { + Text("No matches") + .foregroundColor(.secondary) + } + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var linkList: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: true) { + LazyVStack(spacing: 0) { + ForEach(Array(filteredLinks.enumerated()), id: \.element.id) { index, link in + WebLinkRowView( + link: link, + isSelected: index == selectedIndex, + isEditing: editingLinkID == link.id, + editName: $editName, + editURL: $editURL, + onEdit: { startEditing(link) }, + onDelete: { store.delete(link) }, + onSaveEdit: { saveEdit(link) }, + onCancelEdit: { cancelEditing() } + ) + .id(link.id) + .contentShape(Rectangle()) + .onTapGesture { + selectedIndex = index + if editingLinkID != nil { cancelEditing() } + openLink(link) + } + } + } + .padding(.horizontal, 4) + .padding(.vertical, 2) + } + .onChange(of: selectedIndex) { newValue in + if scrollTrigger, let item = filteredLinks[safe: newValue] { + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(item.id) + } + scrollTrigger = false + } + } + } + } + + private var actionBar: some View { + HStack(spacing: 12) { + if showAddRow { + HStack(spacing: 6) { + TextField("Name", text: $addName) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .frame(width: 100) + TextField("URL", text: $addURL) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .frame(width: 160) + Button("Save") { + saveNew() + } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.accentColor) + Button("Cancel") { + cancelNew() + } + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + .padding(.leading, 4) + } else { + Button(action: { showAddRow = true }) { + HStack(spacing: 4) { + Image(systemName: "plus") + .font(.system(size: 10, weight: .bold)) + Text("Add Link") + .font(.system(size: 11, weight: .medium)) + } + } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + } + + Spacer() + + if !store.links.isEmpty { + Text("\(filteredLinks.count) of \(store.links.count)") + .font(.system(size: 10)) + .foregroundColor(.secondary.opacity(0.4)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Color(NSColor.controlBackgroundColor) + .overlay( + Rectangle() + .frame(height: 0.5) + .foregroundColor(Color.primary.opacity(0.15)), + alignment: .top + ) + ) + } + + private func openLink(_ link: WebLink) { + guard let url = URL(string: link.resolvedURL) else { return } + NSWorkspace.shared.open(url) + store.recordOpen(link) + } + + private func startEditing(_ link: WebLink) { + editingLinkID = link.id + editName = link.name + editURL = link.url + } + + private func saveEdit(_ link: WebLink) { + let trimmedName = editName.trimmingCharacters(in: .whitespaces) + let trimmedURL = editURL.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty, !trimmedURL.isEmpty else { return } + var updated = link + updated.name = trimmedName + updated.url = trimmedURL + store.update(updated) + editingLinkID = nil + } + + private func cancelEditing() { + editingLinkID = nil + editName = "" + editURL = "" + } + + private func saveNew() { + let trimmedName = addName.trimmingCharacters(in: .whitespaces) + let trimmedURL = addURL.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty, !trimmedURL.isEmpty else { return } + store.add(name: trimmedName, url: trimmedURL) + addName = "" + addURL = "" + showAddRow = false + selectedIndex = 0 + } + + private func cancelNew() { + addName = "" + addURL = "" + showAddRow = false + } +} + +struct WebLinkRowView: View { + let link: WebLink + let isSelected: Bool + let isEditing: Bool + @Binding var editName: String + @Binding var editURL: String + let onEdit: () -> Void + let onDelete: () -> Void + let onSaveEdit: () -> Void + let onCancelEdit: () -> Void + + @State private var isHovered = false + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "globe") + .font(.system(size: 14)) + .foregroundColor(.accentColor.opacity(0.7)) + .frame(width: 20) + + if isEditing { + TextField("Name", text: $editName) + .textFieldStyle(.plain) + .font(.system(size: 12, weight: .medium)) + .frame(width: 120) + TextField("URL", text: $editURL) + .textFieldStyle(.plain) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .frame(width: 140) + Button("Save") { onSaveEdit() } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.accentColor) + Button("Cancel") { onCancelEdit() } + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } else { + Text(link.name) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + + Text(link.displayURL) + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.7)) + .lineLimit(1) + + if link.visitCount > 0 { + Text("•") + .font(.system(size: 8)) + .foregroundColor(.secondary.opacity(0.3)) + Text("\(link.visitCount)") + .font(.system(size: 10)) + .foregroundColor(.secondary.opacity(0.4)) + } + } + + Spacer(minLength: 0) + + if isHovered && !isEditing { + HStack(spacing: 8) { + Button(action: onEdit) { + Image(systemName: "pencil") + .font(.system(size: 10)) + } + .buttonStyle(.plain) + .foregroundColor(.secondary.opacity(0.6)) + .help("Edit") + + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: 10)) + } + .buttonStyle(.plain) + .foregroundColor(.secondary.opacity(0.6)) + .help("Delete") + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(isSelected ? Color.accentColor.opacity(0.2) : (isHovered ? Color.primary.opacity(0.05) : Color.clear)) + .cornerRadius(4) + .onHover { hovering in + isHovered = hovering + } + } +}