feat: properly impl find for files (#5741)
* Initial file search impl * Add replace functionality * Rename to find, remove extra icon * Put into seperate component * Fix lint * Change remaining search stuff to find * Use ButtonStyled for buttons, use types from ace editor * Make results label oriented left, add clear button to replace input * Run fix --------- Signed-off-by: Arthur <creeperkatze.dev@gmail.com> Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
This commit is contained in:
@@ -179,16 +179,30 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isEditingImage && isLogFile" class="flex gap-2">
|
||||
<Button
|
||||
v-tooltip="formatMessage(messages.shareToMclogs)"
|
||||
icon-only
|
||||
transparent
|
||||
:aria-label="formatMessage(messages.shareToMclogs)"
|
||||
@click="$emit('share')"
|
||||
<div v-else-if="!isEditingImage" class="flex gap-2">
|
||||
<ButtonStyled v-if="isLogFile" type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.shareToMclogs)"
|
||||
:aria-label="formatMessage(messages.shareToMclogs)"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
circular
|
||||
:type="isEditorFindOpen ? 'standard' : 'transparent'"
|
||||
:color="isEditorFindOpen ? 'brand' : 'standard'"
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.findInFile)"
|
||||
:aria-label="formatMessage(messages.findInFile)"
|
||||
:aria-pressed="isEditorFindOpen"
|
||||
@click="$emit('find')"
|
||||
>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -212,7 +226,6 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import Button from '#ui/components/base/Button.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
@@ -274,6 +287,10 @@ const messages = defineMessages({
|
||||
id: 'files.navbar.share-to-mclogs',
|
||||
defaultMessage: 'Share to mclo.gs',
|
||||
},
|
||||
findInFile: {
|
||||
id: 'files.navbar.find-in-file',
|
||||
defaultMessage: 'Find in file',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -282,6 +299,7 @@ const props = defineProps<{
|
||||
editingFileName?: string
|
||||
editingFilePath?: string
|
||||
isEditingImage?: boolean
|
||||
isEditorFindOpen?: boolean
|
||||
searchQuery: string
|
||||
showRefreshButton?: boolean
|
||||
showInstallFromUrl?: boolean
|
||||
@@ -301,6 +319,7 @@ const emit = defineEmits<{
|
||||
unzipFromUrl: [cf: boolean]
|
||||
refresh: []
|
||||
share: []
|
||||
find: []
|
||||
}>()
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<Transition name="find">
|
||||
<div
|
||||
v-if="isFindOpen && !isEditingImage"
|
||||
class="absolute right-3 top-3 z-10 flex flex-col gap-1 rounded-2xl border border-solid border-surface-5 bg-surface-3 p-1.5 shadow-lg"
|
||||
@keydown.escape.stop="close"
|
||||
>
|
||||
<!-- Find row -->
|
||||
<div class="flex items-center gap-1">
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.toggleReplace)"
|
||||
:aria-label="formatMessage(messages.toggleReplace)"
|
||||
@click="toggleReplace"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class="transition-transform duration-150"
|
||||
:class="{ 'rotate-90': isReplaceOpen }"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
@keydown.enter.exact.prevent.stop="emit('findNext')"
|
||||
@keydown.shift.enter.prevent.stop="emit('findPrevious')"
|
||||
>
|
||||
<StyledInput
|
||||
ref="findInputRef"
|
||||
:model-value="findQuery"
|
||||
type="search"
|
||||
size="small"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.findInFile)"
|
||||
wrapper-class="w-44"
|
||||
@update:model-value="emit('update:findQuery', $event as string)"
|
||||
/>
|
||||
</div>
|
||||
<span class="min-w-[6rem] px-1 text-sm text-secondary tabular-nums">
|
||||
{{
|
||||
findMatchCount > 0
|
||||
? formatMessage(messages.matchCount, {
|
||||
current: currentFindMatch,
|
||||
total: findMatchCount,
|
||||
})
|
||||
: findQuery
|
||||
? formatMessage(messages.noResults)
|
||||
: ''
|
||||
}}
|
||||
</span>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.previousMatch)"
|
||||
:disabled="findMatchCount === 0"
|
||||
:aria-label="formatMessage(messages.previousMatch)"
|
||||
@click="emit('findPrevious')"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.nextMatch)"
|
||||
:disabled="findMatchCount === 0"
|
||||
:aria-label="formatMessage(messages.nextMatch)"
|
||||
@click="emit('findNext')"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mx-0.5 h-4 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.closeFind)"
|
||||
:aria-label="formatMessage(messages.closeFind)"
|
||||
@click="close"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<!-- Replace row -->
|
||||
<div v-if="isReplaceOpen" class="flex items-center gap-1">
|
||||
<div class="w-9 flex-shrink-0" />
|
||||
<div @keydown.enter.prevent.stop="emit('replace', replaceQuery)">
|
||||
<StyledInput
|
||||
ref="replaceInputRef"
|
||||
v-model="replaceQuery"
|
||||
type="search"
|
||||
size="small"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.replaceInFile)"
|
||||
wrapper-class="w-44"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-8 whitespace-nowrap !border !border-surface-5 px-2 text-sm disabled:opacity-50"
|
||||
:disabled="findMatchCount === 0"
|
||||
@click="emit('replace', replaceQuery)"
|
||||
>
|
||||
{{ formatMessage(messages.replace) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!h-8 whitespace-nowrap !border !border-surface-5 px-2 text-sm disabled:opacity-50"
|
||||
:disabled="findMatchCount === 0"
|
||||
@click="emit('replaceAll', replaceQuery)"
|
||||
>
|
||||
{{ formatMessage(messages.replaceAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
isFindOpen: boolean
|
||||
findQuery: string
|
||||
findMatchCount: number
|
||||
currentFindMatch: number
|
||||
isEditingImage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:isFindOpen': [value: boolean]
|
||||
'update:findQuery': [value: string]
|
||||
close: []
|
||||
findNext: []
|
||||
findPrevious: []
|
||||
replace: [query: string]
|
||||
replaceAll: [query: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
findInFile: {
|
||||
id: 'files.editor.find-in-file',
|
||||
defaultMessage: 'Find',
|
||||
},
|
||||
matchCount: {
|
||||
id: 'files.editor.find-match-count',
|
||||
defaultMessage: '{current} of {total}',
|
||||
},
|
||||
noResults: {
|
||||
id: 'files.editor.find-no-results',
|
||||
defaultMessage: 'No results',
|
||||
},
|
||||
previousMatch: {
|
||||
id: 'files.editor.find-previous-match',
|
||||
defaultMessage: 'Previous match',
|
||||
},
|
||||
nextMatch: {
|
||||
id: 'files.editor.find-next-match',
|
||||
defaultMessage: 'Next match',
|
||||
},
|
||||
closeFind: {
|
||||
id: 'files.editor.find-close',
|
||||
defaultMessage: 'Close',
|
||||
},
|
||||
toggleReplace: {
|
||||
id: 'files.editor.find-toggle-replace',
|
||||
defaultMessage: 'Toggle replace',
|
||||
},
|
||||
replaceInFile: {
|
||||
id: 'files.editor.replace-in-file',
|
||||
defaultMessage: 'Replace',
|
||||
},
|
||||
replace: {
|
||||
id: 'files.editor.replace',
|
||||
defaultMessage: 'Replace',
|
||||
},
|
||||
replaceAll: {
|
||||
id: 'files.editor.replace-all',
|
||||
defaultMessage: 'Replace All',
|
||||
},
|
||||
})
|
||||
|
||||
const isReplaceOpen = ref(false)
|
||||
const replaceQuery = ref('')
|
||||
|
||||
const findInputRef = ref<{ focus: () => void } | null>(null)
|
||||
const replaceInputRef = ref<{ focus: () => void } | null>(null)
|
||||
|
||||
function toggleReplace() {
|
||||
isReplaceOpen.value = !isReplaceOpen.value
|
||||
if (isReplaceOpen.value) {
|
||||
nextTick(() => replaceInputRef.value?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
function focusFindInput() {
|
||||
nextTick(() => findInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function openReplace() {
|
||||
isReplaceOpen.value = true
|
||||
nextTick(() => replaceInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function close() {
|
||||
isReplaceOpen.value = false
|
||||
replaceQuery.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isFindOpen,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
isReplaceOpen.value = false
|
||||
replaceQuery.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
focusFindInput,
|
||||
openReplace,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.find-enter-active,
|
||||
.find-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.find-enter-from,
|
||||
.find-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.97);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,21 @@
|
||||
<template>
|
||||
<div
|
||||
ref="editorContainer"
|
||||
class="flex flex-col overflow-hidden rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
class="relative flex flex-col overflow-hidden rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
>
|
||||
<EditorFindReplace
|
||||
ref="findReplaceRef"
|
||||
v-model:is-find-open="isFindOpen"
|
||||
v-model:find-query="inFileFindQuery"
|
||||
:is-editing-image="isEditingImage"
|
||||
:find-match-count="findMatchCount"
|
||||
:current-find-match="currentFindMatch"
|
||||
@find-next="findNext"
|
||||
@find-previous="findPrevious"
|
||||
@close="closeFind"
|
||||
@replace="replaceOne"
|
||||
@replace-all="replaceAllOccurrences"
|
||||
/>
|
||||
<component
|
||||
:is="props.editorComponent"
|
||||
v-if="!isEditingImage && !isLoading && props.editorComponent"
|
||||
@@ -27,6 +40,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import type { Ace } from 'ace-builds'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
@@ -36,6 +50,7 @@ import { getEditorLanguage, getFileExtension, isImageFile } from '#ui/utils/file
|
||||
|
||||
import { injectFileManager } from '../../providers/file-manager'
|
||||
import type { EditingFile } from '../../types'
|
||||
import EditorFindReplace from './EditorFindReplace.vue'
|
||||
import FileImageViewer from './FileImageViewer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -100,10 +115,18 @@ const originalContent = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const imagePreview = ref<Blob | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const editorInstance = ref<unknown>(null)
|
||||
const editorInstance = ref<Ace.Editor | null>(null)
|
||||
const editorContainer = ref<HTMLElement | null>(null)
|
||||
const editorHeight = ref('300px')
|
||||
|
||||
const isFindOpen = ref(false)
|
||||
const inFileFindQuery = ref('')
|
||||
const findMatchCount = ref(0)
|
||||
const currentFindMatch = ref(0)
|
||||
const findReplaceRef = ref<{ focusFindInput: () => void; openReplace: () => void } | null>(null)
|
||||
|
||||
watch(inFileFindQuery, handleFindInput)
|
||||
|
||||
function updateEditorHeight() {
|
||||
if (editorContainer.value) {
|
||||
const top = editorContainer.value.getBoundingClientRect().top
|
||||
@@ -126,6 +149,7 @@ watch(
|
||||
() => props.file,
|
||||
async (newFile) => {
|
||||
if (newFile) {
|
||||
closeFind()
|
||||
await loadFileContent(newFile)
|
||||
nextTick(updateEditorHeight)
|
||||
} else {
|
||||
@@ -180,15 +204,7 @@ function resetState() {
|
||||
imagePreview.value = null
|
||||
}
|
||||
|
||||
function onEditorInit(editor: {
|
||||
commands: {
|
||||
addCommand: (cmd: {
|
||||
name: string
|
||||
bindKey: { win: string; mac: string }
|
||||
exec: () => void
|
||||
}) => void
|
||||
}
|
||||
}) {
|
||||
function onEditorInit(editor: Ace.Editor) {
|
||||
editorInstance.value = editor
|
||||
|
||||
editor.commands.addCommand({
|
||||
@@ -196,6 +212,21 @@ function onEditorInit(editor: {
|
||||
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
|
||||
exec: () => saveFileContent(false),
|
||||
})
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'find',
|
||||
bindKey: { win: 'Ctrl-F', mac: 'Command-F' },
|
||||
exec: () => toggleFind(),
|
||||
})
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'replace',
|
||||
bindKey: { win: 'Ctrl-H', mac: 'Command-Option-F' },
|
||||
exec: () => {
|
||||
isFindOpen.value = true
|
||||
nextTick(() => findReplaceRef.value?.openReplace())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function saveFileContent(exit: boolean = false) {
|
||||
@@ -255,6 +286,93 @@ async function shareToMclogs() {
|
||||
}
|
||||
}
|
||||
|
||||
function countOccurrences(content: string, query: string): number {
|
||||
if (!query) return 0
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return (content.match(new RegExp(escaped, 'gi')) ?? []).length
|
||||
}
|
||||
|
||||
function toggleFind() {
|
||||
if (isFindOpen.value) {
|
||||
closeFind()
|
||||
} else {
|
||||
isFindOpen.value = true
|
||||
nextTick(() => findReplaceRef.value?.focusFindInput())
|
||||
}
|
||||
}
|
||||
|
||||
function closeFind() {
|
||||
isFindOpen.value = false
|
||||
inFileFindQuery.value = ''
|
||||
findMatchCount.value = 0
|
||||
currentFindMatch.value = 0
|
||||
editorInstance.value?.find('', { wrap: true })
|
||||
editorInstance.value?.focus()
|
||||
}
|
||||
|
||||
function replaceOne(query: string) {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
editor.replace(query)
|
||||
nextTick(() => {
|
||||
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
|
||||
findMatchCount.value = count
|
||||
currentFindMatch.value = count > 0 ? Math.min(currentFindMatch.value, count) : 0
|
||||
})
|
||||
}
|
||||
|
||||
function replaceAllOccurrences(query: string) {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
editor.replaceAll(query)
|
||||
nextTick(() => {
|
||||
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
|
||||
findMatchCount.value = count
|
||||
currentFindMatch.value = count > 0 ? 1 : 0
|
||||
if (count > 0) {
|
||||
editor.find(inFileFindQuery.value, { wrap: true, caseSensitive: false })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleFindInput() {
|
||||
const editor = editorInstance.value
|
||||
if (!editor) return
|
||||
|
||||
const query = inFileFindQuery.value
|
||||
if (!query) {
|
||||
findMatchCount.value = 0
|
||||
currentFindMatch.value = 0
|
||||
editor.find('', { wrap: true })
|
||||
return
|
||||
}
|
||||
|
||||
const count = countOccurrences(fileContent.value, query)
|
||||
findMatchCount.value = count
|
||||
|
||||
if (count > 0) {
|
||||
editor.find(query, { wrap: true, caseSensitive: false })
|
||||
currentFindMatch.value = 1
|
||||
} else {
|
||||
currentFindMatch.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function findNext() {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
editor.findNext()
|
||||
currentFindMatch.value = (currentFindMatch.value % findMatchCount.value) + 1
|
||||
}
|
||||
|
||||
function findPrevious() {
|
||||
const editor = editorInstance.value
|
||||
if (!editor || findMatchCount.value === 0) return
|
||||
editor.findPrevious()
|
||||
currentFindMatch.value =
|
||||
((currentFindMatch.value - 2 + findMatchCount.value) % findMatchCount.value) + 1
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetState()
|
||||
emit('close')
|
||||
@@ -271,8 +389,10 @@ defineExpose({
|
||||
shareToMclogs,
|
||||
close,
|
||||
isEditingImage,
|
||||
isFindOpen,
|
||||
fileContent,
|
||||
hasUnsavedChanges,
|
||||
revertChanges,
|
||||
toggleFind,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
:editing-file-name="ctx.editingFile.value?.name"
|
||||
:editing-file-path="ctx.editingFile.value?.path"
|
||||
:is-editing-image="fileEditorRef?.isEditingImage"
|
||||
:is-editor-find-open="fileEditorRef?.isFindOpen"
|
||||
:search-query="searchQuery"
|
||||
:show-refresh-button="showRefreshButton"
|
||||
:show-install-from-url="ctx.showInstallFromUrl"
|
||||
@@ -70,6 +71,7 @@
|
||||
@unzip-from-url="showUnzipFromUrlModal"
|
||||
@refresh="ctx.refresh"
|
||||
@share="() => fileEditorRef?.shareToMclogs()"
|
||||
@find="() => fileEditorRef?.toggleFind()"
|
||||
/>
|
||||
|
||||
<div v-if="!isEditing">
|
||||
|
||||
@@ -548,12 +548,42 @@
|
||||
"files.editor.file-saved-title": {
|
||||
"defaultMessage": "File saved"
|
||||
},
|
||||
"files.editor.find-close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"files.editor.find-in-file": {
|
||||
"defaultMessage": "Find"
|
||||
},
|
||||
"files.editor.find-match-count": {
|
||||
"defaultMessage": "{current} of {total}"
|
||||
},
|
||||
"files.editor.find-next-match": {
|
||||
"defaultMessage": "Next match"
|
||||
},
|
||||
"files.editor.find-no-results": {
|
||||
"defaultMessage": "No results"
|
||||
},
|
||||
"files.editor.find-previous-match": {
|
||||
"defaultMessage": "Previous match"
|
||||
},
|
||||
"files.editor.find-toggle-replace": {
|
||||
"defaultMessage": "Toggle replace"
|
||||
},
|
||||
"files.editor.log-url-copied-text": {
|
||||
"defaultMessage": "Your log file URL has been copied to your clipboard."
|
||||
},
|
||||
"files.editor.log-url-copied-title": {
|
||||
"defaultMessage": "Log URL copied"
|
||||
},
|
||||
"files.editor.replace": {
|
||||
"defaultMessage": "Replace"
|
||||
},
|
||||
"files.editor.replace-all": {
|
||||
"defaultMessage": "Replace All"
|
||||
},
|
||||
"files.editor.replace-in-file": {
|
||||
"defaultMessage": "Replace"
|
||||
},
|
||||
"files.editor.save-failed-text": {
|
||||
"defaultMessage": "Could not save the file."
|
||||
},
|
||||
@@ -644,6 +674,9 @@
|
||||
"files.navbar.file-navigation": {
|
||||
"defaultMessage": "File navigation"
|
||||
},
|
||||
"files.navbar.find-in-file": {
|
||||
"defaultMessage": "Find in file"
|
||||
},
|
||||
"files.navbar.home": {
|
||||
"defaultMessage": "Home"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user