diff --git a/Sources/ManagedFavsGenerator/AppKitTextField.swift b/Sources/ManagedFavsGenerator/AppKitTextField.swift index 694310c..2dfa1f4 100644 --- a/Sources/ManagedFavsGenerator/AppKitTextField.swift +++ b/Sources/ManagedFavsGenerator/AppKitTextField.swift @@ -43,6 +43,14 @@ struct AppKitTextField: NSViewRepresentable { // KRITISCH: TextField muss explizit First Responder werden können textField.refusesFirstResponder = false + // WICHTIG: Komplett deaktiviere Drag & Drop für dieses TextField + textField.unregisterDraggedTypes() + + // Disable text field's cell from accepting drops + if let cell = textField.cell as? NSTextFieldCell { + cell.isScrollable = true + } + return textField } @@ -96,15 +104,43 @@ struct AppKitTextField: NSViewRepresentable { /// - Explizites `acceptsFirstResponder = true` /// - Automatische Window-Aktivierung beim Focus-Wechsel /// - Window-Aktivierung beim Hinzufügen zur View-Hierarchie +/// - Blockiert Drag & Drop Operations komplett durch eigenen Field Editor private class KeyboardReceivingTextField: NSTextField { override var acceptsFirstResponder: Bool { true } override var canBecomeKeyView: Bool { true } + // Custom field editor that never accepts drops + private lazy var customFieldEditor: NoDragTextView = { + let textContainer = NSTextContainer() + let editor = NoDragTextView(frame: .zero, textContainer: textContainer) + editor.isFieldEditor = true + editor.isRichText = false + editor.importsGraphics = false + editor.allowsUndo = true + editor.backgroundColor = .clear + editor.drawsBackground = false + return editor + }() + /// Stellt sicher, dass das Window key wird, wenn das TextField First Responder wird override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() + // Get or create the window's field editor and disable drag & drop + if let window = self.window, + let fieldEditor = window.fieldEditor(true, for: self) as? NSTextView { + fieldEditor.unregisterDraggedTypes() + + // Permanently block re-registration + DispatchQueue.main.async { + fieldEditor.unregisterDraggedTypes() + } + + // Schedule periodic unregistration + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + fieldEditor.unregisterDraggedTypes() + } + } - // Sicherstellen, dass das Window auch key ist + let result = super.becomeFirstResponder() self.window?.makeKeyAndOrderFront(nil) return result @@ -122,4 +158,87 @@ private class KeyboardReceivingTextField: NSTextField { } } } + + + // MARK: - Drag & Drop Protection + + /// Blockiert alle Drag Operations - kein visuelles Feedback + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] // Reject all drags - no cursor feedback + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] // Reject all drags - no cursor feedback + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false // Never accept drops + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false // Don't even prepare + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + // Do nothing - ignore exit + } + + override func wantsPeriodicDraggingUpdates() -> Bool { + return false // No periodic updates needed + } +} + +/// Custom NSTextView that completely disables drag & drop +private class NoDragTextView: NSTextView { + + override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { + super.init(frame: frameRect, textContainer: container) + setupNoDrag() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupNoDrag() + } + + private func setupNoDrag() { + // Never register any drag types + self.unregisterDraggedTypes() + + // Disable additional drag features + self.allowsImageEditing = false + self.isAutomaticQuoteSubstitutionEnabled = false + self.isAutomaticLinkDetectionEnabled = false + } + + // MARK: - Block all drag operations + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + return [] + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + return false + } + + override func wantsPeriodicDraggingUpdates() -> Bool { + return false + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + // Do nothing + } + + // Prevent registering drag types + override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) { + // Do nothing - never register drag types + } } diff --git a/Sources/ManagedFavsGenerator/ContentView.swift b/Sources/ManagedFavsGenerator/ContentView.swift index 5f72369..9b9c6c3 100644 --- a/Sources/ManagedFavsGenerator/ContentView.swift +++ b/Sources/ManagedFavsGenerator/ContentView.swift @@ -9,6 +9,39 @@ struct ContentView: View { @State private var viewModel = FavoritesViewModel() @Environment(\.openWindow) private var openWindow + /// Root level items (no parent) + private var rootLevelItems: [Favorite] { + favorites.filter { $0.parentID == nil }.sorted { $0.order < $1.order } + } + + /// Get children of a folder + private func childrenOf(_ folder: Favorite) -> [Favorite] { + favorites.filter { $0.parentID == folder.id }.sorted { $0.order < $1.order } + } + + /// Handle drop operation + private func handleDrop(droppedIds: [String], toParent parentID: UUID?, atIndex index: Int) { + guard let droppedId = droppedIds.first, + let droppedUUID = UUID(uuidString: droppedId), + let favorite = favorites.first(where: { $0.id == droppedUUID }) else { + return + } + + // Don't allow dropping a folder into itself + if let parentID = parentID, parentID == favorite.id { + return + } + + // Don't allow dropping folders into other folders (only 1 level deep) + if favorite.isFolder && parentID != nil { + return + } + + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.moveFavorite(favorite, toParent: parentID, atIndex: index, allFavorites: favorites) + } + } + var body: some View { ZStack { // Hidden helper view um First Responder zu aktivieren @@ -40,6 +73,17 @@ struct ContentView: View { .keyboardShortcut("n", modifiers: [.command]) .help("Add a new favorite (⌘N)") + // Add Folder + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.addFolder() + } + } label: { + Label("Add Folder", systemImage: "folder.badge.plus") + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + .help("Add a new folder (⌘⇧N)") + Divider() // Copy JSON @@ -101,37 +145,113 @@ struct ContentView: View { private var inputSection: some View { VStack(alignment: .leading, spacing: 16) { - // Toplevel Name - VStack(alignment: .leading, spacing: 8) { - Text("Toplevel Name") - .font(.headline) - AppKitTextField( - text: $viewModel.toplevelName, - placeholder: "e.g., managedFavs" - ) - .frame(height: 22) - } - - Divider() - // Favorites List - VStack(alignment: .leading, spacing: 8) { - Text("Favorites") - .font(.headline) + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + Text("Favorites") + .font(.title2) + .fontWeight(.bold) + Text("Add and organize your favorites") + .font(.subheadline) + .foregroundStyle(.secondary) + } ScrollView { VStack(spacing: 12) { - ForEach(favorites) { favorite in - FavoriteRowView( - favorite: favorite, - onRemove: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - viewModel.removeFavorite(favorite) + // Drop zone BEFORE first root item (always visible) + Color.clear + .frame(height: 20) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: nil, atIndex: 0) + return true + } + + // Root level items (no parent) + ForEach(Array(rootLevelItems.enumerated()), id: \.element.id) { index, item in + if item.isFolder { + // Folder with children + VStack(spacing: 0) { + DisclosureGroup { + VStack(spacing: 12) { + // Drop zone at START of folder (for first position) + Color.clear + .frame(height: 20) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: item.id, atIndex: 0) + return true + } + + ForEach(Array(childrenOf(item).enumerated()), id: \.element.id) { childIndex, child in + FavoriteRowView( + favorite: child, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(child) + } + } + ) + .padding(.leading, 16) + .transition(.scale.combined(with: .opacity)) + .dropDestination(for: String.self) { droppedIds, location in + // Drop AFTER this child + handleDrop(droppedIds: droppedIds, toParent: item.id, atIndex: childIndex + 1) + return true + } + } + + // Drop zone at END of folder (when folder is empty or after last item) + if childrenOf(item).isEmpty { + Color.clear + .frame(height: 40) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: item.id, atIndex: 0) + return true + } + } + } + } label: { + FolderRowView( + folder: item, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(item) + } + }, + onAddChild: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.addFavorite(parentID: item.id) + } + } + ) + } + .disclosureGroupStyle(.automatic) + } + } else { + // Regular favorite + FavoriteRowView( + favorite: item, + onRemove: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.removeFavorite(item) + } } + ) + .transition(.scale.combined(with: .opacity)) + .dropDestination(for: String.self) { droppedIds, location in + // Drop after this favorite + handleDrop(droppedIds: droppedIds, toParent: nil, atIndex: index + 1) + return true } - ) - .transition(.scale.combined(with: .opacity)) + } } + + // Drop zone at end of root level + Color.clear + .frame(height: 40) + .dropDestination(for: String.self) { droppedIds, location in + handleDrop(droppedIds: droppedIds, toParent: nil, atIndex: rootLevelItems.count) + return true + } } .animation(.spring(response: 0.3, dampingFraction: 0.7), value: favorites.count) } @@ -224,11 +344,13 @@ struct FavoriteRowView: View { @Bindable var favorite: Favorite let onRemove: () -> Void @State private var isHovering = false + @State private var isDragging = false /// Generates the favicon URL using Google's favicon service private var faviconURL: URL? { - guard !favorite.url.isEmpty, - let url = URL(string: favorite.url), + guard let urlString = favorite.url, + !urlString.isEmpty, + let url = URL(string: urlString), let domain = url.host else { return nil } @@ -286,7 +408,10 @@ struct FavoriteRowView: View { .frame(height: 22) AppKitTextField( - text: $favorite.url, + text: Binding( + get: { favorite.url ?? "" }, + set: { favorite.url = $0.isEmpty ? nil : $0 } + ), placeholder: "URL" ) .frame(height: 22) @@ -295,10 +420,34 @@ struct FavoriteRowView: View { .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(isHovering ? 0.12 : 0.08), radius: isHovering ? 12 : 8, y: isHovering ? 6 : 4) .scaleEffect(isHovering ? 1.01 : 1.0) + .opacity(isDragging ? 0.5 : 1.0) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovering) + .animation(.easeInOut(duration: 0.2), value: isDragging) .onHover { hovering in isHovering = hovering } + .draggable(favorite.id.uuidString) { + // Drag preview + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + Text(favorite.name) + .font(.headline) + } + if let url = favorite.url { + Text(url) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8)) + .shadow(radius: 8) + .onAppear { isDragging = true } + .onDisappear { isDragging = false } + } } } diff --git a/Sources/ManagedFavsGenerator/Favorite.swift b/Sources/ManagedFavsGenerator/Favorite.swift index a766aae..35948d2 100644 --- a/Sources/ManagedFavsGenerator/Favorite.swift +++ b/Sources/ManagedFavsGenerator/Favorite.swift @@ -1,19 +1,28 @@ import Foundation import SwiftData -/// SwiftData Model für Favoriten +/// SwiftData Model für Favoriten und Ordner // Note: @Model is a SwiftData macro, not a GitHub user mention @Model final class Favorite { @Attribute(.unique) var id: UUID var name: String - var url: String + var url: String? // nil = Folder, String = Favorite + var parentID: UUID? // nil = Root level, UUID = Inside folder + var order: Int = 0 // For sorting within same level (default: 0) var createdAt: Date - init(id: UUID = UUID(), name: String = "", url: String = "") { + /// Computed property to check if this is a folder + var isFolder: Bool { + url == nil + } + + init(id: UUID = UUID(), name: String = "", url: String? = "", parentID: UUID? = nil, order: Int = 0) { self.id = id self.name = name self.url = url + self.parentID = parentID + self.order = order self.createdAt = Date() } } diff --git a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift index a8c598f..3aa36d7 100644 --- a/Sources/ManagedFavsGenerator/FavoritesViewModel.swift +++ b/Sources/ManagedFavsGenerator/FavoritesViewModel.swift @@ -45,13 +45,13 @@ class FavoritesViewModel { // MARK: - Business Logic - func addFavorite() { + func addFavorite(parentID: UUID? = nil) { guard let modelContext = modelContext else { logger.error("ModelContext nicht verfügbar") return } - let favorite = Favorite() + let favorite = Favorite(parentID: parentID) modelContext.insert(favorite) do { @@ -63,6 +63,25 @@ class FavoritesViewModel { } } + func addFolder() { + guard let modelContext = modelContext else { + logger.error("ModelContext nicht verfügbar") + return + } + + // Folder = Favorite with url = nil + let folder = Favorite(name: "New Folder", url: nil) + modelContext.insert(folder) + + do { + try modelContext.save() + logger.info("Ordner hinzugefügt und gespeichert") + } catch { + logger.error("Fehler beim Speichern: \(error.localizedDescription)") + handleError(error) + } + } + func removeFavorite(_ favorite: Favorite) { guard let modelContext = modelContext else { logger.error("ModelContext nicht verfügbar") @@ -80,6 +99,41 @@ class FavoritesViewModel { } } + // MARK: - Drag & Drop + + func moveFavorite(_ favorite: Favorite, toParent newParentID: UUID?, atIndex index: Int, allFavorites: [Favorite]) { + guard let modelContext = modelContext else { + logger.error("ModelContext nicht verfügbar") + return + } + + // Update parentID + favorite.parentID = newParentID + + // Reorder siblings at target location + let siblings = allFavorites + .filter { $0.parentID == newParentID && $0.id != favorite.id } + .sorted { $0.order < $1.order } + + // Insert at new position + var reorderedSiblings = siblings + let targetIndex = min(index, reorderedSiblings.count) + reorderedSiblings.insert(favorite, at: targetIndex) + + // Update order values + for (idx, item) in reorderedSiblings.enumerated() { + item.order = idx + } + + do { + try modelContext.save() + logger.info("Favorit verschoben: parentID=\(newParentID?.uuidString ?? "root"), order=\(favorite.order)") + } catch { + logger.error("Fehler beim Verschieben: \(error.localizedDescription)") + handleError(error) + } + } + func copyToClipboard(_ text: String) { do { try clipboardService.copyToClipboard(text) diff --git a/Sources/ManagedFavsGenerator/FolderRowView.swift b/Sources/ManagedFavsGenerator/FolderRowView.swift new file mode 100644 index 0000000..783e2e7 --- /dev/null +++ b/Sources/ManagedFavsGenerator/FolderRowView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// View für Ordner-Darstellung +struct FolderRowView: View { + @Bindable var folder: Favorite + let onRemove: () -> Void + let onAddChild: () -> Void + @State private var isHovering = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + // Folder Icon + Title + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .foregroundStyle(.yellow) + .frame(width: 20, height: 20) + .imageScale(.medium) + + Label("Folder", systemImage: "folder.fill") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .imageScale(.small) + .labelStyle(.titleOnly) + } + + Spacer() + + // Add to Folder Button + Button(action: onAddChild) { + Image(systemName: "plus.circle.fill") + .foregroundStyle(.blue) + .imageScale(.medium) + } + .buttonStyle(.plain) + .opacity(isHovering ? 1.0 : 0.6) + .help("Add favorite to this folder") + + Button(action: onRemove) { + Image(systemName: "trash") + .foregroundStyle(.red) + .imageScale(.medium) + } + .buttonStyle(.plain) + .opacity(isHovering ? 1.0 : 0.6) + .help("Delete this folder") + } + + AppKitTextField( + text: $folder.name, + placeholder: "Folder Name" + ) + .frame(height: 22) + } + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(isHovering ? 0.12 : 0.08), radius: isHovering ? 12 : 8, y: isHovering ? 6 : 4) + .scaleEffect(isHovering ? 1.01 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovering) + .onHover { hovering in + isHovering = hovering + } + } +} diff --git a/Sources/ManagedFavsGenerator/FormatGenerator.swift b/Sources/ManagedFavsGenerator/FormatGenerator.swift index fa5f3a1..8478142 100644 --- a/Sources/ManagedFavsGenerator/FormatGenerator.swift +++ b/Sources/ManagedFavsGenerator/FormatGenerator.swift @@ -4,24 +4,46 @@ enum FormatGenerator { // JSON for GPO (Windows onPrem) and Intune Settings Catalog (Windows) static func generateJSON(toplevelName: String, favorites: [Favorite]) -> String { - var items: [[String: String]] = [] + var items: [[String: Any]] = [] // First item: toplevel_name items.append(["toplevel_name": toplevelName]) - // Favorites - for favorite in favorites where !favorite.name.isEmpty && !favorite.url.isEmpty { - items.append([ - "url": favorite.url, - "name": favorite.name - ]) - } + // Get root level items (no parent) + let rootItems = favorites.filter { $0.parentID == nil }.sorted { $0.order < $1.order } - // Use JSONEncoder with .withoutEscapingSlashes to prevent https:// -> https:\/\/ - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + for item in rootItems where !item.name.isEmpty { + if item.isFolder { + // Folder: name + children array + let children = favorites.filter { $0.parentID == item.id }.sorted { $0.order < $1.order } + var childrenArray: [[String: String]] = [] + + for child in children where !child.name.isEmpty { + if let url = child.url, !url.isEmpty { + childrenArray.append([ + "name": child.name, + "url": url + ]) + } + } + + items.append([ + "name": item.name, + "children": childrenArray + ]) + } else { + // Regular favorite: name + url + if let url = item.url, !url.isEmpty { + items.append([ + "name": item.name, + "url": url + ]) + } + } + } - guard let jsonData = try? encoder.encode(items), + // Use JSONSerialization for [String: Any] support + guard let jsonData = try? JSONSerialization.data(withJSONObject: items, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), let jsonString = String(data: jsonData, encoding: .utf8) else { return "[]" } @@ -46,14 +68,43 @@ enum FormatGenerator { plistLines.append("\t\t\t\(toplevelName.xmlEscaped)") plistLines.append("\t\t") - // Favorites - for favorite in favorites where !favorite.name.isEmpty && !favorite.url.isEmpty { - plistLines.append("\t\t") - plistLines.append("\t\t\tname") - plistLines.append("\t\t\t\(favorite.name.xmlEscaped)") - plistLines.append("\t\t\turl") - plistLines.append("\t\t\t\(favorite.url.xmlEscaped)") - plistLines.append("\t\t") + // Get root level items (no parent) + let rootItems = favorites.filter { $0.parentID == nil }.sorted { $0.order < $1.order } + + for item in rootItems where !item.name.isEmpty { + if item.isFolder { + // Folder: children array FIRST, then name (Microsoft format) + plistLines.append("\t\t") + plistLines.append("\t\t\tchildren") + plistLines.append("\t\t\t") + + let children = favorites.filter { $0.parentID == item.id }.sorted { $0.order < $1.order } + for child in children where !child.name.isEmpty { + if let url = child.url, !url.isEmpty { + plistLines.append("\t\t\t\t") + plistLines.append("\t\t\t\t\tname") + plistLines.append("\t\t\t\t\t\(child.name.xmlEscaped)") + plistLines.append("\t\t\t\t\turl") + plistLines.append("\t\t\t\t\t\(url.xmlEscaped)") + plistLines.append("\t\t\t\t") + } + } + + plistLines.append("\t\t\t") + plistLines.append("\t\t\tname") + plistLines.append("\t\t\t\(item.name.xmlEscaped)") + plistLines.append("\t\t") + } else { + // Regular favorite: name + url + if let url = item.url, !url.isEmpty { + plistLines.append("\t\t") + plistLines.append("\t\t\tname") + plistLines.append("\t\t\t\(item.name.xmlEscaped)") + plistLines.append("\t\t\turl") + plistLines.append("\t\t\t\(url.xmlEscaped)") + plistLines.append("\t\t") + } + } } plistLines.append("\t") diff --git a/Sources/ManagedFavsGenerator/SettingsView.swift b/Sources/ManagedFavsGenerator/SettingsView.swift index ce3c068..6e57e8a 100644 --- a/Sources/ManagedFavsGenerator/SettingsView.swift +++ b/Sources/ManagedFavsGenerator/SettingsView.swift @@ -2,11 +2,30 @@ import SwiftUI /// Settings View für App-Einstellungen struct SettingsView: View { + @State private var viewModel = FavoritesViewModel() + var body: some View { Form { + Section { + LabeledContent("Toplevel Name") { + TextField("e.g., managedFavs", text: $viewModel.toplevelName) + .textFieldStyle(.roundedBorder) + .frame(width: 250) + } + .help("The toplevel name for your managed favorites structure") + + Text("This name appears as the first entry in the exported JSON/Plist configuration.") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Configuration") + } + + Divider() + Section { LabeledContent("Version") { - Text("1.0.0") + Text("1.1.0") .foregroundStyle(.secondary) } @@ -19,7 +38,7 @@ struct SettingsView: View { } } .formStyle(.grouped) - .frame(width: 500, height: 200) + .frame(width: 550, height: 300) } }