fix(plugins): make file preview pane resizable

Closes #9327
This commit is contained in:
t8y2
2026-09-27 07:16:29 +00:00
parent ba37b2c15f
commit df4315ae44
4 changed files with 337 additions and 7 deletions
@@ -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<Element> | 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<HTMLElement>("[data-file-manager-panes]")!;
const list = container.querySelector<HTMLElement>("[data-file-list]")!;
const row = container.querySelector<HTMLElement>("[data-file-entry-uri]")!;
const separator = container.querySelector<HTMLElement>("[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<HTMLElement>("[data-file-manager-panes]")!;
let separator = container.querySelector<HTMLElement>("[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<HTMLElement>("[data-file-manager-panes]")!;
separator = container.querySelector<HTMLElement>("[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<HTMLElement>('[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 }));
});
});
@@ -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<HTMLElement>();
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) });
<span>{{ error }}</span>
</div>
<div v-else class="grid min-h-0 flex-1 grid-cols-[minmax(18rem,42%)_minmax(0,1fr)] divide-x">
<div class="flex min-h-0 flex-col">
<div v-else ref="paneGroup" data-file-manager-panes class="grid min-h-0 min-w-0 flex-1 overflow-hidden" :style="paneGridStyle">
<div data-file-list-pane class="flex min-h-0 min-w-0 flex-col overflow-hidden">
<div v-if="loading && !entries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFiles") }}</div>
<div v-else-if="!sortedEntries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("pluginPlatform.emptyDirectory") }}</div>
<RecycleScroller v-else class="min-h-0 flex-1 min-w-[28rem] text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
<RecycleScroller v-else data-file-list class="plugin-file-list min-h-0 min-w-0 flex-1 overflow-x-hidden text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
<template #default="{ item: entry }">
<button type="button" class="grid h-[46px] w-full grid-cols-[minmax(0,1fr)_7rem_10rem] items-center gap-3 border-b px-3 text-left hover:bg-muted/50" :class="selected?.uri === entry.uri ? 'bg-muted' : ''" @click="selected = entry" @dblclick="activateEntry(entry)">
<button
type="button"
class="plugin-file-row grid h-[46px] w-full min-w-0 items-center gap-3 border-b px-3 text-left hover:bg-muted/50"
:class="selected?.uri === entry.uri ? 'bg-muted' : ''"
:data-file-entry-uri="entry.uri"
@click="selected = entry"
@dblclick="activateEntry(entry)"
>
<span class="flex min-w-0 items-center gap-2">
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
<File v-else class="size-4 shrink-0 text-muted-foreground" />
<span class="truncate" :title="entry.name">{{ entry.name }}</span>
</span>
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ entry.kind === "directory" ? "" : formatSize(entry.size) }}</span>
<span class="truncate text-xs text-muted-foreground" :title="entry.modifiedAt">{{ entry.modifiedAt || "" }}</span>
<span class="plugin-file-size min-w-0 truncate text-right text-xs tabular-nums text-muted-foreground">{{ entry.kind === "directory" ? "" : formatSize(entry.size) }}</span>
<span class="plugin-file-modified min-w-0 truncate text-xs text-muted-foreground" :title="entry.modifiedAt">{{ entry.modifiedAt || "" }}</span>
</button>
</template>
</RecycleScroller>
@@ -206,7 +329,25 @@ defineExpose({ refresh: () => load(currentUri.value) });
</div>
</div>
<div class="flex min-h-0 min-w-0 flex-col">
<div
data-file-preview-resize
role="separator"
tabindex="0"
aria-orientation="vertical"
:aria-label="t('pluginPlatform.filePreviewResize')"
:aria-valuemin="FILE_LIST_MIN_SIZE"
:aria-valuemax="FILE_LIST_MAX_SIZE"
:aria-valuenow="Math.round(fileListSize)"
:title="t('pluginPlatform.filePreviewResize')"
class="group relative z-10 h-full w-1.5 cursor-col-resize touch-none border-x border-border/70 bg-background transition-colors hover:bg-primary/25 focus-visible:bg-primary/25 focus-visible:outline-none"
@pointerdown="startPaneResize"
@dblclick="resetFilePaneSize"
@keydown="resizeFilePanesWithKeyboard"
>
<span class="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary group-focus-visible:bg-primary" />
</div>
<div data-file-preview-pane class="flex min-h-0 min-w-0 flex-col overflow-hidden">
<div v-if="!selected" class="m-auto max-w-sm px-6 text-center text-sm text-muted-foreground">{{ t("pluginPlatform.fileManagerHint") }}</div>
<template v-else>
<div class="shrink-0 border-b px-4 py-3">
@@ -232,5 +373,43 @@ defineExpose({ refresh: () => load(currentUri.value) });
</template>
</div>
</div>
<Teleport to="body">
<div v-if="resizingPanes" class="plugin-file-resize-overlay fixed inset-0 cursor-col-resize touch-none select-none" aria-hidden="true" />
</Teleport>
</div>
</template>
<style scoped>
.plugin-file-list {
container: plugin-file-list / inline-size;
}
.plugin-file-row {
grid-template-columns: minmax(0, 1fr) 7rem 10rem;
}
@container plugin-file-list (max-width: 28rem) {
.plugin-file-row {
grid-template-columns: minmax(0, 1fr) 6rem;
}
.plugin-file-modified {
display: none;
}
}
@container plugin-file-list (max-width: 20rem) {
.plugin-file-row {
grid-template-columns: minmax(0, 1fr);
}
.plugin-file-size {
display: none;
}
}
.plugin-file-resize-overlay {
z-index: 2147483646;
}
</style>
+1
View File
@@ -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.",
+1
View File
@@ -281,6 +281,7 @@ export default withEnglishFallback({
emptyDirectory: "此目录为空。",
loadMore: "加载更多",
fileManagerHint: "双击目录可打开,双击文件可预览最多 256 KB 的内容。",
filePreviewResize: "拖动或使用方向键调整文件预览宽度,双击恢复默认值",
loadingPreview: "正在加载预览",
previewTruncated: "预览已在 256 KB 处截断",
previewSelectedFileHint: "双击所选文件以加载预览。",