Ariver
2026-05-20 7c97841d07d4a596508f663c887d1f67d105faf6
Apptag/ContentView.swift
@@ -227,7 +227,7 @@
            .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
            .animation(.easeOut(duration: 0.08), value: isDragging)
            .animation(.easeOut(duration: 0.08), value: dragModeActive)
            .id(dragResetToken)
            .id("\(name)|reset-\(dragResetToken)")
            .contentShape(RoundedRectangle(cornerRadius: 7))
            .onTapGesture {
                action()
@@ -264,7 +264,7 @@
            .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
            .animation(.easeOut(duration: 0.08), value: isDragging)
            .animation(.easeOut(duration: 0.08), value: dragModeActive)
            .id(dragResetToken)
            .id("\(name)|reset-\(dragResetToken)")
            .contentShape(RoundedRectangle(cornerRadius: 6))
            .onTapGesture {
                action()
@@ -337,10 +337,16 @@
struct ContentView: View {
    let hideOverlay: () -> Void
    private let initialQuickSearchSource: String?
    @State private var allApps: [AppInfo] = []
    @State private var displayGroups: [TagGroup] = []
    @State private var tagColors: [String: Int] = [:]
    @State private var scrollProxy: ScrollViewProxy? = nil
    @StateObject private var scrollInteractionState = AppGridScrollInteractionState()
    @State private var groupLayoutVersion = 0
    @State private var cachedGridContainerRowsKey: GridContainerRowsKey? = nil
    @State private var cachedGridContainerRows: [GridContainerLayoutRow] = []
    // Edit mode
    @State private var editPhase: EditPhase = .none
@@ -377,6 +383,27 @@
    @State private var bubbleDraftNote = ""
    @FocusState private var bubbleNoteFocused: Bool
    // Quick Search
    @State private var quickSearchVisible = false
    @State private var quickSearchQuery = ""
    @State private var quickSearchDocuments: [QuickSearchDocument] = []
    @State private var quickSearchResults: [QuickSearchResult] = []
    @State private var quickSearchSelectedID: URL? = nil
    @State private var quickSearchManualSelection = false
    @State private var quickSearchFocusToken = 0
    @State private var quickSearchCloseHidesOverlay = false
    @State private var quickSearchErrorMessage: String? = nil
    @State private var initialQuickSearchConsumed = false
    init(hideOverlay: @escaping () -> Void, initialQuickSearchSource: String? = nil) {
        self.hideOverlay = hideOverlay
        self.initialQuickSearchSource = initialQuickSearchSource
        let startsInQuickSearch = initialQuickSearchSource != nil
        _quickSearchVisible = State(initialValue: startsInQuickSearch)
        _quickSearchCloseHidesOverlay = State(initialValue: initialQuickSearchSource == QuickSearchOpenSource.globalHidden)
        _quickSearchFocusToken = State(initialValue: startsInQuickSearch ? 1 : 0)
    }
    // Configurable defaults
    @AppStorage("defaultGroupName") private var defaultGroupName = "Other"
    @AppStorage("tagFontSize") private var tagFontSize: Double = AppDefaults.tagFontSize
@@ -391,7 +418,7 @@
    private let floatingControlsTrailingInset: CGFloat = 20
    private let floatingControlsReservedWidth: CGFloat = 120
    private var appBubbleDisabled: Bool {
        appDragModeActive || pendingUncategorizedDrop != nil
        appDragModeActive || pendingUncategorizedDrop != nil || scrollInteractionState.isFrozen
    }
    private let rightSidebarFloatingClearance: CGFloat = 44
@@ -411,37 +438,47 @@
        displayMode == "coloredContainer" || displayMode == "coloredGridContainer"
    }
    private var quickSearchOnlyMode: Bool {
        quickSearchVisible && quickSearchCloseHidesOverlay
    }
    var body: some View {
        ZStack {
            VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
                .ignoresSafeArea()
                .allowsHitTesting(false)
            if !quickSearchOnlyMode {
                VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
                    .ignoresSafeArea()
                    .allowsHitTesting(false)
            }
            if notchHeight > 0 {
                VStack {
                    Rectangle().fill(.black)
                        .frame(height: notchHeight)
                        .ignoresSafeArea(edges: .top)
                    Spacer()
            if !quickSearchOnlyMode {
                if notchHeight > 0 {
                    VStack {
                        Rectangle().fill(.black)
                            .frame(height: notchHeight)
                            .ignoresSafeArea(edges: .top)
                        Spacer()
                    }
                    .allowsHitTesting(false)
                }
                .allowsHitTesting(false)
                switch editPhase {
                case .none:
                    normalContent
                case .editingTags:
                    editTagsView
                case .editingApps:
                    editAppsView
                }
                uncommonAppBubbleOverlay
                smartStartNoticeOverlay
                editActionFeedbackOverlay
                uncategorizedDropConfirmOverlay
            }
            switch editPhase {
            case .none:
                normalContent
            case .editingTags:
                editTagsView
            case .editingApps:
                editAppsView
            }
            quickSearchOverlay
            uncommonAppBubbleOverlay
            smartStartNoticeOverlay
            editActionFeedbackOverlay
            uncategorizedDropConfirmOverlay
            if let message = dropWarningToast {
            if !quickSearchOnlyMode, let message = dropWarningToast {
                Text(message)
                    .font(.system(size: 16, weight: .semibold))
                    .foregroundStyle(.primary)
@@ -456,7 +493,7 @@
                    .allowsHitTesting(false)
            }
            if dropRefreshVisible {
            if !quickSearchOnlyMode && dropRefreshVisible {
                Color.black.opacity(0.08)
                    .ignoresSafeArea()
                    .transition(.opacity)
@@ -478,15 +515,38 @@
        }
        .onAppear {
            refreshNotchHeight()
            refreshApps()
            refreshAppsIfNeeded()
            if let initialQuickSearchSource, !initialQuickSearchConsumed {
                initialQuickSearchConsumed = true
                quickSearchCloseHidesOverlay = initialQuickSearchSource == QuickSearchOpenSource.globalHidden
                quickSearchVisible = true
                quickSearchFocusToken &+= 1
                NotificationCenter.default.post(
                    name: .tagLauncherQuickSearchVisibilityChanged,
                    object: nil,
                    userInfo: ["active": true]
                )
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
            resetTransientDragState()
            refreshNotchHeight()
            refreshApps()
            if quickSearchVisible {
                refreshAppsIfNeeded()
            } else {
                refreshApps()
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
            resetTransientDragState()
            closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested)) { notification in
            let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
            openQuickSearch(source: source)
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested)) { _ in
            closeQuickSearch()
        }
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
            guard allApps.isEmpty, !refreshInProgress else { return }
@@ -517,6 +577,16 @@
            if editingBubble != nil && !focused {
                commitBubbleNote()
            }
        }
        .onChange(of: draggedTagNames) { _, newOrder in
            rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
        }
        .onChange(of: defaultGroupName) { _, _ in
            rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
        }
        .onChange(of: quickSearchQuery) { _, _ in
            quickSearchErrorMessage = nil
            refreshQuickSearchResults()
        }
        .onChange(of: pendingUncategorizedDrop != nil) { _, active in
            NotificationCenter.default.post(
@@ -551,6 +621,181 @@
                NotificationCenter.default.post(name: .tagLauncherEditModeChanged, object: nil, userInfo: ["active": false])
            }
        }
    }
    // MARK: - Quick Search
    private var quickSearchOverlay: some View {
        GeometryReader { proxy in
            if quickSearchVisible {
                Color.black.opacity(0.001)
                    .frame(width: proxy.size.width, height: proxy.size.height)
                    .ignoresSafeArea()
                    .contentShape(Rectangle())
                    .onTapGesture {
                        closeQuickSearch()
                    }
                    .zIndex(899)
                QuickSearchOverlayView(
                    query: $quickSearchQuery,
                    results: quickSearchResults,
                    selectedID: quickSearchSelectedID,
                    focusToken: quickSearchFocusToken,
                    isLoading: quickSearchDocuments.isEmpty && refreshInProgress,
                    maxVisibleRows: quickSearchMaxVisibleRows(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))
                .zIndex(900)
            }
        }
        .ignoresSafeArea()
        .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 quickSearchPanelTopY(in size: CGSize) -> CGFloat {
        max(notchHeight + 54, min(112, size.height * 0.12))
    }
    private func quickSearchMaxVisibleRows(in size: CGSize) -> Int {
        let bottomClearance: CGFloat = 84
        let chromeHeight: CGFloat = 122
        let rowHeightWithSpacing: CGFloat = 76
        let availableHeight = max(0, size.height - quickSearchPanelTopY(in: size) - bottomClearance - chromeHeight)
        return max(1, min(8, Int(floor(availableHeight / rowHeightWithSpacing))))
    }
    private func openQuickSearch(source: String) {
        guard canOpenQuickSearch else { return }
        dismissAppBubble()
        quickSearchCloseHidesOverlay = source == QuickSearchOpenSource.globalHidden
        quickSearchVisible = true
        quickSearchQuery = ""
        quickSearchManualSelection = false
        quickSearchErrorMessage = nil
        quickSearchFocusToken &+= 1
        refreshQuickSearchResults()
        NotificationCenter.default.post(
            name: .tagLauncherQuickSearchVisibilityChanged,
            object: nil,
            userInfo: ["active": true]
        )
    }
    private var canOpenQuickSearch: Bool {
        editPhase == .none
            && pendingUncategorizedDrop == nil
            && smartStartNotice == nil
            && !dropRefreshVisible
            && !quickSearchVisible
    }
    private func closeQuickSearch(notify: Bool = true, hideOverlayIfNeeded: Bool = true) {
        let shouldHideOverlay = quickSearchVisible && quickSearchCloseHidesOverlay && hideOverlayIfNeeded
        guard quickSearchVisible else { return }
        quickSearchVisible = false
        quickSearchQuery = ""
        quickSearchResults = []
        quickSearchSelectedID = nil
        quickSearchManualSelection = false
        quickSearchErrorMessage = nil
        quickSearchCloseHidesOverlay = false
        if notify {
            NotificationCenter.default.post(
                name: .tagLauncherQuickSearchVisibilityChanged,
                object: nil,
                userInfo: ["active": false]
            )
        }
        if shouldHideOverlay {
            hideOverlay()
        }
    }
    private func refreshQuickSearchResults() {
        let previousSelection = quickSearchSelectedID
        quickSearchResults = QuickSearchEngine.search(quickSearchQuery, documents: quickSearchDocuments)
        if quickSearchResults.isEmpty {
            quickSearchSelectedID = nil
            quickSearchManualSelection = false
            return
        }
        if quickSearchManualSelection,
           let previousSelection,
           quickSearchResults.contains(where: { $0.id == previousSelection }) {
            quickSearchSelectedID = previousSelection
        } else {
            quickSearchSelectedID = quickSearchResults.first?.id
            quickSearchManualSelection = false
        }
    }
    private func handleQuickSearchCommand(_ command: QuickSearchCommand) {
        switch command {
        case .moveUp:
            moveQuickSearchSelection(by: -1)
        case .moveDown:
            moveQuickSearchSelection(by: 1)
        case .submit:
            guard let selected = selectedQuickSearchResult else { return }
            launchQuickSearchResult(selected)
        case .dismiss:
            closeQuickSearch()
        }
    }
    private var selectedQuickSearchResult: QuickSearchResult? {
        guard let quickSearchSelectedID else { return nil }
        return quickSearchResults.first { $0.id == quickSearchSelectedID }
    }
    private func moveQuickSearchSelection(by delta: Int) {
        guard !quickSearchResults.isEmpty else { return }
        let currentIndex = quickSearchSelectedID.flatMap { id in
            quickSearchResults.firstIndex { $0.id == id }
        } ?? 0
        let nextIndex = min(max(currentIndex + delta, 0), quickSearchResults.count - 1)
        quickSearchSelectedID = quickSearchResults[nextIndex].id
        quickSearchManualSelection = true
    }
    private func selectQuickSearchResult(_ result: QuickSearchResult) {
        quickSearchSelectedID = result.id
        quickSearchManualSelection = true
    }
    private func launchQuickSearchResult(_ result: QuickSearchResult) {
        quickSearchErrorMessage = nil
        launchApp(
            result.app,
            closeQuickSearchOnSuccess: true,
            closeOverlayOnSuccess: true,
            onFailure: {
                quickSearchErrorMessage = tr("quickSearch.launchFailed")
                quickSearchFocusToken &+= 1
                NSAccessibility.post(
                    element: NSApp.mainWindow as Any,
                    notification: .announcementRequested,
                    userInfo: [.announcement: tr("quickSearch.launchFailed")]
                )
            }
        )
    }
    // MARK: - Normal Content
@@ -835,7 +1080,7 @@
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 24) {
                    ForEach(groups) { group in
                    ForEach(displayGroups) { group in
                        TagGroupView(
                            group: group,
                            onSelectApp: { app in openApp(app) },
@@ -854,7 +1099,9 @@
                            }
                        ).id(group.id)
                    }
                }.padding(20)
                }
                .padding(20)
                .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
            }
            .id(displayMode)  // force rebuild on mode switch
            .onAppear { scrollProxy = proxy }
@@ -870,7 +1117,7 @@
            let colCount = max(1, Int((available + gap) / (colW + gap)))
            let actualColW = (available - gap * CGFloat(colCount - 1)) / CGFloat(colCount)
            let columns = distributeToColumns(groups: groups, colCount: colCount, colWidth: actualColW)
            let columns = distributeToColumns(groups: displayGroups, colCount: colCount, colWidth: actualColW)
            ScrollViewReader { proxy in
                ScrollView {
@@ -885,6 +1132,7 @@
                        }
                    }
                    .padding(outerPad)
                    .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
                }
                .id(displayMode)  // force rebuild on mode switch
                .onAppear { scrollProxy = proxy }
@@ -987,6 +1235,12 @@
        .animation(.easeOut(duration: 0.045), value: isHovered)
        .animation(.easeOut(duration: 0.08), value: isColorlessFilled)
        .onHover { hovering in
            guard !scrollInteractionState.isFrozen else {
                if !hovering && hoveredContainer == group.name {
                    hoveredContainer = nil
                }
                return
            }
            if isColored || isColorless {
                hoveredContainer = hovering ? group.name : nil
            }
@@ -1008,11 +1262,23 @@
            let gap: CGFloat = 16
            let available = geo.size.width - outerPad * 2
            let preferredCount = preferredGridContainersPerRow(availableWidth: available)
            let rows = gridContainerRows(groups: groups, trackCount: preferredCount, availableWidth: available, gap: gap)
            let rowsKey = GridContainerRowsKey(
                groupLayoutVersion: groupLayoutVersion,
                trackCount: preferredCount,
                availableWidth: Int(available.rounded()),
                iconSize: Int(iconSize.rounded())
            )
            let rows = gridContainerRowsForRender(
                key: rowsKey,
                groups: displayGroups,
                trackCount: preferredCount,
                availableWidth: available,
                gap: gap
            )
            ScrollViewReader { proxy in
                ScrollView {
                    VStack(alignment: .leading, spacing: gap) {
                    LazyVStack(alignment: .leading, spacing: gap) {
                        ForEach(Array(rows.indices), id: \.self) { rowIndex in
                            let row = rows[rowIndex]
@@ -1027,9 +1293,28 @@
                    }
                    .padding(outerPad)
                    .frame(maxWidth: .infinity, alignment: .topLeading)
                    .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
                }
                .id(displayMode)
                .onAppear { scrollProxy = proxy }
                .onAppear {
                    updateCachedGridContainerRows(
                        key: rowsKey,
                        groups: displayGroups,
                        trackCount: preferredCount,
                        availableWidth: available,
                        gap: gap
                    )
                }
                .onChange(of: rowsKey) { _, newKey in
                    updateCachedGridContainerRows(
                        key: newKey,
                        groups: displayGroups,
                        trackCount: preferredCount,
                        availableWidth: available,
                        gap: gap
                    )
                }
            }
        }
    }
@@ -1051,10 +1336,52 @@
        let fixedRows: Int
    }
    private struct GridContainerRowsKey: Equatable {
        let groupLayoutVersion: Int
        let trackCount: Int
        let availableWidth: Int
        let iconSize: Int
    }
    private struct GridContainerCandidate {
        let spans: [Int]
        let rows: Int
        let cost: CGFloat
    }
    private func gridContainerRowsForRender(
        key: GridContainerRowsKey,
        groups: [TagGroup],
        trackCount: Int,
        availableWidth: CGFloat,
        gap: CGFloat
    ) -> [GridContainerLayoutRow] {
        if cachedGridContainerRowsKey == key {
            return cachedGridContainerRows
        }
        return gridContainerRows(
            groups: groups,
            trackCount: trackCount,
            availableWidth: availableWidth,
            gap: gap
        )
    }
    private func updateCachedGridContainerRows(
        key: GridContainerRowsKey,
        groups: [TagGroup],
        trackCount: Int,
        availableWidth: CGFloat,
        gap: CGFloat
    ) {
        guard cachedGridContainerRowsKey != key else { return }
        cachedGridContainerRows = gridContainerRows(
            groups: groups,
            trackCount: trackCount,
            availableWidth: availableWidth,
            gap: gap
        )
        cachedGridContainerRowsKey = key
    }
    private func gridContainerRows(groups: [TagGroup], trackCount: Int, availableWidth: CGFloat, gap: CGFloat) -> [GridContainerLayoutRow] {
@@ -1239,6 +1566,12 @@
        .animation(.easeOut(duration: 0.045), value: isHovered)
        .animation(.easeOut(duration: 0.08), value: isColorlessFilled)
        .onHover { hovering in
            guard !scrollInteractionState.isFrozen else {
                if !hovering && hoveredContainer == group.name {
                    hoveredContainer = nil
                }
                return
            }
            if isColored || isColorlessGrid {
                hoveredContainer = hovering ? group.name : nil
            } else if hovering {
@@ -1765,14 +2098,20 @@
            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()
            DispatchQueue.main.async {
                pendingSmartStartDraft = nil
                allApps = apps
                quickSearchDocuments = quickSearchDocs
                tagColors = colors
                draggedTagNames = order
                rebuildDisplayGroups(apps: apps, tagOrder: order)
                if quickSearchVisible {
                    refreshQuickSearchResults()
                }
                if let summary = result.summary {
                    showSmartStartNotice(mode: .manuallyApplied, summary: summary)
                } else {
@@ -1831,17 +2170,10 @@
        tagName == TagDatabase.uncommonTagKey ? tr("group.uncommon") : tagName
    }
    private var tagLabels: [TagLabel] {
        groups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
    }
    private var groups: [TagGroup] {
        let order = draggedTagNames.isEmpty
            ? TagEditor.orderedTagNames()
            : draggedTagNames
        let raw = AppIndexer.group(apps: allApps, defaultGroupName: defaultGroupName, tagOrder: order)
        // Translate stable keys for display
        return raw.map { group in
    private func makeDisplayGroups(apps: [AppInfo], tagOrder: [String]) -> [TagGroup] {
        let order = tagOrder.isEmpty ? TagEditor.orderedTagNames() : tagOrder
        let rawGroups = AppIndexer.group(apps: apps, defaultGroupName: defaultGroupName, tagOrder: order)
        return rawGroups.map { group in
            if group.name == defaultGroupName {
                return TagGroup(name: tr("group.uncategorized"), apps: group.apps)
            }
@@ -1852,8 +2184,18 @@
        }
    }
    private func rebuildDisplayGroups(apps: [AppInfo], tagOrder: [String]) {
        displayGroups = makeDisplayGroups(apps: apps, tagOrder: tagOrder)
        groupLayoutVersion &+= 1
        cachedGridContainerRowsKey = nil
    }
    private var tagLabels: [TagLabel] {
        displayGroups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
    }
    private var editGroups: [TagGroup] {
        groups + [
        displayGroups + [
            TagGroup(
                name: tr("group.uncommon"),
                apps: allApps.filter(\.isUncommon).sorted {
@@ -1865,6 +2207,11 @@
    // MARK: - Actions
    private func refreshAppsIfNeeded() {
        guard allApps.isEmpty, !refreshInProgress else { return }
        refreshApps()
    }
    private func refreshNotchHeight() {
        let mousePoint = NSEvent.mouseLocation
        let activeScreen = NSScreen.screens.first(where: {
@@ -1873,15 +2220,30 @@
        notchHeight = activeScreen?.safeAreaInsets.top ?? 0
    }
    private func handleBubbleHover(app: AppInfo, frame: CGRect, hovering: Bool) {
    private func handleAppGridScrollActivity() {
        if !scrollInteractionState.isFrozen {
            hoveredAppItemID = nil
            hoveredBubble = nil
            hoveredContainer = nil
        }
        scrollInteractionState.noteScroll()
    }
    private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
        guard !appBubbleDisabled else {
            clearAppBubbleState()
            return
        }
        guard editingBubble == nil else { return }
        if hovering {
            hoveredBubble = AppBubbleContext(app: app, frame: frame)
        } else if hoveredBubble?.app.path == app.path {
        switch event {
        case .entered(let canShowBubble):
            if canShowBubble {
                hoveredBubble = AppBubbleContext(app: app, frame: frame)
            } else {
                hoveredBubble = nil
            }
        case .exited:
            guard hoveredBubble?.app.path == app.path else { return }
            hoveredBubble = nil
        }
    }
@@ -2264,6 +2626,7 @@
    private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) {
        AppDragCoordinator.shared.cancelDrag()
        scrollInteractionState.reset()
        appDragModeActive = false
        appDragResetToken &+= 1
        tagNavDragModeActive = false
@@ -2298,12 +2661,18 @@
            )
            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()
            DispatchQueue.main.async {
                allApps = apps
                quickSearchDocuments = quickSearchDocs
                tagColors = colors
                draggedTagNames = order
                rebuildDisplayGroups(apps: apps, tagOrder: order)
                if quickSearchVisible {
                    refreshQuickSearchResults()
                }
                handleSmartStartRunResult(smartStartResult)
                if forceLayoutRefresh {
                    finishDropRefreshAfterMinimumDuration()
@@ -2319,15 +2688,138 @@
        }
    }
    func openApp(_ app: AppInfo) {
    private func launchApp(
        _ app: AppInfo,
        closeQuickSearchOnSuccess: Bool = false,
        closeOverlayOnSuccess: Bool = true,
        onFailure: (() -> Void)? = nil
    ) {
        appDragModeActive = false
        endTagNavReorder()
        hideOverlay()
        DispatchQueue.global(qos: .utility).async {
            TagEditor.recordLauncherOpen(for: app.path.path)
        }
        let configuration = NSWorkspace.OpenConfiguration()
        NSWorkspace.shared.openApplication(at: app.path, configuration: configuration)
        NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
            DispatchQueue.main.async {
                guard error == nil else {
                    onFailure?()
                    return
                }
                DispatchQueue.global(qos: .utility).async {
                    TagEditor.recordLauncherOpen(for: app.path.path)
                }
                if closeQuickSearchOnSuccess {
                    closeQuickSearch(hideOverlayIfNeeded: false)
                }
                if closeOverlayOnSuccess {
                    hideOverlay()
                }
            }
        }
    }
    func openApp(_ app: AppInfo) {
        launchApp(app)
    }
}
private final class AppGridScrollInteractionState: ObservableObject {
    @Published var isFrozen = false
    private var unfreezeWorkItem: DispatchWorkItem?
    func noteScroll() {
        if !isFrozen {
            isFrozen = true
        }
        unfreezeWorkItem?.cancel()
        let workItem = DispatchWorkItem { [weak self] in
            self?.isFrozen = false
        }
        unfreezeWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: workItem)
    }
    func reset() {
        unfreezeWorkItem?.cancel()
        unfreezeWorkItem = nil
        isFrozen = false
    }
}
private struct AppGridScrollActivityObserver: NSViewRepresentable {
    let onScroll: () -> Void
    func makeCoordinator() -> Coordinator {
        Coordinator(onScroll: onScroll)
    }
    func makeNSView(context: Context) -> ScrollActivityNSView {
        let view = ScrollActivityNSView()
        view.coordinator = context.coordinator
        return view
    }
    func updateNSView(_ view: ScrollActivityNSView, context: Context) {
        context.coordinator.onScroll = onScroll
        view.coordinator = context.coordinator
        view.installObserverIfPossible()
    }
    final class Coordinator {
        var onScroll: () -> Void
        private weak var observedClipView: NSClipView?
        private var observer: NSObjectProtocol?
        init(onScroll: @escaping () -> Void) {
            self.onScroll = onScroll
        }
        func install(from view: NSView) {
            guard let clipView = view.enclosingScrollView?.contentView else { return }
            guard observedClipView !== clipView else { return }
            removeObserver()
            observedClipView = clipView
            clipView.postsBoundsChangedNotifications = true
            observer = NotificationCenter.default.addObserver(
                forName: NSView.boundsDidChangeNotification,
                object: clipView,
                queue: .main
            ) { [weak self] _ in
                self?.onScroll()
            }
        }
        private func removeObserver() {
            if let observer {
                NotificationCenter.default.removeObserver(observer)
            }
            observer = nil
            observedClipView = nil
        }
        deinit {
            removeObserver()
        }
    }
    final class ScrollActivityNSView: NSView {
        weak var coordinator: Coordinator?
        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            installObserverIfPossible()
        }
        override func viewDidMoveToSuperview() {
            super.viewDidMoveToSuperview()
            installObserverIfPossible()
        }
        func installObserverIfPossible() {
            DispatchQueue.main.async { [weak self] in
                guard let self else { return }
                coordinator?.install(from: self)
            }
        }
    }
}