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",