mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
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:
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -497,6 +497,7 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"terminal",
|
||||
"browser",
|
||||
"editor",
|
||||
"files",
|
||||
"shortcuts",
|
||||
"integrations",
|
||||
"notifications",
|
||||
|
||||
Reference in New Issue
Block a user