diff --git a/packages/app/src/components/file-pane-render-mode.ts b/packages/app/src/components/file-pane-render-mode.ts deleted file mode 100644 index b1c36ede46..0000000000 --- a/packages/app/src/components/file-pane-render-mode.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type FilePreviewRenderKind = "markdown" | "html"; - -export function isRenderedMarkdownFile(filePath: string): boolean { - const normalizedPath = filePath.trim().toLowerCase(); - return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown"); -} - -function isRenderedHtmlFile(filePath: string): boolean { - const normalizedPath = filePath.trim().toLowerCase(); - return normalizedPath.endsWith(".html") || normalizedPath.endsWith(".htm"); -} - -export function filePreviewRenderKind(filePath: string): FilePreviewRenderKind | null { - if (isRenderedMarkdownFile(filePath)) return "markdown"; - if (isRenderedHtmlFile(filePath)) return "html"; - return null; -} diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index 8b04590d28..c2749b9c05 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -4,7 +4,7 @@ import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/s import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; import { getCM, vim } from "@replit/codemirror-vim"; -import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; +import { isRenderedMarkdownFile } from "@/file-panels"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { FileEditorModel } from "./model"; import { editorBaseExtensions, editorTheme, type EditorVisualTheme } from "./extensions.web"; diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index ea3c5b1edb..c185cb613e 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,20 +1,13 @@ import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; +import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { ScrollView as RNScrollView, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; -import { filePreviewRenderKind } from "@/components/file-pane-render-mode"; +import { FilePanelPreview, resolveFilePreviewPanel } from "@/file-panels"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { getFileNameFromPath } from "@/attachments/utils"; import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target"; @@ -28,8 +21,6 @@ import { useLiveFile } from "./live-file/hook"; import { useFilePreview } from "./preview-lifecycle/hook"; import { resolveFilePreviewLifecycle } from "./preview-lifecycle/model"; import { FilePanelBar } from "./bar"; -import { FileHtmlPreview } from "./html-preview"; -import { FileMarkdownPreview } from "./markdown-preview"; import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; @@ -39,7 +30,6 @@ import type { LiveFileModel } from "./live-file/model"; import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import type { Theme } from "@/styles/theme"; -import { ZoomableImage } from "@/components/zoomable-viewport/image"; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -144,14 +134,7 @@ function FilePreviewBody({ }: FilePreviewBodyProps) { const { t } = useTranslation(); const filePath = location.path; - // A line target means the caller wants to land on that line, so fall back to - // the highlighted source view even for renderable files. - const renderKind = - preview?.kind === "text" && !location.lineStart && mode !== "source" - ? filePreviewRenderKind(filePath) - : null; - - const previewScrollRef = useRef(null); + const { settings } = useAppSettings(); if (isLoading && !preview) { return ( @@ -170,36 +153,26 @@ function FilePreviewBody({ ); } + const panel = resolveFilePreviewPanel({ + preview, + path: filePath, + associations: settings.filePanelAssociations, + }); + const showSource = preview.kind === "text" && (Boolean(location.lineStart) || mode === "source"); + if (panel && !showSource) { + return ( + + ); + } if (preview.kind === "text") { - if (renderKind === "html") { - // The HTML document owns its own scrolling, so no ScrollView wrapper here. - return ( - - - - ); - } - - if (renderKind === "markdown") { - return ( - - - - - - ); - } - return ( - - {t("panels.file.loading")} - - ); - } - - return ; - } - return ( {t("panels.file.binaryPreviewUnavailable")} @@ -243,6 +203,7 @@ export function FilePane({ navigationRevision: number; }) { const { t } = useTranslation(); + const { settings } = useAppSettings(); const isMobile = useIsCompactFormFactor(); const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview"); @@ -292,7 +253,12 @@ export function FilePane({ const { file: preview, imageAttachment } = resolveFilePreviewLifecycle(previewLifecycle); const imagePreviewUri = useAttachmentPreviewUrl(imageAttachment); - const isRenderable = isRenderablePreview(preview, location.path); + const panel = resolveFilePreviewPanel({ + preview, + path: location.path, + associations: settings.filePanelAssociations, + }); + const isRenderable = preview?.kind === "text" && panel !== null; const editable = isEditableTextFile({ preview, supportsEditing, @@ -333,10 +299,6 @@ export function FilePane({ ); } -function isRenderablePreview(preview: ExplorerFile | null, path: string): boolean { - return preview?.kind === "text" && filePreviewRenderKind(path) !== null; -} - function isEditableTextFile(input: { preview: ExplorerFile | null; supportsEditing: boolean; @@ -708,15 +670,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, - previewScrollContainer: { - flex: 1, - minHeight: 0, - }, - previewContent: { - flex: 1, - minHeight: 0, - }, - previewCodeScrollContent: { - padding: theme.spacing[4], - }, })); diff --git a/packages/app/src/file-panels/builtin.tsx b/packages/app/src/file-panels/builtin.tsx new file mode 100644 index 0000000000..ba1c4e2cd3 --- /dev/null +++ b/packages/app/src/file-panels/builtin.tsx @@ -0,0 +1,92 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { ScrollView, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ZoomableImage } from "@/components/zoomable-viewport/image"; +import type { Theme } from "@/styles/theme"; +import { FileHtmlPreview } from "./html-preview"; +import { FileMarkdownPreview } from "./markdown-preview"; +import type { builtinFilePanels } from "./catalog"; + +interface FilePanelPreviewProps { + content: string; + filePath: string; + workspaceRoot: string; + client: DaemonClient | null; + serverId: string; + imagePreviewUri: string | null; +} + +function MarkdownPanel(props: FilePanelPreviewProps) { + return ( + + + + + + ); +} + +function HtmlPanel({ content }: FilePanelPreviewProps) { + return ( + + + + ); +} + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function ImagePanel({ imagePreviewUri }: FilePanelPreviewProps) { + const { t } = useTranslation(); + if (!imagePreviewUri) { + return ( + + + {t("panels.file.loading")} + + ); + } + return ; +} + +const components = { + "builtin:markdown": MarkdownPanel, + "builtin:html": HtmlPanel, + "builtin:image": ImagePanel, +}; + +export function FilePanelPreview({ + panel, + ...props +}: FilePanelPreviewProps & { + panel: (typeof builtinFilePanels)[number]; +}) { + const Component = components[panel.id]; + return ; +} + +const styles = StyleSheet.create((theme) => ({ + previewScrollContainer: { flex: 1, minHeight: 0 }, + previewContent: { flex: 1, minHeight: 0 }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + padding: theme.spacing[4], + }, + loadingText: { + marginTop: theme.spacing[2], + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/components/file-pane-render-mode.test.ts b/packages/app/src/file-panels/catalog.test.ts similarity index 53% rename from packages/app/src/components/file-pane-render-mode.test.ts rename to packages/app/src/file-panels/catalog.test.ts index 7a0bcf21ec..6fe1554138 100644 --- a/packages/app/src/components/file-pane-render-mode.test.ts +++ b/packages/app/src/file-panels/catalog.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { filePreviewRenderKind, isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; +import { builtinFilePanels, isRenderedMarkdownFile } from "./catalog"; +import { resolveFilePanel } from "./resolve"; + +function filePreviewRenderKind(path: string) { + const { panel } = resolveFilePanel({ + file: { path, kind: "text", size: 100, hasBytes: false }, + candidates: builtinFilePanels, + associations: {}, + }); + return panel ? panel.id.slice("builtin:".length) : null; +} describe("isRenderedMarkdownFile", () => { it("detects .md files", () => { @@ -38,3 +48,31 @@ describe("filePreviewRenderKind", () => { expect(filePreviewRenderKind("index.html.erb")).toBe(null); }); }); + +describe("built-in file panels", () => { + it("keeps image classification independent of extension and associations", () => { + const resolution = resolveFilePanel({ + file: { path: "/capture", kind: "image", size: 512, hasBytes: true }, + candidates: builtinFilePanels, + associations: { "ext:": "builtin:markdown" }, + }); + expect(resolution.panel?.id).toBe("builtin:image"); + expect( + resolveFilePanel({ + file: { path: "capture.png", kind: "binary", size: 512, hasBytes: true }, + candidates: builtinFilePanels, + associations: {}, + }).panel, + ).toBeNull(); + }); + + it("does not impose a new preview size limit on built-in text panels", () => { + expect( + resolveFilePanel({ + file: { path: "notes.md", kind: "text", size: 11 * 1024 * 1024, hasBytes: false }, + candidates: builtinFilePanels, + associations: {}, + }).panel?.id, + ).toBe("builtin:markdown"); + }); +}); diff --git a/packages/app/src/file-panels/catalog.ts b/packages/app/src/file-panels/catalog.ts new file mode 100644 index 0000000000..0c0b0510ef --- /dev/null +++ b/packages/app/src/file-panels/catalog.ts @@ -0,0 +1,28 @@ +import { filePanelExtension, type FilePanelCandidate } from "./resolve"; + +export const builtinFilePanels = [ + { + id: "builtin:markdown", + files: { extensions: ["md", "markdown"] }, + document: "text", + maxBytes: Infinity, + }, + { + id: "builtin:html", + files: { extensions: ["html", "htm"] }, + document: "text", + maxBytes: Infinity, + }, + { + id: "builtin:image", + files: { extensions: [], kind: "image" }, + document: "bytes", + maxBytes: Infinity, + }, +] as const satisfies readonly FilePanelCandidate[]; + +export function isRenderedMarkdownFile(path: string): boolean { + return builtinFilePanels[0].files.extensions.some( + (extension) => extension === filePanelExtension(path), + ); +} diff --git a/packages/app/src/file-pane/html-preview-csp.test.ts b/packages/app/src/file-panels/html-preview-csp.test.ts similarity index 96% rename from packages/app/src/file-pane/html-preview-csp.test.ts rename to packages/app/src/file-panels/html-preview-csp.test.ts index bbf3bbb745..ad713f5910 100644 --- a/packages/app/src/file-pane/html-preview-csp.test.ts +++ b/packages/app/src/file-panels/html-preview-csp.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withPreviewCsp } from "@/file-pane/html-preview-csp"; +import { withPreviewCsp } from "./html-preview-csp"; describe("withPreviewCsp", () => { it("places the policy before the complete source document", () => { diff --git a/packages/app/src/file-pane/html-preview-csp.ts b/packages/app/src/file-panels/html-preview-csp.ts similarity index 100% rename from packages/app/src/file-pane/html-preview-csp.ts rename to packages/app/src/file-panels/html-preview-csp.ts diff --git a/packages/app/src/file-pane/html-preview-navigation.test.ts b/packages/app/src/file-panels/html-preview-navigation.test.ts similarity index 92% rename from packages/app/src/file-pane/html-preview-navigation.test.ts rename to packages/app/src/file-panels/html-preview-navigation.test.ts index e2029085af..3aa3c14d6a 100644 --- a/packages/app/src/file-pane/html-preview-navigation.test.ts +++ b/packages/app/src/file-panels/html-preview-navigation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { htmlPreviewNavigationKind } from "@/file-pane/html-preview-navigation"; +import { htmlPreviewNavigationKind } from "./html-preview-navigation"; describe("htmlPreviewNavigationKind", () => { it("allows the WebView's initial document URLs", () => { diff --git a/packages/app/src/file-pane/html-preview-navigation.ts b/packages/app/src/file-panels/html-preview-navigation.ts similarity index 100% rename from packages/app/src/file-pane/html-preview-navigation.ts rename to packages/app/src/file-panels/html-preview-navigation.ts diff --git a/packages/app/src/file-pane/html-preview.tsx b/packages/app/src/file-panels/html-preview.tsx similarity index 100% rename from packages/app/src/file-pane/html-preview.tsx rename to packages/app/src/file-panels/html-preview.tsx diff --git a/packages/app/src/file-pane/html-preview.web.tsx b/packages/app/src/file-panels/html-preview.web.tsx similarity index 100% rename from packages/app/src/file-pane/html-preview.web.tsx rename to packages/app/src/file-panels/html-preview.web.tsx diff --git a/packages/app/src/file-panels/index.ts b/packages/app/src/file-panels/index.ts new file mode 100644 index 0000000000..2f19121a30 --- /dev/null +++ b/packages/app/src/file-panels/index.ts @@ -0,0 +1,21 @@ +import type { ExplorerFile } from "@/stores/session-store"; +import { builtinFilePanels } from "./catalog"; +import { resolveFilePanel } from "./resolve"; + +export { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve"; +export { builtinFilePanels, isRenderedMarkdownFile } from "./catalog"; +export { FilePanelPreview } from "./builtin"; + +export function resolveFilePreviewPanel(input: { + preview: ExplorerFile | null; + path: string; + associations: Readonly>; +}) { + const { preview, path, associations } = input; + if (!preview) return null; + return resolveFilePanel({ + file: { path, kind: preview.kind, size: preview.size, hasBytes: preview.kind === "image" }, + candidates: builtinFilePanels, + associations, + }).panel; +} diff --git a/packages/app/src/file-pane/markdown-preview/document.test.ts b/packages/app/src/file-panels/markdown-preview/document.test.ts similarity index 100% rename from packages/app/src/file-pane/markdown-preview/document.test.ts rename to packages/app/src/file-panels/markdown-preview/document.test.ts diff --git a/packages/app/src/file-pane/markdown-preview/document.ts b/packages/app/src/file-panels/markdown-preview/document.ts similarity index 100% rename from packages/app/src/file-pane/markdown-preview/document.ts rename to packages/app/src/file-panels/markdown-preview/document.ts diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-panels/markdown-preview/index.tsx similarity index 100% rename from packages/app/src/file-pane/markdown-preview/index.tsx rename to packages/app/src/file-panels/markdown-preview/index.tsx diff --git a/packages/app/src/file-panels/resolve.test.ts b/packages/app/src/file-panels/resolve.test.ts new file mode 100644 index 0000000000..4bac78b4da --- /dev/null +++ b/packages/app/src/file-panels/resolve.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { resolveFilePanel, type FilePanelCandidate, type FilePanelFile } from "./resolve"; + +const markdown: FilePanelCandidate = { + id: "builtin:markdown", + files: { extensions: ["md", "markdown"] }, + document: "text", + maxBytes: Infinity, +}; +const alternative: FilePanelCandidate = { ...markdown, id: "plugin:notes/markdown" }; +const file: FilePanelFile = { path: "/docs/README.MD", kind: "text", size: 100, hasBytes: true }; + +function resolve( + overrides: Partial>[0]> = {}, +) { + return resolveFilePanel({ + file, + candidates: [markdown, alternative], + associations: {}, + ...overrides, + }); +} + +describe("resolveFilePanel", () => { + it("uses the associated candidate before catalog order", () => { + expect(resolve({ associations: { "ext:md": alternative.id } })).toEqual({ + panel: alternative, + candidates: [markdown, alternative], + selectorKey: "ext:md", + }); + }); + it("ignores an absent association without deleting it", () => { + const associations = { "ext:md": "plugin:absent/markdown" }; + expect(resolve({ associations }).panel).toBe(markdown); + expect(associations).toEqual({ "ext:md": "plugin:absent/markdown" }); + }); + it("uses a single candidate and preserves catalog order for multiple candidates", () => { + expect(resolve({ candidates: [alternative] }).panel).toBe(alternative); + expect(resolve().panel).toBe(markdown); + expect(resolve({ candidates: [alternative, markdown] }).panel).toBe(alternative); + }); + it("returns the floor when no selector matches", () => { + expect(resolve({ file: { ...file, path: "index.ts" } })).toEqual({ + panel: null, + candidates: [], + selectorKey: "ext:ts", + }); + }); + it("filters text panels for binary files even with an association", () => { + expect( + resolve({ file: { ...file, kind: "binary" }, associations: { "ext:md": markdown.id } }).panel, + ).toBeNull(); + }); + it("requires bytes for bytes panels and accepts binary files with bytes", () => { + const bytes: FilePanelCandidate = { ...alternative, document: "bytes" }; + expect(resolve({ file: { ...file, hasBytes: false }, candidates: [bytes] }).panel).toBeNull(); + expect(resolve({ file: { ...file, kind: "binary" }, candidates: [bytes] }).panel).toBe(bytes); + }); + it("includes the maxBytes boundary and skips oversized associated panels", () => { + const limited = { ...alternative, maxBytes: 100 }; + expect(resolve({ candidates: [limited] }).panel).toBe(limited); + expect( + resolve({ + file: { ...file, size: 101 }, + candidates: [limited, markdown], + associations: { "ext:md": limited.id }, + }).panel, + ).toBe(markdown); + }); + it("uses exact basename keys when an eligible candidate matches by filename", () => { + const named = { ...alternative, files: { extensions: [], filenames: ["README.MD"] } }; + expect( + resolve({ candidates: [markdown, named], associations: { "name:README.MD": named.id } }), + ).toEqual({ + panel: named, + candidates: [markdown, named], + selectorKey: "name:README.MD", + }); + expect(resolve({ file: { ...file, path: "README.md" }, candidates: [named] }).panel).toBeNull(); + }); + it("uses extension keys when filename candidates were filtered out", () => { + const named = { + ...alternative, + maxBytes: 1, + files: { extensions: [], filenames: ["README.MD"] }, + }; + expect(resolve({ candidates: [named, markdown] }).selectorKey).toBe("ext:md"); + }); + it.each(["/src/Dockerfile", "C:\\src\\Dockerfile"])( + "matches an extensionless exact filename: %s", + (path) => { + const named = { ...alternative, files: { extensions: [], filenames: ["Dockerfile"] } }; + expect(resolve({ file: { ...file, path }, candidates: [named] })).toEqual({ + panel: named, + candidates: [named], + selectorKey: "name:Dockerfile", + }); + }, + ); + it("normalizes extension selectors and keeps extensionless files distinct", () => { + const upper = { ...alternative, files: { extensions: ["MD"] } }; + expect(resolve({ candidates: [upper] }).panel).toBe(upper); + expect(resolve({ file: { ...file, path: "README" } }).selectorKey).toBe("ext:"); + }); +}); diff --git a/packages/app/src/file-panels/resolve.ts b/packages/app/src/file-panels/resolve.ts new file mode 100644 index 0000000000..7d8bd416f1 --- /dev/null +++ b/packages/app/src/file-panels/resolve.ts @@ -0,0 +1,66 @@ +export interface FilePanelFile { + path: string; + kind: "text" | "image" | "binary"; + size: number; + hasBytes: boolean; +} + +export interface FilePanelSelector { + extensions: readonly string[]; + filenames?: readonly string[]; + /** Image classification comes from the file read, independent of its extension. */ + kind?: "image"; +} + +export interface FilePanelCandidate { + id: string; + files: FilePanelSelector; + document: "text" | "bytes"; + maxBytes: number; +} + +export interface FilePanelResolution { + panel: Candidate | null; + candidates: readonly Candidate[]; + selectorKey: string; +} + +export function filePanelExtension(path: string): string { + const name = filePanelBasename(path); + const dot = name.lastIndexOf("."); + return dot < 0 ? "" : name.slice(dot + 1).toLowerCase(); +} + +function filePanelBasename(path: string): string { + return path.trim().split(/[\\/]/).pop() ?? ""; +} + +function matchesSelector(file: FilePanelFile, selector: FilePanelSelector): boolean { + if (selector.kind) return selector.kind === file.kind; + if (selector.filenames?.includes(filePanelBasename(file.path))) return true; + const extension = filePanelExtension(file.path); + return selector.extensions.some((entry) => entry.toLowerCase() === extension); +} + +export function resolveFilePanel(input: { + file: FilePanelFile; + candidates: readonly Candidate[]; + associations: Readonly>; +}): FilePanelResolution { + const { file, associations } = input; + const candidates = input.candidates.filter((candidate) => { + const documentAvailable = candidate.document === "text" ? file.kind === "text" : file.hasBytes; + return ( + documentAvailable && file.size <= candidate.maxBytes && matchesSelector(file, candidate.files) + ); + }); + const filename = filePanelBasename(file.path); + const matchedFilename = candidates.some((candidate) => + candidate.files.filenames?.includes(filename), + ); + const selectorKey = matchedFilename ? `name:${filename}` : `ext:${filePanelExtension(file.path)}`; + const association = associations[selectorKey]; + const panel = + candidates.find((candidate) => candidate.id === association) ?? candidates[0] ?? null; + return { panel, candidates, selectorKey }; +} diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 6d9c8d4c7c..323868280f 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -44,6 +44,33 @@ function makeDeps( } describe("loadAppSettingsFromStorage", () => { + it("persists file panel associations, including panels absent from this host", async () => { + const deps = makeDeps(); + const queryClient = new QueryClient(); + const filePanelAssociations = { + "ext:md": "builtin:markdown", + "name:Dockerfile": "plugin:absent/container", + }; + await saveAppSettings({ queryClient, deps, updates: { filePanelAssociations } }); + expect((await loadAppSettingsFromStorage(deps)).filePanelAssociations).toEqual( + filePanelAssociations, + ); + }); + + it.each([undefined, null, [], "invalid", { "ext:md": 42 }])( + "defaults malformed file panel associations without discarding other settings: %j", + async (filePanelAssociations) => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ filePanelAssociations, vimKeybindings: true }), + }), + }); + const settings = await loadAppSettingsFromStorage(deps); + expect(settings.filePanelAssociations).toEqual({}); + expect(settings.vimKeybindings).toBe(true); + }, + ); + it("preserves a persisted steer send behavior", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 54ee6b10bc..d3b918755f 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -104,6 +104,7 @@ export interface AppSettings { toolCallDetailLevel: ToolCallDetailLevel; chatOutlineEnabled: boolean; vimKeybindings: boolean; + filePanelAssociations: Record; /** Desktop-only preferences for implicit opens into the ordinary side pane. */ openInSidePane: OpenInSidePanePreferences; pullRequestOpenLocation: PullRequestOpenLocation; @@ -160,6 +161,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { toolCallDetailLevel: "detailed", chatOutlineEnabled: true, vimKeybindings: false, + filePanelAssociations: {}, openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES, pullRequestOpenLocation: "explorer", }; @@ -261,6 +263,7 @@ const StoredAppSettingsSchema = z compactToolCalls: z.boolean().optional().catch(undefined), chatOutlineEnabled: z.boolean().catch(true), vimKeybindings: z.boolean().catch(false), + filePanelAssociations: z.record(z.string(), z.string()).catch({}), openInSidePane: z .object({ explorerFiles: z.boolean().catch(false),