Ariver
2026-05-19 554dce57269e2ae24f5de9c39972cacbc0a350dc
Apptag/ContentView.swift
@@ -9,6 +9,9 @@
    static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged")
    static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange")
    static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested")
    static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow")
    static let tagLauncherOverlayDidHide = Notification.Name("TagLauncherOverlayDidHide")
    static let tagLauncherModalInteractionChanged = Notification.Name("TagLauncherModalInteractionChanged")
}
// MARK: - Edit Phase
@@ -198,8 +201,8 @@
    let colorIndex: Int
    var dragModeActive: Bool = false
    var isDragging: Bool = false
    var dragResetToken: Int = 0
    let action: () -> Void
    @State private var wiggle = false
    private var bgColor: Color {
        Color(nsColor: TagColor.nsColor(for: colorIndex))
@@ -221,20 +224,14 @@
            .background(RoundedRectangle(cornerRadius: 7).fill(bgColor))
            .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1)
            .scaleEffect(isDragging ? 1.05 : 1.0)
            .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.8 : -1.8) : 0))
            .animation(
                dragModeActive
                    ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
                    : .default,
                value: wiggle
            )
            .onChange(of: dragModeActive) { _, active in
                wiggle = active
            }
            .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
            .animation(.easeOut(duration: 0.08), value: isDragging)
            .animation(.easeOut(duration: 0.08), value: dragModeActive)
            .id("\(name)|reset-\(dragResetToken)")
            .contentShape(RoundedRectangle(cornerRadius: 7))
            .onTapGesture {
                if !dragModeActive { action() }
        }
                action()
            }
    }
}
@@ -245,8 +242,8 @@
    let colorIndex: Int
    var dragModeActive: Bool = false
    var isDragging: Bool = false
    var dragResetToken: Int = 0
    let action: () -> Void
    @State private var wiggle = false
    private var bgColor: Color {
        Color(nsColor: TagColor.nsColor(for: colorIndex))
@@ -264,20 +261,14 @@
            .background(RoundedRectangle(cornerRadius: 6).fill(bgColor))
            .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1)
            .scaleEffect(isDragging ? 1.03 : 1.0)
            .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.6 : -1.6) : 0))
            .animation(
                dragModeActive
                    ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
                    : .default,
                value: wiggle
            )
            .onChange(of: dragModeActive) { _, active in
                wiggle = active
            }
            .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
            .animation(.easeOut(duration: 0.08), value: isDragging)
            .animation(.easeOut(duration: 0.08), value: dragModeActive)
            .id("\(name)|reset-\(dragResetToken)")
            .contentShape(RoundedRectangle(cornerRadius: 6))
            .onTapGesture {
                if !dragModeActive { action() }
        }
                action()
            }
    }
}
@@ -324,6 +315,12 @@
    let message: String
}
private struct PendingUncategorizedDrop: Identifiable {
    let id = UUID()
    let app: AppInfo
    let assignedTags: [String]
}
private enum SmartStartNoticeMode {
    case autoApplied
    case suggestionOnly
@@ -357,7 +354,9 @@
    @State private var tagReorderFrames: [String: CGRect] = [:]
    @State private var tagNavDragModeActive = false
    @State private var tagNavDragItem: String? = nil
    @State private var tagNavDragResetToken = 0
    @State private var tagNavReorderFrames: [String: CGRect] = [:]
    @State private var tagNavReorderDidMove = false
    @State private var hoveredContainer: String? = nil  // colored container lift
    // Fixed interaction for "Colorless Container": hover fills persistently; click clears.
    @State private var filledColorlessContainer: String? = nil
@@ -370,8 +369,11 @@
    @State private var refreshInProgress = false
    @State private var refreshAgainAfterCurrent = false
    @State private var refreshAgainForceLayout = false
    @State private var hoveredAppItemID: String? = nil
    @State private var hoveredBubble: AppBubbleContext? = nil
    @State private var editingBubble: AppBubbleContext? = nil
    @State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
    @State private var appDragResetToken = 0
    @State private var bubbleDraftNote = ""
    @FocusState private var bubbleNoteFocused: Bool
@@ -388,6 +390,9 @@
    private let editSidebarHorizontalInset: CGFloat = 12
    private let floatingControlsTrailingInset: CGFloat = 20
    private let floatingControlsReservedWidth: CGFloat = 120
    private var appBubbleDisabled: Bool {
        appDragModeActive || pendingUncategorizedDrop != nil
    }
    private let rightSidebarFloatingClearance: CGFloat = 44
    private var isSideLayout: Bool {
@@ -434,6 +439,7 @@
            uncommonAppBubbleOverlay
            smartStartNoticeOverlay
            editActionFeedbackOverlay
            uncategorizedDropConfirmOverlay
            if let message = dropWarningToast {
                Text(message)
@@ -471,14 +477,19 @@
            }
        }
        .onAppear {
            let mousePoint = NSEvent.mouseLocation
            let activeScreen = NSScreen.screens.first(where: {
                NSMouseInRect(mousePoint, $0.frame, false)
            }) ?? NSScreen.main
            notchHeight = activeScreen?.safeAreaInsets.top ?? 0
            refreshNotchHeight()
            refreshApps()
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
            resetTransientDragState()
            refreshNotchHeight()
            refreshApps()
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
            resetTransientDragState()
        }
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
            guard allApps.isEmpty, !refreshInProgress else { return }
            refreshApps()
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
@@ -506,6 +517,20 @@
            if editingBubble != nil && !focused {
                commitBubbleNote()
            }
        }
        .onChange(of: pendingUncategorizedDrop != nil) { _, active in
            NotificationCenter.default.post(
                name: .tagLauncherModalInteractionChanged,
                object: nil,
                userInfo: ["active": active]
            )
        }
        .onDisappear {
            NotificationCenter.default.post(
                name: .tagLauncherModalInteractionChanged,
                object: nil,
                userInfo: ["active": false]
            )
        }
    }
@@ -599,11 +624,9 @@
                    TagPill(name: tag.name, colorIndex: tag.colorIndex,
                            dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                            isDragging: tagNavDragItem == tag.name,
                            dragResetToken: tagNavDragResetToken,
                            action: {
                        if isColorlessContainerMode {
                            toggleColorlessFill(tag.id)
                        }
                        scrollTo(tag.id)
                        activateTagNavigation(tag.id)
                    })
                    .background(tagNavFrameReader(for: tag.name))
                    .zIndex(tagNavDragItem == tag.name ? 1 : 0)
@@ -643,11 +666,9 @@
                    SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
                                dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                                isDragging: tagNavDragItem == tag.name,
                                dragResetToken: tagNavDragResetToken,
                                action: {
                        if isColorlessContainerMode {
                            toggleColorlessFill(tag.id)
                        }
                        scrollTo(tag.id)
                        activateTagNavigation(tag.id)
                    })
                    .background(tagNavFrameReader(for: tag.name))
                    .zIndex(tagNavDragItem == tag.name ? 1 : 0)
@@ -825,6 +846,9 @@
                            onDragModeChange: { setAppDragMode($0) },
                            onBubbleHover: handleBubbleHover,
                            onEditNote: beginEditingBubbleNote,
                            bubbleDisabled: appBubbleDisabled,
                            dragResetToken: appDragResetToken,
                            hoveredAppItemID: $hoveredAppItemID,
                            onDropApp: { path, source, copy in
                                dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
                            }
@@ -927,6 +951,10 @@
                        onDragModeChange: { setAppDragMode($0) },
                        onBubbleHover: handleBubbleHover,
                        onEditNote: beginEditingBubbleNote,
                        bubbleDisabled: appBubbleDisabled,
                        itemID: "\(group.name)|\(app.path.path)",
                        dragResetToken: appDragResetToken,
                        hoveredAppItemID: $hoveredAppItemID,
                        onSelect: { openApp(app) }
                    )
                }
@@ -1163,6 +1191,10 @@
                                onDragModeChange: { setAppDragMode($0) },
                                onBubbleHover: handleBubbleHover,
                                onEditNote: beginEditingBubbleNote,
                                bubbleDisabled: appBubbleDisabled,
                                itemID: "\(group.name)|\(app.path.path)",
                                dragResetToken: appDragResetToken,
                                hoveredAppItemID: $hoveredAppItemID,
                                onSelect: { openApp(app) }
                            )
                                .frame(width: cellWidth)
@@ -1556,6 +1588,32 @@
        .allowsHitTesting(editActionFeedback != nil)
    }
    private var uncategorizedDropConfirmOverlay: some View {
        GeometryReader { proxy in
            if let pendingDrop = pendingUncategorizedDrop {
                ZStack {
                    Color.black.opacity(0.14)
                        .ignoresSafeArea()
                    UncategorizedDropConfirmBubble(
                        title: tr("drop.uncategorizedConfirmTitle"),
                        message: uncategorizedConfirmMessage(for: pendingDrop),
                        cancelTitle: tr("tag.cancel"),
                        confirmTitle: tr("edit.confirm"),
                        onCancel: dismissUncategorizedDropConfirm,
                        onConfirm: confirmPendingUncategorizedDrop
                    )
                    .frame(width: min(540, max(340, proxy.size.width - 120)))
                    .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
                    .transition(.scale(scale: 0.94).combined(with: .opacity))
                }
                .zIndex(710)
            }
        }
        .ignoresSafeArea()
        .allowsHitTesting(pendingUncategorizedDrop != nil)
    }
    private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
        let appNames = selectedApps.map(\.name)
            .joined(separator: localizedListSeparator)
@@ -1807,16 +1865,38 @@
    // MARK: - Actions
    private func handleBubbleHover(app: AppInfo, frame: CGRect, hovering: Bool) {
    private func refreshNotchHeight() {
        let mousePoint = NSEvent.mouseLocation
        let activeScreen = NSScreen.screens.first(where: {
            NSMouseInRect(mousePoint, $0.frame, false)
        }) ?? NSScreen.main
        notchHeight = activeScreen?.safeAreaInsets.top ?? 0
    }
    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
        }
    }
    private func beginEditingBubbleNote(app: AppInfo, frame: CGRect) {
        guard !appBubbleDisabled else {
            clearAppBubbleState()
            return
        }
        bubbleDraftNote = currentNote(for: app)
        hoveredBubble = nil
        editingBubble = AppBubbleContext(app: app, frame: frame)
@@ -1839,6 +1919,16 @@
    }
    private func dismissAppBubble() {
        hoveredBubble = nil
        if editingBubble != nil {
            notifyAppNoteEditing(active: false)
        }
        editingBubble = nil
        bubbleNoteFocused = false
    }
    private func clearAppBubbleState() {
        hoveredAppItemID = nil
        hoveredBubble = nil
        if editingBubble != nil {
            notifyAppNoteEditing(active: false)
@@ -1911,6 +2001,14 @@
        withAnimation(.easeInOut(duration: 0.25)) { scrollProxy?.scrollTo(id, anchor: .top) }
    }
    private func activateTagNavigation(_ id: String) {
        cancelTagNavReorderVisualState()
        if isColorlessContainerMode {
            toggleColorlessFill(id)
        }
        scrollTo(id)
    }
    private func fillColorlessContainer(_ id: String) {
        guard isColorlessContainerMode else { return }
        guard filledColorlessContainer != id else { return }
@@ -1938,8 +2036,8 @@
    }
    private func tagNavReorderGesture(for tagName: String) -> some Gesture {
        LongPressGesture(minimumDuration: 0.5)
            .sequenced(before: DragGesture(minimumDistance: 3, coordinateSpace: .named("tagNavReorder")))
        LongPressGesture(minimumDuration: 0.35)
            .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named("tagNavReorder")))
            .onChanged { value in
                guard canReorderTag(tagName) else { return }
                switch value {
@@ -1965,16 +2063,27 @@
        guard canReorderTag(tagName) else { return }
        if tagNavDragItem == nil {
            tagNavDragItem = tagName
            tagNavReorderDidMove = false
        }
        tagNavDragModeActive = true
    }
    private func endTagNavReorder() {
        if tagNavDragModeActive {
        if tagNavDragModeActive && tagNavReorderDidMove {
            TagEditor.reorderTags(draggedTagNames)
        }
        tagNavDragModeActive = false
        tagNavDragItem = nil
        tagNavReorderDidMove = false
        tagNavDragResetToken &+= 1
    }
    private func cancelTagNavReorderVisualState() {
        guard tagNavDragModeActive || tagNavDragItem != nil else { return }
        tagNavDragModeActive = false
        tagNavDragItem = nil
        tagNavReorderDidMove = false
        tagNavDragResetToken &+= 1
    }
    private func reorderTagNavItem(at location: CGPoint) {
@@ -1985,6 +2094,7 @@
              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
            draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination)
@@ -2019,7 +2129,7 @@
    }
    private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
        appDragModeActive = false
        resetTransientDragState(keepingPendingUncategorizedDrop: true)
        if isUncategorizedDropTarget(targetTag) {
            confirmAndMoveAppToUncategorized(path: path)
@@ -2048,11 +2158,11 @@
        guard let app = allApps.first(where: { $0.path.path == path }) else { return }
        let assignedTags = assignedRegularDisplayTags(for: app)
        guard !assignedTags.isEmpty else { return }
        guard confirmUncategorizedDrop(appName: app.name, assignedTags: assignedTags) else { return }
        TagEditor.removeTags(app.tags, from: [path])
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
        clearAppBubbleState()
        withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
            pendingUncategorizedDrop = PendingUncategorizedDrop(app: app, assignedTags: assignedTags)
        }
    }
    private func assignedRegularDisplayTags(for app: AppInfo) -> [String] {
@@ -2066,21 +2176,35 @@
        return result
    }
    private func confirmUncategorizedDrop(appName: String, assignedTags: [String]) -> Bool {
        let alert = NSAlert()
        alert.alertStyle = .warning
        alert.messageText = tr("drop.uncategorizedConfirmTitle")
        alert.informativeText = formattedFeedbackMessage(
    private func uncategorizedConfirmMessage(for pendingDrop: PendingUncategorizedDrop) -> String {
        formattedFeedbackMessage(
            forKey: "drop.uncategorizedConfirmMessage",
            replacements: [
                "%appName%": appName,
                "%tagCount%": "\(assignedTags.count)",
                "%tagNames%": assignedTags.joined(separator: localizedListSeparator)
                "%appName%": pendingDrop.app.name,
                "%tagCount%": "\(pendingDrop.assignedTags.count)",
                "%tagNames%": pendingDrop.assignedTags.joined(separator: localizedListSeparator)
            ]
        )
        alert.addButton(withTitle: tr("edit.confirm"))
        alert.addButton(withTitle: tr("tag.cancel"))
        return alert.runModal() == .alertFirstButtonReturn
    }
    private func dismissUncategorizedDropConfirm() {
        resetTransientDragState(keepingPendingUncategorizedDrop: true)
        withAnimation(.easeOut(duration: 0.18)) {
            pendingUncategorizedDrop = nil
        }
    }
    private func confirmPendingUncategorizedDrop() {
        guard let pendingDrop = pendingUncategorizedDrop else { return }
        let path = pendingDrop.app.path.path
        let tags = pendingDrop.app.tags
        resetTransientDragState(keepingPendingUncategorizedDrop: true)
        withAnimation(.easeOut(duration: 0.16)) {
            pendingUncategorizedDrop = nil
        }
        TagEditor.removeTags(tags, from: [path])
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
    private func isUncategorizedDropTarget(_ targetTag: String) -> Bool {
@@ -2132,6 +2256,7 @@
    private func setAppDragMode(_ active: Bool) {
        if active {
            endTagNavReorder()
            clearAppBubbleState()
        }
        appDragModeActive = active
        if active {
@@ -2143,10 +2268,29 @@
        }
    }
    private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) {
        AppDragCoordinator.shared.cancelDrag()
        appDragModeActive = false
        appDragResetToken &+= 1
        tagNavDragModeActive = false
        tagNavDragItem = nil
        tagNavReorderDidMove = false
        tagNavDragResetToken &+= 1
        dragItem = nil
        hoveredAppItemID = nil
        hoveredContainer = nil
        clearAppBubbleState()
        if !keepingPendingUncategorizedDrop {
            pendingUncategorizedDrop = nil
        }
    }
    func refreshApps(forceLayoutRefresh: Bool = false) {
        guard !refreshInProgress else {
            refreshAgainAfterCurrent = true
            refreshAgainForceLayout = refreshAgainForceLayout || forceLayoutRefresh
            if forceLayoutRefresh {
                refreshAgainAfterCurrent = true
                refreshAgainForceLayout = true
            }
            return
        }