Browse Source

Merge branch 'feature/search' into develop

oldj 4 years ago
parent
commit
9f6458dafe
38 changed files with 1235 additions and 20 deletions
  1. 87 0
      package-lock.json
  2. 5 1
      package.json
  3. 16 1
      src/common/i18n/languages/en.ts
  4. 16 1
      src/common/i18n/languages/zh.ts
  5. 33 0
      src/common/types.d.ts
  6. 6 0
      src/common/utils/wait.ts
  7. 9 0
      src/main/actions/cmd/focusMainWindow.ts
  8. 1 1
      src/main/actions/config/update.ts
  9. 27 0
      src/main/actions/find/addHistory.ts
  10. 27 0
      src/main/actions/find/addReplaceHistory.ts
  11. 51 0
      src/main/actions/find/findBy.ts
  12. 59 0
      src/main/actions/find/findPositionsInContent.ts
  13. 11 0
      src/main/actions/find/getHistory.ts
  14. 10 0
      src/main/actions/find/getReplaceHistory.ts
  15. 16 0
      src/main/actions/find/setHistory.ts
  16. 10 0
      src/main/actions/find/setReplaceHistory.ts
  17. 15 0
      src/main/actions/find/show.ts
  18. 34 0
      src/main/actions/find/splitContent.ts
  19. 10 0
      src/main/actions/index.ts
  20. 1 1
      src/main/actions/updateTrayTitle.ts
  21. 5 2
      src/main/main.ts
  22. 1 0
      src/main/types.d.ts
  23. 70 0
      src/main/ui/find.ts
  24. 8 0
      src/main/ui/menu.ts
  25. 1 1
      src/main/ui/tray/index.ts
  26. 0 0
      src/main/ui/tray/window.ts
  27. 50 1
      src/renderer/components/Editor/HostsEditor.tsx
  28. 2 0
      src/renderer/components/Editor/codemirror.less
  29. 14 9
      src/renderer/components/List/index.tsx
  30. 1 1
      src/renderer/components/Pref/index.tsx
  31. 74 0
      src/renderer/pages/find.less
  32. 476 0
      src/renderer/pages/find.tsx
  33. 6 0
      src/renderer/styles/fn.less
  34. 1 0
      src/renderer/styles/themes/dark.less
  35. 1 0
      src/renderer/styles/themes/light.less
  36. 1 1
      src/version.json
  37. 55 0
      test/main/findInContent.test.ts
  38. 25 0
      test/main/splitContent.test.ts

+ 87 - 0
package-lock.json

@@ -37,6 +37,8 @@
         "@types/node": "^14.14.35",
         "@types/react": "^17.0.3",
         "@types/react-dom": "^17.0.3",
+        "@types/react-virtualized-auto-sizer": "^1.0.0",
+        "@types/react-window": "^1.8.3",
         "@types/uuid": "^8.3.0",
         "@umijs/preset-react": "1.x",
         "@umijs/test": "^3.4.6",
@@ -62,6 +64,8 @@
         "react": "^16.8.6",
         "react-dom": "^16.8.6",
         "react-icons": "^4.2.0",
+        "react-virtualized-auto-sizer": "^1.0.5",
+        "react-window": "^1.8.6",
         "smooth-scroll-into-view-if-needed": "^1.1.32",
         "ts-node": "^9.1.1",
         "tsconfig-paths-webpack-plugin": "^3.5.1",
@@ -3876,6 +3880,24 @@
         "@types/react-router": "*"
       }
     },
+    "node_modules/@types/react-virtualized-auto-sizer": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz",
+      "integrity": "sha512-NMErdIdSnm2j/7IqMteRiRvRulpjoELnXWUwdbucYCz84xG9PHcoOrr7QfXwB/ku7wd6egiKFrzt/+QK4Imeeg==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-window": {
+      "version": "1.8.3",
+      "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.3.tgz",
+      "integrity": "sha512-Xf+IR2Zyiyh/6z1CM8kv1aQba3S3X/hBXt4tH+T9bDSIGwFhle0GZFZGTSU8nw2cUT3UNbCHDjhxVQVZPtE8cA==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/scheduler": {
       "version": "0.16.1",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz",
@@ -18970,6 +18992,36 @@
         "tween-functions": "^1.0.1"
       }
     },
+    "node_modules/react-virtualized-auto-sizer": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.5.tgz",
+      "integrity": "sha512-kivjYVWX15TX2IUrm8F1jaCEX8EXrpy3DD+u41WGqJ1ZqbljWpiwscV+VxOM1l7sSIM1jwi2LADjhhAJkJ9dxA==",
+      "dev": true,
+      "engines": {
+        "node": ">8.0.0"
+      },
+      "peerDependencies": {
+        "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0",
+        "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0"
+      }
+    },
+    "node_modules/react-window": {
+      "version": "1.8.6",
+      "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz",
+      "integrity": "sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.0.0",
+        "memoize-one": ">=3.1.1 <6"
+      },
+      "engines": {
+        "node": ">8.0.0"
+      },
+      "peerDependencies": {
+        "react": "^15.0.0 || ^16.0.0 || ^17.0.0",
+        "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0"
+      }
+    },
     "node_modules/read-config-file": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.0.0.tgz",
@@ -26668,6 +26720,24 @@
         "@types/react-router": "*"
       }
     },
+    "@types/react-virtualized-auto-sizer": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.0.tgz",
+      "integrity": "sha512-NMErdIdSnm2j/7IqMteRiRvRulpjoELnXWUwdbucYCz84xG9PHcoOrr7QfXwB/ku7wd6egiKFrzt/+QK4Imeeg==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
+    "@types/react-window": {
+      "version": "1.8.3",
+      "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.3.tgz",
+      "integrity": "sha512-Xf+IR2Zyiyh/6z1CM8kv1aQba3S3X/hBXt4tH+T9bDSIGwFhle0GZFZGTSU8nw2cUT3UNbCHDjhxVQVZPtE8cA==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/scheduler": {
       "version": "0.16.1",
       "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz",
@@ -38720,6 +38790,23 @@
         "tween-functions": "^1.0.1"
       }
     },
+    "react-virtualized-auto-sizer": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.5.tgz",
+      "integrity": "sha512-kivjYVWX15TX2IUrm8F1jaCEX8EXrpy3DD+u41WGqJ1ZqbljWpiwscV+VxOM1l7sSIM1jwi2LADjhhAJkJ9dxA==",
+      "dev": true,
+      "requires": {}
+    },
+    "react-window": {
+      "version": "1.8.6",
+      "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz",
+      "integrity": "sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.0.0",
+        "memoize-one": ">=3.1.1 <6"
+      }
+    },
     "read-config-file": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.0.0.tgz",

+ 5 - 1
package.json

@@ -27,7 +27,7 @@
     "mkdirp": "^1.0.4",
     "potdb": "^2.0.1",
     "uuid": "^8.3.2"
-  }, 
+  },
   "devDependencies": {
     "@babel/plugin-proposal-class-properties": "^7.13.0",
     "@babel/plugin-proposal-decorators": "^7.13.5",
@@ -47,6 +47,8 @@
     "@types/node": "^14.14.35",
     "@types/react": "^17.0.3",
     "@types/react-dom": "^17.0.3",
+    "@types/react-virtualized-auto-sizer": "^1.0.0",
+    "@types/react-window": "^1.8.3",
     "@types/uuid": "^8.3.0",
     "@umijs/preset-react": "1.x",
     "@umijs/test": "^3.4.6",
@@ -72,6 +74,8 @@
     "react": "^16.8.6",
     "react-dom": "^16.8.6",
     "react-icons": "^4.2.0",
+    "react-virtualized-auto-sizer": "^1.0.5",
+    "react-window": "^1.8.6",
     "smooth-scroll-into-view-if-needed": "^1.1.32",
     "ts-node": "^9.1.1",
     "tsconfig-paths-webpack-plugin": "^3.5.1",

+ 16 - 1
src/common/i18n/languages/en.ts

@@ -1,7 +1,6 @@
 /**
  * @author: oldj
  * @homepage: https://oldj.net
- * en
  */
 
 export default {
@@ -43,6 +42,10 @@ export default {
   fail: 'Fail!',
   feedback: 'Feedback',
   file: 'File',
+  find: 'Find',
+  find_all: 'Find all',
+  find_and_replace: 'Find and replace',
+  find_history: 'Find history',
   folder: 'Folder',
   front: 'Front',
   general: 'General',
@@ -65,11 +68,14 @@ export default {
   hours: 'hours',
   http_api_on: 'HTTP API on',
   http_api_on_desc: 'Runs on port {0}, can be used by third-party software such as Alfred to switch hosts',
+  ignore_case: 'Ignore case',
   import: 'Import',
   import_done: 'The import is complete.',
   import_fail: 'Import failed!',
   import_from_url: 'Import from URL',
   is_latest_version_inform: 'Great, you are running the latest version!',
+  item_found: '{0} item found.',
+  items_found: '{0} items found.',
   language: 'Language',
   last_refresh: 'Last refresh: ',
   latest_version_desc: 'The latest version is: {0}',
@@ -77,6 +83,7 @@ export default {
   lines: 'lines',
   loading: 'Loading...',
   local: 'Local',
+  match: 'Match',
   migrate_confirm: 'SwitchHosts v4.0 uses a new data storage format, do you want to migrate old data to the new format?',
   migrate_data: 'Migrate data',
   minimize: 'Minimize',
@@ -87,22 +94,28 @@ export default {
   never: 'Never',
   new: 'New',
   new_version_found: 'New version found',
+  next: 'Next',
   no_access_to_hosts: 'No permission to write to the Hosts file.',
   no_record: 'No record',
   password: 'Password',
   paste: 'Paste',
   port: 'Port',
   preferences: 'Preferences',
+  previous: 'Previous',
   protocol: 'Protocol',
   proxy: 'Proxy',
   quit: 'Quit',
   read_only: 'Read only',
   redo: 'Redo',
   refresh: 'Refresh',
+  regexp: 'Regular expression',
   reload: 'Reload',
   remote: 'Remote',
   remove_duplicate_records: 'Remove duplicate records',
   remove_duplicate_records_desc: 'If a domain points to multiple IPs, only the first one will take effect, and the following ones will be converted into comments.',
+  replace: 'Replace',
+  replace_all: 'Replace all',
+  replace_history: 'Replace history',
   reset_zoom: 'Reset zoom',
   search: 'Search',
   select_all: 'Select all',
@@ -122,6 +135,8 @@ export default {
   theme: 'Theme',
   theme_dark: 'Dark',
   theme_light: 'Light',
+  title: 'Title',
+  to_show_source: 'Double-click to show the source code.',
   toggle_developer_tools: 'Toggle Developer Tools',
   toggle_dock_icon: 'Toggle the dock icon',
   toggle_full_screen: 'Toggle full screen',

+ 16 - 1
src/common/i18n/languages/zh.ts

@@ -1,7 +1,6 @@
 /**
  * @author: oldj
  * @homepage: https://oldj.net
- * zh
  */
 
 import { LanguageDict } from '@root/common/types'
@@ -45,6 +44,10 @@ const lang: LanguageDict = {
   fail: '操作失败!',
   feedback: '意见反馈',
   file: '文件',
+  find: '查找',
+  find_all: '查找所有',
+  find_and_replace: '查找并替换',
+  find_history: '查找历史',
   folder: '文件夹',
   front: '前置',
   general: '通用',
@@ -67,11 +70,14 @@ const lang: LanguageDict = {
   hours: '小时',
   http_api_on: '开启 HTTP API',
   http_api_on_desc: '运行于 {0} 端口,可用于 Alfred 等第三方软件切换 hosts',
+  ignore_case: '忽略大小写',
   import: '导入',
   import_done: '导入已完成。',
   import_fail: '导入失败!',
   import_from_url: '从 URL 导入',
   is_latest_version_inform: '太棒了,你正在运行的是最新版本!',
+  item_found: '{0} 项匹配',
+  items_found: '{0} 项匹配',
   language: '语言',
   last_refresh: '最后刷新:',
   latest_version_desc: '最新的版本为:{0}',
@@ -79,6 +85,7 @@ const lang: LanguageDict = {
   lines: '行',
   loading: '加载中...',
   local: '本地',
+  match: '匹配',
   migrate_confirm: 'SwitchHosts v4.0 使用了新的数据存储格式,是否迁移旧数据为新格式?',
   migrate_data: '迁移数据',
   minimize: '最小化',
@@ -89,22 +96,28 @@ const lang: LanguageDict = {
   never: '从不',
   new: '新建',
   new_version_found: '发现新版本',
+  next: '下一个',
   no_access_to_hosts: '没有写入 Hosts 文件的权限。',
   no_record: '没有记录',
   password: '密码',
   paste: '粘贴',
   port: '端口',
   preferences: '选项',
+  previous: '上一个',
   protocol: '协议',
   proxy: '代理',
   quit: '退出',
   read_only: '只读',
   redo: '重做',
   refresh: '刷新',
+  regexp: '正则表达式',
   reload: '重载',
   remote: '远程',
   remove_duplicate_records: '移除重复的记录',
   remove_duplicate_records_desc: '如果一个域名指向多个 IP,只有第一条会生效,后面的将被转为注释。',
+  replace: '替换',
+  replace_all: '替换所有',
+  replace_history: '替换历史',
   reset_zoom: '重置缩放',
   search: '搜索',
   select_all: '全选',
@@ -124,6 +137,8 @@ const lang: LanguageDict = {
   theme: '主题',
   theme_dark: '夜间',
   theme_light: '明亮',
+  title: '标题',
+  to_show_source: '双击显示源代码。',
   toggle_developer_tools: '切换开发者工具',
   toggle_dock_icon: '显示/隐藏任务栏图标',
   toggle_full_screen: '切换全屏',

+ 33 - 0
src/common/types.d.ts

@@ -4,6 +4,7 @@
  * @homepage: https://oldj.net
  */
 
+import { HostsType } from '@root/common/data'
 import { MenuItemConstructorOptions } from 'electron'
 import { default as lang } from './i18n/languages/en'
 
@@ -20,3 +21,35 @@ export interface IPopupMenuOption {
   menu_id: string;
   items: IMenuItemOption[];
 }
+
+export interface IFindPosition {
+  start: number;
+  end: number;
+  line: number;
+  line_pos: number;
+  end_line: number;
+  end_line_pos: number;
+  before: string;
+  match: string;
+  after: string;
+}
+
+export interface IFindSpliter {
+  before: string;
+  match: string;
+  after: string;
+  replace?: string;
+}
+
+export interface IFindItem {
+  item_id: string;
+  item_title: string;
+  item_type: HostsType;
+  positions: IFindPosition[];
+  spliters: IFindSpliter[];
+}
+
+export type IFindShowSourceParam = IFindPosition & {
+  item_id: string;
+  [key: string]: any;
+}

+ 6 - 0
src/common/utils/wait.ts

@@ -0,0 +1,6 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+export default (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

+ 9 - 0
src/main/actions/cmd/focusMainWindow.ts

@@ -0,0 +1,9 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+export default () => {
+  global.main_win.show()
+  global.main_win.focus()
+}

+ 1 - 1
src/main/actions/config/update.ts

@@ -5,7 +5,7 @@
 
 import { updateTrayTitle } from '@main/actions'
 import { cfgdb } from '@main/data'
-import { makeMainMenu } from '@main/libs/menu'
+import { makeMainMenu } from '@main/ui/menu'
 import { ConfigsType } from '@root/common/default_configs'
 import * as http_api from '@main/http'
 

+ 27 - 0
src/main/actions/find/addHistory.ts

@@ -0,0 +1,27 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import getHistory from '@main/actions/find/getHistory'
+import setHistory, { IFindHistoryData } from '@main/actions/find/setHistory'
+
+const MAX_LENGTH = 20
+
+export default async (data: IFindHistoryData) => {
+  let history_all = await getHistory()
+
+  // remove old
+  history_all = history_all.filter(i => i.value !== data.value)
+
+  // insert new
+  history_all.push(data)
+
+  while (history_all.length > MAX_LENGTH) {
+    history_all.shift()
+  }
+
+  await setHistory(history_all)
+
+  return history_all
+}

+ 27 - 0
src/main/actions/find/addReplaceHistory.ts

@@ -0,0 +1,27 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import getReplaceHistory from '@main/actions/find/getReplaceHistory'
+import setReplaceHistory from '@main/actions/find/setReplaceHistory'
+
+const MAX_LENGTH = 20
+
+export default async (value: string) => {
+  let history_all = await getReplaceHistory()
+
+  // remove old
+  history_all = history_all.filter(v => v !== value)
+
+  // insert new
+  history_all.push(value)
+
+  while (history_all.length > MAX_LENGTH) {
+    history_all.shift()
+  }
+
+  await setReplaceHistory(history_all)
+
+  return history_all
+}

+ 51 - 0
src/main/actions/find/findBy.ts

@@ -0,0 +1,51 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import splitContent from '@main/actions/find/splitContent'
+import getContentOfHosts from '@main/actions/hosts/getContent'
+import { flatten } from '@root/common/hostsFn'
+import { IFindItem } from '@root/common/types'
+import findInContent from 'src/main/actions/find/findPositionsInContent'
+import { getList } from '../index'
+
+export interface IFindOptions {
+  is_regexp: boolean;
+  is_ignore_case: boolean;
+}
+
+export default async (keyword: string, options: IFindOptions): Promise<IFindItem[]> => {
+  console.log(keyword)
+  let result_items: IFindItem[] = []
+
+  let tree = await getList()
+  let items = flatten(tree)
+
+  let exp: RegExp
+  if (options.is_regexp) {
+    exp = new RegExp(keyword, options.is_ignore_case ? 'ig' : 'g')
+  } else {
+    let kw = keyword.replace(/([.^$([?*+])/ig, '\\$1')
+    exp = new RegExp(kw, options.is_ignore_case ? 'ig' : 'g')
+  }
+
+  for (let item of items) {
+    const item_type = item.type || 'local'
+    if (item_type === 'group' || item_type === 'folder') {
+      continue
+    }
+    let content = await getContentOfHosts(item.id)
+    let positions = findInContent(content, exp)
+
+    result_items.push({
+      item_title: item.title || '',
+      item_id: item.id,
+      item_type,
+      positions,
+      spliters: splitContent(content, positions),
+    })
+  }
+
+  return result_items
+}

+ 59 - 0
src/main/actions/find/findPositionsInContent.ts

@@ -0,0 +1,59 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { IFindPosition } from '@root/common/types'
+
+type MatchResult = Pick<IFindPosition, 'start' | 'end' | 'before' | 'match' | 'after' | 'line' | 'line_pos' | 'end_line' | 'end_line_pos'>
+
+export default (content: string, exp: RegExp): MatchResult[] => {
+  let result_items: MatchResult[] = []
+
+  let m = content.match(exp)
+  if (!m) {
+    return []
+  }
+
+  let line = 1
+  let start = 0
+
+  let cnt = content
+  for (let i of m) {
+    let idx = cnt.indexOf(i)
+    if (idx === -1) continue
+
+    let head = cnt.slice(0, idx)
+    cnt = cnt.slice(idx + i.length)
+
+    let head_lines = head.split('\n')
+    line += head_lines.length - 1
+    start += head.length
+    let before_lines = content.slice(0, start).split('\n')
+    let before = before_lines[before_lines.length - 1]
+    let after = cnt.split('\n')[0]
+
+    let i_ln = i.split('\n')
+    let end_line = line + i_ln.length - 1
+    let end_line_pos = before.length + i.length
+    if (i_ln.length > 1) {
+      end_line_pos = i_ln[i_ln.length - 1].length
+    }
+
+    result_items.push({
+      start,
+      end: start + i.length,
+      before,
+      match: i,
+      after,
+      line,
+      line_pos: before.length,
+      end_line,
+      end_line_pos,
+    })
+
+    start += i.length
+  }
+
+  return result_items
+}

+ 11 - 0
src/main/actions/find/getHistory.ts

@@ -0,0 +1,11 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { IFindHistoryData } from '@main/actions/find/setHistory'
+import { cfgdb } from '@main/data'
+
+export default async (): Promise<IFindHistoryData[]> => {
+  return await cfgdb.list.find_history.all() as IFindHistoryData[]
+}

+ 10 - 0
src/main/actions/find/getReplaceHistory.ts

@@ -0,0 +1,10 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { cfgdb } from '@main/data'
+
+export default async (): Promise<string[]> => {
+  return await cfgdb.list.replace_history.all() as string[]
+}

+ 16 - 0
src/main/actions/find/setHistory.ts

@@ -0,0 +1,16 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { cfgdb } from '@main/data'
+
+export interface IFindHistoryData {
+  value: string;
+  is_regexp: boolean;
+  is_ignore_case: boolean;
+}
+
+export default async (data: IFindHistoryData[]) => {
+  await cfgdb.list.find_history.set(data)
+}

+ 10 - 0
src/main/actions/find/setReplaceHistory.ts

@@ -0,0 +1,10 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { cfgdb } from '@main/data'
+
+export default async (data: string[]) => {
+  await cfgdb.list.replace_history.set(data)
+}

+ 15 - 0
src/main/actions/find/show.ts

@@ -0,0 +1,15 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { makeWindow } from '@main/ui/find'
+
+export default async () => {
+  if (!global.find_win) {
+    global.find_win = await makeWindow()
+  }
+
+  global.find_win?.show()
+  global.find_win?.focus()
+}

+ 34 - 0
src/main/actions/find/splitContent.ts

@@ -0,0 +1,34 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { IFindPosition, IFindSpliter } from '@root/common/types'
+
+type MatchResult = Pick<IFindPosition, 'start' | 'end' | 'match'> & {
+  [key: string]: any;
+}
+
+export default (content: string, find_results: MatchResult[]): IFindSpliter[] => {
+  let spliters: IFindSpliter[] = []
+
+  let last_end = 0
+  find_results.map((r, idx) => {
+    let { start, match } = r
+    let before = content.slice(last_end, start)
+    let after = ''
+
+    last_end += before.length + match.length
+    if (idx === find_results.length - 1) {
+      after = content.slice(last_end)
+    }
+
+    let spliter: IFindSpliter = {
+      before, after, match,
+    }
+
+    spliters.push(spliter)
+  })
+
+  return spliters
+}

+ 10 - 0
src/main/actions/index.ts

@@ -37,6 +37,7 @@ export { default as restoreItemFromTrashcan } from './trashcan/restoreItem'
 export { default as cmdGetHistoryList } from './cmd/getHistoryList'
 export { default as cmdDeleteHistory } from './cmd/deleteHistory'
 export { default as cmdClearHistory } from './cmd/clearHistory'
+export { default as cmdFocusMainWindow } from './cmd/focusMainWindow'
 
 export { default as openUrl } from './openUrl'
 export { default as showItemInFolder } from './showItemInFolder'
@@ -45,6 +46,15 @@ export { default as checkUpdate } from './checkUpdate'
 export { default as closeMainWindow } from './closeMainWindow'
 export { default as quit } from './quit'
 
+export { default as findShow } from './find/show'
+export { default as findBy } from './find/findBy'
+export { default as findAddHistory } from './find/addHistory'
+export { default as findGetHistory } from './find/getHistory'
+export { default as findSetHistory } from './find/setHistory'
+export { default as findAddReplaceHistory } from './find/addReplaceHistory'
+export { default as findGetReplaceHistory } from './find/getReplaceHistory'
+export { default as findSetReplaceHistory } from './find/setReplaceHistory'
+
 export { default as migrateCheck } from './migrate/checkIfMigration'
 export { default as migrateData } from './migrate/migrateData'
 export { default as exportData } from './migrate/export'

+ 1 - 1
src/main/actions/updateTrayTitle.ts

@@ -6,7 +6,7 @@
 
 import { getList } from '@main/actions/index'
 import { cfgdb } from '@main/data'
-import { tray } from '@main/tray'
+import { tray } from '@main/ui/tray'
 import { flatten } from '@root/common/hostsFn'
 
 export default async (show?: boolean, title?: string) => {

+ 5 - 2
src/main/main.ts

@@ -7,9 +7,10 @@ import * as http_api from '@main/http'
 import * as cron from '@main/libs/cron'
 import getIndex from '@main/libs/getIndex'
 import isDev from '@main/libs/isDev'
-import { makeMainMenu } from '@main/libs/menu'
+import { makeMainMenu } from '@main/ui/menu'
 import Tracer from '@main/libs/tracer'
-import '@main/tray'
+import '@main/ui/tray'
+import * as find from '@main/ui/find'
 import version from '@root/version.json'
 import { app, BrowserWindow } from 'electron'
 import windowStateKeeper from 'electron-window-state'
@@ -125,6 +126,8 @@ app.on('ready', async () => {
   if (http_api_on) {
     http_api.start()
   }
+
+  find.makeWindow()
 })
 
 app.on('window-all-closed', () => {

+ 1 - 0
src/main/types.d.ts

@@ -30,6 +30,7 @@ declare global {
       ua: string; // user agent
       session_id: string; // A random value, refreshed every time the app starts, used to identify different startup sessions.
       main_win: BrowserWindow;
+      find_win?: BrowserWindow | null;
       last_path?: string; // the last path opened by SwitchHosts
       tracer: Tracer;
       is_will_quit?: boolean;

+ 70 - 0
src/main/ui/find.ts

@@ -0,0 +1,70 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { broadcast } from '@main/core/agent'
+import getIndex from '@main/libs/getIndex'
+import isDev from '@main/libs/isDev'
+import { BrowserWindow } from 'electron'
+import path from 'path'
+
+const makeWindow = () => {
+  let win: BrowserWindow | null
+  win = new BrowserWindow({
+    // frame: false,
+    // titleBarStyle: 'hidden',
+    hasShadow: true,
+    // resizable: false,
+    // transparent: true,
+    width: 480,
+    height: 400,
+    minWidth: 300,
+    minHeight: 200,
+    maximizable: false,
+    minimizable: false,
+    skipTaskbar: true,
+    show: false,
+    autoHideMenuBar: true,
+    webPreferences: {
+      contextIsolation: true,
+      preload: path.join(__dirname, 'preload.js'),
+      spellcheck: true,
+    },
+  })
+
+  // win.setVisibleOnAllWorkspaces(true, {
+  //   visibleOnFullScreen: true,
+  // })
+
+  win.loadURL(`${getIndex()}#/find`)
+    .catch(e => console.error(e))
+
+  // win.on('blur', () => win?.hide())
+
+  win.on('close', (e: Electron.Event) => {
+    if (global.is_will_quit) {
+      win = null
+      global.find_win = null
+    } else {
+      e.preventDefault()
+      win?.hide()
+      broadcast('close_find')
+    }
+  })
+
+  if (isDev()) {
+    // Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready
+    win.webContents.once('dom-ready', () => {
+      win!.webContents.openDevTools()
+    })
+  }
+
+  global.find_win = win
+
+  return win
+}
+
+export {
+  makeWindow,
+}

+ 8 - 0
src/main/libs/menu.ts → src/main/ui/menu.ts

@@ -3,6 +3,7 @@
  * @blog https://oldj.net
  */
 
+import { findShow } from '@main/actions'
 import isDev from '@main/libs/isDev'
 import { BrowserWindow, Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron'
 import { I18N, LocaleName } from '@root/common/i18n'
@@ -102,6 +103,13 @@ export const makeMainMenu = (locale: LocaleName = 'en') => {
             broadcast('toggle_comment')
           },
         },
+        {
+          label: lang.find_and_replace,
+          accelerator: 'CommandOrControl+F',
+          click () {
+            findShow()
+          },
+        },
       ],
     },
     {

+ 1 - 1
src/main/tray/index.ts → src/main/ui/tray/index.ts

@@ -6,7 +6,7 @@
 
 import { configGet, configSet, updateTrayTitle } from '@main/actions'
 import { broadcast } from '@main/core/agent'
-import { makeWindow } from '@main/tray/window'
+import { makeWindow } from '@main/ui/tray/window'
 import { I18N } from '@root/common/i18n'
 import version from '@root/version.json'
 import { app, BrowserWindow, Menu, screen, Tray } from 'electron'

+ 0 - 0
src/main/tray/window.ts → src/main/ui/tray/window.ts


+ 50 - 1
src/renderer/components/Editor/HostsEditor.tsx

@@ -9,9 +9,12 @@ import StatusBar from '@renderer/components/StatusBar'
 import { actions, agent } from '@renderer/core/agent'
 import useOnBroadcast from '@renderer/core/useOnBroadcast'
 import { IHostsListObject } from '@root/common/data'
+import { IFindShowSourceParam } from '@root/common/types'
+import wait from '@root/common/utils/wait'
 import clsx from 'clsx'
 import CodeMirror from 'codemirror'
 import 'codemirror/addon/comment/comment'
+import 'codemirror/addon/selection/mark-selection'
 import lodash from 'lodash'
 import React, { useEffect, useRef, useState } from 'react'
 import modeHosts from './cm_hl'
@@ -36,6 +39,7 @@ const HostsEditor = (props: Props) => {
   const [is_read_only, setIsReadOnly] = useState(true)
   const [cm_editor, setCMEditor] = useState<CodeMirror.EditorFromTextArea | null>(null)
   const el_ref = useRef<HTMLTextAreaElement>(null)
+  const [find_params, setFindParams] = useState<IFindShowSourceParam | null>(null)
 
   const loadContent = async () => {
     if (!cm_editor) return
@@ -119,6 +123,12 @@ const HostsEditor = (props: Props) => {
     })
   }, [])
 
+  useEffect(() => {
+    if (find_params && find_params.item_id === hosts.id) {
+      setSelection(find_params)
+    }
+  }, [hosts, find_params])
+
   useOnBroadcast('editor:content_change', (new_content: string) => {
     if (new_content === content) return
     onChange(new_content)
@@ -131,6 +141,11 @@ const HostsEditor = (props: Props) => {
     loadContent()
   }, [hosts, hosts_data, cm_editor])
 
+  useOnBroadcast('hosts_refreshed_by_id', (id: string) => {
+    if (hosts.id !== '0' && id !== hosts.id) return
+    loadContent()
+  }, [hosts, hosts_data, cm_editor])
+
   useOnBroadcast('toggle_comment', toggleComment, [cm_editor, is_read_only])
 
   useOnBroadcast('set_hosts_on_status', () => {
@@ -139,10 +154,44 @@ const HostsEditor = (props: Props) => {
     }
   }, [hosts, cm_editor])
 
+  const setSelection = async (params: IFindShowSourceParam) => {
+    if (!cm_editor) return
+    let doc = cm_editor.getDoc()
+
+    doc.setSelection({
+      line: params.line - 1,
+      ch: params.line_pos,
+    }, {
+      line: params.end_line - 1,
+      ch: params.end_line_pos,
+    })
+
+    // console.log(doc.getSelection())
+    await wait(200)
+    if (!doc.getSelection()) {
+      await setSelection(params)
+    }
+    cm_editor.focus()
+  }
+
+  useOnBroadcast('show_source', async (params: IFindShowSourceParam) => {
+    if (!cm_editor) return
+
+    if (params.item_id !== hosts.id) {
+      setFindParams(params)
+      setTimeout(() => {
+        setFindParams(null)
+      }, 3000)
+      return
+    }
+
+    setSelection(params)
+  }, [hosts, cm_editor])
+
   return (
     <div className={styles.root}>
       <div
-        className={clsx(styles.editor, is_read_only && styles.read_only)}
+        className={clsx(styles.editor, is_read_only && styles.read_only_tag)}
       >
         <textarea
           ref={el_ref}

+ 2 - 0
src/renderer/components/Editor/codemirror.less

@@ -1,3 +1,5 @@
+@import "../../styles/common";
+
 :global {
   /* BASICS */
 

+ 14 - 9
src/renderer/components/List/index.tsx

@@ -13,6 +13,7 @@ import { actions, agent } from '@renderer/core/agent'
 import useOnBroadcast from '@renderer/core/useOnBroadcast'
 import { IHostsListObject } from '@root/common/data'
 import { findItemById, getNextSelectedItem, setOnStateOfItem } from '@root/common/hostsFn'
+import { IFindShowSourceParam } from '@root/common/types'
 import clsx from 'clsx'
 import React, { useEffect, useState } from 'react'
 import { BiChevronRight } from 'react-icons/bi'
@@ -34,20 +35,20 @@ const List = (props: Props) => {
   } = useModel('useHostsData')
   const { configs } = useModel('useConfigs')
   const { lang } = useModel('useI18n')
-  const [ show_list, setShowList ] = useState<IHostsListObject[]>([])
+  const [show_list, setShowList] = useState<IHostsListObject[]>([])
   const toast = useToast()
 
   useEffect(() => {
     if (!is_tray) {
-      setShowList([ {
+      setShowList([{
         id: '0',
         title: lang.system_hosts,
         is_sys: true,
-      }, ...hosts_data.list ])
+      }, ...hosts_data.list])
     } else {
-      setShowList([ ...hosts_data.list ])
+      setShowList([...hosts_data.list])
     }
-  }, [ hosts_data ])
+  }, [hosts_data])
 
   const onToggleItem = async (id: string, on: boolean) => {
     console.log(`toggle hosts #${id} as ${on ? 'on' : 'off'}`)
@@ -116,8 +117,8 @@ const List = (props: Props) => {
   }
 
   if (!is_tray) {
-    useOnBroadcast('toggle_item', onToggleItem, [ hosts_data ])
-    useOnBroadcast('write_hosts_to_system', writeHostsToSystem, [ hosts_data ])
+    useOnBroadcast('toggle_item', onToggleItem, [hosts_data])
+    useOnBroadcast('write_hosts_to_system', writeHostsToSystem, [hosts_data])
   } else {
     useOnBroadcast('tray:list_updated', loadHostsData)
   }
@@ -138,7 +139,7 @@ const List = (props: Props) => {
     if (next_hosts) {
       await setCurrentHosts(next_hosts)
     }
-  }, [ current_hosts, hosts_data ])
+  }, [current_hosts, hosts_data])
 
   useOnBroadcast('select_hosts', async (id: string, wait_ms: number = 0) => {
     let hosts = findItemById(hosts_data.list, id)
@@ -152,7 +153,7 @@ const List = (props: Props) => {
     }
 
     setCurrentHosts(hosts)
-  }, [ hosts_data ])
+  }, [hosts_data])
 
   useOnBroadcast('reload_list', loadHostsData)
 
@@ -165,6 +166,10 @@ const List = (props: Props) => {
     await writeHostsToSystem(list)
   })
 
+  useOnBroadcast('show_source', async (params: IFindShowSourceParam) => {
+    agent.broadcast('select_hosts', params.item_id)
+  })
+
   return (
     <div className={styles.root}>
       {/*<SystemHostsItem/>*/}

+ 1 - 1
src/renderer/components/Pref/index.tsx

@@ -62,7 +62,7 @@ const PreferencePanel = (props: Props) => {
       setColorMode(data.theme)
     }
 
-    agent.broadcast('config_updated')
+    agent.broadcast('config_updated', data)
   }
 
   useEffect(() => {

+ 74 - 0
src/renderer/pages/find.less

@@ -0,0 +1,74 @@
+@import "../styles/common";
+
+.root {
+  width: 100vw;
+  height: 100vh;
+}
+
+.result_row {
+  border-bottom: 1px solid var(--swh-border-color-0);
+  display: grid;
+  grid-template-columns: 1fr 20% 60px;
+  grid-column-gap: 4px;
+  line-height: 28px;
+  padding-left: 8px;
+  cursor: pointer;
+  user-select: none;
+
+  &.selected {
+    background: var(--swh-tree-selected-bg);
+  }
+
+  &.disabled {
+    color: var(--swh-font-color-weak);
+    text-decoration: line-through;
+  }
+
+  &.readonly {
+    color: var(--swh-font-color-weak);
+  }
+}
+
+.result_content {
+  //display: flex;
+
+  .code;
+  .ell;
+
+  span {
+    //display: inline-block;
+  }
+
+  span:first-child {
+    //max-width: 40%;
+    //.ell;
+  }
+}
+
+.result_title {
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+
+  span {
+    display: inline-block;
+    margin-left: 4px;
+    .ell;
+  }
+}
+
+.result_line {
+  color: var(--swh-font-color-weak);
+}
+
+.highlight {
+  background: var(--swh-highlight-bg);
+}
+
+.read_only {
+  //.read_only_tag;
+
+  font-size: 10px;
+  color: var(--swh-font-color-weak);
+  margin-right: 8px;
+}

+ 476 - 0
src/renderer/pages/find.tsx

@@ -0,0 +1,476 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import { useModel } from '@@/plugin-model/useModel'
+import {
+  Box,
+  Button,
+  ButtonGroup,
+  Checkbox,
+  HStack,
+  IconButton,
+  Input,
+  InputGroup,
+  InputLeftElement,
+  Spacer,
+  Spinner,
+  useColorMode,
+  VStack,
+} from '@chakra-ui/react'
+import ItemIcon from '@renderer/components/ItemIcon'
+import { actions, agent } from '@renderer/core/agent'
+import { PopupMenu } from '@renderer/core/PopupMenu'
+import useOnBroadcast from '@renderer/core/useOnBroadcast'
+import { HostsType } from '@root/common/data'
+import { IFindItem, IFindPosition, IFindShowSourceParam } from '@root/common/types'
+import { useDebounce } from 'ahooks'
+import clsx from 'clsx'
+import lodash from 'lodash'
+import React, { useEffect, useRef, useState } from 'react'
+import { IoArrowBackOutline, IoArrowForwardOutline, IoChevronDownOutline, IoSearch } from 'react-icons/io5'
+import AutoSizer from 'react-virtualized-auto-sizer'
+import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
+import scrollIntoView from 'smooth-scroll-into-view-if-needed'
+import styles from './find.less'
+
+interface Props {
+
+}
+
+interface IFindPositionShow extends IFindPosition {
+  item_id: string;
+  item_title: string;
+  item_type: HostsType;
+  index: number;
+  is_disabled?: boolean;
+  is_readonly?: boolean;
+}
+
+const find = (props: Props) => {
+  const { lang, i18n, setLocale } = useModel('useI18n')
+  const { configs, loadConfigs } = useModel('useConfigs')
+  const { colorMode, setColorMode } = useColorMode()
+  const [keyword, setKeyword] = useState('')
+  const [replace_to, setReplaceTo] = useState('')
+  const [is_regexp, setIsRegExp] = useState(false)
+  const [is_ignore_case, setIsIgnoreCase] = useState(false)
+  const [find_result, setFindResult] = useState<IFindItem[]>([])
+  const [find_positions, setFindPositions] = useState<IFindPositionShow[]>([])
+  const [is_searching, setIsSearching] = useState(false)
+  const [current_result_idx, setCurrentResultIdx] = useState(0)
+  const [last_scroll_result_idx, setlastScrollResultIdx] = useState(-1)
+  const debounced_keyword = useDebounce(keyword, { wait: 500 })
+  const ipt_kw = useRef<HTMLInputElement>(null)
+
+  const init = async () => {
+    if (!configs) return
+
+    setLocale(configs.locale)
+
+    let theme = configs.theme
+    let cls = document.body.className
+    document.body.className = cls.replace(/\btheme-\w+/ig, '')
+    document.body.classList.add(`platform-${agent.platform}`, `theme-${theme}`)
+  }
+
+  useEffect(() => {
+    if (!configs) return
+    init().catch(e => console.error(e))
+    console.log(configs.theme)
+    if (colorMode !== configs.theme) {
+      setColorMode(configs.theme)
+    }
+  }, [configs])
+
+  useEffect(() => {
+    console.log(lang.find_and_replace)
+    document.title = lang.find_and_replace
+  }, [lang])
+
+  useEffect(() => {
+    doFind(debounced_keyword)
+  }, [debounced_keyword, is_regexp, is_ignore_case])
+
+  useEffect(() => {
+    const onFocus = () => {
+      if (ipt_kw.current) {
+        ipt_kw.current.focus()
+      }
+    }
+
+    window.addEventListener('focus', onFocus, false)
+    return () => window.removeEventListener('focus', onFocus, false)
+  }, [ipt_kw])
+
+  useOnBroadcast('config_updated', loadConfigs)
+
+  useOnBroadcast('close_find', () => {
+    console.log('on close find...')
+    setFindResult([])
+    setFindPositions([])
+    setKeyword('')
+    setReplaceTo('')
+    setIsRegExp(false)
+    setIsIgnoreCase(false)
+    setCurrentResultIdx(-1)
+    setlastScrollResultIdx(-1)
+  })
+
+  const parsePositionShow = (find_items: IFindItem[]) => {
+    let positions_show: IFindPositionShow[] = []
+
+    find_items.map((item) => {
+      let { item_id, item_title, item_type, positions } = item
+      positions.map((p, index) => {
+        positions_show.push({
+          item_id, item_title, item_type,
+          ...p,
+          index,
+          is_readonly: item_type !== 'local',
+        })
+      })
+    })
+
+    setFindPositions(positions_show)
+  }
+
+  const doFind = lodash.debounce(async (v: string) => {
+    console.log('find by:', v)
+    if (!v) {
+      setFindResult([])
+      return
+    }
+
+    setIsSearching(true)
+    let result = await actions.findBy(v, {
+      is_regexp,
+      is_ignore_case,
+    })
+    setCurrentResultIdx(0)
+    setlastScrollResultIdx(0)
+    setFindResult(result)
+    parsePositionShow(result)
+    setIsSearching(false)
+
+    await actions.findAddHistory({
+      value: v,
+      is_regexp,
+      is_ignore_case,
+    })
+  }, 500)
+
+  const toShowSource = async (result_item: IFindPositionShow) => {
+    // console.log(result_item)
+    await actions.cmdFocusMainWindow()
+    agent.broadcast('show_source', lodash.pick<IFindShowSourceParam>(result_item, [
+      'item_id', 'start', 'end', 'match',
+      'line', 'line_pos', 'end_line', 'end_line_pos',
+    ]))
+  }
+
+  const replaceOne = async () => {
+    let pos: IFindPositionShow = find_positions[current_result_idx]
+    if (!pos) return
+
+    setFindPositions([
+      ...find_positions.slice(0, current_result_idx),
+      {
+        ...pos,
+        is_disabled: true,
+      },
+      ...find_positions.slice(current_result_idx + 1),
+    ])
+
+    if (replace_to) {
+      actions.findAddReplaceHistory(replace_to)
+        .catch(e => console.error(e))
+    }
+
+    let r = find_result.find(i => i.item_id === pos.item_id)
+    if (!r) return
+    let spliters = r.spliters
+    let sp = spliters[pos.index]
+    if (!sp) return
+    sp.replace = replace_to
+
+    const content = spliters.map(sp => `${sp.before}${sp.replace ?? sp.match}${sp.after}`).join('')
+    await actions.setHostsContent(pos.item_id, content)
+    agent.broadcast('hosts_refreshed_by_id', pos.item_id)
+
+    if (current_result_idx < find_positions.length - 1) {
+      setCurrentResultIdx(current_result_idx + 1)
+    }
+  }
+
+  const replaceAll = async () => {
+    for (let item of find_result) {
+      let { item_id, item_type, spliters } = item
+      if (item_type !== 'local') continue
+      const content = spliters.map(sp => `${sp.before}${replace_to}${sp.after}`).join('')
+      await actions.setHostsContent(item_id, content)
+      agent.broadcast('hosts_refreshed_by_id', item_id)
+    }
+
+    setFindPositions(find_positions.map(pos => ({
+      ...pos,
+      is_disabled: !pos.is_readonly,
+    })))
+
+    if (replace_to) {
+      actions.findAddReplaceHistory(replace_to)
+        .catch(e => console.error(e))
+    }
+  }
+
+  const ResultRow = (row_data: ListChildComponentProps) => {
+    const data = find_positions[row_data.index]
+    const el = useRef<HTMLDivElement>(null)
+    const is_selected = current_result_idx === row_data.index
+
+    useEffect(() => {
+      if (el.current && is_selected && current_result_idx !== last_scroll_result_idx) {
+        setlastScrollResultIdx(current_result_idx)
+        scrollIntoView(el.current, {
+          behavior: 'smooth',
+          scrollMode: 'if-needed',
+        })
+      }
+    }, [el, current_result_idx, last_scroll_result_idx])
+
+    return (
+      <Box
+        style={row_data.style}
+        className={clsx(
+          styles.result_row,
+          is_selected && styles.selected,
+          data.is_disabled && styles.disabled,
+          data.is_readonly && styles.readonly,
+        )}
+        borderBottomWidth={1}
+        borderBottomColor={configs?.theme === 'dark' ? 'gray.600' : 'gray.200'}
+        onClick={() => {
+          setCurrentResultIdx(row_data.index)
+        }}
+        onDoubleClick={() => toShowSource(data)}
+        ref={el}
+        title={lang.to_show_source}
+      >
+        <div className={styles.result_content}>
+          {data.is_readonly ? <span className={styles.read_only}>{lang.read_only}</span> : null}
+          <span>
+            {data.before}
+          </span>
+          <span className={styles.highlight}>{data.match}</span>
+          <span>{data.after}</span>
+        </div>
+        <div className={styles.result_title}>
+          <ItemIcon type={data.item_type}/>
+          <span>{data.item_title}</span>
+        </div>
+        <div className={styles.result_line}>{data.line}</div>
+      </Box>
+    )
+  }
+
+  const showKeywordHistory = async () => {
+    let history = await actions.findGetHistory()
+    if (history.length === 0) return
+
+    let menu = new PopupMenu(history.reverse().map(i => ({
+      label: i.value,
+      click () {
+        setKeyword(i.value)
+        setIsRegExp(i.is_regexp)
+        setIsIgnoreCase(i.is_ignore_case)
+      }
+    })))
+
+    menu.show()
+  }
+
+  const showReplaceHistory = async () => {
+    let history = await actions.findGetReplaceHistory()
+    if (history.length === 0) return
+
+    let menu = new PopupMenu(history.reverse().map(v => ({
+      label: v,
+      click () {
+        setReplaceTo(v)
+      }
+    })))
+
+    menu.show()
+  }
+
+  let can_replace = true
+  if (current_result_idx > -1) {
+    let pos = find_positions[current_result_idx]
+    if (pos?.is_disabled || pos?.is_readonly) {
+      can_replace = false
+    }
+  }
+
+  return (
+    <div className={styles.root}>
+      <VStack
+        spacing={0}
+        h="100%"
+      >
+        <InputGroup>
+          <InputLeftElement
+            // pointerEvents="none"
+            children={
+              <HStack
+                spacing={0}
+              >
+                <IoSearch/>
+                <IoChevronDownOutline style={{ fontSize: 10 }}/>
+              </HStack>
+            }
+            onClick={showKeywordHistory}
+          />
+          <Input
+            autoFocus={true}
+            placeholder="keywords"
+            variant="flushed"
+            value={keyword}
+            onChange={(e) => {
+              setKeyword(e.target.value)
+            }}
+            ref={ipt_kw}
+          />
+        </InputGroup>
+
+        <InputGroup>
+          <InputLeftElement
+            // pointerEvents="none"
+            children={
+              <HStack
+                spacing={0}
+              >
+                <IoSearch/>
+                <IoChevronDownOutline style={{ fontSize: 10 }}/>
+              </HStack>
+            }
+            onClick={showReplaceHistory}
+          />
+          <Input
+            placeholder="replace to"
+            variant="flushed"
+            value={replace_to}
+            onChange={(e) => {
+              setReplaceTo(e.target.value)
+            }}
+          />
+        </InputGroup>
+
+        <HStack
+          w="100%"
+          py={2}
+          px={4}
+          spacing={4}
+          // justifyContent="flex-start"
+        >
+          <Checkbox
+            checked={is_regexp}
+            onChange={(e) => setIsRegExp(e.target.checked)}
+          >{lang.regexp}</Checkbox>
+          <Checkbox
+            checked={is_ignore_case}
+            onChange={(e) => setIsIgnoreCase(e.target.checked)}
+          >{lang.ignore_case}</Checkbox>
+        </HStack>
+
+        <Box
+          w="100%"
+          borderTopWidth={1}
+        >
+          <div className={styles.result_row}>
+            <div>{lang.match}</div>
+            <div>{lang.title}</div>
+            <div>{lang.line}</div>
+          </div>
+        </Box>
+
+        <Box
+          w="100%"
+          flex="1"
+          bgColor={configs?.theme === 'dark' ? 'gray.700' : 'gray.100'}
+        >
+          <AutoSizer>
+            {({ width, height }) => (
+              <List
+                width={width}
+                height={height}
+                itemCount={find_positions.length}
+                itemSize={28}
+              >
+                {ResultRow}
+              </List>
+            )}
+          </AutoSizer>
+        </Box>
+
+        <HStack
+          w="100%"
+          py={2}
+          px={4}
+          spacing={4}
+          // justifyContent="flex-end"
+        >
+          {is_searching ? (
+            <Spinner/>
+          ) : (
+            <span>{i18n.trans(
+              find_positions.length > 1 ? 'items_found' : 'item_found',
+              [find_positions.length.toLocaleString()],
+            )}</span>
+          )}
+          <Spacer/>
+          <Button
+            size="sm"
+            variant="outline"
+            isDisabled={is_searching || find_positions.length === 0}
+            onClick={replaceAll}
+          >{lang.replace_all}</Button>
+          <Button
+            size="sm"
+            variant="solid"
+            colorScheme="blue"
+            isDisabled={is_searching || find_positions.length === 0 || !can_replace}
+            onClick={replaceOne}
+          >{lang.replace}</Button>
+
+          <ButtonGroup
+            size="sm"
+            isAttached variant="outline"
+            isDisabled={is_searching || find_positions.length === 0}
+          >
+            <IconButton
+              aria-label="previous" icon={<IoArrowBackOutline/>}
+              onClick={() => {
+                let idx = current_result_idx - 1
+                if (idx < 0) idx = 0
+                setCurrentResultIdx(idx)
+              }}
+              isDisabled={current_result_idx <= 0}
+            />
+            <IconButton
+              aria-label="next" icon={<IoArrowForwardOutline/>}
+              onClick={() => {
+                let idx = current_result_idx + 1
+                if (idx > find_positions.length - 1) idx = find_positions.length - 1
+                setCurrentResultIdx(idx)
+              }}
+              isDisabled={current_result_idx >= find_positions.length - 1}
+            />
+          </ButtonGroup>
+        </HStack>
+      </VStack>
+    </div>
+  )
+}
+
+export default find

+ 6 - 0
src/renderer/styles/fn.less

@@ -4,3 +4,9 @@
 .code {
   font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
 }
+
+.ell {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}

+ 1 - 0
src/renderer/styles/themes/dark.less

@@ -7,6 +7,7 @@
   --swh-font-color-weak: #999;
   --swh-font-color-reverse: #a7a7a7;
   --swh-border-radius: 4px;
+  --swh-highlight-bg: #993;
 
   //  top bar
   --swh-top-bar-height: 40px;

+ 1 - 0
src/renderer/styles/themes/light.less

@@ -7,6 +7,7 @@
   --swh-font-color-weak: #999;
   --swh-font-color-reverse: #fff;
   --swh-border-radius: 4px;
+  --swh-highlight-bg: #ee0;
 
   //  top bar
   --swh-top-bar-height: 40px;

+ 1 - 1
src/version.json

@@ -1 +1 @@
-[4, 0, 0, 6029]
+[4, 0, 1, 6030]

+ 55 - 0
test/main/findInContent.test.ts

@@ -0,0 +1,55 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import assert = require('assert')
+import { default as findInContent } from 'src/main/actions/find/findPositionsInContent'
+
+describe('find in content test', () => {
+  it('basic test 1', () => {
+    let content = `abc12 abc123 abc`
+    let m = findInContent(content, /bc/ig)
+    assert(m.length === 3)
+    assert(m[0].line === 1)
+    assert(m[0].start === 1)
+    assert(m[0].end === 3)
+    assert(m[0].before === 'a')
+    assert(m[0].match === 'bc')
+    assert(typeof m[0].after === 'string')
+
+    assert(m[1].line === 1)
+    assert(m[1].start === 7)
+    assert(m[1].end === 9)
+    assert(m[1].before === 'abc12 a')
+    assert(m[1].match === 'bc')
+    assert(m[1].after === '123 abc')
+
+    assert(m[2].line === 1)
+    assert(m[2].start === 14)
+    assert(m[2].end === 16)
+    assert(m[2].before === 'abc12 abc123 a')
+    assert(m[2].match === 'bc')
+    assert(m[2].after === '')
+  })
+
+  it('basic test 2', () => {
+    let content = `abc12 abc123 abc\nxyza3b`
+    let m = findInContent(content, /a\w*3/ig)
+    // console.log(m)
+    assert(m.length === 2)
+    assert(m[0].line === 1)
+    assert(m[0].start === 6)
+    assert(m[0].end === 12)
+    assert(m[0].before === 'abc12 ')
+    assert(m[0].match === 'abc123')
+    assert(m[0].after === ' abc')
+
+    assert(m[1].line === 2)
+    assert(m[1].start === 20)
+    assert(m[1].end === 22)
+    assert(m[1].before === 'xyz')
+    assert(m[1].match === 'a3')
+    assert(m[1].after === 'b')
+  })
+})

+ 25 - 0
test/main/splitContent.test.ts

@@ -0,0 +1,25 @@
+/**
+ * @author: oldj
+ * @homepage: https://oldj.net
+ */
+
+import assert = require('assert')
+import { default as findInContent } from 'src/main/actions/find/findPositionsInContent'
+import { default as splitContent } from 'src/main/actions/find/splitContent'
+
+describe('split content test', () => {
+  it('basic test 1', () => {
+    let content = `abc12 abc123 abc44`
+    let m = findInContent(content, /bc/ig)
+    let sp = splitContent(content, m)
+    assert(sp[0].before === 'a')
+    assert(sp[0].after === '')
+    assert(sp[1].before === '12 a')
+    assert(sp[1].after === '')
+    assert(sp[2].before === '123 a')
+    assert(sp[2].after === '44')
+
+    let r = sp.map(i => `${i.before}${i.match}${i.after}`).join('')
+    assert(r === content)
+  })
+})