diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 928f5282..b79f7257 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -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; + } } 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/api/utils.ts b/frontend/src/api/utils.ts index 7008e28a..3c730972 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -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); } 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/components/Sidebar.vue b/frontend/src/components/Sidebar.vue index 4d55cf0f..8991b571 100644 --- a/frontend/src/components/Sidebar.vue +++ b/frontend/src/components/Sidebar.vue @@ -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(); + }, }; diff --git a/frontend/src/components/prompts/FileList.vue b/frontend/src/components/prompts/FileList.vue index 6a10a127..2fd5ec71 100644 --- a/frontend/src/components/prompts/FileList.vue +++ b/frontend/src/components/prompts/FileList.vue @@ -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; 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/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index 66685e5e..c1592a21 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -10,6 +10,7 @@ interface ApiOpts { method?: ApiMethod; headers?: object; body?: any; + signal?: AbortSignal; } interface TusSettings { diff --git a/frontend/src/views/Files.vue b/frontend/src/views/Files.vue index e05dbed9..54f0236c 100644 --- a/frontend/src/views/Files.vue +++ b/frontend/src/views/Files.vue @@ -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