From 71bfdd4087d0b4d94bd5345cb45bb335617d737c Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 15:27:05 +0800
Subject: [PATCH] Confirm single-tag removal on empty grid drop

---
 Apptag/ContentView.swift |  112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 110 insertions(+), 2 deletions(-)

diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index c163918..d6ca151 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -317,6 +317,12 @@
     let removableTags: [String]
 }
 
+private struct PendingTagRemovalDrop: Identifiable {
+    let id = UUID()
+    let app: AppInfo
+    let tagName: String
+}
+
 private enum SmartStartNoticeMode {
     case autoApplied
     case suggestionOnly
@@ -371,6 +377,8 @@
     @State private var hoveredBubble: AppBubbleContext? = nil
     @State private var editingBubble: AppBubbleContext? = nil
     @State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
+    @State private var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+    @State private var tagRemovalDropSuppressFuturePrompt = false
     @State private var appDragResetToken = 0
     @State private var bubbleDraftNote = ""
     @FocusState private var bubbleNoteFocused: Bool
@@ -406,13 +414,14 @@
     @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
     @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
     @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+    @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
 
     private let editSidebarWidth: CGFloat = 188
     private let editSidebarHorizontalInset: CGFloat = 12
     private let floatingControlsTrailingInset: CGFloat = 20
     private let floatingControlsReservedWidth: CGFloat = 120
     private var appBubbleDisabled: Bool {
-        appDragModeActive || pendingUncategorizedDrop != nil
+        appDragModeActive || pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil
     }
     private let rightSidebarFloatingClearance: CGFloat = 44
 
@@ -470,6 +479,7 @@
                 smartStartNoticeOverlay
                 editActionFeedbackOverlay
                 uncategorizedDropConfirmOverlay
+                tagRemovalDropConfirmOverlay
             }
 
             quickSearchOverlay
@@ -959,6 +969,9 @@
                     onDropApp: { path, source, target, copy in
                         dropApp(path: path, sourceTag: source, targetTag: target, copy: copy)
                     },
+                    onDropOutsideGroup: { path, source, copy in
+                        dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
+                    },
                     onGroupActivate: { groupName in
                         if isColorlessContainerMode {
                             toggleColorlessFill(groupName)
@@ -1434,6 +1447,34 @@
         }
         .ignoresSafeArea()
         .allowsHitTesting(pendingUncategorizedDrop != nil)
+    }
+
+    private var tagRemovalDropConfirmOverlay: some View {
+        GeometryReader { proxy in
+            if let pendingDrop = pendingTagRemovalDrop {
+                ZStack {
+                    Color.black.opacity(0.14)
+                        .ignoresSafeArea()
+
+                    TagRemovalDropConfirmBubble(
+                        title: tr("drop.removeTagConfirmTitle"),
+                        message: tagRemovalConfirmMessage(for: pendingDrop),
+                        doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"),
+                        doNotRemind: $tagRemovalDropSuppressFuturePrompt,
+                        cancelTitle: tr("drop.removeTagConfirmNo"),
+                        confirmTitle: tr("drop.removeTagConfirmYes"),
+                        onCancel: dismissTagRemovalDropConfirm,
+                        onConfirm: confirmPendingTagRemovalDrop
+                    )
+                    .frame(width: min(560, max(350, proxy.size.width - 120)))
+                    .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+                    .transition(.scale(scale: 0.94).combined(with: .opacity))
+                }
+                .zIndex(711)
+            }
+        }
+        .ignoresSafeArea()
+        .allowsHitTesting(pendingTagRemovalDrop != nil)
     }
 
     private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
@@ -1970,6 +2011,25 @@
         refreshApps(forceLayoutRefresh: true)
     }
 
+    private func dropAppOutsideGroup(path: String, sourceTag: String, copy: Bool) {
+        resetTransientDragState(keepingPendingTagRemovalDrop: true)
+        guard isRemovableRegularTag(sourceTag) else { return }
+        guard let app = allApps.first(where: { $0.path.path == path }),
+              appHasTag(app, tagName: sourceTag)
+        else { return }
+
+        if skipTagRemovalDropConfirm {
+            removeTagFromDroppedApp(app: app, tagName: sourceTag)
+            return
+        }
+
+        clearAppBubbleState()
+        tagRemovalDropSuppressFuturePrompt = false
+        withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
+            pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag)
+        }
+    }
+
     private func confirmAndMoveAppToUncategorized(path: String) {
         guard let app = allApps.first(where: { $0.path.path == path }) else { return }
         let removableTags = removableRegularTags(for: app)
@@ -2019,6 +2079,16 @@
         )
     }
 
+    private func tagRemovalConfirmMessage(for pendingDrop: PendingTagRemovalDrop) -> String {
+        formattedFeedbackMessage(
+            forKey: "drop.removeTagConfirmMessage",
+            replacements: [
+                "%appName%": pendingDrop.app.displayName,
+                "%tagName%": displayTagName(pendingDrop.tagName)
+            ]
+        )
+    }
+
     private func dismissUncategorizedDropConfirm() {
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
         withAnimation(.easeOut(duration: 0.18)) {
@@ -2036,6 +2106,38 @@
         }
         guard !tags.isEmpty else { return }
         TagEditor.removeTags(tags, from: [path])
+        showDropRefresh()
+        refreshApps(forceLayoutRefresh: true)
+    }
+
+    private func dismissTagRemovalDropConfirm() {
+        resetTransientDragState(keepingPendingTagRemovalDrop: true)
+        tagRemovalDropSuppressFuturePrompt = false
+        withAnimation(.easeOut(duration: 0.18)) {
+            pendingTagRemovalDrop = nil
+        }
+    }
+
+    private func confirmPendingTagRemovalDrop() {
+        guard let pendingDrop = pendingTagRemovalDrop else { return }
+        let app = pendingDrop.app
+        let tagName = pendingDrop.tagName
+        if tagRemovalDropSuppressFuturePrompt {
+            skipTagRemovalDropConfirm = true
+        }
+        resetTransientDragState(keepingPendingTagRemovalDrop: true)
+        tagRemovalDropSuppressFuturePrompt = false
+        withAnimation(.easeOut(duration: 0.16)) {
+            pendingTagRemovalDrop = nil
+        }
+        removeTagFromDroppedApp(app: app, tagName: tagName)
+    }
+
+    private func removeTagFromDroppedApp(app: AppInfo, tagName: String) {
+        guard isRemovableRegularTag(tagName),
+              appHasTag(app, tagName: tagName)
+        else { return }
+        TagEditor.removeTags([tagName], from: [app.path.path])
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2114,7 +2216,10 @@
         }
     }
 
-    private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) {
+    private func resetTransientDragState(
+        keepingPendingUncategorizedDrop: Bool = false,
+        keepingPendingTagRemovalDrop: Bool = false
+    ) {
         let hadAppDragState = appDragModeActive
         AppDragCoordinator.shared.cancelDrag()
         if appDragModeActive {
@@ -2139,6 +2244,9 @@
         if !keepingPendingUncategorizedDrop, pendingUncategorizedDrop != nil {
             pendingUncategorizedDrop = nil
         }
+        if !keepingPendingTagRemovalDrop, pendingTagRemovalDrop != nil {
+            pendingTagRemovalDrop = nil
+        }
     }
 
     func refreshApps(forceLayoutRefresh: Bool = false) {

--
Gitblit v1.9.3