mirror of
https://github.com/purocean/yn.git
synced 2026-08-30 17:47:18 +08:00
feat: add advance search
This commit is contained in:
@@ -64,6 +64,7 @@ jobs:
|
||||
- name: Electron-Mac
|
||||
if: matrix.platform == 'mac'
|
||||
run: |
|
||||
sh ./scripts/download-ripgrep.sh
|
||||
yarn run electron-builder --${{ matrix.platform }} --x64 -p never | sed 's/identityName=.*$//'
|
||||
find ./out -regex '.*app.asar.unpacked/node_modules/node-pty/build/Release/pty.node$' | grep pty.node
|
||||
mv out/latest-mac.yml out/latest-mac-x64.yml
|
||||
|
||||
+3
-1
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "^2.0.1",
|
||||
"@vscode/ripgrep": "^1.14.2",
|
||||
"adm-zip": "^0.5.9",
|
||||
"command-exists": "^1.2.9",
|
||||
"dayjs": "^1.10.5",
|
||||
@@ -47,6 +48,7 @@
|
||||
"pako": "^2.0.4",
|
||||
"plantuml-pipe": "^1.4.0",
|
||||
"request": "^2.88.2",
|
||||
"ripgrep-wrapper": "^1.1.1",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"socket.io": "^2.4.0",
|
||||
"socks-proxy-agent": "^6.1.1",
|
||||
@@ -72,7 +74,7 @@
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"@types/mime": "^2.0.3",
|
||||
"@types/node": "^15.12.2",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/pako": "^1.0.3",
|
||||
"@types/request": "^2.48.5",
|
||||
"@types/socket.io-client": "^1.4.34",
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
# Runing only on Darwin
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NODE_MODULES_PATH="$(dirname "$0")/../node_modules"
|
||||
BIN_PATH="$(dirname "$0")/../bin"
|
||||
|
||||
# Download x64 ripgrep
|
||||
export npm_config_arch=x64
|
||||
node "$NODE_MODULES_PATH/@vscode/ripgrep/lib/postinstall.js" --force
|
||||
mv "$NODE_MODULES_PATH/@vscode/ripgrep/bin/rg" "$BIN_PATH/rg-darwin-x64"
|
||||
|
||||
# Download arm64 ripgrep
|
||||
export npm_config_arch=arm64
|
||||
node "$NODE_MODULES_PATH/@vscode/ripgrep/lib/postinstall.js" --force
|
||||
mv "$NODE_MODULES_PATH/@vscode/ripgrep/bin/rg" "$BIN_PATH/rg-darwin-arm64"
|
||||
@@ -59,6 +59,8 @@ export async function transformProtocolRequest (request: ProtocolRequest) {
|
||||
|
||||
const res = new ServerResponse(req)
|
||||
res.write = out.write.bind(out)
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
res.end = out.end.bind(out)
|
||||
|
||||
return { req, res, out }
|
||||
|
||||
@@ -9,6 +9,7 @@ import request from 'request'
|
||||
import { promisify } from 'util'
|
||||
import { STATIC_DIR, HOME_DIR, HELP_DIR, USER_PLUGIN_DIR, FLAG_DISABLE_SERVER, APP_NAME, USER_THEME_DIR, RESOURCES_DIR, BUILD_IN_STYLES, USER_EXTENSION_DIR } from '../constant'
|
||||
import * as file from './file'
|
||||
import * as search from './search'
|
||||
import run from './run'
|
||||
import convert from './convert'
|
||||
import plantuml from './plantuml'
|
||||
@@ -175,11 +176,9 @@ const attachment = async (ctx: any, next: any) => {
|
||||
}
|
||||
|
||||
const searchFile = async (ctx: any, next: any) => {
|
||||
if (ctx.path.startsWith('/api/search')) {
|
||||
const search = ctx.query.search
|
||||
const repo = ctx.query.repo
|
||||
|
||||
ctx.body = result('ok', 'success', await file.search(repo, search))
|
||||
if (ctx.path.startsWith('/api/search') && ctx.method === 'POST') {
|
||||
const query = ctx.request.body.query
|
||||
ctx.body = await search.search(query)
|
||||
} else {
|
||||
await next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import os from 'os'
|
||||
import { ReadableStream } from 'stream/web'
|
||||
import { Readable } from 'node:stream'
|
||||
import { CancellationTokenSource, ITextQuery, TextSearchEngineAdapter } from 'ripgrep-wrapper'
|
||||
import { rgPath } from '@vscode/ripgrep'
|
||||
import { SearchMessage } from '../../share/typings'
|
||||
import { BIN_DIR } from '../constant'
|
||||
import { convertAppPath } from '../helper'
|
||||
|
||||
let rgDiskPath: string
|
||||
if (os.platform() === 'darwin') {
|
||||
rgDiskPath = BIN_DIR + '/rg-darwin-' + os.arch()
|
||||
} else {
|
||||
rgDiskPath = convertAppPath(rgPath)
|
||||
}
|
||||
|
||||
function isReadableEnded (stream: any) {
|
||||
if (stream.readableEnded === true) return true
|
||||
const rState = stream._readableState
|
||||
if (!rState || rState.errored) return false
|
||||
if (typeof rState?.ended !== 'boolean') return null
|
||||
return rState.ended
|
||||
}
|
||||
|
||||
/**
|
||||
* from https://github.com/nodejs/node/blob/ba67fe66eb7777d5055c785be153374843fc647e/lib/internal/streams/readable.js
|
||||
* @param {ReadableStream} readableStream
|
||||
* @param {{
|
||||
* highWaterMark? : number,
|
||||
* encoding? : string,
|
||||
* objectMode? : boolean,
|
||||
* signal? : AbortSignal,
|
||||
* }} [options]
|
||||
* @returns {Readable}
|
||||
*/
|
||||
function newStreamReadableFromReadableStream (readableStream: ReadableStream, options: any = {}) {
|
||||
const reader = readableStream.getReader()
|
||||
let closed = false
|
||||
|
||||
const readable = new Readable({
|
||||
...options,
|
||||
read () {
|
||||
reader.read().then((chunk) => {
|
||||
if (chunk.done) {
|
||||
// Value should always be undefined here.
|
||||
readable.push(null)
|
||||
} else {
|
||||
readable.push(chunk.value)
|
||||
}
|
||||
},
|
||||
(error) => readable.destroy(error))
|
||||
},
|
||||
|
||||
destroy (error, callback) {
|
||||
function done () {
|
||||
try {
|
||||
callback(error)
|
||||
} catch (error) {
|
||||
// In a next tick because this is happening within
|
||||
// a promise context, and if there are any errors
|
||||
// thrown we don't want those to cause an unhandled
|
||||
// rejection. Let's just escape the promise and
|
||||
// handle it separately.
|
||||
process.nextTick(() => { throw error })
|
||||
}
|
||||
}
|
||||
|
||||
if (!closed) {
|
||||
reader.cancel().then(done, done)
|
||||
return
|
||||
}
|
||||
done()
|
||||
},
|
||||
})
|
||||
|
||||
reader.closed.then(
|
||||
() => {
|
||||
closed = true
|
||||
if (!isReadableEnded(readable)) { readable.push(null) }
|
||||
},
|
||||
(error) => {
|
||||
closed = true
|
||||
readable.destroy(error)
|
||||
}
|
||||
)
|
||||
|
||||
return readable
|
||||
}
|
||||
|
||||
export async function search (query: ITextQuery) {
|
||||
const cts = new CancellationTokenSource()
|
||||
const cancel = () => {
|
||||
cts.cancel()
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start (controller) {
|
||||
if (cts.token.isCancellationRequested) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const _enqueue = (chunk: any) => {
|
||||
if (cts.token.isCancellationRequested) {
|
||||
close()
|
||||
} else {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
const enqueue = <T extends 'result' | 'message' | 'done' | 'error'> (type: T, payload: SearchMessage<T>['payload']) => {
|
||||
const message: SearchMessage<T> = { type, payload }
|
||||
_enqueue(JSON.stringify(message) + '\n')
|
||||
}
|
||||
|
||||
const adapter = new TextSearchEngineAdapter(rgDiskPath, query)
|
||||
|
||||
adapter.search(cts.token, (res) => {
|
||||
enqueue('result', res)
|
||||
}, message => {
|
||||
enqueue('message', message)
|
||||
}).then((success) => {
|
||||
enqueue('done', success)
|
||||
_enqueue(null)
|
||||
close()
|
||||
}, (err) => {
|
||||
enqueue('error', err)
|
||||
_enqueue(null)
|
||||
close()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Readable.fromWeb only available in node 17
|
||||
// const result = Readable.fromWeb(stream)
|
||||
const result = newStreamReadableFromReadableStream(stream)
|
||||
|
||||
result.once('close', cancel)
|
||||
result.once('error', cancel)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -7,12 +7,15 @@
|
||||
<svg-icon v-else name="list" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showOutline">
|
||||
<template v-if="!showOutline">
|
||||
<div class="btn flat" @click="showSortMenu()" :title="$t(('tree.sort.by-' + treeSort.by) as any, $t(('tree.sort.' + treeSort.order) as any))">
|
||||
<svg-icon v-if="treeSort.order === 'asc'" name="arrow-up-wide-short-solid" />
|
||||
<svg-icon v-else name="arrow-down-short-wide-solid" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn flat" @click="findInFolder()" :title="$t('search-panel.search-files') + ' ' + getKeysLabel('tree.find-in-folder')">
|
||||
<svg-icon name="search-solid" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="title">{{$t(showOutline ? 'outline' : 'files')}}</div>
|
||||
<div class="btns" v-if="navigation">
|
||||
@@ -37,11 +40,12 @@ import { registerAction, removeAction } from '@fe/core/action'
|
||||
import { getSchema, Schema } from '@fe/services/control-center'
|
||||
import { useContextMenu } from '@fe/support/ui/context-menu'
|
||||
import type { AppState } from '@fe/support/store'
|
||||
import SvgIcon from './SvgIcon.vue'
|
||||
import { useI18n } from '@fe/services/i18n'
|
||||
import { toggleOutline } from '@fe/services/layout'
|
||||
import { findInFolder } from '@fe/services/tree'
|
||||
import { getKeysLabel } from '@fe/core/command'
|
||||
import { FileSort } from '@fe/types'
|
||||
import type { FileSort } from '@fe/types'
|
||||
import SvgIcon from './SvgIcon.vue'
|
||||
|
||||
const store = useStore<AppState>()
|
||||
const navigation = ref<Schema['navigation']>()
|
||||
|
||||
@@ -46,10 +46,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import { computed, defineComponent, nextTick, onMounted, ref, toRefs, watch } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import * as api from '@fe/support/api'
|
||||
import { useI18n } from '@fe/services/i18n'
|
||||
import fuzzyMatch from '@fe/others/fuzzy-match'
|
||||
import { fetchSettings } from '@fe/services/setting'
|
||||
@@ -79,20 +77,16 @@ export default defineComponent({
|
||||
const refFilepath = ref<HTMLElement[]>([])
|
||||
const markedFiles = ref<PathItem[]>(markedFilesCache)
|
||||
|
||||
const { currentRepo, recentOpenTime, tree } = toRefs(store.state)
|
||||
const { recentOpenTime, tree } = toRefs(store.state)
|
||||
|
||||
const selected = ref<any>(null)
|
||||
const searchText = ref('')
|
||||
const currentTab = ref<TabKey>(lastTab)
|
||||
const list = ref<any>([])
|
||||
const lastFetchTime = ref(0)
|
||||
|
||||
const repo = computed(() => currentRepo.value?.name)
|
||||
|
||||
const tabs = computed(() => {
|
||||
const arr: {key: TabKey; label: string}[] = [
|
||||
{ key: 'file', label: t('quick-open.files') },
|
||||
{ key: 'search', label: t('quick-open.search') },
|
||||
]
|
||||
|
||||
if (props.withMarked) {
|
||||
@@ -168,20 +162,6 @@ export default defineComponent({
|
||||
return sortList(arr).slice(0, 70)
|
||||
})
|
||||
|
||||
const searchWithDebounce = debounce(async (text: string, call: Function) => {
|
||||
if (repo.value && text.trim()) {
|
||||
const fetchTime = new Date().getTime()
|
||||
lastFetchTime.value = fetchTime
|
||||
const data = await api.search(repo.value, text.trim())
|
||||
// ensure last result be ahead of list.
|
||||
if (fetchTime >= lastFetchTime.value) {
|
||||
call(data)
|
||||
}
|
||||
} else {
|
||||
call([])
|
||||
}
|
||||
}, 500)
|
||||
|
||||
function highlightText (search: string) {
|
||||
if (refFilename.value && refFilepath.value) {
|
||||
search = search.toLowerCase()
|
||||
@@ -221,15 +201,6 @@ export default defineComponent({
|
||||
list.value = files.value
|
||||
} else if (currentTab.value === 'marked') {
|
||||
list.value = markedFiles.value
|
||||
} else if (currentTab.value === 'search') {
|
||||
const keyword = searchText.value.trim()
|
||||
list.value = keyword ? null : []
|
||||
|
||||
searchWithDebounce(keyword, (data: any[]) => {
|
||||
if (currentTab.value === 'search') {
|
||||
list.value = data
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
<template>
|
||||
<transition name="search-panel-wrapper">
|
||||
<div v-show="visible" class="search-panel-wrapper" @keydown.esc="close">
|
||||
<transition name="search-panel">
|
||||
<div v-if="visible" class="search-panel">
|
||||
<div class="title">{{$t('search-panel.search-files')}}</div>
|
||||
<div class="close-btn" @click="close" :title="$t('close')">
|
||||
<svg-icon class="close-btn-icon" name="times" width="8px" />
|
||||
</div>
|
||||
<div class="search">
|
||||
<div class="search-row">
|
||||
<textarea
|
||||
class="search-input search-pattern"
|
||||
ref="patternInputRef"
|
||||
v-model="pattern"
|
||||
type="text"
|
||||
rows="1"
|
||||
v-up-down-history
|
||||
v-placeholder="{
|
||||
blur: $t('search-panel.placeholder-search'),
|
||||
focus: $t('search-panel.placeholder-search') + ' ' + $t('search-panel.for-history')
|
||||
}"
|
||||
v-auto-resize="{ maxRows: 6, minRows: 1 }"
|
||||
@keydown.enter.prevent="onKeydownEnter"
|
||||
/>
|
||||
<div class="option-btns">
|
||||
<div
|
||||
:class="{'option-btn': true, active: option.isCaseSensitive}"
|
||||
:title="$t('search-panel.match-case')"
|
||||
@click="toggleOption('isCaseSensitive')
|
||||
">
|
||||
<svg-icon name="codicon-case-sensitive" width="15px" />
|
||||
</div>
|
||||
<div
|
||||
:class="{'option-btn': true, active: option.isWordMatch}"
|
||||
:title="$t('search-panel.match-whole-word')"
|
||||
@click="toggleOption('isWordMatch')"
|
||||
>
|
||||
<svg-icon name="codicon-whole-word" width="15px" />
|
||||
</div>
|
||||
<div
|
||||
:class="{'option-btn': true, active: option.isRegExp}"
|
||||
:title="$t('search-panel.use-regex')"
|
||||
@click="toggleOption('isRegExp')"
|
||||
>
|
||||
<svg-icon name="codicon-regex" width="15px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-input-label">{{$t('search-panel.files-to-include')}}</div>
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
v-model="include"
|
||||
@keydown.enter.prevent="onKeydownEnter"
|
||||
v-up-down-history
|
||||
v-placeholder="{
|
||||
blur: '',
|
||||
focus: 'e.g. foo/**/include ' + $t('search-panel.for-history')
|
||||
}"
|
||||
/>
|
||||
<div class="search-input-label">{{$t('search-panel.files-to-exclude')}}</div>
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
v-model="exclude"
|
||||
@keydown.enter.prevent="onKeydownEnter"
|
||||
v-up-down-history
|
||||
v-placeholder="{
|
||||
blur: '',
|
||||
focus: 'e.g. bar/**/exclude ' + $t('search-panel.for-history')
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="message-wrapper">
|
||||
<div class="message">{{message}}</div>
|
||||
<a v-show="loading" class="action-btn" href="javascript:void(0)" @click="stop">{{$t('cancel')}}</a>
|
||||
</div>
|
||||
<div class="results" v-if="result.length > 0">
|
||||
<details class="item" v-for="item in result" :key="item.path" open>
|
||||
<summary :title="item.path">
|
||||
<div class="item-info">
|
||||
<span class="item-name">{{basename(item.path)}}</span>
|
||||
<span class="item-dir">{{dirname(item.path)}}</span>
|
||||
</div>
|
||||
<div class="item-count">{{item.numMatches}}</div>
|
||||
</summary>
|
||||
<div class="matches">
|
||||
<div
|
||||
:class="{match: true, active: currentItemKey === match.key}"
|
||||
v-for="match of (item.results as any)"
|
||||
:key="match.key"
|
||||
@click="chooseMatch(item as any, match, 0)"
|
||||
>
|
||||
<component
|
||||
v-for="(fragment, i) in markText(match.preview.text, match.preview.matches)"
|
||||
:key="i"
|
||||
:is="fragment.type">{{fragment.value}}</component>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
import type { ISearchRange, ISerializedFileMatch, ISerializedSearchSuccess, ITextQuery, ITextSearchMatch } from 'ripgrep-wrapper'
|
||||
import { getLogger, sleep } from '@fe/utils'
|
||||
import { basename, dirname, join, relative } from '@fe/utils/path'
|
||||
import { registerAction, removeAction } from '@fe/core/action'
|
||||
import { CtrlCmd, Shift } from '@fe/core/command'
|
||||
import * as api from '@fe/support/api'
|
||||
import store from '@fe/support/store'
|
||||
import { useToast } from '@fe/support/ui/toast'
|
||||
import { switchDoc } from '@fe/services/document'
|
||||
import { getIsDefault, highlightLine } from '@fe/services/editor'
|
||||
import SvgIcon from './SvgIcon.vue'
|
||||
import { useI18n } from '@fe/services/i18n'
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
const logger = getLogger('search-panel')
|
||||
const toast = useToast()
|
||||
useI18n()
|
||||
|
||||
const patternInputRef = ref<HTMLInputElement>()
|
||||
const pattern = ref('')
|
||||
const include = ref('')
|
||||
const exclude = ref('')
|
||||
const option = reactive({
|
||||
isRegExp: false,
|
||||
isWordMatch: false,
|
||||
isCaseSensitive: false,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const result = shallowRef<(ISerializedFileMatch)[]>([])
|
||||
const success = shallowRef<ISerializedSearchSuccess | null>(null)
|
||||
const currentItemKey = ref('')
|
||||
const visible = ref(false)
|
||||
|
||||
const message = computed(() => {
|
||||
if (result.value.length === 0) {
|
||||
return success.value ? 'No results found' : ''
|
||||
}
|
||||
|
||||
const results = result.value.reduce((acc, cur) => acc + (cur.numMatches || 0), 0)
|
||||
|
||||
if (success?.value?.limitHit) {
|
||||
return `${results} results (limited) in ${result.value.length} files`
|
||||
} else {
|
||||
return `${results} results in ${result.value.length} files`
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(async () => {
|
||||
if (visible.value) {
|
||||
await nextTick()
|
||||
patternInputRef.value?.focus()
|
||||
patternInputRef.value?.select()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => store.state.currentRepo, () => {
|
||||
stop()
|
||||
result.value = []
|
||||
})
|
||||
|
||||
let controller: AbortController | null = null
|
||||
|
||||
async function stop () {
|
||||
logger.debug('stop')
|
||||
success.value = null
|
||||
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
sleep(100)
|
||||
controller = null
|
||||
}
|
||||
}
|
||||
|
||||
async function search () {
|
||||
const folder = store.state.currentRepo?.path
|
||||
const repo = store.state.currentRepo?.name
|
||||
if (!folder || !repo) {
|
||||
toast.show('warning', 'Please choose a repository first')
|
||||
return
|
||||
}
|
||||
|
||||
await stop()
|
||||
|
||||
if (!pattern.value) {
|
||||
loading.value = false
|
||||
result.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const buildGlobObject = (str: string) => {
|
||||
const obj: Record<string, boolean> = {}
|
||||
str.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(s => { obj[s] = true })
|
||||
return obj
|
||||
}
|
||||
|
||||
controller = new AbortController()
|
||||
const query: ITextQuery = {
|
||||
contentPattern: {
|
||||
pattern: pattern.value,
|
||||
isRegExp: option.isRegExp,
|
||||
isWordMatch: option.isWordMatch,
|
||||
isCaseSensitive: option.isCaseSensitive,
|
||||
isMultiline: pattern.value.includes('\n')
|
||||
},
|
||||
folderQueries: [
|
||||
{
|
||||
folder,
|
||||
includePattern: buildGlobObject('**/*.md,' + include.value),
|
||||
excludePattern: buildGlobObject(exclude.value),
|
||||
},
|
||||
],
|
||||
maxResults: MAX_RESULTS,
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
result.value = []
|
||||
const receiveResult = await api.search(controller, query)
|
||||
success.value = await receiveResult(
|
||||
(data) => {
|
||||
result.value = [
|
||||
...result.value,
|
||||
...data.map((item) => ({
|
||||
repo,
|
||||
numMatches: item.numMatches,
|
||||
results: (item.results!).map((match: any, i) => ({
|
||||
...match,
|
||||
key: `${item.path}:${i}`,
|
||||
})),
|
||||
path: join('/', relative(folder, item.path)),
|
||||
})),
|
||||
]
|
||||
},
|
||||
(data) => {
|
||||
logger.debug('onMessage', data)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOption (key: keyof typeof option) {
|
||||
option[key] = !option[key]
|
||||
search()
|
||||
}
|
||||
|
||||
function close () {
|
||||
visible.value = false
|
||||
stop()
|
||||
}
|
||||
|
||||
async function chooseMatch (result: ISerializedFileMatch & { repo: string }, match: ITextSearchMatch & { key: string }, idx: number) {
|
||||
const { path, repo } = result
|
||||
const range = (match.ranges as ISearchRange[])[idx]
|
||||
|
||||
if (!range) {
|
||||
return
|
||||
}
|
||||
|
||||
currentItemKey.value = match.key
|
||||
const lines: [number, number] = [
|
||||
range.startLineNumber + 1,
|
||||
range.endLineNumber + 1,
|
||||
]
|
||||
|
||||
logger.debug('chooseMatch', path, lines)
|
||||
|
||||
await switchDoc({ type: 'file', path, repo, name: basename(path) })
|
||||
if (getIsDefault()) {
|
||||
await sleep(100)
|
||||
highlightLine(lines, true, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydownEnter (e: KeyboardEvent) {
|
||||
if (e.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = e.target as HTMLInputElement
|
||||
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
const start = target.selectionStart
|
||||
const end = target.selectionEnd
|
||||
const content = target.value
|
||||
|
||||
if (start !== null && end !== null) {
|
||||
target.value = content.slice(0, start) + '\n' + content.slice(end)
|
||||
target.dispatchEvent(new Event('input'))
|
||||
}
|
||||
} else {
|
||||
search()
|
||||
}
|
||||
}
|
||||
|
||||
function markText (text: string, ranges: ISearchRange[]) {
|
||||
const lines = text.split('\n')
|
||||
const result: {type: 'span' | 'mark' | 'br', value?: string }[] = []
|
||||
|
||||
let lastLine = 0
|
||||
let lastColumn = 0
|
||||
for (const range of ranges) {
|
||||
const start = range.startLineNumber
|
||||
const end = range.endLineNumber
|
||||
const startOffset = range.startColumn
|
||||
const endOffset = range.endColumn
|
||||
|
||||
if (start < lastLine) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (start === lastLine && startOffset < lastColumn) {
|
||||
continue
|
||||
}
|
||||
|
||||
// process previous lines
|
||||
if (start > lastLine) {
|
||||
const lastTail = lines[lastLine].slice(lastColumn)
|
||||
lastTail && result.push({ type: 'span', value: lastTail })
|
||||
result.push({ type: 'br' })
|
||||
|
||||
const prevLines = lines.slice(lastLine + 1, start)
|
||||
prevLines.forEach((line) => {
|
||||
line && result.push({ type: 'span', value: line })
|
||||
result.push({ type: 'br' })
|
||||
})
|
||||
}
|
||||
|
||||
// process current range lines
|
||||
const currentStartLine = lines[start]
|
||||
const currentStartLinePrefix = currentStartLine.slice(0, startOffset)
|
||||
currentStartLinePrefix && result.push({ type: 'span', value: currentStartLine.slice(0, startOffset) })
|
||||
|
||||
if (start === end) {
|
||||
const startLineMarked = currentStartLine.slice(startOffset, endOffset)
|
||||
startLineMarked && result.push({ type: 'mark', value: startLineMarked })
|
||||
} else {
|
||||
const startLineMarked = currentStartLine.slice(startOffset)
|
||||
startLineMarked && result.push({ type: 'mark', value: startLineMarked })
|
||||
result.push({ type: 'br' })
|
||||
|
||||
const currentMiddleLines = lines.slice(start + 1, end)
|
||||
currentMiddleLines.forEach((line) => {
|
||||
line && result.push({ type: 'mark', value: line })
|
||||
result.push({ type: 'br' })
|
||||
})
|
||||
|
||||
const currentEndLine = lines[end]
|
||||
const endLineMarked = currentEndLine.slice(0, endOffset)
|
||||
endLineMarked && result.push({ type: 'mark', value: endLineMarked })
|
||||
}
|
||||
|
||||
lastLine = end
|
||||
lastColumn = endOffset
|
||||
}
|
||||
|
||||
if (lastLine < lines.length - 1) {
|
||||
const lastTail = lines[lastLine].slice(lastColumn)
|
||||
lastTail && result.push({ type: 'span', value: lastTail })
|
||||
result.push({ type: 'br' })
|
||||
|
||||
const restLines = lines.slice(lastLine + 1)
|
||||
restLines.forEach((line) => {
|
||||
line && result.push({ type: 'span', value: line })
|
||||
result.push({ type: 'br' })
|
||||
})
|
||||
}
|
||||
|
||||
// remove end br
|
||||
while (result[result.length - 1].type === 'br') {
|
||||
result.pop()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
registerAction({
|
||||
name: 'tree.find-in-folder',
|
||||
keys: [CtrlCmd, Shift, 'f'],
|
||||
handler: (path) => {
|
||||
visible.value = true
|
||||
if (path) {
|
||||
include.value = path.replace(/^\//, '')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeAction('tree.find-in-folder')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@fe/styles/mixins.scss';
|
||||
|
||||
.search-panel-wrapper {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(1.5px);
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s cubic-bezier(1, 0.29, 0.63, 0.94);
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
margin-top: 36px;
|
||||
background: var(--g-background-color);
|
||||
height: calc(100% - 36px);
|
||||
width: 100%;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s cubic-bezier(1, 0.38, 0.58, 0.97);
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
font-size: 14px;
|
||||
color: var(--g-color-20);
|
||||
flex: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--g-color-30);
|
||||
|
||||
&:hover {
|
||||
color: var(--g-color-0);
|
||||
background-color: var(--g-color-86);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 6px 4px;
|
||||
flex: none;
|
||||
|
||||
.search-input {
|
||||
font-size: 13px;
|
||||
padding: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--g-color-94);
|
||||
resize: none;
|
||||
outline: 1px solid var(--g-color-80);
|
||||
|
||||
&.search-pattern {
|
||||
padding-right: 68px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: var(--g-color-90);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-label {
|
||||
font-size: 12px;
|
||||
color: var(--g-color-30);
|
||||
margin-top: 6px;
|
||||
margin-bottom: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
position: relative;
|
||||
|
||||
.option-btns {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.option-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--g-color-30);
|
||||
margin-right: 2px;
|
||||
border-radius: var(--g-border-radius);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--g-color-80);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--g-color-10);
|
||||
background-color: var(--g-color-80);
|
||||
outline: 1px solid var(--g-color-70);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
padding: 0 4px;
|
||||
user-select: none;
|
||||
|
||||
.message {
|
||||
color: var(--g-color-30);
|
||||
overflow-wrap: break-word;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 4px 0 ;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
text-decoration: none;
|
||||
flex: none;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 40px);
|
||||
margin-top: 2px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--g-color-90);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
details.item {
|
||||
cursor: pointer;
|
||||
|
||||
& > summary {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
padding: 6px;
|
||||
color: var(--g-color-10);
|
||||
|
||||
&::-webkit-details-marker,
|
||||
&::marker {
|
||||
content: '';
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
content: url(data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgZm9jdXNhYmxlPSJmYWxzZSIgZGF0YS1wcmVmaXg9ImZhciIgZGF0YS1pY29uPSJjaGV2cm9uLWRvd24iIHJvbGU9ImltZyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgNDQ4IDUxMiIgPjxwYXRoIGZpbGw9IiM3YzdmODIiIGQ9Ik00NDEuOSAxNjcuM2wtMTkuOC0xOS44Yy00LjctNC43LTEyLjMtNC43LTE3IDBMMjI0IDMyOC4yIDQyLjkgMTQ3LjVjLTQuNy00LjctMTIuMy00LjctMTcgMEw2LjEgMTY3LjNjLTQuNyA0LjctNC43IDEyLjMgMCAxN2wyMDkuNCAyMDkuNGM0LjcgNC43IDEyLjMgNC43IDE3IDBsMjA5LjQtMjA5LjRjNC43LTQuNyA0LjctMTIuMyAwLTE3eiIgY2xhc3M9IiI+PC9wYXRoPjwvc3ZnPg==);
|
||||
margin-right: 4px;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--g-color-95);
|
||||
}
|
||||
|
||||
.item-info {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
.item-dir {
|
||||
color: var(--g-color-50);
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.item-count {
|
||||
flex: none;
|
||||
background-color: var(--g-color-90);
|
||||
line-height: 16px;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
margin-left: 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&[open] > summary::before {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
.matches {
|
||||
font-size: 16px;
|
||||
|
||||
.match {
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
box-sizing: border-box;
|
||||
padding-left: 20px;
|
||||
user-select: none;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 17px;
|
||||
font-size: 13px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 58px;
|
||||
color: var(--g-color-15);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--g-color-95);
|
||||
color: var(--g-color-0);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--g-color-90);
|
||||
color: var(--g-color-0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.search-panel-wrapper-leave-to,
|
||||
.search-panel-wrapper-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.search-panel-leave-to,
|
||||
.search-panel-enter-from {
|
||||
transform: translateY(80vh);
|
||||
}
|
||||
|
||||
mark {
|
||||
background: #fff8c5 !important;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
mark {
|
||||
background: #746900 !important;
|
||||
color: #ebebeb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -48,6 +48,10 @@ const icons: { [key: string]: string } = {
|
||||
'moon-solid': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 512"><path fill="currentColor" d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"/></svg>',
|
||||
'sun-solid': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM352 256c0 53-43 96-96 96s-96-43-96-96s43-96 96-96s96 43 96 96zm32 0c0-70.7-57.3-128-128-128s-128 57.3-128 128s57.3 128 128 128s128-57.3 128-128z"/></svg>',
|
||||
'circle-half-stroke-solid': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M448 256c0-106-86-192-192-192V448c106 0 192-86 192-192zm64 0c0 141.4-114.6 256-256 256S0 397.4 0 256S114.6 0 256 0S512 114.6 512 256z"/></svg>',
|
||||
'bolt-solid': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288H175.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5L349.4 44.6z"/></svg>',
|
||||
'codicon-regex': '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.012 2h.976v3.113l2.56-1.557.486.885L11.47 6l2.564 1.559-.485.885-2.561-1.557V10h-.976V6.887l-2.56 1.557-.486-.885L9.53 6 6.966 4.441l.485-.885 2.561 1.557V2zM2 10h4v4H2v-4z"/></svg>',
|
||||
'codicon-case-sensitive': '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M8.85352 11.7021H7.85449L7.03809 9.54297H3.77246L3.00439 11.7021H2L4.9541 4H5.88867L8.85352 11.7021ZM6.74268 8.73193L5.53418 5.4502C5.49479 5.34277 5.4554 5.1709 5.41602 4.93457H5.39453C5.35872 5.15299 5.31755 5.32487 5.271 5.4502L4.07324 8.73193H6.74268Z"/><path d="M13.756 11.7021H12.8752V10.8428H12.8537C12.4706 11.5016 11.9066 11.8311 11.1618 11.8311C10.6139 11.8311 10.1843 11.686 9.87273 11.396C9.56479 11.106 9.41082 10.721 9.41082 10.2412C9.41082 9.21354 10.016 8.61556 11.2262 8.44727L12.8752 8.21631C12.8752 7.28174 12.4974 6.81445 11.7419 6.81445C11.0794 6.81445 10.4815 7.04004 9.94793 7.49121V6.58887C10.4886 6.24512 11.1117 6.07324 11.8171 6.07324C13.1097 6.07324 13.756 6.75716 13.756 8.125V11.7021ZM12.8752 8.91992L11.5485 9.10254C11.1403 9.15983 10.8324 9.26188 10.6247 9.40869C10.417 9.55192 10.3132 9.80794 10.3132 10.1768C10.3132 10.4453 10.4081 10.6655 10.5978 10.8374C10.7912 11.0057 11.0472 11.0898 11.3659 11.0898C11.8027 11.0898 12.1626 10.9377 12.4455 10.6333C12.7319 10.3254 12.8752 9.93685 12.8752 9.46777V8.91992Z"/></svg>',
|
||||
'codicon-whole-word': '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M0 11H1V13H15V11H16V14H15H1H0V11Z"/><path d="M6.84048 11H5.95963V10.1406H5.93814C5.555 10.7995 4.99104 11.1289 4.24625 11.1289C3.69839 11.1289 3.26871 10.9839 2.95718 10.6938C2.64924 10.4038 2.49527 10.0189 2.49527 9.53906C2.49527 8.51139 3.10041 7.91341 4.3107 7.74512L5.95963 7.51416C5.95963 6.57959 5.58186 6.1123 4.82632 6.1123C4.16389 6.1123 3.56591 6.33789 3.03238 6.78906V5.88672C3.57307 5.54297 4.19612 5.37109 4.90152 5.37109C6.19416 5.37109 6.84048 6.05501 6.84048 7.42285V11ZM5.95963 8.21777L4.63297 8.40039C4.22476 8.45768 3.91682 8.55973 3.70914 8.70654C3.50145 8.84977 3.39761 9.10579 3.39761 9.47461C3.39761 9.74316 3.4925 9.96338 3.68228 10.1353C3.87564 10.3035 4.13166 10.3877 4.45035 10.3877C4.8872 10.3877 5.24706 10.2355 5.52994 9.93115C5.8164 9.62321 5.95963 9.2347 5.95963 8.76562V8.21777Z"/><path d="M9.3475 10.2051H9.32601V11H8.44515V2.85742H9.32601V6.4668H9.3475C9.78076 5.73633 10.4146 5.37109 11.2489 5.37109C11.9543 5.37109 12.5057 5.61816 12.9032 6.1123C13.3042 6.60286 13.5047 7.26172 13.5047 8.08887C13.5047 9.00911 13.2809 9.74674 12.8333 10.3018C12.3857 10.8532 11.7734 11.1289 10.9964 11.1289C10.2695 11.1289 9.71989 10.821 9.3475 10.2051ZM9.32601 7.98682V8.75488C9.32601 9.20964 9.47282 9.59635 9.76644 9.91504C10.0636 10.2301 10.4396 10.3877 10.8944 10.3877C11.4279 10.3877 11.8451 10.1836 12.1458 9.77539C12.4502 9.36719 12.6024 8.79964 12.6024 8.07275C12.6024 7.46045 12.4609 6.98063 12.1781 6.6333C11.8952 6.28597 11.512 6.1123 11.0286 6.1123C10.5166 6.1123 10.1048 6.29134 9.7933 6.64941C9.48177 7.00391 9.32601 7.44971 9.32601 7.98682Z"/></svg>',
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { App, nextTick } from 'vue'
|
||||
|
||||
export function install (app: App) {
|
||||
app.directive('auto-resize', {
|
||||
mounted (el, binding) {
|
||||
const { value } = binding
|
||||
const { minRows, maxRows } = value
|
||||
|
||||
const style = window.getComputedStyle(el)
|
||||
const lineHeight = parseFloat(style.lineHeight)
|
||||
const paddingTop = parseFloat(style.paddingTop)
|
||||
const paddingBottom = parseFloat(style.paddingBottom)
|
||||
|
||||
const min = minRows * lineHeight + paddingTop + paddingBottom
|
||||
const max = maxRows * lineHeight + paddingTop + paddingBottom
|
||||
|
||||
const resize = () => {
|
||||
el.style.height = 'auto'
|
||||
el.style.overflowY = 'auto'
|
||||
|
||||
const height = el.scrollHeight
|
||||
if (height < min) {
|
||||
el.style.height = min + 'px'
|
||||
el.style.overflowY = 'auto'
|
||||
} else if (height > max) {
|
||||
el.style.height = max + 'px'
|
||||
el.style.overflowY = 'auto'
|
||||
} else {
|
||||
el.style.height = height + 'px'
|
||||
el.style.overflowY = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('input', resize)
|
||||
nextTick(resize)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { App } from 'vue'
|
||||
import * as autoResize from './auto-resize'
|
||||
import * as placeholder from './placeholder'
|
||||
import * as upDownHistory from './up-down-history'
|
||||
|
||||
export default function (app: App) {
|
||||
autoResize.install(app)
|
||||
placeholder.install(app)
|
||||
upDownHistory.install(app)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { App } from 'vue'
|
||||
|
||||
export function install (app: App) {
|
||||
app.directive('placeholder', {
|
||||
mounted (el, binding) {
|
||||
const { value } = binding
|
||||
const { focus, blur } = value
|
||||
|
||||
if (el === document.activeElement) {
|
||||
el.placeholder = focus
|
||||
} else {
|
||||
el.placeholder = blur
|
||||
}
|
||||
|
||||
el.addEventListener('focus', () => {
|
||||
el.placeholder = focus
|
||||
})
|
||||
|
||||
el.addEventListener('blur', () => {
|
||||
el.placeholder = blur
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { App } from 'vue'
|
||||
|
||||
export function install (app: App) {
|
||||
app.directive('up-down-history', {
|
||||
mounted (el, binding) {
|
||||
const { value = {} } = binding
|
||||
const { maxLength = 50 } = value
|
||||
|
||||
const history: string[] = []
|
||||
let index = -1
|
||||
|
||||
const toggle = (e: KeyboardEvent) => {
|
||||
if (history.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = e.target as HTMLInputElement
|
||||
const offset = e.key === 'ArrowUp' ? -1 : 1
|
||||
|
||||
if (target.tagName === 'INPUT') {
|
||||
e.preventDefault()
|
||||
} else if (target.selectionStart === target.selectionEnd) {
|
||||
const style = window.getComputedStyle(target)
|
||||
const singleHeight = parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
|
||||
if (
|
||||
(!target.value.includes('\n') && target.clientHeight < singleHeight + 4) ||
|
||||
(offset < 0 && target.selectionStart === 0) ||
|
||||
(offset > 0 && target.selectionEnd === target.value.length)
|
||||
) {
|
||||
e.preventDefault()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
index += offset
|
||||
|
||||
if (index < 0) {
|
||||
index = 0
|
||||
} else if (index >= history.length) {
|
||||
index = history.length - 1
|
||||
}
|
||||
|
||||
el.value = history[index]
|
||||
el.dispatchEvent(new Event('input'))
|
||||
}
|
||||
|
||||
el.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
toggle(e)
|
||||
} else if (e.key === 'Enter') {
|
||||
if (el.value && el.value !== history[index]) {
|
||||
if (history.length >= maxLength) {
|
||||
history.shift()
|
||||
}
|
||||
|
||||
history.push(el.value)
|
||||
index = history.length - 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createApp } from 'vue'
|
||||
import App from '@fe/App.vue'
|
||||
import router from '@fe/router'
|
||||
|
||||
import directives from '@fe/directives'
|
||||
import store from '@fe/support/store'
|
||||
import toast from '@fe/support/ui/toast'
|
||||
import modal from '@fe/support/ui/modal'
|
||||
@@ -12,6 +13,7 @@ import quickFilter from '@fe/support/ui/quick-filter'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(directives)
|
||||
app.use(store)
|
||||
app.use(router)
|
||||
app.use(toast)
|
||||
|
||||
@@ -55,7 +55,8 @@ export default {
|
||||
] : []),
|
||||
{ id: 'refresh', label: t('tree.context-menu.refresh'), onClick: () => ctx.tree.refreshTree() },
|
||||
...(node.type === 'dir' && !FLAG_DISABLE_XTERM ? [
|
||||
{ id: 'open-in-terminal', label: t('tree.context-menu.open-in-terminal'), onClick: revealInXterminal }
|
||||
{ id: 'open-in-terminal', label: t('tree.context-menu.open-in-terminal'), onClick: revealInXterminal },
|
||||
{ id: 'find-in-folder', label: t('tree.context-menu.find-in-folder'), onClick: () => ctx.tree.findInFolder(node.path) },
|
||||
] : []),
|
||||
...(isMarkdown ? [
|
||||
{ id: 'create-in-cd', label: t('tree.context-menu.create-in-cd'), onClick: () => ctx.doc.createDoc({ repo: node.repo }, node) }
|
||||
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
},
|
||||
{
|
||||
type: 'btn',
|
||||
icon: 'search-solid',
|
||||
icon: 'bolt-solid',
|
||||
flat: true,
|
||||
title: ctx.i18n.t('control-center.navigation.goto', ctx.command.getKeysLabel('filter.show-quick-open')),
|
||||
showInActionBar: true,
|
||||
|
||||
@@ -60,6 +60,14 @@ export function revealCurrentNode () {
|
||||
getActionHandler('tree.reveal-current-node')()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find in folder
|
||||
* @param path
|
||||
*/
|
||||
export function findInFolder (path?: string) {
|
||||
getActionHandler('tree.find-in-folder')(path)
|
||||
}
|
||||
|
||||
store.watch(state => state.treeSort, async () => {
|
||||
await refreshTree()
|
||||
await nextTick()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { IProgressMessage, ISerializedFileMatch, ISerializedSearchSuccess, ITextQuery } from 'ripgrep-wrapper'
|
||||
import type { Components, Doc, ExportType, FileItem, FileSort, PathItem } from '@fe/types'
|
||||
import type { SearchMessage } from '@share/typings'
|
||||
import { isElectron } from '@fe/support/env'
|
||||
import { JWT_TOKEN } from './args'
|
||||
|
||||
@@ -249,15 +251,67 @@ export async function choosePath (options: Record<string, any>): Promise<{ cance
|
||||
return result.data
|
||||
}
|
||||
|
||||
type SearchReturn = (
|
||||
onResult: (result: ISerializedFileMatch[]) => void,
|
||||
onMessage?: (message: IProgressMessage) => void
|
||||
) => Promise<ISerializedSearchSuccess | null>
|
||||
|
||||
/**
|
||||
* Search in a repository.
|
||||
* @param repo
|
||||
* @param text
|
||||
* Search files.
|
||||
* @param controller
|
||||
* @param query
|
||||
* @returns
|
||||
*/
|
||||
export async function search (repo: string, text: string): Promise<Pick<Doc, 'repo' | 'type' | 'path' | 'name'>> {
|
||||
const result = await fetchHttp(`/api/search?repo=${encodeURIComponent(repo)}&search=${encodeURIComponent(text)}`)
|
||||
return result.data
|
||||
export async function search (controller: AbortController, query: ITextQuery): Promise<SearchReturn> {
|
||||
const response = await fetchHttp('/api/search', {
|
||||
signal: controller.signal,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query })
|
||||
})
|
||||
|
||||
return async function (onResult, onMessage) {
|
||||
let val = ''
|
||||
let success: ISerializedSearchSuccess | null = null
|
||||
|
||||
const reader: ReadableStreamDefaultReader = response.body.getReader()
|
||||
|
||||
// read stream
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
return success
|
||||
}
|
||||
|
||||
val += new TextDecoder().decode(value)
|
||||
|
||||
const idx = val.lastIndexOf('\n')
|
||||
if (idx === -1) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lines = val.slice(0, idx)
|
||||
val = val.slice(idx + 1)
|
||||
|
||||
for (const line of lines.split('\n')) {
|
||||
const data = JSON.parse(line)
|
||||
|
||||
switch (data.type) {
|
||||
case 'result':
|
||||
onResult((<SearchMessage<'result'>>data).payload)
|
||||
break
|
||||
case 'message':
|
||||
onMessage?.((<SearchMessage<'message'>>data).payload)
|
||||
break
|
||||
case 'done':
|
||||
success = (<SearchMessage<'done'>>data).payload
|
||||
break
|
||||
default:
|
||||
throw (<SearchMessage<'error'>>data).payload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -254,6 +254,7 @@ export type BuildInActions = {
|
||||
'control-center.refresh': () => void,
|
||||
'tree.refresh': () => void,
|
||||
'tree.reveal-current-node': () => void,
|
||||
'tree.find-in-folder': (path?: string) => void,
|
||||
'editor.toggle-wrap': () => void,
|
||||
'editor.refresh-custom-editor': () => void,
|
||||
'editor.trigger-save': () => void,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ActionBar />
|
||||
<Outline show-filter v-if="showOutline" />
|
||||
<Tree v-show="!showOutline" />
|
||||
<SearchPanel />
|
||||
</template>
|
||||
<template v-slot:terminal>
|
||||
<Xterm @hide="hideXterm" />
|
||||
@@ -58,6 +59,7 @@ import ControlCenter from '@fe/components/ControlCenter.vue'
|
||||
import DocHistory from '@fe/components/DocHistory.vue'
|
||||
import ActionBar from '@fe/components/ActionBar.vue'
|
||||
import Outline from '@fe/components/Outline.vue'
|
||||
import SearchPanel from '@fe/components/SearchPanel.vue'
|
||||
import ExtensionManager from '@fe/components/ExtensionManager.vue'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -79,6 +81,7 @@ export default defineComponent({
|
||||
DocHistory,
|
||||
ActionBar,
|
||||
Outline,
|
||||
SearchPanel,
|
||||
ExtensionManager,
|
||||
},
|
||||
setup () {
|
||||
|
||||
@@ -290,6 +290,7 @@ const data = {
|
||||
'create-in-cd': 'New File',
|
||||
'copy-name': 'Copy Name',
|
||||
'copy-path': 'Copy Path',
|
||||
'find-in-folder': 'Find in Folder',
|
||||
},
|
||||
'toast': {
|
||||
'moved': '[%s] Moved to [%s]',
|
||||
@@ -407,7 +408,6 @@ const data = {
|
||||
'input-placeholder': 'Type characters...',
|
||||
'empty': 'Empty',
|
||||
'files': 'Files',
|
||||
'search': 'Search',
|
||||
'marked': 'Marked',
|
||||
},
|
||||
'editor': {
|
||||
@@ -596,6 +596,16 @@ const data = {
|
||||
'help': 'Help',
|
||||
'recent': 'Recent',
|
||||
},
|
||||
'search-panel': {
|
||||
'search-files': 'Search Files',
|
||||
'placeholder-search': 'Search',
|
||||
'for-history': '(⇅ for history)',
|
||||
'files-to-include': 'Files to include',
|
||||
'files-to-exclude': 'Files to exclude',
|
||||
'match-case': 'Match Case',
|
||||
'match-whole-word': 'Match Whole Word',
|
||||
'use-regex': 'Use Regular Expression',
|
||||
},
|
||||
}
|
||||
|
||||
export type BaseLanguage = typeof data
|
||||
|
||||
@@ -291,6 +291,7 @@ const data: BaseLanguage = {
|
||||
'create-in-cd': '当前目录创建新文件',
|
||||
'copy-name': '复制名称',
|
||||
'copy-path': '复制路径',
|
||||
'find-in-folder': '在文件夹中查找',
|
||||
},
|
||||
'toast': {
|
||||
'moved': '[%s] 已移动到 [%s]',
|
||||
@@ -408,7 +409,6 @@ const data: BaseLanguage = {
|
||||
'input-placeholder': '键入字符……',
|
||||
'empty': '无结果',
|
||||
'files': '快速跳转',
|
||||
'search': '搜索内容',
|
||||
'marked': '已标记',
|
||||
},
|
||||
'editor': {
|
||||
@@ -597,6 +597,16 @@ const data: BaseLanguage = {
|
||||
'help': '帮助',
|
||||
'recent': '最近打开',
|
||||
},
|
||||
'search-panel': {
|
||||
'search-files': '搜索文件',
|
||||
'placeholder-search': '搜索',
|
||||
'for-history': '(⇅ 切换历史)',
|
||||
'files-to-include': '包含文件',
|
||||
'files-to-exclude': '排除文件',
|
||||
'match-case': '区分大小写',
|
||||
'match-whole-word': '匹配整词',
|
||||
'use-regex': '使用正则表达式',
|
||||
},
|
||||
}
|
||||
|
||||
export default data
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IProgressMessage, ISerializedFileMatch, ISerializedSearchSuccess } from 'ripgrep-wrapper'
|
||||
|
||||
export interface SearchMessage<T extends 'result' | 'message' | 'done' | 'error'> {
|
||||
type: T
|
||||
payload: T extends 'result'
|
||||
? ISerializedFileMatch[]
|
||||
: T extends 'message'
|
||||
? IProgressMessage
|
||||
: T extends 'done'
|
||||
? ISerializedSearchSuccess
|
||||
: Error
|
||||
}
|
||||
@@ -1466,7 +1466,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
|
||||
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
|
||||
|
||||
"@types/node@*", "@types/node@^15.12.2":
|
||||
"@types/node@*":
|
||||
version "15.12.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.4.tgz#e1cf817d70a1e118e81922c4ff6683ce9d422e26"
|
||||
integrity sha512-zrNj1+yqYF4WskCMOHwN+w9iuD12+dGm0rQ35HLl9/Ouuq52cEtd0CH9qMgrdNmi5ejC1/V7vKEXYubB+65DkA==
|
||||
@@ -1481,6 +1481,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.1.tgz#459886b51f52aa923dc06b9ea81cb8b1d733e9d3"
|
||||
integrity sha512-fTFWOFrgAkj737w1o0HLTIgisgYHnsZfeiqhG1Ltrf/iJjudEbUwetQAsfrtVE49JGwvpEzQR+EbMkIqG4227g==
|
||||
|
||||
"@types/node@^18.11.9":
|
||||
version "18.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
|
||||
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
|
||||
|
||||
"@types/normalize-package-data@^2.4.0":
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
|
||||
@@ -1764,6 +1769,14 @@
|
||||
vscode-nls "^5.0.0"
|
||||
vscode-uri "^2.1.2"
|
||||
|
||||
"@vscode/ripgrep@^1.14.2":
|
||||
version "1.14.2"
|
||||
resolved "https://registry.yarnpkg.com/@vscode/ripgrep/-/ripgrep-1.14.2.tgz#47c0eec2b64f53d8f7e1b5ffd22a62e229191c34"
|
||||
integrity sha512-KDaehS8Jfdg1dqStaIPDKYh66jzKd5jy5aYEPzIv0JYFLADPsCSQPBUdsJVXnr0t72OlDcj96W05xt/rSnNFFQ==
|
||||
dependencies:
|
||||
https-proxy-agent "^5.0.0"
|
||||
proxy-from-env "^1.1.0"
|
||||
|
||||
"@vue/babel-helper-vue-transform-on@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.0.2.tgz#9b9c691cd06fc855221a2475c3cc831d774bc7dc"
|
||||
@@ -7154,6 +7167,11 @@ proto-list@~1.2.1:
|
||||
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
|
||||
integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
|
||||
|
||||
proxy-from-env@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
|
||||
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
|
||||
|
||||
psl@^1.1.28, psl@^1.1.33:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
|
||||
@@ -7540,6 +7558,13 @@ rimraf@^3.0.0, rimraf@^3.0.2:
|
||||
dependencies:
|
||||
glob "^7.1.3"
|
||||
|
||||
ripgrep-wrapper@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ripgrep-wrapper/-/ripgrep-wrapper-1.1.1.tgz#26205eefe2833123ce899cf4bcc8a5bdca42e0e3"
|
||||
integrity sha512-qdXkNQ7URwnef8ymXj4Eh+Strt2qHiF5a9XxBNbiNcQRiA3zGXAQMRvuSgJoh2x/c72ZXA9MfOyNQZNI9dumew==
|
||||
dependencies:
|
||||
vscode-regexpp "^3.1.0"
|
||||
|
||||
roarr@^2.15.3:
|
||||
version "2.15.4"
|
||||
resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd"
|
||||
@@ -8807,6 +8832,11 @@ vscode-pug-languageservice@0.29.8:
|
||||
pug-parser "^6.0.0"
|
||||
vscode-languageserver "^8.0.0-next.2"
|
||||
|
||||
vscode-regexpp@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/vscode-regexpp/-/vscode-regexpp-3.1.0.tgz#42d059b6fffe99bd42939c0d013f632f0cad823f"
|
||||
integrity sha512-pqtN65VC1jRLawfluX4Y80MMG0DHJydWhe5ZwMHewZD6sys4LbU6lHwFAHxeuaVE6Y6+xZOtAw+9hvq7/0ejkg==
|
||||
|
||||
vscode-textmate@5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-5.2.0.tgz#01f01760a391e8222fe4f33fbccbd1ad71aed74e"
|
||||
|
||||
Reference in New Issue
Block a user