feat(file-panels): choose and forget file panels in Settings > Files

Settings > Files lists each extension or filename where a plugin panel competes with
another candidate, or that has a remembered panel. Each row chooses the panel that opens
it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for
text files. Panels no connected host offers can be forgotten but not chosen.
This commit is contained in:
Mohamed Boudra
2026-10-19 13:40:04 +02:00
parent 18d6d00d66
commit 9d2533183b
5 changed files with 314 additions and 0 deletions
@@ -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();
});
@@ -24,6 +24,7 @@ const SECTION_LABELS = {
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
files: "Files",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
@@ -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",
@@ -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 (
<SettingsSection title={t("settings.files.title")} testID="file-panel-settings">
{rows.length === 0 ? (
<View style={settingsStyles.card} testID="file-panel-settings-empty">
<View style={styles.emptyCard}>
<Text style={styles.emptyText}>{t("settings.files.empty")}</Text>
</View>
</View>
) : (
<SettingsCard testID="file-panel-settings-card">
{rows.map((row) => (
<FilePanelSettingsRowView
key={row.selectorKey}
row={row}
onChoose={choose}
onAskAgain={askAgain}
/>
))}
</SettingsCard>
)}
</SettingsSection>
);
}
function FilePanelSettingsRowView({
row,
onChoose,
onAskAgain,
}: {
row: FilePanelSettingsRow<FilePanel>;
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 (
<SettingsRow label={label} testID={`file-panel-settings-row-${selectorKey}`}>
<View style={styles.controls}>
<DropdownMenu>
<DropdownTrigger
disabled={row.candidates.length === 0}
accessibilityRole="button"
accessibilityLabel={`${label}: ${value}`}
>
{value}
</DropdownTrigger>
<DropdownMenuContent
side="bottom"
align="end"
minWidth={260}
maxWidth={360}
testID="file-panel-settings-menu"
>
{row.candidates.map((panel) => (
<FilePanelOption
key={panel.id}
panel={panel}
selected={panel.id === associatedPanelId}
onSelect={choose}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
onPress={askAgain}
disabled={associatedPanelId === null}
accessibilityLabel={t("settings.files.askAgainFor", { label })}
>
{t("settings.files.askAgain")}
</Button>
</View>
</SettingsRow>
);
}
/** What the dropdown shows: the remembered panel, or that Paseo asks on every open. */
function associatedValueLabel(t: TFunction, row: FilePanelSettingsRow<FilePanel>): 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(
() => (
<ThemedPanelIcon
icon={filePanelIcon(panel)}
size={MENU_ICON_SIZE}
uniProps={mutedIconMapping}
/>
),
[panel],
);
return (
<DropdownMenuItem selected={selected} leading={leading} onSelect={select}>
{filePanelOptionLabel(t, panel)}
</DropdownMenuItem>
);
}
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,
},
}));
+1
View File
@@ -497,6 +497,7 @@ export const SETTINGS_SECTION_SLUGS = [
"terminal",
"browser",
"editor",
"files",
"shortcuts",
"integrations",
"notifications",