From e47223fbd1a6ec0c1ea417d36c4eff1feee93b81 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 30 May 2026 17:01:31 +0800
Subject: [PATCH] Prepare 7.8.3 release package

---
 Apptag/ContentView.swift |  568 ++++++++++++++++++++++++++++++--------------------------
 1 files changed, 303 insertions(+), 265 deletions(-)

diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 3c68021..9b6ce7c 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -304,6 +304,24 @@
     let arrowOffset: CGFloat
 }
 
+private struct AppGridInteractionState {
+    var appDragModeActive = false
+    var dropWarningToast: String? = nil
+    var dropRefreshVisible = false
+    var dropRefreshStartedAt: Date? = nil
+    var hoveredBubble: AppBubbleContext? = nil
+    var editingBubble: AppBubbleContext? = nil
+    var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
+    var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+    var uncategorizedDropSuppressFuturePrompt = false
+    var tagRemovalDropSuppressFuturePrompt = false
+    var appDragResetToken = 0
+    var bubbleDraftNote = ""
+    var tagNavigationHoveredGroupName: String? = nil
+    var tagNavigationLastHoverScrollID: String? = nil
+    var tagNavigationLastHoverScrollAt: Date? = nil
+}
+
 private struct EditActionFeedback: Identifiable {
     let id = UUID()
     let title: String
@@ -321,20 +339,6 @@
     let id = UUID()
     let app: AppInfo
     let tagName: String
-}
-
-private enum SmartStartNoticeMode {
-    case autoApplied
-    case suggestionOnly
-    case manuallyApplied
-}
-
-private struct SmartStartNotice: Identifiable {
-    let id = UUID()
-    let mode: SmartStartNoticeMode
-    let title: String
-    let message: String
-    let summary: SmartStartSummary
 }
 
 struct ContentView: View {
@@ -365,22 +369,12 @@
     @State private var tagNavReorderDidMove = false
     // Fixed interaction for "Colorless Container": hover fills persistently; click clears.
     @State private var filledColorlessContainer: String? = nil
-    @State private var appDragModeActive = false
-    @State private var dropWarningToast: String? = nil
+    @State private var appGridInteraction = AppGridInteractionState()
     @State private var smartStartNotice: SmartStartNotice? = nil
     @State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil
-    @State private var dropRefreshVisible = false
-    @State private var dropRefreshStartedAt: Date? = nil
     @State private var refreshInProgress = false
     @State private var refreshAgainAfterCurrent = false
     @State private var refreshAgainForceLayout = false
-    @State private var hoveredBubble: AppBubbleContext? = nil
-    @State private var editingBubble: AppBubbleContext? = nil
-    @State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
-    @State private var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
-    @State private var tagRemovalDropSuppressFuturePrompt = false
-    @State private var appDragResetToken = 0
-    @State private var bubbleDraftNote = ""
     @FocusState private var bubbleNoteFocused: Bool
 
     // Quick Search
@@ -393,6 +387,7 @@
     @State private var quickSearchFocusToken = 0
     @State private var quickSearchSelectionScrollToken = 0
     @State private var quickSearchCloseHidesOverlay = false
+    @State private var quickSearchOnlySession = false
     @State private var quickSearchErrorMessage: String? = nil
     @State private var initialQuickSearchConsumed = false
 
@@ -400,8 +395,10 @@
         self.hideOverlay = hideOverlay
         self.initialQuickSearchSource = initialQuickSearchSource
         let startsInQuickSearch = initialQuickSearchSource != nil
+        let startsInQuickSearchOnlySession = initialQuickSearchSource == QuickSearchOpenSource.globalHidden
         _quickSearchVisible = State(initialValue: startsInQuickSearch)
-        _quickSearchCloseHidesOverlay = State(initialValue: initialQuickSearchSource == QuickSearchOpenSource.globalHidden)
+        _quickSearchCloseHidesOverlay = State(initialValue: startsInQuickSearchOnlySession)
+        _quickSearchOnlySession = State(initialValue: startsInQuickSearchOnlySession)
         _quickSearchFocusToken = State(initialValue: startsInQuickSearch ? 1 : 0)
     }
 
@@ -414,16 +411,25 @@
     @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
     @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
     @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+    @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
     @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
+    @AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false
 
     private let editSidebarWidth: CGFloat = 188
     private let editSidebarHorizontalInset: CGFloat = 12
     private let floatingControlsTrailingInset: CGFloat = 20
     private let floatingControlsReservedWidth: CGFloat = 120
     private var appBubbleDisabled: Bool {
-        appDragModeActive || pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil
+        appGridInteraction.appDragModeActive
+            || appGridInteraction.pendingUncategorizedDrop != nil
+            || appGridInteraction.pendingTagRemovalDrop != nil
+    }
+    private var appGridHighlightedGroupName: String? {
+        appGridInteraction.tagNavigationHoveredGroupName
+            ?? (isColorlessContainerMode ? filledColorlessContainer : nil)
     }
     private let rightSidebarFloatingClearance: CGFloat = 44
+    private let tagNavigationHoverScrollInterval: TimeInterval = 0.22
 
     private var floatingButtonSurfaceColor: Color {
         colorScheme == .dark
@@ -444,7 +450,10 @@
     }
 
     private var shouldRenderAppGridBehindQuickSearch: Bool {
-        !quickSearchVisible || !quickSearchCloseHidesOverlay
+        if quickSearchOnlySession {
+            return false
+        }
+        return !quickSearchVisible || !quickSearchCloseHidesOverlay
     }
 
     var body: some View {
@@ -484,7 +493,7 @@
 
             quickSearchOverlay
 
-            if shouldRenderAppGridBehindQuickSearch, let message = dropWarningToast {
+            if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast {
                 Text(message)
                     .font(.system(size: 16, weight: .semibold))
                     .foregroundStyle(.primary)
@@ -499,7 +508,7 @@
                     .allowsHitTesting(false)
             }
 
-            if shouldRenderAppGridBehindQuickSearch && dropRefreshVisible {
+            if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible {
                 Color.black.opacity(0.08)
                     .ignoresSafeArea()
                     .transition(.opacity)
@@ -524,7 +533,10 @@
             refreshAppsIfNeeded()
             if let initialQuickSearchSource, !initialQuickSearchConsumed {
                 initialQuickSearchConsumed = true
-                quickSearchCloseHidesOverlay = initialQuickSearchSource == QuickSearchOpenSource.globalHidden
+                if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
+                    quickSearchCloseHidesOverlay = true
+                    quickSearchOnlySession = true
+                }
                 if !quickSearchVisible {
                     quickSearchVisible = true
                     quickSearchFocusToken &+= 1
@@ -533,7 +545,10 @@
                 NotificationCenter.default.post(
                     name: .tagLauncherQuickSearchVisibilityChanged,
                     object: nil,
-                    userInfo: ["active": true]
+                    userInfo: [
+                        "active": true,
+                        "hideOverlayOnClose": quickSearchCloseHidesOverlay
+                    ]
                 )
             }
         }
@@ -545,6 +560,8 @@
         .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
             resetTransientDragState()
             closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
+            quickSearchOnlySession = false
+            quickSearchCloseHidesOverlay = false
         }
         .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested)) { notification in
             let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
@@ -579,7 +596,7 @@
             )
         }
         .onChange(of: bubbleNoteFocused) { _, focused in
-            if editingBubble != nil && !focused {
+            if appGridInteraction.editingBubble != nil && !focused {
                 commitBubbleNote()
             }
         }
@@ -593,10 +610,10 @@
             quickSearchErrorMessage = nil
             refreshQuickSearchResults()
         }
-        .onChange(of: pendingUncategorizedDrop != nil) { _, _ in
+        .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
             publishModalInteractionState()
         }
-        .onChange(of: pendingTagRemovalDrop != nil) { _, _ in
+        .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
             publishModalInteractionState()
         }
         .onDisappear {
@@ -612,7 +629,7 @@
         NotificationCenter.default.post(
             name: .tagLauncherModalInteractionChanged,
             object: nil,
-            userInfo: ["active": pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil]
+            userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil]
         )
     }
 
@@ -649,7 +666,7 @@
                     }
                     .zIndex(899)
 
-                QuickSearchOverlayView(
+                QuickSearchPanelPresentationView(
                     query: $quickSearchQuery,
                     results: quickSearchResults,
                     selectedID: quickSearchSelectedID,
@@ -657,16 +674,15 @@
                     selectionScrollToken: quickSearchSelectionScrollToken,
                     isLoading: quickSearchDocuments.isEmpty && refreshInProgress,
                     maxVisibleRows: quickSearchMaxVisibleRows(in: proxy.size),
+                    panelTopY: quickSearchPanelTopY(in: proxy.size),
+                    panelHeight: quickSearchPanelContentHeight(in: proxy.size),
                     errorMessage: quickSearchErrorMessage,
                     onCommand: handleQuickSearchCommand,
                     onHover: selectQuickSearchResult,
                     onLaunch: launchQuickSearchResult
                 )
-                .position(
-                    x: proxy.size.width / 2,
-                    y: quickSearchPanelCenterY(in: proxy.size)
-                )
-                .transition(.scale(scale: 0.98).combined(with: .opacity))
+                .frame(width: 0, height: 0)
+                .allowsHitTesting(false)
                 .zIndex(900)
             }
         }
@@ -674,10 +690,15 @@
         .animation(.easeOut(duration: 0.12), value: quickSearchVisible)
     }
 
-    private func quickSearchPanelCenterY(in size: CGSize) -> CGFloat {
-        let visibleRows = max(1, min(quickSearchResults.isEmpty ? 1 : quickSearchResults.count, quickSearchMaxVisibleRows(in: size)))
-        let estimatedPanelHeight = CGFloat(visibleRows) * 76 + 122
-        return quickSearchPanelTopY(in: size) + estimatedPanelHeight / 2
+    private func quickSearchPanelContentHeight(in size: CGSize) -> CGFloat {
+        let hasResultList = !(quickSearchDocuments.isEmpty && refreshInProgress)
+            && quickSearchErrorMessage == nil
+            && !quickSearchResults.isEmpty
+        let visibleRows = min(max(1, quickSearchResults.count), quickSearchMaxVisibleRows(in: size))
+        return QuickSearchPanelMetrics.contentHeight(
+            hasResultList: hasResultList,
+            visibleRows: visibleRows
+        )
     }
 
     private func quickSearchPanelTopY(in size: CGSize) -> CGFloat {
@@ -686,8 +707,11 @@
 
     private func quickSearchMaxVisibleRows(in size: CGSize) -> Int {
         let bottomClearance: CGFloat = 84
-        let chromeHeight: CGFloat = 122
-        let rowHeightWithSpacing: CGFloat = 76
+        let chromeHeight = QuickSearchPanelMetrics.headerHeight
+            + QuickSearchPanelMetrics.dividerHeight
+            + QuickSearchPanelMetrics.resultListVerticalInset * 2
+        let rowHeightWithSpacing = QuickSearchPanelMetrics.rowHeight
+            + QuickSearchPanelMetrics.rowSpacing
         let availableHeight = max(0, size.height - quickSearchPanelTopY(in: size) - bottomClearance - chromeHeight)
         return max(1, min(8, Int(floor(availableHeight / rowHeightWithSpacing))))
     }
@@ -695,7 +719,11 @@
     private func openQuickSearch(source: String) {
         guard canOpenQuickSearch else { return }
         dismissAppBubble()
-        quickSearchCloseHidesOverlay = source == QuickSearchOpenSource.globalHidden
+        if source == QuickSearchOpenSource.globalHidden {
+            quickSearchOnlySession = true
+        }
+        quickSearchCloseHidesOverlay = quickSearchCloseHidesOverlay
+            || source == QuickSearchOpenSource.globalHidden
         quickSearchVisible = true
         quickSearchQuery = ""
         quickSearchManualSelection = false
@@ -705,15 +733,18 @@
         NotificationCenter.default.post(
             name: .tagLauncherQuickSearchVisibilityChanged,
             object: nil,
-            userInfo: ["active": true]
+            userInfo: [
+                "active": true,
+                "hideOverlayOnClose": quickSearchCloseHidesOverlay
+            ]
         )
     }
 
     private var canOpenQuickSearch: Bool {
         editPhase == .none
-            && pendingUncategorizedDrop == nil
+            && appGridInteraction.pendingUncategorizedDrop == nil
             && smartStartNotice == nil
-            && !dropRefreshVisible
+            && !appGridInteraction.dropRefreshVisible
             && !quickSearchVisible
     }
 
@@ -726,16 +757,20 @@
         quickSearchSelectedID = nil
         quickSearchManualSelection = false
         quickSearchErrorMessage = nil
-        quickSearchCloseHidesOverlay = false
         if notify {
             NotificationCenter.default.post(
                 name: .tagLauncherQuickSearchVisibilityChanged,
                 object: nil,
-                userInfo: ["active": false]
+                userInfo: [
+                    "active": false,
+                    "hideOverlayOnClose": shouldHideOverlay
+                ]
             )
         }
         if shouldHideOverlay {
             hideOverlay()
+        } else {
+            quickSearchCloseHidesOverlay = false
         }
     }
 
@@ -859,7 +894,7 @@
     private var topLayout: some View {
         VStack(spacing: 0) {
             Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28)
-            if !tagLabels.isEmpty { tagBar.padding(.bottom, 8) }
+            if !tagLabels.isEmpty { topTagNavigation.padding(.bottom, 8) }
             Divider().opacity(0.3)
             appGridContent
         }
@@ -870,14 +905,79 @@
             Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28)
             Divider().opacity(0.3)
             HStack(spacing: 0) {
-                if tagPosition == "left" { tagSidebar; sideDivider }
+                if tagPosition == "left" { leftTagSidebar; sideDivider }
                 appGridContent
                 if tagPosition == "right" { sideDivider; rightTagSidebar }
             }
         }
     }
 
-    private var tagBar: some View {
+    private var topTagNavigation: some View {
+        Group {
+            if useAppKitTagNavigation {
+                appKitTagNavigation(orientation: .horizontal)
+                    .frame(height: 34)
+            } else {
+                swiftUITopTagBar
+            }
+        }
+    }
+
+    private var leftTagSidebar: some View {
+        Group {
+            if useAppKitTagNavigation {
+                appKitTagNavigation(orientation: .vertical)
+            } else {
+                swiftUITagSidebarList
+            }
+        }
+        .frame(width: 135)
+    }
+
+    private var rightTagSidebar: some View {
+        Group {
+            if useAppKitTagNavigation {
+                appKitTagNavigation(orientation: .vertical)
+            } else {
+                swiftUITagSidebarList
+            }
+        }
+        .padding(.top, rightSidebarFloatingClearance)
+        .frame(width: 135)
+    }
+
+    private func appKitTagNavigation(orientation: TagNavigationView.Orientation) -> some View {
+        let isHorizontal = orientation == .horizontal
+        return TagNavigationView(
+            items: tagLabels,
+            orientation: orientation,
+            contentInsets: isHorizontal
+                ? NSEdgeInsets(top: 3, left: 24, bottom: 3, right: tagPosition == "top" ? floatingControlsReservedWidth : 24)
+                : NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12),
+            dragModeActive: tagNavDragModeActive,
+            draggingItemID: tagNavDragItem,
+            onActivate: { tagID in
+                activateTagNavigation(tagID)
+            },
+            onHoverChange: { tagID, active in
+                handleTagNavigationHover(tagID, active: active)
+            },
+            canReorder: { tagID in
+                canReorderTag(tagID)
+            },
+            onReorderBegan: { tagID in
+                beginTagNavReorder(tagID)
+            },
+            onReorderMoved: { tagID, targetID in
+                reorderTagNavItem(fromName: tagID, to: targetID)
+            },
+            onReorderEnded: {
+                endTagNavReorder()
+            }
+        )
+    }
+
+    private var swiftUITopTagBar: some View {
         ScrollView(.horizontal, showsIndicators: false) {
             HStack(spacing: 8) {
                 ForEach(tagLabels) { tag in
@@ -891,10 +991,7 @@
                     .zIndex(tagNavDragItem == tag.name ? 1 : 0)
                     .highPriorityGesture(tagNavReorderGesture(for: tag.name))
                     .onHover { hovering in
-                        if hovering {
-                            fillColorlessContainer(tag.id)
-                            scrollTo(tag.id)
-                        }
+                        handleTagNavigationHover(tag.id, active: hovering)
                     }
                 }
             }
@@ -907,18 +1004,7 @@
         }
     }
 
-    private var tagSidebar: some View {
-        tagSidebarList
-            .frame(width: 135)
-    }
-
-    private var rightTagSidebar: some View {
-        tagSidebarList
-            .padding(.top, rightSidebarFloatingClearance)
-            .frame(width: 135)
-    }
-
-    private var tagSidebarList: some View {
+    private var swiftUITagSidebarList: some View {
         ScrollView(.vertical, showsIndicators: false) {
             VStack(spacing: 6) {
                 ForEach(tagLabels) { tag in
@@ -932,10 +1018,7 @@
                     .zIndex(tagNavDragItem == tag.name ? 1 : 0)
                     .highPriorityGesture(tagNavReorderGesture(for: tag.name))
                     .onHover { hovering in
-                        if hovering {
-                            fillColorlessContainer(tag.id)
-                            scrollTo(tag.id)
-                        }
+                        handleTagNavigationHover(tag.id, active: hovering)
                     }
                 }
             }
@@ -966,7 +1049,7 @@
                     showNames: !hideAppNames,
                     bubbleDisabled: appBubbleDisabled,
                     showUncommonAppBubbles: showUncommonAppBubbles,
-                    highlightedGroupName: filledColorlessContainer,
+                    highlightedGroupName: appGridHighlightedGroupName,
                     contentRevision: groupLayoutVersion,
                     scrollTargetID: appGridScrollTargetID,
                     scrollRequestToken: appGridScrollRequestToken,
@@ -993,9 +1076,9 @@
 
     private var uncommonAppBubbleOverlay: some View {
         GeometryReader { proxy in
-            if let context = editingBubble ?? hoveredBubble {
+            if let context = appGridInteraction.editingBubble ?? appGridInteraction.hoveredBubble {
                 let rootFrame = proxy.frame(in: .global)
-                let editing = editingBubble != nil
+                let editing = appGridInteraction.editingBubble != nil
                 let width = min(editing ? 440 : 520, max(260, proxy.size.width - 48))
                 let placement = bubblePlacement(for: context.frame, rootFrame: rootFrame)
                 let metrics = bubbleMetrics(
@@ -1013,7 +1096,7 @@
                     isEditing: editing,
                     placement: placement,
                     arrowOffset: metrics.arrowOffset,
-                    draftNote: $bubbleDraftNote,
+                    draftNote: $appGridInteraction.bubbleDraftNote,
                     noteFocused: $bubbleNoteFocused,
                     onCommit: commitBubbleNote,
                     onCancel: dismissAppBubble
@@ -1026,92 +1109,16 @@
             }
         }
         .ignoresSafeArea()
-        .allowsHitTesting(editingBubble != nil)
+        .allowsHitTesting(appGridInteraction.editingBubble != nil)
     }
 
     private var smartStartNoticeOverlay: some View {
-        GeometryReader { proxy in
-            if let notice = smartStartNotice {
-                let panelWidth = min(560, max(320, proxy.size.width - 64))
-
-                ZStack {
-                    Color.black.opacity(0.10)
-                        .ignoresSafeArea()
-
-                    VStack(spacing: 18) {
-                        Image(systemName: notice.mode == .suggestionOnly ? "sparkles" : "checkmark.seal.fill")
-                            .font(.system(size: 30, weight: .semibold))
-                            .foregroundStyle(Color.accentColor)
-                            .frame(width: 58, height: 58)
-                            .background(
-                                Circle()
-                                    .fill(Color.accentColor.opacity(0.12))
-                            )
-
-                        VStack(spacing: 8) {
-                            Text(notice.title)
-                                .font(.system(size: 21, weight: .semibold))
-                                .foregroundStyle(.primary)
-                                .multilineTextAlignment(.center)
-
-                            Text(notice.message)
-                                .font(.system(size: 15, weight: .regular))
-                                .foregroundStyle(.secondary)
-                                .multilineTextAlignment(.center)
-                                .lineLimit(4)
-                                .fixedSize(horizontal: false, vertical: true)
-                        }
-
-                        HStack(spacing: 12) {
-                            switch notice.mode {
-                            case .suggestionOnly:
-                                Button(tr("smartstart.later")) {
-                                    dismissSmartStartNotice()
-                                }
-                                .buttonStyle(.bordered)
-                                .controlSize(.large)
-
-                                Button(tr("smartstart.apply")) {
-                                    applySmartStartSuggestion()
-                                }
-                                .buttonStyle(.borderedProminent)
-                                .controlSize(.large)
-                            case .autoApplied, .manuallyApplied:
-                                if notice.summary.backupPath != nil {
-                                    Button(tr("smartstart.undo")) {
-                                        undoSmartStart()
-                                    }
-                                    .buttonStyle(.bordered)
-                                    .controlSize(.large)
-                                }
-                                Button(tr("smartstart.ok")) {
-                                    dismissSmartStartNotice()
-                                }
-                                .buttonStyle(.borderedProminent)
-                                .controlSize(.large)
-                            }
-                        }
-                    }
-                    .padding(.horizontal, 30)
-                    .padding(.vertical, 26)
-                    .frame(width: panelWidth)
-                    .background(
-                        RoundedRectangle(cornerRadius: 16)
-                            .fill(.ultraThickMaterial)
-                            .shadow(color: .black.opacity(0.26), radius: 28, y: 16)
-                    )
-                    .overlay(
-                        RoundedRectangle(cornerRadius: 16)
-                            .stroke(.white.opacity(0.16), lineWidth: 1)
-                    )
-                }
-                .frame(width: proxy.size.width, height: proxy.size.height)
-                .transition(.scale(scale: 0.96).combined(with: .opacity))
-                .zIndex(650)
-            }
-        }
-        .ignoresSafeArea()
-        .allowsHitTesting(smartStartNotice != nil)
+        SmartStartNoticeOverlay(
+            notice: smartStartNotice,
+            onDismiss: dismissSmartStartNotice,
+            onApply: applySmartStartSuggestion,
+            onUndo: undoSmartStart
+        )
     }
     // MARK: - Edit Tags View
 
@@ -1432,7 +1439,7 @@
 
     private var uncategorizedDropConfirmOverlay: some View {
         GeometryReader { proxy in
-            if let pendingDrop = pendingUncategorizedDrop {
+            if let pendingDrop = appGridInteraction.pendingUncategorizedDrop {
                 ZStack {
                     Color.black.opacity(0.14)
                         .ignoresSafeArea()
@@ -1440,6 +1447,8 @@
                     UncategorizedDropConfirmBubble(
                         title: tr("drop.uncategorizedConfirmTitle"),
                         message: uncategorizedConfirmMessage(for: pendingDrop),
+                        doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"),
+                        doNotRemind: $appGridInteraction.uncategorizedDropSuppressFuturePrompt,
                         cancelTitle: tr("tag.cancel"),
                         confirmTitle: tr("edit.confirm"),
                         onCancel: dismissUncategorizedDropConfirm,
@@ -1453,12 +1462,12 @@
             }
         }
         .ignoresSafeArea()
-        .allowsHitTesting(pendingUncategorizedDrop != nil)
+        .allowsHitTesting(appGridInteraction.pendingUncategorizedDrop != nil)
     }
 
     private var tagRemovalDropConfirmOverlay: some View {
         GeometryReader { proxy in
-            if let pendingDrop = pendingTagRemovalDrop {
+            if let pendingDrop = appGridInteraction.pendingTagRemovalDrop {
                 ZStack {
                     Color.black.opacity(0.14)
                         .ignoresSafeArea()
@@ -1467,7 +1476,7 @@
                         title: tr("drop.removeTagConfirmTitle"),
                         message: tagRemovalConfirmMessage(for: pendingDrop),
                         doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"),
-                        doNotRemind: $tagRemovalDropSuppressFuturePrompt,
+                        doNotRemind: $appGridInteraction.tagRemovalDropSuppressFuturePrompt,
                         cancelTitle: tr("drop.removeTagConfirmNo"),
                         confirmTitle: tr("drop.removeTagConfirmYes"),
                         onCancel: dismissTagRemovalDropConfirm,
@@ -1481,7 +1490,7 @@
             }
         }
         .ignoresSafeArea()
-        .allowsHitTesting(pendingTagRemovalDrop != nil)
+        .allowsHitTesting(appGridInteraction.pendingTagRemovalDrop != nil)
     }
 
     private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
@@ -1632,23 +1641,14 @@
 
         let scannedApps = allApps
         DispatchQueue.global(qos: .userInitiated).async {
-            let result = SmartStartService.applySuggestion(draft)
-            let store = result.store
-            let apps = TagEditor.annotate(apps: scannedApps, store: store)
-            let quickSearchDocs = QuickSearchEngine.makeDocuments(apps: apps, store: store)
-            let colors = store.tags.mapValues { $0.color }
-            let order = TagEditor.orderedTagNames()
+            let result = AppLibraryController.applySmartStartSuggestion(
+                draft,
+                scannedApps: scannedApps
+            )
 
             DispatchQueue.main.async {
                 pendingSmartStartDraft = nil
-                allApps = apps
-                quickSearchDocuments = quickSearchDocs
-                tagColors = colors
-                draggedTagNames = order
-                rebuildDisplayGroups(apps: apps, tagOrder: order)
-                if quickSearchVisible {
-                    refreshQuickSearchResults()
-                }
+                applyAppLibrarySnapshot(result.snapshot)
                 if let summary = result.summary {
                     showSmartStartNotice(mode: .manuallyApplied, summary: summary)
                 } else {
@@ -1726,8 +1726,8 @@
         groupLayoutVersion &+= 1
     }
 
-    private var tagLabels: [TagLabel] {
-        displayGroups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
+    private var tagLabels: [TagNavigationItem] {
+        displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
     }
 
     private var editGroups: [TagGroup] {
@@ -1757,7 +1757,9 @@
     }
 
     private func handleAppGridScrollActivity() {
-        hoveredBubble = nil
+        if appGridInteraction.hoveredBubble != nil {
+            appGridInteraction.hoveredBubble = nil
+        }
     }
 
     private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
@@ -1765,17 +1767,17 @@
             clearAppBubbleState()
             return
         }
-        guard editingBubble == nil else { return }
+        guard appGridInteraction.editingBubble == nil else { return }
         switch event {
         case .entered(let canShowBubble):
             if canShowBubble {
-                hoveredBubble = AppBubbleContext(app: app, frame: frame)
+                appGridInteraction.hoveredBubble = AppBubbleContext(app: app, frame: frame)
             } else {
-                hoveredBubble = nil
+                appGridInteraction.hoveredBubble = nil
             }
         case .exited:
-            guard hoveredBubble?.app.path == app.path else { return }
-            hoveredBubble = nil
+            guard appGridInteraction.hoveredBubble?.app.path == app.path else { return }
+            appGridInteraction.hoveredBubble = nil
         }
     }
 
@@ -1784,9 +1786,9 @@
             clearAppBubbleState()
             return
         }
-        bubbleDraftNote = currentNote(for: app)
-        hoveredBubble = nil
-        editingBubble = AppBubbleContext(app: app, frame: frame)
+        appGridInteraction.bubbleDraftNote = currentNote(for: app)
+        appGridInteraction.hoveredBubble = nil
+        appGridInteraction.editingBubble = AppBubbleContext(app: app, frame: frame)
         notifyAppNoteEditing(active: true)
         DispatchQueue.main.async {
             bubbleNoteFocused = true
@@ -1794,33 +1796,33 @@
     }
 
     private func commitBubbleNote() {
-        guard let context = editingBubble else { return }
-        let limited = String(bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
+        guard let context = appGridInteraction.editingBubble else { return }
+        let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
             .trimmingCharacters(in: .whitespacesAndNewlines)
         TagEditor.setAppNote(limited, for: context.app.path.path)
-        bubbleDraftNote = limited
-        editingBubble = nil
+        appGridInteraction.bubbleDraftNote = limited
+        appGridInteraction.editingBubble = nil
         bubbleNoteFocused = false
         notifyAppNoteEditing(active: false)
         refreshApps()
     }
 
     private func dismissAppBubble() {
-        hoveredBubble = nil
-        if editingBubble != nil {
+        appGridInteraction.hoveredBubble = nil
+        if appGridInteraction.editingBubble != nil {
             notifyAppNoteEditing(active: false)
         }
-        editingBubble = nil
+        appGridInteraction.editingBubble = nil
         bubbleNoteFocused = false
     }
 
     private func clearAppBubbleState() {
-        if hoveredBubble != nil {
-            hoveredBubble = nil
+        if appGridInteraction.hoveredBubble != nil {
+            appGridInteraction.hoveredBubble = nil
         }
-        if editingBubble != nil {
+        if appGridInteraction.editingBubble != nil {
             notifyAppNoteEditing(active: false)
-            editingBubble = nil
+            appGridInteraction.editingBubble = nil
         }
         if bubbleNoteFocused {
             bubbleNoteFocused = false
@@ -1897,6 +1899,31 @@
         if isColorlessContainerMode {
             toggleColorlessFill(id)
         }
+        scrollTo(id)
+    }
+
+    private func handleTagNavigationHover(_ id: String, active: Bool) {
+        guard active else {
+            if appGridInteraction.tagNavigationHoveredGroupName == id {
+                appGridInteraction.tagNavigationHoveredGroupName = nil
+            }
+            return
+        }
+
+        appGridInteraction.tagNavigationHoveredGroupName = id
+        fillColorlessContainer(id)
+        scrollToTagFromHover(id)
+    }
+
+    private func scrollToTagFromHover(_ id: String) {
+        let now = Date()
+        if appGridInteraction.tagNavigationLastHoverScrollID == id,
+           let lastScrollAt = appGridInteraction.tagNavigationLastHoverScrollAt,
+           now.timeIntervalSince(lastScrollAt) < tagNavigationHoverScrollInterval {
+            return
+        }
+        appGridInteraction.tagNavigationLastHoverScrollID = id
+        appGridInteraction.tagNavigationLastHoverScrollAt = now
         scrollTo(id)
     }
 
@@ -1980,11 +2007,18 @@
     private func reorderTagNavItem(at location: CGPoint) {
         guard let fromName = tagNavDragItem,
               let targetName = tagNavReorderFrames.first(where: { $0.value.contains(location) })?.key,
-              fromName != targetName,
+              fromName != targetName
+        else { return }
+        reorderTagNavItem(fromName: fromName, to: targetName)
+    }
+
+    private func reorderTagNavItem(fromName: String, to targetName: String) {
+        guard fromName != targetName,
+              canReorderTag(fromName),
+              canReorderTag(targetName),
               let fromIndex = draggedTagNames.firstIndex(of: fromName),
               let toIndex = draggedTagNames.firstIndex(of: targetName)
         else { return }
-
         tagNavReorderDidMove = true
         withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) {
             let destination = toIndex > fromIndex ? toIndex + 1 : toIndex
@@ -2031,9 +2065,9 @@
         }
 
         clearAppBubbleState()
-        tagRemovalDropSuppressFuturePrompt = false
+        appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
         withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
-            pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag)
+            appGridInteraction.pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag)
         }
     }
 
@@ -2043,9 +2077,15 @@
         guard !removableTags.isEmpty else { return }
         let assignedTags = assignedRegularDisplayTags(for: removableTags)
 
+        if skipUncategorizedDropConfirm {
+            moveDroppedAppToUncategorized(path: app.path.path, tags: removableTags)
+            return
+        }
+
         clearAppBubbleState()
+        appGridInteraction.uncategorizedDropSuppressFuturePrompt = false
         withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
-            pendingUncategorizedDrop = PendingUncategorizedDrop(
+            appGridInteraction.pendingUncategorizedDrop = PendingUncategorizedDrop(
                 app: app,
                 assignedTags: assignedTags,
                 removableTags: removableTags
@@ -2097,20 +2137,30 @@
     }
 
     private func dismissUncategorizedDropConfirm() {
+        appGridInteraction.uncategorizedDropSuppressFuturePrompt = false
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
         withAnimation(.easeOut(duration: 0.18)) {
-            pendingUncategorizedDrop = nil
+            appGridInteraction.pendingUncategorizedDrop = nil
         }
     }
 
     private func confirmPendingUncategorizedDrop() {
-        guard let pendingDrop = pendingUncategorizedDrop else { return }
+        guard let pendingDrop = appGridInteraction.pendingUncategorizedDrop else { return }
         let path = pendingDrop.app.path.path
         let tags = pendingDrop.removableTags
+        let shouldSuppressFuturePrompt = appGridInteraction.uncategorizedDropSuppressFuturePrompt
+        if shouldSuppressFuturePrompt {
+            skipUncategorizedDropConfirm = true
+        }
+        appGridInteraction.uncategorizedDropSuppressFuturePrompt = false
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
         withAnimation(.easeOut(duration: 0.16)) {
-            pendingUncategorizedDrop = nil
+            appGridInteraction.pendingUncategorizedDrop = nil
         }
+        moveDroppedAppToUncategorized(path: path, tags: tags)
+    }
+
+    private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
         guard !tags.isEmpty else { return }
         TagEditor.removeTags(tags, from: [path])
         showDropRefresh()
@@ -2118,23 +2168,23 @@
     }
 
     private func dismissTagRemovalDropConfirm() {
-        tagRemovalDropSuppressFuturePrompt = false
+        appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
         withAnimation(.easeOut(duration: 0.18)) {
-            pendingTagRemovalDrop = nil
+            appGridInteraction.pendingTagRemovalDrop = nil
         }
     }
 
     private func confirmPendingTagRemovalDrop() {
-        guard let pendingDrop = pendingTagRemovalDrop else { return }
+        guard let pendingDrop = appGridInteraction.pendingTagRemovalDrop else { return }
         let app = pendingDrop.app
         let tagName = pendingDrop.tagName
-        let shouldSuppressFuturePrompt = tagRemovalDropSuppressFuturePrompt
+        let shouldSuppressFuturePrompt = appGridInteraction.tagRemovalDropSuppressFuturePrompt
         if shouldSuppressFuturePrompt {
             skipTagRemovalDropConfirm = true
         }
-        tagRemovalDropSuppressFuturePrompt = false
+        appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
         withAnimation(.easeOut(duration: 0.16)) {
-            pendingTagRemovalDrop = nil
+            appGridInteraction.pendingTagRemovalDrop = nil
         }
         DispatchQueue.main.async {
             removeTagFromDroppedApp(app: app, tagName: tagName)
@@ -2180,45 +2230,45 @@
 
     private func showDropWarning() {
         withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
-            dropWarningToast = tr("drop.systemDefaultWarning")
+            appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning")
         }
         DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
             withAnimation(.easeOut(duration: 0.18)) {
-                dropWarningToast = nil
+                appGridInteraction.dropWarningToast = nil
             }
         }
     }
 
     private func showDropRefresh() {
-        dropRefreshStartedAt = Date()
+        appGridInteraction.dropRefreshStartedAt = Date()
         withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) {
-            dropRefreshVisible = true
+            appGridInteraction.dropRefreshVisible = true
         }
     }
 
     private func finishDropRefreshAfterMinimumDuration() {
         let minimumDuration: TimeInterval = 0.85
-        let elapsed = dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration
+        let elapsed = appGridInteraction.dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration
         let delay = max(0, minimumDuration - elapsed)
         DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
             withAnimation(.easeOut(duration: 0.18)) {
-                dropRefreshVisible = false
+                appGridInteraction.dropRefreshVisible = false
             }
-            dropRefreshStartedAt = nil
+            appGridInteraction.dropRefreshStartedAt = nil
         }
     }
 
     private func setAppDragMode(_ active: Bool) {
-        guard appDragModeActive != active else { return }
+        guard appGridInteraction.appDragModeActive != active else { return }
         if active {
             endTagNavReorder()
             clearAppBubbleState()
         }
-        appDragModeActive = active
+        appGridInteraction.appDragModeActive = active
         if active {
             DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
-                if appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
-                    appDragModeActive = false
+                if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
+                    appGridInteraction.appDragModeActive = false
                 }
             }
         }
@@ -2228,13 +2278,13 @@
         keepingPendingUncategorizedDrop: Bool = false,
         keepingPendingTagRemovalDrop: Bool = false
     ) {
-        let hadAppDragState = appDragModeActive
+        let hadAppDragState = appGridInteraction.appDragModeActive
         AppDragCoordinator.shared.cancelDrag()
-        if appDragModeActive {
-            appDragModeActive = false
+        if appGridInteraction.appDragModeActive {
+            appGridInteraction.appDragModeActive = false
         }
         if hadAppDragState {
-            appDragResetToken &+= 1
+            appGridInteraction.appDragResetToken &+= 1
         }
         if tagNavDragModeActive {
             tagNavDragModeActive = false
@@ -2249,11 +2299,11 @@
             dragItem = nil
         }
         clearAppBubbleState()
-        if !keepingPendingUncategorizedDrop, pendingUncategorizedDrop != nil {
-            pendingUncategorizedDrop = nil
+        if !keepingPendingUncategorizedDrop, appGridInteraction.pendingUncategorizedDrop != nil {
+            appGridInteraction.pendingUncategorizedDrop = nil
         }
-        if !keepingPendingTagRemovalDrop, pendingTagRemovalDrop != nil {
-            pendingTagRemovalDrop = nil
+        if !keepingPendingTagRemovalDrop, appGridInteraction.pendingTagRemovalDrop != nil {
+            appGridInteraction.pendingTagRemovalDrop = nil
         }
     }
 
@@ -2268,27 +2318,10 @@
 
         refreshInProgress = true
         DispatchQueue.global(qos: .userInitiated).async {
-            let scannedApps = AppIndexer.scan()
-            let reconciledStore = TagEditor.reconcileScannedApps(scannedApps)
-            let smartStartResult = SmartStartService.runIfNeeded(
-                apps: scannedApps,
-                store: reconciledStore
-            )
-            let store = smartStartResult.store
-            let apps = TagEditor.annotate(apps: scannedApps, store: store)
-            let quickSearchDocs = QuickSearchEngine.makeDocuments(apps: apps, store: store)
-            let colors = store.tags.mapValues { $0.color }
-            let order = TagEditor.orderedTagNames()
+            let result = AppLibraryController.refresh()
             DispatchQueue.main.async {
-                allApps = apps
-                quickSearchDocuments = quickSearchDocs
-                tagColors = colors
-                draggedTagNames = order
-                rebuildDisplayGroups(apps: apps, tagOrder: order)
-                if quickSearchVisible {
-                    refreshQuickSearchResults()
-                }
-                handleSmartStartRunResult(smartStartResult)
+                applyAppLibrarySnapshot(result.snapshot)
+                handleSmartStartRunResult(result.smartStartResult)
                 if forceLayoutRefresh {
                     finishDropRefreshAfterMinimumDuration()
                 }
@@ -2303,13 +2336,24 @@
         }
     }
 
+    private func applyAppLibrarySnapshot(_ snapshot: AppLibrarySnapshot) {
+        allApps = snapshot.apps
+        quickSearchDocuments = snapshot.quickSearchDocuments
+        tagColors = snapshot.tagColors
+        draggedTagNames = snapshot.tagOrder
+        rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
+        if quickSearchVisible {
+            refreshQuickSearchResults()
+        }
+    }
+
     private func launchApp(
         _ app: AppInfo,
         closeQuickSearchOnSuccess: Bool = false,
         closeOverlayOnSuccess: Bool = true,
         onFailure: (() -> Void)? = nil
     ) {
-        appDragModeActive = false
+        appGridInteraction.appDragModeActive = false
         endTagNavReorder()
         let configuration = NSWorkspace.OpenConfiguration()
         NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
@@ -2354,12 +2398,6 @@
     static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
         value.merge(nextValue(), uniquingKeysWith: { _, new in new })
     }
-}
-
-private struct TagLabel: Identifiable {
-    var id: String { name }
-    let name: String
-    let colorIndex: Int
 }
 
 // MARK: - NSVisualEffectView bridge

--
Gitblit v1.9.3