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:
Arthur
2026-04-16 12:59:38 +02:00
committed by GitHub
parent 4d68f3cea4
commit 5b5c8c06e3
5 changed files with 441 additions and 21 deletions

View File

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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"
},