Ariver
2026-07-01 0ae3a9359e4cdf6ae5b669c4affd91662463f370
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()