Skip to content
Open
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: 5 additions & 1 deletion apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export function ExperimentalFileLinkMenu({

return (
<>
<ContextMenuItem onSelect={() => navigation.openFilePreview(intent)}>
<ContextMenuItem
onSelect={() =>
navigation.openFilePreview({ ...intent, viewer: "builtin" })
}
>
Open preview
</ContextMenuItem>
{matchingOpeners.length > 0 ? (
Expand Down
12 changes: 10 additions & 2 deletions apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ import { getBrowserUrlHost } from "@/lib/browser-url";
import { isRoutePath } from "@/lib/route-paths";
import { UrlOpenRoutingProvider } from "@/lib/url-open-routing";
import { usePluginSlots } from "@/lib/plugin-slots";
import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference";
import { shouldDownloadWithFileOpenerPreference } from "@/lib/plugin-slot-resolvers";
import {
AppNavigationHostProvider,
type AppFilePreviewIntent,
Expand Down Expand Up @@ -289,6 +291,7 @@ export function PluginPanelRightPanelHost({
storageFiles: undefined,
terminalSessions: undefined,
});
const fileOpenerPreference = useFileOpenerPreferenceValue();
const createTerminal = useCreateTerminal();
const { mutateAsync: closeTerminal } = useCloseTerminal();
const hostsQuery = useHosts();
Expand Down Expand Up @@ -415,6 +418,11 @@ export function PluginPanelRightPanelHost({
if (normalized === null || panel === null) return false;
const lineRange = toFilePreviewLineRange(normalized.location);
const { target } = normalized;
const expectsDownload = shouldDownloadWithFileOpenerPreference({
path: target.path,
preference: fileOpenerPreference,
...(intent.viewer !== undefined ? { override: intent.viewer } : {}),
});
const tab =
target.kind === "workspace"
? openTab(
Expand Down Expand Up @@ -447,11 +455,11 @@ export function PluginPanelRightPanelHost({
},
{ viewer: intent.viewer },
);
if (tab === null) return false;
if (tab === null) return expectsDownload;
revealPanel();
return true;
},
[openTab, panel, revealPanel],
[fileOpenerPreference, openTab, panel, revealPanel],
);
const navigationCapabilities = useMemo(
() => ({ openFilePreview, openFixedTab }),
Expand Down
162 changes: 162 additions & 0 deletions apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
} from "@/lib/plugin-slots";
import {
DOWNLOAD_FILE_OPENER_PREFERENCE,
useFileOpenerPreference,
} from "@/lib/file-opener-preference";

const syncMocks = vi.hoisted(() => ({
scheduleLocalThreadTabsMigration: vi.fn(),
Expand Down Expand Up @@ -924,6 +928,164 @@ describe("useThreadFileTabs file opener diversion", () => {
title: "other.md",
});
});

it("downloads workspace, host, and thread-storage files without opening tabs", async () => {
const downloadUrls: string[] = [];
const fetchMock = vi.fn((_input: RequestInfo | URL) =>
Promise.resolve(new Response("%PDF", { status: 200 })),
);
vi.stubGlobal("fetch", fetchMock);
const createObjectUrl = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:download");
const revokeObjectUrl = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => {});
const click = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(function (this: HTMLAnchorElement) {
downloadUrls.push(this.href);
});
const { result } = renderThreadHook(() =>
useThreadFileTabs({
panelStateId: "download-opener",
syncThreadId: "thr_download",
environmentId: "env_download",
projectId: "proj_download",
storageFiles: undefined,
terminalSessions: undefined,
}),
);

act(() =>
result.current.openTab(
{
kind: "workspace-file-preview",
tab: {
lineRange: null,
path: "reports/workspace.pdf",
source: { kind: "working-tree" },
statusLabel: null,
},
},
{ viewer: "download" },
),
);
act(() =>
result.current.openTab(
{
kind: "host-file-preview",
tab: { lineRange: null, path: "/tmp/host.pdf" },
},
{ viewer: "download" },
),
);
act(() =>
result.current.openTab(
{
kind: "thread-storage-file-preview",
tab: { lineRange: null, path: "reports/storage.pdf" },
},
{ viewer: "download" },
),
);

expect(
fetchMock.mock.calls.map(([url]) => {
const parsed = new URL(String(url), window.location.origin);
return `${parsed.pathname}?${parsed.searchParams.toString()}`;
}),
).toEqual([
"/api/v1/projects/proj_download/files/content?disposition=attachment&path=reports%2Fworkspace.pdf&environmentId=env_download",
"/api/v1/threads/thr_download/host-files/content?disposition=attachment&path=%2Ftmp%2Fhost.pdf",
"/api/v1/threads/thr_download/thread-storage/content?disposition=attachment&path=reports%2Fstorage.pdf",
]);
await waitFor(() => expect(downloadUrls).toHaveLength(3));
expect(downloadUrls).toEqual([
"blob:download",
"blob:download",
"blob:download",
]);
expect(result.current.orderedSecondaryFileTabs).toEqual([]);
expect(document.querySelector("a[download]")).toBeNull();
click.mockRestore();
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
vi.unstubAllGlobals();
});

it("uses the saved PDF download opener while preserving explicit preview", async () => {
const downloadUrls: string[] = [];
const fetchMock = vi.fn((_input: RequestInfo | URL) =>
Promise.resolve(new Response("%PDF", { status: 200 })),
);
vi.stubGlobal("fetch", fetchMock);
const createObjectUrl = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:download-preference");
const revokeObjectUrl = vi
.spyOn(URL, "revokeObjectURL")
.mockImplementation(() => {});
const click = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(function (this: HTMLAnchorElement) {
downloadUrls.push(this.href);
});
const { result } = renderThreadHook(() => {
const [, setPreference] = useFileOpenerPreference();
const tabs = useThreadFileTabs({
panelStateId: "download-preference",
syncThreadId: "thr_download_preference",
environmentId: "env_download_preference",
projectId: "proj_download_preference",
storageFiles: undefined,
terminalSessions: undefined,
});
return { setPreference, tabs };
});

act(() => {
result.current.setPreference({
pdf: DOWNLOAD_FILE_OPENER_PREFERENCE,
});
});
act(() =>
result.current.tabs.openTab({
kind: "workspace-file-preview",
tab: {
lineRange: null,
path: "reports/default.pdf",
source: { kind: "working-tree" },
statusLabel: null,
},
}),
);
await waitFor(() => expect(downloadUrls).toHaveLength(1));
expect(result.current.tabs.orderedSecondaryFileTabs).toEqual([]);

act(() =>
result.current.tabs.openTab(
{
kind: "workspace-file-preview",
tab: {
lineRange: null,
path: "reports/preview.pdf",
source: { kind: "working-tree" },
statusLabel: null,
},
},
{ viewer: "builtin" },
),
);
expect(downloadUrls).toHaveLength(1);
expect(result.current.tabs.activeWorkspaceFilePath).toBe(
"reports/preview.pdf",
);
click.mockRestore();
createObjectUrl.mockRestore();
revokeObjectUrl.mockRestore();
vi.unstubAllGlobals();
});
});

describe("useThreadFileTabs legacy side-chat tabs", () => {
Expand Down
27 changes: 26 additions & 1 deletion apps/app/src/components/secondary-panel/useThreadFileTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ import {
fileOpenerIdFromActionId,
parseFileOpenerParams,
} from "@/components/plugin/file-opener-tabs";
import type { FileOpenerOverride } from "@/lib/plugin-slot-resolvers";
import {
shouldDownloadWithFileOpenerPreference,
type FileOpenerOverride,
} from "@/lib/plugin-slot-resolvers";
import {
downloadFileForOpenRequest,
getFileOpenRequestPath,
} from "@/lib/file-download";
import type { OpenPluginPanelArgs } from "@/components/plugin/PluginPanelActions";
import type {
HostFileTabState,
Expand Down Expand Up @@ -422,6 +429,24 @@ export function useThreadFileTabs({
behavior: OpenResolvedTabBehavior,
viewer?: FileOpenerOverride,
): SecondaryPanelTab | null => {
const path = getFileOpenRequestPath(request);
if (
path !== null &&
shouldDownloadWithFileOpenerPreference({
path,
preference: fileOpenerPreference,
...(viewer !== undefined ? { override: viewer } : {}),
}) &&
downloadFileForOpenRequest({
projectHostId,
projectId,
request,
resolvedEnvironmentId,
threadId: resolvedFileOwnerThreadId,
})
) {
return null;
}
const openerTab = createFileOpenerTabForRequest({
fileOpeners,
preference: fileOpenerPreference,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
} from "@/lib/plugin-slots";
import { BUILT_IN_FILE_OPENER_PREFERENCE } from "@/lib/file-opener-preference";
import {
BUILT_IN_FILE_OPENER_PREFERENCE,
DOWNLOAD_FILE_OPENER_PREFERENCE,
} from "@/lib/file-opener-preference";
import { FileOpenersSettingsSection } from "./FileOpenersSettingsSection";

function registerNotesOpener() {
Expand Down Expand Up @@ -35,6 +38,20 @@ afterEach(() => {
});

describe("FileOpenersSettingsSection", () => {
it("offers preview and download for PDFs without requiring a plugin", async () => {
render(<FileOpenersSettingsSection />);

const trigger = screen.getByRole("button", {
name: "Default opener for .pdf files",
});
expect(trigger.textContent).toContain("Built-in preview");

await selectOption(trigger, /^Download$/u);
expect(storedPreference()).toEqual({
pdf: DOWNLOAD_FILE_OPENER_PREFERENCE,
});
});

it("defaults to automatic and persists built-in, plugin, and automatic choices", async () => {
registerNotesOpener();
render(<FileOpenersSettingsSection />);
Expand Down
37 changes: 28 additions & 9 deletions apps/app/src/components/settings/FileOpenersSettingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
import {
BUILT_IN_FILE_OPENER_PREFERENCE,
DOWNLOAD_FILE_OPENER_PREFERENCE,
buildFileOpenerRef,
useFileOpenerPreference,
} from "@/lib/file-opener-preference";
Expand All @@ -22,6 +23,7 @@ import { cn } from "@bb/shared-ui/lib/utils";

const AUTOMATIC_FILE_OPENER_PREFERENCE = "__automatic__";
const BUILTIN_LABEL = "Built-in preview";
const BUILT_IN_FILE_OPENER_EXTENSIONS = ["pdf"];
const DROPDOWN_TRIGGER_CLASS =
"h-7 w-full justify-between border-border/60 bg-card px-2 text-xs sm:w-44";
const DROPDOWN_CONTENT_CLASS =
Expand All @@ -33,7 +35,12 @@ export function FileOpenersSettingsSection() {

const extensions = useMemo(
() =>
[...new Set(fileOpeners.flatMap((opener) => opener.extensions))].sort(),
[
...new Set([
...BUILT_IN_FILE_OPENER_EXTENSIONS,
...fileOpeners.flatMap((opener) => opener.extensions),
]),
].sort(),
[fileOpeners],
);

Expand All @@ -42,7 +49,7 @@ export function FileOpenersSettingsSection() {
return (
<SettingsSection
title="File openers"
description="Automatically use matching plugins, or choose a viewer for each file type. Right-click a file link for a one-off choice."
description="Automatically use matching plugins, or choose a viewer for each file type. Thread file links offer one-off choices."
>
<div className="space-y-5">
{extensions.map((extension) => (
Expand All @@ -53,7 +60,12 @@ export function FileOpenersSettingsSection() {
opener.extensions.includes(extension),
)}
preference={
preference[extension] ?? AUTOMATIC_FILE_OPENER_PREFERENCE
preference[extension] ??
(fileOpeners.some((opener) =>
opener.extensions.includes(extension),
)
? AUTOMATIC_FILE_OPENER_PREFERENCE
: BUILT_IN_FILE_OPENER_PREFERENCE)
}
onSelect={(selection) =>
setPreference((previous) => {
Expand Down Expand Up @@ -85,21 +97,28 @@ function ExtensionOpenerControl({
preference: string;
}) {
const automaticOpener = openers[0];
if (automaticOpener === undefined) return null;

const options = [
{
key: AUTOMATIC_FILE_OPENER_PREFERENCE,
label: `Automatic (${automaticOpener.title})`,
},
...(automaticOpener === undefined
? []
: [
{
key: AUTOMATIC_FILE_OPENER_PREFERENCE,
label: `Automatic (${automaticOpener.title})`,
},
]),
{ key: BUILT_IN_FILE_OPENER_PREFERENCE, label: BUILTIN_LABEL },
...(extension === "pdf"
? [{ key: DOWNLOAD_FILE_OPENER_PREFERENCE, label: "Download" }]
: []),
...openers.map((opener) => ({
key: buildFileOpenerRef(opener),
label: `${opener.title} (${opener.pluginId})`,
})),
];
const selected =
options.find((option) => option.key === preference) ?? options[1];
options.find((option) => option.key === preference) ??
options.find((option) => option.key === BUILT_IN_FILE_OPENER_PREFERENCE);
if (selected === undefined) return null;

return (
Expand Down
Loading