Browse Source

feat: add tags and enhance searching (#2015)

* feat: add tags and enhance searching

* fix: highlight selected tags and allow deselecting

* fix: fix dropdown being truncated by the container
Gerald 1 year ago
parent
commit
5a44680d52
42 changed files with 1038 additions and 174 deletions
  1. 12 0
      src/_locales/ar/messages.yml
  2. 12 0
      src/_locales/cs/messages.yml
  3. 12 0
      src/_locales/de/messages.yml
  4. 12 0
      src/_locales/el/messages.yml
  5. 21 0
      src/_locales/en/messages.yml
  6. 12 0
      src/_locales/es/messages.yml
  7. 12 0
      src/_locales/es_419/messages.yml
  8. 12 0
      src/_locales/fi/messages.yml
  9. 12 0
      src/_locales/fr/messages.yml
  10. 12 0
      src/_locales/hr/messages.yml
  11. 12 0
      src/_locales/hu/messages.yml
  12. 12 0
      src/_locales/id/messages.yml
  13. 12 0
      src/_locales/it/messages.yml
  14. 12 0
      src/_locales/ja/messages.yml
  15. 12 0
      src/_locales/ko/messages.yml
  16. 12 0
      src/_locales/lv/messages.yml
  17. 12 0
      src/_locales/nl/messages.yml
  18. 12 0
      src/_locales/pl/messages.yml
  19. 12 0
      src/_locales/pt_BR/messages.yml
  20. 12 0
      src/_locales/pt_PT/messages.yml
  21. 12 0
      src/_locales/ro/messages.yml
  22. 12 0
      src/_locales/ru/messages.yml
  23. 12 0
      src/_locales/sk/messages.yml
  24. 12 0
      src/_locales/sr/messages.yml
  25. 12 0
      src/_locales/th/messages.yml
  26. 12 0
      src/_locales/tr/messages.yml
  27. 12 0
      src/_locales/uk/messages.yml
  28. 12 0
      src/_locales/vi/messages.yml
  29. 12 0
      src/_locales/zh_CN/messages.yml
  30. 12 0
      src/_locales/zh_TW/messages.yml
  31. 2 1
      src/background/utils/db.js
  32. 3 1
      src/common/ui/style/style.css
  33. 4 0
      src/common/util.js
  34. 2 1
      src/options/index.js
  35. 2 0
      src/options/utils/index.js
  36. 115 0
      src/options/utils/search.js
  37. 1 0
      src/options/views/edit/index.vue
  38. 8 0
      src/options/views/edit/settings.vue
  39. 41 7
      src/options/views/script-item.vue
  40. 126 164
      src/options/views/tab-installed.vue
  41. 352 0
      test/options/__snapshots__/search.test.js.snap
  42. 13 0
      test/options/search.test.js

+ 12 - 0
src/_locales/ar/messages.yml

@@ -206,12 +206,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -513,6 +516,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: ''
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -852,6 +858,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/cs/messages.yml

@@ -210,12 +210,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -517,6 +520,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: ''
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -856,6 +862,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/de/messages.yml

@@ -242,12 +242,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Alles
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Code
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Name
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: Größe
@@ -560,6 +563,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Benutzername: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Theme: '
@@ -937,6 +943,12 @@ titleSearchHint:
   message: |-
     * <Enter>-Taste fügt den Text zum Autovervollständigungs-Verlauf hinzu
     * RegExp-Syntax wird unterstützt: /re/ und /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Externe Bearbeitungen tracken

+ 12 - 0
src/_locales/el/messages.yml

@@ -222,12 +222,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Όλα
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Κώδικας
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Όνομ
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -535,6 +538,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Όνομα χρήστη:'
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -878,6 +884,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 21 - 0
src/_locales/en/messages.yml

@@ -234,12 +234,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: All
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Code
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Name
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: size
@@ -545,6 +548,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Username: '
+labelTags:
+  description: Label for custom tags.
+  message: 'Tags (space separated):'
 labelTheme:
   description: Label for the visual theme option.
   message: 'Theme: '
@@ -914,6 +920,21 @@ titleSearchHint:
   message: |-
     * <Enter> key adds the text to autocomplete history
     * RegExp syntax is supported: /re/ and /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: |-
+    * <kbd>Enter</kbd> key adds the text to autocomplete history
+    * All conditions are case-insensitive
+    * Space separated conditions can be combined
+    * Search by metadata: <code>"Awesome Script" "Description"</code>
+    * Search by tags: <code>#tag1 #tag2</code>
+    * Search by script name: <code>name:"awesome name"</code>
+    * Search by script code: <code>code:"awesome code"</code>
+    * Negative search: <code>!#tag2 !name:"unwanted"</code>
+    * Regular expressions: <code>/\w+?/</code>, <code>/\w+?/gi</code>, <code>name:/\w+?/</code>, <code>name+re:"\w+? with space"</code>
 trackEdits:
   description: Button in a script installation dialog.
   message: Track external edits

+ 12 - 0
src/_locales/es/messages.yml

@@ -231,12 +231,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Todo
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Código
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nombre
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: tamaño
@@ -544,6 +547,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nombre de usuario:'
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Tema:'
@@ -912,6 +918,12 @@ titleSearchHint:
   message: |-
     * La clave <Enter> añade el texto al historial de autocompletar
     * Soporte para sintaxis RegExp: /re/ y /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/es_419/messages.yml

@@ -236,12 +236,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Todo
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Código
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nombre
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: tamaño
@@ -551,6 +554,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nombre de usuario: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Tema:'
@@ -917,6 +923,12 @@ titleSearchHint:
   message: |-
     * La clave <Enter> añade texto para auto-completar el historial
     * Soporte para sintaxis RegExp: /re/ y /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/fi/messages.yml

@@ -210,12 +210,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -517,6 +520,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: ''
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -858,6 +864,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/fr/messages.yml

@@ -229,12 +229,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Tous
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Code
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nom
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: taille
@@ -553,6 +556,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: "Nom d’utilisateur\_: "
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Thème :'
@@ -915,6 +921,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: "* La touche <Enter> ajoute le texte à l'historique d'autocomplétion\n* La syntaxe pour les expressions régulières est supportée\_: /re/ et /re/flags"
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/hr/messages.yml

@@ -210,12 +210,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -517,6 +520,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: ''
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -856,6 +862,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/hu/messages.yml

@@ -246,12 +246,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Mindenhol
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Kódban
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Névben
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: méret alapján
@@ -561,6 +564,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Felhasználónév: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Téma: '
@@ -943,6 +949,12 @@ titleSearchHint:
     történethez
 
     * RegExp szintaxis támogatott: /re/ és /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Külső módosítások nyomon követése

+ 12 - 0
src/_locales/id/messages.yml

@@ -231,12 +231,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Semua
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Kode
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nama
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ukuran
@@ -546,6 +549,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nama pengguna: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Tema:'
@@ -918,6 +924,12 @@ titleSearchHint:
   message: |-
     * <Enter> menambahkan teks ke riwayat autocomplete
     * Sintaksis RegExp didukung: /re/ dan /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Lacak penyuntingan eksternal

+ 12 - 0
src/_locales/it/messages.yml

@@ -230,12 +230,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Tutti
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Codice
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nome
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: dimensione
@@ -547,6 +550,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nome utente: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Tema:'
@@ -911,6 +917,12 @@ titleSearchHint:
   message: |-
     * il tasto <Enter> aggiunge il testo alla cronologia di autocompletamento
     * La sintassi RegExp è supportata: /re/ e /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/ja/messages.yml

@@ -225,12 +225,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: すべて
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: コード
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: 名前
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: サイズ
@@ -534,6 +537,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'ユーザ名: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'テーマ:'
@@ -883,6 +889,12 @@ titleSearchHint:
   message: |-
     * <Enter> キーで自動補完の履歴に文字列を追加
     * 使用できる正規表現の構文: /条件/ や /条件/フラグ
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: 外部での編集を追跡

+ 12 - 0
src/_locales/ko/messages.yml

@@ -223,12 +223,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: 전체
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: 코드
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: 이름
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: 크기 순
@@ -532,6 +535,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: '사용자: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: '테마: '
@@ -882,6 +888,12 @@ titleSearchHint:
   message: |-
     * <Enter>키로 자동완성 내역에 추가할 수 있습니다.
     * 정규표현식을 지원합니다: /re/ 또는 /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: 외부 편집 추적

+ 12 - 0
src/_locales/lv/messages.yml

@@ -206,12 +206,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -513,6 +516,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: ''
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -852,6 +858,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/nl/messages.yml

@@ -241,12 +241,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Alle
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Code
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Naam
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: omvang
@@ -552,6 +555,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Gebruikersnaam: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Thema:'
@@ -923,6 +929,12 @@ titleSearchHint:
   message: |-
     * Druk op <Enter> om de tekst toe te voegen aan de auto-aanvulgeschiedenis
     * Ondersteunt reguliere uitdrukkingen: /re/ en /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Externe bewerkingen bijhouden

+ 12 - 0
src/_locales/pl/messages.yml

@@ -235,12 +235,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Wszędzie
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Kod
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nazwa
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: rozmiaru
@@ -550,6 +553,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nazwa użytkownika:'
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Motyw:'
@@ -918,6 +924,12 @@ titleSearchHint:
   message: |-
     * Klawisz <Enter> dodaje tekst do historii auto-uzupełnienia
     * Obsługiwana jest składnia RegExp: /re/ i /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/pt_BR/messages.yml

@@ -240,12 +240,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Tudo
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Código
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nome
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: Tamanho
@@ -555,6 +558,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nome do usuário: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Tema: '
@@ -917,6 +923,12 @@ titleSearchHint:
   message: |-
     * <Enter> chave adiciona ao histórico de texto do preenchimento automático
     * A sintaxe RegExp suportada são: /re/ e /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/pt_PT/messages.yml

@@ -218,12 +218,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Tudo
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Código
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nome
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -527,6 +530,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nome de utilizador: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Tema: '
@@ -868,6 +874,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/ro/messages.yml

@@ -237,12 +237,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Toate
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Cod
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Nume
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: dimensiune
@@ -555,6 +558,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Nume de utilizator: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Temă:'
@@ -928,6 +934,12 @@ titleSearchHint:
   message: |-
     * Tasta <Enter> adaugă textul în istoricul completării automate
     * Sintaxa RegExp este suportată: /re/ și /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Urmărește editările externe

+ 12 - 0
src/_locales/ru/messages.yml

@@ -241,12 +241,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Все
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Код
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Имя
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: размер
@@ -552,6 +555,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Логин:'
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Тема:'
@@ -923,6 +929,12 @@ titleSearchHint:
   message: |-
     * Клавиша <Enter> добавляет текст в историю автозаполнения.
     * Поддерживается RegExp: /re/ и /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Отслеживать внешние правки

+ 12 - 0
src/_locales/sk/messages.yml

@@ -242,12 +242,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Všetko
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Kód
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Názov
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: veľkosť
@@ -555,6 +558,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Používateľské meno: '
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Motív:'
@@ -925,6 +931,12 @@ titleSearchHint:
   message: |-
     * kláves <Enter> pridá text do histórie automatického dopĺňania
     * je podporovaná syntax RegExp: /re/ a /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Sledovať externé úpravy

+ 12 - 0
src/_locales/sr/messages.yml

@@ -210,12 +210,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -519,6 +522,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: ''
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -858,6 +864,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/th/messages.yml

@@ -206,12 +206,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: ''
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: ''
@@ -513,6 +516,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: ''
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: ''
@@ -852,6 +858,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: ''
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/tr/messages.yml

@@ -241,12 +241,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Tümü
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Kod
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: İsim
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: boyut
@@ -554,6 +557,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Kullanıcı adı:'
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Tema:'
@@ -923,6 +929,12 @@ titleSearchHint:
   message: |-
     * <Enter> tuşu metni otomatik tamamlama geçmişine ekler
     * RegExp sözdizimi desteklenmektedir: /re/ ve /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: Harici düzenlemeleri takip edin

+ 12 - 0
src/_locales/uk/messages.yml

@@ -231,12 +231,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Всі
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Код
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Ім'я
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: розміру
@@ -540,6 +543,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Ім''я користувача:'
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Тема:'
@@ -901,6 +907,12 @@ titleSearchHint:
   message: |-
     * <Enter> Клавіша додає текст до історії автозаповнення.
     * Підтримується RegExp: /re/ та /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/vi/messages.yml

@@ -231,12 +231,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: Hiện tất cả
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: Cách viết mã
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: Tên
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: kích thước
@@ -542,6 +545,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 'Tài khoản:'
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: 'Giao diện: '
@@ -903,6 +909,12 @@ titleBadgeColorBlocked:
 titleSearchHint:
   description: Hover title for search icon in dashboard.
   message: Nhập chính xác hoặc gần giống nội dung cần tìm
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: ''

+ 12 - 0
src/_locales/zh_CN/messages.yml

@@ -222,12 +222,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: 全部
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: 代码
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: 名字
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: 大小
@@ -531,6 +534,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 用户名:
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: '主题:'
@@ -878,6 +884,12 @@ titleSearchHint:
   message: |-
     * <Enter> 键把文本加入自动补全历史
     * 支持正则表达式:/re/ 和 /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: 跟踪外部编辑

+ 12 - 0
src/_locales/zh_TW/messages.yml

@@ -222,12 +222,15 @@ filterLastUpdateOrder:
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: 全部
+  touched: false
 filterScopeCode:
   description: Option in dashboard's search scope filter.
   message: 程式碼
+  touched: false
 filterScopeName:
   description: Option in dashboard's search scope filter.
   message: 名稱
+  touched: false
 filterSize:
   description: Label for option to sort scripts by size.
   message: 大小
@@ -531,6 +534,9 @@ labelSyncService:
 labelSyncUsername:
   description: Label for input to hold username.
   message: 使用者名稱:
+labelTags:
+  description: Label for custom tags.
+  message: ''
 labelTheme:
   description: Label for the visual theme option.
   message: '主題:'
@@ -880,6 +886,12 @@ titleSearchHint:
   message: |-
     * 按 <Enter> 鍵會將文字加到自動完成記錄中
     * 支援正規表示式語法:/re/ 和 /re/flags
+  touched: false
+titleSearchHintV2:
+  description: >-
+    Hover title for search icon in dashboard. Do not translate content between
+    <code>...</code>.
+  message: ''
 trackEdits:
   description: Button in a script installation dialog.
   message: 追蹤外部編輯

+ 2 - 1
src/background/utils/db.js

@@ -1,7 +1,7 @@
 import {
   compareVersion, dataUri2text, i18n, getScriptHome, isDataUri,
   getFullUrl, getScriptName, getScriptUpdateUrl, isRemote, sendCmd, trueJoin,
-  getScriptPrettyUrl, getScriptRunAt, makePause, isHttpOrHttps,
+  getScriptPrettyUrl, getScriptRunAt, makePause, isHttpOrHttps, normalizeTag,
 } from '@/common';
 import { INFERRED, TIMEOUT_24HOURS, TIMEOUT_WEEK } from '@/common/consts';
 import { deepSize, forEachEntry, forEachKey, forEachValue } from '@/common/object';
@@ -628,6 +628,7 @@ export async function parseScript(src) {
     script.custom.homepageURL = src.from;
   }
   if (isRemote(src.url)) script.custom.lastInstallURL = src.url;
+  script.custom.tags = script.custom.tags?.split(/\s+/).map(normalizeTag).filter(Boolean).join(' ').toLowerCase();
   if (!src.update) storage.mod.remove(getScriptUpdateUrl(script, { all: true }) || []);
   buildPathMap(script, src.url);
   const depsPromise = fetchResources(script, src);

+ 3 - 1
src/common/ui/style/style.css

@@ -143,6 +143,7 @@ input[type=password],
 textarea {
   padding: 0 .2em;
 }
+kbd,
 code {
   padding: 0 .2em;
   background: hsla(45, 75%, 75%, .25);
@@ -403,7 +404,8 @@ body .vl-tooltip {
   box-shadow: 0 0 40px #000;
 }
 
-.has-error {
+.has-error,
+input[type].has-error {
   // reminder: make sure all colors are readable in light/dark schemes
   border-color: #8008;
   background: #f002;

+ 4 - 0
src/common/util.js

@@ -338,3 +338,7 @@ export function dumpScriptValue(value, jsonDump = JSON.stringify) {
     return `${simple || 'o'}${simple ? value : jsonDump(value)}`;
   }
 }
+
+export function normalizeTag(tag) {
+  return tag.replace(/[^\w.-]/g, '');
+}

+ 2 - 1
src/options/index.js

@@ -38,7 +38,7 @@ function initScript(script, sizes) {
     getLocaleString(meta, 'description'),
     script.custom.name,
     script.custom.description,
-  ]::trueJoin('\n');
+  ]::trueJoin('\n').toLowerCase();
   const name = script.custom.name || localeName;
   const lowerName = name.toLowerCase();
   let total = 0;
@@ -51,6 +51,7 @@ function initScript(script, sizes) {
     search,
     name,
     lowerName,
+    tags: script.custom.tags || '',
     size: formatByteLength(total, true).replace(' ', ''),
     sizes: str.slice(0, -1).replace(/\x20/g, '\xA0').replace(/[^B]$/gm, '$&B'),
     sizeNum: total,

+ 2 - 0
src/options/utils/index.js

@@ -2,6 +2,8 @@ import { reactive } from 'vue';
 import { sendCmdDirectly } from '@/common';
 import { route } from '@/common/router';
 
+export * from './search';
+
 export const store = reactive({
   route,
   /** Speedup and deflicker initial page load by not rendering an invisible script list */

+ 115 - 0
src/options/utils/search.js

@@ -0,0 +1,115 @@
+import { normalizeTag } from '@/common';
+
+export function parseSearch(search) {
+  /**
+   * @type Array<{
+   *   prefix: string;
+   *   raw: string;
+   *   negative: boolean;
+   * }>
+   */
+  const tokens = [];
+  search = search.toLowerCase();
+  let offset = 0;
+  while (search[offset] === ' ') offset += 1;
+  while (offset < search.length) {
+    const negative = search[offset] === '!';
+    if (negative) offset += 1;
+    const prefix =
+      search.slice(offset).match(/^(#|re:|(?:name|code)(?:\+re)?:)/)?.[1] || '';
+    if (prefix) offset += prefix.length;
+    const startOffset = offset;
+    const quote =
+      (!prefix || prefix.endsWith(':')) &&
+      search.slice(offset).match(/^['"]/)?.[0];
+    if (quote) offset += 1;
+    let pattern = '';
+    const endChar = quote || ' ';
+    while (offset < search.length) {
+      const ch = search[offset];
+      if (quote && ch === quote && search[offset + 1] === quote) {
+        // escape quotes by double it
+        pattern += quote;
+        offset += 2;
+      } else if (ch !== endChar) {
+        pattern += ch;
+        offset += 1;
+      } else {
+        break;
+      }
+    }
+    if (quote) {
+      if (offset < search.length) offset += 1;
+      else throw new Error('Unmatched quotes');
+    }
+    tokens.push({
+      prefix,
+      raw: search.slice(startOffset, offset),
+      parsed: pattern,
+      negative,
+    });
+    while (search[offset] === ' ') offset += 1;
+  }
+  return tokens;
+}
+
+export function createSearchRules(search) {
+  const tokens = parseSearch(search);
+  /**
+   * @type Array<{
+   *   scope: string;
+   *   pattern: string | RegExp;
+   *   negative: boolean;
+   * }>
+   */
+  const rules = [];
+  const includeTags = [];
+  const excludeTags = [];
+  for (const token of tokens) {
+    if (token.prefix === '#') {
+      (token.negative ? excludeTags : includeTags).push(token.parsed);
+    } else {
+      // Strip ':'
+      let scope = token.prefix.slice(0, -1);
+      let pattern = token.parsed;
+      if (/(?:^|\+)re$/.test(scope)) {
+        scope = scope.slice(0, -3);
+        pattern = new RegExp(pattern, 'i');
+      } else {
+        const reMatches = pattern.match(/^\/(.*?)\/(\w*)$/);
+        if (reMatches) pattern = new RegExp(reMatches[1], reMatches[2] || 'i');
+      }
+      rules.push({
+        scope,
+        pattern,
+        negative: token.negative,
+      });
+    }
+  }
+  [includeTags, excludeTags].forEach((tags, negative) => {
+    const sanitizedTags = tags
+      .map((tag) => normalizeTag(tag).replace(/\./g, '\\.'))
+      .filter(Boolean)
+      .join('|');
+    if (sanitizedTags) {
+      rules.unshift({
+        scope: 'tags',
+        pattern: new RegExp(`(?:^|\\s)(${sanitizedTags})(\\s|$)`),
+        negative: !!negative,
+      });
+    }
+  });
+  return {
+    tokens,
+    rules,
+  };
+}
+
+export function testSearchRule(rule, data) {
+  const { pattern, negative } = rule;
+  const result =
+    typeof pattern.test === 'function'
+      ? pattern.test(data)
+      : data.includes(pattern);
+  return negative ^ result;
+}

+ 1 - 0
src/options/views/edit/index.vue

@@ -180,6 +180,7 @@ const CUSTOM_PROPS = {
   homepageURL: '',
   updateURL: '',
   downloadURL: '',
+  tags: '',
   origInclude: true,
   origExclude: true,
   origMatch: true,

+ 8 - 0
src/options/views/edit/settings.vue

@@ -8,6 +8,14 @@
       </label>
     </div>
     <VMSettingsUpdate v-bind="{script}"/>
+    <table>
+      <tr>
+        <td v-text="i18n('labelTags')"></td>
+        <td>
+          <input type="text" v-model="custom.tags" :disabled="readOnly">
+        </td>
+      </tr>
+    </table>
     <h4 v-text="i18n('editLabelMeta')"></h4>
     <!-- Using tables to auto-adjust width, which differs substantially between languages -->
     <table>

+ 41 - 7
src/options/views/script-item.vue

@@ -18,11 +18,19 @@
     </div>
     <!-- We disable native dragging on name to avoid confusion with exec re-ordering.
     Users who want to open a new tab via dragging the link can drag the icon. -->
-    <div class="script-name ellipsis">
-      <!-- Using a nested element to center the text vertically AND apply text-ellipsis -->
+    <div class="script-info-1">
       <a v-text="script.$cache.name" v-bind="viewTable && { draggable: false, href: url, tabIndex }"
          :data-order="script.config.removed ? null : script.props.position"
-         class="ellipsis" />
+         class="script-name ellipsis" />
+      <div class="script-tags" v-if="canRender">
+        <a v-for="(item, i) in tags.slice(0, 2)" :key="i" v-text="`#${item}`" @click.prevent="onTagClick(item)" :class="{ active: activeTags?.includes(item) }"></a>
+        <Dropdown v-if="tags.length > 2">
+          <a>...</a>
+          <template #content>
+            <a v-for="(item, i) in tags.slice(2)" :key="i" class="dropdown-menu-item" v-text="`#${item}`" @click.prevent="onTagClick(item)" :class="{ active: activeTags?.includes(item) }"></a>
+          </template>
+        </Dropdown>
+      </div>
     </div>
     <div class="script-info flex ml-1c">
       <template v-if="canRender">
@@ -122,6 +130,7 @@
 </template>
 
 <script>
+import Dropdown from 'vueleton/lib/dropdown';
 import Tooltip from 'vueleton/lib/tooltip';
 import {
   getLocaleString, getScriptHome, formatTime,
@@ -142,8 +151,10 @@ export default {
     'focused',
     'hotkeys',
     'showHotkeys',
+    'activeTags',
   ],
   components: {
+    Dropdown,
     Icon,
     Tooltip,
   },
@@ -165,6 +176,9 @@ export default {
         name: matches ? matches[1] : text,
       };
     },
+    tags() {
+      return this.script.custom.tags?.split(' ').filter(Boolean) || [];
+    },
     labelEnable() {
       return this.script.config.enabled ? this.i18n('buttonDisable') : this.i18n('buttonEnable');
     },
@@ -252,6 +266,9 @@ export default {
     onBlur() {
       keyboardService.setContext('scriptFocus', false);
     },
+    onTagClick(item) {
+      this.$emit('clickTag', item);
+    },
     toggleTip(e) {
       toggleTip(e.target);
     },
@@ -347,21 +364,38 @@ $removedItemHeight: calc(
       color: #f00;
     }
   }
-  &-name {
+  &-info-1 {
+    display: flex;
+    gap: 8px;
     min-width: 100px;
   }
-  &-name a {
+  &-name {
     font-weight: 500;
     font-size: $nameFontSize;
     color: inherit;
     padding-left: .5rem;
-    .removed & {
+    &.removed {
       margin-right: 8px;
     }
-    .disabled & {
+    &.disabled {
       color: var(--fill-8);
     }
   }
+  &-tags {
+    white-space: nowrap;
+    a {
+      margin-right: 4px;
+      cursor: pointer;
+      color: var(--fill-4);
+      &:hover {
+        color: var(--fill-6);
+      }
+    }
+    .active {
+      color: var(--fill-6);
+      font-weight: bold;
+    }
+  }
   &-buttons {
     display: flex;
     align-items: center;

+ 126 - 164
src/options/views/tab-installed.vue

@@ -2,7 +2,7 @@
   <div class="tab-installed" ref="scroller">
     <div v-if="store.canRenderScripts">
       <header class="flex">
-        <div class="flex flex-auto" v-if="!showRecycle">
+        <div class="flex" v-if="!showRecycle">
           <Dropdown
             :closeAfterClick="true"
             :class="{active: state.menuNewActive}"
@@ -35,7 +35,8 @@
             </a>
           </Tooltip>
         </div>
-        <div class="flex-auto" v-else v-text="i18n('headerRecycleBin')" />
+        <div v-else v-text="i18n('headerRecycleBin')" />
+        <div class="flex-auto"></div>
         <LocaleGroup i18n-key="labelFilterSort">
           <select :value="filters.sort" @change="handleOrderChange" class="h-100">
             <option
@@ -67,31 +68,29 @@
           </template>
         </Dropdown>
         <!-- form and id are required for the built-in autocomplete using entered values -->
-        <form class="filter-search hidden-xs flex" @submit.prevent>
-          <Tooltip placement="bottom">
-            <label>
-              <input
-                type="search"
-                :class="{'has-error': state.searchError}"
-                :placeholder="i18n('labelSearchScript')"
-                v-model="state.search"
-                ref="refSearch"
-                id="installed-search">
-              <Icon name="search" />
-            </label>
-            <template #content>
-              <pre
-                class="filter-search-tooltip"
-                v-text="state.searchError || i18n('titleSearchHint')"
-              />
-            </template>
-          </Tooltip>
-          <select v-model="filters.searchScope" @change="handleOnScopeChange">
-            <option value="name" v-text="i18n('filterScopeName')"/>
-            <option value="code" v-text="i18n('filterScopeCode')"/>
-            <option value="all" v-text="i18n('filterScopeAll')"/>
-          </select>
+        <form class="filter-search hidden-xs" @submit.prevent>
+          <label>
+            <input
+              type="search"
+              :class="{'has-error': state.search.error}"
+              :placeholder="i18n('labelSearchScript')"
+              v-model="state.search.value"
+              ref="refSearch"
+              id="installed-search">
+            <Icon name="search" />
+          </label>
         </form>
+        <Dropdown align="right">
+          <a class="btn-ghost" tabindex="0" :class="{'has-error': state.search.error}">
+            <Icon name="question"></Icon>
+          </a>
+          <template #content>
+            <div class="filter-search-tooltip">
+              <div class="has-error" v-if="state.search.error" v-text="state.search.error" />
+              <div v-html="i18n('titleSearchHintV2')" />
+            </div>
+          </template>
+        </Dropdown>
       </header>
       <div v-if="showRecycle" class="hint mx-1 my-1 flex flex-col">
         <span v-text="i18n('hintRecycleBin')"/>
@@ -106,9 +105,9 @@
         :data-columns="state.numColumns"
         :data-show-order="filters.showOrder || null"
         :data-table="filters.viewTable || null">
-        <script-item
+        <ScriptItem
           v-for="(script, index) in state.sortedScripts"
-          v-show="!state.search || script.$cache.show !== false"
+          v-show="!state.search.rules.length || script.$cache.show !== false"
           :key="script.props.id"
           :focused="selectedScript === script"
           :showHotkeys="state.showHotkeys"
@@ -117,11 +116,13 @@
           :visible="index < state.batchRender.limit"
           :viewTable="filters.viewTable"
           :hotkeys="scriptHotkeys"
+          :activeTags="activeTags"
           @remove="handleActionRemove"
           @restore="handleActionRestore"
           @toggle="handleActionToggle"
           @update="handleActionUpdate"
           @scrollDelta="handleSmoothScroll"
+          @clickTag="handleClickTag"
         />
       </div>
     </div>
@@ -140,7 +141,7 @@
   </div>
 </template>
 
-<script>
+<script setup>
 import { computed, reactive, nextTick, onMounted, watch, ref } from 'vue';
 import { i18n, sendCmdDirectly, debounce, makePause, trueJoin } from '@/common';
 import options from '@/common/options';
@@ -156,7 +157,7 @@ import SettingCheck from '@/common/ui/setting-check';
 import Icon from '@/common/ui/icon';
 import LocaleGroup from '@/common/ui/locale-group';
 import { customCssElem, findStyleSheetRules } from '@/common/ui/style';
-import { markRemove, store } from '../utils';
+import { markRemove, store, createSearchRules, testSearchRule } from '../utils';
 import toggleDragging from '../utils/dragging';
 import ScriptItem from './script-item';
 import Edit from './edit';
@@ -240,8 +241,11 @@ const state = reactive({
   focusedIndex: -1,
   menuNewActive: false,
   showHotkeys: false,
-  search: '',
-  searchError: null,
+  search: {
+    value: '',
+    error: null,
+    ...createSearchRules(''),
+  },
   sortedScripts: [],
   filteredScripts: [],
   script: null,
@@ -261,17 +265,17 @@ const message = computed(() => {
   if (store.loading) {
     return null;
   }
-  if (state.search ? !state.sortedScripts.find(s => s.$cache.show !== false) : !state.sortedScripts.length) {
+  if (state.search.rules.length ? !state.sortedScripts.find(s => s.$cache.show !== false) : !state.sortedScripts.length) {
     return i18n('labelNoSearchScripts');
   }
   return null;
 });
-const searchNeedsCodeIds = computed(() => state.search
-        && ['code', 'all'].includes(filters.searchScope)
+const searchNeedsCodeIds = computed(() => state.search.rules.some(rule => !rule.scope || rule.scope === 'code')
         && store.scripts.filter(s => s.$cache.code == null).map(s => s.props.id));
+const activeTags = computed(() => state.search.tokens.filter(token => token.prefix === '#' && !token.negative).map(token => token.parsed));
 const getCurrentList = () => showRecycle.value ? store.removedScripts : store.scripts;
 
-const debouncedUpdate = debounce(onUpdate, 100);
+const debouncedSearch = debounce(scheduleSearch, 200);
 const debouncedRender = debounce(renderScripts);
 
 function resetList() {
@@ -291,11 +295,11 @@ async function refreshUI() {
 }
 function onUpdate() {
   const scripts = [...getCurrentList()];
-  const numFound = state.search ? performSearch(scripts) : scripts.length;
+  const numFound = state.search.rules.length ? performSearch(scripts) : scripts.length;
   const cmp = currentSortCompare.value;
   if (cmp) scripts.sort(combinedCompare(cmp));
   state.sortedScripts = scripts;
-  state.filteredScripts = state.search ? scripts.filter(({ $cache }) => $cache.show) : scripts;
+  state.filteredScripts = state.search.rules.length ? scripts.filter(({ $cache }) => $cache.show) : scripts;
   selectScript(state.focusedIndex);
   if (!step || numFound < step) renderScripts();
   else debouncedRender();
@@ -338,10 +342,6 @@ async function moveScript(from, to) {
 function handleOrderChange(e) {
   options.set('filters.sort', e.target.value);
 }
-function handleOnScopeChange(e) {
-  if (state.search) scheduleSearch();
-  options.set('filters.searchScope', e.target.value);
-}
 function handleStateChange(active) {
   state.menuNewActive = active;
 }
@@ -388,7 +388,7 @@ async function renderScripts() {
   const startTime = performance.now();
   // If we entered a new loop of rendering, state.batchRender will no longer be batchRender
   while (limit < length && batchRender === state.batchRender) {
-    if (step && state.search) {
+    if (step && state.search.rules.length) {
       // Only visible items contribute to the batch size
       for (let vis = 0; vis < step && limit < length; limit += 1) {
         vis += state.sortedScripts[limit].$cache.show ? 1 : 0;
@@ -404,40 +404,40 @@ async function renderScripts() {
     if (step && limit < length) await makePause();
   }
 }
-function performSearch(scripts) {
-  let searchRE;
+function performSearch() {
   let count = 0;
-  const [,
-  expr = state.search.replace(/[.+^*$?|\\()[\]{}]/g, '\\$&'),
-  flags = 'i',
-] = state.search.match(/^\/(.+?)\/(\w*)$|$/);
-  const scope = filters.searchScope;
-  const scopeName = scope === 'name' || scope === 'all';
-  const scopeCode = scope === 'code' || scope === 'all';
+  store.scripts.forEach(({ $cache }) => {
+    const dataMap = {
+      name: $cache.lowerName,
+      code: $cache.code,
+      tags: $cache.tags,
+      '': $cache.search,
+    };
+    $cache.show = state.search.rules.every(rule => testSearchRule(rule, dataMap[rule.scope]));
+    count += $cache.show;
+  });
+  return count;
+}
+function scheduleSearch() {
   try {
-    searchRE = expr && new RegExp(expr, flags);
-    scripts.forEach(({ $cache }) => {
-      $cache.show = !expr
-        || scopeName && searchRE.test($cache.search)
-        || scopeCode && searchRE.test($cache.code);
-      count += $cache.show;
-    });
-    state.searchError = null;
+    state.search = {
+      ...state.search,
+      ...createSearchRules(state.search.value),
+    };
+    state.search.error = null;
   } catch (err) {
-    state.searchError = err.message;
+    state.search.error = err.message;
   }
-  return count;
-}
-async function scheduleSearch() {
   const ids = searchNeedsCodeIds.value;
-  if (ids?.length) await getCodeFromStorage(ids);
-  debouncedUpdate();
+  if (ids?.length) getCodeFromStorage(ids);
+  onUpdate();
 }
 async function getCodeFromStorage(ids) {
   const data = await sendCmdDirectly('GetScriptCode', ids);
   store.scripts.forEach(({ $cache, props: { id } }) => {
     if (id in data) $cache.code = data[id];
   });
+  onUpdate();
 }
 async function handleEmptyRecycleBin() {
   if (await showConfirmation(i18n('buttonEmptyRecycleBin'))) {
@@ -501,6 +501,16 @@ function handleActionToggle(script) {
 function handleActionUpdate(script) {
   sendCmdDirectly('CheckUpdate', script.props.id);
 }
+function handleClickTag(tag) {
+  if (activeTags.value.includes(tag)) {
+    // remove tag
+    const tokens = state.search.tokens.filter(token => !(token.prefix === '#' && token.parsed === tag));
+    state.search.value = tokens.map(token => `${token.prefix}${token.raw}`).join(' ');
+  } else {
+    // add tag
+    state.search.value = [state.search.value.trim(), `#${tag} `].filter(Boolean).join(' ');
+  }
+}
 function handleSmoothScroll(delta) {
   if (!delta) return;
   const el = refList.value;
@@ -629,100 +639,51 @@ function bindKeys() {
   });
 }
 
-export default {
-  components: {
-    Dropdown,
-    Tooltip,
-    SettingCheck,
-    Icon,
-    LocaleGroup,
-    ScriptItem,
-    Edit,
-  },
-  directives: {
-    focus: vFocus,
-  },
-  setup() {
-    resetList();
-    watch(showRecycle, resetList);
-    watch(() => store.canRenderScripts && refList.value && draggableRaw.value,
-      dr => toggleDragging(refList.value, moveScript, dr));
-    watch(() => state.search, scheduleSearch);
-    watch(() => [filters.sort, filters.showEnabledFirst], debouncedUpdate);
-    if (screen.availWidth > 767) {
-      watch(() => filters.viewSingleColumn, adjustScriptWidth);
-      watch(() => filters.viewTable, adjustNarrowWidth);
-    }
-    watch(getCurrentList, refreshUI);
-    watch(() => store.route.paths[1], onHashChange);
-    watch(selectedScript, script => {
-      keyboardService.setContext('selectedScript', script);
-    });
-    watch(() => state.showHotkeys, value => {
-      keyboardService.setContext('showHotkeys', value);
-    });
+resetList();
+watch(showRecycle, resetList);
+watch(() => store.canRenderScripts && refList.value && draggableRaw.value,
+  dr => toggleDragging(refList.value, moveScript, dr));
+watch(() => state.search.value, debouncedSearch);
+watch(() => [filters.sort, filters.showEnabledFirst], debouncedSearch);
+if (screen.availWidth > 767) {
+  watch(() => filters.viewSingleColumn, adjustScriptWidth);
+  watch(() => filters.viewTable, adjustNarrowWidth);
+}
+watch(getCurrentList, refreshUI);
+watch(() => store.route.paths[1], onHashChange);
+watch(selectedScript, script => {
+  keyboardService.setContext('selectedScript', script);
+});
+watch(() => state.showHotkeys, value => {
+  keyboardService.setContext('showHotkeys', value);
+});
 
-    onMounted(() => {
-      // Ensure the correct UI is shown when mounted:
-      // * on subsequent navigation via history back/forward;
-      // * on first initialization in some weird case the scripts got loaded early.
-      if (!store.loading) refreshUI();
-      // Extract --columns-cards and --columns-table from `:root` or `html` selector. CustomCSS may override it.
-      if (!columnsForCardsMode.length) {
-        const style = customCssElem?.textContent.match(/--columns-(cards|table)\b/)
-          && getComputedStyle(document.documentElement);
-        if (style) {
-          for (const [type, arr] of [
-            ['cards', columnsForCardsMode],
-            ['table', columnsForTableMode],
-          ]) {
-            const val = style.getPropertyValue(`--columns-${type}`);
-            if (val) arr.push(...val.split(',').map(Number).filter(Boolean));
-          }
-        } else {
-          columnsForCardsMode.push(1300, 1900, 2500); // 1366x768, 1920x1080, 2560x1440
-          columnsForTableMode.push(1600, 2500, 3400); // 1680x1050, 2560x1440, 3440x1440
-        }
-        addEventListener('resize', adjustScriptWidth);
+onMounted(() => {
+  // Ensure the correct UI is shown when mounted:
+  // * on subsequent navigation via history back/forward;
+  // * on first initialization in some weird case the scripts got loaded early.
+  if (!store.loading) refreshUI();
+  // Extract --columns-cards and --columns-table from `:root` or `html` selector. CustomCSS may override it.
+  if (!columnsForCardsMode.length) {
+    const style = customCssElem?.textContent.match(/--columns-(cards|table)\b/)
+      && getComputedStyle(document.documentElement);
+    if (style) {
+      for (const [type, arr] of [
+        ['cards', columnsForCardsMode],
+        ['table', columnsForTableMode],
+      ]) {
+        const val = style.getPropertyValue(`--columns-${type}`);
+        if (val) arr.push(...val.split(',').map(Number).filter(Boolean));
       }
-      adjustScriptWidth();
-      return bindKeys();
-    });
-
-    return {
-      // Refs
-      refSearch,
-      refList,
-      scroller,
-
-      // Values
-      store,
-      state,
-      filters,
-      filterOptions,
-      currentSortCompare,
-      selectedScript,
-      draggable,
-      scriptHotkeys,
-      showRecycle,
-      message,
-
-      // Methods
-      handleStateChange,
-      handleOrderChange,
-      handleEditScript,
-      handleEmptyRecycleBin,
-      handleInstallFromURL,
-      handleUpdateAll,
-      handleOnScopeChange,
-      handleActionRemove,
-      handleActionRestore,
-      handleActionToggle,
-      handleActionUpdate,
-      handleSmoothScroll,
-    };
-  },
-};
+    } else {
+      columnsForCardsMode.push(1300, 1900, 2500); // 1366x768, 1920x1080, 2560x1440
+      columnsForTableMode.push(1600, 2500, 3400); // 1680x1050, 2560x1440, 3440x1440
+    }
+    addEventListener('resize', adjustScriptWidth);
+  }
+  adjustScriptWidth();
+  return bindKeys();
+});
 </script>
 
 <style>
@@ -768,9 +729,6 @@ $iconSize: 2rem; // from .icon in ui/style.css
   text-align: center;
   color: var(--fill-8);
 }
-.scripts {
-  overflow-y: auto;
-}
 .backdrop > *,
 .backdrop::after {
   display: inline-block;
@@ -800,6 +758,7 @@ $iconSize: 2rem; // from .icon in ui/style.css
   }
 }
 .filter-search {
+  min-width: 14rem;
   label {
     position: relative;
   }
@@ -810,13 +769,16 @@ $iconSize: 2rem; // from .icon in ui/style.css
     right: .5rem;
   }
   input {
-    width: 14rem;
-    max-width: calc(100vw - 16rem);
+    width: 100%;
+    height: 2rem;
     padding-left: .5rem;
     padding-right: 2rem;
-    height: 100%;
   }
   &-tooltip {
+    width: 24rem;
+    max-width: 100vw;
+    font-size: 14px;
+    line-height: 1.5;
     white-space: pre-wrap;
   }
 }

+ 352 - 0
test/options/__snapshots__/search.test.js.snap

@@ -0,0 +1,352 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`createSearchRules 1`] = `
+{
+  "rules": [],
+  "tokens": [],
+}
+`;
+
+exports[`createSearchRules 2`] = `
+{
+  "rules": [
+    {
+      "negative": true,
+      "pattern": /\\(\\?:\\^\\|\\\\s\\)\\(c\\)\\(\\\\s\\|\\$\\)/,
+      "scope": "tags",
+    },
+    {
+      "negative": false,
+      "pattern": /\\(\\?:\\^\\|\\\\s\\)\\(a\\|b\\)\\(\\\\s\\|\\$\\)/,
+      "scope": "tags",
+    },
+    {
+      "negative": false,
+      "pattern": "hello",
+      "scope": "",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "a",
+      "prefix": "#",
+      "raw": "a",
+    },
+    {
+      "negative": false,
+      "parsed": "b",
+      "prefix": "#",
+      "raw": "b",
+    },
+    {
+      "negative": true,
+      "parsed": "c",
+      "prefix": "#",
+      "raw": "c",
+    },
+    {
+      "negative": false,
+      "parsed": "hello",
+      "prefix": "",
+      "raw": "hello",
+    },
+  ],
+}
+`;
+
+exports[`createSearchRules 3`] = `
+{
+  "rules": [
+    {
+      "negative": false,
+      "pattern": /\\(\\?:\\^\\|\\\\s\\)\\(a-b\\|b\\)\\(\\\\s\\|\\$\\)/,
+      "scope": "tags",
+    },
+    {
+      "negative": false,
+      "pattern": "hello",
+      "scope": "name",
+    },
+    {
+      "negative": false,
+      "pattern": "world",
+      "scope": "",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "a-b",
+      "prefix": "#",
+      "raw": "a-b",
+    },
+    {
+      "negative": false,
+      "parsed": "b",
+      "prefix": "#",
+      "raw": "b",
+    },
+    {
+      "negative": false,
+      "parsed": "hello",
+      "prefix": "name:",
+      "raw": "hello",
+    },
+    {
+      "negative": false,
+      "parsed": "world",
+      "prefix": "",
+      "raw": "world",
+    },
+  ],
+}
+`;
+
+exports[`createSearchRules 4`] = `
+{
+  "rules": [
+    {
+      "negative": false,
+      "pattern": /\\(\\?:\\^\\|\\\\s\\)\\(a\\\\\\.b\\|b\\)\\(\\\\s\\|\\$\\)/,
+      "scope": "tags",
+    },
+    {
+      "negative": false,
+      "pattern": "hello world",
+      "scope": "name",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "a.b",
+      "prefix": "#",
+      "raw": "a.b",
+    },
+    {
+      "negative": false,
+      "parsed": "b",
+      "prefix": "#",
+      "raw": "b",
+    },
+    {
+      "negative": false,
+      "parsed": "hello world",
+      "prefix": "name:",
+      "raw": ""hello world"",
+    },
+  ],
+}
+`;
+
+exports[`createSearchRules 5`] = `
+{
+  "rules": [
+    {
+      "negative": false,
+      "pattern": /\\(\\?:\\^\\|\\\\s\\)\\(a\\\\\\.b\\|b\\)\\(\\\\s\\|\\$\\)/,
+      "scope": "tags",
+    },
+    {
+      "negative": false,
+      "pattern": /hello world/i,
+      "scope": "name",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "a.b",
+      "prefix": "#",
+      "raw": "a.b",
+    },
+    {
+      "negative": false,
+      "parsed": "b",
+      "prefix": "#",
+      "raw": "b",
+    },
+    {
+      "negative": false,
+      "parsed": "hello world",
+      "prefix": "name+re:",
+      "raw": ""hello world"",
+    },
+  ],
+}
+`;
+
+exports[`createSearchRules 6`] = `
+{
+  "rules": [
+    {
+      "negative": false,
+      "pattern": /\\(\\?:\\^\\|\\\\s\\)\\(a\\\\\\.b\\|b\\)\\(\\\\s\\|\\$\\)/,
+      "scope": "tags",
+    },
+    {
+      "negative": true,
+      "pattern": /hello world/i,
+      "scope": "name",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "a.b",
+      "prefix": "#",
+      "raw": "a.b",
+    },
+    {
+      "negative": false,
+      "parsed": "b",
+      "prefix": "#",
+      "raw": "b",
+    },
+    {
+      "negative": true,
+      "parsed": "hello world",
+      "prefix": "name+re:",
+      "raw": ""hello world"",
+    },
+  ],
+}
+`;
+
+exports[`createSearchRules 7`] = `
+{
+  "rules": [
+    {
+      "negative": false,
+      "pattern": "#a.b",
+      "scope": "",
+    },
+    {
+      "negative": true,
+      "pattern": "#b",
+      "scope": "",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "#a.b",
+      "prefix": "",
+      "raw": ""#a.b"",
+    },
+    {
+      "negative": true,
+      "parsed": "#b",
+      "prefix": "",
+      "raw": ""#b"",
+    },
+  ],
+}
+`;
+
+exports[`createSearchRules 8`] = `
+{
+  "rules": [
+    {
+      "negative": false,
+      "pattern": /regexp/i,
+      "scope": "",
+    },
+    {
+      "negative": false,
+      "pattern": /regexp/u,
+      "scope": "code",
+    },
+    {
+      "negative": false,
+      "pattern": "/not",
+      "scope": "",
+    },
+    {
+      "negative": false,
+      "pattern": "regexp/",
+      "scope": "",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "/regexp/",
+      "prefix": "",
+      "raw": "/regexp/",
+    },
+    {
+      "negative": false,
+      "parsed": "/regexp/u",
+      "prefix": "code:",
+      "raw": "/regexp/u",
+    },
+    {
+      "negative": false,
+      "parsed": "/not",
+      "prefix": "",
+      "raw": "/not",
+    },
+    {
+      "negative": false,
+      "parsed": "regexp/",
+      "prefix": "",
+      "raw": "regexp/",
+    },
+  ],
+}
+`;
+
+exports[`createSearchRules 9`] = `
+{
+  "rules": [
+    {
+      "negative": false,
+      "pattern": "foobar",
+      "scope": "",
+    },
+    {
+      "negative": false,
+      "pattern": /foobar/i,
+      "scope": "",
+    },
+    {
+      "negative": false,
+      "pattern": /foobar/i,
+      "scope": "name",
+    },
+    {
+      "negative": false,
+      "pattern": /foobar/i,
+      "scope": "code",
+    },
+  ],
+  "tokens": [
+    {
+      "negative": false,
+      "parsed": "foobar",
+      "prefix": "",
+      "raw": "foobar",
+    },
+    {
+      "negative": false,
+      "parsed": "foobar",
+      "prefix": "re:",
+      "raw": "foobar",
+    },
+    {
+      "negative": false,
+      "parsed": "foobar",
+      "prefix": "name+re:",
+      "raw": "foobar",
+    },
+    {
+      "negative": false,
+      "parsed": "foobar",
+      "prefix": "code+re:",
+      "raw": "foobar",
+    },
+  ],
+}
+`;

+ 13 - 0
test/options/search.test.js

@@ -0,0 +1,13 @@
+import { createSearchRules } from '@/options/utils/search';
+
+test('createSearchRules', () => {
+  expect(createSearchRules('')).toMatchSnapshot();
+  expect(createSearchRules('#a #b !#c hello')).toMatchSnapshot();
+  expect(createSearchRules('#a-b #b name:hello world')).toMatchSnapshot();
+  expect(createSearchRules('#a.b #b name:"hello world"')).toMatchSnapshot();
+  expect(createSearchRules('#a.b #b name+re:"hello world"')).toMatchSnapshot();
+  expect(createSearchRules('#a.b #b !name+re:"hello world"')).toMatchSnapshot();
+  expect(createSearchRules('"#a.b" !"#b"')).toMatchSnapshot();
+  expect(createSearchRules('/regexp/ code:/regexp/u /not regexp/')).toMatchSnapshot();
+  expect(createSearchRules('foobar re:foobar name+re:foobar code+re:foobar')).toMatchSnapshot();
+});