find.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
  1. /**
  2. * @author: oldj
  3. * @homepage: https://oldj.net
  4. */
  5. import {
  6. Box,
  7. Button,
  8. ButtonGroup,
  9. Checkbox,
  10. HStack,
  11. IconButton,
  12. Input,
  13. InputGroup,
  14. InputLeftElement,
  15. Spacer,
  16. Spinner,
  17. useColorMode,
  18. VStack,
  19. } from '@chakra-ui/react'
  20. import ItemIcon from '@renderer/components/ItemIcon'
  21. import { actions, agent } from '@renderer/core/agent'
  22. import { PopupMenu } from '@renderer/core/PopupMenu'
  23. import useOnBroadcast from '@renderer/core/useOnBroadcast'
  24. import { HostsType } from '@root/common/data'
  25. import events from '@root/common/events'
  26. import { IFindItem, IFindPosition, IFindShowSourceParam } from '@root/common/types'
  27. import { useDebounce, useDebounceFn } from 'ahooks'
  28. import clsx from 'clsx'
  29. import lodash from 'lodash'
  30. import React, { useEffect, useRef, useState } from 'react'
  31. import {
  32. IoArrowBackOutline,
  33. IoArrowForwardOutline,
  34. IoChevronDownOutline,
  35. IoSearch,
  36. } from 'react-icons/io5'
  37. import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
  38. import scrollIntoView from 'smooth-scroll-into-view-if-needed'
  39. import useConfigs from '../models/useConfigs'
  40. import useI18n from '../models/useI18n'
  41. import styles from './find.less'
  42. interface IFindPositionShow extends IFindPosition {
  43. item_id: string
  44. item_title: string
  45. item_type: HostsType
  46. index: number
  47. is_disabled?: boolean
  48. is_readonly?: boolean
  49. }
  50. const find = () => {
  51. const { lang, i18n, setLocale } = useI18n()
  52. const { configs, loadConfigs } = useConfigs()
  53. const { colorMode, setColorMode } = useColorMode()
  54. const [keyword, setKeyword] = useState('')
  55. const [replace_to, setReplaceTo] = useState('')
  56. const [is_regexp, setIsRegExp] = useState(false)
  57. const [is_ignore_case, setIsIgnoreCase] = useState(false)
  58. const [find_result, setFindResult] = useState<IFindItem[]>([])
  59. const [find_positions, setFindPositions] = useState<IFindPositionShow[]>([])
  60. const [is_searching, setIsSearching] = useState(false)
  61. const [current_result_idx, setCurrentResultIdx] = useState(0)
  62. const [last_scroll_result_idx, setlastScrollResultIdx] = useState(-1)
  63. const debounced_keyword = useDebounce(keyword, { wait: 500 })
  64. const ipt_kw = useRef<HTMLInputElement>(null)
  65. const ref_result_box = useRef<HTMLDivElement>(null)
  66. const init = async () => {
  67. if (!configs) return
  68. setLocale(configs.locale)
  69. let theme = configs.theme
  70. let cls = document.body.className
  71. document.body.className = cls.replace(/\btheme-\w+/gi, '')
  72. document.body.classList.add(`platform-${agent.platform}`, `theme-${theme}`)
  73. }
  74. useEffect(() => {
  75. if (!configs) return
  76. init().catch((e) => console.error(e))
  77. console.log(configs.theme)
  78. if (colorMode !== configs.theme) {
  79. setColorMode(configs.theme)
  80. }
  81. }, [configs])
  82. useEffect(() => {
  83. console.log(lang.find_and_replace)
  84. document.title = lang.find_and_replace
  85. }, [lang])
  86. useEffect(() => {
  87. doFind(debounced_keyword)
  88. }, [debounced_keyword, is_regexp, is_ignore_case])
  89. useEffect(() => {
  90. const onFocus = () => {
  91. if (ipt_kw.current) {
  92. ipt_kw.current.focus()
  93. }
  94. }
  95. window.addEventListener('focus', onFocus, false)
  96. return () => window.removeEventListener('focus', onFocus, false)
  97. }, [ipt_kw])
  98. useOnBroadcast(events.config_updated, loadConfigs)
  99. useOnBroadcast(events.close_find, () => {
  100. console.log('on close find...')
  101. setFindResult([])
  102. setFindPositions([])
  103. setKeyword('')
  104. setReplaceTo('')
  105. setIsRegExp(false)
  106. setIsIgnoreCase(false)
  107. setCurrentResultIdx(-1)
  108. setlastScrollResultIdx(-1)
  109. })
  110. const parsePositionShow = (find_items: IFindItem[]) => {
  111. let positions_show: IFindPositionShow[] = []
  112. find_items.map((item) => {
  113. let { item_id, item_title, item_type, positions } = item
  114. positions.map((p, index) => {
  115. positions_show.push({
  116. item_id,
  117. item_title,
  118. item_type,
  119. ...p,
  120. index,
  121. is_readonly: item_type !== 'local',
  122. })
  123. })
  124. })
  125. setFindPositions(positions_show)
  126. }
  127. const { run: doFind } = useDebounceFn(
  128. async (v: string) => {
  129. console.log('find by:', v)
  130. if (!v) {
  131. setFindResult([])
  132. return
  133. }
  134. setIsSearching(true)
  135. let result = await actions.findBy(v, {
  136. is_regexp,
  137. is_ignore_case,
  138. })
  139. setCurrentResultIdx(0)
  140. setlastScrollResultIdx(0)
  141. setFindResult(result)
  142. parsePositionShow(result)
  143. setIsSearching(false)
  144. await actions.findAddHistory({
  145. value: v,
  146. is_regexp,
  147. is_ignore_case,
  148. })
  149. },
  150. { wait: 500 },
  151. )
  152. const toShowSource = async (result_item: IFindPositionShow) => {
  153. // console.log(result_item)
  154. await actions.cmdFocusMainWindow()
  155. agent.broadcast(
  156. events.show_source,
  157. lodash.pick<IFindShowSourceParam>(result_item, [
  158. 'item_id',
  159. 'start',
  160. 'end',
  161. 'match',
  162. 'line',
  163. 'line_pos',
  164. 'end_line',
  165. 'end_line_pos',
  166. ]),
  167. )
  168. }
  169. const replaceOne = async () => {
  170. let pos: IFindPositionShow = find_positions[current_result_idx]
  171. if (!pos) return
  172. setFindPositions([
  173. ...find_positions.slice(0, current_result_idx),
  174. {
  175. ...pos,
  176. is_disabled: true,
  177. },
  178. ...find_positions.slice(current_result_idx + 1),
  179. ])
  180. if (replace_to) {
  181. actions.findAddReplaceHistory(replace_to).catch((e) => console.error(e))
  182. }
  183. let r = find_result.find((i) => i.item_id === pos.item_id)
  184. if (!r) return
  185. let splitters = r.splitters
  186. let sp = splitters[pos.index]
  187. if (!sp) return
  188. sp.replace = replace_to
  189. const content = splitters
  190. .map((sp) => `${sp.before}${sp.replace ?? sp.match}${sp.after}`)
  191. .join('')
  192. await actions.setHostsContent(pos.item_id, content)
  193. agent.broadcast(events.hosts_refreshed_by_id, pos.item_id)
  194. if (current_result_idx < find_positions.length - 1) {
  195. setCurrentResultIdx(current_result_idx + 1)
  196. }
  197. }
  198. const replaceAll = async () => {
  199. for (let item of find_result) {
  200. let { item_id, item_type, splitters } = item
  201. if (item_type !== 'local' || splitters.length === 0) continue
  202. const content = splitters.map((sp) => `${sp.before}${replace_to}${sp.after}`).join('')
  203. await actions.setHostsContent(item_id, content)
  204. agent.broadcast(events.hosts_refreshed_by_id, item_id)
  205. }
  206. setFindPositions(
  207. find_positions.map((pos) => ({
  208. ...pos,
  209. is_disabled: !pos.is_readonly,
  210. })),
  211. )
  212. if (replace_to) {
  213. actions.findAddReplaceHistory(replace_to).catch((e) => console.error(e))
  214. }
  215. }
  216. const ResultRow = (row_data: ListChildComponentProps) => {
  217. const data = find_positions[row_data.index]
  218. const el = useRef<HTMLDivElement>(null)
  219. const is_selected = current_result_idx === row_data.index
  220. useEffect(() => {
  221. if (el.current && is_selected && current_result_idx !== last_scroll_result_idx) {
  222. setlastScrollResultIdx(current_result_idx)
  223. scrollIntoView(el.current, {
  224. behavior: 'smooth',
  225. scrollMode: 'if-needed',
  226. }).catch((e) => console.error(e))
  227. }
  228. }, [el, current_result_idx, last_scroll_result_idx])
  229. return (
  230. <Box
  231. style={row_data.style}
  232. className={clsx(
  233. styles.result_row,
  234. is_selected && styles.selected,
  235. data.is_disabled && styles.disabled,
  236. data.is_readonly && styles.readonly,
  237. )}
  238. borderBottomWidth={1}
  239. borderBottomColor={configs?.theme === 'dark' ? 'gray.600' : 'gray.200'}
  240. onClick={() => {
  241. setCurrentResultIdx(row_data.index)
  242. }}
  243. onDoubleClick={() => toShowSource(data)}
  244. ref={el}
  245. title={lang.to_show_source}
  246. >
  247. <div className={styles.result_content}>
  248. {data.is_readonly ? <span className={styles.read_only}>{lang.read_only}</span> : null}
  249. <span>{data.before}</span>
  250. <span className={styles.highlight}>{data.match}</span>
  251. <span>{data.after}</span>
  252. </div>
  253. <div className={styles.result_title}>
  254. <ItemIcon type={data.item_type} />
  255. <span>{data.item_title}</span>
  256. </div>
  257. <div className={styles.result_line}>{data.line}</div>
  258. </Box>
  259. )
  260. }
  261. const showKeywordHistory = async () => {
  262. let history = await actions.findGetHistory()
  263. if (history.length === 0) return
  264. let menu = new PopupMenu(
  265. history.reverse().map((i) => ({
  266. label: i.value,
  267. click() {
  268. setKeyword(i.value)
  269. setIsRegExp(i.is_regexp)
  270. setIsIgnoreCase(i.is_ignore_case)
  271. },
  272. })),
  273. )
  274. menu.show()
  275. }
  276. const showReplaceHistory = async () => {
  277. let history = await actions.findGetReplaceHistory()
  278. if (history.length === 0) return
  279. let menu = new PopupMenu(
  280. history.reverse().map((v) => ({
  281. label: v,
  282. click() {
  283. setReplaceTo(v)
  284. },
  285. })),
  286. )
  287. menu.show()
  288. }
  289. let can_replace = true
  290. if (current_result_idx > -1) {
  291. let pos = find_positions[current_result_idx]
  292. if (pos?.is_disabled || pos?.is_readonly) {
  293. can_replace = false
  294. }
  295. }
  296. return (
  297. <div className={styles.root}>
  298. <VStack spacing={0} h="100%">
  299. <InputGroup>
  300. <InputLeftElement
  301. // pointerEvents="none"
  302. children={
  303. <HStack spacing={0}>
  304. <IoSearch />
  305. <IoChevronDownOutline style={{ fontSize: 10 }} />
  306. </HStack>
  307. }
  308. onClick={showKeywordHistory}
  309. />
  310. <Input
  311. autoFocus={true}
  312. placeholder="keywords"
  313. variant="flushed"
  314. value={keyword}
  315. onChange={(e) => {
  316. setKeyword(e.target.value)
  317. }}
  318. ref={ipt_kw}
  319. />
  320. </InputGroup>
  321. <InputGroup>
  322. <InputLeftElement
  323. // pointerEvents="none"
  324. children={
  325. <HStack spacing={0}>
  326. <IoSearch />
  327. <IoChevronDownOutline style={{ fontSize: 10 }} />
  328. </HStack>
  329. }
  330. onClick={showReplaceHistory}
  331. />
  332. <Input
  333. placeholder="replace to"
  334. variant="flushed"
  335. value={replace_to}
  336. onChange={(e) => {
  337. setReplaceTo(e.target.value)
  338. }}
  339. />
  340. </InputGroup>
  341. <HStack
  342. w="100%"
  343. py={2}
  344. px={4}
  345. spacing={4}
  346. // justifyContent="flex-start"
  347. >
  348. <Checkbox checked={is_regexp} onChange={(e) => setIsRegExp(e.target.checked)}>
  349. {lang.regexp}
  350. </Checkbox>
  351. <Checkbox checked={is_ignore_case} onChange={(e) => setIsIgnoreCase(e.target.checked)}>
  352. {lang.ignore_case}
  353. </Checkbox>
  354. </HStack>
  355. <Box w="100%" borderTopWidth={1}>
  356. <div className={styles.result_row}>
  357. <div>{lang.match}</div>
  358. <div>{lang.title}</div>
  359. <div>{lang.line}</div>
  360. </div>
  361. </Box>
  362. <Box
  363. w="100%"
  364. flex="1"
  365. bgColor={configs?.theme === 'dark' ? 'gray.700' : 'gray.100'}
  366. ref={ref_result_box}
  367. >
  368. <List
  369. width={'100%'}
  370. height={ref_result_box.current ? ref_result_box.current.clientHeight : 0}
  371. itemCount={find_positions.length}
  372. itemSize={28}
  373. >
  374. {ResultRow}
  375. </List>
  376. </Box>
  377. <HStack
  378. w="100%"
  379. py={2}
  380. px={4}
  381. spacing={4}
  382. // justifyContent="flex-end"
  383. >
  384. {is_searching ? (
  385. <Spinner />
  386. ) : (
  387. <span>
  388. {i18n.trans(find_positions.length > 1 ? 'items_found' : 'item_found', [
  389. find_positions.length.toLocaleString(),
  390. ])}
  391. </span>
  392. )}
  393. <Spacer />
  394. <Button
  395. size="sm"
  396. variant="outline"
  397. isDisabled={is_searching || find_positions.length === 0}
  398. onClick={replaceAll}
  399. >
  400. {lang.replace_all}
  401. </Button>
  402. <Button
  403. size="sm"
  404. variant="solid"
  405. colorScheme="blue"
  406. isDisabled={is_searching || find_positions.length === 0 || !can_replace}
  407. onClick={replaceOne}
  408. >
  409. {lang.replace}
  410. </Button>
  411. <ButtonGroup
  412. size="sm"
  413. isAttached
  414. variant="outline"
  415. isDisabled={is_searching || find_positions.length === 0}
  416. >
  417. <IconButton
  418. aria-label="previous"
  419. icon={<IoArrowBackOutline />}
  420. onClick={() => {
  421. let idx = current_result_idx - 1
  422. if (idx < 0) idx = 0
  423. setCurrentResultIdx(idx)
  424. }}
  425. isDisabled={current_result_idx <= 0}
  426. />
  427. <IconButton
  428. aria-label="next"
  429. icon={<IoArrowForwardOutline />}
  430. onClick={() => {
  431. let idx = current_result_idx + 1
  432. if (idx > find_positions.length - 1) idx = find_positions.length - 1
  433. setCurrentResultIdx(idx)
  434. }}
  435. isDisabled={current_result_idx >= find_positions.length - 1}
  436. />
  437. </ButtonGroup>
  438. </HStack>
  439. </VStack>
  440. </div>
  441. )
  442. }
  443. export default find