Skip to content

Commit b1eb113

Browse files
committed
Add recent queries and highlight matched signature fragments
1 parent fb05307 commit b1eb113

File tree

6 files changed

+149
-55
lines changed

6 files changed

+149
-55
lines changed

scaladoc-js/common/css/searchbar.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ div[selected] > .scaladoc-searchbar-inkuire-package {
7171
height: 16px;
7272
width: 16px;
7373
margin: 4px 8px 0px 0px;
74+
color: var(--text-secondary)
7475
}
7576

7677
.scaladoc-searchbar-row[result] {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package dotty.tools.scaladoc
2+
3+
import scala.scalajs.js
4+
import scala.scalajs.js._
5+
import org.scalajs.dom._
6+
import org.scalajs.dom.ext._
7+
import scala.util.chaining._
8+
9+
class SafeLocalStorage[T <: js.Any](key: String, defaultValue: T) {
10+
11+
val isLocalStorageSupported: Boolean = try {
12+
val testKey = "__TEST__KEY__"
13+
window.localStorage.setItem(testKey, "")
14+
window.localStorage.removeItem(testKey)
15+
true
16+
} catch {
17+
case _ => false
18+
}
19+
20+
def checkSupport[U](defaultValue: U)(callback: () => U): U =
21+
if isLocalStorageSupported then callback() else defaultValue
22+
23+
def parseData(data: String): T =
24+
try {
25+
Option(JSON.parse(data).asInstanceOf[T]).getOrElse(defaultValue)
26+
} catch {
27+
case _ => defaultValue
28+
}
29+
30+
def getData: T =
31+
checkSupport(defaultValue) { () =>
32+
window.localStorage
33+
.getItem(key)
34+
.pipe(parseData)
35+
}
36+
37+
def setData(data: T): Unit =
38+
checkSupport(()) { () =>
39+
JSON.stringify(data)
40+
.pipe(window.localStorage.setItem(key, _))
41+
}
42+
43+
def isEmpty: Boolean = getData == defaultValue
44+
}

scaladoc-js/main/src/searchbar/SearchbarComponent.scala

Lines changed: 73 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dotty.tools.scaladoc
22

33
import utils.HTML._
44

5+
import scala.scalajs.js.Date
56
import org.scalajs.dom._
67
import org.scalajs.dom.ext._
78
import org.scalajs.dom.html.Input
@@ -16,7 +17,7 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
1617
val initialChunkSize = 5
1718
val resultsChunkSize = 20
1819
extension (p: PageEntry)
19-
def toHTML =
20+
def toHTML(boldChars: Set[Int]) =
2021
val location = if (p.isLocationExternal) {
2122
p.location
2223
} else {
@@ -25,7 +26,7 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
2526

2627
div(cls := "scaladoc-searchbar-row monospace", "result" := "")(
2728
a(href := location)(
28-
p.fullName,
29+
p.fullName.zipWithIndex.map((c, i) => if boldChars.contains(i) then b(c.toString) else c.toString),
2930
span(cls := "pull-right scaladoc-searchbar-location")(p.description)
3031
).tap { _.onclick = (event: Event) =>
3132
if (document.body.contains(rootDiv)) {
@@ -63,14 +64,29 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
6364
})
6465
}
6566

66-
def createKindSeparator(kind: String) =
67+
extension (rq: RecentQuery)
68+
def toHTML =
69+
div(cls := "scaladoc-searchbar-row monospace", "result" := "")(
70+
a(
71+
span(rq.query)
72+
)
73+
).tap { _.addEventListener("click", _ => {
74+
inputElem.value = rq.query
75+
inputElem.dispatchEvent(new Event("input"))
76+
})
77+
}.tap { wrapper => wrapper.addEventListener("mouseover", {
78+
case e: MouseEvent => handleHover(wrapper)
79+
})
80+
}
81+
82+
def createKindSeparator(kind: String, customClass: String = "") =
6783
div(cls := "scaladoc-searchbar-row monospace", "divider" := "")(
68-
span(cls := s"micon ${kind.take(2)}"),
84+
span(cls := s"micon ${kind.take(2)} $customClass"),
6985
span(kind)
7086
)
7187

7288
def handleNewFluffQuery(matchers: List[Matchers]) =
73-
val result = engine.query(matchers)
89+
val result: List[(PageEntry, Set[Int])] = engine.query(matchers)
7490
val fragment = document.createDocumentFragment()
7591
def createLoadMoreElement =
7692
div(cls := "scaladoc-searchbar-row monospace", "loadmore" := "")(
@@ -81,10 +97,10 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
8197
.addEventListener("mouseover", _ => handleHover(loadMoreElement))
8298
}
8399

84-
result.groupBy(_.kind).map {
100+
result.groupBy(_._1.kind).map {
85101
case (kind, entries) =>
86102
val kindSeparator = createKindSeparator(kind)
87-
val htmlEntries = entries.map(_.toHTML)
103+
val htmlEntries = entries.map((p, set) => p.toHTML(set))
88104
val loadMoreElement = createLoadMoreElement
89105
def loadMoreResults(entries: List[raw.HTMLElement]): Unit = {
90106
loadMoreElement.onclick = (event: Event) => {
@@ -109,9 +125,26 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
109125
}
110126

111127
resultsDiv.scrollTop = 0
112-
while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild)
113128
resultsDiv.appendChild(fragment)
114129

130+
def handleRecentQueries(query: String) = {
131+
val recentQueries = RecentQueryStorage.getData
132+
if query != "" then RecentQueryStorage.addEntry(RecentQuery(query, Date.now()))
133+
val matching = recentQueries
134+
.filterNot(rq => rq.query.equalsIgnoreCase(query)) // Don't show recent query that is equal to provided query
135+
.filter { rq => // Fuzzy search
136+
rq.query.foldLeft(query) { (pattern, nextChar) =>
137+
if !pattern.isEmpty then {
138+
if pattern.head.toString.equalsIgnoreCase(nextChar.toString) then pattern.tail else pattern
139+
} else ""
140+
}.isEmpty
141+
}
142+
if matching.nonEmpty then {
143+
resultsDiv.appendChild(createKindSeparator("Recently searched", "fas fa-clock"))
144+
matching.map(_.toHTML).foreach(resultsDiv.appendChild)
145+
}
146+
}
147+
115148
def createLoadingAnimation: raw.HTMLElement =
116149
div(cls := "loading-wrapper")(
117150
div(cls := "loading")
@@ -124,35 +157,33 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
124157

125158
var timeoutHandle: SetTimeoutHandle = null
126159
def handleNewQuery(query: String) =
127-
clearTimeout(timeoutHandle)
128160
resultsDiv.scrollTop = 0
129161
resultsDiv.onscroll = (event: Event) => { }
130-
def clearResults() = while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild)
131162
val fragment = document.createDocumentFragment()
132-
parser.parse(query) match {
133-
case EngineMatchersQuery(matchers) =>
134-
clearResults()
135-
handleNewFluffQuery(matchers)
136-
case BySignature(signature) =>
137-
timeoutHandle = setTimeout(1.second) {
138-
val loading = createLoadingAnimation
139-
val kindSeparator = createKindSeparator("inkuire")
140-
clearResults()
141-
resultsDiv.appendChild(loading)
142-
resultsDiv.appendChild(kindSeparator)
143-
inkuireEngine.query(query) { (m: InkuireMatch) =>
144-
val next = resultsDiv.children
145-
.find(child => child.hasAttribute("mq") && Integer.parseInt(child.getAttribute("mq")) > m.mq)
146-
next.fold {
147-
resultsDiv.appendChild(m.toHTML)
148-
} { next =>
149-
resultsDiv.insertBefore(m.toHTML, next)
163+
timeoutHandle = setTimeout(600.millisecond) {
164+
clearResults()
165+
handleRecentQueries(query)
166+
parser.parse(query) match {
167+
case EngineMatchersQuery(matchers) =>
168+
handleNewFluffQuery(matchers)
169+
case BySignature(signature) =>
170+
val loading = createLoadingAnimation
171+
val kindSeparator = createKindSeparator("inkuire")
172+
resultsDiv.appendChild(loading)
173+
resultsDiv.appendChild(kindSeparator)
174+
inkuireEngine.query(query) { (m: InkuireMatch) =>
175+
val next = resultsDiv.children
176+
.find(child => child.hasAttribute("mq") && Integer.parseInt(child.getAttribute("mq")) > m.mq)
177+
next.fold {
178+
resultsDiv.appendChild(m.toHTML)
179+
} { next =>
180+
resultsDiv.insertBefore(m.toHTML, next)
181+
}
182+
} { (s: String) =>
183+
resultsDiv.removeChild(loading)
184+
resultsDiv.appendChild(s.toHTMLError)
150185
}
151-
} { (s: String) =>
152-
resultsDiv.removeChild(loading)
153-
resultsDiv.appendChild(s.toHTMLError)
154-
}
155-
}
186+
}
156187
}
157188

158189
private val searchIcon: html.Button =
@@ -173,9 +204,12 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
173204
private val inputElem: html.Input =
174205
input(id := "scaladoc-searchbar-input", `type` := "search", `placeholder`:= "Find anything").tap { element =>
175206
element.addEventListener("input", { e =>
207+
clearTimeout(timeoutHandle)
176208
val inputValue = e.target.asInstanceOf[html.Input].value
177-
if inputValue.isEmpty then showHints()
178-
else handleNewQuery(inputValue)
209+
if inputValue.isEmpty then {
210+
clearResults()
211+
if RecentQueryStorage.isEmpty then showHints() else handleRecentQueries("")
212+
} else handleNewQuery(inputValue)
179213
})
180214

181215
element.autocomplete = "off"
@@ -184,6 +218,8 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
184218
private val resultsDiv: html.Div =
185219
div(id := "scaladoc-searchbar-results")
186220

221+
def clearResults() = while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild)
222+
187223
private val rootHiddenClasses = "hidden"
188224
private val rootShowClasses = ""
189225

@@ -291,7 +327,7 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
291327
private def handleEscape() = {
292328
// clear the search input and close the search
293329
inputElem.value = ""
294-
showHints()
330+
inputElem.dispatchEvent(new Event("input"))
295331
document.body.removeChild(rootDiv)
296332
}
297333

@@ -321,7 +357,6 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
321357
}
322358

323359
private def showHints() = {
324-
def clearResults() = while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild)
325360
val hintsDiv = div(cls := "searchbar-hints")(
326361
span(cls := "lightbulb"),
327362
h1(cls := "body-medium")("A bunch of search hints to make your life easier"),
@@ -339,8 +374,7 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch
339374
li(cls := "link body-small")("Availability of searching by inkuire depends on the configuration of Scaladoc. For more info, ", a(href := "https://docs.scala-lang.org/scala3/guides/scaladoc/search-engine.html")("the documentation")),
340375
)
341376
)
342-
clearResults()
343377
resultsDiv.appendChild(hintsDiv)
344378
}
345379

346-
showHints()
380+
inputElem.dispatchEvent(new Event("input"))

scaladoc-js/main/src/searchbar/engine/Matchers.scala

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,26 @@ sealed trait EngineQuery
44
case class EngineMatchersQuery(matchers: List[Matchers]) extends EngineQuery
55
case class BySignature(signature: String) extends EngineQuery
66

7-
sealed trait Matchers extends Function1[PageEntry, Int]
7+
case class Match(priority: Int, matchedIndexes: Set[Int]) // matchedIndexes - indexes of chars that got matched
8+
9+
sealed trait Matchers extends Function1[PageEntry, Match]
810

911
case class ByName(query: String) extends Matchers:
1012
val tokens = StringUtils.createCamelCaseTokens(query)
11-
def apply(p: PageEntry): Int = {
13+
def apply(p: PageEntry): Match = {
1214
val nameOption = Option(p.shortName.toLowerCase)
1315
//Edge case for empty query string
14-
if query == "" then 1
16+
if query == "" then Match(1, Set.empty)
1517
else {
16-
val results = List(
17-
nameOption.filter(_.contains(query.toLowerCase)).fold(-1)(_.size - query.size),
18-
if p.tokens.size >= tokens.size && p.tokens.zip(tokens).forall( (token, query) => token.startsWith(query))
19-
then p.tokens.size - tokens.size + 1
20-
else -1
21-
//acronym.filter(_.contains(query)).fold(-1)(_.size - query.size + 1)
22-
)
23-
if results.forall(_ == -1) then -1 else results.filter(_ != -1).min
18+
val (result, indexes) = p.shortName.toLowerCase.zipWithIndex.foldLeft((query.toLowerCase, Set.empty[Int])) {
19+
case ((pattern, indexes), (nextChar, index)) =>
20+
if !pattern.isEmpty then {
21+
if pattern.head.toString.equalsIgnoreCase(nextChar.toString) then (pattern.tail, indexes + index) else (pattern, indexes)
22+
} else ("", indexes)
23+
}
24+
if result.isEmpty then Match(p.shortName.size - query.size + 1, indexes) else Match(-1, Set.empty)
2425
}
2526
}
2627

2728
case class ByKind(kind: String) extends Matchers:
28-
def apply(p: PageEntry): Int = p.fullName.split(" ").headOption.filter(_.equalsIgnoreCase(kind)).fold(-1)(_ => 1)
29+
def apply(p: PageEntry): Match = Match(p.fullName.split(" ").headOption.filter(_.equalsIgnoreCase(kind)).fold(-1)(_ => 1), Set.empty)

scaladoc-js/main/src/searchbar/engine/SearchbarEngine.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import math.Ordering.Implicits.seqOrdering
44
import org.scalajs.dom.Node
55

66
class SearchbarEngine(pages: List[PageEntry]):
7-
def query(query: List[Matchers]): List[PageEntry] =
7+
def query(query: List[Matchers]): List[(PageEntry, Set[Int])] =
88
pages
99
.map( page =>
1010
page -> query.map(matcher => matcher(page))
1111
)
1212
.filterNot {
13-
case (page, matchResults) => matchResults.exists(_ < 0)
13+
case (page, matchResults) => matchResults.map(_.priority).exists(_ < 0)
1414
}
1515
.sortBy {
16-
case (page, matchResults) => matchResults
16+
case (page, matchResults) => matchResults.map(_.priority)
1717
}
1818
.map {
19-
case (page, matchResults) => page
19+
case (page, matchResults) => page -> matchResults.map(_.matchedIndexes).reduceLeft(_ ++ _)
2020
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package dotty.tools.scaladoc
2+
3+
import scala.scalajs.js
4+
5+
class RecentQuery(val query: String, val timestamp: Double) extends js.Object
6+
7+
object RecentQueryStorage extends SafeLocalStorage[js.Array[RecentQuery]]("__RECENT__QUERIES__", js.Array()) {
8+
val maxEntries = 5
9+
10+
def addEntry(rq: RecentQuery): Unit = {
11+
val newData = getData :+ rq
12+
setData(newData.sortBy(_.timestamp).distinctBy(_.query).takeRight(maxEntries))
13+
}
14+
}

0 commit comments

Comments
 (0)