From bcbc2646fdaaf16aed6c3f13d9cafe56aa016d76 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Tue, 3 Mar 2026 01:07:03 +0200 Subject: [PATCH] Improve files view behavior and Open In integration (#579) * feat: open focused file in selected desktop app * fix: make file tree indicators match active open tabs * fix: limit preview functionality to markdown files only * feat: add Open In action to file editor toolbar. add antigravity to openin app list --- packages/desktop/src-tauri/src/main.rs | 176 +++++++++++++++++ .../components/desktop/OpenInAppButton.tsx | 69 +++---- packages/ui/src/components/layout/Header.tsx | 11 +- .../components/layout/SidebarFilesTree.tsx | 13 +- .../ui/src/components/views/FilesView.tsx | 184 +++++++++++------- packages/ui/src/lib/desktop.ts | 34 ++++ packages/ui/src/lib/openInApps.ts | 44 +++++ 7 files changed, 421 insertions(+), 110 deletions(-) create mode 100644 packages/ui/src/lib/openInApps.ts diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 9f1af657..91da941c 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -489,6 +489,181 @@ fn desktop_open_path(path: String, app: Option) -> Result<(), String> { } } +#[cfg(target_os = "macos")] +#[derive(Clone)] +struct OpenCommandSpec { + program: &'static str, + args: Vec, +} + +#[cfg(target_os = "macos")] +fn run_open_command_chain(specs: &[OpenCommandSpec]) -> Result<(), String> { + let mut failures: Vec = Vec::new(); + + for spec in specs { + match Command::new(spec.program).args(&spec.args).status() { + Ok(status) if status.success() => return Ok(()), + Ok(status) => failures.push(format!( + "{} {} exited with status {}", + spec.program, + spec.args.join(" "), + status + )), + Err(error) => failures.push(format!( + "{} {} failed: {}", + spec.program, + spec.args.join(" "), + error + )), + } + } + + if failures.is_empty() { + return Err("No launch strategies available".to_string()); + } + + Err(failures.join("; ")) +} + +#[cfg(target_os = "macos")] +fn is_jetbrains_app_id(app_id: &str) -> bool { + matches!( + app_id, + "pycharm" + | "intellij" + | "webstorm" + | "phpstorm" + | "rider" + | "rustrover" + | "android-studio" + ) +} + +#[cfg(target_os = "macos")] +fn cli_for_app_id(app_id: &str) -> Option<&'static str> { + match app_id { + "vscode" => Some("code"), + "cursor" => Some("cursor"), + "vscodium" => Some("codium"), + "windsurf" => Some("windsurf"), + "zed" => Some("zed"), + _ => None, + } +} + +#[tauri::command] +fn desktop_open_in_app( + project_path: String, + app_id: String, + app_name: String, + file_path: Option, +) -> Result<(), String> { + let trimmed_project_path = project_path.trim(); + if trimmed_project_path.is_empty() { + return Err("Project path is required".to_string()); + } + + let trimmed_app_id = app_id.trim().to_lowercase(); + if trimmed_app_id.is_empty() { + return Err("App id is required".to_string()); + } + + let trimmed_app_name = app_name.trim(); + if trimmed_app_name.is_empty() { + return Err("App name is required".to_string()); + } + + let normalized_file_path = file_path + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + + #[cfg(target_os = "macos")] + { + let project = trimmed_project_path.to_string(); + let app_name_owned = trimmed_app_name.to_string(); + let file = normalized_file_path.map(|value| value.to_string()); + let mut specs: Vec = Vec::new(); + + if trimmed_app_id == "finder" { + specs.push(OpenCommandSpec { + program: "open", + args: vec![project.clone()], + }); + return run_open_command_chain(&specs); + } + + if matches!(trimmed_app_id.as_str(), "terminal" | "iterm2" | "ghostty") { + specs.push(OpenCommandSpec { + program: "open", + args: vec!["-a".to_string(), app_name_owned.clone(), project.clone()], + }); + return run_open_command_chain(&specs); + } + + if let Some(cli) = cli_for_app_id(trimmed_app_id.as_str()) { + let mut cli_args = vec!["-n".to_string(), project.clone()]; + if let Some(file_path) = file.as_ref() { + cli_args.push("-g".to_string()); + cli_args.push(file_path.clone()); + } + specs.push(OpenCommandSpec { + program: cli, + args: cli_args, + }); + } + + if is_jetbrains_app_id(trimmed_app_id.as_str()) { + let mut args = vec![ + "-na".to_string(), + app_name_owned.clone(), + "--args".to_string(), + project.clone(), + ]; + if let Some(file_path) = file.as_ref() { + args.push(file_path.clone()); + } + specs.push(OpenCommandSpec { + program: "open", + args, + }); + } + + if let Some(file_path) = file.as_ref() { + specs.push(OpenCommandSpec { + program: "open", + args: vec![ + "-na".to_string(), + app_name_owned.clone(), + "--args".to_string(), + project.clone(), + file_path.clone(), + ], + }); + } + + specs.push(OpenCommandSpec { + program: "open", + args: vec!["-a".to_string(), app_name_owned.clone(), project.clone()], + }); + + if let Some(file_path) = file { + specs.push(OpenCommandSpec { + program: "open", + args: vec!["-a".to_string(), app_name_owned, file_path], + }); + } + + return run_open_command_chain(&specs); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = normalized_file_path; + Err("desktop_open_in_app is only supported on macOS".to_string()) + } +} + #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] struct InstalledAppInfo { @@ -2655,6 +2830,7 @@ fn main() { desktop_new_window_at_url, desktop_clear_cache, desktop_open_path, + desktop_open_in_app, desktop_filter_installed_apps, desktop_get_installed_apps, desktop_fetch_app_icons, diff --git a/packages/ui/src/components/desktop/OpenInAppButton.tsx b/packages/ui/src/components/desktop/OpenInAppButton.tsx index 58d9c6fa..92411d1e 100644 --- a/packages/ui/src/components/desktop/OpenInAppButton.tsx +++ b/packages/ui/src/components/desktop/OpenInAppButton.tsx @@ -10,57 +10,42 @@ import { toast } from '@/components/ui'; import { updateDesktopSettings } from '@/lib/persistence'; import { copyTextToClipboard } from '@/lib/clipboard'; import { cn } from '@/lib/utils'; -import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; +import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopProjectInApp, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; +import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS, getOpenInAppById, type OpenInApp } from '@/lib/openInApps'; import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react'; const FINDER_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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'; const TERMINAL_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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'; type OpenInAppOption = { - id: string; - label: string; - appName: string; + id: OpenInApp['id']; + label: OpenInApp['label']; + appName: OpenInApp['appName']; fallbackIconDataUrl?: string; iconDataUrl?: string; }; -const OPEN_IN_APPS: OpenInAppOption[] = [ - { id: 'finder', label: 'Finder', appName: 'Finder', fallbackIconDataUrl: FINDER_DEFAULT_ICON_DATA_URL }, - { id: 'terminal', label: 'Terminal', appName: 'Terminal', fallbackIconDataUrl: TERMINAL_DEFAULT_ICON_DATA_URL }, - { id: 'iterm2', label: 'iTerm2', appName: 'iTerm' }, - { id: 'ghostty', label: 'Ghostty', appName: 'Ghostty' }, - { id: 'vscode', label: 'VS Code', appName: 'Visual Studio Code' }, - { id: 'intellij', label: 'IntelliJ', appName: 'IntelliJ IDEA' }, - { id: 'visual-studio', label: 'Visual Studio', appName: 'Visual Studio' }, - { id: 'cursor', label: 'Cursor', appName: 'Cursor' }, - { id: 'android-studio', label: 'Android Studio', appName: 'Android Studio' }, - { id: 'pycharm', label: 'PyCharm', appName: 'PyCharm' }, - { id: 'xcode', label: 'Xcode', appName: 'Xcode' }, - { id: 'sublime-text', label: 'Sublime', appName: 'Sublime Text' }, - { id: 'webstorm', label: 'WebStorm', appName: 'WebStorm' }, - { id: 'rider', label: 'Rider', appName: 'Rider' }, - { id: 'zed', label: 'Zed', appName: 'Zed' }, - { id: 'phpstorm', label: 'PhpStorm', appName: 'PhpStorm' }, - { id: 'eclipse', label: 'Eclipse', appName: 'Eclipse' }, - { id: 'windsurf', label: 'Windsurf', appName: 'Windsurf' }, - { id: 'vscodium', label: 'VSCodium', appName: 'VSCodium' }, - { id: 'rustrover', label: 'RustRover', appName: 'RustRover' }, - { id: 'trae', label: 'Trae', appName: 'Trae' }, -]; +const OPEN_IN_APP_OPTIONS: OpenInAppOption[] = OPEN_IN_APPS.map((app) => ({ + ...app, + fallbackIconDataUrl: app.id === 'finder' + ? FINDER_DEFAULT_ICON_DATA_URL + : app.id === 'terminal' + ? TERMINAL_DEFAULT_ICON_DATA_URL + : undefined, +})); -const DEFAULT_APP_ID = 'finder'; const ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']); -const getAlwaysAvailableApps = () => OPEN_IN_APPS.filter((app) => ALWAYS_AVAILABLE_APP_IDS.has(app.id)); +const getAlwaysAvailableApps = () => OPEN_IN_APP_OPTIONS.filter((app) => ALWAYS_AVAILABLE_APP_IDS.has(app.id)); const getStoredAppId = (): string => { if (typeof window === 'undefined') { - return DEFAULT_APP_ID; + return DEFAULT_OPEN_IN_APP_ID; } const stored = window.localStorage.getItem('openInAppId'); - if (stored && OPEN_IN_APPS.some((app) => app.id === stored)) { + if (stored && getOpenInAppById(stored)) { return stored; } - return DEFAULT_APP_ID; + return DEFAULT_OPEN_IN_APP_ID; }; const AppIcon = ({ @@ -102,10 +87,11 @@ const AppIcon = ({ type OpenInAppButtonProps = { directory: string; + activeFilePath?: string | null; className?: string; }; -export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) => { +export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenInAppButtonProps) => { const [selectedAppId, setSelectedAppId] = React.useState(getStoredAppId); const [availableApps, setAvailableApps] = React.useState(getAlwaysAvailableApps); const [hasLoadedApps, setHasLoadedApps] = React.useState(false); @@ -125,7 +111,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) const nextId = detail && typeof detail.openInAppId === 'string' && detail.openInAppId.length > 0 - && OPEN_IN_APPS.some((app) => app.id === detail.openInAppId) + && getOpenInAppById(detail.openInAppId) ? detail.openInAppId : null; if (!nextId) { @@ -163,7 +149,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) const allowed = new Set(installed.map((app) => app.name)); const iconMap = new Map(installed.map((app) => [app.name, app.iconDataUrl ?? undefined])); - const filtered = OPEN_IN_APPS.filter( + const filtered = OPEN_IN_APP_OPTIONS.filter( (app) => allowed.has(app.appName) || ALWAYS_AVAILABLE_APP_IDS.has(app.id) ); const withIcons = filtered.map((app) => ({ @@ -177,7 +163,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) const loadInstalledApps = React.useCallback(async (force?: boolean) => { if (isLoadingRef.current) return; if (hasLoadedApps && !force) return; - const appNames = OPEN_IN_APPS.map((app) => app.appName); + const appNames = OPEN_IN_APP_OPTIONS.map((app) => app.appName); if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; @@ -271,9 +257,9 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) }, [isDesktopLocal, loadInstalledApps]); const selectedApp = React.useMemo(() => { - const known = OPEN_IN_APPS.find((app) => app.id === selectedAppId) - ?? OPEN_IN_APPS.find((app) => app.id === DEFAULT_APP_ID) - ?? OPEN_IN_APPS[0]; + const known = OPEN_IN_APP_OPTIONS.find((app) => app.id === selectedAppId) + ?? OPEN_IN_APP_OPTIONS.find((app) => app.id === DEFAULT_OPEN_IN_APP_ID) + ?? OPEN_IN_APP_OPTIONS[0]; if (known) { const iconDataUrl = availableApps.find((app) => app.appName === known.appName)?.iconDataUrl; return iconDataUrl ? { ...known, iconDataUrl } : known; @@ -290,7 +276,10 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) } const handleOpen = async (app: OpenInAppOption) => { - await openDesktopPath(directory, app.appName); + const opened = await openDesktopProjectInApp(directory, app.id, app.appName, activeFilePath); + if (!opened) { + await openDesktopPath(directory, app.appName); + } }; const handleSelect = async (app: OpenInAppOption) => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 026b053f..fd16c8a5 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -23,6 +23,7 @@ import { useSessionStore } from '@/stores/useSessionStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -506,6 +507,14 @@ export const Header: React.FC = ({ return worktreeDirectory || sessionDirectory || draftDirectory; }, [draftDirectory, sessionDirectory, worktreeDirectory]); + const selectedFilePath = useFilesViewTabsStore((state) => { + const directory = normalize(openDirectory || ''); + if (!directory) { + return null; + } + return state.byRoot[directory]?.selectedPath ?? null; + }); + const actionDirectory = React.useMemo(() => { return normalize(openDirectory || activeProject?.path || ''); }, [activeProject?.path, openDirectory]); @@ -1084,7 +1093,7 @@ export const Header: React.FC = ({ )} - + { diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index 43506263..07081577 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -305,13 +305,20 @@ export const SidebarFilesTree: React.FC = () => { const inFlightDirsRef = React.useRef>(new Set()); const EMPTY_PATHS: string[] = React.useMemo(() => [], []); + const EMPTY_CONTEXT_TABS: Array<{ mode: string; targetPath: string | null }> = React.useMemo(() => [], []); const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); - const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS)); const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null)); const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath); const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath); const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix); const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath); + const contextTabs = useUIStore((state) => (root ? (state.contextPanelByDirectory[root]?.tabs ?? EMPTY_CONTEXT_TABS) : EMPTY_CONTEXT_TABS)); + const openContextFilePaths = React.useMemo(() => new Set( + contextTabs + .map((tab) => (tab.mode === 'file' ? tab.targetPath : null)) + .filter((targetPath): targetPath is string => typeof targetPath === 'string' && targetPath.length > 0) + .map((targetPath) => normalizePath(targetPath)) + ), [contextTabs]); // Context menu state const [contextMenuPath, setContextMenuPath] = React.useState(null); @@ -552,7 +559,7 @@ export const SidebarFilesTree: React.FC = () => { // --- Git status helpers (matching FilesView) --- const getFileStatus = React.useCallback((path: string): FileStatus | null => { - if (openPaths.includes(path)) return 'open'; + if (openContextFilePaths.has(path)) return 'open'; if (gitStatus?.files) { const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path; @@ -564,7 +571,7 @@ export const SidebarFilesTree: React.FC = () => { } } return null; - }, [openPaths, gitStatus, root]); + }, [openContextFilePaths, gitStatus, root]); const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => { if (!gitStatus?.files) return null; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index d6b81962..bf3769a3 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -22,8 +22,8 @@ import { RiFolderAddLine, RiDeleteBinLine, RiEditLine, - RiEyeLine, RiFileCopyLine, + RiFileTransferFill, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; @@ -74,6 +74,8 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; +import { openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop'; +import { getDefaultOpenInApp, getOpenInAppById, OPEN_DIRECTORY_APP_IDS, type OpenInApp } from '@/lib/openInApps'; type FileNode = { name: string; @@ -88,6 +90,37 @@ type SelectedLineRange = { end: number; }; +const getSelectedOpenInApp = (): OpenInApp => { + const stored = typeof window !== 'undefined' ? window.localStorage.getItem('openInAppId') : null; + const selected = getOpenInAppById(stored); + if (selected) { + return selected; + } + return getDefaultOpenInApp(); +}; + +const getParentDirectoryPath = (path: string): string => { + const normalized = normalizePath(path); + if (!normalized) return ''; + if (normalized === '/' || /^[A-Za-z]:\/$/.test(normalized)) { + return normalized; + } + + const lastSlash = normalized.lastIndexOf('/'); + if (lastSlash < 0) { + return normalized; + } + if (lastSlash === 0) { + return '/'; + } + + const parent = normalized.slice(0, lastSlash); + if (/^[A-Za-z]:$/.test(parent)) { + return `${parent}/`; + } + return parent; +}; + const sortNodes = (items: FileNode[]) => items.slice().sort((a, b) => { if (a.type !== b.type) { @@ -413,6 +446,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [isFullscreen, setIsFullscreen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit'); + const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit'); const lightTheme = React.useMemo( () => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false), @@ -527,9 +561,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [copiedContent, setCopiedContent] = React.useState(false); const [copiedPath, setCopiedPath] = React.useState(false); - // Markdown view mode (global, not per-file) - const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit'); - const canCreateFile = Boolean(files.writeFile); const canCreateFolder = Boolean(files.createDirectory); const canRename = Boolean(files.rename); @@ -543,6 +574,38 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }); }, [files]); + const handleOpenInSelectedApp = React.useCallback(async () => { + if (!selectedFile?.path || !root) { + return; + } + + const selectedApp = getSelectedOpenInApp(); + const fileDirectory = getParentDirectoryPath(selectedFile.path) || root; + + if (OPEN_DIRECTORY_APP_IDS.has(selectedApp.id)) { + const openedDirectory = await openDesktopPath(fileDirectory, selectedApp.appName); + if (!openedDirectory) { + toast.error(`Failed to open in ${selectedApp.appName}`); + } + return; + } + + const openedInApp = await openDesktopProjectInApp(root, selectedApp.id, selectedApp.appName, selectedFile.path); + if (openedInApp) { + return; + } + + const openedFile = await openDesktopPath(selectedFile.path, selectedApp.appName); + if (openedFile) { + return; + } + + const openedDirectory = await openDesktopPath(fileDirectory, selectedApp.appName); + if (!openedDirectory) { + toast.error(`Failed to open in ${selectedApp.appName}`); + } + }, [root, selectedFile?.path]); + const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => { setActiveDialog(type); setDialogData(data); @@ -776,37 +839,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } }, [loadDirectory, root, showGitignored, showHidden]); - const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; - - // Load markdown view mode preference from localStorage on mount - React.useEffect(() => { - try { - const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); - if (stored === 'preview') { - setMdViewMode('preview'); - } else if (stored === 'edit') { - setMdViewMode('edit'); - } - } catch { - // Ignore localStorage errors - } - }, []); - - // Save markdown view mode preference to localStorage -const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => { - setMdViewMode(mode); - try { - localStorage.setItem(MD_VIEWER_MODE_KEY, mode); - } catch { - // Ignore localStorage errors - } -}, []); - - // Get the view mode for a markdown file (from state, default to 'edit') - const getMdViewMode = React.useCallback((): 'preview' | 'edit' => { - return mdViewMode; - }, [mdViewMode]); - const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => { e?.preventDefault(); if (!dialogData || !activeDialog) return; @@ -1614,6 +1646,34 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => { setTextViewMode('edit'); }, [selectedFile?.path]); + const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode'; + + React.useEffect(() => { + try { + const stored = localStorage.getItem(MD_VIEWER_MODE_KEY); + if (stored === 'preview') { + setMdViewMode('preview'); + } else if (stored === 'edit') { + setMdViewMode('edit'); + } + } catch { + // Ignore localStorage errors + } + }, []); + + const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => { + setMdViewMode(mode); + try { + localStorage.setItem(MD_VIEWER_MODE_KEY, mode); + } catch { + // Ignore localStorage errors + } + }, []); + + const getMdViewMode = React.useCallback((): 'preview' | 'edit' => { + return mdViewMode; + }, [mdViewMode]); + React.useEffect(() => { if (!pendingFileNavigation || !root) { return; @@ -2196,25 +2256,21 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => { )} + + {canEdit && !isSelectedImage && (