Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Mobile dialog layouts** — Download-method option descriptions now wrap instead of clipping on narrow screens, and the share/direct-link and edit-share dialogs size to their content on small screens instead of stretching to near-full height above a pinned footer.

- **iOS PDF preview** — The built-in PDF viewer now installs a guarded `ReadableStream` async-iterator shim before rendering, working around WebKit's missing `Symbol.asyncIterator` support (Safari before 26.4 — every browser on iOS), which made pdf.js 6.x `getTextContent()` throw and blocked document pages from rendering at all. Browsers with native support keep their built-in code path, and environments without `ReadableStream` are unaffected.

- **iOS public-share downloads** — Share-page file downloads now use the browser download trigger with the shared file name instead of opening the download endpoint in a new tab, preserving filenames on iOS while keeping the existing share authorization and storage delivery paths.

- **Storage migration multipart memory bound** — Storage-policy Blob migration now
plans provider part limits separately from the local heap budget, uses bounded
reader uploads with reopenable source ranges for retries, and exposes multipart
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **移动端对话框布局** — 下载方式选项的描述文本在窄屏上正确换行而不是被截断;分享/直链与编辑分享对话框在小屏幕上高度贴合内容,不再被撑到接近全屏、底部按钮与表单之间留出大片空白。

- **iOS PDF 预览** — 内置 PDF 预览现在在渲染前安装带条件检测的 `ReadableStream` 异步迭代器 shim,绕过 WebKit 缺失 `Symbol.asyncIterator` 支持(Safari 26.4 之前,即 iOS 上的所有浏览器)导致 pdf.js 6.x `getTextContent()` 抛错、文档页面完全无法渲染的问题。已原生支持的浏览器继续使用内置实现,没有 `ReadableStream` 的环境不受影响。

- **存储迁移 multipart 内存上限** — Storage policy Blob migration 现在分别规划 provider part 限制与本地 heap budget,使用支持重试时重新打开源 range 的有界 reader upload,并在 dry-run preflight 中返回 multipart capability 结果。现有 hash、verification、abort、checkpoint 与 Blob CAS 语义保持不变。

- **上传流精确大小校验** — 内置 stream 与 multipart 上传路径现在校验每个 reader 产生的字节数与声明值完全一致,将过短、超长或负数大小输入作为 precondition failure 拒绝,并在提交前清理 staged attempt。Provider-backed 上传复用同一边界校验;通用 multipart fallback 会拒绝超过 64 MiB 内存预算的 part,不再按无界声明分配内存。
Expand Down Expand Up @@ -72,6 +76,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **iOS 公开分享下载** — 分享页文件下载现在使用带文件名的浏览器下载触发器,不再通过新标签页打开下载端点;iOS 上会保留分享文件名,同时继续使用现有的分享授权与存储交付路径。

- **存储 connector 校验反馈** — 连接测试错误现在会暴露校验失败的 connector 配置或静态凭据字段;管理端策略表单根据 connector 元数据本地化错误信息并高亮对应输入框,不再展示原始 provider 诊断。

- **文件浏览器导航与已删除目录恢复** — 路由导航现在优先于并发的 SSE 与 mutation 完成刷新,保持 URL、当前目录、面包屑和可见内容一致。清空回收站完成时使用作用域聚合事件替代全量 reconciliation 信号;当前目录被移入回收站或永久删除时,个人与团队目录路由会恢复到最近的可用祖先(或工作区根);目录列表、信息和祖先 API 统一返回 `folder.not_found` 与可读生命周期信息。
Expand Down
41 changes: 41 additions & 0 deletions frontend-panel/src/components/files/DownloadCenter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,47 @@ describe("DownloadCenter", () => {
expect(useDownloadStore.getState().pendingSelection).toBeNull();
});

it("lets download-method option descriptions wrap on narrow screens", () => {
useDownloadStore.setState({
pendingSelection: {
workspace: { kind: "personal" },
files: [{ id: 1, name: "first.txt" }],
folders: [{ id: 2, name: "docs" }],
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

const { unmount } = render(<DownloadCenter />);

// The Button base variant forces whitespace-nowrap, which clipped the
// description line on narrow screens; each option must opt back out.
for (const name of [
/download_proxy_archive/,
/download_to_folder/,
/download_browser_archive/,
]) {
expect(screen.getByRole("button", { name })).toHaveClass(
"whitespace-normal",
);
}
unmount();

useDownloadStore.setState({
pendingSelection: {
workspace: { kind: "personal" },
files: [{ id: 3, name: "only.txt" }],
folders: [],
},
});

render(<DownloadCenter />);

for (const name of [/download_proxy_file/, /download_browser_default/]) {
expect(screen.getByRole("button", { name })).toHaveClass(
"whitespace-normal",
);
}
});

it("hides ZIP download methods when archive downloads are disabled", () => {
useFrontendConfigStore.setState({
archiveDownloadUserEnabled: false,
Expand Down
10 changes: 5 additions & 5 deletions frontend-panel/src/components/files/DownloadCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ export function DownloadCenter() {
<Button
type="button"
variant="outline"
className="h-auto justify-start gap-3 p-3 text-left"
className="h-auto justify-start gap-3 p-3 text-left whitespace-normal"
onClick={() => {
dismissSelection();
void startProxyFileDownload(
Expand All @@ -373,7 +373,7 @@ export function DownloadCenter() {
<Button
type="button"
variant="outline"
className="h-auto justify-start gap-3 p-3 text-left"
className="h-auto justify-start gap-3 p-3 text-left whitespace-normal"
onClick={() => {
dismissSelection();
void startProxyArchiveDownload(pendingSelection);
Expand All @@ -395,7 +395,7 @@ export function DownloadCenter() {
<Button
type="button"
variant="outline"
className="h-auto justify-start gap-3 p-3 text-left"
className="h-auto justify-start gap-3 p-3 text-left whitespace-normal"
onClick={() => {
dismissSelection();
if (directoryDownloadSupported) {
Expand All @@ -422,7 +422,7 @@ export function DownloadCenter() {
<Button
type="button"
variant="ghost"
className="h-auto justify-start gap-3 p-3 text-left"
className="h-auto justify-start gap-3 p-3 text-left whitespace-normal"
onClick={() => {
dismissSelection();
void createBatchService(pendingSelection.workspace)
Expand All @@ -448,7 +448,7 @@ export function DownloadCenter() {
<Button
type="button"
variant="ghost"
className="h-auto justify-start gap-3 p-3 text-left"
className="h-auto justify-start gap-3 p-3 text-left whitespace-normal"
onClick={() => {
dismissSelection();
void startAuthenticatedFileDownload(
Expand Down
25 changes: 23 additions & 2 deletions frontend-panel/src/components/files/EditShareDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,16 @@ vi.mock("@/components/ui/button", () => ({
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
DialogContent: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
Expand Down Expand Up @@ -289,4 +297,17 @@ describe("EditShareDialog", () => {
);
});
});

it("sizes the dialog to its content on small screens", () => {
render(
<EditShareDialog open onOpenChange={vi.fn()} share={createShare()} />,
);

// ManagerDialogShell defaults to a fixed near-viewport height below the
// sm breakpoint; this short form must override it to hug its content.
expect(screen.getByTestId("dialog-content")).toHaveClass(
"h-auto",
"max-h-[min(92dvh,44rem)]",
);
});
});
1 change: 1 addition & 0 deletions frontend-panel/src/components/files/EditShareDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export function EditShareDialog({
open={open}
onOpenChange={onOpenChange}
onOpenChangeComplete={handleOpenChangeComplete}
className="h-auto max-h-[min(92dvh,44rem)]"
title={
<span className="flex items-center gap-2">
<Icon name="PencilSimple" className="size-4" />
Expand Down
19 changes: 18 additions & 1 deletion frontend-panel/src/components/files/ShareDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,11 @@ vi.mock("@/components/ui/dialog", () => ({
}: {
children: React.ReactNode;
className?: string;
}) => <div className={className}>{children}</div>,
}) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
Expand Down Expand Up @@ -410,6 +414,19 @@ describe("ShareDialog", () => {
expect(titleText).toHaveClass("min-w-0", "break-words");
});

it("sizes the dialog to its content on small screens", () => {
render(
<ShareDialog open onOpenChange={vi.fn()} fileId={42} name="demo.zip" />,
);

// ManagerDialogShell defaults to a fixed near-viewport height below the
// sm breakpoint; this short form must override it to hug its content.
expect(screen.getByTestId("dialog-content")).toHaveClass(
"h-auto",
"max-h-[min(92dvh,44rem)]",
);
});

it("creates direct links for files and exposes a force-download variant", async () => {
mockState.getDirectLinkToken.mockResolvedValue({ token: "direct-token" });
const onShareCreated = vi.fn();
Expand Down
1 change: 1 addition & 0 deletions frontend-panel/src/components/files/ShareDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export function ShareDialog({
open={open}
onOpenChange={handleClose}
onOpenChangeComplete={handleOpenChangeComplete}
className="h-auto max-h-[min(92dvh,44rem)]"
title={
<span className="flex max-w-full min-w-0 items-start gap-2 leading-snug">
<Icon name="Link" className="mt-0.5 size-4 shrink-0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Input } from "@/components/ui/input";
import { useBlobUrl } from "@/hooks/useBlobUrl";
import { startAuthenticatedDownload } from "@/lib/authenticatedDownload";
import { isImeComposingKeyEvent } from "@/lib/keyboard";
import { ensureReadableStreamAsyncIterator } from "@/lib/readableStreamAsyncIterator";
import { type ResourcePath, resourceRequestPath } from "@/lib/resourceRequest";
import { PreviewError } from "../../shared/PreviewError";
import { PreviewLoadingState } from "../../shared/PreviewLoadingState";
Expand All @@ -35,6 +36,11 @@ import {
PreviewSurfaceContent,
} from "../../shared/PreviewSurface";

// pdf.js 6.x iterates text-content streams with `for await...of`, which throws
// on WebKit without ReadableStream async-iterator support (Safari < 26.4, i.e.
// every iOS browser). Must run before any Document renders.
ensureReadableStreamAsyncIterator();

pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
Expand Down
30 changes: 29 additions & 1 deletion frontend-panel/src/lib/authenticatedDownload.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startAuthenticatedDownload } from "@/lib/authenticatedDownload";
import {
startAuthenticatedDownload,
startBrowserDownload,
} from "@/lib/authenticatedDownload";

const mockState = vi.hoisted(() => ({
ensureFreshSession: vi.fn(),
Expand Down Expand Up @@ -68,3 +71,28 @@ describe("startAuthenticatedDownload", () => {
createElementSpy.mockRestore();
});
});

describe("startBrowserDownload", () => {
it("uses a named anchor download without navigating the page", () => {
const createElement = document.createElement.bind(document);
const anchor = createElement("a");
const clickSpy = vi.spyOn(anchor, "click").mockImplementation(() => {});
const createElementSpy = vi
.spyOn(document, "createElement")
.mockImplementation(((tagName: string) =>
tagName === "a"
? anchor
: createElement(tagName)) as typeof document.createElement);

startBrowserDownload("/s/share-token/download", "报告.txt");

expect(anchor.getAttribute("href")).toBe("/api/v1/s/share-token/download");
expect(anchor.download).toBe("报告.txt");
expect(anchor.rel).toBe("noopener");
expect(anchor.isConnected).toBe(false);
expect(clickSpy).toHaveBeenCalledTimes(1);

createElementSpy.mockRestore();
clickSpy.mockRestore();
});
});
8 changes: 5 additions & 3 deletions frontend-panel/src/lib/authenticatedDownload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { resolveApiResourceUrl } from "@/lib/apiUrl";
import { logger } from "@/lib/logger";
import { useAuthStore } from "@/stores/authStore";

function triggerBrowserDownload(path: string) {
/** Trigger a browser download without navigating the current page. */
export function startBrowserDownload(path: string, filename?: string) {
const anchor = document.createElement("a");
anchor.href = resolveApiResourceUrl(path);
anchor.download = "";
if (filename) anchor.download = filename;
anchor.rel = "noopener";
document.body.append(anchor);
anchor.click();
anchor.remove();
Expand All @@ -23,5 +25,5 @@ export async function startAuthenticatedDownload(path: string): Promise<void> {
logger.error("authenticated download session refresh failed", path, error);
throw error;
}
triggerBrowserDownload(path);
startBrowserDownload(path);
}
Loading
Loading