@tooee/search 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +53 -0
- package/src/SearchBar.tsx +55 -0
- package/src/index.ts +5 -0
- package/src/search-hook.ts +147 -0
- package/src/search.ts +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tooee/search",
|
|
3
|
+
"version": "0.1.14",
|
|
4
|
+
"description": "Search hook, utilities, and SearchBar component for Tooee",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"opentui",
|
|
8
|
+
"search",
|
|
9
|
+
"terminal",
|
|
10
|
+
"tui"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/gingerhendrix/tooee",
|
|
13
|
+
"bugs": "https://github.com/gingerhendrix/tooee/issues",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Gareth Andrew",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/gingerhendrix/tooee.git",
|
|
19
|
+
"directory": "packages/search"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"import": {
|
|
29
|
+
"@tooee/source": "./src/index.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc --noEmit"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@tooee/commands": "0.1.14",
|
|
39
|
+
"@tooee/themes": "0.1.14"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@opentui/core": "^0.1.86",
|
|
43
|
+
"@opentui/react": "^0.1.86",
|
|
44
|
+
"@types/bun": "^1.3.10",
|
|
45
|
+
"@types/react": "^19.2.14",
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@opentui/core": "^0.1.86",
|
|
50
|
+
"@opentui/react": "^0.1.86",
|
|
51
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useTheme } from "@tooee/themes"
|
|
2
|
+
|
|
3
|
+
export interface SearchBarProps {
|
|
4
|
+
query: string
|
|
5
|
+
onQueryChange: (query: string) => void
|
|
6
|
+
onSubmit: () => void
|
|
7
|
+
onCancel: () => void
|
|
8
|
+
matchCount?: number
|
|
9
|
+
currentMatch?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SearchBar({
|
|
13
|
+
query,
|
|
14
|
+
onQueryChange,
|
|
15
|
+
onSubmit,
|
|
16
|
+
onCancel: _onCancel,
|
|
17
|
+
matchCount,
|
|
18
|
+
currentMatch,
|
|
19
|
+
}: SearchBarProps) {
|
|
20
|
+
const { theme } = useTheme()
|
|
21
|
+
|
|
22
|
+
const matchDisplay =
|
|
23
|
+
matchCount !== undefined && matchCount > 0
|
|
24
|
+
? `${(currentMatch ?? 0) + 1}/${matchCount}`
|
|
25
|
+
: matchCount === 0 && query.length > 0
|
|
26
|
+
? "No matches"
|
|
27
|
+
: ""
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<box
|
|
31
|
+
style={{
|
|
32
|
+
flexDirection: "row",
|
|
33
|
+
flexShrink: 0,
|
|
34
|
+
backgroundColor: theme.backgroundPanel,
|
|
35
|
+
paddingLeft: 1,
|
|
36
|
+
paddingRight: 1,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<text content="/" style={{ fg: theme.accent }} />
|
|
40
|
+
<input
|
|
41
|
+
value={query}
|
|
42
|
+
focused
|
|
43
|
+
onInput={onQueryChange}
|
|
44
|
+
onSubmit={onSubmit}
|
|
45
|
+
backgroundColor="transparent"
|
|
46
|
+
focusedBackgroundColor="transparent"
|
|
47
|
+
textColor={theme.text}
|
|
48
|
+
cursorColor={theme.accent}
|
|
49
|
+
cursorStyle={{ style: "line", blinking: true }}
|
|
50
|
+
style={{ flexGrow: 1 }}
|
|
51
|
+
/>
|
|
52
|
+
{matchDisplay ? <text content={` ${matchDisplay}`} style={{ fg: theme.textMuted }} /> : null}
|
|
53
|
+
</box>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from "react"
|
|
2
|
+
import { useCommand, useMode, useSetMode, type Mode } from "@tooee/commands"
|
|
3
|
+
|
|
4
|
+
export interface UseSearchOptions {
|
|
5
|
+
match: (query: string) => number[]
|
|
6
|
+
onJump: (index: number) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SearchState {
|
|
10
|
+
searchQuery: string
|
|
11
|
+
searchActive: boolean
|
|
12
|
+
setSearchQuery: (query: string) => void
|
|
13
|
+
matchingLines: number[]
|
|
14
|
+
currentMatchIndex: number
|
|
15
|
+
submitSearch: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const EMPTY: number[] = []
|
|
19
|
+
const CURSOR_MODES: Mode[] = ["cursor"]
|
|
20
|
+
const ALL_MODES: Mode[] = ["cursor", "select", "insert"]
|
|
21
|
+
|
|
22
|
+
export function useSearch({ match, onJump }: UseSearchOptions): SearchState {
|
|
23
|
+
const mode = useMode()
|
|
24
|
+
const setMode = useSetMode()
|
|
25
|
+
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState("")
|
|
27
|
+
const [searchActive, setSearchActive] = useState(false)
|
|
28
|
+
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
|
29
|
+
const [committedQuery, setCommittedQuery] = useState("")
|
|
30
|
+
const preSearchModeRef = useRef<Mode>("cursor")
|
|
31
|
+
|
|
32
|
+
const matchRef = useRef(match)
|
|
33
|
+
matchRef.current = match
|
|
34
|
+
|
|
35
|
+
const onJumpRef = useRef(onJump)
|
|
36
|
+
onJumpRef.current = onJump
|
|
37
|
+
|
|
38
|
+
const activeQuery = searchActive ? searchQuery : committedQuery
|
|
39
|
+
|
|
40
|
+
const matchingLines = useMemo(() => {
|
|
41
|
+
if (!activeQuery) return EMPTY
|
|
42
|
+
return matchRef.current(activeQuery)
|
|
43
|
+
}, [activeQuery])
|
|
44
|
+
|
|
45
|
+
const matchingLinesRef = useRef(matchingLines)
|
|
46
|
+
matchingLinesRef.current = matchingLines
|
|
47
|
+
|
|
48
|
+
// Imperatively set search query, reset match index, and jump to first match.
|
|
49
|
+
const updateSearchQuery = useCallback((query: string) => {
|
|
50
|
+
setSearchQuery(query)
|
|
51
|
+
setCurrentMatchIndex(0)
|
|
52
|
+
const matches = query ? matchRef.current(query) : []
|
|
53
|
+
if (matches[0] != null) {
|
|
54
|
+
onJumpRef.current(matches[0])
|
|
55
|
+
}
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
useCommand({
|
|
59
|
+
id: "cursor-search-start",
|
|
60
|
+
title: "Search",
|
|
61
|
+
hotkey: "/",
|
|
62
|
+
modes: CURSOR_MODES,
|
|
63
|
+
handler: () => {
|
|
64
|
+
preSearchModeRef.current = mode
|
|
65
|
+
setSearchActive(true)
|
|
66
|
+
setSearchQuery("")
|
|
67
|
+
setMode("insert")
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
useCommand({
|
|
72
|
+
id: "cursor-search-next",
|
|
73
|
+
title: "Next match",
|
|
74
|
+
hotkey: "n",
|
|
75
|
+
modes: CURSOR_MODES,
|
|
76
|
+
when: () => !searchActive,
|
|
77
|
+
handler: () => {
|
|
78
|
+
const matches = matchingLinesRef.current
|
|
79
|
+
if (matches.length === 0) return
|
|
80
|
+
|
|
81
|
+
setCurrentMatchIndex((index) => {
|
|
82
|
+
const nextIndex = (index + 1) % matches.length
|
|
83
|
+
const nextMatch = matches[nextIndex]
|
|
84
|
+
if (nextMatch != null) {
|
|
85
|
+
onJumpRef.current(nextMatch)
|
|
86
|
+
}
|
|
87
|
+
return nextIndex
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
useCommand({
|
|
93
|
+
id: "cursor-search-prev",
|
|
94
|
+
title: "Previous match",
|
|
95
|
+
hotkey: "shift+n",
|
|
96
|
+
modes: CURSOR_MODES,
|
|
97
|
+
when: () => !searchActive,
|
|
98
|
+
handler: () => {
|
|
99
|
+
const matches = matchingLinesRef.current
|
|
100
|
+
if (matches.length === 0) return
|
|
101
|
+
|
|
102
|
+
setCurrentMatchIndex((index) => {
|
|
103
|
+
const nextIndex = (index - 1 + matches.length) % matches.length
|
|
104
|
+
const nextMatch = matches[nextIndex]
|
|
105
|
+
if (nextMatch != null) {
|
|
106
|
+
onJumpRef.current(nextMatch)
|
|
107
|
+
}
|
|
108
|
+
return nextIndex
|
|
109
|
+
})
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
useCommand({
|
|
114
|
+
id: "search-cancel",
|
|
115
|
+
title: "Cancel search",
|
|
116
|
+
hotkey: "escape",
|
|
117
|
+
modes: ALL_MODES,
|
|
118
|
+
when: () => searchActive,
|
|
119
|
+
handler: () => {
|
|
120
|
+
setSearchActive(false)
|
|
121
|
+
setSearchQuery("")
|
|
122
|
+
setCommittedQuery("")
|
|
123
|
+
setCurrentMatchIndex(0)
|
|
124
|
+
setMode(preSearchModeRef.current)
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const submitSearch = useCallback(() => {
|
|
129
|
+
setCommittedQuery(searchQuery)
|
|
130
|
+
setSearchActive(false)
|
|
131
|
+
setCurrentMatchIndex(0)
|
|
132
|
+
const matches = searchQuery ? matchRef.current(searchQuery) : []
|
|
133
|
+
if (matches[0] != null) {
|
|
134
|
+
onJumpRef.current(matches[0])
|
|
135
|
+
}
|
|
136
|
+
setMode(preSearchModeRef.current)
|
|
137
|
+
}, [searchQuery, setMode])
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
searchQuery,
|
|
141
|
+
searchActive,
|
|
142
|
+
setSearchQuery: updateSearchQuery,
|
|
143
|
+
matchingLines,
|
|
144
|
+
currentMatchIndex,
|
|
145
|
+
submitSearch,
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function findMatchingLines(text: string, query: string): number[] {
|
|
2
|
+
if (!query) return []
|
|
3
|
+
const lowerQuery = query.toLowerCase()
|
|
4
|
+
const results: number[] = []
|
|
5
|
+
let lineStart = 0
|
|
6
|
+
let lineNum = 0
|
|
7
|
+
for (let i = 0; i <= text.length; i++) {
|
|
8
|
+
if (i === text.length || text[i] === "\n") {
|
|
9
|
+
const line = text.slice(lineStart, i).toLowerCase()
|
|
10
|
+
if (line.includes(lowerQuery)) {
|
|
11
|
+
results.push(lineNum)
|
|
12
|
+
}
|
|
13
|
+
lineStart = i + 1
|
|
14
|
+
lineNum++
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return results
|
|
18
|
+
}
|