diff --git a/packages/ui/src/apps/MobileFilesSurface.tsx b/packages/ui/src/apps/MobileFilesSurface.tsx index 5ec4e314..0bf72858 100644 --- a/packages/ui/src/apps/MobileFilesSurface.tsx +++ b/packages/ui/src/apps/MobileFilesSurface.tsx @@ -28,7 +28,7 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; -import { getImageMimeType, getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers'; +import { getImageMimeType, getLanguageFromExtension, isBinaryFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; import type { FileListEntry, FileSearchResult } from '@/lib/api/types'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { cn } from '@/lib/utils'; @@ -98,6 +98,7 @@ export const MobileFilesSurface: React.FC = ({ onClose const [imageSrc, setImageSrc] = React.useState(''); const [fileError, setFileError] = React.useState(null); const [isLoadingFile, setIsLoadingFile] = React.useState(false); + const [binaryBlocked, setBinaryBlocked] = React.useState(false); const directoryLoadRequestIdRef = React.useRef(0); React.useEffect(() => { @@ -174,8 +175,9 @@ export const MobileFilesSurface: React.FC = ({ onClose setFileContent(''); setImageSrc(''); setFileError(null); + setBinaryBlocked(false); - if (isImageFile(route.path) && !route.path.toLowerCase().endsWith('.svg')) { + if (isImageFile(route.path) && !isSvgFile(route.path)) { let cancelled = false; let objectUrl = ''; setIsLoadingFile(true); @@ -203,6 +205,14 @@ export const MobileFilesSurface: React.FC = ({ onClose }; } + // Never load PDF/office/archives/etc. as UTF-8 text — that path can corrupt originals + // if a future write path is added, and it shows gibberish in the viewer. + if (isBinaryFile(route.path) || isPdfFile(route.path)) { + setBinaryBlocked(true); + setIsLoadingFile(false); + return; + } + if (!files.readFile) { setFileError(t('mobile.files.error.readUnavailable')); setIsLoadingFile(false); @@ -214,6 +224,11 @@ export const MobileFilesSurface: React.FC = ({ onClose void files.readFile(route.path) .then((result) => { if (cancelled) return; + if (looksLikeBinaryText(result.content)) { + setBinaryBlocked(true); + setFileContent(''); + return; + } setFileContent(result.content.length > MAX_MOBILE_FILE_CHARS ? `${result.content.slice(0, MAX_MOBILE_FILE_CHARS)}\n\n${t('mobile.files.file.truncated')}` : result.content); @@ -263,6 +278,7 @@ export const MobileFilesSurface: React.FC = ({ onClose imageSrc={imageSrc} error={fileError} isLoading={isLoadingFile} + binaryBlocked={binaryBlocked} onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })} onCopyPath={() => void handleCopyPath(route.path)} onCopyContent={() => void handleCopyContent()} @@ -413,10 +429,11 @@ const MobileFileDetail: React.FC<{ imageSrc: string; error: string | null; isLoading: boolean; + binaryBlocked: boolean; onBack: () => void; onCopyPath: () => void; onCopyContent: () => void; -}> = ({ path, content, imageSrc, error, isLoading, onBack, onCopyPath, onCopyContent }) => { +}> = ({ path, content, imageSrc, error, isLoading, binaryBlocked, onBack, onCopyPath, onCopyContent }) => { const { t } = useI18n(); return ( @@ -433,7 +450,7 @@ const MobileFileDetail: React.FC<{

{getNameFromPath(path)}

- {!isImageFile(path) ? ( + {!isImageFile(path) && !binaryBlocked ? ( @@ -455,6 +472,11 @@ const MobileFileDetail: React.FC<{ {getNameFromPath(path)} + ) : binaryBlocked ? ( +
+
{t('filesView.editor.cannotPreviewBinary')}
+
{t('filesView.editor.binaryFileDescription')}
+
) : ( )} diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index b164a9cd..9a7e4a04 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -868,7 +868,7 @@ export const SidebarFilesTree: React.FC = () => { const handleOpenFile = React.useCallback(async (node: FileNode) => { if (!root) return; - const openValidation = await validateContextFileOpen(files, node.path); + const openValidation = await validateContextFileOpen(files, node.path, { directory: root }); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index ebae3dc5..73f870ca 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -146,6 +146,7 @@ const GeneralSectionContent: React.FC = () => { {!isVSCode && } = [ { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, @@ -330,6 +330,8 @@ export const OpenChamberVisualSettings: React.FC const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled); const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar); const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar); + const autoSaveEnabled = useUIStore(state => state.autoSaveEnabled); + const setAutoSaveEnabled = useUIStore(state => state.setAutoSaveEnabled); const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled); const setWideChatLayoutEnabled = useUIStore(state => state.setWideChatLayoutEnabled); const codeBlockLineWrap = useUIStore(state => state.codeBlockLineWrap); @@ -643,7 +645,7 @@ export const OpenChamberVisualSettings: React.FC ? hasLocalizationSettings : (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile); - const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || (shouldShow('expandedEditorToolbar') && !isVSCode); + const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode); const hasBehaviorSettings = shouldShow('mermaidRendering') || (shouldShow('sessionGoal') && !isVSCode) || shouldShow('userMessageRendering') @@ -1515,6 +1517,16 @@ export const OpenChamberVisualSettings: React.FC )}
+ {shouldShow('autoSaveEnabled') && ( + + )} {shouldShow('expandedEditorToolbar') && !isVSCode && ( { const handleOpenFile = React.useCallback( async (filePath: string) => { if (!currentRoot) return; - const validation = await validateContextFileOpen(filesApi, filePath); + const validation = await validateContextFileOpen(filesApi, filePath, { directory: currentRoot }); if (!validation.ok) { toast.error(getContextFileOpenFailureMessage(validation.reason)); return; diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 95d83840..31d70c20 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -1537,7 +1537,7 @@ export const DiffView: React.FC = ({ : getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified)); const absolutePath = toAbsolutePath(effectiveDirectory, filePath); - const openValidation = await validateContextFileOpen(files, absolutePath); + const openValidation = await validateContextFileOpen(files, absolutePath, { directory: effectiveDirectory }); if (!openValidation.ok) { toast.error(getContextFileOpenFailureMessage(openValidation.reason)); return; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 838ac74a..fec12654 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -44,7 +44,8 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; -import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile, isPdfFile } from '@/lib/toolHelpers'; +import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; +import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; @@ -304,7 +305,6 @@ const isFileMissingError = (error: unknown): boolean => { }; const MAX_VIEW_CHARS = 200_000; -const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled'; type FileLineEnding = '\n' | '\r\n'; const detectFileLineEnding = (content: string): FileLineEnding => { @@ -332,18 +332,6 @@ const serializeEditorContent = (content: string, lineEnding: FileLineEnding): st return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized; }; -const getInitialAutoSaveEnabled = (): boolean => { - if (typeof window === 'undefined') { - return true; - } - - try { - return window.localStorage.getItem(FILE_EDITOR_AUTO_SAVE_KEY) !== 'false'; - } catch { - return true; - } -}; - const getFileIcon = (filePath: string, extension?: string): React.ReactNode => { return ; }; @@ -924,7 +912,9 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const loadingFilePathRef = React.useRef(null); const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle'); const [diagramSaved, setDiagramSaved] = React.useState(false); - const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled); + const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false); + const autoSaveEnabled = useUIStore((state) => state.autoSaveEnabled); + const setAutoSaveEnabled = useUIStore((state) => state.setAutoSaveEnabled); const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false); const pendingSelectFileRef = React.useRef(null); @@ -1626,17 +1616,31 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return false; } - if (!isDirty) { - return true; + const selectedIsBinary = isBinaryFile(selectedFile.path) || contentDetectedBinary; + if (!shouldAllowFileDraftSave({ + selectedFilePath: selectedFile.path, + loadedFilePath, + fileLoading, + isDirty, + draftContent, + fileContent, + isNonEditableBinary: selectedIsBinary, + })) { + if (selectedIsBinary) { + console.warn(`[saveDraft] refusing to save binary file "${selectedFile.path}".`); + } else if (draftContent === '' && fileContent !== '' && loadedFilePath !== selectedFile.path) { + console.warn( + `[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` + + 'The file may have been read during a concurrent write (O_TRUNC race). ' + + 'Try again after content finishes loading if the save was intentional.', + ); + } + return false; } - if (draftContent === '' && fileContent !== '' && loadedFilePath !== selectedFile.path) { - console.warn( - `[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` + - 'The file may have been read during a concurrent write (O_TRUNC race). ' + - 'Try again after content finishes loading if the save was intentional.', - ); - return false; + // Clean draft: treat as success so discard/save dialogs and Ctrl+S are not stranded. + if (!isDirty) { + return true; } setIsSaving(true); @@ -1668,7 +1672,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } finally { setIsSaving(false); } - }, [draftContent, fileContent, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]); + }, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]); React.useEffect(() => { if (!isDirty) { @@ -1696,14 +1700,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, [isDirty, setMainTabGuard]); - React.useEffect(() => { - try { - window.localStorage.setItem(FILE_EDITOR_AUTO_SAVE_KEY, autoSaveEnabled ? 'true' : 'false'); - } catch { - // Ignore localStorage errors; the in-memory preference still applies. - } - }, [autoSaveEnabled]); - React.useEffect(() => { if (autoSaveEnabled) { return; @@ -1721,7 +1717,17 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { React.useEffect(() => { const canWrite = Boolean(selectedFile && files.writeFile); - if (!autoSaveEnabled || !isDirty || !canWrite || isSaving) { + const selectedIsBinary = Boolean(selectedFile?.path && (isBinaryFile(selectedFile.path) || contentDetectedBinary)); + if (!shouldScheduleFileAutosave({ + autoSaveEnabled, + isDirty, + canWrite, + isSaving, + fileLoading, + selectedFilePath: selectedFile?.path, + loadedFilePath, + isNonEditableBinary: selectedIsBinary, + })) { return; } @@ -1739,7 +1745,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { autoSaveTimerRef.current = null; } }; - }, [autoSaveEnabled, draftContent, isDirty, selectedFile, files.writeFile, isSaving, saveDraft]); + }, [autoSaveEnabled, contentDetectedBinary, draftContent, fileLoading, isDirty, loadedFilePath, selectedFile, files.writeFile, isSaving, saveDraft]); // Reset auto-save status when switching files React.useEffect(() => { @@ -1789,10 +1795,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setFileError(null); setDesktopImageSrc(''); setLoadedFilePath(null); + setContentDetectedBinary(false); const selectedIsImage = isImageFile(node.path); - const isSvg = node.path.toLowerCase().endsWith('.svg'); + const isSvg = isSvgFile(node.path); const selectedIsPdf = isPdfFile(node.path); + const selectedIsBinary = isBinaryFile(node.path); if (isMobile) { setShowMobilePageContent(true); @@ -1823,6 +1831,16 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return; } + // Other known binaries (docx/xlsx/zip/…) must never be opened as text — + // a later autosave would corrupt them. + if (selectedIsBinary) { + setFileContent(''); + setDraftContent(''); + setLoadedFilePath(node.path); + setFileLoading(false); + return; + } + setFileLoading(true); const outsideFileGrant = getOutsideFileGrant(node.path); @@ -1836,6 +1854,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (!isCurrentLoad()) { return; } + if (looksLikeBinaryText(content)) { + setContentDetectedBinary(true); + setFileContent(''); + setDraftContent(''); + setLoadedFilePath(node.path); + return; + } const editorContent = normalizeEditorLineEndings(content); setLoadedFileLineEnding(detectFileLineEnding(content)); setFileContent(editorContent); @@ -2302,8 +2327,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); - const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); + const isSelectedSvg = Boolean(selectedFile?.path && isSvgFile(selectedFile.path)); const isSelectedPdf = Boolean(selectedFile?.path && isPdfFile(selectedFile.path)); + const isSelectedBinary = Boolean( + selectedFile?.path + && (isBinaryFile(selectedFile.path) || contentDetectedBinary) + ); + const isUnsupportedBinary = isSelectedBinary && !isSelectedImage && !isSelectedPdf; const pendingNavigationTargetPath = React.useMemo( () => normalizePath(pendingFileNavigation?.path ?? ''), [pendingFileNavigation?.path], @@ -2316,21 +2346,24 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { && !fileLoading && !fileError && !isSelectedImage - && !isSelectedPdf, + && !isSelectedPdf + && !isUnsupportedBinary, ); const displaySelectedPath = React.useMemo(() => { return getDisplayPath(root, selectedFilePath); }, [selectedFilePath, root]); - const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && !isSelectedPdf && fileContent.length > 0); + const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && !isSelectedPdf && !isUnsupportedBinary && fileContent.length > 0); const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0); - const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedImage && !isSelectedPdf && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); + // Keep image/SVG on the preview path: `isBinaryFile` excludes `.svg`, so binary + // alone would flip canEdit/isTextFile true and show a dead edit toggle + no-op Save. + const canEdit = Boolean(selectedFile && !selectedFileIsOutsideWorkspace && !isSelectedBinary && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS); const isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path)); const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path)); const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path)); const isDrawio = Boolean(selectedFile?.path && isDrawioFile(selectedFile.path)); - const isTextFile = Boolean(selectedFile && !isSelectedImage && !isSelectedPdf); + const isTextFile = Boolean(selectedFile && !isSelectedBinary && !isSelectedImage); const canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview'); const isEditingFile = (isMarkdown && mdViewMode === 'edit') || (isHtml && htmlViewMode === 'edit') @@ -2535,7 +2568,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const handleDiagramChange = React.useCallback((xml: string) => { diagramXmlRef.current = xml; - if (!selectedFile?.path || drawioViewMode !== 'preview' || !files.writeFile) { + if (!autoSaveEnabled || !selectedFile?.path || drawioViewMode !== 'preview' || !files.writeFile) { return; } @@ -2554,7 +2587,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed')); }); }, AUTO_SAVE_DELAY); - }, [drawioViewMode, files.writeFile, saveDiagramXml, selectedFile?.path, t]); + }, [autoSaveEnabled, drawioViewMode, files.writeFile, saveDiagramXml, selectedFile?.path, t]); const diagramEditorXml = React.useMemo(() => { if (!isDrawio) { @@ -2674,7 +2707,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return; } - if (fileError || isSelectedImage || isSelectedPdf) { + if (fileError || isSelectedImage || isSelectedPdf || isUnsupportedBinary) { setPendingFileNavigation(null); pendingNavigationCycleRef.current = { key: '', attempts: 0 }; return; @@ -2746,6 +2779,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { fileLoading, isSelectedImage, isSelectedPdf, + isUnsupportedBinary, loadedFilePath, handleSelectFile, pendingFileNavigation, @@ -2783,7 +2817,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { // JSON tree, images, PDFs) never mount a CodeMirror editor, so the request // must clear regardless — otherwise it lingers and replays on every // dependency change. - if (!fileError && !isSelectedImage && !isSelectedPdf && canEdit && textViewMode === 'edit') { + if (!fileError && !isSelectedImage && !isSelectedPdf && !isUnsupportedBinary && canEdit && textViewMode === 'edit') { editorViewRef.current?.focus(); } @@ -2794,6 +2828,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { fileLoading, isSelectedImage, isSelectedPdf, + isUnsupportedBinary, loadedFilePath, pendingFileFocusPath, root, @@ -3189,7 +3224,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : isSelectedPdf ? ( renderPdfPreview(selectedFile) + ) : isUnsupportedBinary ? ( +
+
{t('filesView.editor.cannotPreviewBinary')}
+
{t('filesView.editor.binaryFileDescription')}
+ {files.downloadFile ? ( + + ) : null} +
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
= ({ mode = 'full' }) => {
) : isSelectedPdf ? ( renderPdfPreview(selectedFile) + ) : isUnsupportedBinary ? ( +
+
{t('filesView.editor.cannotPreviewBinary')}
+
{t('filesView.editor.binaryFileDescription')}
+ {files.downloadFile ? ( + + ) : null} +
) : isMarkdown && getMdViewMode() === 'preview' ? (
{fileContent.length > 500 * 1024 && ( diff --git a/packages/ui/src/contexts/RuntimeAPIProvider.tsx b/packages/ui/src/contexts/RuntimeAPIProvider.tsx index f91a4abe..46ec8376 100644 --- a/packages/ui/src/contexts/RuntimeAPIProvider.tsx +++ b/packages/ui/src/contexts/RuntimeAPIProvider.tsx @@ -1,17 +1,32 @@ import React, { type JSX, type ReactNode } from 'react'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import type { RuntimeAPIs } from '@/lib/api/types'; +import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types'; import { createContentCachedFiles } from '@/contexts/content-cache-owner'; +type ContentCachedFiles = ReturnType; + export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element { - const cachedFiles = React.useMemo(() => createContentCachedFiles(apis.files), [apis.files]); - React.useEffect(() => () => cachedFiles.dispose(), [cachedFiles]); + // Effect-owned lifecycle: React Strict Mode dispose+remount must create a fresh + // owner. useMemo + dispose reused a dead owner and broke text-file opens + // (binaries skipped the pre-read, so they still appeared to work). + const [cachedOwner, setCachedOwner] = React.useState(null); + + React.useEffect(() => { + const owner = createContentCachedFiles(apis.files); + setCachedOwner(owner); + return () => { + owner.dispose(); + setCachedOwner((current) => (current === owner ? null : current)); + }; + }, [apis.files]); + + const files: FilesAPI = cachedOwner?.files ?? apis.files; const cachedApis = React.useMemo( () => ({ ...apis, - files: cachedFiles.files, + files, }), - [apis, cachedFiles], + [apis, files], ); return {children}; } diff --git a/packages/ui/src/contexts/content-cache-owner.test.ts b/packages/ui/src/contexts/content-cache-owner.test.ts index 31547064..a9095493 100644 --- a/packages/ui/src/contexts/content-cache-owner.test.ts +++ b/packages/ui/src/contexts/content-cache-owner.test.ts @@ -71,4 +71,54 @@ describe("content cache owner", () => { expect(second.content).toBe("/b-2") owner.dispose() }) + + test("disposed owners throw on subsequent reads", async () => { + const owner = createContentCachedFiles({ + readFile: async (path: string) => ({ path, content: "value" }), + statFile: async () => ({ isFile: true, isDirectory: false, size: 5, mtimeMs: 1 }), + } as unknown as FilesAPI) + + owner.dispose() + await expect(owner.files.readFile!("notes.txt", { optional: true, directory: "/tmp/project" })) + .rejects.toThrow("File cache owner disposed") + }) + + test("runtime endpoint changes clear cache but keep serving reads", async () => { + const originalWindow = globalThis.window + const events = new EventTarget() + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + dispatchEvent: events.dispatchEvent.bind(events), + }, + }) + + try { + let reads = 0 + const owner = createContentCachedFiles({ + readFile: async (path: string) => ({ path, content: `value-${++reads}` }), + statFile: async () => ({ isFile: true, isDirectory: false, size: 7, mtimeMs: 1 }), + } as unknown as FilesAPI) + + expect((await owner.files.readFile!("notes.txt")).content).toBe("value-1") + window.dispatchEvent(new CustomEvent("openchamber:runtime-endpoint-will-change", { + detail: { + apiBaseUrl: "http://127.0.0.1:3902", + previousApiBaseUrl: "http://127.0.0.1:3901", + runtimeKey: "url:http://127.0.0.1:3902", + previousRuntimeKey: "url:http://127.0.0.1:3901", + }, + })) + expect((await owner.files.readFile!("notes.txt")).content).toBe("value-2") + expect(reads).toBe(2) + owner.dispose() + } finally { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }) + } + }) }) diff --git a/packages/ui/src/contexts/content-cache-owner.ts b/packages/ui/src/contexts/content-cache-owner.ts index 34b8e8aa..0a027ab4 100644 --- a/packages/ui/src/contexts/content-cache-owner.ts +++ b/packages/ui/src/contexts/content-cache-owner.ts @@ -5,6 +5,14 @@ const MAX_ENTRIES = 40; const MAX_BYTES = 20 * 1024 * 1024; type Entry = { content: string; path: string; sourcePath: string; size: number; mtimeMs: number; bytes: number }; +/** + * Content-cached `FilesAPI.readFile` wrapper. + * + * Lifecycle: `RuntimeAPIProvider` owns create/dispose in an effect so React + * Strict Mode remounts get a fresh owner. After `dispose()`, reads throw — + * callers must not keep using a torn-down owner. Runtime endpoint switches + * bump generation and clear the cache without deactivating the owner. + */ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; dispose: () => void } { const cache = new Map(); let totalBytes = 0; @@ -32,6 +40,10 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di && cached.mtimeMs === latest.mtimeMs && cached.size === latest.size ); + const clearCache = () => { + cache.clear(); + totalBytes = 0; + }; const cacheResult = ( key: string, sourcePath: string, @@ -61,7 +73,7 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di const before = await files.statFile?.(path, options).catch(() => null); const result = await files.readFile!(path, options); const after = await files.statFile?.(path, options).catch(() => null); - if (!active) throw new Error('File read invalidated by runtime change'); + if (!active) throw new Error('File cache owner disposed'); if (capturedGeneration !== generation) return cachedReadFile!(path, options); const stable = before && after && before.isFile && after.isFile && before.mtimeMs !== undefined && after.mtimeMs !== undefined @@ -79,7 +91,7 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di const hit = cache.get(key); if (!hit) return readFresh(key, path, options, capturedGeneration); const latest = await files.statFile?.(path, options).catch(() => null); - if (!active) throw new Error('File read invalidated by runtime change'); + if (!active) throw new Error('File cache owner disposed'); if (capturedGeneration !== generation) return cachedReadFile!(path, options); if (!latest || !metadataMatches(hit, latest)) { removeEntry(key); @@ -116,18 +128,18 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di }; const unsubscribeRuntime = subscribeRuntimeEndpointWillChange((detail) => { if (detail.runtimeKey === detail.previousRuntimeKey) return; - active = false; + // Invalidate cached content for the previous runtime, but keep serving reads. + // `apis.files` is typically stable across endpoint switches, so permanently + // deactivating this owner would break every subsequent text-file open. generation += 1; - cache.clear(); - totalBytes = 0; + clearCache(); }); return { files: cachedFiles, dispose: () => { active = false; generation += 1; - cache.clear(); - totalBytes = 0; + clearCache(); unsubscribeRuntime(); }, }; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index e97a1ada..04e329bf 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -637,6 +637,7 @@ export interface SettingsPayload { nativeNotificationsEnabled?: boolean; notificationMode?: 'always' | 'hidden-only'; autoDeleteEnabled?: boolean; + autoSaveEnabled?: boolean; autoDeleteAfterDays?: number; sessionRetentionAction?: 'archive' | 'delete'; followUpBehavior?: 'steer' | 'queue'; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 10d57fd5..d0b314a2 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -31,6 +31,7 @@ type AppearanceSlice = { summaryLength: number; maxLastMessageLength: number; autoDeleteEnabled: boolean; + autoSaveEnabled: boolean; autoDeleteAfterDays: number; sessionRetentionAction: 'archive' | 'delete'; fontSize: number; @@ -78,6 +79,7 @@ export const startAppearanceAutoSave = (): void => { summaryLength: useUIStore.getState().summaryLength, maxLastMessageLength: useUIStore.getState().maxLastMessageLength, autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled, + autoSaveEnabled: useUIStore.getState().autoSaveEnabled, autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays, sessionRetentionAction: useUIStore.getState().sessionRetentionAction, fontSize: useUIStore.getState().fontSize, @@ -117,6 +119,7 @@ export const startAppearanceAutoSave = (): void => { summaryLength: state.summaryLength, maxLastMessageLength: state.maxLastMessageLength, autoDeleteEnabled: state.autoDeleteEnabled, + autoSaveEnabled: state.autoSaveEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, sessionRetentionAction: state.sessionRetentionAction, fontSize: state.fontSize, @@ -196,6 +199,9 @@ export const startAppearanceAutoSave = (): void => { if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) { diff.autoDeleteEnabled = current.autoDeleteEnabled; } + if (current.autoSaveEnabled !== previous.autoSaveEnabled) { + diff.autoSaveEnabled = current.autoSaveEnabled; + } if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) { diff.autoDeleteAfterDays = current.autoDeleteAfterDays; } diff --git a/packages/ui/src/lib/contextFileOpenGuard.test.ts b/packages/ui/src/lib/contextFileOpenGuard.test.ts new file mode 100644 index 00000000..b87b3643 --- /dev/null +++ b/packages/ui/src/lib/contextFileOpenGuard.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; + +import type { FilesAPI } from '@/lib/api/types'; +import { validateContextFileOpen } from './contextFileOpenGuard'; + +const filesApi = (content: string): FilesAPI => + ({ + listDirectory: async () => ({ directory: '/', entries: [] }), + readFile: async () => ({ content, path: '/x' }), + }) as unknown as FilesAPI; + +describe('validateContextFileOpen', () => { + test('allows known binaries through without reading text', async () => { + const files = { + listDirectory: async () => ({ directory: '/', entries: [] }), + readFile: async () => { + throw new Error('should not read binary as text'); + }, + } as unknown as FilesAPI; + + expect(await validateContextFileOpen(files, '/repo/docs/report.pdf')).toEqual({ ok: true }); + expect(await validateContextFileOpen(files, '/repo/docs/report.docx')).toEqual({ ok: true }); + expect(await validateContextFileOpen(files, '/repo/docs/pixel.png')).toEqual({ ok: true }); + expect(await validateContextFileOpen(files, '/repo/bin/archive.zip')).toEqual({ ok: true }); + }); + + test('rejects text payloads that look binary', async () => { + expect(await validateContextFileOpen(filesApi('%PDF-1.7\nbinary'), '/repo/mystery.bin.bak')).toEqual({ + ok: false, + reason: 'binary', + }); + }); + + test('allows ordinary text files', async () => { + expect(await validateContextFileOpen(filesApi('hello\nworld\n'), '/repo/notes.txt')).toEqual({ ok: true }); + }); +}); diff --git a/packages/ui/src/lib/contextFileOpenGuard.ts b/packages/ui/src/lib/contextFileOpenGuard.ts index e4339fab..b9fd4526 100644 --- a/packages/ui/src/lib/contextFileOpenGuard.ts +++ b/packages/ui/src/lib/contextFileOpenGuard.ts @@ -2,17 +2,22 @@ import type { FilesAPI } from '@/lib/api/types'; import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits'; import { getCurrentIntlLocale } from '@/lib/i18n'; import { formatMessage, useI18nStore } from '@/lib/i18n/store'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { isBinaryFile, isImageFile, isPdfFile, looksLikeBinaryText } from '@/lib/toolHelpers'; const t = (key: Parameters[1], params?: Parameters[2]) => formatMessage(useI18nStore.getState().dictionary, key, params); -import { runtimeFetch } from '@/lib/runtime-fetch'; -export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable'; +export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable' | 'binary'; export type ContextFileOpenValidationResult = | { ok: true } | { ok: false; reason: ContextFileOpenFailureReason }; +export type ContextFileOpenOptions = { + directory?: string; +}; + const classifyReadError = (error: unknown): ContextFileOpenFailureReason => { const message = error instanceof Error ? error.message : String(error ?? ''); const normalized = message.toLowerCase(); @@ -30,16 +35,27 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => { return 'unreadable'; }; -const readFileContent = async (files: FilesAPI, path: string): Promise => { +const readFileContent = async ( + files: FilesAPI, + path: string, + options?: ContextFileOpenOptions, +): Promise => { if (files.readFile) { - const result = await files.readFile(path, { optional: true }); + const result = await files.readFile(path, { + optional: true, + directory: options?.directory, + }); return result.content ?? ''; } const params = new URLSearchParams({ path, optional: 'true' }); + if (options?.directory) { + params.set('directory', options.directory); + } const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { // Avoid conditional requests (304 + empty body). cache: 'no-store', + headers: options?.directory ? { 'x-opencode-directory': options.directory } : undefined, }); if (!response.ok) { const errorPayload = await response.json().catch(() => ({ error: response.statusText })); @@ -49,9 +65,25 @@ const readFileContent = async (files: FilesAPI, path: string): Promise = return response.text(); }; -export const validateContextFileOpen = async (files: FilesAPI, path: string): Promise => { +/** + * Validate whether a context-panel click may open a path in the shared file editor. + * Previewable/non-text binaries are allowed through so FilesView can show image/PDF + * preview or the cannot-preview empty state — never by decoding them as editable text here. + */ +export const validateContextFileOpen = async ( + files: FilesAPI, + path: string, + options?: ContextFileOpenOptions, +): Promise => { + if (isBinaryFile(path) || isPdfFile(path) || isImageFile(path)) { + return { ok: true }; + } + try { - const content = await readFileContent(files, path); + const content = await readFileContent(files, path, options); + if (looksLikeBinaryText(content)) { + return { ok: false, reason: 'binary' }; + } const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES); if (lineCount > MAX_OPEN_FILE_LINES) { return { ok: false, reason: 'too-large' }; @@ -73,5 +105,9 @@ export const getContextFileOpenFailureMessage = (reason: ContextFileOpenFailureR return t('contextFileOpen.failure.missing'); } + if (reason === 'binary') { + return t('filesView.editor.cannotPreviewBinary'); + } + return t('contextFileOpen.failure.unreadable'); }; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index bd5e9d12..8b9898ad 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -107,6 +107,7 @@ export type DesktopSettings = { renamedGroups?: Record; // groupId -> custom label }>; // Per-provider custom model groups configuration autoDeleteEnabled?: boolean; + autoSaveEnabled?: boolean; autoDeleteAfterDays?: number; sessionRetentionAction?: 'archive' | 'delete'; tunnelProvider?: string; diff --git a/packages/ui/src/lib/fileEditorAutosave.test.ts b/packages/ui/src/lib/fileEditorAutosave.test.ts new file mode 100644 index 00000000..66218ade --- /dev/null +++ b/packages/ui/src/lib/fileEditorAutosave.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; + +import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from './fileEditorAutosave'; + +describe('shouldScheduleFileAutosave', () => { + const ready = { + autoSaveEnabled: true, + isDirty: true, + canWrite: true, + isSaving: false, + fileLoading: false, + selectedFilePath: '/repo/a.txt', + loadedFilePath: '/repo/a.txt', + isNonEditableBinary: false, + }; + + test('schedules when dirty text file is fully loaded', () => { + expect(shouldScheduleFileAutosave(ready)).toBe(true); + }); + + test('skips while loading or when loaded path mismatches selection', () => { + expect(shouldScheduleFileAutosave({ ...ready, fileLoading: true })).toBe(false); + expect(shouldScheduleFileAutosave({ ...ready, loadedFilePath: null })).toBe(false); + expect(shouldScheduleFileAutosave({ ...ready, loadedFilePath: '/repo/other.txt' })).toBe(false); + }); + + test('skips when autosave disabled or file is binary', () => { + expect(shouldScheduleFileAutosave({ ...ready, autoSaveEnabled: false })).toBe(false); + expect(shouldScheduleFileAutosave({ ...ready, isNonEditableBinary: true })).toBe(false); + }); + + test('skips when not dirty, cannot write, or already saving', () => { + expect(shouldScheduleFileAutosave({ ...ready, isDirty: false })).toBe(false); + expect(shouldScheduleFileAutosave({ ...ready, canWrite: false })).toBe(false); + expect(shouldScheduleFileAutosave({ ...ready, isSaving: true })).toBe(false); + }); +}); + +describe('shouldAllowFileDraftSave', () => { + const ready = { + selectedFilePath: '/repo/a.txt', + loadedFilePath: '/repo/a.txt', + fileLoading: false, + isDirty: true, + draftContent: 'edited', + fileContent: 'original', + isNonEditableBinary: false, + }; + + test('allows save for loaded dirty text', () => { + expect(shouldAllowFileDraftSave(ready)).toBe(true); + }); + + test('refuses incomplete load or binary; clean draft is a successful no-op', () => { + expect(shouldAllowFileDraftSave({ ...ready, fileLoading: true })).toBe(false); + expect(shouldAllowFileDraftSave({ ...ready, loadedFilePath: null })).toBe(false); + expect(shouldAllowFileDraftSave({ ...ready, isNonEditableBinary: true })).toBe(false); + expect(shouldAllowFileDraftSave({ ...ready, isDirty: false })).toBe(true); + }); +}); diff --git a/packages/ui/src/lib/fileEditorAutosave.ts b/packages/ui/src/lib/fileEditorAutosave.ts new file mode 100644 index 00000000..dbcdbe85 --- /dev/null +++ b/packages/ui/src/lib/fileEditorAutosave.ts @@ -0,0 +1,60 @@ +export type FileEditorAutosaveGate = { + autoSaveEnabled: boolean; + isDirty: boolean; + canWrite: boolean; + isSaving: boolean; + fileLoading: boolean; + selectedFilePath: string | null | undefined; + loadedFilePath: string | null; + /** True when the selected file must never be written as text (binary / non-editable). */ + isNonEditableBinary: boolean; +}; + +/** + * Whether the FilesView autosave effect should schedule a debounced save. + * Incomplete loads and binary files must never trigger a write. + */ +export function shouldScheduleFileAutosave(gate: FileEditorAutosaveGate): boolean { + if (!gate.autoSaveEnabled || !gate.isDirty || !gate.canWrite || gate.isSaving) { + return false; + } + if (gate.fileLoading || gate.isNonEditableBinary) { + return false; + } + if (!gate.selectedFilePath || gate.loadedFilePath !== gate.selectedFilePath) { + return false; + } + return true; +} + +export type FileEditorSaveDraftGate = { + selectedFilePath: string | null | undefined; + loadedFilePath: string | null; + fileLoading: boolean; + isDirty: boolean; + draftContent: string; + fileContent: string; + isNonEditableBinary: boolean; +}; + +/** + * Whether saveDraft may proceed. + * - Clean drafts return true ("nothing to save" is success) so callers like the + * unsaved-changes dialog and Ctrl+S do not treat a no-op as failure. + * - Incomplete loads and binary targets return false (refused). + */ +export function shouldAllowFileDraftSave(gate: FileEditorSaveDraftGate): boolean { + if (!gate.selectedFilePath) { + return false; + } + if (!gate.isDirty) { + return true; + } + if (gate.fileLoading || gate.loadedFilePath !== gate.selectedFilePath || gate.isNonEditableBinary) { + return false; + } + if (gate.draftContent === '' && gate.fileContent !== '' && gate.loadedFilePath !== gate.selectedFilePath) { + return false; + } + return true; +} diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 07d2bbb1..5eac5495 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1883,6 +1883,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt Navigator', 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', + 'settings.openchamber.visual.field.autoSaveEnabledAria': 'Auto-save files', + 'settings.openchamber.visual.field.autoSaveEnabled': 'Auto-save files', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Automatically save file edits after you stop typing. Disable to require manual save.', 'settings.openchamber.visual.field.wideChatLayoutAria': 'Wide chat layout', 'settings.openchamber.visual.field.wideChatLayout': 'Wide Chat Layout', 'settings.openchamber.visual.field.codeBlockLineWrapAria': 'Wrap code block lines', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 23f5011c..b201235b 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1247,6 +1247,8 @@ export const dict = { 'filesView.editor.showControlsAria': 'Show editor controls', 'filesView.editor.controlsTitle': 'Editor controls', 'filesView.editor.pickFileFromTree': 'Pick a file from the tree.', + 'filesView.editor.cannotPreviewBinary': 'Cannot preview binary file', + 'filesView.editor.binaryFileDescription': 'This file is binary and cannot be edited in OpenChamber. Download it to open with another app.', 'filesView.state.loading': 'Loading...', 'filesView.state.openingFileAtChange': 'Opening file at change...', 'filesView.tree.search.placeholder': 'Search files...', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 5997ed0a..dfaf6c11 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1850,6 +1850,9 @@ export const settingsDict = { "settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts", "settings.openchamber.visual.field.expandedEditorToolbarAria": "Mostrar siempre la barra de herramientas del editor", "settings.openchamber.visual.field.expandedEditorToolbar": "Mostrar siempre la barra de herramientas del editor (anclada bajo las pestañas)", + "settings.openchamber.visual.field.autoSaveEnabledAria": "Guardado automático de archivos", + "settings.openchamber.visual.field.autoSaveEnabled": "Guardado automático de archivos", + "settings.openchamber.visual.field.autoSaveEnabledInfo": "Guarda automáticamente las ediciones del archivo después de dejar de escribir. Desactívalo para exigir un guardado manual.", "settings.openchamber.visual.field.wideChatLayoutAria": "Diseño de chat ancho", "settings.openchamber.visual.field.wideChatLayout": "Diseño de chat ancho", "settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Acciones en línea del asistente", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index e5f46751..f5144b9d 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1213,6 +1213,8 @@ export const dict: Record = { "filesView.editor.showControlsAria": "Mostrar controles del editor", "filesView.editor.controlsTitle": "Controles del editor", "filesView.editor.pickFileFromTree": "Selecciona un archivo del árbol.", + "filesView.editor.cannotPreviewBinary": "No se puede previsualizar el archivo binario", + "filesView.editor.binaryFileDescription": "Este archivo es binario y no se puede editar en OpenChamber. Descárgalo para abrirlo con otra aplicación.", "filesView.state.loading": "Cargando...", "filesView.state.openingFileAtChange": "Abriendo archivo en cambio...", "filesView.tree.search.placeholder": "Buscar archivos...", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index a081feba..46dbd110 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1755,6 +1755,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Navigateur de prompts', 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Toujours afficher la barre d’outils de l’éditeur', 'settings.openchamber.visual.field.expandedEditorToolbar': 'Toujours afficher la barre d’outils de l’éditeur (ancrée sous les onglets de fichiers)', + 'settings.openchamber.visual.field.autoSaveEnabledAria': 'Enregistrement automatique des fichiers', + 'settings.openchamber.visual.field.autoSaveEnabled': 'Enregistrement automatique des fichiers', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Enregistre automatiquement les modifications après l’arrêt de la saisie. Désactivez pour exiger un enregistrement manuel.', 'settings.openchamber.visual.field.wideChatLayoutAria': 'Large disposition de discussion', 'settings.openchamber.visual.field.wideChatLayout': 'Disposition de discussion large', 'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'Actions intégrées de l\'assistant', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 60ba4fc0..159be205 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1075,6 +1075,8 @@ export const dict = { 'filesView.editor.showControlsAria': 'Afficher les contrôles de l\'éditeur', 'filesView.editor.controlsTitle': 'Contrôles de l\'éditeur', 'filesView.editor.pickFileFromTree': 'Choisissez un fichier dans l\'arborescence.', + 'filesView.editor.cannotPreviewBinary': 'Impossible de prévisualiser le fichier binaire', + 'filesView.editor.binaryFileDescription': 'Ce fichier est binaire et ne peut pas être modifié dans OpenChamber. Téléchargez-le pour l’ouvrir avec une autre application.', 'filesView.state.loading': 'Chargement...', 'filesView.state.openingFileAtChange': 'Ouverture du fichier lors du changement...', 'filesView.tree.search.placeholder': 'Rechercher des fichiers...', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 133071e8..adfa17bb 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1883,6 +1883,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.promptNavigatorEnabled': 'プロンプトナビゲーター', 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'エディターツールバーを常に表示', 'settings.openchamber.visual.field.expandedEditorToolbar': 'エディターツールバーを常に表示(ファイルタブの下にドッキング)', + 'settings.openchamber.visual.field.autoSaveEnabledAria': 'ファイルの自動保存', + 'settings.openchamber.visual.field.autoSaveEnabled': 'ファイルの自動保存', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': '入力を止めた後にファイルの編集内容を自動保存します。無効にすると手動保存が必要になります。', 'settings.openchamber.visual.field.wideChatLayoutAria': 'ワイドチャットレイアウト', 'settings.openchamber.visual.field.wideChatLayout': 'ワイドチャットレイアウト', 'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'インラインアシスタントアクション', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index ef32a963..d31555e2 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1243,6 +1243,8 @@ export const dict: Record = { 'filesView.editor.showControlsAria': 'エディターコントロールを表示', 'filesView.editor.controlsTitle': 'エディターコントロール', 'filesView.editor.pickFileFromTree': 'ツリーからファイルを選択してください。', + 'filesView.editor.cannotPreviewBinary': 'バイナリファイルはプレビューできません', + 'filesView.editor.binaryFileDescription': 'このファイルはバイナリのため、OpenChamberでは編集できません。別のアプリで開くにはダウンロードしてください。', 'filesView.state.loading': '読み込み中...', 'filesView.state.openingFileAtChange': '変更箇所のファイルを開いています...', 'filesView.tree.search.placeholder': 'ファイルを検索...', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 3b673361..810af15d 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1850,6 +1850,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.promptNavigatorEnabled': '프롬프트 탐색기', 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', + 'settings.openchamber.visual.field.autoSaveEnabledAria': '파일 자동 저장', + 'settings.openchamber.visual.field.autoSaveEnabled': '파일 자동 저장', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': '입력을 멈춘 후 파일 편집 내용을 자동으로 저장합니다. 끄면 수동으로 저장해야 합니다.', 'settings.openchamber.visual.field.wideChatLayoutAria': '넓은 채팅 레이아웃', 'settings.openchamber.visual.field.wideChatLayout': '넓은 채팅 레이아웃', 'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '인라인 어시스턴트 작업', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 607b1dbf..a06fbcda 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1250,6 +1250,8 @@ export const dict: Record = { 'filesView.editor.showControlsAria': '편집기 컨트롤 표시', 'filesView.editor.controlsTitle': '편집기 컨트롤', 'filesView.editor.pickFileFromTree': '트리에서 파일을 선택하세요.', + 'filesView.editor.cannotPreviewBinary': '바이너리 파일을 미리볼 수 없음', + 'filesView.editor.binaryFileDescription': '이 파일은 바이너리이므로 OpenChamber에서 편집할 수 없습니다. 다른 앱으로 열려면 다운로드하세요.', 'filesView.state.loading': '로드 중…', 'filesView.state.openingFileAtChange': '변경 위치에서 파일 여는 중…', 'filesView.tree.search.placeholder': '파일 검색…', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 516e2b59..f102e2ec 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1090,6 +1090,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Nawigator promptów', 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', + 'settings.openchamber.visual.field.autoSaveEnabledAria': 'Autozapis plików', + 'settings.openchamber.visual.field.autoSaveEnabled': 'Autozapis plików', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Automatycznie zapisuje edycje pliku po zatrzymaniu pisania. Wyłącz, aby wymagać ręcznego zapisu.', 'settings.openchamber.visual.field.terminalFontSize': 'Rozmiar czcionki terminala', 'settings.openchamber.visual.field.terminalShell': 'Powłoka terminala', 'settings.openchamber.visual.field.terminalShellAria': 'Wybierz powłokę terminala', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index ad56768c..ac1f8e11 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1728,6 +1728,8 @@ export const dict: Record = { 'filesView.editor.openFilesAria': 'Otwarte pliki', 'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej', 'filesView.editor.pickFileFromTree': 'Wybierz plik z drzewa.', + 'filesView.editor.cannotPreviewBinary': 'Nie można podglądać pliku binarnego', + 'filesView.editor.binaryFileDescription': 'Ten plik jest binarny i nie można go edytować w OpenChamber. Pobierz go, aby otworzyć w innej aplikacji.', 'filesView.editor.refreshApps': 'Odśwież aplikacje', 'filesView.editor.saveAria': 'Zapisz ({shortcut})', 'filesView.editor.saveFile': 'Zapisz plik', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index b671e38c..2870ca26 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1850,6 +1850,9 @@ export const settingsDict = { "settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts", "settings.openchamber.visual.field.expandedEditorToolbarAria": "Mostrar sempre a barra de ferramentas do editor", "settings.openchamber.visual.field.expandedEditorToolbar": "Mostrar sempre a barra de ferramentas do editor (ancorada sob as abas)", + "settings.openchamber.visual.field.autoSaveEnabledAria": "Salvamento automático de arquivos", + "settings.openchamber.visual.field.autoSaveEnabled": "Salvamento automático de arquivos", + "settings.openchamber.visual.field.autoSaveEnabledInfo": "Salva automaticamente as edições do arquivo depois que você parar de digitar. Desative para exigir salvamento manual.", "settings.openchamber.visual.field.wideChatLayoutAria": "Layout de chat amplo", "settings.openchamber.visual.field.wideChatLayout": "Layout de chat amplo", "settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Ações inline do assistente", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4c1496dd..a7812c58 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1213,6 +1213,8 @@ export const dict: Record = { "filesView.editor.showControlsAria": "Mostrar controles do editor", "filesView.editor.controlsTitle": "Controles do editor", "filesView.editor.pickFileFromTree": "Selecione um arquivo na árvore.", + "filesView.editor.cannotPreviewBinary": "Não é possível pré-visualizar o arquivo binário", + "filesView.editor.binaryFileDescription": "Este arquivo é binário e não pode ser editado no OpenChamber. Baixe-o para abrir em outro aplicativo.", "filesView.state.loading": "Carregando...", "filesView.state.openingFileAtChange": "Abrindo arquivo na alteração...", "filesView.tree.search.placeholder": "Pesquisar arquivos...", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 67106832..09bab6b9 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1850,6 +1850,9 @@ export const settingsDict = { "settings.openchamber.visual.field.promptNavigatorEnabled": "Навігатор промптів", "settings.openchamber.visual.field.expandedEditorToolbarAria": "Завжди показувати панель інструментів редактора", "settings.openchamber.visual.field.expandedEditorToolbar": "Завжди показувати панель інструментів редактора (закріплена під вкладками)", + "settings.openchamber.visual.field.autoSaveEnabledAria": "Автозбереження файлів", + "settings.openchamber.visual.field.autoSaveEnabled": "Автозбереження файлів", + "settings.openchamber.visual.field.autoSaveEnabledInfo": "Автоматично зберігати зміни у файлі після того, як ви припините друкувати. Вимкніть, щоб зберігати лише вручну.", "settings.openchamber.visual.field.wideChatLayoutAria": "Широкий макет чату", "settings.openchamber.visual.field.wideChatLayout": "Широкий макет чату", "settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Вбудовані дії асистента", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index cc76d4d2..a0095e81 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1213,6 +1213,8 @@ export const dict: Record = { "filesView.editor.showControlsAria": "Показати елементи керування редактора", "filesView.editor.controlsTitle": "Елементи керування редактора", "filesView.editor.pickFileFromTree": "Вибрати файл із дерева.", + "filesView.editor.cannotPreviewBinary": "Неможливо попередньо переглянути бінарний файл", + "filesView.editor.binaryFileDescription": "Цей файл є бінарним і його не можна редагувати в OpenChamber. Завантажте його, щоб відкрити в іншій програмі.", "filesView.state.loading": "Завантаження...", "filesView.state.openingFileAtChange": "Відкриття файлу на зміні...", "filesView.tree.search.placeholder": "Пошук файлів...", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index c6f60506..43f8f964 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1850,6 +1850,9 @@ export const settingsDict = { 'settings.openchamber.visual.field.promptNavigatorEnabled': '提示词导航', 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', + 'settings.openchamber.visual.field.autoSaveEnabledAria': '自动保存文件', + 'settings.openchamber.visual.field.autoSaveEnabled': '自动保存文件', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': '停止输入后自动保存文件编辑内容。关闭后需手动保存。', 'settings.openchamber.visual.field.wideChatLayoutAria': '宽聊天布局', 'settings.openchamber.visual.field.wideChatLayout': '宽聊天布局', 'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '内联助手操作', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index d9c335c3..cdc65f88 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1213,6 +1213,8 @@ export const dict: Record = { 'filesView.editor.showControlsAria': '显示编辑器控制项', 'filesView.editor.controlsTitle': '编辑器控制项', 'filesView.editor.pickFileFromTree': '请从文件树中选择一个文件。', + 'filesView.editor.cannotPreviewBinary': '无法预览二进制文件', + 'filesView.editor.binaryFileDescription': '此文件为二进制文件,无法在 OpenChamber 中编辑。请下载后使用其他应用打开。', 'filesView.state.loading': '加载中...', 'filesView.state.openingFileAtChange': '正在打开变更处的文件...', 'filesView.tree.search.placeholder': '搜索文件...', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index b4f7f5fb..332d0f91 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1756,6 +1756,9 @@ 'settings.openchamber.visual.field.promptNavigatorEnabled': '提示詞導覽', 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar', 'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)', + 'settings.openchamber.visual.field.autoSaveEnabledAria': '自動儲存檔案', + 'settings.openchamber.visual.field.autoSaveEnabled': '自動儲存檔案', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': '停止輸入後自動儲存檔案編輯內容。關閉後需手動儲存。', 'settings.openchamber.visual.field.wideChatLayoutAria': '寬聊天佈局', 'settings.openchamber.visual.field.wideChatLayout': '寬聊天佈局', 'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '行內助理操作', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 79d88b7b..ba25880a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1224,6 +1224,8 @@ export const dict: Record = { 'filesView.editor.showControlsAria': '顯示編輯器控制項', 'filesView.editor.controlsTitle': '編輯器控制項', 'filesView.editor.pickFileFromTree': '請從檔案樹中選擇一個檔案。', + 'filesView.editor.cannotPreviewBinary': '無法預覽二進位檔案', + 'filesView.editor.binaryFileDescription': '此檔案為二進位檔案,無法在 OpenChamber 中編輯。請下載後使用其他應用程式開啟。', 'filesView.state.loading': '載入中...', 'filesView.state.openingFileAtChange': '正在開啟變更處的檔案...', 'filesView.tree.search.placeholder': '搜尋檔案...', diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index daa09e3f..38057e9c 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -497,4 +497,74 @@ describe('updateDesktopSettings', () => { expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true); expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('zsh'))).toBe(true); }); + + test('applies persisted autoSaveEnabled from server settings', async () => { + getWindow(); + invalidateSettingsCache(); + useUIStore.getState().setAutoSaveEnabled(true); + registerSettingsApi(async () => ({}), async () => ({ + settings: { autoSaveEnabled: false, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, + source: 'web', + })); + + await syncDesktopSettings(); + + expect(useUIStore.getState().autoSaveEnabled).toBe(false); + }); + + test('autosaves autoSaveEnabled changes to shared settings', async () => { + getWindow(); + useUIStore.getState().setAutoSaveEnabled(true); + const saveCalls: Array> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return changes as SettingsPayload; + }); + startAppearanceAutoSave(); + + useUIStore.getState().setAutoSaveEnabled(false); + await delay(500); + + expect(saveCalls.some((changes) => changes.autoSaveEnabled === false)).toBe(true); + }); + + test('seeds omitted autoSaveEnabled from the hydrated client preference', async () => { + getWindow(); + invalidateSettingsCache(); + useUIStore.getState().setAutoSaveEnabled(false); + const saveCalls: Array> = []; + registerSettingsApi(async (changes) => { + saveCalls.push(changes); + return { ...changes } as SettingsPayload; + }, async () => ({ + settings: { draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, + source: 'web', + })); + + await syncDesktopSettings(); + await delay(500); + + expect(useUIStore.getState().autoSaveEnabled).toBe(false); + expect(saveCalls.some((changes) => changes.autoSaveEnabled === false)).toBe(true); + }); + + test('seeds default autoSaveEnabled when omitted and client still has the default', async () => { + getWindow(); + invalidateSettingsCache(); + useUIStore.getState().setAutoSaveEnabled(true); + const saveCalls: Array> = []; + registerSettingsApi(async (changes) => { + saveCalls.push(changes); + return { ...changes } as SettingsPayload; + }, async () => ({ + settings: { draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true }, + source: 'web', + })); + + await syncDesktopSettings(); + await delay(500); + + expect(useUIStore.getState().autoSaveEnabled).toBe(true); + expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true); + }); }); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index f8395f16..e89cddbf 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -531,6 +531,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget, collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks, autoDeleteEnabled: defaults.autoDeleteEnabled, + autoSaveEnabled: defaults.autoSaveEnabled, autoDeleteAfterDays: defaults.autoDeleteAfterDays, sessionRetentionAction: defaults.sessionRetentionAction, followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, @@ -637,6 +638,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) { store.setAutoDeleteEnabled(settings.autoDeleteEnabled); } + if (typeof settings.autoSaveEnabled === 'boolean' && settings.autoSaveEnabled !== store.autoSaveEnabled) { + store.setAutoSaveEnabled(settings.autoSaveEnabled); + } if (typeof settings.autoDeleteAfterDays === 'number' && Number.isFinite(settings.autoDeleteAfterDays)) { const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays)); if (normalized !== store.autoDeleteAfterDays) { @@ -1095,6 +1099,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.autoDeleteEnabled === 'boolean') { result.autoDeleteEnabled = candidate.autoDeleteEnabled; } + if (typeof candidate.autoSaveEnabled === 'boolean') { + result.autoSaveEnabled = candidate.autoSaveEnabled; + } if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) { result.autoDeleteAfterDays = candidate.autoDeleteAfterDays; } @@ -1737,6 +1744,12 @@ export const syncDesktopSettings = async (): Promise => { if (!isSettingsRuntimeContextCurrent(context)) return; const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true || settings.draftStartersScheduleTaskAdded !== true; + // `autoSaveEnabled` is new to the settings backend. Until the server has a + // value, materialize would invent the client default (true) and overwrite a + // deliberate legacy "off" preference migrated from + // `openchamber:files:auto-save-enabled`. Prefer the hydrated store value and + // seed the backend once so later omitted→default authority is correct. + const shouldSeedAutoSaveEnabled = typeof settings.autoSaveEnabled !== 'boolean'; const authoritativeSettings = materializeAuthoritativeUiSettings(settings); try { persistToLocalStorage(settings); @@ -1745,6 +1758,9 @@ export const syncDesktopSettings = async (): Promise => { } await waitForHydration(); if (!isSettingsRuntimeContextCurrent(context)) return; + if (shouldSeedAutoSaveEnabled) { + authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled; + } if (settings.draftStarters === undefined) { useUIStore.setState({ globalDraftStarters: null }); } @@ -1753,12 +1769,19 @@ export const syncDesktopSettings = async (): Promise => { } catch (error) { console.warn('applyDesktopUiPreferences failed:', error); } + const migrationPatch: Partial = {}; if (shouldPersistCraftGoalMigration) { - await updateDesktopSettings({ - ...(authoritativeSettings.draftStarters ? { draftStarters: authoritativeSettings.draftStarters } : {}), - draftStartersCraftGoalAdded: true, - draftStartersScheduleTaskAdded: true, - }); + if (authoritativeSettings.draftStarters) { + migrationPatch.draftStarters = authoritativeSettings.draftStarters; + } + migrationPatch.draftStartersCraftGoalAdded = true; + migrationPatch.draftStartersScheduleTaskAdded = true; + } + if (shouldSeedAutoSaveEnabled) { + migrationPatch.autoSaveEnabled = authoritativeSettings.autoSaveEnabled; + } + if (Object.keys(migrationPatch).length > 0) { + await updateDesktopSettings(migrationPatch); if (!isSettingsRuntimeContextCurrent(context)) return; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index c95d1b54..0773e9fb 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -147,6 +147,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ // Only the mobile composer applies this offset (ChatInput gates on isMobile). isAvailable: (ctx) => ctx.isMobile, }, + { + id: 'appearance.auto-save-enabled', + page: 'general', + titleKey: 'settings.openchamber.visual.field.autoSaveEnabled', + descriptionKey: 'settings.openchamber.visual.field.autoSaveEnabledInfo', + keywords: ['editor', 'autosave', 'auto-save', 'files', 'save'], + }, { id: 'appearance.expanded-editor-toolbar', page: 'general', diff --git a/packages/ui/src/lib/toolHelpers.binary.test.ts b/packages/ui/src/lib/toolHelpers.binary.test.ts new file mode 100644 index 00000000..03a294c8 --- /dev/null +++ b/packages/ui/src/lib/toolHelpers.binary.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; + +import { + getFileExtension, + isBinaryFile, + isImageFile, + isPdfFile, + isSvgFile, + looksLikeBinaryText, +} from './toolHelpers'; + +describe('binary file helpers', () => { + test('classifies common binary extensions', () => { + expect(isBinaryFile('/repo/docs/report.pdf')).toBe(true); + expect(isBinaryFile('/repo/sheet.XLSX')).toBe(true); + expect(isBinaryFile('archive.zip')).toBe(true); + expect(isBinaryFile('photo.png')).toBe(true); + expect(isBinaryFile('notes.docx')).toBe(true); + expect(isPdfFile('report.pdf')).toBe(true); + expect(isImageFile('photo.png')).toBe(true); + }); + + test('keeps text and SVG editable', () => { + expect(isBinaryFile('/repo/README.md')).toBe(false); + expect(isBinaryFile('/repo/src/main.ts')).toBe(false); + expect(isBinaryFile('/repo/icon.svg')).toBe(false); + expect(isSvgFile('/repo/icon.svg')).toBe(true); + expect(isBinaryFile('/repo/.env')).toBe(false); + }); + + test('getFileExtension ignores leading dots and path separators', () => { + expect(getFileExtension('/a/b/c.PDF')).toBe('pdf'); + expect(getFileExtension('.gitignore')).toBe(''); + expect(getFileExtension('Makefile')).toBe(''); + }); + + test('looksLikeBinaryText detects nulls, PDF, ZIP, and replacement-heavy content', () => { + expect(looksLikeBinaryText('hello\0world')).toBe(true); + expect(looksLikeBinaryText('%PDF-1.7\nstream\n...')).toBe(true); + expect(looksLikeBinaryText(`PK\u0003\u0004${'x'.repeat(20)}`)).toBe(true); + expect(looksLikeBinaryText(`${'\uFFFD'.repeat(40)}${'a'.repeat(40)}`)).toBe(true); + expect(looksLikeBinaryText('plain text file\nwith newlines\n')).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/toolHelpers.ts b/packages/ui/src/lib/toolHelpers.ts index 603485cb..c4f2a7c5 100644 --- a/packages/ui/src/lib/toolHelpers.ts +++ b/packages/ui/src/lib/toolHelpers.ts @@ -705,6 +705,84 @@ export function isPdfFile(filePath: string): boolean { return ext === 'pdf'; } +export function isSvgFile(filePath: string): boolean { + return filePath.toLowerCase().endsWith('.svg'); +} + +/** Known non-text extensions that must not be opened or saved as UTF-8 text. */ +const BINARY_FILE_EXTENSIONS = new Set([ + // Documents / office + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', + // Archives / packages + 'zip', 'rar', '7z', 'gz', 'tgz', 'tar', 'bz2', 'xz', 'jar', 'war', 'apk', 'dmg', 'iso', + 'deb', 'rpm', 'msi', + // Images (svg is text and is excluded via isSvgFile) + ...IMAGE_EXTENSIONS.filter((ext) => ext !== 'svg'), + // Audio / video + 'mp3', 'mp4', 'm4a', 'aac', 'flac', 'ogg', 'wav', 'wma', 'avi', 'mov', 'mkv', 'webm', 'wmv', + // Fonts + 'ttf', 'otf', 'woff', 'woff2', 'eot', + // Native / bytecode + 'exe', 'dll', 'so', 'dylib', 'bin', 'class', 'o', 'a', 'lib', 'wasm', 'node', + // Databases / locks / misc binary + 'sqlite', 'sqlite3', 'db', 'dat', 'parquet', 'feather', 'pickle', 'pyc', 'pyo', 'lockb', +]); + +export function getFileExtension(filePath: string): string { + const base = filePath.split(/[/\\]/).pop() ?? filePath; + const dot = base.lastIndexOf('.'); + if (dot <= 0 || dot === base.length - 1) { + return ''; + } + return base.slice(dot + 1).toLowerCase(); +} + +/** True for known binary extensions (including images/PDF). SVG is not binary. */ +export function isBinaryFile(filePath: string): boolean { + if (isSvgFile(filePath)) { + return false; + } + const ext = getFileExtension(filePath); + return BINARY_FILE_EXTENSIONS.has(ext); +} + +/** + * Heuristic for UTF-8 text that is actually binary (or was lossily decoded). + * Used as defense-in-depth when extension checks miss a binary file. + */ +export function looksLikeBinaryText(content: string): boolean { + if (!content) { + return false; + } + + const sample = content.length > 8192 ? content.slice(0, 8192) : content; + if (sample.includes('\0')) { + return true; + } + if (sample.startsWith('%PDF')) { + return true; + } + // ZIP-based formats (docx/xlsx/pptx/jar/apk…) and raw ZIP. + if (sample.startsWith('PK\u0003\u0004') || sample.startsWith('PK\u0005\u0006') || sample.startsWith('PK\u0007\u0008')) { + return true; + } + + let suspicious = 0; + for (let index = 0; index < sample.length; index += 1) { + const code = sample.charCodeAt(index); + if (code === 0xFFFD) { + suspicious += 1; + continue; + } + // C0 controls excluding common whitespace (TAB/LF/VT/FF/CR). + if (code < 9 || (code > 13 && code < 32) || code === 127) { + suspicious += 1; + } + } + + return sample.length > 0 && suspicious / sample.length > 0.1; +} + export function getImageMimeType(filePath: string): string { const ext = filePath.split('.').pop()?.toLowerCase(); const mimeMap: Record = { diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 88f08930..3a780be9 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -621,6 +621,8 @@ interface UIStore { activityRenderMode: ActivityRenderMode; showDeletionDialog: boolean; autoDeleteEnabled: boolean; + /** Global file-editor autosave. Default true for backward compatibility. */ + autoSaveEnabled: boolean; autoDeleteAfterDays: number; sessionRetentionAction: SessionRetentionAction; autoDeleteLastRunAt: number | null; @@ -784,6 +786,7 @@ interface UIStore { setActivityRenderMode: (value: ActivityRenderMode) => void; setShowDeletionDialog: (value: boolean) => void; setAutoDeleteEnabled: (value: boolean) => void; + setAutoSaveEnabled: (value: boolean) => void; setAutoDeleteAfterDays: (days: number) => void; setSessionRetentionAction: (value: SessionRetentionAction) => void; setAutoDeleteLastRunAt: (timestamp: number | null) => void; @@ -941,6 +944,7 @@ export const useUIStore = create()( activityRenderMode: 'summary', showDeletionDialog: true, autoDeleteEnabled: false, + autoSaveEnabled: true, autoDeleteAfterDays: 30, sessionRetentionAction: 'archive', autoDeleteLastRunAt: null, @@ -1682,6 +1686,10 @@ export const useUIStore = create()( set({ autoDeleteEnabled: value }); }, + setAutoSaveEnabled: (value) => { + set({ autoSaveEnabled: value }); + }, + setAutoDeleteAfterDays: (days) => { const clampedDays = Math.max(1, Math.min(365, days)); set({ autoDeleteAfterDays: clampedDays }); @@ -2254,13 +2262,32 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 12, + version: 13, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; } const state = persistedState as Record; + // v12 -> v13: promote FilesView localStorage autosave toggle into the store. + if (version < 13) { + if (typeof state.autoSaveEnabled !== 'boolean') { + let legacyEnabled = true; + try { + if (typeof localStorage !== 'undefined') { + const legacy = localStorage.getItem('openchamber:files:auto-save-enabled'); + if (legacy !== null) { + legacyEnabled = legacy !== 'false'; + localStorage.removeItem('openchamber:files:auto-save-enabled'); + } + } + } catch { + legacyEnabled = true; + } + state.autoSaveEnabled = legacyEnabled; + } + } + // v11 -> v12: drop legacy window-controls "auto" (always meant right). if (version < 12) { if (state.desktopWindowControlsPosition === 'auto' || state.desktopWindowControlsPosition == null) { @@ -2360,6 +2387,10 @@ export const useUIStore = create()( state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); + if (typeof state.autoSaveEnabled !== 'boolean') { + state.autoSaveEnabled = true; + } + state.contextRailOrder = Array.isArray(state.contextRailOrder) ? (state.contextRailOrder as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '') : []; @@ -2396,6 +2427,7 @@ export const useUIStore = create()( activityRenderMode: state.activityRenderMode, showDeletionDialog: state.showDeletionDialog, autoDeleteEnabled: state.autoDeleteEnabled, + autoSaveEnabled: state.autoSaveEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, sessionRetentionAction: state.sessionRetentionAction, autoDeleteLastRunAt: state.autoDeleteLastRunAt,