diff --git a/apps/desktop/src/components/plugins/PluginFileManager.spec.ts b/apps/desktop/src/components/plugins/PluginFileManager.spec.ts new file mode 100644 index 0000000000..edacfa1811 --- /dev/null +++ b/apps/desktop/src/components/plugins/PluginFileManager.spec.ts @@ -0,0 +1,149 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginFilesystemProviderContribution } from "@/types/database"; + +const api = vi.hoisted(() => ({ + listPluginFilesystemEntries: vi.fn(), + readPluginFilesystemFile: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => api); +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock("vue-virtual-scroller", () => ({ + RecycleScroller: defineComponent({ + inheritAttrs: false, + props: { items: { type: Array, default: () => [] } }, + setup(props, { attrs, slots }) { + return () => h("div", attrs, (props.items as unknown[]).flatMap((item) => slots.default?.({ item }) ?? [])); + }, + }), +})); + +import PluginFileManager from "./PluginFileManager.vue"; + +const SPLIT_SIZE_KEY = "dbx-plugin-file-manager-split-size"; +const provider: PluginFilesystemProviderContribution = { + type: "filesystem-provider", + id: "ftp.files", + label: "FTP files", + schemes: ["ftp"], + root_uri: "ftp:/", + capabilities: ["read"], +}; + +let app: App | undefined; +let root: HTMLDivElement; + +async function flushUi() { + await Promise.resolve(); + await Promise.resolve(); + await nextTick(); +} + +async function mountFileManager() { + root = document.createElement("div"); + root.style.width = "300px"; + document.body.appendChild(root); + app = createApp(PluginFileManager, { pluginId: "io.dbx.ftp", provider, connectionId: "ftp-connection" }); + app.mount(root); + await flushUi(); + return root; +} + +function pointer(target: EventTarget, type: string, clientX: number) { + target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, button: 0, clientX })); +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + api.listPluginFilesystemEntries.mockResolvedValue({ + entries: [{ name: "release-notes.txt", uri: "ftp:/release-notes.txt", kind: "file", size: 2048, modifiedAt: "2026-09-27T10:11:12Z", contentType: "text/plain" }], + }); + api.readPluginFilesystemFile.mockResolvedValue({ dataBase64: btoa("preview contents"), contentType: "text/plain", truncated: false }); +}); + +afterEach(() => { + app?.unmount(); + app = undefined; + document.body.replaceChildren(); + document.body.classList.remove("dbx-is-resizing"); +}); + +describe("PluginFileManager", () => { + it("keeps a narrow list inside its pane without the legacy 28rem minimum", async () => { + const container = await mountFileManager(); + const panes = container.querySelector("[data-file-manager-panes]")!; + const list = container.querySelector("[data-file-list]")!; + const row = container.querySelector("[data-file-entry-uri]")!; + const separator = container.querySelector("[data-file-preview-resize]")!; + + expect(panes.classList).toContain("min-w-0"); + expect(panes.classList).toContain("overflow-hidden"); + expect(list.classList).toContain("min-w-0"); + expect(list.className).not.toContain("min-w-[28rem]"); + expect(row.classList).toContain("min-w-0"); + expect(separator.getAttribute("role")).toBe("separator"); + expect(separator.getAttribute("aria-valuenow")).toBe("42"); + expect(panes.style.gridTemplateColumns).toContain("42fr"); + }); + + it("resizes with pointer and keyboard input, clamps, and restores the persisted split", async () => { + let container = await mountFileManager(); + let panes = container.querySelector("[data-file-manager-panes]")!; + let separator = container.querySelector("[data-file-preview-resize]")!; + vi.spyOn(panes, "getBoundingClientRect").mockReturnValue({ width: 1000 } as DOMRect); + vi.spyOn(separator, "getBoundingClientRect").mockReturnValue({ width: 6 } as DOMRect); + + pointer(separator, "pointerdown", 420); + pointer(window, "pointermove", 1200); + await nextTick(); + expect(separator.getAttribute("aria-valuenow")).toBe("80"); + expect(document.body.classList).toContain("dbx-is-resizing"); + pointer(window, "pointerup", 1200); + await nextTick(); + expect(localStorage.getItem(SPLIT_SIZE_KEY)).toBe("80"); + expect(document.body.classList).not.toContain("dbx-is-resizing"); + + separator.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight" })); + expect(separator.getAttribute("aria-valuenow")).toBe("80"); + separator.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Home" })); + await nextTick(); + expect(separator.getAttribute("aria-valuenow")).toBe("20"); + separator.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowLeft" })); + expect(separator.getAttribute("aria-valuenow")).toBe("20"); + expect(localStorage.getItem(SPLIT_SIZE_KEY)).toBe("20"); + + app!.unmount(); + app = undefined; + root.remove(); + container = await mountFileManager(); + panes = container.querySelector("[data-file-manager-panes]")!; + separator = container.querySelector("[data-file-preview-resize]")!; + expect(separator.getAttribute("aria-valuenow")).toBe("20"); + expect(panes.style.gridTemplateColumns).toContain("20fr"); + + separator.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); + await nextTick(); + expect(separator.getAttribute("aria-valuenow")).toBe("42"); + expect(localStorage.getItem(SPLIT_SIZE_KEY)).toBeNull(); + }); + + it("preserves file selection and bounded text preview behavior", async () => { + const container = await mountFileManager(); + const row = container.querySelector('[data-file-entry-uri="ftp:/release-notes.txt"]')!; + + row.click(); + await nextTick(); + expect(container.textContent).toContain("release-notes.txt"); + expect(container.textContent).toContain("pluginPlatform.previewSelectedFileHint"); + + row.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); + await flushUi(); + expect(api.readPluginFilesystemFile).toHaveBeenCalledWith("io.dbx.ftp", "ftp.files", "ftp:/release-notes.txt", { connectionId: "ftp-connection", maxBytes: 256 * 1024 }); + expect(container.textContent).toContain("preview contents"); + expect(api.listPluginFilesystemEntries).toHaveBeenCalledWith("io.dbx.ftp", "ftp.files", expect.objectContaining({ connectionId: "ftp-connection", uri: "ftp:/", limit: 200 })); + }); +}); diff --git a/apps/desktop/src/components/plugins/PluginFileManager.vue b/apps/desktop/src/components/plugins/PluginFileManager.vue index 1a922ff739..b5aa313bc2 100644 --- a/apps/desktop/src/components/plugins/PluginFileManager.vue +++ b/apps/desktop/src/components/plugins/PluginFileManager.vue @@ -5,10 +5,27 @@ import { AlertTriangle, ArrowUp, File, FileCode2, Folder, Loader2, RefreshCw } f import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import * as api from "@/lib/backend/api"; +import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState"; +import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries, uniquePluginFilesystemEntries } from "@/lib/plugins/pluginFilesystem"; import type { PluginFilesystemEntry, PluginFilesystemProviderContribution } from "@/types/database"; import { useI18n } from "vue-i18n"; +const FILE_LIST_SIZE_STORAGE_KEY = "dbx-plugin-file-manager-split-size"; +const FILE_LIST_DEFAULT_SIZE = 42; +const FILE_LIST_MIN_SIZE = 20; +const FILE_LIST_MAX_SIZE = 80; +const FILE_LIST_KEYBOARD_STEP = 2; + +function clampFileListSize(size: number) { + return Math.min(FILE_LIST_MAX_SIZE, Math.max(FILE_LIST_MIN_SIZE, size)); +} + +function restoredFileListSize() { + const saved = Number(safeLocalStorageGet(FILE_LIST_SIZE_STORAGE_KEY)); + return Number.isFinite(saved) && saved >= FILE_LIST_MIN_SIZE && saved <= FILE_LIST_MAX_SIZE ? saved : FILE_LIST_DEFAULT_SIZE; +} + const props = defineProps<{ pluginId: string; provider: PluginFilesystemProviderContribution; @@ -33,8 +50,17 @@ const previewContentType = ref(""); const previewTruncated = ref(false); const previewLoading = ref(false); const previewError = ref(""); +const paneGroup = ref(); +const fileListSize = ref(restoredFileListSize()); +const resizingPanes = ref(false); +const paneGridStyle = computed(() => ({ gridTemplateColumns: `minmax(0, ${fileListSize.value}fr) 0.375rem minmax(0, ${100 - fileListSize.value}fr)` })); let listGeneration = 0; let previewGeneration = 0; +let resizeHandle: HTMLElement | null = null; +let resizePointerId: number | null = null; +let resizeStartX = 0; +let resizeStartSize = FILE_LIST_DEFAULT_SIZE; +let resizeAvailableWidth = 0; const rootUri = computed(() => pluginFilesystemRootUri(props.provider)); const canRead = computed(() => (props.provider.capabilities || []).includes("read")); @@ -153,8 +179,98 @@ function formatSize(size?: number) { return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; } +function setFileListSize(size: number) { + fileListSize.value = Math.round(clampFileListSize(size) * 10) / 10; +} + +function persistFileListSize() { + safeLocalStorageSet(FILE_LIST_SIZE_STORAGE_KEY, String(fileListSize.value)); +} + +function cleanupPaneResize() { + window.removeEventListener("pointermove", resizeFilePanes, true); + window.removeEventListener("pointerup", finishPaneResize, true); + window.removeEventListener("pointercancel", cancelPaneResize); + window.removeEventListener("blur", cancelPaneResize); + window.removeEventListener("keydown", cancelPaneResizeWithEscape, true); + resizeHandle?.removeEventListener("lostpointercapture", cancelPaneResize); + if (resizePointerId !== null && resizeHandle?.hasPointerCapture?.(resizePointerId)) resizeHandle.releasePointerCapture(resizePointerId); + resizeHandle = null; + resizePointerId = null; + if (resizingPanes.value) endPanelResize(); + resizingPanes.value = false; +} + +function cancelPaneResize() { + if (!resizingPanes.value) return; + setFileListSize(resizeStartSize); + cleanupPaneResize(); +} + +function cancelPaneResizeWithEscape(event: KeyboardEvent) { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + cancelPaneResize(); +} + +function resizeFilePanes(event: PointerEvent) { + if (event.pointerId !== resizePointerId) return; + event.preventDefault(); + setFileListSize(resizeStartSize + ((event.clientX - resizeStartX) / resizeAvailableWidth) * 100); +} + +function finishPaneResize(event: PointerEvent) { + if (event.pointerId !== resizePointerId) return; + resizeFilePanes(event); + const changed = fileListSize.value !== resizeStartSize; + cleanupPaneResize(); + if (changed) persistFileListSize(); +} + +function startPaneResize(event: PointerEvent) { + if (event.button !== 0 || resizingPanes.value) return; + const groupWidth = paneGroup.value?.getBoundingClientRect().width ?? 0; + const handleWidth = (event.currentTarget as HTMLElement | null)?.getBoundingClientRect().width ?? 0; + resizeAvailableWidth = groupWidth - handleWidth; + if (resizeAvailableWidth <= 0) return; + event.preventDefault(); + resizeStartX = event.clientX; + resizeStartSize = fileListSize.value; + resizeHandle = event.currentTarget as HTMLElement; + resizePointerId = event.pointerId; + resizeHandle.setPointerCapture?.(resizePointerId); + resizeHandle.addEventListener("lostpointercapture", cancelPaneResize); + resizingPanes.value = true; + beginPanelResize(); + window.addEventListener("pointermove", resizeFilePanes, { capture: true, passive: false }); + window.addEventListener("pointerup", finishPaneResize, true); + window.addEventListener("pointercancel", cancelPaneResize); + window.addEventListener("blur", cancelPaneResize); + window.addEventListener("keydown", cancelPaneResizeWithEscape, true); +} + +function resizeFilePanesWithKeyboard(event: KeyboardEvent) { + let nextSize: number; + if (event.key === "ArrowLeft") nextSize = fileListSize.value - (event.shiftKey ? FILE_LIST_KEYBOARD_STEP * 5 : FILE_LIST_KEYBOARD_STEP); + else if (event.key === "ArrowRight") nextSize = fileListSize.value + (event.shiftKey ? FILE_LIST_KEYBOARD_STEP * 5 : FILE_LIST_KEYBOARD_STEP); + else if (event.key === "Home") nextSize = FILE_LIST_MIN_SIZE; + else if (event.key === "End") nextSize = FILE_LIST_MAX_SIZE; + else return; + event.preventDefault(); + setFileListSize(nextSize); + persistFileListSize(); +} + +function resetFilePaneSize() { + cancelPaneResize(); + fileListSize.value = FILE_LIST_DEFAULT_SIZE; + safeLocalStorageRemove(FILE_LIST_SIZE_STORAGE_KEY); +} + onMounted(() => void load(props.initialUri || rootUri.value)); onBeforeUnmount(() => { + cancelPaneResize(); revokePreviewImage(); }); watch( @@ -183,21 +299,28 @@ defineExpose({ refresh: () => load(currentUri.value) }); {{ error }} -
-
+
+
{{ t("pluginPlatform.loadingFiles") }}
{{ t("pluginPlatform.emptyDirectory") }}
- + @@ -206,7 +329,25 @@ defineExpose({ refresh: () => load(currentUri.value) });
-
+ + +
{{ t("pluginPlatform.fileManagerHint") }}
+ + + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5d0620e7d8..3ab8248152 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -365,6 +365,7 @@ export default { emptyDirectory: "This directory is empty.", loadMore: "Load more", fileManagerHint: "Double-click a directory to open it, or a file to preview up to 256 KB.", + filePreviewResize: "Drag or use the arrow keys to resize the file preview; double-click to reset", loadingPreview: "Loading preview", previewTruncated: "Preview truncated at 256 KB", previewSelectedFileHint: "Double-click the selected file to load its preview.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 04acabed25..8530fc5339 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -281,6 +281,7 @@ export default withEnglishFallback({ emptyDirectory: "此目录为空。", loadMore: "加载更多", fileManagerHint: "双击目录可打开,双击文件可预览最多 256 KB 的内容。", + filePreviewResize: "拖动或使用方向键调整文件预览宽度,双击恢复默认值", loadingPreview: "正在加载预览", previewTruncated: "预览已在 256 KB 处截断", previewSelectedFileHint: "双击所选文件以加载预览。",