From 4f60fce1e17eebeff8274e74bb6fad3fbeb2acb1 Mon Sep 17 00:00:00 2001 From: Dave Otero <31802309+daveotero@users.noreply.github.com> Date: Thu, 23 Apr 2026 07:14:14 -0600 Subject: [PATCH] feat: add 'Open files in preview mode' setting (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add 'Open files in preview mode' setting - Add defaultFileViewerPreview setting to persist user preference for file viewer default mode - Add checkbox in Settings → Sessions → Session Defaults to toggle the setting - Files now open in preview mode by default when setting is enabled - Respects per-file-type localStorage persistence for markdown/HTML/JSON files - Setting persists across sessions via /api/config/settings endpoint * fix: address PR review feedback - FilesView: respect HTML localStorage preference in file-change effect, falling back to global setting only when nothing stored - DefaultsSettings: read defaultFileViewerPreview directly from config store instead of redundant local state + separate fetch - DefaultsSettings: extract duplicated toggle logic into handleToggleFileViewerPreview callback * fix: honor default preview setting for markdown files Previously mdViewMode only read localStorage on mount (deps: []), so when settingsDefaultFileViewerPreview was enabled and no MD_VIEWER_MODE_KEY was stored, markdown files silently opened in edit mode — ignoring the setting for the very file type users most want to preview. Fold md init into the per-file-change effect, mirroring the html handling: localStorage preference wins, falling back to the setting-derived default. The saveMdViewMode callback stays untouched so user-initiated toggles still persist. --------- Co-authored-by: Bohdan Triapitsyn --- .../sections/openchamber/DefaultsSettings.tsx | 25 ++++++++++ .../ui/src/components/views/FilesView.tsx | 49 +++++++++---------- packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/stores/useConfigStore.ts | 14 ++++++ .../server/lib/opencode/settings-helpers.js | 3 ++ 5 files changed, 67 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index d7edb1bd..f4ff08f8 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -30,6 +30,8 @@ export const DefaultsSettings: React.FC = () => { const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel); const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant); const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent); + const setSettingsDefaultFileViewerPreview = useConfigStore((state) => state.setSettingsDefaultFileViewerPreview); + const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const providers = useConfigStore((state) => state.providers); @@ -187,6 +189,12 @@ export const DefaultsSettings: React.FC = () => { [setAgent, setSettingsDefaultAgent] ); + const handleToggleFileViewerPreview = React.useCallback(() => { + const next = !settingsDefaultFileViewerPreview; + setSettingsDefaultFileViewerPreview(next); + updateDesktopSettings({ defaultFileViewerPreview: next }).catch(console.warn); + }, [settingsDefaultFileViewerPreview, setSettingsDefaultFileViewerPreview]); + const availableVariants = React.useMemo(() => { if (!parsedModel.providerId || !parsedModel.modelId) return []; const provider = providers.find((p) => p.id === parsedModel.providerId); @@ -300,6 +308,23 @@ export const DefaultsSettings: React.FC = () => { Show Deletion Dialog +
{ + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + handleToggleFileViewerPreview(); + } + }} + > + + Open files in preview mode +
+ ); diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index dab7ca74..c3f5db25 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -71,6 +71,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useUIStore } from '@/stores/useUIStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useGitStatus } from '@/stores/useGitStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments'; import { opencodeClient } from '@/lib/opencode/client'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; @@ -729,6 +730,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); // Global mouseup to end drag selection React.useEffect(() => { @@ -1864,25 +1866,37 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } }, [canEdit, textViewMode]); - React.useEffect(() => { - setTextViewMode('edit'); - setHtmlViewMode('edit'); - }, [selectedFile?.path]); - const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; + const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode'; React.useEffect(() => { + const defaultMode = settingsDefaultFileViewerPreview ? 'view' : 'edit'; + setTextViewMode(defaultMode); + + // Respect per-type localStorage preference when available, + // falling back to the setting-derived default when nothing is stored. + let mdDefault: 'preview' | 'edit' = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; try { const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); - if (stored === 'preview') { - setMdViewMode('preview'); - } else if (stored === 'edit') { - setMdViewMode('edit'); + if (stored === 'preview' || stored === 'edit') { + mdDefault = stored; } } catch { // Ignore localStorage errors } - }, []); + setMdViewMode(mdDefault); + + let htmlDefault: 'preview' | 'edit' = settingsDefaultFileViewerPreview ? 'preview' : 'edit'; + try { + const stored = localStorage.getItem(HTML_VIEWER_MODE_KEY); + if (stored === 'preview' || stored === 'edit') { + htmlDefault = stored; + } + } catch { + // Ignore localStorage errors + } + setHtmlViewMode(htmlDefault); + }, [selectedFile?.path, settingsDefaultFileViewerPreview]); const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => { setMdViewMode(mode); @@ -1912,21 +1926,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } }, []); - const HTML_VIEWER_MODE_KEY = 'openchamber:files:html-viewer-mode'; - - React.useEffect(() => { - try { - const stored = localStorage.getItem(HTML_VIEWER_MODE_KEY); - if (stored === 'preview') { - setHtmlViewMode('preview'); - } else if (stored === 'edit') { - setHtmlViewMode('edit'); - } - } catch { - // Ignore localStorage errors - } - }, []); - const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => { setJsonViewMode(mode); try { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index d3a74de1..fa231ee4 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -115,6 +115,7 @@ export type DesktopSettings = { autoCreateWorktree?: boolean; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; + defaultFileViewerPreview?: boolean; zenModel?: string; gitProviderId?: string; gitModelId?: string; diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index a94ba23e..e139b01b 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -28,6 +28,7 @@ interface OpenChamberDefaults { defaultAgent?: string; autoCreateWorktree?: boolean; gitmojiEnabled?: boolean; + defaultFileViewerPreview?: boolean; zenModel?: string; messageStreamTransport?: 'auto' | 'ws' | 'sse'; } @@ -45,6 +46,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; + const defaultFileViewerPreview = typeof data?.defaultFileViewerPreview === 'boolean' ? data.defaultFileViewerPreview : undefined; const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : ''; const messageStreamTransport = data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto' @@ -57,6 +59,7 @@ const fetchOpenChamberDefaults = async (): Promise => { defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, gitmojiEnabled, + defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, messageStreamTransport, }; @@ -79,6 +82,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; + const defaultFileViewerPreview = typeof data?.defaultFileViewerPreview === 'boolean' ? data.defaultFileViewerPreview : undefined; const zenModel = typeof data?.zenModel === 'string' ? data.zenModel.trim() : ''; const messageStreamTransport = data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto' @@ -91,6 +95,7 @@ const fetchOpenChamberDefaults = async (): Promise => { defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, gitmojiEnabled, + defaultFileViewerPreview, zenModel: zenModel.length > 0 ? zenModel : undefined, messageStreamTransport, }; @@ -479,6 +484,7 @@ interface ConfigStore { settingsDefaultAgent: string | undefined; settingsAutoCreateWorktree: boolean; settingsGitmojiEnabled: boolean; + settingsDefaultFileViewerPreview: boolean; settingsZenModel: string | undefined; settingsMessageStreamTransport: 'auto' | 'ws' | 'sse'; // Voice provider preference ('browser', 'openai', 'openai-compatible', or 'say' for macOS) @@ -548,6 +554,7 @@ interface ConfigStore { setSettingsDefaultAgent: (agent: string | undefined) => void; setSettingsAutoCreateWorktree: (enabled: boolean) => void; setSettingsGitmojiEnabled: (enabled: boolean) => void; + setSettingsDefaultFileViewerPreview: (enabled: boolean) => void; setSettingsZenModel: (model: string | undefined) => void; setSettingsMessageStreamTransport: (transport: 'auto' | 'ws' | 'sse') => void; getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null; @@ -601,6 +608,7 @@ export const useConfigStore = create()( settingsDefaultAgent: undefined, settingsAutoCreateWorktree: false, settingsGitmojiEnabled: false, + settingsDefaultFileViewerPreview: false, settingsZenModel: undefined, settingsMessageStreamTransport: 'auto', // Voice provider preference - load from localStorage or default to 'browser' @@ -1275,6 +1283,7 @@ export const useConfigStore = create()( settingsDefaultAgent: openChamberDefaults.defaultAgent, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, + settingsDefaultFileViewerPreview: openChamberDefaults.defaultFileViewerPreview ?? false, settingsZenModel: resolvedZenModel, settingsMessageStreamTransport: openChamberDefaults.messageStreamTransport ?? state.settingsMessageStreamTransport ?? 'auto', directoryScoped: { @@ -1704,6 +1713,10 @@ export const useConfigStore = create()( set({ settingsGitmojiEnabled: enabled }); }, + setSettingsDefaultFileViewerPreview: (enabled: boolean) => { + set({ settingsDefaultFileViewerPreview: enabled }); + }, + setSettingsZenModel: (model: string | undefined) => { set({ settingsZenModel: model }); }, @@ -2027,6 +2040,7 @@ export const useConfigStore = create()( settingsDefaultAgent: state.settingsDefaultAgent, settingsAutoCreateWorktree: state.settingsAutoCreateWorktree, settingsGitmojiEnabled: state.settingsGitmojiEnabled, + settingsDefaultFileViewerPreview: state.settingsDefaultFileViewerPreview, settingsZenModel: state.settingsZenModel, settingsMessageStreamTransport: state.settingsMessageStreamTransport, speechRate: state.speechRate, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 5714dcf9..913cb219 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -289,6 +289,9 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } + if (typeof candidate.defaultFileViewerPreview === 'boolean') { + result.defaultFileViewerPreview = candidate.defaultFileViewerPreview; + } if (typeof candidate.zenModel === 'string') { const trimmed = candidate.zenModel.trim(); result.zenModel = trimmed.length > 0 ? trimmed : undefined;