Ariver
2026-06-02 406c5898d103427193e589b4bbe615d0c739b2c5
Prepare 7.8.9 tag navigation drop test package
9 files modified
2 files added
519 ■■■■■ changed files
AGENTS.md 13 ●●●●● patch | view | raw | blame | history
Apptag/AppDragCoordinator.swift 51 ●●●● patch | view | raw | blame | history
Apptag/ApptagApp.swift 83 ●●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 75 ●●●●● patch | view | raw | blame | history
Apptag/Diagnostics.swift 44 ●●●●● patch | view | raw | blame | history
Apptag/Info.plist 4 ●●●● patch | view | raw | blame | history
Apptag/OverlayWindowController.swift 16 ●●●● patch | view | raw | blame | history
Apptag/QuickSearch.swift 4 ●●● patch | view | raw | blame | history
Apptag/TagNavigationView.swift 126 ●●●●● patch | view | raw | blame | history
CHANGELOG.md 18 ●●●●● patch | view | raw | blame | history
Scripts/window_logic_qa.sh 85 ●●●●● patch | view | raw | blame | history
AGENTS.md
New file
@@ -0,0 +1,13 @@
# AGENTS.md - Taglauncher Agent 入口
## 项目属性
项目类型:研发项目
主要产物:macOS App
## 项目规则
- 默认遵守 `/Users/ar/.codex/AGENTS.md`;项目本地文档、脚本和 release 规则优先。
- 发布、分发或验证安装包前,必须读取并遵守 `~/.codex/rules/release-versioning.md`。
- 可分发构建应先运行 `./build.sh` 生成 `.app`,再运行 `./make_dmg.sh` 生成 `.dmg`;只生成 `.app` 只能视为本地 QA 构建。
- 版本、build 编号、`CHANGELOG.md`、`Release/` 资料和最终安装包文件必须保持一致。
Apptag/AppDragCoordinator.swift
@@ -25,6 +25,7 @@
    private var dragWindow: NSWindow?
    private var dragImageSize: NSSize = .zero
    private var activePayload = ""
    private weak var hoveredTarget: AppDropTargetReceivingView?
    private init() {}
@@ -129,15 +130,20 @@
            CATransaction.setDisableActions(true)
            dragLayer.position = contentPoint
            CATransaction.commit()
            updateHoverTarget(at: screenPoint)
            return
        }
        guard let dragWindow else { return }
        guard let dragWindow else {
            updateHoverTarget(at: screenPoint)
            return
        }
        let origin = NSPoint(
            x: screenPoint.x - dragImageSize.width / 2,
            y: screenPoint.y - dragImageSize.height / 2
        )
        dragWindow.setFrameOrigin(origin)
        updateHoverTarget(at: screenPoint)
    }
    func finishDrag(at screenPoint: NSPoint, copy: Bool) {
@@ -147,18 +153,7 @@
        let source = parts.dropFirst().first ?? ""
        pruneDeadTargets()
        let hitTarget = targets.values
            .compactMap { target -> (AppDropTargetReceivingView, CGFloat)? in
                guard let view = target.view,
                      let frame = view.screenFrame(),
                      frame.contains(screenPoint)
                else { return nil }
                return (view, frame.width * frame.height)
            }
            .sorted { $0.1 < $1.1 }
            .first?.0
        if let hitTarget {
        if let hitTarget = dropTarget(at: screenPoint) {
            hitTarget.performDrop(path: path, source: source, copy: copy)
            return
        }
@@ -191,6 +186,8 @@
    }
    private func endDragVisuals() {
        hoveredTarget?.appDragHoverChanged(active: false)
        hoveredTarget = nil
        dragLayer?.removeFromSuperlayer()
        dragLayer = nil
        dragLayerHostView = nil
@@ -207,6 +204,29 @@
    private func pruneDeadTargets() {
        targets = targets.filter { $0.value.view != nil }
        emptyTargets = emptyTargets.filter { $0.value.view != nil }
    }
    private func updateHoverTarget(at screenPoint: NSPoint) {
        guard hasActiveDrag else { return }
        pruneDeadTargets()
        let nextTarget = dropTarget(at: screenPoint)
        guard nextTarget !== hoveredTarget else { return }
        hoveredTarget?.appDragHoverChanged(active: false)
        nextTarget?.appDragHoverChanged(active: true)
        hoveredTarget = nextTarget
    }
    private func dropTarget(at screenPoint: NSPoint) -> AppDropTargetReceivingView? {
        targets.values
            .compactMap { target -> (AppDropTargetReceivingView, CGFloat)? in
                guard let view = target.view,
                      let frame = view.screenFrame(),
                      frame.contains(screenPoint)
                else { return nil }
                return (view, frame.width * frame.height)
            }
            .sorted { $0.1 < $1.1 }
            .first?.0
    }
    private static func cgImage(from image: NSImage) -> CGImage? {
@@ -261,6 +281,7 @@
protocol AppDropTargetReceivingView: AnyObject {
    func screenFrame() -> NSRect?
    func appDragHoverChanged(active: Bool)
    func performDrop(path: String, source: String, copy: Bool)
}
@@ -277,6 +298,10 @@
    }
}
extension AppDropTargetReceivingView {
    func appDragHoverChanged(active: Bool) {}
}
extension AppEmptyDropReceivingView where Self: NSView {
    func screenFrame() -> NSRect? {
        guard let window else { return nil }
Apptag/ApptagApp.swift
@@ -62,6 +62,7 @@
    private var lastShowDockIcon: Bool?
    private var statusMenuScreenForNextOverlay: NSScreen?
    private var suppressReopenUntil = Date.distantPast
    private var overlayOpenedByQuickSearchOnly = false
    private lazy var overlayController = OverlayWindowController(
        dependencies: OverlayWindowController.Dependencies(
@@ -106,6 +107,11 @@
                self?.removeOverlayKeyMonitor()
            },
            removeQuickSearchMouseMonitor: { [weak self] in
                Diagnostics.log("app.quickSearch.removeMouseMonitor", [
                    "quickSearchOpen": self?.isQuickSearchOpen,
                    "quickSearchOnlyOverlaySession": self?.quickSearchOnlyOverlaySession,
                    "quickSearchShouldHideOverlayOnClose": self?.quickSearchShouldHideOverlayOnClose
                ])
                self?.removeQuickSearchExternalMouseMonitor()
                self?.quickSearchShouldHideOverlayOnClose = false
                self?.quickSearchOnlyOverlaySession = false
@@ -122,7 +128,8 @@
            onWillHide: {
                TagDatabase.flushPendingCategorySchemeBackupBatch()
            },
            onDidHide: {
            onDidHide: { [weak self] in
                self?.overlayOpenedByQuickSearchOnly = false
                NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil)
            },
            onDidShow: {
@@ -220,9 +227,22 @@
            return false
        }
        guard UserDefaults.standard.bool(forKey: Self.showDockIconKey) else { return false }
        guard isPointerNearDockArea() else { return false }
        guard isRecentUserClickNearDockArea() else { return false }
        showOrFocusOverlay()
        return false  // Suppress default "unhide all windows" behavior
    }
    private func isRecentUserClickNearDockArea() -> Bool {
        let lastMouseDown = CGEventSource.secondsSinceLastEventType(
            .combinedSessionState,
            eventType: .leftMouseDown
        )
        let lastMouseUp = CGEventSource.secondsSinceLastEventType(
            .combinedSessionState,
            eventType: .leftMouseUp
        )
        let recentMouseClick = min(lastMouseDown, lastMouseUp) < 1.2
        return recentMouseClick && isPointerNearDockArea()
    }
    private func isPointerNearDockArea() -> Bool {
@@ -881,10 +901,12 @@
    }
    private func performToggleOverlay(preferredScreen: NSScreen?) {
        overlayOpenedByQuickSearchOnly = false
        overlayController.toggle(preferredScreen: preferredScreen)
    }
    private func showOrFocusOverlay(preferredScreen: NSScreen? = nil) {
        overlayOpenedByQuickSearchOnly = false
        overlayController.showOrFocus(preferredScreen: preferredScreen)
    }
@@ -893,10 +915,20 @@
        preferredScreen: NSScreen? = nil,
        stagedForAllSpaces: Bool = false
    ) {
        Diagnostics.log("app.overlay.showRequest", [
            "initialQuickSearchSource": initialQuickSearchSource,
            "stagedForAllSpaces": stagedForAllSpaces,
            "isQuickSearchOpen": isQuickSearchOpen,
            "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
            "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
        ])
        if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
            isQuickSearchOpen = true
            quickSearchShouldHideOverlayOnClose = true
            quickSearchOnlyOverlaySession = true
            overlayOpenedByQuickSearchOnly = true
        } else if initialQuickSearchSource == nil {
            overlayOpenedByQuickSearchOnly = false
        }
        overlayController.show(
            initialQuickSearchSource: initialQuickSearchSource,
@@ -959,8 +991,9 @@
        if shouldHideOverlayAfterDismiss {
            quickSearchShouldHideOverlayOnClose = false
            quickSearchOnlyOverlaySession = false
            overlayOpenedByQuickSearchOnly = true
            DispatchQueue.main.async { [weak self] in
                self?.hideOverlay(force: true)
                self?.hideOverlay(force: true, discardWindow: true)
            }
        }
    }
@@ -971,6 +1004,9 @@
            || source == QuickSearchOpenSource.globalHidden
        quickSearchOnlyOverlaySession = quickSearchOnlyOverlaySession
            || source == QuickSearchOpenSource.globalHidden
        if source == QuickSearchOpenSource.globalHidden {
            overlayOpenedByQuickSearchOnly = true
        }
        promoteOverlayToForegroundInput()
        updateOverlayLevelForTextInput()
        installQuickSearchExternalMouseMonitor()
@@ -1014,6 +1050,14 @@
    }
    private func hideOverlay(force: Bool = false, discardWindow: Bool = false) {
        Diagnostics.log("app.overlay.hideRequest", [
            "force": force,
            "discardWindow": discardWindow,
            "isQuickSearchOpen": isQuickSearchOpen,
            "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
            "quickSearchShouldHideOverlayOnClose": quickSearchShouldHideOverlayOnClose,
            "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
        ])
        overlayController.hide(force: force, discardWindow: discardWindow)
    }
@@ -1106,6 +1150,13 @@
    }
    private func handleHotkeyEvent(id: UInt32) {
        Diagnostics.log("app.hotkey", [
            "id": id,
            "isOverlayVisible": isOverlayVisible,
            "isQuickSearchOpen": isQuickSearchOpen,
            "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession,
            "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly
        ])
        if id == LauncherHotkeyKind.quickSearch.eventID {
            showQuickSearchFromGlobalHotkey()
        } else {
@@ -1115,9 +1166,14 @@
    private func showQuickSearchFromGlobalHotkey() {
        let now = Date()
        if overlayWindow?.isVisible == true, isQuickSearchOpen || quickSearchOnlyOverlaySession {
        if overlayWindow?.isVisible == true,
           isQuickSearchOpen || quickSearchOnlyOverlaySession || overlayOpenedByQuickSearchOnly {
            lastQuickSearchHotkeyAt = now
            dismissQuickSearchFromKeyboard()
            if isQuickSearchOpen || quickSearchOnlyOverlaySession {
                dismissQuickSearchFromKeyboard()
            } else {
                hideOverlay(force: true)
            }
            return
        }
@@ -1411,10 +1467,23 @@
            guard let self else { return }
            let active = (notification.userInfo?["active"] as? Bool) ?? false
            let hideOverlayOnClose = (notification.userInfo?["hideOverlayOnClose"] as? Bool) ?? false
            let wasQuickSearchOnlyOverlay = self.quickSearchOnlyOverlaySession
                || self.quickSearchShouldHideOverlayOnClose
                || self.overlayOpenedByQuickSearchOnly
            Diagnostics.log("app.quickSearch.visibilityChanged", [
                "active": active,
                "hideOverlayOnClose": hideOverlayOnClose,
                "isQuickSearchOpenBefore": self.isQuickSearchOpen,
                "quickSearchOnlyOverlaySessionBefore": self.quickSearchOnlyOverlaySession,
                "quickSearchShouldHideOverlayOnCloseBefore": self.quickSearchShouldHideOverlayOnClose,
                "overlayOpenedByQuickSearchOnlyBefore": self.overlayOpenedByQuickSearchOnly,
                "wasQuickSearchOnlyOverlay": wasQuickSearchOnlyOverlay
            ])
            self.isQuickSearchOpen = active
            if active, hideOverlayOnClose {
                self.quickSearchShouldHideOverlayOnClose = true
                self.quickSearchOnlyOverlaySession = true
                self.overlayOpenedByQuickSearchOnly = true
            }
            if self.isQuickSearchOpen {
                self.promoteOverlayToForegroundInput()
@@ -1424,10 +1493,10 @@
                self.installQuickSearchExternalMouseMonitor()
            } else {
                self.removeQuickSearchExternalMouseMonitor()
                if hideOverlayOnClose || self.quickSearchShouldHideOverlayOnClose {
                if hideOverlayOnClose || wasQuickSearchOnlyOverlay {
                    self.quickSearchShouldHideOverlayOnClose = false
                    self.quickSearchOnlyOverlaySession = false
                    self.hideOverlay(force: true)
                    self.hideOverlay(force: true, discardWindow: wasQuickSearchOnlyOverlay)
                }
            }
        }
Apptag/ContentView.swift
@@ -318,6 +318,7 @@
    var appDragResetToken = 0
    var bubbleDraftNote = ""
    var tagNavigationHoveredGroupName: String? = nil
    var tagNavigationAppDropTargetName: String? = nil
    var tagNavigationLastHoverScrollID: String? = nil
    var tagNavigationLastHoverScrollAt: Date? = nil
}
@@ -948,6 +949,7 @@
    private func appKitTagNavigation(orientation: TagNavigationView.Orientation) -> some View {
        let isHorizontal = orientation == .horizontal
        let appDropTargetID = appGridInteraction.tagNavigationAppDropTargetName
        return TagNavigationView(
            items: tagLabels,
            orientation: orientation,
@@ -956,6 +958,8 @@
                : NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12),
            dragModeActive: tagNavDragModeActive,
            draggingItemID: tagNavDragItem,
            appDragModeActive: appGridInteraction.appDragModeActive,
            appDropTargetID: appDropTargetID,
            onActivate: { tagID in
                activateTagNavigation(tagID)
            },
@@ -965,6 +969,9 @@
            canReorder: { tagID in
                canReorderTag(tagID)
            },
            canAcceptAppDrop: { tagID in
                canAssignDroppedAppToTag(tagID)
            },
            onReorderBegan: { tagID in
                beginTagNavReorder(tagID)
            },
@@ -973,6 +980,12 @@
            },
            onReorderEnded: {
                endTagNavReorder()
            },
            onAppDropHoverChange: { tagID, active in
                handleTagNavigationAppDropHover(tagID, active: active)
            },
            onAppDrop: { path, source, targetTag, copy in
                dropAppOnTagNavigation(path: path, sourceTag: source, targetTag: targetTag, copy: copy)
            }
        )
    }
@@ -1903,6 +1916,13 @@
    }
    private func handleTagNavigationHover(_ id: String, active: Bool) {
        if appGridInteraction.appDragModeActive {
            if !active, appGridInteraction.tagNavigationHoveredGroupName == id {
                appGridInteraction.tagNavigationHoveredGroupName = nil
            }
            return
        }
        guard active else {
            if appGridInteraction.tagNavigationHoveredGroupName == id {
                appGridInteraction.tagNavigationHoveredGroupName = nil
@@ -1913,6 +1933,24 @@
        appGridInteraction.tagNavigationHoveredGroupName = id
        fillColorlessContainer(id)
        scrollToTagFromHover(id)
    }
    private func handleTagNavigationAppDropHover(_ id: String, active: Bool) {
        guard appGridInteraction.appDragModeActive else {
            if appGridInteraction.tagNavigationAppDropTargetName == id {
                appGridInteraction.tagNavigationAppDropTargetName = nil
            }
            return
        }
        guard active, canAssignDroppedAppToTag(id) else {
            if appGridInteraction.tagNavigationAppDropTargetName == id {
                appGridInteraction.tagNavigationAppDropTargetName = nil
            }
            return
        }
        appGridInteraction.tagNavigationAppDropTargetName = id
    }
    private func scrollToTagFromHover(_ id: String) {
@@ -2050,6 +2088,22 @@
        )
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
    private func dropAppOnTagNavigation(path: String, sourceTag: String, targetTag: String, copy: Bool) {
        resetTransientDragState()
        guard canAssignDroppedAppToTag(targetTag) else { return }
        guard let app = allApps.first(where: { $0.path.path == path }) else { return }
        guard !appHasTag(app, tagName: targetTag) else { return }
        TagEditor.appendTags([targetTag], to: [path])
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
    private func canAssignDroppedAppToTag(_ tagName: String) -> Bool {
        tagColors[tagName] != nil
            && !isAppleBuiltInDropTarget(tagName)
            && !isUncategorizedDropTarget(tagName)
    }
    private func dropAppOutsideGroup(path: String, sourceTag: String, copy: Bool) {
@@ -2263,12 +2317,17 @@
        if active {
            endTagNavReorder()
            clearAppBubbleState()
            appGridInteraction.tagNavigationAppDropTargetName = nil
        }
        appGridInteraction.appDragModeActive = active
        if !active {
            appGridInteraction.tagNavigationAppDropTargetName = nil
        }
        if active {
            DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
                if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
                    appGridInteraction.appDragModeActive = false
                    appGridInteraction.tagNavigationAppDropTargetName = nil
                }
            }
        }
@@ -2282,6 +2341,9 @@
        AppDragCoordinator.shared.cancelDrag()
        if appGridInteraction.appDragModeActive {
            appGridInteraction.appDragModeActive = false
        }
        if appGridInteraction.tagNavigationAppDropTargetName != nil {
            appGridInteraction.tagNavigationAppDropTargetName = nil
        }
        if hadAppDragState {
            appGridInteraction.appDragResetToken &+= 1
@@ -2355,6 +2417,13 @@
    ) {
        appGridInteraction.appDragModeActive = false
        endTagNavReorder()
        let closeQuickSearchAndOverlayBeforeOpening = closeQuickSearchOnSuccess
            && closeOverlayOnSuccess
            && (quickSearchCloseHidesOverlay || quickSearchOnlySession)
        if closeQuickSearchAndOverlayBeforeOpening {
            closeQuickSearch(hideOverlayIfNeeded: true)
            hideOverlay()
        }
        let configuration = NSWorkspace.OpenConfiguration()
        NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
            DispatchQueue.main.async {
@@ -2366,10 +2435,10 @@
                DispatchQueue.global(qos: .utility).async {
                    TagEditor.recordLauncherOpen(for: app.path.path)
                }
                if closeQuickSearchOnSuccess {
                    closeQuickSearch(hideOverlayIfNeeded: false)
                if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
                    closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess)
                }
                if closeOverlayOnSuccess {
                if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
                    hideOverlay()
                }
            }
Apptag/Diagnostics.swift
New file
@@ -0,0 +1,44 @@
import Foundation
enum Diagnostics {
    private static let enabledKey = "diagnosticLoggingEnabled"
    private static let queue = DispatchQueue(label: "com.taglauncher.app.diagnostics")
    static var logURL: URL {
        AppIdentity.applicationSupportDirectory
            .appendingPathComponent("TagLauncher-diagnostics.log")
    }
    static func log(_ event: String, _ fields: [String: CustomStringConvertible?] = [:]) {
        guard UserDefaults.standard.bool(forKey: enabledKey) else { return }
        let timestamp = ISO8601DateFormatter().string(from: Date())
        let suffix = fields
            .sorted { $0.key < $1.key }
            .map { key, value in "\(key)=\(value?.description ?? "nil")" }
            .joined(separator: " ")
        let line = suffix.isEmpty
            ? "\(timestamp) \(event)\n"
            : "\(timestamp) \(event) \(suffix)\n"
        queue.async {
            do {
                try FileManager.default.createDirectory(
                    at: AppIdentity.applicationSupportDirectory,
                    withIntermediateDirectories: true
                )
                if !FileManager.default.fileExists(atPath: logURL.path) {
                    FileManager.default.createFile(atPath: logURL.path, contents: nil)
                }
                let handle = try FileHandle(forWritingTo: logURL)
                defer { try? handle.close() }
                try handle.seekToEnd()
                if let data = line.data(using: .utf8) {
                    try handle.write(contentsOf: data)
                }
            } catch {
                fputs("[TagLauncher] Diagnostics log failed: \(error)\n", stderr)
            }
        }
    }
}
Apptag/Info.plist
@@ -19,9 +19,9 @@
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>7.8.7</string>
    <string>7.8.9</string>
    <key>CFBundleVersion</key>
    <string>20260531.0252</string>
    <string>20260602.1929</string>
    <key>LSApplicationCategoryType</key>
    <string>public.app-category.utilities</string>
    <key>LSMinimumSystemVersion</key>
Apptag/OverlayWindowController.swift
@@ -130,6 +130,7 @@
            initialQuickSearchSource: initialQuickSearchSource
        )
        generation &+= 1
        let showGeneration = generation
        window = newWindow
        dependencies.installOverlayKeyMonitor()
        if shouldStageAsAccessory {
@@ -149,6 +150,7 @@
        let placementFrame = placement.frame
        let finishForegroundClaim: () -> Void = { [weak self, weak newWindow] in
            guard let self, let newWindow, self.window === newWindow else { return }
            guard self.generation == showGeneration, newWindow.isVisible else { return }
            self.dependencies.refreshChromeState(!shouldAvoidSpaceSwitch, shouldAvoidSpaceSwitch)
            if newWindow.frame != placementFrame {
                newWindow.setFrame(placementFrame, display: true)
@@ -157,6 +159,7 @@
            for delay in [0.2, 0.5] {
                DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self, weak newWindow] in
                    guard let self, let newWindow, self.window === newWindow else { return }
                    guard self.generation == showGeneration, newWindow.isVisible else { return }
                    guard newWindow.frame != placementFrame else { return }
                    newWindow.setFrame(placementFrame, display: true)
                    self.orderFront(newWindow)
@@ -182,15 +185,20 @@
        if let settingsWindow = dependencies.settingsWindow(), settingsWindow.parent == window {
            dependencies.detachSettingsWindow(settingsWindow)
        }
        window?.orderOut(nil)
        let windowToHide = window
        windowToHide?.ignoresMouseEvents = true
        windowToHide?.alphaValue = 0
        windowToHide?.orderOut(nil)
        dependencies.removeOverlayKeyMonitor()
        dependencies.removeQuickSearchMouseMonitor()
        windowToHide?.contentView = nil
        windowToHide?.close()
        if window === windowToHide {
            window = nil
        }
        avoidsSpaceSwitch = false
        dependencies.refreshChromeState(false, false)
        dependencies.onDidHide()
        if discardWindow {
            window = nil
        }
    }
    func screenUnderMouse() -> NSScreen? {
Apptag/QuickSearch.swift
@@ -1385,7 +1385,9 @@
    override func mouseDown(with event: NSEvent) {
        if let result {
            onLaunch(result)
            DispatchQueue.main.async { [onLaunch] in
                onLaunch(result)
            }
        } else {
            super.mouseDown(with: event)
        }
Apptag/TagNavigationView.swift
@@ -18,12 +18,17 @@
    let contentInsets: NSEdgeInsets
    let dragModeActive: Bool
    let draggingItemID: String?
    let appDragModeActive: Bool
    let appDropTargetID: String?
    let onActivate: (String) -> Void
    let onHoverChange: (String, Bool) -> Void
    let canReorder: (String) -> Bool
    let canAcceptAppDrop: (String) -> Bool
    let onReorderBegan: (String) -> Void
    let onReorderMoved: (String, String) -> Void
    let onReorderEnded: () -> Void
    let onAppDropHoverChange: (String, Bool) -> Void
    let onAppDrop: (String, String, String, Bool) -> Void
    func makeNSView(context: Context) -> TagNavigationHostView {
        let view = TagNavigationHostView()
@@ -33,12 +38,17 @@
            contentInsets: contentInsets,
            dragModeActive: dragModeActive,
            draggingItemID: draggingItemID,
            appDragModeActive: appDragModeActive,
            appDropTargetID: appDropTargetID,
            onActivate: onActivate,
            onHoverChange: onHoverChange,
            canReorder: canReorder,
            canAcceptAppDrop: canAcceptAppDrop,
            onReorderBegan: onReorderBegan,
            onReorderMoved: onReorderMoved,
            onReorderEnded: onReorderEnded
            onReorderEnded: onReorderEnded,
            onAppDropHoverChange: onAppDropHoverChange,
            onAppDrop: onAppDrop
        )
        return view
    }
@@ -50,12 +60,17 @@
            contentInsets: contentInsets,
            dragModeActive: dragModeActive,
            draggingItemID: draggingItemID,
            appDragModeActive: appDragModeActive,
            appDropTargetID: appDropTargetID,
            onActivate: onActivate,
            onHoverChange: onHoverChange,
            canReorder: canReorder,
            canAcceptAppDrop: canAcceptAppDrop,
            onReorderBegan: onReorderBegan,
            onReorderMoved: onReorderMoved,
            onReorderEnded: onReorderEnded
            onReorderEnded: onReorderEnded,
            onAppDropHoverChange: onAppDropHoverChange,
            onAppDrop: onAppDrop
        )
    }
}
@@ -82,12 +97,17 @@
        contentInsets: NSEdgeInsets,
        dragModeActive: Bool,
        draggingItemID: String?,
        appDragModeActive: Bool,
        appDropTargetID: String?,
        onActivate: @escaping (String) -> Void,
        onHoverChange: @escaping (String, Bool) -> Void,
        canReorder: @escaping (String) -> Bool,
        canAcceptAppDrop: @escaping (String) -> Bool,
        onReorderBegan: @escaping (String) -> Void,
        onReorderMoved: @escaping (String, String) -> Void,
        onReorderEnded: @escaping () -> Void
        onReorderEnded: @escaping () -> Void,
        onAppDropHoverChange: @escaping (String, Bool) -> Void,
        onAppDrop: @escaping (String, String, String, Bool) -> Void
    ) {
        documentView.update(
            items: items,
@@ -95,15 +115,20 @@
            contentInsets: contentInsets,
            dragModeActive: dragModeActive,
            draggingItemID: draggingItemID,
            appDragModeActive: appDragModeActive,
            appDropTargetID: appDropTargetID,
            onActivate: onActivate,
            onHoverChange: onHoverChange,
            canReorder: canReorder,
            canAcceptAppDrop: canAcceptAppDrop,
            onReorderBegan: onReorderBegan,
            onReorderMoved: onReorderMoved,
            onReorderEnded: onReorderEnded
            onReorderEnded: onReorderEnded,
            onAppDropHoverChange: onAppDropHoverChange,
            onAppDrop: onAppDrop
        )
        scrollView.hasHorizontalScroller = orientation == .horizontal
        scrollView.hasVerticalScroller = orientation == .vertical
        scrollView.hasHorizontalScroller = false
        scrollView.hasVerticalScroller = false
        needsLayout = true
    }
@@ -126,6 +151,8 @@
        scrollView.borderType = .noBorder
        scrollView.autohidesScrollers = true
        scrollView.scrollerStyle = .overlay
        scrollView.horizontalScrollElasticity = .allowed
        scrollView.verticalScrollElasticity = .allowed
        scrollView.hasHorizontalScroller = false
        scrollView.hasVerticalScroller = false
        scrollView.documentView = documentView
@@ -140,12 +167,17 @@
    private var buttons: [TagNavigationButton] = []
    private var dragModeActive = false
    private var draggingItemID: String?
    private var appDragModeActive = false
    private var appDropTargetID: String?
    private var onActivate: (String) -> Void = { _ in }
    private var onHoverChange: (String, Bool) -> Void = { _, _ in }
    private var canReorder: (String) -> Bool = { _ in false }
    private var canAcceptAppDrop: (String) -> Bool = { _ in false }
    private var onReorderBegan: (String) -> Void = { _ in }
    private var onReorderMoved: (String, String) -> Void = { _, _ in }
    private var onReorderEnded: () -> Void = {}
    private var onAppDropHoverChange: (String, Bool) -> Void = { _, _ in }
    private var onAppDrop: (String, String, String, Bool) -> Void = { _, _, _, _ in }
    override var isFlipped: Bool { true }
@@ -155,12 +187,17 @@
        contentInsets: NSEdgeInsets,
        dragModeActive: Bool,
        draggingItemID: String?,
        appDragModeActive: Bool,
        appDropTargetID: String?,
        onActivate: @escaping (String) -> Void,
        onHoverChange: @escaping (String, Bool) -> Void,
        canReorder: @escaping (String) -> Bool,
        canAcceptAppDrop: @escaping (String) -> Bool,
        onReorderBegan: @escaping (String) -> Void,
        onReorderMoved: @escaping (String, String) -> Void,
        onReorderEnded: @escaping () -> Void
        onReorderEnded: @escaping () -> Void,
        onAppDropHoverChange: @escaping (String, Bool) -> Void,
        onAppDrop: @escaping (String, String, String, Bool) -> Void
    ) {
        let oldNames = self.items.map(\.name)
        let newNames = items.map(\.name)
@@ -170,12 +207,17 @@
        self.contentInsets = contentInsets
        self.dragModeActive = dragModeActive
        self.draggingItemID = draggingItemID
        self.appDragModeActive = appDragModeActive
        self.appDropTargetID = appDropTargetID
        self.onActivate = onActivate
        self.onHoverChange = onHoverChange
        self.canReorder = canReorder
        self.canAcceptAppDrop = canAcceptAppDrop
        self.onReorderBegan = onReorderBegan
        self.onReorderMoved = onReorderMoved
        self.onReorderEnded = onReorderEnded
        self.onAppDropHoverChange = onAppDropHoverChange
        self.onAppDrop = onAppDrop
        if needsRebuild {
            rebuildButtons()
@@ -226,6 +268,14 @@
                self?.handleReorderMove(tagID: tagID, screenPoint: screenPoint)
            }
            button.onReorderEnded = { [weak self] in self?.onReorderEnded() }
            button.canAcceptAppDrop = { [weak self] tagID in self?.canAcceptAppDrop(tagID) ?? false }
            button.onAppDropHoverChange = { [weak self] tagID, active in
                self?.setLocalAppDropHover(tagID, active: active)
                self?.onAppDropHoverChange(tagID, active)
            }
            button.onAppDrop = { [weak self] path, source, targetTag, copy in
                self?.onAppDrop(path, source, targetTag, copy)
            }
            addSubview(button)
            return button
        }
@@ -236,17 +286,30 @@
        buttons = items.compactMap { item in
            guard let button = existing[item.name] else { return nil }
            button.configure(item: item, orientation: orientation)
            button.refreshAppDropRegistration()
            return button
        }
    }
    private func updateButtonRuntimeState() {
        for button in buttons {
            let isAppDropTarget = appDropTargetID == button.itemName
            let appDropVisualActive = appDropTargetID != nil
            button.configureRuntime(
                dragModeActive: dragModeActive && canReorder(button.itemName),
                isDragging: draggingItemID == button.itemName
                dragModeActive: (dragModeActive && canReorder(button.itemName)) || appDropVisualActive,
                isDragging: draggingItemID == button.itemName || isAppDropTarget
            )
            button.refreshAppDropRegistration()
        }
    }
    private func setLocalAppDropHover(_ tagID: String, active: Bool) {
        if active {
            appDropTargetID = tagID
        } else if appDropTargetID == tagID {
            appDropTargetID = nil
        }
        updateButtonRuntimeState()
    }
    private func handleReorderMove(tagID: String, screenPoint: NSPoint) {
@@ -283,13 +346,16 @@
    }
}
final class TagNavigationButton: NSButton {
final class TagNavigationButton: NSButton, AppDropTargetReceivingView {
    var onActivate: (String) -> Void = { _ in }
    var onHoverChange: (String, Bool) -> Void = { _, _ in }
    var canReorder: (String) -> Bool = { _ in false }
    var onReorderBegan: (String) -> Void = { _ in }
    var onReorderMoved: (String, NSPoint) -> Void = { _, _ in }
    var onReorderEnded: () -> Void = {}
    var canAcceptAppDrop: (String) -> Bool = { _ in false }
    var onAppDropHoverChange: (String, Bool) -> Void = { _, _ in }
    var onAppDrop: (String, String, String, Bool) -> Void = { _, _, _, _ in }
    private var item: TagNavigationItem
    private var orientation: TagNavigationView.Orientation
@@ -297,6 +363,8 @@
    private var isMouseInside = false
    private var dragModeActive = false
    private var isDragging = false
    private let appDropTargetID = UUID()
    private var appDropRegistered = false
    var itemName: String { item.name }
@@ -330,12 +398,16 @@
    }
    func configure(item: TagNavigationItem, orientation: TagNavigationView.Orientation) {
        if self.item.name != item.name {
            unregisterAppDropTarget()
        }
        self.item = item
        self.orientation = orientation
        title = item.name
        alignment = orientation == .horizontal ? .center : .left
        setAccessibilityLabel(item.name)
        updateAppearance()
        refreshAppDropRegistration()
        needsLayout = true
    }
@@ -343,6 +415,34 @@
        self.dragModeActive = dragModeActive
        self.isDragging = isDragging
        updateAppearance()
    }
    func refreshAppDropRegistration() {
        guard window != nil, canAcceptAppDrop(item.id) else {
            unregisterAppDropTarget()
            return
        }
        AppDragCoordinator.shared.register(id: appDropTargetID, view: self, tag: item.name)
        appDropRegistered = true
    }
    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        refreshAppDropRegistration()
    }
    func appDragHoverChanged(active: Bool) {
        guard canAcceptAppDrop(item.id) || !active else { return }
        onAppDropHoverChange(item.name, active)
    }
    func performDrop(path: String, source: String, copy: Bool) {
        guard canAcceptAppDrop(item.id) else { return }
        onAppDrop(path, source, item.name, copy)
    }
    deinit {
        unregisterAppDropTarget()
    }
    override func updateTrackingAreas() {
@@ -421,6 +521,12 @@
        onActivate(item.id)
    }
    private func unregisterAppDropTarget() {
        guard appDropRegistered else { return }
        AppDragCoordinator.shared.unregister(id: appDropTargetID)
        appDropRegistered = false
    }
    private func updateAppearance() {
        let background = TagColor.nsColor(for: item.colorIndex)
        let dragScale: CGFloat = isDragging ? 1.04 : 1.0
CHANGELOG.md
@@ -1,5 +1,23 @@
# TagLauncher Changelog
## [7.8.9] — 2026-06-02
- 新增 App Grid 拖拽到标签栏建立关联:在 5 种 App Grid 视图中,App 图标可拖到顶部、左侧或右侧标签名称上,放手后追加该标签关联
- 拖到标签名称时复用标签排序的视觉反馈:目标标签浮起,其他标签变暗,帮助用户确认即将生效的标签
- 标签栏 drop 只追加目标标签,不删除原有标签;已有关联时不重复写入
- 复用现有 App 拖拽协调器并增加 drop-target hover 通知,避免新增平行拖拽系统
- 版本号更新为 `7.8.9`,Build 更新为 `20260602.1929`
## [7.8.8] — 2026-05-31
- 修复全局 `Fn + Space` Quick Search-only 场景下,鼠标在候选区 hover 或点击结果后 App Grid / Overlay 可能残留或重新冒出的问题
- Quick Search 结果点击改为在鼠标事件完成后的主队列启动,避免 AppKit `mouseDown` 与窗口关闭交叉导致背板露出
- Overlay 关闭时不再复用已隐藏的旧 `NSPanel`,每次关闭都释放窗口实例,下一次显示重新绑定当前 Space,减少隐藏 Dock / 全屏 / Split View 场景下的幽灵窗口风险
- 移除顶部 AppKit 标签栏下方多余的浅灰色滚动条,同时保留标签栏横向滚动能力
- 加固 Dock 图标显式打开 App Grid 的判定:必须是最近真实鼠标点击且位于 Dock 区域,避免重复启动或程序化打开误触发 App Grid
- 窗口 QA 增加隐藏 Dock 下 Quick Search hover、点击搜索结果不弹 App Grid 的专项回归,并继续覆盖全屏、Split View、Settings、文件面板、Dock 图标和滚动后键盘路由
- 版本号更新为 `7.8.8`,Build 更新为 `20260531.1601`
## [7.8.7] — 2026-05-31
- 修复关闭“Show in Dock”后用主快捷键打开 App Grid 时 TagLauncher Dock 图标仍会闪现的问题;隐藏 Dock 图标设置现在严格生效
Scripts/window_logic_qa.sh
@@ -97,6 +97,22 @@
  osascript -e 'tell application "System Events" to keystroke space using {option down, shift down}'
}
send_quick_search_hotkey() {
  swift - <<'SWIFT' >/dev/null 2>&1
import CoreGraphics
import Foundation
let source = CGEventSource(stateID: .hidSystemState)
let down = CGEvent(keyboardEventSource: source, virtualKey: 49, keyDown: true)!
down.flags = [.maskSecondaryFn]
down.post(tap: .cghidEventTap)
usleep(80_000)
let up = CGEvent(keyboardEventSource: source, virtualKey: 49, keyDown: false)!
up.flags = [.maskSecondaryFn]
up.post(tap: .cghidEventTap)
SWIFT
}
send_cmd_comma() {
  osascript -e 'tell application "System Events" to keystroke "," using {command down}'
}
@@ -767,6 +783,25 @@
    let overlayWidth = dimension(overlayBounds, "Width")
    let overlayHeight = dimension(overlayBounds, "Height")
    print("\(Int(round(overlayX + overlayWidth / 2))) \(Int(round(overlayY + overlayHeight / 2)))")
case "quick-search-result":
    guard let quickSearch = tag.first(where: { window in
        let name = (window[kCGWindowName as String] as? String) ?? ""
        let bounds = window[kCGWindowBounds as String] as? NSDictionary ?? [:]
        let width = dimension(bounds, "Width")
        let height = dimension(bounds, "Height")
        return name.isEmpty
            && width >= 500
            && width <= 900
            && height >= 120
            && height <= 850
    }), let bounds = quickSearch[kCGWindowBounds as String] as? NSDictionary else {
        fputs("FAIL: could not find quick search bounds\n", stderr)
        exit(1)
    }
    let x = dimension(bounds, "X")
    let y = dimension(bounds, "Y")
    let width = dimension(bounds, "Width")
    print("\(Int(round(x + width * 0.35))) \(Int(round(y + 125)))")
case "fullscreen-target-center":
    guard let target = raw.first(where: { ($0[kCGWindowName as String] as? String) == "TagLauncherFullscreenQATargetFullscreen" }),
          let bounds = target[kCGWindowBounds as String] as? NSDictionary else {
@@ -1026,6 +1061,23 @@
  click_xy "$x" "$y"
}
hover_quick_search_results() {
  local coords x y
  coords="$(swift "$coords_swift" quick-search-result)"
  read -r x y <<<"$coords"
  for offset in 0 42 84 126 84 42 0; do
    move_xy "$x" "$((y + offset))"
    sleep 0.08
  done
}
click_quick_search_result() {
  local coords x y
  coords="$(swift "$coords_swift" quick-search-result)"
  read -r x y <<<"$coords"
  click_xy "$x" "$y"
}
page_scroll_appgrid() {
  local coords
  coords="$(swift "$coords_swift" overlay-center)"
@@ -1147,6 +1199,36 @@
kill_all_taglauncher_instances
sleep 0.4
swift_assert no-overlay
prepare_isolated_app_instance
assert_no_dock_tile
log "==> QA hidden Dock: Fn+Space Quick Search hover does not resurrect App Grid"
send_quick_search_hotkey
sleep 0.8
wait_swift_assert quick-search
hover_quick_search_results
sleep 0.8
wait_swift_assert quick-search
assert_no_dock_tile
send_quick_search_hotkey
sleep 0.8
wait_swift_assert no-overlay
assert_no_dock_tile
kill_all_taglauncher_instances
sleep 0.4
swift_assert no-overlay
prepare_isolated_app_instance
assert_no_dock_tile
log "==> QA hidden Dock: clicking Quick Search result closes without showing App Grid"
send_quick_search_hotkey
sleep 0.8
wait_swift_assert quick-search
click_quick_search_result
sleep 1.4
wait_swift_assert no-overlay
assert_no_dock_tile
kill_all_taglauncher_instances
sleep 0.4
swift_assert no-overlay
log "==> QA split-view fullscreen geometry detection"
swift_assert split-geometry
@@ -1164,6 +1246,7 @@
  log "==> QA fullscreen Space: quick search from appgrid does not switch Space"
  send_keycode 49
  sleep 0.4
  wait_swift_assert quick-search
  assert_fullscreen_overlay_stable
  send_keycode 53
  sleep 0.3
@@ -1209,7 +1292,7 @@
swift_assert file-panel
send_keycode 53
sleep 0.3
send_cmd_w
close_settings_window
sleep 0.9
swift_assert overlay
assert_frontmost_taglauncher