Ariver
2026-05-18 00d66fcdb928754e5a8c69a3b4ccab7832af5b54
Apptag/ContentView.swift
@@ -9,6 +9,7 @@
    static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged")
    static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange")
    static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested")
    static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow")
}
// MARK: - Edit Phase
@@ -324,6 +325,12 @@
    let message: String
}
private struct PendingUncategorizedDrop: Identifiable {
    let id = UUID()
    let app: AppInfo
    let assignedTags: [String]
}
private enum SmartStartNoticeMode {
    case autoApplied
    case suggestionOnly
@@ -370,8 +377,10 @@
    @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 bubbleDraftNote = ""
    @FocusState private var bubbleNoteFocused: Bool
@@ -388,6 +397,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 +446,7 @@
            uncommonAppBubbleOverlay
            smartStartNoticeOverlay
            editActionFeedbackOverlay
            uncategorizedDropConfirmOverlay
            if let message = dropWarningToast {
                Text(message)
@@ -471,14 +484,15 @@
            }
        }
        .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
            refreshNotchHeight()
            refreshApps()
        }
        .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
            guard allApps.isEmpty, !refreshInProgress else { return }
            refreshApps()
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
@@ -825,6 +839,8 @@
                            onDragModeChange: { setAppDragMode($0) },
                            onBubbleHover: handleBubbleHover,
                            onEditNote: beginEditingBubbleNote,
                            bubbleDisabled: appBubbleDisabled,
                            hoveredAppItemID: $hoveredAppItemID,
                            onDropApp: { path, source, copy in
                                dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
                            }
@@ -927,6 +943,9 @@
                        onDragModeChange: { setAppDragMode($0) },
                        onBubbleHover: handleBubbleHover,
                        onEditNote: beginEditingBubbleNote,
                        bubbleDisabled: appBubbleDisabled,
                        itemID: "\(group.name)|\(app.path.path)",
                        hoveredAppItemID: $hoveredAppItemID,
                        onSelect: { openApp(app) }
                    )
                }
@@ -1163,6 +1182,9 @@
                                onDragModeChange: { setAppDragMode($0) },
                                onBubbleHover: handleBubbleHover,
                                onEditNote: beginEditingBubbleNote,
                                bubbleDisabled: appBubbleDisabled,
                                itemID: "\(group.name)|\(app.path.path)",
                                hoveredAppItemID: $hoveredAppItemID,
                                onSelect: { openApp(app) }
                            )
                                .frame(width: cellWidth)
@@ -1556,6 +1578,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,7 +1855,19 @@
    // MARK: - Actions
    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, hovering: Bool) {
        guard !appBubbleDisabled else {
            clearAppBubbleState()
            return
        }
        guard editingBubble == nil else { return }
        if hovering {
            hoveredBubble = AppBubbleContext(app: app, frame: frame)
@@ -1817,6 +1877,10 @@
    }
    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 +1903,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)
@@ -2048,11 +2122,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 +2140,33 @@
        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() {
        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
        withAnimation(.easeOut(duration: 0.16)) {
            pendingUncategorizedDrop = nil
        }
        TagEditor.removeTags(tags, from: [path])
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
    private func isUncategorizedDropTarget(_ targetTag: String) -> Bool {
@@ -2132,6 +2218,7 @@
    private func setAppDragMode(_ active: Bool) {
        if active {
            endTagNavReorder()
            clearAppBubbleState()
        }
        appDragModeActive = active
        if active {
@@ -2145,8 +2232,10 @@
    func refreshApps(forceLayoutRefresh: Bool = false) {
        guard !refreshInProgress else {
            refreshAgainAfterCurrent = true
            refreshAgainForceLayout = refreshAgainForceLayout || forceLayoutRefresh
            if forceLayoutRefresh {
                refreshAgainAfterCurrent = true
                refreshAgainForceLayout = true
            }
            return
        }