diff --git a/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts
new file mode 100644
index 0000000000..15dcb40f96
--- /dev/null
+++ b/packages/app/e2e/browser/plugin-file-panel-settings.spec.ts
@@ -0,0 +1,144 @@
+import type { TestInfo } from "@playwright/test";
+import { expect, test, type Page } from "../support/fixtures";
+import { openSettings } from "../support/helpers/app";
+import { openFileExplorer } from "../support/helpers/file-explorer";
+import { gotoWorkspace } from "../support/helpers/launcher";
+import {
+ BUILTIN_MARKDOWN_OPTION,
+ MARKDOWN_PANEL_ID,
+ PLUGIN_MARKDOWN_OPTION,
+ SOURCE_OPTION,
+ expectPluginMarkdown,
+ filePanelPicker,
+ installMarkdownPanelPlugin,
+ openMarkdown,
+ storedFilePanelAssociations,
+} from "../support/helpers/markdown-file-panel-plugin";
+import { seedWorkspace } from "../support/helpers/seed-client";
+import { openSettingsSection } from "../support/helpers/settings";
+
+const ASK_EVERY_TIME = "Ask every time";
+
+function markdownRow(page: Page) {
+ return page.getByTestId("file-panel-settings-row-ext:md");
+}
+
+function markdownDropdown(page: Page, value: string) {
+ return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true });
+}
+
+function askAgain(page: Page) {
+ return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true });
+}
+
+async function openFilesSettings(page: Page) {
+ await openSettings(page);
+ await openSettingsSection(page, "files");
+ await expect(page.getByTestId("file-panel-settings")).toBeVisible();
+}
+
+async function capture(page: Page, testInfo: TestInfo, name: string) {
+ const file = testInfo.outputPath(`${name}.png`);
+ await page.screenshot({ path: file });
+ await testInfo.attach(name, { path: file, contentType: "image/png" });
+}
+
+async function expectMenuOpen(page: Page) {
+ await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1");
+}
+
+test("Settings > Files chooses which panel opens .md and asks again on request", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(120_000);
+ const workspace = await seedWorkspace({
+ repoPrefix: "file-panel-settings-",
+ repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
+ });
+ const plugin = await installMarkdownPanelPlugin();
+ try {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await gotoWorkspace(page, workspace.workspaceId);
+ await openFilesSettings(page);
+
+ // Nothing remembered: the row asks every time and has nothing to forget.
+ await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 });
+ await expect(askAgain(page)).toBeDisabled();
+ await capture(page, testInfo, "file-panel-settings-wide");
+
+ await markdownDropdown(page, ASK_EVERY_TIME).click();
+ await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
+ await expectMenuOpen(page);
+ await capture(page, testInfo, "file-panel-settings-wide-menu");
+ await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
+ await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible();
+ await expect
+ .poll(() => storedFilePanelAssociations(page))
+ .toEqual({
+ "ext:md": MARKDOWN_PANEL_ID,
+ });
+ await capture(page, testInfo, "file-panel-settings-wide-chosen");
+
+ // The next open uses the plugin without asking.
+ await gotoWorkspace(page, workspace.workspaceId);
+ await openFileExplorer(page);
+ await openMarkdown(page, "notes.md");
+ await expectPluginMarkdown(page, "notes.md");
+ await expect(filePanelPicker(page)).toBeHidden();
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/settings/files");
+ await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 });
+ await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
+ await capture(page, testInfo, "file-panel-settings-compact");
+ await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
+ await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
+ await expectMenuOpen(page);
+ await capture(page, testInfo, "file-panel-settings-compact-menu");
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
+
+ // Ask again forgets the choice; the next open asks.
+ await askAgain(page).click();
+ await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible();
+ await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await gotoWorkspace(page, workspace.workspaceId);
+ await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
+ } finally {
+ await plugin.uninstall();
+ await workspace.cleanup();
+ }
+});
+
+test("Settings > Files explains where file panels come from when no plugin adds one", async ({
+ page,
+}, testInfo) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await page.goto("/settings/files");
+ const empty = page.getByTestId("file-panel-settings-empty");
+ await expect(empty).toHaveText("File panels appear here when a plugin adds one", {
+ timeout: 30_000,
+ });
+ await capture(page, testInfo, "file-panel-settings-empty-wide");
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(empty).toBeVisible();
+ await capture(page, testInfo, "file-panel-settings-empty-compact");
+});
+
+test("Settings > Files lets a panel no host has be forgotten but not chosen", async ({ page }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "@paseo:app-settings",
+ JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }),
+ );
+ });
+ await page.goto("/settings/files");
+ const row = page.getByTestId("file-panel-settings-row-ext:pdf");
+ const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" });
+ await expect(dropdown).toBeDisabled({ timeout: 30_000 });
+ await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click();
+ await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible();
+});
diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts
index 6b810e6d13..952cc5b69d 100644
--- a/packages/app/e2e/support/helpers/settings.ts
+++ b/packages/app/e2e/support/helpers/settings.ts
@@ -24,6 +24,7 @@ const SECTION_LABELS = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
+ files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx
index 31f5058940..281f106077 100644
--- a/packages/app/src/screens/settings-screen.tsx
+++ b/packages/app/src/screens/settings-screen.tsx
@@ -34,6 +34,7 @@ import {
FolderGit2,
SquareTerminal,
Code2,
+ Files,
Smartphone,
Sparkles,
Blocks,
@@ -78,6 +79,7 @@ import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
+import { FilesSection } from "@/screens/settings/files-section";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { CommunityLinks } from "@/components/community-links";
@@ -185,6 +187,7 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
webOnly: true,
Content: EditorSection,
},
+ { id: "files", labelKey: "settings.sections.files", icon: Files, Content: FilesSection },
{
id: "shortcuts",
labelKey: "settings.sections.shortcuts",
diff --git a/packages/app/src/screens/settings/files-section.tsx b/packages/app/src/screens/settings/files-section.tsx
new file mode 100644
index 0000000000..7dec87b418
--- /dev/null
+++ b/packages/app/src/screens/settings/files-section.tsx
@@ -0,0 +1,165 @@
+import { useCallback, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
+import { Text, View } from "react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { SettingsCard, SettingsRow, SettingsSection } from "@/components/settings";
+import { Button } from "@/components/ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
+import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
+import {
+ filePanelIcon,
+ filePanelOptionLabel,
+ filePanelSelectorLabel,
+ useFilePanelSettings,
+ type FilePanel,
+ type FilePanelSettingsRow,
+} from "@/file-panels";
+import { PluginIconView } from "@/plugins/icons";
+import { settingsStyles } from "@/styles/settings";
+import type { Theme } from "@/styles/theme";
+
+const MENU_ICON_SIZE = 16;
+
+const ThemedPanelIcon = withUnistyles(PluginIconView);
+const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+
+/** Which panel opens each file type, for the types more than one panel claims. */
+export function FilesSection() {
+ const { t } = useTranslation();
+ const { rows, choose, askAgain } = useFilePanelSettings();
+ return (
+
+ {rows.length === 0 ? (
+
+
+ {t("settings.files.empty")}
+
+
+ ) : (
+
+ {rows.map((row) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function FilePanelSettingsRowView({
+ row,
+ onChoose,
+ onAskAgain,
+}: {
+ row: FilePanelSettingsRow;
+ onChoose(selectorKey: string, panelId: string): void;
+ onAskAgain(selectorKey: string): void;
+}) {
+ const { t } = useTranslation();
+ const { selectorKey, associatedPanelId } = row;
+ const label = filePanelSelectorLabel(selectorKey);
+ const value = associatedValueLabel(t, row);
+ const choose = useCallback(
+ (panelId: string) => onChoose(selectorKey, panelId),
+ [onChoose, selectorKey],
+ );
+ const askAgain = useCallback(() => onAskAgain(selectorKey), [onAskAgain, selectorKey]);
+ return (
+
+
+
+
+ {value}
+
+
+ {row.candidates.map((panel) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/** What the dropdown shows: the remembered panel, or that Paseo asks on every open. */
+function associatedValueLabel(t: TFunction, row: FilePanelSettingsRow): string {
+ if (row.associatedPanelId === null) return t("settings.files.askEveryTime");
+ const panel = row.candidates.find((candidate) => candidate.id === row.associatedPanelId);
+ return panel ? filePanelOptionLabel(t, panel) : t("settings.files.unavailable");
+}
+
+function FilePanelOption({
+ panel,
+ selected,
+ onSelect,
+}: {
+ panel: FilePanel;
+ selected: boolean;
+ onSelect(panelId: string): void;
+}) {
+ const { t } = useTranslation();
+ const select = useCallback(() => onSelect(panel.id), [onSelect, panel.id]);
+ const leading = useMemo(
+ () => (
+
+ ),
+ [panel],
+ );
+ return (
+
+ {filePanelOptionLabel(t, panel)}
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ controls: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ emptyCard: {
+ padding: theme.spacing[4],
+ alignItems: "center",
+ },
+ emptyText: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.base,
+ },
+}));
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index c13d6f25f9..cb828f9e5a 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.ts
@@ -497,6 +497,7 @@ export const SETTINGS_SECTION_SLUGS = [
"terminal",
"browser",
"editor",
+ "files",
"shortcuts",
"integrations",
"notifications",