feat: add streaming response support for search results
parent
9b5a6afb00
commit
791e8638ac
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
<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>
|
||||
<i v-else class="material-icons">search</i>
|
||||
<input
|
||||
|
@ -21,6 +22,15 @@
|
|||
:aria-label="$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 id="result" ref="result">
|
||||
|
@ -57,9 +67,6 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p id="renew">
|
||||
<i class="material-icons spin">autorenew</i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -42,7 +42,8 @@
|
|||
"update": "Update",
|
||||
"upload": "Upload",
|
||||
"openFile": "Open file",
|
||||
"discardChanges": "Discard"
|
||||
"discardChanges": "Discard",
|
||||
"stopSearch": "Stop searching"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Download File",
|
||||
|
|
|
@ -39,7 +39,8 @@
|
|||
"update": "更新",
|
||||
"upload": "アップロード",
|
||||
"openFile": "ファイルを開く",
|
||||
"continue": "続行"
|
||||
"continue": "続行",
|
||||
"stopSearch": "検索を停止"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "ファイルのダウンロード",
|
||||
|
|
|
@ -33,7 +33,8 @@
|
|||
"switchView": "보기 전환",
|
||||
"toggleSidebar": "사이드바 전환",
|
||||
"update": "업데이트",
|
||||
"upload": "업로드"
|
||||
"upload": "업로드",
|
||||
"stopSearch": "검색 중지"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "파일 다운로드",
|
||||
|
|
|
@ -37,7 +37,8 @@
|
|||
"toggleSidebar": "Боковая панель",
|
||||
"update": "Обновить",
|
||||
"upload": "Загрузить",
|
||||
"openFile": "Открыть файл"
|
||||
"openFile": "Открыть файл",
|
||||
"stopSearch": "Прекратить поиск"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Скачать файл",
|
||||
|
|
|
@ -42,7 +42,8 @@
|
|||
"openFile": "打开文件",
|
||||
"continue": "继续",
|
||||
"fullScreen": "切换全屏",
|
||||
"discardChanges": "放弃更改"
|
||||
"discardChanges": "放弃更改",
|
||||
"stopSearch": "停止搜索"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "下载文件",
|
||||
|
|
|
@ -41,7 +41,8 @@
|
|||
"openFile": "開啟檔案",
|
||||
"continue": "繼續",
|
||||
"fullScreen": "切換全螢幕",
|
||||
"discardChanges": "放棄變更"
|
||||
"discardChanges": "放棄變更",
|
||||
"stopSearch": "停止搜尋"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "下載檔案",
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue