From 791e8638ac8d0a7842792bd78b6cf5d5c12341ea Mon Sep 17 00:00:00 2001 From: manx98 <1323517022@qq.com> Date: Mon, 26 May 2025 21:40:26 +0800 Subject: [PATCH] feat: add streaming response support for search results --- frontend/src/api/search.ts | 59 ++++++++++++++++++------- frontend/src/components/Search.vue | 61 ++++++++++++++++++++------ frontend/src/i18n/en.json | 3 +- frontend/src/i18n/ja.json | 3 +- frontend/src/i18n/ko.json | 3 +- frontend/src/i18n/ru.json | 3 +- frontend/src/i18n/zh-cn.json | 3 +- frontend/src/i18n/zh-tw.json | 3 +- http/search.go | 69 +++++++++++++++++++++++++----- search/search.go | 6 ++- 10 files changed, 167 insertions(+), 46 deletions(-) diff --git a/frontend/src/api/search.ts b/frontend/src/api/search.ts index 871f0aed..01c166b1 100644 --- a/frontend/src/api/search.ts +++ b/frontend/src/api/search.ts @@ -1,7 +1,12 @@ -import { fetchURL, removePrefix } from "./utils"; +import { fetchURL, removePrefix, StatusError } from "./utils"; 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); query = encodeURIComponent(query); @@ -9,19 +14,43 @@ export default async function search(base: string, query: string) { 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(); - - data = data.map((item: UploadItem) => { - item.url = `/files${base}` + url.encodePath(item.path); - - if (item.dir) { - item.url += "/"; + for (const line of lines) { + if (line) { + const item = JSON.parse(line) as UploadItem; + item.url = `/files${base}` + url.encodePath(item.path); + if (item.dir) { + item.url += "/"; + } + callback(item); + } + } + if (done) break; } - - return item; - }); - - return data; + } catch (e) { + // Check if the error is an intentional cancellation + if (e instanceof Error && e.name === "AbortError") { + throw new StatusError("000 No connection", 0, true); + } + throw e; + } } diff --git a/frontend/src/components/Search.vue b/frontend/src/components/Search.vue index 08b40e3e..fc0e7184 100644 --- a/frontend/src/components/Search.vue +++ b/frontend/src/components/Search.vue @@ -5,10 +5,11 @@ v-if="active" class="action" @click="close" - :aria-label="$t('buttons.close')" - :title="$t('buttons.close')" + :aria-label="closeButtonTitle" + :title="closeButtonTitle" > - arrow_back + stop_circle + arrow_back search + autorenew + + + {{ results.length }} +
- autorenew -
@@ -70,10 +77,11 @@ import { useLayoutStore } from "@/stores/layout"; import url from "@/utils/url"; 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 { useRoute } from "vue-router"; import { storeToRefs } from "pinia"; +import { StatusError } from "@/api/utils.ts"; const boxes = { image: { label: "images", icon: "insert_photo" }, @@ -84,6 +92,7 @@ const boxes = { const layoutStore = useLayoutStore(); const fileStore = useFileStore(); +let searchAbortController = new AbortController(); const { currentPromptName } = storeToRefs(layoutStore); @@ -124,9 +133,7 @@ watch(currentPromptName, (newVal, oldVal) => { }); watch(prompt, () => { - if (results.value.length) { - reset(); - } + reset(); }); // ...mapState(useFileStore, ["isListing"]), @@ -149,6 +156,10 @@ const filteredResults = computed(() => { return results.value.slice(0, resultsCount.value); }); +const closeButtonTitle = computed(() => { + return ongoing.value ? t("buttons.stopSearch") : t("buttons.close"); +}); + onMounted(() => { if (result.value === null) { return; @@ -164,14 +175,23 @@ onMounted(() => { }); }); +onUnmounted(() => { + abortLastSearch(); +}); + const open = () => { !active.value && layoutStore.showHover("search"); }; const close = (event: Event) => { - event.stopPropagation(); - event.preventDefault(); - layoutStore.closeHovers(); + if (ongoing.value) { + abortLastSearch(); + ongoing.value = false; + } else { + event.stopPropagation(); + event.preventDefault(); + layoutStore.closeHovers(); + } }; const keyup = (event: KeyboardEvent) => { @@ -188,11 +208,16 @@ const init = (string: string) => { }; const reset = () => { + abortLastSearch(); ongoing.value = false; resultsCount.value = 50; results.value = []; }; +const abortLastSearch = () => { + searchAbortController.abort(); +}; + const submit = async (event: Event) => { event.preventDefault(); @@ -208,8 +233,16 @@ const submit = async (event: Event) => { ongoing.value = true; 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) { + if (error instanceof StatusError && error.is_canceled) { + return; + } $showError(error); } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 1360bbec..733b4c69 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -42,7 +42,8 @@ "update": "Update", "upload": "Upload", "openFile": "Open file", - "discardChanges": "Discard" + "discardChanges": "Discard", + "stopSearch": "Stop searching" }, "download": { "downloadFile": "Download File", diff --git a/frontend/src/i18n/ja.json b/frontend/src/i18n/ja.json index f16a9d16..b334fdb0 100644 --- a/frontend/src/i18n/ja.json +++ b/frontend/src/i18n/ja.json @@ -39,7 +39,8 @@ "update": "更新", "upload": "アップロード", "openFile": "ファイルを開く", - "continue": "続行" + "continue": "続行", + "stopSearch": "検索を停止" }, "download": { "downloadFile": "ファイルのダウンロード", diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json index 722da2d2..e0a7bdab 100644 --- a/frontend/src/i18n/ko.json +++ b/frontend/src/i18n/ko.json @@ -33,7 +33,8 @@ "switchView": "보기 전환", "toggleSidebar": "사이드바 전환", "update": "업데이트", - "upload": "업로드" + "upload": "업로드", + "stopSearch": "검색 중지" }, "download": { "downloadFile": "파일 다운로드", diff --git a/frontend/src/i18n/ru.json b/frontend/src/i18n/ru.json index bb6e8c92..6861d737 100644 --- a/frontend/src/i18n/ru.json +++ b/frontend/src/i18n/ru.json @@ -37,7 +37,8 @@ "toggleSidebar": "Боковая панель", "update": "Обновить", "upload": "Загрузить", - "openFile": "Открыть файл" + "openFile": "Открыть файл", + "stopSearch": "Прекратить поиск" }, "download": { "downloadFile": "Скачать файл", diff --git a/frontend/src/i18n/zh-cn.json b/frontend/src/i18n/zh-cn.json index 376dc029..db6035f8 100644 --- a/frontend/src/i18n/zh-cn.json +++ b/frontend/src/i18n/zh-cn.json @@ -42,7 +42,8 @@ "openFile": "打开文件", "continue": "继续", "fullScreen": "切换全屏", - "discardChanges": "放弃更改" + "discardChanges": "放弃更改", + "stopSearch": "停止搜索" }, "download": { "downloadFile": "下载文件", diff --git a/frontend/src/i18n/zh-tw.json b/frontend/src/i18n/zh-tw.json index 050666f8..cde21ae3 100644 --- a/frontend/src/i18n/zh-tw.json +++ b/frontend/src/i18n/zh-tw.json @@ -41,7 +41,8 @@ "openFile": "開啟檔案", "continue": "繼續", "fullScreen": "切換全螢幕", - "discardChanges": "放棄變更" + "discardChanges": "放棄變更", + "stopSearch": "停止搜尋" }, "download": { "downloadFile": "下載檔案", diff --git a/http/search.go b/http/search.go index 1c78b781..555b00d6 100644 --- a/http/search.go +++ b/http/search.go @@ -1,28 +1,77 @@ package http import ( + "context" + "encoding/json" + "github.com/filebrowser/filebrowser/v2/search" "net/http" "os" - - "github.com/filebrowser/filebrowser/v2/search" + "sync" + "time" ) 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") - err := search.Search(d.user.Fs, r.URL.Path, query, d, func(path string, f os.FileInfo) error { - response = append(response, map[string]interface{}{ + err := search.Search(ctx, d.user.Fs, r.URL.Path, query, d, func(path string, f os.FileInfo) error { + select { + case <-ctx.Done(): + case response <- map[string]interface{}{ "dir": f.IsDir(), "path": path, - }) - - return nil + }: + } + return context.Cause(ctx) }) - + close(response) + wg.Wait() + if err == nil { + err = context.Cause(ctx) + } if err != nil { return http.StatusInternalServerError, err } - return renderJSON(w, r, response) + return 0, nil }) diff --git a/search/search.go b/search/search.go index 380dcde3..f32dc308 100644 --- a/search/search.go +++ b/search/search.go @@ -1,6 +1,7 @@ package search import ( + "context" "os" "path" "path/filepath" @@ -18,13 +19,16 @@ type searchOptions struct { } // 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) scope = filepath.ToSlash(filepath.Clean(scope)) scope = path.Join("/", scope) 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 = path.Join("/", fPath) relativePath := strings.TrimPrefix(fPath, scope)