pull/4716/merge
manx98 2025-06-16 18:31:57 +02:00 committed by GitHub
commit 941d285ec3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 247 additions and 69 deletions

View File

@ -2,14 +2,22 @@ import { useAuthStore } from "@/stores/auth";
import { useLayoutStore } from "@/stores/layout"; import { useLayoutStore } from "@/stores/layout";
import { baseURL } from "@/utils/constants"; import { baseURL } from "@/utils/constants";
import { upload as postTus, useTus } from "./tus"; import { upload as postTus, useTus } from "./tus";
import { createURL, fetchURL, removePrefix } from "./utils"; import { createURL, fetchURL, removePrefix, StatusError } from "./utils";
export async function fetch(url: string) { export async function fetch(url: string, signal?: AbortSignal) {
url = removePrefix(url); url = removePrefix(url);
const res = await fetchURL(`/api/resources${url}`, { signal });
const res = await fetchURL(`/api/resources${url}`, {}); let data: Resource;
try {
const data = (await res.json()) as Resource; data = (await res.json()) as Resource;
} 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;
}
data.url = `/files${url}`; data.url = `/files${url}`;
if (data.isDir) { if (data.isDir) {
@ -210,10 +218,18 @@ export function getSubtitlesURL(file: ResourceItem) {
return file.subtitles?.map((d) => createURL("api/subtitle" + d, params)); return file.subtitles?.map((d) => createURL("api/subtitle" + d, params));
} }
export async function usage(url: string) { export async function usage(url: string, signal: AbortSignal) {
url = removePrefix(url); url = removePrefix(url);
const res = await fetchURL(`/api/usage${url}`, {}); const res = await fetchURL(`/api/usage${url}`, { signal });
return await res.json(); try {
return await res.json();
} 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;
}
} }

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

@ -6,7 +6,8 @@ import { encodePath } from "@/utils/url";
export class StatusError extends Error { export class StatusError extends Error {
constructor( constructor(
message: any, message: any,
public status?: number public status?: number,
public is_canceled?: boolean
) { ) {
super(message); super(message);
this.name = "StatusError"; this.name = "StatusError";
@ -33,7 +34,11 @@ export async function fetchURL(
}, },
...rest, ...rest,
}); });
} catch { } 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 new StatusError("000 No connection", 0); throw new StatusError("000 No connection", 0);
} }

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

@ -129,6 +129,7 @@ import {
import { files as api } from "@/api"; import { files as api } from "@/api";
import ProgressBar from "@/components/ProgressBar.vue"; import ProgressBar from "@/components/ProgressBar.vue";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { StatusError } from "@/api/utils.js";
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 }; const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
@ -136,7 +137,7 @@ export default {
name: "sidebar", name: "sidebar",
setup() { setup() {
const usage = reactive(USAGE_DEFAULT); const usage = reactive(USAGE_DEFAULT);
return { usage }; return { usage, usageAbortController: new AbortController() };
}, },
components: { components: {
ProgressBar, ProgressBar,
@ -157,6 +158,9 @@ export default {
}, },
methods: { methods: {
...mapActions(useLayoutStore, ["closeHovers", "showHover"]), ...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
abortOngoingFetchUsage() {
this.usageAbortController.abort();
},
async fetchUsage() { async fetchUsage() {
const path = this.$route.path.endsWith("/") const path = this.$route.path.endsWith("/")
? this.$route.path ? this.$route.path
@ -166,13 +170,18 @@ export default {
return Object.assign(this.usage, usageStats); return Object.assign(this.usage, usageStats);
} }
try { try {
const usage = await api.usage(path); this.abortOngoingFetchUsage();
this.usageAbortController = new AbortController();
const usage = await api.usage(path, this.usageAbortController.signal);
usageStats = { usageStats = {
used: prettyBytes(usage.used, { binary: true }), used: prettyBytes(usage.used, { binary: true }),
total: prettyBytes(usage.total, { binary: true }), total: prettyBytes(usage.total, { binary: true }),
usedPercentage: Math.round((usage.used / usage.total) * 100), usedPercentage: Math.round((usage.used / usage.total) * 100),
}; };
} catch (error) { } catch (error) {
if (error instanceof StatusError && error.is_canceled) {
return;
}
this.$showError(error); this.$showError(error);
} }
return Object.assign(this.usage, usageStats); return Object.assign(this.usage, usageStats);
@ -200,5 +209,8 @@ export default {
immediate: true, immediate: true,
}, },
}, },
unmounted() {
this.abortOngoingFetchUsage();
},
}; };
</script> </script>

View File

@ -31,6 +31,7 @@ import { useFileStore } from "@/stores/file";
import url from "@/utils/url"; import url from "@/utils/url";
import { files } from "@/api"; import { files } from "@/api";
import { StatusError } from "@/api/utils.js";
export default { export default {
name: "file-list", name: "file-list",
@ -43,6 +44,7 @@ export default {
}, },
selected: null, selected: null,
current: window.location.pathname, current: window.location.pathname,
nextAbortController: new AbortController(),
}; };
}, },
inject: ["$showError"], inject: ["$showError"],
@ -56,7 +58,13 @@ export default {
mounted() { mounted() {
this.fillOptions(this.req); this.fillOptions(this.req);
}, },
unmounted() {
this.abortOngoingNext();
},
methods: { methods: {
abortOngoingNext() {
this.nextAbortController.abort();
},
fillOptions(req) { fillOptions(req) {
// Sets the current path and resets // Sets the current path and resets
// the current items. // the current items.
@ -94,8 +102,17 @@ export default {
// just clicked in and fill the options with its // just clicked in and fill the options with its
// content. // content.
const uri = event.currentTarget.dataset.url; const uri = event.currentTarget.dataset.url;
this.abortOngoingNext();
files.fetch(uri).then(this.fillOptions).catch(this.$showError); this.nextAbortController = new AbortController();
files
.fetch(uri, this.nextAbortController.signal)
.then(this.fillOptions)
.catch((e) => {
if (e instanceof StatusError && e.is_canceled) {
return;
}
this.$showError(e);
});
}, },
touchstart(event) { touchstart(event) {
const url = event.currentTarget.dataset.url; const url = event.currentTarget.dataset.url;

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

@ -10,6 +10,7 @@ interface ApiOpts {
method?: ApiMethod; method?: ApiMethod;
headers?: object; headers?: object;
body?: any; body?: any;
signal?: AbortSignal;
} }
interface TusSettings { interface TusSettings {

View File

@ -61,9 +61,7 @@ const route = useRoute();
const { t } = useI18n({}); const { t } = useI18n({});
const clean = (path: string) => { let fetchDataController = new AbortController();
return path.endsWith("/") ? path.slice(0, -1) : path;
};
const error = ref<StatusError | null>(null); const error = ref<StatusError | null>(null);
@ -101,6 +99,7 @@ onUnmounted(() => {
layoutStore.toggleShell(); layoutStore.toggleShell();
} }
fileStore.updateRequest(null); fileStore.updateRequest(null);
fetchDataController.abort();
}); });
watch(route, (to, from) => { watch(route, (to, from) => {
@ -142,20 +141,21 @@ const fetchData = async () => {
let url = route.path; let url = route.path;
if (url === "") url = "/"; if (url === "") url = "/";
if (url[0] !== "/") url = "/" + url; if (url[0] !== "/") url = "/" + url;
// Cancel the ongoing request
fetchDataController.abort();
fetchDataController = new AbortController();
try { try {
const res = await api.fetch(url); const res = await api.fetch(url, fetchDataController.signal);
if (clean(res.path) !== clean(`/${[...route.params.path].join("/")}`)) {
throw new Error("Data Mismatch!");
}
fileStore.updateRequest(res); fileStore.updateRequest(res);
document.title = `${res.name} - ${t("files.files")} - ${name}`; document.title = `${res.name} - ${t("files.files")} - ${name}`;
layoutStore.loading = false;
} catch (err) { } catch (err) {
if (err instanceof StatusError && err.is_canceled) {
return;
}
if (err instanceof Error) { if (err instanceof Error) {
error.value = err; error.value = err;
} }
} finally {
layoutStore.loading = false; layoutStore.loading = false;
} }
}; };

View File

@ -1,28 +1,82 @@
package http package http
import ( import (
"context"
"encoding/json"
"errors"
"net/http" "net/http"
"os" "os"
"sync"
"time"
"github.com/filebrowser/filebrowser/v2/search" "github.com/filebrowser/filebrowser/v2/search"
) )
const searchPingInterval = 5
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(searchPingInterval * 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)
if err != nil { wg.Wait()
if err == nil {
err = context.Cause(ctx)
}
// ignore cancellation errors from user aborts
if err != nil && !errors.Is(err, context.Canceled) {
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,17 @@ 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)