Ariver
2026-06-25 c8be92c4ca12b53cec27c39977d2139bea3f0fe7
src/Apptag/ContentView.swift
@@ -8,9 +8,15 @@
    static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged")
    static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange")
    static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested")
    static let tagLauncherPreferencesTabRequested = Notification.Name("TagLauncherPreferencesTabRequested")
    static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow")
    static let tagLauncherOverlayDidHide = Notification.Name("TagLauncherOverlayDidHide")
    static let tagLauncherModalInteractionChanged = Notification.Name("TagLauncherModalInteractionChanged")
}
enum SettingsTabTarget {
    static let userInfoKey = "tab"
    static let tags = "tags"
}
// MARK: - Edit Phase
@@ -82,6 +88,10 @@
    }
    func updateNSView(_ container: TextFieldContainer, context: Context) {
        if let editor = container.textField.currentEditor() as? NSTextView,
           editor.hasMarkedText() {
            return
        }
        if container.textField.stringValue != text {
            container.textField.stringValue = text
        }
@@ -106,6 +116,9 @@
        }
        func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
            if textView.hasMarkedText() {
                return false
            }
            if commandSelector == #selector(NSResponder.insertNewline(_:)) {
                onSubmit?()
                return true
@@ -350,9 +363,13 @@
    @State private var allApps: [AppInfo] = []
    @State private var displayGroups: [TagGroup] = []
    @State private var tagColors: [String: Int] = [:]
    @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
    @State private var containerAppOrder: [String: [String]] = [:]
    @State private var groupLayoutVersion = 0
    @State private var appGridScrollTargetID: String? = nil
    @State private var appGridScrollRequestToken = 0
    @State private var selectedUsageTipIndex = 0
    @State private var usageTipsHovered = false
    // Edit mode
    @State private var editPhase: EditPhase = .none
@@ -374,6 +391,8 @@
    @State private var smartStartNotice: SmartStartNotice? = nil
    @State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil
    @State private var refreshInProgress = false
    @State private var loadingSpinnerVisible = false
    @State private var loadingSpinnerToken = 0
    @State private var refreshAgainAfterCurrent = false
    @State private var refreshAgainForceLayout = false
    @State private var refreshAgainUseCache = true
@@ -414,6 +433,8 @@
    @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
    @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
    @AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips
    @AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID
    @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
    @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
    @AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false
@@ -422,8 +443,10 @@
    private let editSidebarHorizontalInset: CGFloat = 12
    private let floatingControlsTrailingInset: CGFloat = 20
    private let floatingControlsReservedWidth: CGFloat = 120
    private let loadingSpinnerDelay: TimeInterval = 0.25
    private var appBubbleDisabled: Bool {
        appGridInteraction.appDragModeActive
        usageTipsHovered
            || appGridInteraction.appDragModeActive
            || appGridInteraction.pendingUncategorizedDrop != nil
            || appGridInteraction.pendingTagRemovalDrop != nil
    }
@@ -432,12 +455,20 @@
            ?? (isColorlessContainerMode ? filledColorlessContainer : nil)
    }
    private let rightSidebarFloatingClearance: CGFloat = 44
    private let tagNavigationHoverScrollDelay: TimeInterval = 0.14
    private let tagNavigationHoverScrollInterval: TimeInterval = 0.22
    private var floatingButtonSurfaceColor: Color {
        colorScheme == .dark
        return colorScheme == .dark
            ? Color.white.opacity(0.10)
            : Color.white.opacity(0.78)
    }
    private var appGridTheme: AppGridTheme {
        AppGridTheme(storedID: appGridThemeID)
    }
    private var renderedAppGridTheme: AppGridTheme {
        editPhase == .none ? appGridTheme : .defaultLight
    }
    private var isSideLayout: Bool {
@@ -452,6 +483,23 @@
        displayMode == "container" || displayMode == "gridContainer"
    }
    private var shouldShowUsageTips: Bool {
        !hideUsageTips && !allApps.isEmpty && !quickSearchOnlySession
    }
    private var appGridUsageTips: [AppGridUsageTip] {
        [
            AppGridUsageTip(id: 1, titleKey: "usageTips.tip1.title", detailKey: "usageTips.tip1.detail"),
            AppGridUsageTip(id: 2, titleKey: "usageTips.tip2.title", detailKey: "usageTips.tip2.detail"),
            AppGridUsageTip(id: 3, titleKey: "usageTips.tip3.title", detailKey: "usageTips.tip3.detail"),
            AppGridUsageTip(id: 4, titleKey: "usageTips.tip4.title", detailKey: "usageTips.tip4.detail"),
            AppGridUsageTip(id: 5, titleKey: "usageTips.tip5.title", detailKey: "usageTips.tip5.detail"),
            AppGridUsageTip(id: 6, titleKey: "usageTips.tip6.title", detailKey: "usageTips.tip6.detail"),
            AppGridUsageTip(id: 7, titleKey: "usageTips.tip7.title", detailKey: "usageTips.tip7.detail"),
            AppGridUsageTip(id: 8, titleKey: "usageTips.tip8.title", detailKey: "usageTips.tip8.detail"),
        ]
    }
    private var shouldRenderAppGridBehindQuickSearch: Bool {
        if quickSearchOnlySession {
            return false
@@ -462,9 +510,7 @@
    var body: some View {
        ZStack {
            if shouldRenderAppGridBehindQuickSearch {
                VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
                    .ignoresSafeArea()
                    .allowsHitTesting(false)
                appGridBackground
            }
            if shouldRenderAppGridBehindQuickSearch {
@@ -623,6 +669,9 @@
        .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
            publishModalInteractionState()
        }
        .onChange(of: smartStartNotice != nil) { _, _ in
            publishModalInteractionState()
        }
        .onDisappear {
            NotificationCenter.default.post(
                name: .tagLauncherModalInteractionChanged,
@@ -636,7 +685,11 @@
        NotificationCenter.default.post(
            name: .tagLauncherModalInteractionChanged,
            object: nil,
            userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil]
            userInfo: [
                "active": smartStartNotice != nil
                    || appGridInteraction.pendingUncategorizedDrop != nil
                    || appGridInteraction.pendingTagRemovalDrop != nil
            ]
        )
    }
@@ -919,6 +972,39 @@
        }
    }
    private var appGridBackground: some View {
        let theme = renderedAppGridTheme
        return ZStack {
            if theme.usesVisualEffectBackdrop {
                VisualEffectView(
                    material: theme.material,
                    blendingMode: .behindWindow
                )
            }
            if theme.isPureBlackBackground {
                Color.black
            }
            if !theme.isDefaultLight {
                LinearGradient(
                    colors: theme.backgroundBaseColors,
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                )
                LinearGradient(
                    colors: theme.backgroundAccentColors,
                    startPoint: .topTrailing,
                    endPoint: .bottomLeading
                )
                if theme.backgroundDimmingOpacity > 0 {
                    Rectangle()
                        .fill(Color.black.opacity(theme.backgroundDimmingOpacity))
                }
            }
        }
        .ignoresSafeArea()
        .allowsHitTesting(false)
    }
    private var floatingActionButtons: some View {
        HStack(spacing: 8) {
            floatingOverlayButton(systemImage: "pencil.line") {
@@ -1016,6 +1102,9 @@
            onActivate: { tagID in
                activateTagNavigation(tagID)
            },
            onDoubleActivate: { _ in
                openTagSettingsFromNavigation()
            },
            onHoverChange: { tagID, active in
                handleTagNavigationHover(tagID, active: active)
            },
@@ -1040,6 +1129,14 @@
            onAppDrop: { path, source, targetTag, copy in
                dropAppOnTagNavigation(path: path, sourceTag: source, targetTag: targetTag, copy: copy)
            }
        )
    }
    private func openTagSettingsFromNavigation() {
        NotificationCenter.default.post(
            name: .tagLauncherOpenPreferencesRequested,
            object: nil,
            userInfo: [SettingsTabTarget.userInfoKey: SettingsTabTarget.tags]
        )
    }
@@ -1104,7 +1201,9 @@
        Group {
            if allApps.isEmpty {
                Spacer()
                ProgressView().scaleEffect(0.8)
                if loadingSpinnerVisible {
                    ProgressView().scaleEffect(0.8)
                }
                Spacer()
            } else {
                AppGridCollectionView(
@@ -1113,9 +1212,14 @@
                    displayMode: displayMode,
                    iconSize: iconSize,
                    showNames: !hideAppNames,
                    appGridTheme: renderedAppGridTheme,
                    bubbleDisabled: appBubbleDisabled,
                    showUncommonAppBubbles: showUncommonAppBubbles,
                    highlightedGroupName: appGridHighlightedGroupName,
                    bottomContentPadding: shouldShowUsageTips ? AppGridUsageTipsMetrics.reservedHeight : 0,
                    usageTipsVisible: shouldShowUsageTips,
                    usageTips: appGridUsageTips,
                    selectedUsageTipIndex: $selectedUsageTipIndex,
                    contentRevision: groupLayoutVersion,
                    scrollTargetID: appGridScrollTargetID,
                    scrollRequestToken: appGridScrollRequestToken,
@@ -1128,13 +1232,18 @@
                    onDropOutsideGroup: { path, source, copy in
                        dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
                    },
                    onReorderApps: { containerID, orderedPaths in
                        reorderApps(inContainer: containerID, orderedPaths: orderedPaths)
                    },
                    onGroupActivate: { groupName in
                        if isColorlessContainerMode {
                            toggleColorlessFill(groupName)
                        }
                    },
                    onScrollActivity: handleAppGridScrollActivity,
                    onDragModeChange: { setAppDragMode($0) }
                    onDragModeChange: { setAppDragMode($0) },
                    onHideUsageTips: { hideUsageTips = true },
                    onUsageTipsHoverChange: handleUsageTipsHoverChange
                )
            }
        }
@@ -1196,17 +1305,32 @@
                } label: {
                    Label(tr("edit.exit"), systemImage: "rectangle.portrait.and.arrow.right")
                        .font(.system(size: 12))
                        .foregroundStyle(renderedAppGridTheme.editPrimaryTextColor)
                        .padding(.horizontal, 8)
                        .frame(height: 28)
                        .background(
                            RoundedRectangle(cornerRadius: 7, style: .continuous)
                                .fill(renderedAppGridTheme.editControlSurfaceColor)
                        )
                        .overlay(
                            RoundedRectangle(cornerRadius: 7, style: .continuous)
                                .stroke(renderedAppGridTheme.editControlStrokeColor, lineWidth: 1)
                        )
                }
                .buttonStyle(.bordered)
                .buttonStyle(.plain)
                .shadow(color: renderedAppGridTheme.editButtonShadowColor, radius: 10, y: 4)
                Spacer()
                Text(tr("edit.tags")).font(.headline)
                Text(tr("edit.tags"))
                    .font(.headline)
                    .foregroundStyle(renderedAppGridTheme.editPrimaryTextColor)
                Spacer()
            }
            .padding(.horizontal, 24)
            .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20)
            .padding(.bottom, 12)
            .background(renderedAppGridTheme.editToolbarSurfaceColor)
            Divider().opacity(0.3)
            Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
            TagEditorView(
                tagColors: $tagColors,
@@ -1221,6 +1345,7 @@
    private var editAppsView: some View {
        VStack(spacing: 0) {
            EditAppsHeaderView(
                theme: renderedAppGridTheme,
                operation: editTagOperation,
                hintText: editModeHintText,
                confirmTitle: editConfirmTitle,
@@ -1234,11 +1359,12 @@
                onConfirm: confirmAssign
            )
            Divider().opacity(0.3)
            Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
            HStack(spacing: 0) {
                VStack(alignment: .leading, spacing: 4) {
                    EditAppsSidebarIntroView(
                        theme: renderedAppGridTheme,
                        width: editSidebarWidth,
                        horizontalInset: editSidebarHorizontalInset
                    )
@@ -1270,10 +1396,14 @@
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                }
                .frame(width: editSidebarWidth, alignment: .topLeading)
                Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1)
                Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(width: 1)
                if allApps.isEmpty {
                    Spacer(); ProgressView().scaleEffect(0.8); Spacer()
                    Spacer()
                    if loadingSpinnerVisible {
                        ProgressView().scaleEffect(0.8)
                    }
                    Spacer()
                } else {
                    editAppsGrid
                }
@@ -1297,12 +1427,12 @@
    private func editFlatGroup(_ group: TagGroup) -> some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack(spacing: 0) {
                Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
                Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
                Text(group.name)
                    .font(.system(size: tagFontSize, weight: .semibold))
                    .foregroundStyle(.secondary)
                    .foregroundStyle(renderedAppGridTheme.editSecondaryTextColor)
                    .padding(.horizontal, 10)
                Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
                Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
            }
            .padding(.bottom, 6)
@@ -1335,7 +1465,8 @@
            colorIndex: colorIndex,
            operation: editTagOperation,
            isSelected: isSelected,
            isRemovableCandidate: isRemovableCandidate
            isRemovableCandidate: isRemovableCandidate,
            theme: renderedAppGridTheme
        ) {
            if isSelected {
                selectedTagNames.remove(tagName)
@@ -1409,7 +1540,8 @@
        return EditableAppSelectionItem(
            app: app,
            iconSize: iconSize,
            isSelected: isSelected
            isSelected: isSelected,
            theme: renderedAppGridTheme
        ) {
            toggleEditableAppSelection(app)
        }
@@ -1775,13 +1907,27 @@
    private func makeDisplayGroups(apps: [AppInfo], tagOrder: [String]) -> [TagGroup] {
        let order = tagOrder.isEmpty ? TagEditor.orderedTagNames() : tagOrder
        let rawGroups = AppIndexer.group(apps: apps, defaultGroupName: defaultGroupName, tagOrder: order)
        let rawGroups = AppIndexer.group(
            apps: apps,
            defaultGroupName: defaultGroupName,
            tagOrder: order,
            tagDefinitions: tagDefinitions,
            containerAppOrder: containerAppOrder
        )
        return rawGroups.map { group in
            if group.name == defaultGroupName {
                return TagGroup(name: tr("group.uncategorized"), apps: group.apps)
                return TagGroup(
                    containerID: group.containerID,
                    name: tr("group.uncategorized"),
                    apps: group.apps
                )
            }
            if group.name == "Mac自带" {
                return TagGroup(name: tr("group.appleBuiltIn"), apps: group.apps)
                return TagGroup(
                    containerID: group.containerID,
                    name: tr("group.appleBuiltIn"),
                    apps: group.apps
                )
            }
            return group
        }
@@ -1818,16 +1964,24 @@
        // Each overlay show creates a fresh ContentView with empty `allApps`. The indexer
        // cache may still be warm from a prior overlay/settings scan, so path-signature
        // alone must not skip the first hydrate or the grid spinner never clears.
        guard allApps.isEmpty || AppIndexer.shouldRefreshForSearchPathChanges() else { return }
        let wasEmpty = allApps.isEmpty
        hydrateFromLastAppLibrarySnapshotIfNeeded()
        guard wasEmpty || AppIndexer.shouldRefreshForSearchPathChanges() else { return }
        refreshApps()
    }
    private func refreshAppsForQuickSearch() {
        hydrateFromLastAppLibrarySnapshotIfNeeded()
        guard allApps.isEmpty
            || quickSearchDocuments.isEmpty
            || AppIndexer.shouldRefreshForSearchPathChanges()
        else { return }
        refreshApps()
    }
    private func hydrateFromLastAppLibrarySnapshotIfNeeded() {
        guard allApps.isEmpty, let snapshot = AppLibraryController.lastSnapshot() else { return }
        applyAppLibrarySnapshot(snapshot)
    }
    private func refreshNotchHeight() {
@@ -1840,6 +1994,13 @@
    private func handleAppGridScrollActivity() {
        if appGridInteraction.hoveredBubble != nil {
            appGridInteraction.hoveredBubble = nil
        }
    }
    private func handleUsageTipsHoverChange(_ hovering: Bool) {
        usageTipsHovered = hovering
        if hovering, appGridInteraction.hoveredBubble != nil {
            appGridInteraction.hoveredBubble = nil
        }
    }
@@ -1985,7 +2146,7 @@
    }
    private func handleTagNavigationHover(_ id: String, active: Bool) {
        if appGridInteraction.appDragModeActive {
        if appGridInteraction.appDragModeActive || tagNavDragModeActive {
            if !active, appGridInteraction.tagNavigationHoveredGroupName == id {
                appGridInteraction.tagNavigationHoveredGroupName = nil
            }
@@ -2001,7 +2162,7 @@
        appGridInteraction.tagNavigationHoveredGroupName = id
        fillColorlessContainer(id)
        scrollToTagFromHover(id)
        scheduleTagNavigationHoverScroll(id)
    }
    private func handleTagNavigationAppDropHover(_ id: String, active: Bool) {
@@ -2020,6 +2181,14 @@
        }
        appGridInteraction.tagNavigationAppDropTargetName = id
    }
    private func scheduleTagNavigationHoverScroll(_ id: String) {
        DispatchQueue.main.asyncAfter(deadline: .now() + tagNavigationHoverScrollDelay) {
            guard appGridInteraction.tagNavigationHoveredGroupName == id else { return }
            guard !appGridInteraction.appDragModeActive && !tagNavDragModeActive else { return }
            scrollToTagFromHover(id)
        }
    }
    private func scrollToTagFromHover(_ id: String) {
@@ -2136,6 +2305,8 @@
    private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
        resetTransientDragState(keepingPendingUncategorizedDrop: true)
        guard sourceTag != targetTag else { return }
        if isUncategorizedDropTarget(targetTag) {
            confirmAndMoveAppToUncategorized(path: path)
            return
@@ -2147,7 +2318,6 @@
        }
        guard tagColors[targetTag] != nil else { return }
        guard sourceTag != targetTag || copy else { return }
        TagEditor.moveApp(
            path: path,
            from: sourceTag,
@@ -2166,6 +2336,18 @@
        guard !appHasTag(app, tagName: targetTag) else { return }
        TagEditor.appendTags([targetTag], to: [path])
        showDropRefresh()
        refreshApps(forceLayoutRefresh: true)
    }
    private func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
        resetTransientDragState()
        let key = TagDatabase.normalizedContainerID(containerID, tags: tagDefinitions)
        let paths = TagDatabase.normalizedAppOrderPaths(orderedPaths)
        guard !key.isEmpty, !paths.isEmpty else { return }
        containerAppOrder[key] = paths
        rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
        TagEditor.reorderApps(inContainer: key, orderedPaths: paths)
        refreshApps(forceLayoutRefresh: true)
    }
@@ -2447,6 +2629,7 @@
        }
        refreshInProgress = true
        scheduleLoadingSpinnerIfNeeded()
        DispatchQueue.global(qos: .userInitiated).async {
            let result = AppLibraryController.refresh(useCache: useCache)
            DispatchQueue.main.async {
@@ -2456,6 +2639,7 @@
                    finishDropRefreshAfterMinimumDuration()
                }
                refreshInProgress = false
                hideLoadingSpinner()
                if refreshAgainAfterCurrent {
                    let shouldForceLayout = refreshAgainForceLayout
                    let shouldUseCache = refreshAgainUseCache
@@ -2468,10 +2652,31 @@
        }
    }
    private func scheduleLoadingSpinnerIfNeeded() {
        loadingSpinnerToken &+= 1
        let token = loadingSpinnerToken
        loadingSpinnerVisible = false
        guard allApps.isEmpty else { return }
        DispatchQueue.main.asyncAfter(deadline: .now() + loadingSpinnerDelay) {
            guard token == loadingSpinnerToken,
                  refreshInProgress,
                  allApps.isEmpty
            else { return }
            loadingSpinnerVisible = true
        }
    }
    private func hideLoadingSpinner() {
        loadingSpinnerToken &+= 1
        loadingSpinnerVisible = false
    }
    private func applyAppLibrarySnapshot(_ snapshot: AppLibrarySnapshot) {
        allApps = snapshot.apps
        quickSearchDocuments = snapshot.quickSearchDocuments
        tagColors = snapshot.tagColors
        tagDefinitions = snapshot.tagDefinitions
        containerAppOrder = snapshot.containerAppOrder
        draggedTagNames = snapshot.tagOrder
        rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
        if quickSearchVisible {