feat: add streaming response support for search results

pull/4716/head
manx98 2025-05-26 21:40:26 +08:00
parent 9b5a6afb00
commit 791e8638ac
10 changed files with 167 additions and 46 deletions

View File

@ -1,7 +1,12 @@
import { fetchURL, removePrefix } from "./utils"; import { fetchURL, removePrefix, StatusError } from "./utils";
import url from "../utils/url"; import url from "../utils/url";
export default async function search(base: string, query: string) { export default async function search(
base: string,
query: string,
signal: AbortSignal,
callback: (item: UploadItem) => void
) {
base = removePrefix(base); base = removePrefix(base);
query = encodeURIComponent(query); query = encodeURIComponent(query);
@ -9,19 +14,43 @@ export default async function search(base: string, query: string) {
base += "/"; base += "/";
} }
const res = await fetchURL(`/api/search${base}?query=${query}`, {}); const res = await fetchURL(`/api/search${base}?query=${query}`, { signal });
if (!res.body) {
throw new StatusError("000 No connection", 0);
}
try {
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (value) {
buffer += value;
}
const lines = buffer.split(/\n/);
let lastLine = lines.pop();
// Save incomplete last line
if (!lastLine) {
lastLine = "";
}
buffer = lastLine;
let data = await res.json(); for (const line of lines) {
if (line) {
data = data.map((item: UploadItem) => { const item = JSON.parse(line) as UploadItem;
item.url = `/files${base}` + url.encodePath(item.path); item.url = `/files${base}` + url.encodePath(item.path);
if (item.dir) {
if (item.dir) { item.url += "/";
item.url += "/"; }
callback(item);
}
}
if (done) break;
} }
} catch (e) {
return item; // Check if the error is an intentional cancellation
}); if (e instanceof Error && e.name === "AbortError") {
throw new StatusError("000 No connection", 0, true);
return data; }
throw e;
}
} }

View File

@ -5,10 +5,11 @@
v-if="active" v-if="active"
class="action" class="action"
@click="close" @click="close"
:aria-label="$t('buttons.close')" :aria-label="closeButtonTitle"
:title="$t('buttons.close')" :title="closeButtonTitle"
> >
<i class="material-icons">arrow_back</i> <i v-if="ongoing" class="material-icons">stop_circle</i>
<i v-else class="material-icons">arrow_back</i>
</button> </button>
<i v-else class="material-icons">search</i> <i v-else class="material-icons">search</i>
<input <input
@ -21,6 +22,15 @@
:aria-label="$t('search.search')" :aria-label="$t('search.search')"
:placeholder="$t('search.search')" :placeholder="$t('search.search')"
/> />
<i
v-show="ongoing"
class="material-icons spin"
style="display: inline-block"
>autorenew
</i>
<span style="margin-top: 5px" v-show="results.length > 0">
{{ results.length }}
</span>
</div> </div>
<div id="result" ref="result"> <div id="result" ref="result">
@ -57,9 +67,6 @@
</li> </li>
</ul> </ul>
</div> </div>
<p id="renew">
<i class="material-icons spin">autorenew</i>
</p>
</div> </div>
</div> </div>
</template> </template>
@ -70,10 +77,11 @@ import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url"; import url from "@/utils/url";
import { search } from "@/api"; import { search } from "@/api";
import { computed, inject, onMounted, ref, watch } from "vue"; import { computed, inject, onMounted, ref, watch, onUnmounted } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { StatusError } from "@/api/utils.ts";
const boxes = { const boxes = {
image: { label: "images", icon: "insert_photo" }, image: { label: "images", icon: "insert_photo" },
@ -84,6 +92,7 @@ const boxes = {
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const fileStore = useFileStore(); const fileStore = useFileStore();
let searchAbortController = new AbortController();
const { currentPromptName } = storeToRefs(layoutStore); const { currentPromptName } = storeToRefs(layoutStore);
@ -124,9 +133,7 @@ watch(currentPromptName, (newVal, oldVal) => {
}); });
watch(prompt, () => { watch(prompt, () => {
if (results.value.length) { reset();
reset();
}
}); });
// ...mapState(useFileStore, ["isListing"]), // ...mapState(useFileStore, ["isListing"]),
@ -149,6 +156,10 @@ const filteredResults = computed(() => {
return results.value.slice(0, resultsCount.value); return results.value.slice(0, resultsCount.value);
}); });
const closeButtonTitle = computed(() => {
return ongoing.value ? t("buttons.stopSearch") : t("buttons.close");
});
onMounted(() => { onMounted(() => {
if (result.value === null) { if (result.value === null) {
return; return;
@ -164,14 +175,23 @@ onMounted(() => {
}); });
}); });
onUnmounted(() => {
abortLastSearch();
});
const open = () => { const open = () => {
!active.value && layoutStore.showHover("search"); !active.value && layoutStore.showHover("search");
}; };
const close = (event: Event) => { const close = (event: Event) => {
event.stopPropagation(); if (ongoing.value) {
event.preventDefault(); abortLastSearch();
layoutStore.closeHovers(); ongoing.value = false;
} else {
event.stopPropagation();
event.preventDefault();
layoutStore.closeHovers();
}
}; };
const keyup = (event: KeyboardEvent) => { const keyup = (event: KeyboardEvent) => {
@ -188,11 +208,16 @@ const init = (string: string) => {
}; };
const reset = () => { const reset = () => {
abortLastSearch();
ongoing.value = false; ongoing.value = false;
resultsCount.value = 50; resultsCount.value = 50;
results.value = []; results.value = [];
}; };
const abortLastSearch = () => {
searchAbortController.abort();
};
const submit = async (event: Event) => { const submit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
@ -208,8 +233,16 @@ const submit = async (event: Event) => {
ongoing.value = true; ongoing.value = true;
try { try {
results.value = await search(path, prompt.value); abortLastSearch();
searchAbortController = new AbortController();
results.value = [];
await search(path, prompt.value, searchAbortController.signal, (item) =>
results.value.push(item)
);
} catch (error: any) { } catch (error: any) {
if (error instanceof StatusError && error.is_canceled) {
return;
}
$showError(error); $showError(error);
} }

View File

@ -42,7 +42,8 @@
"update": "Update", "update": "Update",
"upload": "Upload", "upload": "Upload",
"openFile": "Open file", "openFile": "Open file",
"discardChanges": "Discard" "discardChanges": "Discard",
"stopSearch": "Stop searching"
}, },
"download": { "download": {
"downloadFile": "Download File", "downloadFile": "Download File",

View File

@ -39,7 +39,8 @@
"update": "更新", "update": "更新",
"upload": "アップロード", "upload": "アップロード",
"openFile": "ファイルを開く", "openFile": "ファイルを開く",
"continue": "続行" "continue": "続行",
"stopSearch": "検索を停止"
}, },
"download": { "download": {
"downloadFile": "ファイルのダウンロード", "downloadFile": "ファイルのダウンロード",

View File

@ -33,7 +33,8 @@
"switchView": "보기 전환", "switchView": "보기 전환",
"toggleSidebar": "사이드바 전환", "toggleSidebar": "사이드바 전환",
"update": "업데이트", "update": "업데이트",
"upload": "업로드" "upload": "업로드",
"stopSearch": "검색 중지"
}, },
"download": { "download": {
"downloadFile": "파일 다운로드", "downloadFile": "파일 다운로드",

View File

@ -37,7 +37,8 @@
"toggleSidebar": "Боковая панель", "toggleSidebar": "Боковая панель",
"update": "Обновить", "update": "Обновить",
"upload": "Загрузить", "upload": "Загрузить",
"openFile": "Открыть файл" "openFile": "Открыть файл",
"stopSearch": "Прекратить поиск"
}, },
"download": { "download": {
"downloadFile": "Скачать файл", "downloadFile": "Скачать файл",

View File

@ -42,7 +42,8 @@
"openFile": "打开文件", "openFile": "打开文件",
"continue": "继续", "continue": "继续",
"fullScreen": "切换全屏", "fullScreen": "切换全屏",
"discardChanges": "放弃更改" "discardChanges": "放弃更改",
"stopSearch": "停止搜索"
}, },
"download": { "download": {
"downloadFile": "下载文件", "downloadFile": "下载文件",

View File

@ -41,7 +41,8 @@
"openFile": "開啟檔案", "openFile": "開啟檔案",
"continue": "繼續", "continue": "繼續",
"fullScreen": "切換全螢幕", "fullScreen": "切換全螢幕",
"discardChanges": "放棄變更" "discardChanges": "放棄變更",
"stopSearch": "停止搜尋"
}, },
"download": { "download": {
"downloadFile": "下載檔案", "downloadFile": "下載檔案",

View File

@ -1,28 +1,77 @@
package http package http
import ( import (
"context"
"encoding/json"
"github.com/filebrowser/filebrowser/v2/search"
"net/http" "net/http"
"os" "os"
"sync"
"github.com/filebrowser/filebrowser/v2/search" "time"
) )
var searchHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) { var searchHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
response := []map[string]interface{}{} response := make(chan map[string]interface{})
ctx, cancel := context.WithCancelCause(r.Context())
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Avoid connection timeout
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
var err error
var infoBytes []byte
select {
case info := <-response:
if (info) == nil {
return
}
infoBytes, err = json.Marshal(info)
case <-timeout.C:
// Send a heartbeat packet
infoBytes = nil
case <-ctx.Done():
return
}
if err != nil {
cancel(err)
return
}
_, err = w.Write(infoBytes)
if err == nil {
_, err = w.Write([]byte("\n"))
}
if err != nil {
cancel(err)
return
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
}()
query := r.URL.Query().Get("query") query := r.URL.Query().Get("query")
err := search.Search(d.user.Fs, r.URL.Path, query, d, func(path string, f os.FileInfo) error { err := search.Search(ctx, d.user.Fs, r.URL.Path, query, d, func(path string, f os.FileInfo) error {
response = append(response, map[string]interface{}{ select {
case <-ctx.Done():
case response <- map[string]interface{}{
"dir": f.IsDir(), "dir": f.IsDir(),
"path": path, "path": path,
}) }:
}
return nil return context.Cause(ctx)
}) })
close(response)
wg.Wait()
if err == nil {
err = context.Cause(ctx)
}
if err != nil { if err != nil {
return http.StatusInternalServerError, err return http.StatusInternalServerError, err
} }
return renderJSON(w, r, response) return 0, nil
}) })

View File

@ -1,6 +1,7 @@
package search package search
import ( import (
"context"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@ -18,13 +19,16 @@ type searchOptions struct {
} }
// Search searches for a query in a fs. // Search searches for a query in a fs.
func Search(fs afero.Fs, scope, query string, checker rules.Checker, found func(path string, f os.FileInfo) error) error { func Search(ctx context.Context, fs afero.Fs, scope, query string, checker rules.Checker, found func(path string, f os.FileInfo) error) error {
search := parseSearch(query) search := parseSearch(query)
scope = filepath.ToSlash(filepath.Clean(scope)) scope = filepath.ToSlash(filepath.Clean(scope))
scope = path.Join("/", scope) scope = path.Join("/", scope)
return afero.Walk(fs, scope, func(fPath string, f os.FileInfo, _ error) error { return afero.Walk(fs, scope, func(fPath string, f os.FileInfo, _ error) error {
if ctx.Err() != nil {
return context.Cause(ctx)
}
fPath = filepath.ToSlash(filepath.Clean(fPath)) fPath = filepath.ToSlash(filepath.Clean(fPath))
fPath = path.Join("/", fPath) fPath = path.Join("/", fPath)
relativePath := strings.TrimPrefix(fPath, scope) relativePath := strings.TrimPrefix(fPath, scope)