Compare commits

...
Author SHA1 Message Date
Graham Steffaniak 0ebfde8b6a fixed linting 2026-11-14 20:43:50 -06:00
Graham SteffaniakandCursor 0fd08cf795 test(frontend): drop stale .js tests deleted by be2b16b0d
ContextMenu.advancedSearch/copy, FileList.parentNavigation, General.leave,
ListingView.drop/paste (.js) predate the TS/composition-API pass; their
successors are the .ts tests (restored in the previous commits for the three
that be2b16b0d dropped, already live for copy/drop/paste).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:34:15 -06:00
Graham SteffaniakandCursor deb14f9b57 test(frontend): restore deleted tests as TS, consolidate clipboard test
- FileList.parentNavigation: mount <script setup> via createApp + DOM events;
  mock useListingVirtualization as all-items (jsdom has no layout).
- ContextMenu.advancedSearch: mount with props.items, assert on the rendered
  action icons (select-multiple, share <=1 gate, create exits hidden).
- General.leave: call collapseLeave/collapseEnter from @/utils/transitions.
- useListingClipboard: import from ../useListingClipboard.ts in __tests__/
  and drive copyCut(event,key) instead of the removed test-only seam
  (__reset/__setClipboardForTest are gone from the public module).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:32:29 -06:00
Graham SteffaniakandCursor 712c39d502 fix(files): single-click '..' navigation in picker (#3355)
In browse mode the '..' row is the parent shortcut. A single click now
selects and navigates (select + next), with event.detail>1 swallowing the
second half of a double-click and path.value===item.path swallowing clicks
on a replaced row. Double-click on '..' stays a no-op in browse mode
(main's (!fileList || path===item.path) guard), so no extra fetch fires.

Fixes the Playwright file-actions spec (file-actions.spec.ts:121).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:32:22 -06:00
Graham SteffaniakandCursor 8c5de53225 chore(config): regenerate generated settings config
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:32:05 -06:00
Graham SteffaniakandCursor 200a4c99af fix(frontend): restore cross-platform rolldown entries in package-lock
Regenerate the lockfile from dev's baseline so npm ci installs native
@rolldown bindings on arm64 Docker builders (slim image frontend build).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-14 20:31:57 -06:00
19 changed files with 2633 additions and 402 deletions

No files matched your search

+2110
View File
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -50,6 +50,7 @@ server:
configured: false # true when explicitly set (allows intentional deny-all)
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
cacheDirMaxSizeMB: 0 # maximum size in MB of the preview cache (cacheDir/diskcache). Past it, the least recently used files are deleted. 0 means no limit (default: 0)
maxArchiveSize: 20 # maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
filesystem: # filesystem settings
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
@@ -224,6 +225,7 @@ userDefaults: # optional signup/CLI defaults; per-us
uploadChunkSizeMb: 10
clearAll: false
downloadChunkSizeMb: 0
uploadStallTimeoutSeconds: 10 # seconds without progress before an upload is paused, 0 uses the default (10)
account:
permissions:
api: false # allow api access
@@ -296,9 +298,12 @@ http:
trustProxyHeaders: false # honor X-Forwarded-* and X-Real-IP from a reverse proxy (default: false)
trustedProxies: # IPs/CIDRs of trusted proxies; forwarded client headers are only honored when the socket peer matches one of these (default: empty = forwarded headers ignored)
unixSocketTrustedUIDs: # UnixSocketTrustedUIDs and UnixSocketTrustedGIDs list peer credentials (Linux SO_PEERCRED) allowed to
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, forwarded headers
# are not trusted on Unix sockets. Abstract sockets (@name) never trust forwarded headers.
# present X-Forwarded-* on a filesystem http.socket listener. When both are empty, peers are not trusted
# unless UnixSocketTrustAllLocalPeers is true (legacy; startup logs a warning). A GID allowlist trusts
# every peer with that GID, not only a single reverse-proxy process. Abstract sockets (@name) never trust
# forwarded headers.
unixSocketTrustedGIDs:
unixSocketTrustAllLocalPeers: false # default false: set true to trust forwarded headers from any local filesystem socket peer when allowlists are empty
socketFileMode: "" # octal mode for filesystem http.socket (default "0660"); ignored for abstract sockets
socketGroup: "" # optional group name or numeric gid for the socket file (filesystem sockets only)
disableRateLimit: false # turns off built-in auth route rate limiting and failed-login lockout (default false).
+2
View File
@@ -804,6 +804,7 @@ async function downloadChunked(file, shareHash = "") {
* @param {((percent: number) => void) | undefined} [onupload]
* @param {Record<string, string | number>} [headers]
* @param {boolean} [isDir]
* @param {(() => void) | undefined} [onsent]
* @returns {Promise<any> & { xhr?: XMLHttpRequest | null, abort?: () => void }}
*/
export function post(
@@ -1431,6 +1432,7 @@ export function getTextPreviewURLPublic(path, viewToken = state.req?.viewToken)
* @param {((percent: number) => void) | undefined} [onupload]
* @param {Record<string, string | number>} [headers]
* @param {boolean} [isDir]
* @param {(() => void) | undefined} [onsent]
* @returns {Promise<any> & { xhr?: XMLHttpRequest | null, abort?: () => void }}
*/
export function postPublic(
@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { store } = vi.hoisted(() => ({ store: { tool: null, showSelectMultiple: false, mobile: false } }));
vi.mock("@/store", () => ({
state: {
get user() {
return { showSelectMultiple: store.showSelectMultiple };
},
},
mutations: {},
getters: {
currentTool: () => store.tool,
isMobile: () => store.mobile,
},
}));
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
vi.mock("@/notify", () => ({ notify: {} }));
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
vi.mock("@/utils", () => ({ url: {} }));
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
import ContextMenu from "./ContextMenu.vue";
const { computed } = ContextMenu;
const inAdvancedSearch = () => computed.isAdvancedSearch.call({});
describe("ContextMenu in advanced search", () => {
beforeEach(() => {
store.tool = null;
store.showSelectMultiple = false;
store.mobile = false;
});
it("detects the advanced search tool", () => {
expect(inAdvancedSearch()).toBe(false);
store.tool = { component: "DuplicateFinder" };
expect(inAdvancedSearch()).toBe(false);
store.tool = { component: "AdvancedSearch" };
expect(inAdvancedSearch()).toBe(true);
});
it("offers Select multiple on desktop even when the user preference is off", () => {
const menu = (isAdvancedSearch) => ({ showLimitedOptions: false, isMultiple: false, isSearchActive: false, isAdvancedSearch });
expect(computed.showSelectMultiple.call(menu(false))).toBe(false);
expect(computed.showSelectMultiple.call(menu(true))).toBe(true);
});
it("doesn't offer Select multiple once multiple mode is on", () => {
expect(
computed.showSelectMultiple.call({ showLimitedOptions: false, isMultiple: true, isSearchActive: false, isAdvancedSearch: true }),
).toBe(false);
});
it("hides the create (+) toggle, since results have no single folder to create in", () => {
const toggle = (isAdvancedSearch) =>
computed.showCreateToggle.call({ showLimitedOptions: false, createOnly: false, isSearchActive: false, isAdvancedSearch });
expect(toggle(false)).toBe(true);
expect(toggle(true)).toBe(false);
});
it("hides Share when nothing is selected, since the results listing has no folder to share", () => {
const share = (isAdvancedSearch, selectedCount) =>
computed.showShareAction.call({ showLimitedOptions: false, isAdvancedSearch, selectedCount, showShare: true });
expect(share(true, 0)).toBe(false);
expect(share(true, 1)).toBe(true);
expect(share(true, 2)).toBe(false);
expect(share(false, 0)).toBe(true);
});
});
@@ -0,0 +1,173 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, h, type App } from "vue";
const { store } = vi.hoisted(() => ({
store: {
tool: null as null | { component: string },
showSelectMultiple: false,
mobile: false,
share: false,
multiple: false,
selected: [] as string[],
selectedCount: 0,
perms: { view: true, download: true, modify: true, create: true, delete: true, share: true },
},
}));
vi.mock("@/store", () => ({
state: {
selected: [],
multiple: false,
isSearchActive: false,
shareInfo: {},
navigation: {},
sources: { current: "data" },
req: { items: [], source: "data", path: "/" },
user: {
get showSelectMultiple() {
return store.showSelectMultiple;
},
get templates() {
return [];
},
},
},
mutations: {
setContextMenuHasItems: vi.fn(),
setMultiple: vi.fn(),
closeHovers: vi.fn(),
closeTopPrompt: vi.fn(),
showPrompt: vi.fn(),
selectAllItems: vi.fn(),
},
getters: {
isShare: () => store.share,
canCreateInSource: () => true,
globalPermissions: () => ({ share: true, admin: false, create: true }),
sourcePermissions: () => store.perms,
currentPromptName: () => "ContextMenu",
currentPrompt: () => ({ props: {} }),
currentTool: () => store.tool,
currentView: () => "listingView",
isMobile: () => store.mobile,
isAdmin: () => false,
isLoggedIn: () => true,
isPreviewView: () => false,
isSplitViewActive: () => false,
},
}));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) }));
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
vi.mock("@/notify", () => ({ notify: {} }));
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
vi.mock("@/utils", () => ({ url: {} }));
vi.mock("@/utils/url.js", () => ({ goToItem: vi.fn(), getParentDir: vi.fn(), removeLastDir: vi.fn() }));
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: () => false, nativeShareFile: vi.fn() }));
vi.mock("@/utils/mimetype", () => ({
isRichTextPreviewMimeType: () => false,
getTypeInfoFromExt: () => ({ materialSymbol: "", classes: "" }),
}));
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: () => false }));
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
// transitions import (popEnter/popLeave on mount) — leave real, WAAPI absent in jsdom.
import ContextMenu from "./ContextMenu.vue";
let app: App | null = null;
let host: HTMLElement | null = null;
/** Icons of the actions the menu offers, like ContextMenu.copy.test.ts does. */
function offeredIcons(items?: Array<{ name: string; path: string; source: string; isDir?: boolean }>): string[] {
host = document.createElement("div");
document.body.append(host);
app = createApp({
render: () =>
h(ContextMenu, {
items: items ?? [
{ name: "a.txt", path: "/a.txt", source: "data" },
{ name: "b.txt", path: "/b.txt", source: "data" },
],
}),
});
app.mount(host);
return [...host.querySelectorAll("#context-menu .action > i")].map((el) => el.textContent ?? "");
}
const FILE_ITEMS = [
{ name: "a.txt", path: "/a.txt", source: "data" },
{ name: "b.txt", path: "/b.txt", source: "data" },
];
describe("ContextMenu in advanced search", () => {
beforeEach(() => {
store.tool = null;
store.showSelectMultiple = false;
store.mobile = false;
store.share = false;
store.multiple = false;
store.perms = { view: true, download: true, modify: true, create: true, delete: true, share: true };
});
afterEach(() => {
app?.unmount();
host?.remove();
app = null;
host = null;
});
it("detects the advanced search tool", () => {
expect(offeredIcons(FILE_ITEMS)).not.toContain("check_circle");
store.tool = { component: "DuplicateFinder" };
expect(offeredIcons(FILE_ITEMS)).not.toContain("check_circle");
store.tool = { component: "AdvancedSearch" };
expect(offeredIcons(FILE_ITEMS)).toContain("check_circle");
});
it("offers Select multiple on desktop even when the user preference is off", () => {
store.tool = { component: "AdvancedSearch" };
store.showSelectMultiple = false;
expect(offeredIcons(FILE_ITEMS)).toContain("check_circle");
});
it("offers Select multiple with a single or multiple results", () => {
store.tool = { component: "AdvancedSearch" };
store.showSelectMultiple = false;
const one = [{ name: "a.txt", path: "/a.txt", source: "data" }];
expect(offeredIcons(one)).toContain("check_circle");
expect(offeredIcons(FILE_ITEMS)).toContain("check_circle");
});
it("shows Archive in advanced search (create exits stay hidden)", () => {
store.tool = { component: "AdvancedSearch" };
store.perms.create = true;
// Archive needs showCreate=true (createOnly) — advanced search forces it off, so
// archive surfaces when the create panel is open. We assert the observable surface:
// new-folder/file/upload stay hidden while archive/unarchive become available.
const icons = offeredIcons(FILE_ITEMS);
expect(icons).not.toContain("create_new_folder");
expect(icons).not.toContain("note_add");
expect(icons).not.toContain("file_upload");
});
it("hides Share when nothing is selected, since the results listing has no folder to share", () => {
// selectedCount<=1 gate: one item is shareable, two are not; zero is not in adv search.
const one = [{ name: "a.txt", path: "/a.txt", source: "data" }];
store.tool = { component: "AdvancedSearch" };
store.perms.share = true;
expect(offeredIcons(one)).toContain("share");
expect(offeredIcons(FILE_ITEMS)).not.toContain("share");
expect(offeredIcons([])).not.toContain("share");
});
it("in the listing, offers Share even with no items selected", () => {
const one = [{ name: "a.txt", path: "/a.txt", source: "data" }];
store.tool = null;
store.perms.share = true;
expect(offeredIcons(one)).toContain("share");
expect(offeredIcons(FILE_ITEMS)).not.toContain("share");
// Listing allows selectedCount===0 (share the current folder).
expect(offeredIcons([])).toContain("share");
});
});
@@ -1,55 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { store } = vi.hoisted(() => ({
store: { share: false, creatable: [], user: { scopes: [{ name: "data" }, { name: "inbox" }] } },
}));
vi.mock("@/store", () => ({
state: { get user() { return store.user; } },
mutations: {},
getters: {
isShare: () => store.share,
canCreateInSource: (source) => store.creatable.includes(source),
},
}));
vi.mock("@/api", () => ({ resourcesApi: {}, shareApi: {}, usersApi: {} }));
vi.mock("@/components/Action.vue", () => ({ default: { render: () => null } }));
vi.mock("@/notify", () => ({ notify: {} }));
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
vi.mock("@/utils", () => ({ url: {} }));
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: vi.fn() }));
vi.mock("@/utils/constants.js", () => ({ globalVars: {} }));
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
vi.mock("@/utils/nativeShare", () => ({ canNativeShare: vi.fn(), nativeShareFile: vi.fn() }));
vi.mock("@/utils/mimetype", () => ({ isRichTextPreviewMimeType: vi.fn(), getTypeInfoFromExt: vi.fn() }));
vi.mock("@/utils/mediaFile", () => ({ isMediaRequest: vi.fn() }));
vi.mock("@/utils/expandTransition", () => ({ expandBeforeEnter: vi.fn(), expandEnter: vi.fn(), expandLeave: vi.fn() }));
import ContextMenu from "./ContextMenu.vue";
const showCopy = (permissions) =>
ContextMenu.computed.showCopy.call({ showLimitedOptions: false, showCreate: false, selectedCount: 1, permissions });
describe("ContextMenu Copy", () => {
beforeEach(() => {
store.share = false;
store.creatable = ["inbox"];
});
it("is offered in a read-only source, since copying only reads it", () => {
expect(showCopy({ download: true, modify: false })).toBe(true);
});
it("is hidden when no source accepts the copy", () => {
store.creatable = [];
expect(showCopy({ download: true, modify: false })).toBe(false);
});
it("is hidden without download", () => {
expect(showCopy({ download: false, modify: true })).toBe(false);
});
it("in a share, still requires modify, which the server checks for every change", () => {
store.share = true;
expect(showCopy({ download: true, modify: false })).toBe(false);
expect(showCopy({ download: true, modify: true })).toBe(true);
});
});
@@ -1,112 +0,0 @@
import { createApp, h, nextTick } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
const { fetchFiles, state } = vi.hoisted(() => ({
fetchFiles: vi.fn(),
state: { req: {}, prompts: [], selected: [], sources: { info: { data: {} } } },
}));
vi.mock("@/api", () => ({ resourcesApi: { fetchFiles } }));
vi.mock("@/store", () => ({
state,
mutations: {},
getters: {
isShare: () => false,
isMobile: () => false,
currentPromptName: () => null,
pickerSorting: () => ({ by: "name", asc: true }),
},
}));
vi.mock("@/utils", async () => {
const { removeLastDir } = await import("@/utils/url.js");
return { url: { removeLastDir } };
});
vi.mock("@/router/index.ts", () => ({ router: {} }));
vi.mock("@/components/files/ListingHeader.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/files/ListingItem.vue", () => ({
default: {
props: ["name"],
render() {
return h("button", {}, this.name);
},
},
}));
import FileList from "./FileList.vue";
let app, root;
const selection = vi.fn();
function mount(items = [], path = "/child/") {
state.req = { source: "data", path, items };
root = document.createElement("div");
document.body.append(root);
app = createApp(FileList, { hidePathChrome: true, "onUpdate:selected": selection });
app.config.globalProperties.$t = (key) => key;
app.mount(root);
}
function row(name) {
return Array.from(root.querySelectorAll("button")).find((button) => button.textContent === name);
}
afterEach(() => {
app?.unmount();
root?.remove();
fetchFiles.mockReset();
selection.mockReset();
});
describe("file picker parent navigation", () => {
it("loads the parent and shows sibling folders after one click on '..'", async () => {
mount();
fetchFiles.mockResolvedValue({
source: "data",
items: [{ name: "sibling", path: "/sibling/", type: "directory" }],
});
await nextTick();
row("..").click();
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/", source: "data" }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
await vi.waitFor(() => expect(row("sibling")).toBeDefined());
});
it("keeps single-click selection and double-click navigation for ordinary folders", async () => {
mount([{ name: "nested", path: "/child/nested/", type: "directory" }]);
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
row("nested").click();
expect(fetchFiles).not.toHaveBeenCalled();
row("nested").dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/child/nested/");
});
it("does not fetch the parent again when the same row receives a double-click", async () => {
mount();
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
const parent = row("..");
parent.click();
parent.click();
parent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/");
});
it("ignores the rest of a double-click on a replacement parent row", async () => {
mount([], "/parent/child/");
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
const firstParent = row("..");
firstParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
await vi.waitFor(() => {
expect(row("..")).toBeDefined();
expect(row("..")).not.toBe(firstParent);
});
const nextParent = row("..");
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 2 }));
nextParent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 }));
expect(fetchFiles).toHaveBeenCalledExactlyOnceWith("data", "/parent/");
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
expect(fetchFiles).toHaveBeenLastCalledWith("data", "/");
expect(fetchFiles).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,171 @@
import { createApp, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
const { fetchFiles, state } = vi.hoisted(() => ({
fetchFiles: vi.fn(),
state: { req: {}, prompts: [], selected: [], sources: { info: { data: {} } } },
}));
// The virtual composable needs measured DOM (jsdom has none), so it just forwards
// groups() as all-items — same seam as the real `useListingVirtualization`.
vi.mock("@/api", () => ({ resourcesApi: { fetchFiles } }));
vi.mock("@/composables/useListingVirtualization.ts", () => ({
findScrollParents: () => [],
findScroller: () => null,
useListingVirtualization: (options: { groups: () => Array<{ key: string; count: number; at: (index: number) => unknown }> }) => ({
active: { value: false },
group: (key: string) => {
const group = options.groups().find((g) => g.key === key);
if (!group) return { items: [], position: 0, style: undefined };
const items: unknown[] = [];
for (let i = 0; i < group.count; i++) items.push(group.at(i));
return { items, position: 0, style: undefined };
},
scrollToIndex: vi.fn(),
}),
}));
vi.mock("@/store", () => ({
state,
mutations: {
resetSelected: vi.fn(),
setSelected: vi.fn(),
setMultiple: vi.fn(),
closeTopPrompt: vi.fn(),
setNavigationTransitioning: vi.fn(),
},
getters: {
isShare: () => false,
isMobile: () => false,
shareHash: () => "",
currentPromptName: () => null,
pickerSorting: () => ({ by: "name", asc: true }),
navigationSorting: () => ({ by: "name", asc: true }),
sorting: () => ({ by: "name", asc: true }),
searchNavigationItems: () => null,
},
}));
vi.mock("@/utils", async () => {
const { removeLastDir } = await import("@/utils/url.js");
return { url: { removeLastDir } };
});
vi.mock("@/router/index.ts", () => ({ router: {} }));
vi.mock("@/utils/sort.ts", () => ({
sortedItems: (entries: unknown[]) => entries,
}));
vi.mock("@/components/files/ListingHeader.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/files/ListingItem.vue", () => ({
default: {
props: ["name"],
render(props: { name: string }) {
return h("button", {}, props.name);
},
},
}));
vi.mock("@/components/LoadingSpinner.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/settings/ExpandDropdown.vue", () => ({ default: { render: () => null } }));
import FileList from "./FileList.vue";
let app: App | null = null;
let root: HTMLElement | null = null;
const selection = vi.fn();
function mount(items: unknown[] = [], path = "/child/") {
state.req = { source: "data", path, items };
root = document.createElement("div");
document.body.append(root);
app = createApp(FileList as never, { hidePathChrome: true, "onUpdate:selected": selection });
app.config.globalProperties.$t = (key: string) => key;
app.mount(root);
}
function row(name: string): HTMLElement | undefined {
return Array.from(root?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === name,
) as HTMLElement | undefined;
}
/** Assert-and-return: every spec clicks a row that must exist after mount. */
function mustRow(name: string): HTMLElement {
const found = row(name);
expect(found).toBeDefined();
return found as HTMLElement;
}
afterEach(() => {
app?.unmount();
root?.remove();
app = null;
root = null;
fetchFiles.mockReset();
selection.mockReset();
});
describe("file picker parent navigation", () => {
it("loads the parent and shows sibling folders after one click on '..'", async () => {
mount();
fetchFiles.mockResolvedValue({
source: "data",
items: [{ name: "sibling", path: "/sibling/", type: "directory" }],
});
await nextTick();
mustRow("..").click();
expect(selection).toHaveBeenLastCalledWith(expect.objectContaining({ path: "/", source: "data" }));
expect(fetchFiles).toHaveBeenCalledTimes(1);
expect(fetchFiles).toHaveBeenCalledWith("data", "/");
await vi.waitFor(() => expect(row("sibling")).toBeDefined());
});
it("keeps single-click selection and double-click navigation for ordinary folders", async () => {
mount([{ name: "nested", path: "/child/nested/", type: "directory" }]);
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
mustRow("nested").click();
expect(fetchFiles).not.toHaveBeenCalled();
mustRow("nested").dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect(fetchFiles).toHaveBeenCalledTimes(1);
expect(fetchFiles).toHaveBeenCalledWith("data", "/child/nested/");
});
it("does not fetch the parent again when the same row receives a double-click", async () => {
mount();
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
const parent = mustRow("..");
parent.click();
parent.click();
parent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect(fetchFiles).toHaveBeenCalledTimes(1);
expect(fetchFiles).toHaveBeenCalledWith("data", "/");
});
it("ignores the rest of a double-click on a replacement parent row", async () => {
mount([], "/parent/child/");
fetchFiles.mockResolvedValue({ source: "data", items: [] });
await nextTick();
const firstParent = mustRow("..");
firstParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
// `withLoading` keeps `loading=true` for 200ms, so the listing container is
// unmounted — the `..` row only returns once it clears, then Vue re-renders it.
await vi.waitFor(() => {
expect(row("..")).toBeDefined();
expect(row("..")).not.toBe(firstParent);
});
// The second click of the double-click lands on the *new* `..` row whose path
// is the parent itself, so it must not refetch the parent — the row still
// carries `path="/parent/"`. The count stays at the single "/parent/" fetch.
const nextParent = mustRow("..");
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 2 }));
nextParent.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, detail: 2 }));
expect(fetchFiles.mock.calls).toEqual([["data", "/parent/"]]);
// A fresh single click on the still-mounted `..` row navigates up again,
// to root.
nextParent.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
expect(fetchFiles.mock.calls).toEqual([
["data", "/parent/"],
["data", "/"],
]);
});
});
@@ -554,6 +554,16 @@ function handleItemClick(item: ListEntry, _index: number, event: MouseEvent) {
navigateToItem(item);
return;
}
// Picker #3355: the ".." row is the parent-folder shortcut; a single click
// navigates to it so the copy/move destination picker can go up a level.
// `event.detail > 1` swallows the second half of a double-click, and the
// path check swallows clicks on a stale row that was replaced.
if (item.name === "..") {
if (event.detail > 1 || path.value === item.path) return;
select(item.path);
next(item.path);
return;
}
// Browse mode: single click selects only, emit selected, do not navigate
select(item.path);
}
@@ -561,6 +571,9 @@ function handleItemClick(item: ListEntry, _index: number, event: MouseEvent) {
function handleItemDblClick(item: ListEntry, _index: number, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
// In browse mode ".." is the single-click parent shortcut; a double-click on it
// navigates nothing extra (main's `if (.. && (!fileList || path === item.path)) return`).
if (item.name === ".." && (!props.fileList || path.value === item.path)) return;
next(item.path);
}
@@ -66,9 +66,11 @@ describe("sidebar File actions in advanced search", () => {
expect(mutations.showPrompt).toHaveBeenCalledWith({ name: "ContextMenu", props: { showCentered: true } });
});
it("opens the create-only menu in the listing, as before", () => {
it("opens the create-only menu in the listing, without wiping the selection", () => {
methods.openContextMenu.call({ isAdvancedSearch: false });
expect(mutations.resetSelected).toHaveBeenCalled();
// Kurami32: resetSelected() here lost the caller's selection on mobile when
// using the File actions menu; `createOnly` already restricts the menu.
expect(mutations.resetSelected).not.toHaveBeenCalled();
expect(mutations.showPrompt).toHaveBeenCalledWith({
name: "ContextMenu",
props: { showCentered: true, createOnly: true },
@@ -1,23 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/utils/auth", () => ({ logout: vi.fn() }));
vi.mock("@/utils/constants", () => ({ globalVars: {} }));
vi.mock("@/store", () => ({ state: {}, getters: {}, mutations: {} }));
vi.mock("@/utils/tooltipHelp.js", () => ({ hideInteractiveTooltip: vi.fn(), showHoverTooltip: vi.fn() }));
vi.mock("./Links.vue", () => ({ default: { render: () => null } }));
vi.mock("@/utils", () => ({ url: {} }));
import General from "./General.vue";
describe("sidebar File actions leave transition", () => {
afterEach(() => vi.useRealTimers());
it("finishes even when no transitionend ever fires", () => {
vi.useFakeTimers();
const el = document.createElement("div");
const done = vi.fn();
General.methods.leave(el, done);
expect(done).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(done).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { collapseEnter, collapseLeave } from "@/utils/transitions.ts";
// The old Options API test called `General.methods.leave` — that helper moved to
// `@/utils/transitions` (`collapseLeave`) in the composition-API pass, and there is
// no `components` tree to mount for a 1-liner assertion.
describe("sidebar File actions leave transition", () => {
afterEach(() => vi.useRealTimers());
it("collapseLeave finishes even when no transitionend ever fires", () => {
vi.useFakeTimers();
const el = document.createElement("div");
const done = vi.fn();
collapseLeave(el, done);
expect(done).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(done).toHaveBeenCalledOnce();
});
it("collapseEnter (the showFileActions mount) also finishes", () => {
vi.useFakeTimers();
const el = document.createElement("div");
const done = vi.fn();
collapseEnter(el, done);
expect(done).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(done).toHaveBeenCalledOnce();
});
});
+3 -1
View File
@@ -175,7 +175,9 @@ export default {
mutations.showPrompt({ name: "ContextMenu", props: { showCentered: true } });
return;
}
mutations.resetSelected();
// Listing: keep the caller's selection — `createOnly` already makes the menu
// act on nothing but create options (Kurami32: wiping it lost the selection
// on mobile when using the actions menu).
mutations.showPrompt({
name: "ContextMenu",
props: {
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { store, mutations } = vi.hoisted(() => ({
store: {
share: false,
current: "inbox",
selected: [0] as number[],
items: [
{
path: "/a.txt",
name: "a.txt",
type: "file",
source: "data",
},
] as Array<{ path: string; name: string; type: string; source: string }>,
permissions: {
data: { view: true, download: true, modify: false, create: false, delete: false },
inbox: { view: true, download: true, modify: true, create: true, delete: true },
share: { view: true, download: true, modify: false, create: true, delete: false },
},
},
mutations: { showPrompt: vi.fn() },
}));
vi.mock("@/store", () => ({
state: {
get selected() {
return store.selected;
},
get req() {
return { path: "/", source: store.current, items: store.items };
},
get route() {
return { path: "/other" };
},
get shareInfo() {
return store.share ? { hash: "h" } : null;
},
},
mutations,
getters: {
isShare: () => store.share,
sourcePermissions: (source?: string) =>
store.permissions[(source ?? store.current) as keyof typeof store.permissions],
},
}));
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
vi.mock("@/api", () => ({
resourcesApi: {
moveCopy: vi.fn().mockResolvedValue(undefined),
moveCopyPublic: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("@/router", () => ({ router: {} }));
vi.mock("@/utils/upload.ts", () => ({ checkConflict: vi.fn(() => false) }));
vi.mock("@/utils/appNotifications.js", () => ({ notifyMoveCopyFailure: vi.fn() }));
import { useListingClipboard } from "../useListingClipboard.ts";
const { copyCut, handleInternalPaste } = useListingClipboard();
const fakeEvent = { target: { tagName: "DIV" } } as unknown as KeyboardEvent;
function selectFrom(source: string) {
store.selected = [0];
store.items = [{ path: "/a.txt", name: "a.txt", type: "file", source }];
}
describe("useListingClipboard internal paste permissions", () => {
beforeEach(() => {
vi.clearAllMocks();
store.share = false;
store.current = "inbox";
store.selected = [0];
// clipboard module state is per-import; copyCut in the previous test leaves
// an entry behind for the next — copyCut itself never clears, so resetting
// `state` isn't possible without the test seam. Each test overwrites it
// immediately, so just re-drive copyCut every time.
});
it("pastes a copy out of a read-only source into a writable one", async () => {
selectFrom("data");
copyCut(fakeEvent, "c");
await handleInternalPaste();
expect(mutations.showPrompt).toHaveBeenCalledOnce();
});
it("refuses a cut out of a source that cannot modify", async () => {
selectFrom("data");
copyCut(fakeEvent, "x");
await handleInternalPaste();
expect(mutations.showPrompt).not.toHaveBeenCalled();
});
it("refuses any paste into a source that cannot create", async () => {
store.current = "data";
selectFrom("inbox");
copyCut(fakeEvent, "c");
await handleInternalPaste();
expect(mutations.showPrompt).not.toHaveBeenCalled();
});
it("in a share, requires modify even for a copy", async () => {
store.share = true;
store.permissions.share.modify = false;
store.permissions.share.create = true;
store.current = "share";
selectFrom("share");
copyCut(fakeEvent, "c");
await handleInternalPaste();
expect(mutations.showPrompt).not.toHaveBeenCalled();
});
});
+4
View File
@@ -638,6 +638,7 @@ export const mutations = {
state.user.fileLoading = {
maxConcurrentUpload: 3,
uploadChunkSizeMb: 5,
uploadStallTimeoutSeconds: 10,
downloadChunkSizeMb: 0,
clearAll: false
};
@@ -655,6 +656,9 @@ export const mutations = {
if (state.user.fileLoading.downloadChunkSizeMb === undefined) {
state.user.fileLoading.downloadChunkSizeMb = 0;
}
if (state.user.fileLoading.uploadStallTimeoutSeconds === undefined) {
state.user.fileLoading.uploadStallTimeoutSeconds = 10;
}
}
// Load stored values or use defaults as fallback
+1
View File
@@ -286,6 +286,7 @@ export interface UserObject {
fileLoading?: {
maxConcurrentUpload?: number;
uploadChunkSizeMb?: number;
uploadStallTimeoutSeconds?: number;
clearAll?: boolean;
downloadChunkSizeMb?: number;
maxConcurrent?: number;
+3 -3
View File
@@ -329,9 +329,9 @@ class UploadManager {
try {
const testPath = `${basePath}${dirName}`;
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, testPath, new Blob([]), false, undefined, {}, true);
await resourcesApi.postPublic(state.shareInfo?.hash, testPath, new Blob([]), false, undefined, {}, true, undefined);
} else {
await resourcesApi.post(state.req?.source, testPath, new Blob([]), false, undefined, {}, true);
await resourcesApi.post(state.req?.source, testPath, new Blob([]), false, undefined, {}, true, undefined);
}
// No conflict - directory was created successfully
// Mark it so we can skip it later in the queue
@@ -839,7 +839,7 @@ class UploadManager {
}
progressTimeoutMs(): number {
const seconds = state.user?.fileLoading?.uploadStallTimeoutSeconds;
const seconds = state.user?.fileLoading?.uploadStallTimeoutSeconds ?? 0;
return seconds > 0 ? seconds * 1000 : this.PROGRESS_TIMEOUT_MS;
}
@@ -1,55 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const { store } = vi.hoisted(() => ({ store: { create: false } }));
vi.mock("@/store", () => ({
state: { selected: [], isSafari: false },
mutations: { setSearch: vi.fn() },
getters: { isShare: () => false, sourcePermissions: () => ({ create: store.create }) },
}));
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
vi.mock("@/api", () => ({ resourcesApi: {} }));
vi.mock("@/router", () => ({ router: {} }));
vi.mock("@/utils/upload", () => ({ readAllDirectoryEntries: vi.fn(), checkConflict: vi.fn() }));
vi.mock("@/utils/throttle", () => ({ default: (fn) => fn }));
vi.mock("@/utils", () => ({ url: {} }));
vi.mock("@/components/files/ListingItem.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/prompts/Upload.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/LoadingSpinner.vue", () => ({ default: { render: () => null } }));
vi.mock("@/utils/listingTypeAhead.js", () => ({
isTypeAheadSessionActive: vi.fn(),
processTypeAheadKey: vi.fn(),
resetTypeAheadSession: vi.fn(),
}));
vi.mock("@/utils/appNotifications", () => ({ notifyMoveCopyFailure: vi.fn() }));
import ListingView from "./ListingView.vue";
const dropEvents = ["dragenter", "dragleave", "drop"];
const types = (spy) => spy.mock.calls.map(([type]) => type);
describe("ListingView drop listeners", () => {
// The listing is not remounted when navigating between sources, so a user who lands on a source
// they cannot write to must still get a drop target once they move to one they can.
it("are attached and removed even when the user cannot write at mount", () => {
const el = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const ctx = { $el: el, permissions: { modify: false, create: false } };
ListingView.mounted.call(ctx);
expect(types(el.addEventListener)).toEqual(expect.arrayContaining(dropEvents));
ListingView.beforeUnmount.call(ctx);
expect(types(el.removeEventListener)).toEqual(expect.arrayContaining(dropEvents));
});
// dragEnter shows the drop indicator; if the source is found read-only before the drop, the
// refused drop must still take the indicator down (browsers send no dragleave on drop).
it("clear the drop indicator when the drop is refused", async () => {
const el = {};
const ctx = { $el: el, dragTargets: new Set([el]) };
const event = { preventDefault: vi.fn(), dataTransfer: { types: ["Files"] } };
store.create = false;
await ListingView.methods.drop.call(ctx, event);
expect(ctx.dragTargets.size).toBe(0);
});
});
@@ -1,74 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { store, mutations } = vi.hoisted(() => ({
store: {
share: false,
current: "inbox",
permissions: {
data: { view: true, download: true, modify: false, create: false, delete: false },
inbox: { view: true, download: true, modify: true, create: true, delete: true },
},
},
mutations: { showPrompt: vi.fn() },
}));
vi.mock("@/store", () => ({
state: { get req() { return { path: "/", source: store.current }; } },
mutations,
getters: {
isShare: () => store.share,
sourcePermissions: (source) => store.permissions[source ?? store.current],
},
}));
vi.mock("@/utils/download", () => ({ default: vi.fn() }));
vi.mock("@/api", () => ({ resourcesApi: {} }));
vi.mock("@/router", () => ({ router: {} }));
vi.mock("@/utils/upload", () => ({ readAllDirectoryEntries: vi.fn(), checkConflict: vi.fn() }));
vi.mock("@/utils/throttle", () => ({ default: (fn) => fn }));
vi.mock("@/utils", () => ({ url: {} }));
vi.mock("@/components/files/ListingItem.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/prompts/Upload.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/LoadingSpinner.vue", () => ({ default: { render: () => null } }));
vi.mock("@/utils/listingTypeAhead.js", () => ({
isTypeAheadSessionActive: vi.fn(),
processTypeAheadKey: vi.fn(),
resetTypeAheadSession: vi.fn(),
}));
vi.mock("@/utils/appNotifications", () => ({ notifyMoveCopyFailure: vi.fn() }));
import ListingView from "./ListingView.vue";
const paste = (key, fromSource) =>
ListingView.methods.handleInternalPaste.call({
clipboard: { key, items: [{ from: "/a.txt", fromSource, name: "a.txt" }] },
});
describe("ListingView internal paste permissions", () => {
beforeEach(() => {
vi.clearAllMocks();
store.share = false;
store.current = "inbox";
});
it("pastes a copy out of a read-only source into a writable one", async () => {
await paste("c", "data");
expect(mutations.showPrompt).toHaveBeenCalledOnce();
});
it("refuses a cut out of a source that cannot modify", async () => {
await paste("x", "data");
expect(mutations.showPrompt).not.toHaveBeenCalled();
});
it("refuses any paste into a source that cannot create", async () => {
store.current = "data";
await paste("c", "inbox");
expect(mutations.showPrompt).not.toHaveBeenCalled();
});
it("in a share, requires modify even for a copy", async () => {
store.share = true;
store.permissions.share = { download: true, modify: false, create: true };
store.current = "share";
await paste("c", "share");
expect(mutations.showPrompt).not.toHaveBeenCalled();
});
});