From c5bdc7f1eb3ff4287beeeebb17d4f02f376a8d38 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 28 Aug 2026 17:34:19 +0800
Subject: [PATCH] feat: add quick search tag filtering
---
C1.source/Apptag/Localization/no.json | 4
C1.source/Scripts/quick_search_tag_filter_qa.sh | 308 ++++++++++++++++++++
C1.source/Apptag/Localization/nl.json | 4
C1.source/CHANGELOG.md | 8
C1.source/Apptag/Localization/es.json | 4
C1.source/Apptag/Localization/pl.json | 4
C1.source/Apptag/Localization/sr-Cyrl.json | 4
C1.source/Apptag/Localization/zh-Hans.json | 4
C1.source/Apptag/Localization/ja.json | 4
C1.source/Apptag/Localization/ms.json | 4
C1.source/Apptag/Localization/ar-Najdi.json | 4
C1.source/Apptag/Localization/zh-Hant.json | 4
C1.source/Apptag/Localization/th.json | 4
C1.source/Apptag/Localization/da.json | 4
C1.source/Apptag/Localization/id.json | 4
C1.source/Apptag/Localization/ro.json | 4
C1.source/Apptag/Localization/cs.json | 4
C1.source/Apptag/Localization/it.json | 4
C1.source/Apptag/ContentView.swift | 71 ++++
C1.source/Apptag/Localization/tr.json | 4
C1.source/Apptag/Info.plist | 4
C1.source/Apptag/Localization/nn.json | 4
C1.source/Apptag/Localization/ru.json | 4
C1.source/Apptag/Localization/en.json | 4
C1.source/Apptag/Localization/sv.json | 4
C1.source/Apptag/Localization/pt-BR.json | 4
CODEGRAPH.md | 13
C1.source/Apptag/Localization/ko.json | 4
C1.source/Apptag/DataLayer.swift | 29 +
C1.source/Apptag/Localization/de.json | 4
C1.source/Apptag/TagNavigationView.swift | 11
C1.source/Apptag/Localization/uk.json | 4
C1.source/Apptag/QuickSearch.swift | 320 ++++++++++++++++++++-
C1.source/Apptag/Localization/vi.json | 4
C1.source/Apptag/Localization/fr.json | 4
C1.source/Apptag/Localization/ar.json | 4
C1.source/Apptag/Localization/nb.json | 4
37 files changed, 849 insertions(+), 31 deletions(-)
diff --git a/C1.source/Apptag/ContentView.swift b/C1.source/Apptag/ContentView.swift
index b1ca582..d9ae06f 100644
--- a/C1.source/Apptag/ContentView.swift
+++ b/C1.source/Apptag/ContentView.swift
@@ -447,6 +447,7 @@
@State private var quickSearchQuery = ""
@State private var quickSearchDocuments: [QuickSearchDocument] = []
@State private var quickSearchResults: [QuickSearchResult] = []
+ @State private var quickSearchSelectedTagName: String? = nil
@State private var quickSearchSelectedID: URL? = nil
@State private var quickSearchManualSelection = false
@State private var quickSearchFocusToken = 0
@@ -620,6 +621,9 @@
}
.onChange(of: defaultGroupName) { _, _ in
rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+ if quickSearchVisible {
+ refreshQuickSearchResults()
+ }
}
.onChange(of: quickSearchQuery) { _, _ in
quickSearchErrorMessage = nil
@@ -816,6 +820,8 @@
QuickSearchPanelPresentationView(
query: $quickSearchQuery,
results: quickSearchResults,
+ tagFilters: quickSearchTagFilters,
+ selectedTagName: quickSearchSelectedTagName,
selectedID: quickSearchSelectedID,
focusToken: quickSearchFocusToken,
selectionScrollToken: quickSearchSelectionScrollToken,
@@ -826,7 +832,8 @@
errorMessage: quickSearchErrorMessage,
onCommand: handleQuickSearchCommand,
onHover: selectQuickSearchResult,
- onLaunch: launchQuickSearchResult
+ onLaunch: launchQuickSearchResult,
+ onTagFilterChange: selectQuickSearchTagFilter
)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
@@ -843,6 +850,7 @@
&& !quickSearchResults.isEmpty
let visibleRows = min(max(1, quickSearchResults.count), quickSearchMaxVisibleRows(in: size))
return QuickSearchPanelMetrics.contentHeight(
+ hasTagFilters: !quickSearchTagFilters.isEmpty,
hasResultList: hasResultList,
visibleRows: visibleRows
)
@@ -855,6 +863,7 @@
private func quickSearchMaxVisibleRows(in size: CGSize) -> Int {
let bottomClearance: CGFloat = 84
let chromeHeight = QuickSearchPanelMetrics.headerHeight
+ + (quickSearchTagFilters.isEmpty ? 0 : QuickSearchPanelMetrics.tagFilterBarHeight)
+ QuickSearchPanelMetrics.dividerHeight
+ QuickSearchPanelMetrics.resultListVerticalInset * 2
let rowHeightWithSpacing = QuickSearchPanelMetrics.rowHeight
@@ -874,6 +883,7 @@
quickSearchVisible = true
quickSearchOpenedAt = Date()
quickSearchQuery = ""
+ quickSearchSelectedTagName = nil
quickSearchCompositionActive = false
quickSearchManualSelection = false
quickSearchErrorMessage = nil
@@ -928,6 +938,7 @@
quickSearchCompositionActive = false
quickSearchQuery = ""
quickSearchResults = []
+ quickSearchSelectedTagName = nil
quickSearchSelectedID = nil
quickSearchManualSelection = false
quickSearchErrorMessage = nil
@@ -977,9 +988,12 @@
private func refreshQuickSearchResults() {
let previousSelection = quickSearchSelectedID
+ let documents = quickSearchVisibleDocuments
+ let selectedTagName = validateQuickSearchSelectedTagName(against: quickSearchTagFilters)
quickSearchResults = QuickSearchEngine.search(
quickSearchQuery,
- documents: quickSearchDocuments.filter { FileManager.default.fileExists(atPath: $0.app.path.path) }
+ documents: documents,
+ selectedTagName: selectedTagName
)
if quickSearchResults.isEmpty {
@@ -996,6 +1010,58 @@
quickSearchSelectedID = quickSearchResults.first?.id
quickSearchManualSelection = false
}
+ }
+
+ private var quickSearchVisibleDocuments: [QuickSearchDocument] {
+ quickSearchDocuments.filter { FileManager.default.fileExists(atPath: $0.app.path.path) }
+ }
+
+ private var quickSearchTagFilters: [QuickSearchTagFilterOption] {
+ let availableTagNames = Set(
+ quickSearchVisibleDocuments
+ .flatMap(\.tagNames)
+ .filter(isQuickSearchFilterableTag)
+ )
+ guard !availableTagNames.isEmpty else { return [] }
+
+ let ordered = draggedTagNames
+ .filter { availableTagNames.contains($0) && isQuickSearchFilterableTag($0) }
+ let orderedSet = Set(ordered)
+ let remaining = availableTagNames
+ .filter { !orderedSet.contains($0) }
+ .sorted { $0.localizedStandardCompare($1) == .orderedAscending }
+
+ return (ordered + remaining).map { tagName in
+ let definition = tagDefinitions[tagName]
+ return QuickSearchTagFilterOption(
+ name: tagName,
+ colorIndex: definition?.color ?? tagColors[tagName] ?? 0,
+ customColor: definition?.customColor ?? tagCustomColors[tagName]
+ )
+ }
+ }
+
+ private func isQuickSearchFilterableTag(_ tagName: String) -> Bool {
+ let name = tagName.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty, tagDefinitions[name] != nil else { return false }
+ return !AppContainerID.isReservedGroupName(name, defaultGroupName: defaultGroupName)
+ }
+
+ private func validateQuickSearchSelectedTagName(against filters: [QuickSearchTagFilterOption]) -> String? {
+ guard let quickSearchSelectedTagName else { return nil }
+ guard filters.contains(where: { $0.name == quickSearchSelectedTagName }) else {
+ self.quickSearchSelectedTagName = nil
+ return nil
+ }
+ return quickSearchSelectedTagName
+ }
+
+ private func selectQuickSearchTagFilter(_ tagName: String?) {
+ quickSearchSelectedTagName = tagName
+ quickSearchErrorMessage = nil
+ quickSearchManualSelection = false
+ quickSearchFocusToken &+= 1
+ refreshQuickSearchResults()
}
private func handleQuickSearchCommand(_ command: QuickSearchCommand) {
@@ -2429,6 +2495,7 @@
quickSearchFocusToken &+= 1
}
quickSearchOpenedAt = Date()
+ quickSearchSelectedTagName = nil
refreshQuickSearchResults()
refreshAppsForQuickSearch()
NotificationCenter.default.post(
diff --git a/C1.source/Apptag/DataLayer.swift b/C1.source/Apptag/DataLayer.swift
index b949e7b..f39f906 100644
--- a/C1.source/Apptag/DataLayer.swift
+++ b/C1.source/Apptag/DataLayer.swift
@@ -70,12 +70,31 @@
return tag(name)
}
+ static func isReservedGroupName(_ name: String, defaultGroupName: String = "Other") -> Bool {
+ reservedGroupNames(defaultGroupName: defaultGroupName).contains(
+ name.trimmingCharacters(in: .whitespacesAndNewlines)
+ )
+ }
+
private static func isUncategorizedDisplayName(_ name: String) -> Bool {
name == "Other" || name == tr("group.uncategorized")
}
private static func isAppleBuiltInDisplayName(_ name: String) -> Bool {
name == "Mac自带" || name == tr("group.appleBuiltIn")
+ }
+
+ private static func reservedGroupNames(defaultGroupName: String) -> Set<String> {
+ let names = [
+ "Other",
+ defaultGroupName,
+ tr("group.uncategorized"),
+ "Mac自带",
+ tr("group.appleBuiltIn"),
+ TagDatabase.uncommonTagKey,
+ tr("group.uncommon")
+ ]
+ return Set(names.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty })
}
}
@@ -868,7 +887,9 @@
} else {
for tag in uniqueOrdered(app.tags) {
let displayName = nameOverrides[tag] ?? tag
- guard displayName != macCategory else { continue }
+ guard !AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName) else {
+ continue
+ }
appendGroupedApp(
app,
to: displayName,
@@ -886,9 +907,9 @@
tagOrder: tagOrder
) {
let displayName = nameOverrides[tagName] ?? tagName
- guard displayName != macCategory,
- displayName != defaultGroupName
- else { continue }
+ guard !AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName) else {
+ continue
+ }
if containerIDsByGroupName[displayName] == nil {
containerIDsByGroupName[displayName] = AppContainerID.forTag(
diff --git a/C1.source/Apptag/Info.plist b/C1.source/Apptag/Info.plist
index 7885d89..39009ee 100644
--- a/C1.source/Apptag/Info.plist
+++ b/C1.source/Apptag/Info.plist
@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>8.3.7</string>
+ <string>8.4.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -32,7 +32,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
- <string>20260822.2207</string>
+ <string>20260828.1732</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
diff --git a/C1.source/Apptag/Localization/ar-Najdi.json b/C1.source/Apptag/Localization/ar-Najdi.json
index 567f86f..0dc94c8 100644
--- a/C1.source/Apptag/Localization/ar-Najdi.json
+++ b/C1.source/Apptag/Localization/ar-Najdi.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "حقل البحث السريع",
"quickSearch.emptyPrompt": "ابدأ بالكتابة عشان تبحث في التطبيقات والوسوم والملاحظات.",
"quickSearch.noResults": "ما لقينا تطبيقات.",
+ "quickSearch.tagFilter.all": "الكل",
+ "quickSearch.tagFilter.noResults": "ما لقينا تطبيقات في %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "إظهار كل نتائج البحث السريع",
+ "quickSearch.tagFilter.tagAccessibility": "تصفية البحث السريع حسب الوسم %tagName%",
"quickSearch.launchFailed": "تعذر فتح هالتطبيق. جرّب مرة ثانية أو اختر تطبيق ثاني.",
"quickSearch.tagPrefix": "وسم",
"quickSearch.hotkeys": "اختصارات لوحة المفاتيح",
diff --git a/C1.source/Apptag/Localization/ar.json b/C1.source/Apptag/Localization/ar.json
index e1f14ef..3247b0a 100644
--- a/C1.source/Apptag/Localization/ar.json
+++ b/C1.source/Apptag/Localization/ar.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "حقل البحث السريع",
"quickSearch.emptyPrompt": "ابدأ بالكتابة للبحث في التطبيقات والوسوم والملاحظات.",
"quickSearch.noResults": "لم يتم العثور على تطبيقات.",
+ "quickSearch.tagFilter.all": "الكل",
+ "quickSearch.tagFilter.noResults": "لم يتم العثور على تطبيقات في %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "إظهار كل نتائج البحث السريع",
+ "quickSearch.tagFilter.tagAccessibility": "تصفية البحث السريع حسب الوسم %tagName%",
"quickSearch.launchFailed": "تعذر فتح هذا التطبيق. حاول مرة أخرى أو اختر تطبيقًا آخر.",
"quickSearch.tagPrefix": "وسم",
"quickSearch.hotkeys": "اختصارات لوحة المفاتيح",
diff --git a/C1.source/Apptag/Localization/cs.json b/C1.source/Apptag/Localization/cs.json
index 8f09d1e..c632ad8 100644
--- a/C1.source/Apptag/Localization/cs.json
+++ b/C1.source/Apptag/Localization/cs.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Vstup rychlého hledání",
"quickSearch.emptyPrompt": "Začněte psát a hledejte aplikace, štítky a poznámky.",
"quickSearch.noResults": "Nebyly nalezeny žádné aplikace.",
+ "quickSearch.tagFilter.all": "Vše",
+ "quickSearch.tagFilter.noResults": "V %tagName% nebyly nalezeny žádné aplikace.",
+ "quickSearch.tagFilter.allAccessibility": "Zobrazit všechny výsledky rychlého hledání",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrovat rychlé hledání podle štítku %tagName%",
"quickSearch.launchFailed": "Tuto aplikaci nelze otevřít. Zkuste to znovu nebo vyberte jinou aplikaci.",
"quickSearch.tagPrefix": "Štítek",
"quickSearch.hotkeys": "Klávesové zkratky",
diff --git a/C1.source/Apptag/Localization/da.json b/C1.source/Apptag/Localization/da.json
index 2c1cc90..3557250 100644
--- a/C1.source/Apptag/Localization/da.json
+++ b/C1.source/Apptag/Localization/da.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Input til hurtigsøgning",
"quickSearch.emptyPrompt": "Begynd at skrive for at søge efter apps, mærker og noter.",
"quickSearch.noResults": "Ingen apps fundet.",
+ "quickSearch.tagFilter.all": "Alle",
+ "quickSearch.tagFilter.noResults": "Ingen apps fundet i %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Vis alle resultater i Hurtig søgning",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrer Hurtig søgning efter tagget %tagName%",
"quickSearch.launchFailed": "Denne app kunne ikke åbnes. Prøv igen, eller vælg en anden app.",
"quickSearch.tagPrefix": "Mærke",
"quickSearch.hotkeys": "Tastaturgenveje",
diff --git a/C1.source/Apptag/Localization/de.json b/C1.source/Apptag/Localization/de.json
index a1ee613..78bb775 100644
--- a/C1.source/Apptag/Localization/de.json
+++ b/C1.source/Apptag/Localization/de.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Eingabefeld für Schnellsuche",
"quickSearch.emptyPrompt": "Tippen Sie, um Apps, Tags und Notizen zu suchen.",
"quickSearch.noResults": "Keine Apps gefunden.",
+ "quickSearch.tagFilter.all": "Alle",
+ "quickSearch.tagFilter.noResults": "Keine Apps in %tagName% gefunden.",
+ "quickSearch.tagFilter.allAccessibility": "Alle Quick Search-Ergebnisse anzeigen",
+ "quickSearch.tagFilter.tagAccessibility": "Quick Search nach Tag %tagName% filtern",
"quickSearch.launchFailed": "Diese App konnte nicht geöffnet werden. Versuchen Sie es erneut oder wählen Sie eine andere App.",
"quickSearch.tagPrefix": "Tag",
"quickSearch.hotkeys": "Tastaturkurzbefehle",
diff --git a/C1.source/Apptag/Localization/en.json b/C1.source/Apptag/Localization/en.json
index 83e8723..8b976fb 100644
--- a/C1.source/Apptag/Localization/en.json
+++ b/C1.source/Apptag/Localization/en.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Quick Search input",
"quickSearch.emptyPrompt": "Start typing to search apps, tags, and notes.",
"quickSearch.noResults": "No apps found.",
+ "quickSearch.tagFilter.all": "All",
+ "quickSearch.tagFilter.noResults": "No apps found in %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Show all Quick Search results",
+ "quickSearch.tagFilter.tagAccessibility": "Filter Quick Search by tag %tagName%",
"quickSearch.launchFailed": "Unable to open this app. Please try again or choose another app.",
"quickSearch.tagPrefix": "Tag",
"quickSearch.hotkeys": "Keyboard shortcuts",
diff --git a/C1.source/Apptag/Localization/es.json b/C1.source/Apptag/Localization/es.json
index 013bd9c..94076f6 100644
--- a/C1.source/Apptag/Localization/es.json
+++ b/C1.source/Apptag/Localization/es.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Campo de búsqueda rápida",
"quickSearch.emptyPrompt": "Empieza a escribir para buscar apps, etiquetas y notas.",
"quickSearch.noResults": "No se encontraron apps.",
+ "quickSearch.tagFilter.all": "Todo",
+ "quickSearch.tagFilter.noResults": "No se encontraron apps en %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Mostrar todos los resultados de búsqueda rápida",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrar búsqueda rápida por la etiqueta %tagName%",
"quickSearch.launchFailed": "No se pudo abrir esta app. Inténtalo de nuevo o elige otra app.",
"quickSearch.tagPrefix": "Etiqueta",
"quickSearch.hotkeys": "Atajos de teclado",
diff --git a/C1.source/Apptag/Localization/fr.json b/C1.source/Apptag/Localization/fr.json
index 757cd64..4f23cec 100644
--- a/C1.source/Apptag/Localization/fr.json
+++ b/C1.source/Apptag/Localization/fr.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Champ de recherche rapide",
"quickSearch.emptyPrompt": "Commencez à saisir pour rechercher des apps, des étiquettes et des notes.",
"quickSearch.noResults": "Aucune app trouvée.",
+ "quickSearch.tagFilter.all": "Tout",
+ "quickSearch.tagFilter.noResults": "Aucune app trouvée dans %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Afficher tous les résultats de Recherche rapide",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrer Recherche rapide par le tag %tagName%",
"quickSearch.launchFailed": "Impossible d’ouvrir cette app. Réessayez ou choisissez une autre app.",
"quickSearch.tagPrefix": "Étiquette",
"quickSearch.hotkeys": "Raccourcis clavier",
diff --git a/C1.source/Apptag/Localization/id.json b/C1.source/Apptag/Localization/id.json
index a510814..8be6c5c 100644
--- a/C1.source/Apptag/Localization/id.json
+++ b/C1.source/Apptag/Localization/id.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Input pencarian cepat",
"quickSearch.emptyPrompt": "Mulai mengetik untuk mencari app, tag, dan catatan.",
"quickSearch.noResults": "Tidak ada app ditemukan.",
+ "quickSearch.tagFilter.all": "Semua",
+ "quickSearch.tagFilter.noResults": "Tidak ada app yang ditemukan di %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Tampilkan semua hasil Pencarian Cepat",
+ "quickSearch.tagFilter.tagAccessibility": "Filter Pencarian Cepat menurut tag %tagName%",
"quickSearch.launchFailed": "App ini tidak dapat dibuka. Coba lagi atau pilih app lain.",
"quickSearch.tagPrefix": "Tag",
"quickSearch.hotkeys": "Pintasan keyboard",
diff --git a/C1.source/Apptag/Localization/it.json b/C1.source/Apptag/Localization/it.json
index 6399d25..03eb6ec 100644
--- a/C1.source/Apptag/Localization/it.json
+++ b/C1.source/Apptag/Localization/it.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Campo di ricerca rapida",
"quickSearch.emptyPrompt": "Inizia a digitare per cercare app, tag e note.",
"quickSearch.noResults": "Nessuna app trovata.",
+ "quickSearch.tagFilter.all": "Tutte",
+ "quickSearch.tagFilter.noResults": "Nessuna app trovata in %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Mostra tutti i risultati di Ricerca rapida",
+ "quickSearch.tagFilter.tagAccessibility": "Filtra Ricerca rapida per tag %tagName%",
"quickSearch.launchFailed": "Impossibile aprire questa app. Riprova o scegli un’altra app.",
"quickSearch.tagPrefix": "Tag",
"quickSearch.hotkeys": "Scorciatoie da tastiera",
diff --git a/C1.source/Apptag/Localization/ja.json b/C1.source/Apptag/Localization/ja.json
index ee5e996..fc64737 100644
--- a/C1.source/Apptag/Localization/ja.json
+++ b/C1.source/Apptag/Localization/ja.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "クイック検索入力欄",
"quickSearch.emptyPrompt": "入力を開始してアプリ、タグ、メモを検索します。",
"quickSearch.noResults": "アプリが見つかりません。",
+ "quickSearch.tagFilter.all": "すべて",
+ "quickSearch.tagFilter.noResults": "%tagName% にアプリが見つかりません。",
+ "quickSearch.tagFilter.allAccessibility": "クイック検索のすべての結果を表示",
+ "quickSearch.tagFilter.tagAccessibility": "%tagName% タグでクイック検索を絞り込む",
"quickSearch.launchFailed": "このアプリを開けません。もう一度試すか、別のアプリを選択してください。",
"quickSearch.tagPrefix": "タグ",
"quickSearch.hotkeys": "キーボードショートカット",
diff --git a/C1.source/Apptag/Localization/ko.json b/C1.source/Apptag/Localization/ko.json
index 7b67c10..45f9911 100644
--- a/C1.source/Apptag/Localization/ko.json
+++ b/C1.source/Apptag/Localization/ko.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "빠른 검색 입력란",
"quickSearch.emptyPrompt": "앱, 태그, 메모를 검색하려면 입력을 시작하세요.",
"quickSearch.noResults": "앱을 찾을 수 없습니다.",
+ "quickSearch.tagFilter.all": "전체",
+ "quickSearch.tagFilter.noResults": "%tagName%에서 앱을 찾을 수 없습니다.",
+ "quickSearch.tagFilter.allAccessibility": "모든 빠른 검색 결과 보기",
+ "quickSearch.tagFilter.tagAccessibility": "%tagName% 태그로 빠른 검색 필터링",
"quickSearch.launchFailed": "이 앱을 열 수 없습니다. 다시 시도하거나 다른 앱을 선택하세요.",
"quickSearch.tagPrefix": "태그",
"quickSearch.hotkeys": "키보드 단축키",
diff --git a/C1.source/Apptag/Localization/ms.json b/C1.source/Apptag/Localization/ms.json
index b16ac1b..090aaea 100644
--- a/C1.source/Apptag/Localization/ms.json
+++ b/C1.source/Apptag/Localization/ms.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Input carian pantas",
"quickSearch.emptyPrompt": "Mula menaip untuk mencari app, tag dan nota.",
"quickSearch.noResults": "Tiada app ditemui.",
+ "quickSearch.tagFilter.all": "Semua",
+ "quickSearch.tagFilter.noResults": "Tiada app ditemui dalam %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Tunjukkan semua hasil Carian Pantas",
+ "quickSearch.tagFilter.tagAccessibility": "Tapis Carian Pantas mengikut tag %tagName%",
"quickSearch.launchFailed": "App ini tidak dapat dibuka. Cuba lagi atau pilih app lain.",
"quickSearch.tagPrefix": "Tag",
"quickSearch.hotkeys": "Pintasan papan kekunci",
diff --git a/C1.source/Apptag/Localization/nb.json b/C1.source/Apptag/Localization/nb.json
index 4b53453..f904085 100644
--- a/C1.source/Apptag/Localization/nb.json
+++ b/C1.source/Apptag/Localization/nb.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Inndata for hurtigsøk",
"quickSearch.emptyPrompt": "Begynn å skrive for å søke etter apper, tagger og notater.",
"quickSearch.noResults": "Fant ingen apper.",
+ "quickSearch.tagFilter.all": "Alle",
+ "quickSearch.tagFilter.noResults": "Ingen apper funnet i %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Vis alle hurtigsøk-resultater",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrer hurtigsøk etter taggen %tagName%",
"quickSearch.launchFailed": "Kunne ikke åpne denne appen. Prøv igjen eller velg en annen app.",
"quickSearch.tagPrefix": "Tagg",
"quickSearch.hotkeys": "Tastatursnarveier",
diff --git a/C1.source/Apptag/Localization/nl.json b/C1.source/Apptag/Localization/nl.json
index 9bc018d..b9083ec 100644
--- a/C1.source/Apptag/Localization/nl.json
+++ b/C1.source/Apptag/Localization/nl.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Invoer voor snel zoeken",
"quickSearch.emptyPrompt": "Begin met typen om apps, tags en notities te zoeken.",
"quickSearch.noResults": "Geen apps gevonden.",
+ "quickSearch.tagFilter.all": "Alles",
+ "quickSearch.tagFilter.noResults": "Geen apps gevonden in %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Alle resultaten van Snel zoeken tonen",
+ "quickSearch.tagFilter.tagAccessibility": "Snel zoeken filteren op tag %tagName%",
"quickSearch.launchFailed": "Deze app kan niet worden geopend. Probeer opnieuw of kies een andere app.",
"quickSearch.tagPrefix": "Tag",
"quickSearch.hotkeys": "Toetscombinaties",
diff --git a/C1.source/Apptag/Localization/nn.json b/C1.source/Apptag/Localization/nn.json
index 990634f..10fb631 100644
--- a/C1.source/Apptag/Localization/nn.json
+++ b/C1.source/Apptag/Localization/nn.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Inndata for hurtigsøk",
"quickSearch.emptyPrompt": "Begynn å skrive for å søke etter apper, tagger og notater.",
"quickSearch.noResults": "Fant ingen apper.",
+ "quickSearch.tagFilter.all": "Alle",
+ "quickSearch.tagFilter.noResults": "Fann ingen appar i %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Vis alle hurtigsøk-resultat",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrer hurtigsøk etter taggen %tagName%",
"quickSearch.launchFailed": "Kunne ikke åpne denne appen. Prøv igjen eller velg en annen app.",
"quickSearch.tagPrefix": "Tagg",
"quickSearch.hotkeys": "Tastatursnarvegar",
diff --git a/C1.source/Apptag/Localization/no.json b/C1.source/Apptag/Localization/no.json
index 4b53453..f904085 100644
--- a/C1.source/Apptag/Localization/no.json
+++ b/C1.source/Apptag/Localization/no.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Inndata for hurtigsøk",
"quickSearch.emptyPrompt": "Begynn å skrive for å søke etter apper, tagger og notater.",
"quickSearch.noResults": "Fant ingen apper.",
+ "quickSearch.tagFilter.all": "Alle",
+ "quickSearch.tagFilter.noResults": "Ingen apper funnet i %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Vis alle hurtigsøk-resultater",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrer hurtigsøk etter taggen %tagName%",
"quickSearch.launchFailed": "Kunne ikke åpne denne appen. Prøv igjen eller velg en annen app.",
"quickSearch.tagPrefix": "Tagg",
"quickSearch.hotkeys": "Tastatursnarveier",
diff --git a/C1.source/Apptag/Localization/pl.json b/C1.source/Apptag/Localization/pl.json
index 6b44813..5115844 100644
--- a/C1.source/Apptag/Localization/pl.json
+++ b/C1.source/Apptag/Localization/pl.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Pole szybkiego wyszukiwania",
"quickSearch.emptyPrompt": "Zacznij pisać, aby szukać aplikacji, tagów i notatek.",
"quickSearch.noResults": "Nie znaleziono aplikacji.",
+ "quickSearch.tagFilter.all": "Wszystkie",
+ "quickSearch.tagFilter.noResults": "Nie znaleziono aplikacji w %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Pokaż wszystkie wyniki szybkiego wyszukiwania",
+ "quickSearch.tagFilter.tagAccessibility": "Filtruj szybkie wyszukiwanie według tagu %tagName%",
"quickSearch.launchFailed": "Nie można otworzyć tej aplikacji. Spróbuj ponownie lub wybierz inną aplikację.",
"quickSearch.tagPrefix": "Tag",
"quickSearch.hotkeys": "Skróty klawiaturowe",
diff --git a/C1.source/Apptag/Localization/pt-BR.json b/C1.source/Apptag/Localization/pt-BR.json
index 7fff595..5d64984 100644
--- a/C1.source/Apptag/Localization/pt-BR.json
+++ b/C1.source/Apptag/Localization/pt-BR.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Campo de busca rápida",
"quickSearch.emptyPrompt": "Comece a digitar para buscar apps, etiquetas e notas.",
"quickSearch.noResults": "Nenhum app encontrado.",
+ "quickSearch.tagFilter.all": "Tudo",
+ "quickSearch.tagFilter.noResults": "Nenhum app encontrado em %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Mostrar todos os resultados da busca rápida",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrar busca rápida pela tag %tagName%",
"quickSearch.launchFailed": "Não foi possível abrir este app. Tente novamente ou escolha outro app.",
"quickSearch.tagPrefix": "Etiqueta",
"quickSearch.hotkeys": "Atalhos de teclado",
diff --git a/C1.source/Apptag/Localization/ro.json b/C1.source/Apptag/Localization/ro.json
index c324790..3b1f546 100644
--- a/C1.source/Apptag/Localization/ro.json
+++ b/C1.source/Apptag/Localization/ro.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Câmp de căutare rapidă",
"quickSearch.emptyPrompt": "Începe să tastezi pentru a căuta aplicații, etichete și note.",
"quickSearch.noResults": "Nu s-au găsit aplicații.",
+ "quickSearch.tagFilter.all": "Toate",
+ "quickSearch.tagFilter.noResults": "Nu s-au găsit aplicații în %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Afișează toate rezultatele Căutării rapide",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrează Căutarea rapidă după eticheta %tagName%",
"quickSearch.launchFailed": "Această aplicație nu poate fi deschisă. Încearcă din nou sau alege altă aplicație.",
"quickSearch.tagPrefix": "Etichetă",
"quickSearch.hotkeys": "Scurtături de la tastatură",
diff --git a/C1.source/Apptag/Localization/ru.json b/C1.source/Apptag/Localization/ru.json
index 941c049..4e657f8 100644
--- a/C1.source/Apptag/Localization/ru.json
+++ b/C1.source/Apptag/Localization/ru.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Поле быстрого поиска",
"quickSearch.emptyPrompt": "Начните вводить, чтобы искать приложения, теги и заметки.",
"quickSearch.noResults": "Приложения не найдены.",
+ "quickSearch.tagFilter.all": "Все",
+ "quickSearch.tagFilter.noResults": "В %tagName% приложения не найдены.",
+ "quickSearch.tagFilter.allAccessibility": "Показать все результаты быстрого поиска",
+ "quickSearch.tagFilter.tagAccessibility": "Фильтровать быстрый поиск по тегу %tagName%",
"quickSearch.launchFailed": "Не удалось открыть это приложение. Повторите попытку или выберите другое приложение.",
"quickSearch.tagPrefix": "Тег",
"quickSearch.hotkeys": "Сочетания клавиш",
diff --git a/C1.source/Apptag/Localization/sr-Cyrl.json b/C1.source/Apptag/Localization/sr-Cyrl.json
index 820e8ae..2ce10e3 100644
--- a/C1.source/Apptag/Localization/sr-Cyrl.json
+++ b/C1.source/Apptag/Localization/sr-Cyrl.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Поље брзе претраге",
"quickSearch.emptyPrompt": "Почните да куцате да претражите апликације, ознаке и белешке.",
"quickSearch.noResults": "Нема пронађених апликација.",
+ "quickSearch.tagFilter.all": "Све",
+ "quickSearch.tagFilter.noResults": "Нема апликација у %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Прикажи све резултате брзе претраге",
+ "quickSearch.tagFilter.tagAccessibility": "Филтрирај брзу претрагу по ознаци %tagName%",
"quickSearch.launchFailed": "Ову апликацију није могуће отворити. Покушајте поново или изаберите другу апликацију.",
"quickSearch.tagPrefix": "Ознака",
"quickSearch.hotkeys": "Пречице на тастатури",
diff --git a/C1.source/Apptag/Localization/sv.json b/C1.source/Apptag/Localization/sv.json
index 5d46075..eba12be 100644
--- a/C1.source/Apptag/Localization/sv.json
+++ b/C1.source/Apptag/Localization/sv.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Inmatning för snabbsökning",
"quickSearch.emptyPrompt": "Börja skriva för att söka appar, taggar och anteckningar.",
"quickSearch.noResults": "Inga appar hittades.",
+ "quickSearch.tagFilter.all": "Alla",
+ "quickSearch.tagFilter.noResults": "Inga appar hittades i %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Visa alla resultat i Snabbsökning",
+ "quickSearch.tagFilter.tagAccessibility": "Filtrera Snabbsökning efter taggen %tagName%",
"quickSearch.launchFailed": "Det gick inte att öppna appen. Försök igen eller välj en annan app.",
"quickSearch.tagPrefix": "Tagg",
"quickSearch.hotkeys": "Kortkommandon",
diff --git a/C1.source/Apptag/Localization/th.json b/C1.source/Apptag/Localization/th.json
index 8265015..28ee2bb 100644
--- a/C1.source/Apptag/Localization/th.json
+++ b/C1.source/Apptag/Localization/th.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "ช่องค้นหาอย่างรวดเร็ว",
"quickSearch.emptyPrompt": "เริ่มพิมพ์เพื่อค้นหาแอป แท็ก และบันทึก",
"quickSearch.noResults": "ไม่พบแอป",
+ "quickSearch.tagFilter.all": "ทั้งหมด",
+ "quickSearch.tagFilter.noResults": "ไม่พบแอปใน %tagName%",
+ "quickSearch.tagFilter.allAccessibility": "แสดงผลลัพธ์ Quick Search ทั้งหมด",
+ "quickSearch.tagFilter.tagAccessibility": "กรอง Quick Search ด้วยแท็ก %tagName%",
"quickSearch.launchFailed": "ไม่สามารถเปิดแอปนี้ได้ ลองอีกครั้งหรือเลือกแอปอื่น",
"quickSearch.tagPrefix": "แท็ก",
"quickSearch.hotkeys": "แป้นพิมพ์ลัด",
diff --git a/C1.source/Apptag/Localization/tr.json b/C1.source/Apptag/Localization/tr.json
index 8364404..c89788e 100644
--- a/C1.source/Apptag/Localization/tr.json
+++ b/C1.source/Apptag/Localization/tr.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Hızlı arama girişi",
"quickSearch.emptyPrompt": "Uygulamaları, etiketleri ve notları aramak için yazmaya başlayın.",
"quickSearch.noResults": "Uygulama bulunamadı.",
+ "quickSearch.tagFilter.all": "Tümü",
+ "quickSearch.tagFilter.noResults": "%tagName% içinde uygulama bulunamadı.",
+ "quickSearch.tagFilter.allAccessibility": "Tüm Hızlı Arama sonuçlarını göster",
+ "quickSearch.tagFilter.tagAccessibility": "Hızlı Aramayı %tagName% etiketiyle filtrele",
"quickSearch.launchFailed": "Bu uygulama açılamadı. Yeniden deneyin veya başka bir uygulama seçin.",
"quickSearch.tagPrefix": "Etiket",
"quickSearch.hotkeys": "Klavye kısayolları",
diff --git a/C1.source/Apptag/Localization/uk.json b/C1.source/Apptag/Localization/uk.json
index f16feb0..ed8a799 100644
--- a/C1.source/Apptag/Localization/uk.json
+++ b/C1.source/Apptag/Localization/uk.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Поле швидкого пошуку",
"quickSearch.emptyPrompt": "Почніть вводити, щоб шукати програми, теги й нотатки.",
"quickSearch.noResults": "Програми не знайдено.",
+ "quickSearch.tagFilter.all": "Усі",
+ "quickSearch.tagFilter.noResults": "У %tagName% не знайдено програм.",
+ "quickSearch.tagFilter.allAccessibility": "Показати всі результати швидкого пошуку",
+ "quickSearch.tagFilter.tagAccessibility": "Фільтрувати швидкий пошук за тегом %tagName%",
"quickSearch.launchFailed": "Не вдалося відкрити цю програму. Спробуйте ще раз або виберіть іншу програму.",
"quickSearch.tagPrefix": "Тег",
"quickSearch.hotkeys": "Комбінації клавіш",
diff --git a/C1.source/Apptag/Localization/vi.json b/C1.source/Apptag/Localization/vi.json
index 8ae8be4..dd46d2c 100644
--- a/C1.source/Apptag/Localization/vi.json
+++ b/C1.source/Apptag/Localization/vi.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "Ô nhập tìm kiếm nhanh",
"quickSearch.emptyPrompt": "Bắt đầu nhập để tìm ứng dụng, thẻ và ghi chú.",
"quickSearch.noResults": "Không tìm thấy ứng dụng.",
+ "quickSearch.tagFilter.all": "Tất cả",
+ "quickSearch.tagFilter.noResults": "Không tìm thấy ứng dụng trong %tagName%.",
+ "quickSearch.tagFilter.allAccessibility": "Hiển thị tất cả kết quả Tìm kiếm nhanh",
+ "quickSearch.tagFilter.tagAccessibility": "Lọc Tìm kiếm nhanh theo thẻ %tagName%",
"quickSearch.launchFailed": "Không thể mở ứng dụng này. Hãy thử lại hoặc chọn ứng dụng khác.",
"quickSearch.tagPrefix": "Thẻ",
"quickSearch.hotkeys": "Phím tắt bàn phím",
diff --git a/C1.source/Apptag/Localization/zh-Hans.json b/C1.source/Apptag/Localization/zh-Hans.json
index e0b1b3f..7f50b3c 100644
--- a/C1.source/Apptag/Localization/zh-Hans.json
+++ b/C1.source/Apptag/Localization/zh-Hans.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "快捷搜索输入框",
"quickSearch.emptyPrompt": "开始输入以搜索 App、标签和备注。",
"quickSearch.noResults": "未找到应用。",
+ "quickSearch.tagFilter.all": "全部",
+ "quickSearch.tagFilter.noResults": "“%tagName%”标签下未找到应用。",
+ "quickSearch.tagFilter.allAccessibility": "显示全部快捷搜索结果",
+ "quickSearch.tagFilter.tagAccessibility": "按“%tagName%”标签筛选快捷搜索",
"quickSearch.launchFailed": "无法打开这个应用。请重试或选择其他应用。",
"quickSearch.tagPrefix": "标签",
"quickSearch.hotkeys": "快捷键",
diff --git a/C1.source/Apptag/Localization/zh-Hant.json b/C1.source/Apptag/Localization/zh-Hant.json
index ef7bad4..29493cd 100644
--- a/C1.source/Apptag/Localization/zh-Hant.json
+++ b/C1.source/Apptag/Localization/zh-Hant.json
@@ -170,6 +170,10 @@
"quickSearch.inputAccessibility": "快速搜尋輸入框",
"quickSearch.emptyPrompt": "開始輸入以搜尋 App、標籤和備註。",
"quickSearch.noResults": "找不到 App。",
+ "quickSearch.tagFilter.all": "全部",
+ "quickSearch.tagFilter.noResults": "「%tagName%」標籤下找不到 App。",
+ "quickSearch.tagFilter.allAccessibility": "顯示全部快速搜尋結果",
+ "quickSearch.tagFilter.tagAccessibility": "依「%tagName%」標籤篩選快速搜尋",
"quickSearch.launchFailed": "無法開啟這個 App。請重試或選擇其他 App。",
"quickSearch.tagPrefix": "標籤",
"quickSearch.hotkeys": "快速鍵",
diff --git a/C1.source/Apptag/QuickSearch.swift b/C1.source/Apptag/QuickSearch.swift
index f4365c4..1c0c33e 100644
--- a/C1.source/Apptag/QuickSearch.swift
+++ b/C1.source/Apptag/QuickSearch.swift
@@ -298,6 +298,13 @@
var app: AppInfo { document.app }
}
+struct QuickSearchTagFilterOption: Identifiable, Equatable {
+ var id: String { name }
+ let name: String
+ let colorIndex: Int
+ let customColor: TagCustomColor?
+}
+
enum QuickSearchEngine {
static func makeDocuments(apps: [AppInfo], store: TagDatabase.Store) -> [QuickSearchDocument] {
apps
@@ -329,14 +336,34 @@
}
}
- static func search(_ query: String, documents: [QuickSearchDocument], limit: Int = 50) -> [QuickSearchResult] {
+ static func search(
+ _ query: String,
+ documents: [QuickSearchDocument],
+ selectedTagName: String? = nil,
+ limit: Int = 50
+ ) -> [QuickSearchResult] {
+ let activeTagName = normalizedSelectedTagName(selectedTagName)
+ let scopedDocuments = activeTagName.map { tagName in
+ documents.filter { document in
+ document.tagNames.contains(tagName)
+ }
+ } ?? documents
let normalizedQuery = normalizeQuery(query)
guard !normalizedQuery.isEmpty else {
- return emptyQueryResults(documents: documents, limit: min(limit, 6))
+ if let activeTagName {
+ return tagFilteredEmptyQueryResults(
+ documents: scopedDocuments,
+ selectedTagName: activeTagName,
+ limit: limit
+ )
+ }
+ return emptyQueryResults(documents: scopedDocuments, limit: min(limit, 6))
}
let tokens = normalizedQuery.split(separator: " ").map(String.init)
- let results = documents.compactMap { result(for: $0, tokens: tokens) }
+ let results = scopedDocuments.compactMap {
+ result(for: $0, tokens: tokens, selectedTagName: activeTagName)
+ }
return results.sorted(by: rank).prefix(limit).map { $0 }
}
@@ -350,7 +377,11 @@
.lowercased()
}
- private static func result(for document: QuickSearchDocument, tokens: [String]) -> QuickSearchResult? {
+ private static func result(
+ for document: QuickSearchDocument,
+ tokens: [String],
+ selectedTagName: String?
+ ) -> QuickSearchResult? {
let fields = document.searchableFields
var textScore: Double = 0
var bestFieldRank = Int.max
@@ -379,7 +410,7 @@
finalScore: finalScore,
textScore: textScore,
bestFieldRank: bestFieldRank,
- matchedTagName: matchedTagName,
+ matchedTagName: matchedTagName ?? selectedTagName,
noteSnippet: noteSnippet
)
}
@@ -549,6 +580,27 @@
return nil
}
+ private static func tagFilteredEmptyQueryResults(
+ documents: [QuickSearchDocument],
+ selectedTagName: String,
+ limit: Int
+ ) -> [QuickSearchResult] {
+ documents
+ .map {
+ QuickSearchResult(
+ document: $0,
+ finalScore: behaviorBoost(for: $0),
+ textScore: 0,
+ bestFieldRank: Int.max,
+ matchedTagName: selectedTagName,
+ noteSnippet: nil
+ )
+ }
+ .sorted(by: rank)
+ .prefix(limit)
+ .map { $0 }
+ }
+
private static func emptyQueryResults(documents: [QuickSearchDocument], limit: Int) -> [QuickSearchResult] {
var used = Set<URL>()
let recent = documents
@@ -698,6 +750,12 @@
return normalizeField(String(parts))
}
+ private static func normalizedSelectedTagName(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
private static func internalBundleNames(for app: AppInfo) -> [String] {
guard let bundle = Bundle(url: app.path) else { return [] }
let values = [
@@ -738,23 +796,28 @@
static let rowSpacing: CGFloat = 2
static let resultListVerticalInset: CGFloat = 10
static let headerHeight: CGFloat = 84
+ static let tagFilterBarHeight: CGFloat = 40
static let dividerHeight: CGFloat = 1
static let messageRowHeight: CGFloat = 86
- static func contentHeight(hasResultList: Bool, visibleRows: Int) -> CGFloat {
+ static func contentHeight(hasTagFilters: Bool, hasResultList: Bool, visibleRows: Int) -> CGFloat {
+ let tagFilterHeight = hasTagFilters ? tagFilterBarHeight : 0
if hasResultList {
return headerHeight
+ + tagFilterHeight
+ dividerHeight
+ CGFloat(max(1, visibleRows)) * (rowHeight + rowSpacing)
+ resultListVerticalInset * 2
}
- return headerHeight + dividerHeight + messageRowHeight
+ return headerHeight + tagFilterHeight + dividerHeight + messageRowHeight
}
}
struct QuickSearchPanelPresentationView: NSViewRepresentable {
@Binding var query: String
let results: [QuickSearchResult]
+ let tagFilters: [QuickSearchTagFilterOption]
+ let selectedTagName: String?
let selectedID: URL?
let focusToken: Int
let selectionScrollToken: Int
@@ -766,6 +829,7 @@
let onCommand: (QuickSearchCommand) -> Void
let onHover: (QuickSearchResult) -> Void
let onLaunch: (QuickSearchResult) -> Void
+ let onTagFilterChange: (String?) -> Void
func makeNSView(context: Context) -> QuickSearchPanelAnchorView {
QuickSearchPanelAnchorView()
@@ -784,6 +848,8 @@
QuickSearchOverlayView(
query: $query,
results: results,
+ tagFilters: tagFilters,
+ selectedTagName: selectedTagName,
selectedID: selectedID,
focusToken: focusToken,
selectionScrollToken: selectionScrollToken,
@@ -792,7 +858,8 @@
errorMessage: errorMessage,
onCommand: onCommand,
onHover: onHover,
- onLaunch: onLaunch
+ onLaunch: onLaunch,
+ onTagFilterChange: onTagFilterChange
)
)
)
@@ -957,10 +1024,15 @@
override var canBecomeMain: Bool { false }
override func sendEvent(_ event: NSEvent) {
- if event.type == .leftMouseDown,
- let row = quickSearchResultRow(at: event.locationInWindow) {
- row.launchFromMouseClick()
- return
+ if event.type == .leftMouseDown {
+ if let tagChip = quickSearchTagFilterClickTarget(at: event.locationInWindow),
+ tagChip.activateFromMouseClick() {
+ return
+ }
+ if let row = quickSearchResultRow(at: event.locationInWindow) {
+ row.launchFromMouseClick()
+ return
+ }
}
super.sendEvent(event)
}
@@ -976,9 +1048,42 @@
}
return contentView.quickSearchResultRowDescendant(containingWindowPoint: windowPoint)
}
+
+ private func quickSearchTagFilterClickTarget(at windowPoint: NSPoint) -> QuickSearchTagFilterClickTargetView? {
+ guard let contentView else { return nil }
+ if let chip = contentView.quickSearchTagFilterClickTargetFromHitTest(atWindowPoint: windowPoint) {
+ return chip
+ }
+ return contentView.quickSearchTagFilterClickTargetDescendant(containingWindowPoint: windowPoint)
+ }
}
private extension NSView {
+ func quickSearchTagFilterClickTargetFromHitTest(atWindowPoint windowPoint: NSPoint) -> QuickSearchTagFilterClickTargetView? {
+ let localPoint = convert(windowPoint, from: nil)
+ var hitView = hitTest(localPoint)
+ while let candidate = hitView {
+ if let chip = candidate as? QuickSearchTagFilterClickTargetView {
+ return chip
+ }
+ hitView = candidate.superview
+ }
+ return nil
+ }
+
+ func quickSearchTagFilterClickTargetDescendant(containingWindowPoint windowPoint: NSPoint) -> QuickSearchTagFilterClickTargetView? {
+ for subview in subviews.reversed() {
+ if let chip = subview as? QuickSearchTagFilterClickTargetView,
+ chip.containsWindowPointForActivation(windowPoint) {
+ return chip
+ }
+ if let chip = subview.quickSearchTagFilterClickTargetDescendant(containingWindowPoint: windowPoint) {
+ return chip
+ }
+ }
+ return nil
+ }
+
func quickSearchResultRowFromHitTest(atWindowPoint windowPoint: NSPoint) -> QuickSearchResultRowView? {
let localPoint = convert(windowPoint, from: nil)
var hitView = hitTest(localPoint)
@@ -1051,6 +1156,8 @@
struct QuickSearchOverlayView: View {
@Binding var query: String
let results: [QuickSearchResult]
+ let tagFilters: [QuickSearchTagFilterOption]
+ let selectedTagName: String?
let selectedID: URL?
let focusToken: Int
let selectionScrollToken: Int
@@ -1060,6 +1167,7 @@
let onCommand: (QuickSearchCommand) -> Void
let onHover: (QuickSearchResult) -> Void
let onLaunch: (QuickSearchResult) -> Void
+ let onTagFilterChange: (String?) -> Void
@Environment(\.colorScheme) private var colorScheme
@@ -1074,6 +1182,26 @@
private var panelShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 34, style: .continuous)
+ }
+
+ private var hasTagFilters: Bool {
+ !tagFilters.isEmpty
+ }
+
+ private var emptyResultsSystemImage: String {
+ if selectedTagName != nil {
+ return "tag"
+ }
+ return query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "keyboard" : "magnifyingglass"
+ }
+
+ private var emptyResultsMessage: String {
+ if let selectedTagName {
+ return tr("quickSearch.tagFilter.noResults", replacements: ["%tagName%": selectedTagName])
+ }
+ return query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ ? tr("quickSearch.emptyPrompt")
+ : tr("quickSearch.noResults")
}
var body: some View {
@@ -1096,6 +1224,15 @@
.padding(.top, 22)
.padding(.bottom, 18)
+ if hasTagFilters {
+ QuickSearchTagFilterBar(
+ filters: tagFilters,
+ selectedTagName: selectedTagName,
+ onSelect: onTagFilterChange
+ )
+ .frame(height: QuickSearchPanelMetrics.tagFilterBarHeight)
+ }
+
Divider().opacity(0.35)
if isLoading {
@@ -1112,10 +1249,8 @@
)
} else if results.isEmpty {
QuickSearchMessageRow(
- systemImage: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "keyboard" : "magnifyingglass",
- message: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- ? tr("quickSearch.emptyPrompt")
- : tr("quickSearch.noResults"),
+ systemImage: emptyResultsSystemImage,
+ message: emptyResultsMessage,
tint: .secondary
)
} else {
@@ -1152,6 +1287,159 @@
}
}
+private struct QuickSearchTagFilterBar: View {
+ let filters: [QuickSearchTagFilterOption]
+ let selectedTagName: String?
+ let onSelect: (String?) -> Void
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ QuickSearchTagFilterChip(
+ title: tr("quickSearch.tagFilter.all"),
+ colorIndex: nil,
+ customColor: nil,
+ isSelected: selectedTagName == nil,
+ accessibilityLabel: tr("quickSearch.tagFilter.allAccessibility")
+ ) {
+ onSelect(nil)
+ }
+
+ ForEach(filters) { filter in
+ QuickSearchTagFilterChip(
+ title: filter.name,
+ colorIndex: filter.colorIndex,
+ customColor: filter.customColor,
+ isSelected: selectedTagName == filter.name,
+ accessibilityLabel: tr(
+ "quickSearch.tagFilter.tagAccessibility",
+ replacements: ["%tagName%": filter.name]
+ )
+ ) {
+ onSelect(selectedTagName == filter.name ? nil : filter.name)
+ }
+ }
+ }
+ .padding(.horizontal, 28)
+ .padding(.vertical, 5)
+ }
+ .accessibilityElement(children: .contain)
+ }
+}
+
+private struct QuickSearchTagFilterChip: View {
+ let title: String
+ let colorIndex: Int?
+ let customColor: TagCustomColor?
+ let isSelected: Bool
+ let accessibilityLabel: String
+ let action: () -> Void
+
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var isHovered = false
+
+ private var tagDotColor: Color? {
+ guard let colorIndex else { return nil }
+ return Color(nsColor: TagColor.nsColor(for: colorIndex, customColor: customColor))
+ }
+
+ private var backgroundColor: Color {
+ if isSelected {
+ return Color.primary.opacity(colorScheme == .dark ? 0.18 : 0.10)
+ }
+ return Color.primary.opacity(isHovered ? (colorScheme == .dark ? 0.11 : 0.075) : (colorScheme == .dark ? 0.08 : 0.055))
+ }
+
+ private var strokeColor: Color {
+ Color.primary.opacity(isSelected ? 0.34 : 0.11)
+ }
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 7) {
+ if let tagDotColor {
+ Circle()
+ .fill(tagDotColor)
+ .frame(width: 7, height: 7)
+ }
+
+ Text(title)
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .foregroundStyle(.primary)
+ .layoutPriority(1)
+ }
+ .padding(.horizontal, 11)
+ .frame(maxWidth: 160, minHeight: 30, maxHeight: 30, alignment: .center)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(backgroundColor)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .stroke(strokeColor, lineWidth: isSelected ? 1.2 : 1)
+ )
+ .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ .help(title)
+ .onHover { isHovered = $0 }
+ .overlay(
+ QuickSearchTagFilterClickTarget(action: action)
+ .accessibilityHidden(true)
+ )
+ .accessibilityLabel(accessibilityLabel)
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ }
+}
+
+private struct QuickSearchTagFilterClickTarget: NSViewRepresentable {
+ let action: () -> Void
+
+ func makeNSView(context: Context) -> QuickSearchTagFilterClickTargetView {
+ let view = QuickSearchTagFilterClickTargetView()
+ view.onActivate = action
+ return view
+ }
+
+ func updateNSView(_ view: QuickSearchTagFilterClickTargetView, context: Context) {
+ view.onActivate = action
+ }
+}
+
+private final class QuickSearchTagFilterClickTargetView: NSView {
+ var onActivate: (() -> Void)?
+
+ override var isFlipped: Bool { true }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ guard activateFromMouseClick() else {
+ super.mouseDown(with: event)
+ return
+ }
+ }
+
+ @discardableResult
+ func activateFromMouseClick() -> Bool {
+ guard let onActivate else { return false }
+ DispatchQueue.main.async { [onActivate] in
+ onActivate()
+ }
+ return true
+ }
+
+ func containsWindowPointForActivation(_ windowPoint: NSPoint) -> Bool {
+ guard !isHidden, window != nil else { return false }
+ let localPoint = convert(windowPoint, from: nil)
+ return bounds.contains(localPoint) && visibleRect.contains(localPoint)
+ }
+}
+
private struct QuickSearchResultListView: NSViewRepresentable {
let results: [QuickSearchResult]
let selectedID: URL?
diff --git a/C1.source/Apptag/TagNavigationView.swift b/C1.source/Apptag/TagNavigationView.swift
index 551eaa1..5cc3871 100644
--- a/C1.source/Apptag/TagNavigationView.swift
+++ b/C1.source/Apptag/TagNavigationView.swift
@@ -209,7 +209,7 @@
) {
let oldNames = self.items.map(\.name)
let newNames = items.map(\.name)
- let needsRebuild = Set(oldNames) != Set(newNames) || self.orientation != orientation
+ let needsRebuild = oldNames.count != newNames.count || Set(oldNames) != Set(newNames) || self.orientation != orientation
self.items = items
self.orientation = orientation
self.contentInsets = contentInsets
@@ -292,9 +292,14 @@
}
private func reuseButtonsInCurrentOrder() {
- let existing = Dictionary(uniqueKeysWithValues: buttons.map { ($0.itemName, $0) })
+ var existing: [String: [TagNavigationButton]] = [:]
+ for button in buttons {
+ existing[button.itemName, default: []].append(button)
+ }
buttons = items.compactMap { item in
- guard let button = existing[item.name] else { return nil }
+ guard var candidates = existing[item.name], !candidates.isEmpty else { return nil }
+ let button = candidates.removeFirst()
+ existing[item.name] = candidates
button.configure(item: item, orientation: orientation)
button.refreshAppDropRegistration()
return button
diff --git a/C1.source/CHANGELOG.md b/C1.source/CHANGELOG.md
index 2b922e5..d69fb3a 100644
--- a/C1.source/CHANGELOG.md
+++ b/C1.source/CHANGELOG.md
@@ -2,6 +2,14 @@
## [Unreleased]
+## [8.4.0] — 2026-08-28
+
+- Quick Search 增加标签筛选条,可点击 `全部` 清除筛选,也可点击普通标签在该标签内继续搜索 App、标签和备注
+- 标签筛选保持运行时单选状态,不持久化,不改变 App Grid、Pro 权益、数据结构或发布链路
+- 修复 `全部` 标签在真实 UI 下不能稳定清除筛选的问题,并防止保护容器名触发 duplicate-key 崩溃
+- 补齐 29 个语种的 Quick Search 标签筛选文案,新增专项 QA 门禁与真实 UI 复测证据
+- 版本号更新为 `8.4.0`,Build 更新为 `20260828.1732`
+
## [8.3.7] — 2026-08-22
- 优化 App Grid 备注气泡输入框的默认提示文字颜色,在深色浮层上保持次级提示感但不再近似不可见
diff --git a/C1.source/Scripts/quick_search_tag_filter_qa.sh b/C1.source/Scripts/quick_search_tag_filter_qa.sh
new file mode 100755
index 0000000..aa6bc86
--- /dev/null
+++ b/C1.source/Scripts/quick_search_tag_filter_qa.sh
@@ -0,0 +1,308 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+QUICK_SEARCH="$ROOT_DIR/Apptag/QuickSearch.swift"
+CONTENT_VIEW="$ROOT_DIR/Apptag/ContentView.swift"
+DATA_LAYER="$ROOT_DIR/Apptag/DataLayer.swift"
+TAG_NAVIGATION="$ROOT_DIR/Apptag/TagNavigationView.swift"
+L10N_SWIFT="$ROOT_DIR/Apptag/L10n.swift"
+LOCALIZATION_DIR="$ROOT_DIR/Apptag/Localization"
+
+python3 - "$QUICK_SEARCH" "$CONTENT_VIEW" "$DATA_LAYER" "$TAG_NAVIGATION" "$L10N_SWIFT" "$LOCALIZATION_DIR" <<'PY'
+import json
+import re
+import sys
+from pathlib import Path
+
+quick_search = Path(sys.argv[1]).read_text(encoding="utf-8")
+content_view = Path(sys.argv[2]).read_text(encoding="utf-8")
+data_layer = Path(sys.argv[3]).read_text(encoding="utf-8")
+tag_navigation = Path(sys.argv[4]).read_text(encoding="utf-8")
+l10n_swift = Path(sys.argv[5]).read_text(encoding="utf-8")
+localization_dir = Path(sys.argv[6])
+
+
+def fail(message: str) -> None:
+ print(f"FAIL: {message}", file=sys.stderr)
+ sys.exit(1)
+
+
+def require(condition: bool, message: str) -> None:
+ if not condition:
+ fail(message)
+
+
+def require_contains(source: str, needle: str, message: str) -> None:
+ require(needle in source, message)
+
+
+def block(source: str, pattern: str, message: str) -> str:
+ match = re.search(pattern, source, re.S)
+ require(match is not None, message)
+ return match.group(0)
+
+
+search_block = block(
+ quick_search,
+ r"static func search\(\n _ query: String,.*?static func normalizeQuery",
+ "QuickSearchEngine.search block is missing",
+)
+for needle, message in [
+ ("selectedTagName: String? = nil", "search must accept a runtime selected tag filter"),
+ ("let activeTagName = normalizedSelectedTagName(selectedTagName)", "search must normalize the selected tag name without persisting it"),
+ ("documents.filter { document in", "search must scope documents before text matching"),
+ ("document.tagNames.contains(tagName)", "search tag filter must use exact tag membership"),
+ ("if let activeTagName", "empty query must branch when a tag filter is active"),
+ ("tagFilteredEmptyQueryResults(", "empty query with selected tag must list that tag's apps"),
+ ("emptyQueryResults(documents: scopedDocuments, limit: min(limit, 6))", "empty query without selected tag must keep the existing recent/frequent cap"),
+ ("result(for: $0, tokens: tokens, selectedTagName: activeTagName)", "text query must combine keyword matching with the selected tag scope"),
+]:
+ require_contains(search_block, needle, message)
+
+require_contains(
+ quick_search,
+ "matchedTagName: matchedTagName ?? selectedTagName",
+ "filtered results should prefer the selected tag in the right-side row badge",
+)
+require_contains(
+ quick_search,
+ "struct QuickSearchTagFilterOption: Identifiable, Equatable",
+ "Quick Search must expose tag filter options for the panel",
+)
+
+metrics_block = block(
+ quick_search,
+ r"enum QuickSearchPanelMetrics.*?struct QuickSearchPanelPresentationView",
+ "Quick Search panel metrics block is missing",
+)
+for needle, message in [
+ ("static let tagFilterBarHeight: CGFloat = 40", "tag filter bar height must follow the V1 design contract"),
+ ("contentHeight(hasTagFilters: Bool", "panel height must account for the tag filter bar"),
+ ("let tagFilterHeight = hasTagFilters ? tagFilterBarHeight : 0", "panel height must only grow when real filters exist"),
+]:
+ require_contains(metrics_block, needle, message)
+
+presentation_block = block(
+ quick_search,
+ r"struct QuickSearchPanelPresentationView: NSViewRepresentable.*?private final class QuickSearchPanel",
+ "Quick Search presentation view block is missing",
+)
+for needle, message in [
+ ("let tagFilters: [QuickSearchTagFilterOption]", "panel presentation must receive available tag filters"),
+ ("let selectedTagName: String?", "panel presentation must receive the selected tag"),
+ ("let onTagFilterChange: (String?) -> Void", "panel presentation must report tag changes"),
+ ("tagFilters: tagFilters", "panel presentation must pass filters into the overlay"),
+ ("selectedTagName: selectedTagName", "panel presentation must pass selected tag into the overlay"),
+ ("onTagFilterChange: onTagFilterChange", "panel presentation must pass tag change handler into the overlay"),
+]:
+ require_contains(presentation_block, needle, message)
+
+overlay_block = block(
+ quick_search,
+ r"struct QuickSearchOverlayView: View.*?private struct QuickSearchResultListView",
+ "Quick Search overlay block is missing",
+)
+for needle, message in [
+ ("QuickSearchTagFilterBar(", "overlay must render the tag filter bar"),
+ ("ScrollView(.horizontal, showsIndicators: false)", "tag filter bar must be single-line horizontal scrolling"),
+ ("QuickSearchTagFilterChip(", "tag filter bar must render tappable chips"),
+ ("title: tr(\"quickSearch.tagFilter.all\")", "tag filter bar must include the localized All chip"),
+ ("onSelect(nil)", "tapping the All chip must clear the selected tag filter"),
+ ("onSelect(selectedTagName == filter.name ? nil : filter.name)", "tapping the selected tag must clear the filter"),
+ ("tr(\"quickSearch.tagFilter.noResults\"", "selected tag no-results copy must be localized"),
+ (".frame(maxWidth: 160, minHeight: 30, maxHeight: 30", "tag chips must cap long labels and keep a stable hit target"),
+ (".help(title)", "long or truncated tag chips must expose the full tag name"),
+]:
+ require_contains(overlay_block, needle, message)
+
+tag_bar_index = overlay_block.find("QuickSearchTagFilterBar(")
+divider_index = overlay_block.find("Divider().opacity")
+result_list_index = overlay_block.find("QuickSearchResultListView(")
+require(
+ 0 <= tag_bar_index < divider_index < result_list_index,
+ "tag filter bar must sit between the search field and the result list divider",
+)
+require(
+ "LazyVGrid" not in overlay_block and "GridItem" not in overlay_block,
+ "Quick Search results must remain long-list rows, not a square grid",
+)
+require_contains(
+ overlay_block,
+ "QuickSearchResultListView(",
+ "Quick Search result list must continue using the existing long-row host",
+)
+
+panel_class = block(
+ quick_search,
+ r"private final class QuickSearchPanel: NSPanel.*?final class QuickSearchPanelAnchorView",
+ "QuickSearchPanel class block is missing",
+)
+for needle, message in [
+ ("quickSearchTagFilterClickTarget(at: event.locationInWindow)", "panel mouse fallback must resolve tag filter chips from the event point"),
+ ("tagChip.activateFromMouseClick()", "panel mouse fallback must invoke the resolved tag chip action"),
+ ("quickSearchTagFilterClickTargetFromHitTest(atWindowPoint: windowPoint)", "panel tag fallback should keep the direct hitTest path"),
+ ("quickSearchTagFilterClickTargetDescendant(containingWindowPoint: windowPoint)", "panel tag fallback must keep a recursive geometry path"),
+ ("quickSearchResultRow(at: event.locationInWindow)", "panel mouse fallback must preserve result-row launch handling"),
+]:
+ require_contains(panel_class, needle, message)
+tag_fallback_index = panel_class.find("quickSearchTagFilterClickTarget(at: event.locationInWindow)")
+row_fallback_index = panel_class.find("quickSearchResultRow(at: event.locationInWindow)")
+require(
+ 0 <= tag_fallback_index < row_fallback_index,
+ "panel mouse fallback must process tag chips before result rows",
+)
+
+chip_block = block(
+ quick_search,
+ r"private struct QuickSearchTagFilterChip: View.*?private struct QuickSearchResultListView",
+ "QuickSearchTagFilterChip block is missing",
+)
+for needle, message in [
+ ("QuickSearchTagFilterClickTarget(action: action)", "tag chips must install a native mouse click target"),
+ ("private struct QuickSearchTagFilterClickTarget: NSViewRepresentable", "tag chips must use an AppKit-backed click target"),
+ ("private final class QuickSearchTagFilterClickTargetView: NSView", "tag chip native click target view is missing"),
+ ("override func acceptsFirstMouse(for event: NSEvent?) -> Bool", "tag chip native click target must accept first mouse"),
+ ("override func mouseDown(with event: NSEvent)", "tag chip native click target must handle real mouse down"),
+ ("func activateFromMouseClick() -> Bool", "tag chip native click target must expose a panel-level activation fallback"),
+ ("func containsWindowPointForActivation(_ windowPoint: NSPoint) -> Bool", "tag chip native click target must expose geometry containment"),
+ ("bounds.contains(localPoint) && visibleRect.contains(localPoint)", "tag chip geometry fallback must reject hidden or clipped chip areas"),
+]:
+ require_contains(chip_block, needle, message)
+
+state_block = block(
+ content_view,
+ r"// Quick Search\n.*?init\(hideOverlay",
+ "ContentView Quick Search state block is missing",
+)
+require_contains(
+ state_block,
+ "@State private var quickSearchSelectedTagName: String? = nil",
+ "ContentView must keep selected tag as runtime state only",
+)
+require(
+ "AppStorage(\"quickSearchSelectedTagName\")" not in content_view
+ and "UserDefaults.standard.set" not in block(content_view, r"private func selectQuickSearchTagFilter.*?private func handleQuickSearchCommand", "tag selection handler block is missing"),
+ "Quick Search tag filter must not be persisted",
+)
+
+content_panel_block = block(
+ content_view,
+ r"private var quickSearchOverlay: some View.*?private func openQuickSearch",
+ "ContentView Quick Search overlay block is missing",
+)
+for needle, message in [
+ ("tagFilters: quickSearchTagFilters", "ContentView must pass real available tag filters into the panel"),
+ ("selectedTagName: quickSearchSelectedTagName", "ContentView must pass selected tag into the panel"),
+ ("onTagFilterChange: selectQuickSearchTagFilter", "ContentView must handle tag chip clicks"),
+ ("hasTagFilters: !quickSearchTagFilters.isEmpty", "panel height must account for visible filters"),
+ ("quickSearchTagFilters.isEmpty ? 0 : QuickSearchPanelMetrics.tagFilterBarHeight", "max visible rows must reserve height for the filter bar"),
+]:
+ require_contains(content_panel_block, needle, message)
+
+refresh_block = block(
+ content_view,
+ r"private func refreshQuickSearchResults\(\).*?private func handleQuickSearchCommand",
+ "ContentView Quick Search refresh block is missing",
+)
+for needle, message in [
+ ("let documents = quickSearchVisibleDocuments", "refresh must filter out missing app paths once before searching"),
+ ("let selectedTagName = validateQuickSearchSelectedTagName(against: quickSearchTagFilters)", "refresh must clear deleted/renamed tag filters before searching"),
+ ("selectedTagName: selectedTagName", "refresh must pass the validated selected tag to search"),
+ ("private var quickSearchTagFilters: [QuickSearchTagFilterOption]", "ContentView must derive real tag filters"),
+ (".flatMap(\\.tagNames)", "available filters must come from indexed app tags"),
+ (".filter(isQuickSearchFilterableTag)", "available filters must exclude protected/system-only names"),
+ ("draggedTagNames", "available filters must follow user tag order"),
+ ("definition?.customColor", "available filters must preserve custom tag colors"),
+ ("guard !name.isEmpty, tagDefinitions[name] != nil else { return false }", "filter chips must only include real tag definitions"),
+ ("AppContainerID.isReservedGroupName(name, defaultGroupName: defaultGroupName)", "filter chips must use the shared reserved group-name guard"),
+ ("private func validateQuickSearchSelectedTagName(against filters: [QuickSearchTagFilterOption]) -> String?", "selected tag validation must return the active filter or clear it"),
+ ("quickSearchFocusToken &+= 1", "tag clicks must return focus to the search input"),
+]:
+ require_contains(refresh_block, needle, message)
+
+container_id_block = block(
+ data_layer,
+ r"enum AppContainerID.*?enum AppDisplayNameResolver",
+ "AppContainerID block is missing",
+)
+for needle, message in [
+ ("static func isReservedGroupName(_ name: String, defaultGroupName: String = \"Other\") -> Bool", "AppContainerID must expose a shared reserved group-name guard"),
+ ("\"Other\"", "reserved group-name guard must include the neutral uncategorized key"),
+ ("defaultGroupName", "reserved group-name guard must include the configured uncategorized key"),
+ ("tr(\"group.uncategorized\")", "reserved group-name guard must include localized uncategorized names"),
+ ("\"Mac自带\"", "reserved group-name guard must include the Apple built-in container"),
+ ("tr(\"group.appleBuiltIn\")", "reserved group-name guard must include localized Apple built-in names"),
+ ("TagDatabase.uncommonTagKey", "reserved group-name guard must include the system uncommon key"),
+ ("tr(\"group.uncommon\")", "reserved group-name guard must include localized uncommon names"),
+ ("trimmingCharacters(in: .whitespacesAndNewlines)", "reserved group-name guard must trim names before comparison"),
+]:
+ require_contains(container_id_block, needle, message)
+
+group_block = block(
+ data_layer,
+ r"static func group\(\n apps: \[AppInfo\],.*?static func orderedApps",
+ "AppIndexer.group block is missing",
+)
+require_contains(
+ group_block,
+ "!AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName)",
+ "AppIndexer.group must skip protected container/system tag names before building display groups",
+)
+require(
+ group_block.count("!AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName)") >= 2,
+ "AppIndexer.group must skip protected names in both app tag grouping and empty tag-definition groups",
+)
+
+reuse_block = block(
+ tag_navigation,
+ r"private func reuseButtonsInCurrentOrder\(\).*?private func updateButtonRuntimeState",
+ "TagNavigationView button reuse block is missing",
+)
+require(
+ "Dictionary(uniqueKeysWithValues: buttons.map { ($0.itemName, $0) })" not in reuse_block,
+ "TagNavigationView must not fatal when duplicate display names reach button reuse",
+)
+require_contains(
+ tag_navigation,
+ "oldNames.count != newNames.count",
+ "TagNavigationView must rebuild when duplicate-name counts change",
+)
+for needle, message in [
+ ("var existing: [String: [TagNavigationButton]] = [:]", "TagNavigationView must build a duplicate-tolerant reuse map"),
+ ("existing[button.itemName, default: []].append(button)", "TagNavigationView must keep every reusable button for duplicate names"),
+ ("let button = candidates.removeFirst()", "TagNavigationView must consume one reusable button per item"),
+]:
+ require_contains(reuse_block, needle, message)
+
+for pattern, message in [
+ (r"private func openQuickSearch\(source: String\).*?private var canOpenQuickSearch", "openQuickSearch block is missing"),
+ (r"private func closeQuickSearch\(.*?private func shouldIgnoreEarlyQuickSearchDismiss", "closeQuickSearch block is missing"),
+ (r"private func handleContentAppear\(\).*?private func handleOverlayDidShow", "initial Quick Search block is missing"),
+]:
+ require_contains(block(content_view, pattern, message), "quickSearchSelectedTagName = nil", f"{message}; selected tag must reset on lifecycle transition")
+
+supported_match = re.search(r"static let supported: \[\(code: String, name: String\)\] = \[(.*?)\n \]", l10n_swift, re.S)
+require(supported_match is not None, "L10n.supported block is missing")
+supported_codes = re.findall(r'\("([^"]+)"\s*,', supported_match.group(1))
+required_l10n_keys = [
+ "quickSearch.tagFilter.all",
+ "quickSearch.tagFilter.noResults",
+ "quickSearch.tagFilter.allAccessibility",
+ "quickSearch.tagFilter.tagAccessibility",
+]
+for code in supported_codes:
+ file = localization_dir / f"{code}.json"
+ require(file.exists(), f"missing localization file for {code}")
+ try:
+ data = json.loads(file.read_text(encoding="utf-8"))
+ except json.JSONDecodeError as exc:
+ fail(f"invalid JSON in {file.name}: {exc}")
+ for key in required_l10n_keys:
+ value = data.get(key)
+ require(isinstance(value, str) and value.strip(), f"{file.name} missing non-empty {key}")
+ require("%tagName%" in data["quickSearch.tagFilter.noResults"], f"{file.name} no-results copy must include %tagName%")
+ require("%tagName%" in data["quickSearch.tagFilter.tagAccessibility"], f"{file.name} tag accessibility copy must include %tagName%")
+
+print("PASS quick search tag filter QA")
+PY
diff --git a/CODEGRAPH.md b/CODEGRAPH.md
index 2c01bc6..fa29b4f 100644
--- a/CODEGRAPH.md
+++ b/CODEGRAPH.md
@@ -33,6 +33,7 @@
- 编辑模式使用 runtime theme override:任何主题下进入编辑都临时显示默认浅色毛玻璃。
- 使用最近一次完整 `AppLibrarySnapshot` 先渲染 AppGrid,再后台刷新,避免启动/重开时立刻显示转圈。
- 使用技巧关闭提醒由 `ContentView` 统一弹 modal 并写入 `hideUsageTips` / `skipUsageTipsCloseReminder`。
+ - Quick Search 标签筛选状态只存在于当前面板生命周期;`ContentView` 从当前索引和 `tagDefinitions` 生成可选真实标签,并通过 `AppContainerID.isReservedGroupName` 排除未分类 / Mac 自带 / 不常用等保护容器名,打开、关闭、初始 Quick Search、标签删除/重命名或刷新后失效时都清空筛选。
- Quick Search 点击 TagLauncher 自身结果时只关闭当前浮层,不走外部自启动,避免 AppGrid 被重新拉起。
- `C1.source/Apptag/PreferencesView.swift`
- `PreferencesView`:设置窗口、语言、通用、主题、快捷键、标签、数据、Pro、关于等设置页。
@@ -83,7 +84,7 @@
- `C1.source/Apptag/DataLayer.swift`
- `AppInfo`:应用模型。
- `AppDisplayNameResolver`:多语言应用显示名解析。
- - `AppIndexer`:扫描 `/Applications` 等应用来源,处理 bundle / wrapper / localized display name。
+ - `AppIndexer`:扫描 `/Applications` 等应用来源,处理 bundle / wrapper / localized display name;主分组生成时通过 `AppContainerID.isReservedGroupName` 跳过被错误写成普通标签的未分类 / Mac 自带 / 不常用等保护容器名,避免本地化显示名重复导致标签导航 fatal。
- `TagGroup` / `TagColor` / `TagCustomColor`:标签分组、基础色和可选 sRGB 自定义色;`TagDef.customColor` 为可选字段,旧 JSON 缺字段时保持兼容。
- `TagDatabase`:标签、备注、隐藏状态、分类方案、导入导出、备份和持久化。
- `TagEditor`:把数据库中的标签/备注/隐藏状态标注回扫描到的 App 列表;`setCustomColor` 是写入自定义标签颜色的唯一数据层入口,必须先过 Pro gate,基础色切换会清除自定义色。
@@ -104,9 +105,10 @@
- `C1.source/Apptag/QuickSearch.swift`
- `LauncherHotkey` / `LauncherHotkeyKind`:主面板和 Quick Search 快捷键定义。
- `LauncherHotkeyRegistrationStore`:快捷键注册状态持久化。
- - `QuickSearchDocument` / `QuickSearchEngine`:搜索索引与排序。
+ - `QuickSearchDocument` / `QuickSearchEngine`:搜索索引与排序;`selectedTagName` 是运行时单选筛选条件,先按真实标签精确收窄文档,再与文本 token 做交集。无文本但已选标签时列出该标签下 App;无标签筛选的空搜索继续保持最近/常用候选。
+ - `QuickSearchTagFilterOption` / `QuickSearchTagFilterBar` / `QuickSearchTagFilterChip`:搜索框下方、结果列表上方的单行横向标签筛选条;只在存在真实可用标签时显示,包含本地化 `全部` 和真实标签胶囊,不改结果长条列表形态。
- `QuickSearchPanelPresentationView` / `QuickSearchOverlayView` / `QuickSearchResultListHostView`:Quick Search 浮层 UI 与结果列表;Quick Search 面板必须是可成为 key 的激活面板,不能使用 `.nonactivatingPanel`,否则 Return 可能落到前一个前台 App。
- - `QuickSearchPanel.sendEvent` 对左键点击做 AppKit 层兜底:先通过 `hitTest` 解析 `QuickSearchResultRowView`,失败时按窗口坐标递归扫描结果行几何命中,并走同一启动路径,避免 SwiftUI / NSHostingView / NSScrollView 层级吞掉候选行点击。
+ - `QuickSearchPanel.sendEvent` 对左键点击做 AppKit 层兜底:先解析 `QuickSearchTagFilterClickTargetView` 并触发标签筛选 / 清除;再通过 `hitTest` 和窗口坐标递归几何命中解析 `QuickSearchResultRowView` 并走同一启动路径,避免 SwiftUI / NSHostingView / NSScrollView 层级吞掉标签 chip 或候选行点击。
- `QuickSearchResultRowView`:结果行内部文字、图标、标签区域统一命中到整行,保证点击任意可见区域都能启动并关闭浮层。
- `QuickSearchTextField` 同时保留 field editor command delegate 和 `NSControl.target/action` submit fallback,保证 Return / Enter 都能进入 `.submit`。
- `C1.source/Apptag/LauncherHotkeySettings.swift`
@@ -184,6 +186,8 @@
- Quick Search 应用显示名回归。
- `C1.source/Scripts/quick_search_system_app_qa.sh`
- Quick Search 系统应用回归。
+- `C1.source/Scripts/quick_search_tag_filter_qa.sh`
+ - Quick Search 标签筛选静态门禁;检查运行时单选标签筛选、`全部` 清除、tag chip 原生鼠标命中兜底、空搜索 + 标签列出该标签 App、标签 + 关键词交集、保护容器名从 Quick Search / 主分组排除且导航复用不因重复显示名 fatal、标签条横向胶囊、结果仍走长条候选项和 29 语种本地化 key 完整。
- `C1.source/Scripts/quick_search_launch_contract_qa.sh`
- Quick Search 启动路径静态门禁;检查 in-app mouse monitor 不吞结果行点击、面板是激活 key panel、panel-level `sendEvent` 点击兜底存在、Return submit fallback 存在、点击 / submit 都接到 `NSWorkspace.openApplication` 启动路径。
- `C1.source/Scripts/windowserver_event_safety_qa.sh`
@@ -242,9 +246,10 @@
- 旧用户 `useDarkAppGrid=true` 必须迁移到 `deepBlue` 主题。
- AppGrid 启动或重开不应因为新 `ContentView` 初始 `allApps.isEmpty` 立刻显示转圈;应先复用最近一次完整快照,并延迟显示 loading。
- Quick Search 显示标题应优先使用解析后的 `AppInfo.displayName`,内部 bundle 名只作为搜索字段。
+- Quick Search 标签筛选条只做 V1 单选运行时过滤:显示 `全部` + 当前索引中真实存在的标签;不得显示未分类 / Mac 自带 / 不常用等保护容器;点击已选标签或 `全部` 清除,并由原生鼠标命中兜底保持面板稳定打开;选中标签后输入关键词必须取交集;不得改成方块矩阵、不得持久化筛选状态。
- Quick Search 结果行内部文字 / 图标区域不得吞掉点击;点中行内任意可见区域都应按结果启动路径处理。
- Quick Search 打开后必须可靠接收键盘;面板不得是 non-activating panel,展示时必须激活 App 并成为 key window,Return / Enter 必须通过 command delegate 或 target/action fallback 进入 submit。
-- Quick Search 打开期间,本地 mouse monitor 不得吞掉 TagLauncher overlayWindow 内部事件;外部点击可关闭 Quick Search,结果行点击必须通过 `QuickSearchResultRowView.mouseDown` 或 `QuickSearchPanel.sendEvent` 兜底进入同一启动路径;panel 级兜底不得只依赖 `hitTest`,必须保留窗口坐标递归几何命中结果行的路径。
+- Quick Search 打开期间,本地 mouse monitor 不得吞掉 TagLauncher overlayWindow 内部事件;外部点击可关闭 Quick Search,标签 chip 和结果行点击必须通过对应 native view mouseDown 或 `QuickSearchPanel.sendEvent` 兜底进入同一筛选 / 启动路径;panel 级兜底不得只依赖 `hitTest`,必须保留窗口坐标递归几何命中路径。
- Quick Search-only 点击 TagLauncher 自身结果必须关闭浮层,不得重新打开 AppGrid。
- Apple 默认应用备注必须按当前语言显示,不允许 A 语言出现 B 语言备注。
- SmartStart/default system tag 初始化标签必须按当前语言显示;升级用户启动后应重刷带 `systemCategoryID` 的系统标签,不能长期保留旧英文初始化标签。
--
Gitblit v1.9.3