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 { baseURL } from "@/utils/constants";
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);
const res = await fetchURL(`/api/resources${url}`, { signal });
const res = await fetchURL(`/api/resources${url}`, {});
const data = (await res.json()) as Resource;
let data: Resource;
try {
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}`;
if (data.isDir) {
@ -210,10 +218,18 @@ export function getSubtitlesURL(file: ResourceItem) {
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);
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";
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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,82 @@
package http
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"sync"
"time"
"github.com/filebrowser/filebrowser/v2/search"
)
const searchPingInterval = 5
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")
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)
})
if err != nil {
close(response)
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 renderJSON(w, r, response)
return 0, nil
})

View File

@ -1,6 +1,7 @@
package search
import (
"context"
"os"
"path"
"path/filepath"
@ -18,13 +19,17 @@ 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)