Ariver
2026-07-02 2309264ee94daca4b9abe3592d84602c98b77690
src/Apptag/ContentView.swift
@@ -19,6 +19,7 @@
    static let theme = "theme"
    static let tags = "tags"
    static let data = "data"
    static let pro = "pro"
    static let about = "about"
}
@@ -37,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
@@ -57,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()
    }
}
@@ -214,19 +234,21 @@
struct TagPill: View {
    let name: String
    let colorIndex: Int
    var customColor: TagCustomColor? = nil
    var dragModeActive: Bool = false
    var isDragging: Bool = false
    let action: () -> Void
    private var resolvedNSColor: NSColor {
        TagColor.nsColor(for: colorIndex, customColor: customColor)
    }
    private var bgColor: Color {
        Color(nsColor: TagColor.nsColor(for: colorIndex))
        Color(nsColor: resolvedNSColor)
    }
    private var textColor: Color {
        if colorIndex == 0 || colorIndex == 5 {
            return .primary
        }
        return .white
        TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
    }
    var body: some View {
@@ -253,15 +275,21 @@
struct SideTagPill: View {
    let name: String
    let colorIndex: Int
    var customColor: TagCustomColor? = nil
    var dragModeActive: Bool = false
    var isDragging: Bool = false
    let action: () -> Void
    private var bgColor: Color {
        Color(nsColor: TagColor.nsColor(for: colorIndex))
    private var resolvedNSColor: NSColor {
        TagColor.nsColor(for: colorIndex, customColor: customColor)
    }
    private var bgColor: Color {
        Color(nsColor: resolvedNSColor)
    }
    private var textColor: Color {
        colorIndex == 0 || colorIndex == 5 ? .primary : .white
        TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
    }
    var body: some View {
@@ -365,6 +393,7 @@
    let id = UUID()
    let feature: ProFeature
    let noteQuotaStatus: ProNoteQuotaStatus?
    let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
}
struct ContentView: View {
@@ -376,7 +405,9 @@
    @State private var allApps: [AppInfo] = []
    @State private var displayGroups: [TagGroup] = []
    @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
@@ -425,6 +456,7 @@
    @State private var quickSearchOpenedAt: Date? = nil
    @State private var quickSearchErrorMessage: String? = nil
    @State private var initialQuickSearchConsumed = false
    @State private var quickSearchCompositionActive = false
    init(hideOverlay: @escaping () -> Void, initialQuickSearchSource: String? = nil) {
        self.hideOverlay = hideOverlay
@@ -499,6 +531,10 @@
        )
    }
    private var renderedDisplayMode: String {
        ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)
    }
    private func storedThemeTuning(for theme: AppGridTheme) -> Double? {
        switch theme {
        case .deepBlue:
@@ -519,7 +555,7 @@
    }
    private var isColorlessContainerMode: Bool {
        displayMode == "container" || displayMode == "gridContainer"
        renderedDisplayMode == "container" || renderedDisplayMode == "gridContainer"
    }
    private var shouldShowUsageTips: Bool {
@@ -553,6 +589,7 @@
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide), perform: handleOverlayDidHide)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested), perform: handleQuickSearchRequested)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested), perform: handleQuickSearchDismissRequested)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchCompositionChanged), perform: handleQuickSearchCompositionChanged)
            .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), perform: handleApplicationDidBecomeActive)
            .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange), perform: handleDataDidChange)
            .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange), perform: handleAppLanguageDidChange)
@@ -683,11 +720,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)
@@ -740,7 +777,10 @@
            // Always sync tag list from database when entering edit mode
            let store = TagDatabase.load()
            tagColors = store.tags.mapValues { $0.color }
            tagCustomColors = store.tags.compactMapValues { $0.customColor }
            tagDefinitions = store.tags
            draggedTagNames = TagEditor.orderedTagNames()
            refreshCustomContainerQuotaStatus(in: store)
        }
        editPhase = phase
        if phase == .none {
@@ -826,6 +866,7 @@
        quickSearchVisible = true
        quickSearchOpenedAt = Date()
        quickSearchQuery = ""
        quickSearchCompositionActive = false
        quickSearchManualSelection = false
        quickSearchErrorMessage = nil
        quickSearchFocusToken &+= 1
@@ -869,10 +910,14 @@
        if shouldIgnoreEarlyQuickSearchDismiss(from: dismissalSource) {
            return
        }
        if shouldIgnoreQuickSearchDismissDuringComposition(from: dismissalSource) {
            return
        }
        let shouldHideOverlay = quickSearchVisible && quickSearchCloseHidesOverlay && hideOverlayIfNeeded
        guard quickSearchVisible else { return }
        quickSearchVisible = false
        quickSearchOpenedAt = nil
        quickSearchCompositionActive = false
        quickSearchQuery = ""
        quickSearchResults = []
        quickSearchSelectedID = nil
@@ -908,6 +953,16 @@
            "source": source,
            "age": String(format: "%.3f", age),
            "gracePeriod": String(format: "%.2f", gracePeriod)
        ])
        return true
    }
    private func shouldIgnoreQuickSearchDismissDuringComposition(from source: String?) -> Bool {
        guard quickSearchCompositionActive,
              source == QuickSearchDismissSource.mouse || source == QuickSearchDismissSource.backdrop
        else { return false }
        Diagnostics.log("content.quickSearch.ignoredCompositionDismiss", [
            "source": source
        ])
        return true
    }
@@ -1185,6 +1240,7 @@
            HStack(spacing: 8) {
                ForEach(tagLabels) { tag in
                    TagPill(name: tag.name, colorIndex: tag.colorIndex,
                            customColor: tag.customColor,
                            dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                            isDragging: tagNavDragItem == tag.name,
                            action: {
@@ -1212,6 +1268,7 @@
            VStack(spacing: 6) {
                ForEach(tagLabels) { tag in
                    SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
                                customColor: tag.customColor,
                                dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
                                isDragging: tagNavDragItem == tag.name,
                                action: {
@@ -1249,7 +1306,8 @@
                AppGridCollectionView(
                    groups: displayGroups,
                    tagColors: tagColors,
                    displayMode: displayMode,
                    tagCustomColors: tagCustomColors,
                    displayMode: renderedDisplayMode,
                    iconSize: iconSize,
                    showNames: !hideAppNames,
                    appGridTheme: renderedAppGridTheme,
@@ -1376,10 +1434,51 @@
            TagEditorView(
                tagColors: $tagColors,
                tagCustomColors: $tagCustomColors,
                excludedTagNames: ["Mac自带", defaultGroupName],
                onRefresh: { refreshApps() }
                isCustomColorUnlocked: proEntitlement.isUnlocked,
                onLockedCustomColor: { presentProPrompt(for: .customTagColors) },
                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
@@ -1531,7 +1630,7 @@
            .onEnded { _ in
                guard canReorderTag(tagName) else { return }
                dragItem = nil
                TagEditor.reorderTags(draggedTagNames)
                persistTagOrder()
            }
    }
@@ -1559,9 +1658,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 = []
@@ -2009,7 +2110,13 @@
    }
    private var tagLabels: [TagNavigationItem] {
        displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
        displayGroups.map {
            TagNavigationItem(
                name: $0.name,
                colorIndex: tagColors[$0.name] ?? 0,
                customColor: tagCustomColors[$0.name]
            )
        }
    }
    private var editGroups: [TagGroup] {
@@ -2203,16 +2310,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
        }
    }
@@ -2229,6 +2388,10 @@
        if let preview = proEntitlement.themePreviewState {
            appGridThemeID = preview.theme.rawValue
            proEntitlement.stopThemePreview()
        }
        if let preview = proEntitlement.displayModePreviewState {
            displayMode = preview.displayMode
            proEntitlement.stopDisplayModePreview()
        }
        let hadPrompt = appGridInteraction.proPrompt != nil
@@ -2293,6 +2456,14 @@
    private func handleQuickSearchDismissRequested(_ notification: Notification) {
        let source = notification.userInfo?["source"] as? String
        closeQuickSearch(dismissalSource: source)
    }
    private func handleQuickSearchCompositionChanged(_ notification: Notification) {
        guard quickSearchVisible else {
            quickSearchCompositionActive = false
            return
        }
        quickSearchCompositionActive = (notification.userInfo?["active"] as? Bool) ?? false
    }
    private func handleApplicationDidBecomeActive(_ notification: Notification) {
@@ -2498,11 +2669,15 @@
        let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
        guard hadDragState else { return }
        if tagNavDragModeActive && tagNavReorderDidMove {
            TagEditor.reorderTags(draggedTagNames)
            persistTagOrder()
        }
        tagNavDragModeActive = false
        tagNavDragItem = nil
        tagNavReorderDidMove = false
    }
    private func persistTagOrder() {
        _ = TagEditor.reorderTags(draggedTagNames)
    }
    private func cancelTagNavReorderVisualState() {
@@ -2550,13 +2725,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)
    }
@@ -2566,7 +2742,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)
    }
@@ -2703,7 +2880,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)
    }
@@ -2770,7 +2948,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)
    }
@@ -2946,8 +3125,10 @@
        quickSearchDocuments = snapshot.quickSearchDocuments
        tagColors = snapshot.tagColors
        tagDefinitions = snapshot.tagDefinitions
        tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }
        containerAppOrder = snapshot.containerAppOrder
        draggedTagNames = snapshot.tagOrder
        refreshCustomContainerQuotaStatus()
        rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
        if quickSearchVisible {
            refreshQuickSearchResults()