From 0ae3a9359e4cdf6ae5b669c4affd91662463f370 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:17:36 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.1 candidate

---
 src/Apptag/ContentView.swift |  168 +++++++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 149 insertions(+), 19 deletions(-)

diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 4a9cd47..3fede8e 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -38,8 +38,8 @@
 
 // MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
 
-/// Custom container that wraps NSTextField so that hitTest returns the container
-/// and mouseDown can reliably make the text field first responder.
+/// Custom container that wraps NSTextField so first focus is reliable while
+/// subsequent clicks keep native caret placement and selection behavior.
 final class TextFieldContainer: NSView {
     let textField: NSTextField
 
@@ -58,14 +58,33 @@
         textField.frame = bounds
     }
 
+    private var isEditing: Bool {
+        guard let window else { return false }
+        return window.firstResponder === textField || textField.currentEditor() != nil
+    }
+
     override func hitTest(_ point: NSPoint) -> NSView? {
-        if bounds.contains(point) { return self }
-        return nil
+        guard bounds.contains(point) else { return nil }
+        if isEditing {
+            let fieldPoint = textField.convert(point, from: self)
+            return textField.hitTest(fieldPoint) ?? textField
+        }
+        return self
+    }
+
+    func focusTextField() {
+        guard let w = window else { return }
+        let shouldSelectAll = !isEditing
+        w.makeFirstResponder(textField)
+        if shouldSelectAll {
+            textField.selectText(nil)
+        }
     }
 
     override func mouseDown(with event: NSEvent) {
-        if let w = window { w.makeFirstResponder(textField) }
-        textField.mouseDown(with: event)
+        // Do not forward this same NSEvent to NSTextField.mouseDown. AppKit can
+        // forward it back to this container during control tracking and recurse.
+        focusTextField()
     }
 }
 
@@ -374,6 +393,7 @@
     let id = UUID()
     let feature: ProFeature
     let noteQuotaStatus: ProNoteQuotaStatus?
+    let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
 }
 
 struct ContentView: View {
@@ -387,6 +407,7 @@
     @State private var tagColors: [String: Int] = [:]
     @State private var tagCustomColors: [String: TagCustomColor] = [:]
     @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
+    @State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()
     @State private var containerAppOrder: [String: [String]] = [:]
     @State private var groupLayoutVersion = 0
     @State private var appGridScrollTargetID: String? = nil
@@ -697,11 +718,11 @@
                     benefitText: tr(prompt.feature.benefitKey),
                     operationText: proEntitlement.operationState.statusMessageKey.map(tr),
                     unlockTitle: tr("pro.card.unlock"),
-                    restoreTitle: tr("pro.card.restore"),
+                    restoreTitle: proPromptSecondaryTitle(for: prompt),
                     isBusy: proEntitlement.operationState.isBusy,
                     onClose: dismissProPrompt,
                     onUnlock: { proEntitlement.purchasePro() },
-                    onRestore: { proEntitlement.restorePurchases() }
+                    onRestore: { handleProPromptSecondaryAction(prompt) }
                 )
             }
             .transition(.opacity)
@@ -757,6 +778,7 @@
             tagCustomColors = store.tags.compactMapValues { $0.customColor }
             tagDefinitions = store.tags
             draggedTagNames = TagEditor.orderedTagNames()
+            refreshCustomContainerQuotaStatus(in: store)
         }
         editPhase = phase
         if phase == .none {
@@ -1399,9 +1421,47 @@
                 excludedTagNames: ["Mac自带", defaultGroupName],
                 isCustomColorUnlocked: proEntitlement.isUnlocked,
                 onLockedCustomColor: { presentProPrompt(for: .customTagColors) },
-                onRefresh: { refreshApps() }
+                onCustomContainerQuotaExceeded: presentCustomContainerQuotaPrompt,
+                onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus,
+                topLeadingAccessory: AnyView(customContainerQuotaAccessory),
+                onRefresh: {
+                    refreshCustomContainerQuotaStatus()
+                    refreshApps()
+                }
             )
         }
+    }
+
+    private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
+        if proEntitlement.isUnlocked {
+            return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
+        }
+        return customContainerQuotaStatus
+    }
+
+    private var customContainerQuotaAccessory: some View {
+        let status = effectiveCustomContainerQuotaStatus
+        let text = status.isUnlimited
+            ? tr("pro.customContainers.quotaUnlimitedShort")
+            : tr(
+                "pro.customContainers.quotaShort",
+                replacements: [
+                    "%used%": "\(status.used)",
+                    "%limit%": "\(status.limit)"
+                ]
+            )
+        return Text(text)
+            .font(.system(size: 12, weight: .medium))
+            .foregroundStyle(.secondary)
+            .lineLimit(1)
+    }
+
+    private func refreshCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
+        customContainerQuotaStatus = status
+    }
+
+    private func refreshCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
+        customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
     }
 
     // MARK: - Edit Apps View
@@ -1553,7 +1613,7 @@
             .onEnded { _ in
                 guard canReorderTag(tagName) else { return }
                 dragItem = nil
-                TagEditor.reorderTags(draggedTagNames)
+                persistTagOrderIfAllowed()
             }
     }
 
@@ -1581,9 +1641,11 @@
 
         switch editTagOperation {
         case .add:
-            TagEditor.appendTags(tags, to: paths)
+            let result = TagEditor.appendTags(tags, to: paths)
+            guard handleTagMutationResult(result) else { return }
         case .remove:
-            TagEditor.removeTags(tags, from: paths)
+            let result = TagEditor.removeTags(tags, from: paths)
+            guard handleTagMutationResult(result) else { return }
         }
         selectedAppPaths = []
         selectedTagNames = []
@@ -2231,16 +2293,68 @@
            status.remaining <= 0 {
             return tr("pro.notes.limitReached")
         }
+        if prompt.feature == .customContainerQuota,
+           let status = prompt.customContainerQuotaStatus,
+           !status.isUnlimited {
+            return tr(
+                "pro.customContainers.quotaPrompt",
+                replacements: [
+                    "%used%": "\(status.used)",
+                    "%limit%": "\(status.limit)",
+                    "%remaining%": "\(status.remaining)"
+                ]
+            )
+        }
         return tr(prompt.feature.promptMessageKey)
     }
 
-    private func presentProPrompt(for feature: ProFeature, noteQuotaStatus: ProNoteQuotaStatus? = nil) {
+    private func proPromptSecondaryTitle(for prompt: PendingProPrompt) -> String {
+        prompt.feature == .customContainerQuota
+            ? tr("pro.customContainers.manageExisting")
+            : tr("pro.card.restore")
+    }
+
+    private func handleProPromptSecondaryAction(_ prompt: PendingProPrompt) {
+        if prompt.feature == .customContainerQuota {
+            dismissProPrompt()
+            setEditPhase(.editingTags)
+            return
+        }
+        proEntitlement.restorePurchases()
+    }
+
+    private func presentProPrompt(
+        for feature: ProFeature,
+        noteQuotaStatus: ProNoteQuotaStatus? = nil,
+        customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
+    ) {
         proEntitlement.clearTransientOperationState()
         withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
             appGridInteraction.proPrompt = PendingProPrompt(
                 feature: feature,
-                noteQuotaStatus: noteQuotaStatus
+                noteQuotaStatus: noteQuotaStatus,
+                customContainerQuotaStatus: customContainerQuotaStatus
             )
+        }
+    }
+
+    private func presentCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
+        presentProPrompt(
+            for: .customContainerQuota,
+            customContainerQuotaStatus: status
+        )
+    }
+
+    private func handleTagMutationResult(_ result: TagEditorMutationResult) -> Bool {
+        switch result {
+        case .saved:
+            refreshCustomContainerQuotaStatus()
+            return true
+        case .noChange:
+            return false
+        case .blockedCustomContainerQuota(let status):
+            presentCustomContainerQuotaPrompt(status)
+            return false
         }
     }
 
@@ -2530,11 +2644,22 @@
         let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
         guard hadDragState else { return }
         if tagNavDragModeActive && tagNavReorderDidMove {
-            TagEditor.reorderTags(draggedTagNames)
+            persistTagOrderIfAllowed()
         }
         tagNavDragModeActive = false
         tagNavDragItem = nil
         tagNavReorderDidMove = false
+    }
+
+    private func persistTagOrderIfAllowed() {
+        guard proEntitlement.isUnlocked else {
+            showDropWarning(message: tr("pro.tagSorting.previewToast"))
+            return
+        }
+        guard TagEditor.reorderTags(draggedTagNames) else {
+            showDropWarning(message: tr("pro.tagSorting.previewToast"))
+            return
+        }
     }
 
     private func cancelTagNavReorderVisualState() {
@@ -2582,13 +2707,14 @@
         }
 
         guard tagColors[targetTag] != nil else { return }
-        TagEditor.moveApp(
+        let result = TagEditor.moveApp(
             path: path,
             from: sourceTag,
             to: targetTag,
             color: tagColors[targetTag] ?? 0,
             copy: copy
         )
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2598,7 +2724,8 @@
         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])
+        let result = TagEditor.appendTags([targetTag], to: [path])
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2735,7 +2862,8 @@
 
     private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
         guard !tags.isEmpty else { return }
-        TagEditor.removeTags(tags, from: [path])
+        let result = TagEditor.removeTags(tags, from: [path])
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2802,7 +2930,8 @@
         guard isRemovableRegularTag(tagName),
               appHasTag(app, tagName: tagName)
         else { return }
-        TagEditor.removeTags([tagName], from: [app.path.path])
+        let result = TagEditor.removeTags([tagName], from: [app.path.path])
+        guard handleTagMutationResult(result) else { return }
         showDropRefresh()
         refreshApps(forceLayoutRefresh: true)
     }
@@ -2981,6 +3110,7 @@
         tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }
         containerAppOrder = snapshot.containerAppOrder
         draggedTagNames = snapshot.tagOrder
+        refreshCustomContainerQuotaStatus()
         rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
         if quickSearchVisible {
             refreshQuickSearchResults()

--
Gitblit v1.9.3