Merge pull request #2534 from openchamber/feat/bc-38abb61a-bce6-4a01-a189-c569346927fa-33b6
fix(files): stop autosave data loss on load lag and binary files
This commit is contained in:
@@ -28,7 +28,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
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 type { FileListEntry, FileSearchResult } from '@/lib/api/types';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -98,6 +98,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
|||||||
const [imageSrc, setImageSrc] = React.useState('');
|
const [imageSrc, setImageSrc] = React.useState('');
|
||||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||||
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
||||||
|
const [binaryBlocked, setBinaryBlocked] = React.useState(false);
|
||||||
const directoryLoadRequestIdRef = React.useRef(0);
|
const directoryLoadRequestIdRef = React.useRef(0);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -174,8 +175,9 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
|||||||
setFileContent('');
|
setFileContent('');
|
||||||
setImageSrc('');
|
setImageSrc('');
|
||||||
setFileError(null);
|
setFileError(null);
|
||||||
|
setBinaryBlocked(false);
|
||||||
|
|
||||||
if (isImageFile(route.path) && !route.path.toLowerCase().endsWith('.svg')) {
|
if (isImageFile(route.path) && !isSvgFile(route.path)) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let objectUrl = '';
|
let objectUrl = '';
|
||||||
setIsLoadingFile(true);
|
setIsLoadingFile(true);
|
||||||
@@ -203,6 +205,14 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ 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) {
|
if (!files.readFile) {
|
||||||
setFileError(t('mobile.files.error.readUnavailable'));
|
setFileError(t('mobile.files.error.readUnavailable'));
|
||||||
setIsLoadingFile(false);
|
setIsLoadingFile(false);
|
||||||
@@ -214,6 +224,11 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
|||||||
void files.readFile(route.path)
|
void files.readFile(route.path)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (looksLikeBinaryText(result.content)) {
|
||||||
|
setBinaryBlocked(true);
|
||||||
|
setFileContent('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFileContent(result.content.length > MAX_MOBILE_FILE_CHARS
|
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.slice(0, MAX_MOBILE_FILE_CHARS)}\n\n${t('mobile.files.file.truncated')}`
|
||||||
: result.content);
|
: result.content);
|
||||||
@@ -263,6 +278,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
|||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
error={fileError}
|
error={fileError}
|
||||||
isLoading={isLoadingFile}
|
isLoading={isLoadingFile}
|
||||||
|
binaryBlocked={binaryBlocked}
|
||||||
onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
||||||
onCopyPath={() => void handleCopyPath(route.path)}
|
onCopyPath={() => void handleCopyPath(route.path)}
|
||||||
onCopyContent={() => void handleCopyContent()}
|
onCopyContent={() => void handleCopyContent()}
|
||||||
@@ -413,10 +429,11 @@ const MobileFileDetail: React.FC<{
|
|||||||
imageSrc: string;
|
imageSrc: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
binaryBlocked: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onCopyPath: () => void;
|
onCopyPath: () => void;
|
||||||
onCopyContent: () => void;
|
onCopyContent: () => void;
|
||||||
}> = ({ path, content, imageSrc, error, isLoading, onBack, onCopyPath, onCopyContent }) => {
|
}> = ({ path, content, imageSrc, error, isLoading, binaryBlocked, onBack, onCopyPath, onCopyContent }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -433,7 +450,7 @@ const MobileFileDetail: React.FC<{
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(path)}</h2>
|
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(path)}</h2>
|
||||||
</div>
|
</div>
|
||||||
{!isImageFile(path) ? (
|
{!isImageFile(path) && !binaryBlocked ? (
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyContent} aria-label={t('mobile.files.copyContentAria')}>
|
<Button type="button" variant="ghost" size="icon" onClick={onCopyContent} aria-label={t('mobile.files.copyContentAria')}>
|
||||||
<RiFileCopyLine className="size-4" />
|
<RiFileCopyLine className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -455,6 +472,11 @@ const MobileFileDetail: React.FC<{
|
|||||||
<ScrollShadow className="h-full overflow-auto p-4">
|
<ScrollShadow className="h-full overflow-auto p-4">
|
||||||
<img src={`data:${getImageMimeType(path)};utf8,${encodeURIComponent(content)}`} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
<img src={`data:${getImageMimeType(path)};utf8,${encodeURIComponent(content)}`} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||||
</ScrollShadow>
|
</ScrollShadow>
|
||||||
|
) : binaryBlocked ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
||||||
|
<div className="typography-ui-header text-foreground">{t('filesView.editor.cannotPreviewBinary')}</div>
|
||||||
|
<div className="max-w-sm typography-ui text-muted-foreground">{t('filesView.editor.binaryFileDescription')}</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MobileTextFile path={path} content={content} />
|
<MobileTextFile path={path} content={content} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -868,7 +868,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
const handleOpenFile = React.useCallback(async (node: FileNode) => {
|
const handleOpenFile = React.useCallback(async (node: FileNode) => {
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
|
||||||
const openValidation = await validateContextFileOpen(files, node.path);
|
const openValidation = await validateContextFileOpen(files, node.path, { directory: root });
|
||||||
if (!openValidation.ok) {
|
if (!openValidation.ok) {
|
||||||
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ const GeneralSectionContent: React.FC = () => {
|
|||||||
{!isVSCode && <OpenCodeCliSettings />}
|
{!isVSCode && <OpenCodeCliSettings />}
|
||||||
<OpenChamberVisualSettings visibleSettings={[
|
<OpenChamberVisualSettings visibleSettings={[
|
||||||
'fileEditorKeymap',
|
'fileEditorKeymap',
|
||||||
|
'autoSaveEnabled',
|
||||||
'expandedEditorToolbar',
|
'expandedEditorToolbar',
|
||||||
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
||||||
...(!isVSCode ? ['terminalShell' as const] : []),
|
...(!isVSCode ? ['terminalShell' as const] : []),
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
|||||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||||
};
|
};
|
||||||
|
|
||||||
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled';
|
||||||
|
|
||||||
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
|
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
|
||||||
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
|
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
|
||||||
@@ -330,6 +330,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled);
|
const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled);
|
||||||
const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar);
|
const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar);
|
||||||
const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar);
|
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 wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled);
|
||||||
const setWideChatLayoutEnabled = useUIStore(state => state.setWideChatLayoutEnabled);
|
const setWideChatLayoutEnabled = useUIStore(state => state.setWideChatLayoutEnabled);
|
||||||
const codeBlockLineWrap = useUIStore(state => state.codeBlockLineWrap);
|
const codeBlockLineWrap = useUIStore(state => state.codeBlockLineWrap);
|
||||||
@@ -643,7 +645,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
? hasLocalizationSettings
|
? hasLocalizationSettings
|
||||||
: (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
|
: (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 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')
|
const hasBehaviorSettings = shouldShow('mermaidRendering')
|
||||||
|| (shouldShow('sessionGoal') && !isVSCode)
|
|| (shouldShow('sessionGoal') && !isVSCode)
|
||||||
|| shouldShow('userMessageRendering')
|
|| shouldShow('userMessageRendering')
|
||||||
@@ -1515,6 +1517,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
</SettingsControlGroup>
|
</SettingsControlGroup>
|
||||||
)}
|
)}
|
||||||
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
<div className={SETTINGS_OPTION_STACK_CLASS}>
|
||||||
|
{shouldShow('autoSaveEnabled') && (
|
||||||
|
<SettingsCheckboxRow
|
||||||
|
checked={autoSaveEnabled}
|
||||||
|
onChange={setAutoSaveEnabled}
|
||||||
|
label={t('settings.openchamber.visual.field.autoSaveEnabled')}
|
||||||
|
ariaLabel={t('settings.openchamber.visual.field.autoSaveEnabledAria')}
|
||||||
|
info={t('settings.openchamber.visual.field.autoSaveEnabledInfo')}
|
||||||
|
settingsItem="appearance.auto-save-enabled"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{shouldShow('expandedEditorToolbar') && !isVSCode && (
|
{shouldShow('expandedEditorToolbar') && !isVSCode && (
|
||||||
<SettingsCheckboxRow
|
<SettingsCheckboxRow
|
||||||
checked={expandedEditorToolbar}
|
checked={expandedEditorToolbar}
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ export const CommandPalette: React.FC = () => {
|
|||||||
const handleOpenFile = React.useCallback(
|
const handleOpenFile = React.useCallback(
|
||||||
async (filePath: string) => {
|
async (filePath: string) => {
|
||||||
if (!currentRoot) return;
|
if (!currentRoot) return;
|
||||||
const validation = await validateContextFileOpen(filesApi, filePath);
|
const validation = await validateContextFileOpen(filesApi, filePath, { directory: currentRoot });
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
toast.error(getContextFileOpenFailureMessage(validation.reason));
|
toast.error(getContextFileOpenFailureMessage(validation.reason));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1537,7 +1537,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
|
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
|
||||||
|
|
||||||
const absolutePath = toAbsolutePath(effectiveDirectory, filePath);
|
const absolutePath = toAbsolutePath(effectiveDirectory, filePath);
|
||||||
const openValidation = await validateContextFileOpen(files, absolutePath);
|
const openValidation = await validateContextFileOpen(files, absolutePath, { directory: effectiveDirectory });
|
||||||
if (!openValidation.ok) {
|
if (!openValidation.ok) {
|
||||||
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
|||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
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 { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||||
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||||
@@ -304,7 +305,6 @@ const isFileMissingError = (error: unknown): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_VIEW_CHARS = 200_000;
|
const MAX_VIEW_CHARS = 200_000;
|
||||||
const FILE_EDITOR_AUTO_SAVE_KEY = 'openchamber:files:auto-save-enabled';
|
|
||||||
type FileLineEnding = '\n' | '\r\n';
|
type FileLineEnding = '\n' | '\r\n';
|
||||||
|
|
||||||
const detectFileLineEnding = (content: string): FileLineEnding => {
|
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;
|
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 => {
|
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
|
||||||
return <FileTypeIcon filePath={filePath} extension={extension} />;
|
return <FileTypeIcon filePath={filePath} extension={extension} />;
|
||||||
};
|
};
|
||||||
@@ -924,7 +912,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const loadingFilePathRef = React.useRef<string | null>(null);
|
const loadingFilePathRef = React.useRef<string | null>(null);
|
||||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||||
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
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 [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
||||||
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
|
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
|
||||||
@@ -1626,17 +1616,31 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDirty) {
|
const selectedIsBinary = isBinaryFile(selectedFile.path) || contentDetectedBinary;
|
||||||
return true;
|
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) {
|
// Clean draft: treat as success so discard/save dialogs and Ctrl+S are not stranded.
|
||||||
console.warn(
|
if (!isDirty) {
|
||||||
`[saveDraft] refusing to save empty draft for "${selectedFile.path}" (${fileContent.length} bytes were expected). ` +
|
return true;
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
@@ -1668,7 +1672,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [draftContent, fileContent, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]);
|
}, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isDirty) {
|
if (!isDirty) {
|
||||||
@@ -1696,14 +1700,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
};
|
};
|
||||||
}, [isDirty, setMainTabGuard]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (autoSaveEnabled) {
|
if (autoSaveEnabled) {
|
||||||
return;
|
return;
|
||||||
@@ -1721,7 +1717,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const canWrite = Boolean(selectedFile && files.writeFile);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1739,7 +1745,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
autoSaveTimerRef.current = null;
|
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
|
// Reset auto-save status when switching files
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -1789,10 +1795,12 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setFileError(null);
|
setFileError(null);
|
||||||
setDesktopImageSrc('');
|
setDesktopImageSrc('');
|
||||||
setLoadedFilePath(null);
|
setLoadedFilePath(null);
|
||||||
|
setContentDetectedBinary(false);
|
||||||
|
|
||||||
const selectedIsImage = isImageFile(node.path);
|
const selectedIsImage = isImageFile(node.path);
|
||||||
const isSvg = node.path.toLowerCase().endsWith('.svg');
|
const isSvg = isSvgFile(node.path);
|
||||||
const selectedIsPdf = isPdfFile(node.path);
|
const selectedIsPdf = isPdfFile(node.path);
|
||||||
|
const selectedIsBinary = isBinaryFile(node.path);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setShowMobilePageContent(true);
|
setShowMobilePageContent(true);
|
||||||
@@ -1823,6 +1831,16 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return;
|
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);
|
setFileLoading(true);
|
||||||
|
|
||||||
const outsideFileGrant = getOutsideFileGrant(node.path);
|
const outsideFileGrant = getOutsideFileGrant(node.path);
|
||||||
@@ -1836,6 +1854,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
if (!isCurrentLoad()) {
|
if (!isCurrentLoad()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (looksLikeBinaryText(content)) {
|
||||||
|
setContentDetectedBinary(true);
|
||||||
|
setFileContent('');
|
||||||
|
setDraftContent('');
|
||||||
|
setLoadedFilePath(node.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const editorContent = normalizeEditorLineEndings(content);
|
const editorContent = normalizeEditorLineEndings(content);
|
||||||
setLoadedFileLineEnding(detectFileLineEnding(content));
|
setLoadedFileLineEnding(detectFileLineEnding(content));
|
||||||
setFileContent(editorContent);
|
setFileContent(editorContent);
|
||||||
@@ -2302,8 +2327,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
|
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 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(
|
const pendingNavigationTargetPath = React.useMemo(
|
||||||
() => normalizePath(pendingFileNavigation?.path ?? ''),
|
() => normalizePath(pendingFileNavigation?.path ?? ''),
|
||||||
[pendingFileNavigation?.path],
|
[pendingFileNavigation?.path],
|
||||||
@@ -2316,21 +2346,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
&& !fileLoading
|
&& !fileLoading
|
||||||
&& !fileError
|
&& !fileError
|
||||||
&& !isSelectedImage
|
&& !isSelectedImage
|
||||||
&& !isSelectedPdf,
|
&& !isSelectedPdf
|
||||||
|
&& !isUnsupportedBinary,
|
||||||
);
|
);
|
||||||
|
|
||||||
const displaySelectedPath = React.useMemo(() => {
|
const displaySelectedPath = React.useMemo(() => {
|
||||||
return getDisplayPath(root, selectedFilePath);
|
return getDisplayPath(root, selectedFilePath);
|
||||||
}, [selectedFilePath, root]);
|
}, [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 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 isMarkdown = Boolean(selectedFile?.path && isMarkdownFile(selectedFile.path));
|
||||||
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
|
const isJson = Boolean(selectedFile?.path && isJsonFile(selectedFile.path));
|
||||||
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
const isHtml = Boolean(selectedFile?.path && isHtmlFile(selectedFile.path));
|
||||||
const isDrawio = Boolean(selectedFile?.path && isDrawioFile(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 canUseShikiFileView = isTextFile && !isMarkdown && !isDrawio && !(isHtml && htmlViewMode === 'preview');
|
||||||
const isEditingFile = (isMarkdown && mdViewMode === 'edit')
|
const isEditingFile = (isMarkdown && mdViewMode === 'edit')
|
||||||
|| (isHtml && htmlViewMode === 'edit')
|
|| (isHtml && htmlViewMode === 'edit')
|
||||||
@@ -2535,7 +2568,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
const handleDiagramChange = React.useCallback((xml: string) => {
|
const handleDiagramChange = React.useCallback((xml: string) => {
|
||||||
diagramXmlRef.current = xml;
|
diagramXmlRef.current = xml;
|
||||||
if (!selectedFile?.path || drawioViewMode !== 'preview' || !files.writeFile) {
|
if (!autoSaveEnabled || !selectedFile?.path || drawioViewMode !== 'preview' || !files.writeFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2554,7 +2587,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed'));
|
toast.error(error instanceof Error ? error.message : t('filesView.toast.saveFailed'));
|
||||||
});
|
});
|
||||||
}, AUTO_SAVE_DELAY);
|
}, AUTO_SAVE_DELAY);
|
||||||
}, [drawioViewMode, files.writeFile, saveDiagramXml, selectedFile?.path, t]);
|
}, [autoSaveEnabled, drawioViewMode, files.writeFile, saveDiagramXml, selectedFile?.path, t]);
|
||||||
|
|
||||||
const diagramEditorXml = React.useMemo(() => {
|
const diagramEditorXml = React.useMemo(() => {
|
||||||
if (!isDrawio) {
|
if (!isDrawio) {
|
||||||
@@ -2674,7 +2707,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileError || isSelectedImage || isSelectedPdf) {
|
if (fileError || isSelectedImage || isSelectedPdf || isUnsupportedBinary) {
|
||||||
setPendingFileNavigation(null);
|
setPendingFileNavigation(null);
|
||||||
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
|
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
|
||||||
return;
|
return;
|
||||||
@@ -2746,6 +2779,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
fileLoading,
|
fileLoading,
|
||||||
isSelectedImage,
|
isSelectedImage,
|
||||||
isSelectedPdf,
|
isSelectedPdf,
|
||||||
|
isUnsupportedBinary,
|
||||||
loadedFilePath,
|
loadedFilePath,
|
||||||
handleSelectFile,
|
handleSelectFile,
|
||||||
pendingFileNavigation,
|
pendingFileNavigation,
|
||||||
@@ -2783,7 +2817,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
// JSON tree, images, PDFs) never mount a CodeMirror editor, so the request
|
// JSON tree, images, PDFs) never mount a CodeMirror editor, so the request
|
||||||
// must clear regardless — otherwise it lingers and replays on every
|
// must clear regardless — otherwise it lingers and replays on every
|
||||||
// dependency change.
|
// dependency change.
|
||||||
if (!fileError && !isSelectedImage && !isSelectedPdf && canEdit && textViewMode === 'edit') {
|
if (!fileError && !isSelectedImage && !isSelectedPdf && !isUnsupportedBinary && canEdit && textViewMode === 'edit') {
|
||||||
editorViewRef.current?.focus();
|
editorViewRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2794,6 +2828,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
fileLoading,
|
fileLoading,
|
||||||
isSelectedImage,
|
isSelectedImage,
|
||||||
isSelectedPdf,
|
isSelectedPdf,
|
||||||
|
isUnsupportedBinary,
|
||||||
loadedFilePath,
|
loadedFilePath,
|
||||||
pendingFileFocusPath,
|
pendingFileFocusPath,
|
||||||
root,
|
root,
|
||||||
@@ -3189,7 +3224,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAutoSaveEnabled((enabled) => !enabled)}
|
onClick={() => setAutoSaveEnabled(!autoSaveEnabled)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-6 p-0 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent',
|
'size-6 p-0 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent',
|
||||||
autoSaveEnabled ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
|
autoSaveEnabled ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
|
||||||
@@ -3245,7 +3280,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{!isSelectedImage && !isSelectedPdf && (
|
{!isSelectedImage && !isSelectedPdf && !isUnsupportedBinary && (
|
||||||
<>
|
<>
|
||||||
{withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'),
|
{withTooltip(wrapLines ? t('filesView.editor.disableLineWrap') : t('filesView.editor.enableLineWrap'),
|
||||||
<Button
|
<Button
|
||||||
@@ -3826,6 +3861,29 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : isSelectedPdf ? (
|
) : isSelectedPdf ? (
|
||||||
renderPdfPreview(selectedFile)
|
renderPdfPreview(selectedFile)
|
||||||
|
) : isUnsupportedBinary ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<div className="typography-ui-header text-foreground">{t('filesView.editor.cannotPreviewBinary')}</div>
|
||||||
|
<div className="max-w-md typography-ui text-muted-foreground">{t('filesView.editor.binaryFileDescription')}</div>
|
||||||
|
{files.downloadFile ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const fn = files.downloadFile;
|
||||||
|
if (!fn || !selectedFile) return;
|
||||||
|
void fn(selectedFile.path).catch((error) => {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="download" className="mr-2 size-4" />
|
||||||
|
{t('filesView.editor.saveFile')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
|
) : selectedFile && isDrawio && drawioViewMode === 'preview' ? (
|
||||||
<div className="h-full overflow-hidden" style={{ minHeight: '400px' }}>
|
<div className="h-full overflow-hidden" style={{ minHeight: '400px' }}>
|
||||||
<DiagramEditor
|
<DiagramEditor
|
||||||
@@ -4197,6 +4255,29 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : isSelectedPdf ? (
|
) : isSelectedPdf ? (
|
||||||
renderPdfPreview(selectedFile)
|
renderPdfPreview(selectedFile)
|
||||||
|
) : isUnsupportedBinary ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<div className="typography-ui-header text-foreground">{t('filesView.editor.cannotPreviewBinary')}</div>
|
||||||
|
<div className="max-w-md typography-ui text-muted-foreground">{t('filesView.editor.binaryFileDescription')}</div>
|
||||||
|
{files.downloadFile ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const fn = files.downloadFile;
|
||||||
|
if (!fn || !selectedFile) return;
|
||||||
|
void fn(selectedFile.path).catch((error) => {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="download" className="mr-2 size-4" />
|
||||||
|
{t('filesView.editor.saveFile')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
) : isMarkdown && getMdViewMode() === 'preview' ? (
|
||||||
<div className="h-full overflow-auto p-4">
|
<div className="h-full overflow-auto p-4">
|
||||||
{fileContent.length > 500 * 1024 && (
|
{fileContent.length > 500 * 1024 && (
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import React, { type JSX, type ReactNode } from 'react';
|
import React, { type JSX, type ReactNode } from 'react';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
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';
|
import { createContentCachedFiles } from '@/contexts/content-cache-owner';
|
||||||
|
|
||||||
|
type ContentCachedFiles = ReturnType<typeof createContentCachedFiles>;
|
||||||
|
|
||||||
export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element {
|
export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element {
|
||||||
const cachedFiles = React.useMemo(() => createContentCachedFiles(apis.files), [apis.files]);
|
// Effect-owned lifecycle: React Strict Mode dispose+remount must create a fresh
|
||||||
React.useEffect(() => () => cachedFiles.dispose(), [cachedFiles]);
|
// 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<ContentCachedFiles | null>(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<RuntimeAPIs>(
|
const cachedApis = React.useMemo<RuntimeAPIs>(
|
||||||
() => ({
|
() => ({
|
||||||
...apis,
|
...apis,
|
||||||
files: cachedFiles.files,
|
files,
|
||||||
}),
|
}),
|
||||||
[apis, cachedFiles],
|
[apis, files],
|
||||||
);
|
);
|
||||||
return <RuntimeAPIContext.Provider value={cachedApis}>{children}</RuntimeAPIContext.Provider>;
|
return <RuntimeAPIContext.Provider value={cachedApis}>{children}</RuntimeAPIContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,4 +71,54 @@ describe("content cache owner", () => {
|
|||||||
expect(second.content).toBe("/b-2")
|
expect(second.content).toBe("/b-2")
|
||||||
owner.dispose()
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ const MAX_ENTRIES = 40;
|
|||||||
const MAX_BYTES = 20 * 1024 * 1024;
|
const MAX_BYTES = 20 * 1024 * 1024;
|
||||||
type Entry = { content: string; path: string; sourcePath: string; size: number; mtimeMs: number; bytes: number };
|
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 } {
|
export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; dispose: () => void } {
|
||||||
const cache = new Map<string, Entry>();
|
const cache = new Map<string, Entry>();
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
@@ -32,6 +40,10 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di
|
|||||||
&& cached.mtimeMs === latest.mtimeMs
|
&& cached.mtimeMs === latest.mtimeMs
|
||||||
&& cached.size === latest.size
|
&& cached.size === latest.size
|
||||||
);
|
);
|
||||||
|
const clearCache = () => {
|
||||||
|
cache.clear();
|
||||||
|
totalBytes = 0;
|
||||||
|
};
|
||||||
const cacheResult = (
|
const cacheResult = (
|
||||||
key: string,
|
key: string,
|
||||||
sourcePath: 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 before = await files.statFile?.(path, options).catch(() => null);
|
||||||
const result = await files.readFile!(path, options);
|
const result = await files.readFile!(path, options);
|
||||||
const after = await files.statFile?.(path, options).catch(() => null);
|
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);
|
if (capturedGeneration !== generation) return cachedReadFile!(path, options);
|
||||||
const stable = before && after && before.isFile && after.isFile
|
const stable = before && after && before.isFile && after.isFile
|
||||||
&& before.mtimeMs !== undefined && after.mtimeMs !== undefined
|
&& before.mtimeMs !== undefined && after.mtimeMs !== undefined
|
||||||
@@ -79,7 +91,7 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di
|
|||||||
const hit = cache.get(key);
|
const hit = cache.get(key);
|
||||||
if (!hit) return readFresh(key, path, options, capturedGeneration);
|
if (!hit) return readFresh(key, path, options, capturedGeneration);
|
||||||
const latest = await files.statFile?.(path, options).catch(() => null);
|
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 (capturedGeneration !== generation) return cachedReadFile!(path, options);
|
||||||
if (!latest || !metadataMatches(hit, latest)) {
|
if (!latest || !metadataMatches(hit, latest)) {
|
||||||
removeEntry(key);
|
removeEntry(key);
|
||||||
@@ -116,18 +128,18 @@ export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; di
|
|||||||
};
|
};
|
||||||
const unsubscribeRuntime = subscribeRuntimeEndpointWillChange((detail) => {
|
const unsubscribeRuntime = subscribeRuntimeEndpointWillChange((detail) => {
|
||||||
if (detail.runtimeKey === detail.previousRuntimeKey) return;
|
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;
|
generation += 1;
|
||||||
cache.clear();
|
clearCache();
|
||||||
totalBytes = 0;
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
files: cachedFiles,
|
files: cachedFiles,
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
active = false;
|
active = false;
|
||||||
generation += 1;
|
generation += 1;
|
||||||
cache.clear();
|
clearCache();
|
||||||
totalBytes = 0;
|
|
||||||
unsubscribeRuntime();
|
unsubscribeRuntime();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -637,6 +637,7 @@ export interface SettingsPayload {
|
|||||||
nativeNotificationsEnabled?: boolean;
|
nativeNotificationsEnabled?: boolean;
|
||||||
notificationMode?: 'always' | 'hidden-only';
|
notificationMode?: 'always' | 'hidden-only';
|
||||||
autoDeleteEnabled?: boolean;
|
autoDeleteEnabled?: boolean;
|
||||||
|
autoSaveEnabled?: boolean;
|
||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
sessionRetentionAction?: 'archive' | 'delete';
|
sessionRetentionAction?: 'archive' | 'delete';
|
||||||
followUpBehavior?: 'steer' | 'queue';
|
followUpBehavior?: 'steer' | 'queue';
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type AppearanceSlice = {
|
|||||||
summaryLength: number;
|
summaryLength: number;
|
||||||
maxLastMessageLength: number;
|
maxLastMessageLength: number;
|
||||||
autoDeleteEnabled: boolean;
|
autoDeleteEnabled: boolean;
|
||||||
|
autoSaveEnabled: boolean;
|
||||||
autoDeleteAfterDays: number;
|
autoDeleteAfterDays: number;
|
||||||
sessionRetentionAction: 'archive' | 'delete';
|
sessionRetentionAction: 'archive' | 'delete';
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -78,6 +79,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
summaryLength: useUIStore.getState().summaryLength,
|
summaryLength: useUIStore.getState().summaryLength,
|
||||||
maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
|
maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
|
||||||
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
||||||
|
autoSaveEnabled: useUIStore.getState().autoSaveEnabled,
|
||||||
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
||||||
sessionRetentionAction: useUIStore.getState().sessionRetentionAction,
|
sessionRetentionAction: useUIStore.getState().sessionRetentionAction,
|
||||||
fontSize: useUIStore.getState().fontSize,
|
fontSize: useUIStore.getState().fontSize,
|
||||||
@@ -117,6 +119,7 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
summaryLength: state.summaryLength,
|
summaryLength: state.summaryLength,
|
||||||
maxLastMessageLength: state.maxLastMessageLength,
|
maxLastMessageLength: state.maxLastMessageLength,
|
||||||
autoDeleteEnabled: state.autoDeleteEnabled,
|
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||||
|
autoSaveEnabled: state.autoSaveEnabled,
|
||||||
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||||
sessionRetentionAction: state.sessionRetentionAction,
|
sessionRetentionAction: state.sessionRetentionAction,
|
||||||
fontSize: state.fontSize,
|
fontSize: state.fontSize,
|
||||||
@@ -196,6 +199,9 @@ export const startAppearanceAutoSave = (): void => {
|
|||||||
if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) {
|
if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) {
|
||||||
diff.autoDeleteEnabled = current.autoDeleteEnabled;
|
diff.autoDeleteEnabled = current.autoDeleteEnabled;
|
||||||
}
|
}
|
||||||
|
if (current.autoSaveEnabled !== previous.autoSaveEnabled) {
|
||||||
|
diff.autoSaveEnabled = current.autoSaveEnabled;
|
||||||
|
}
|
||||||
if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
|
if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
|
||||||
diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
|
diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,17 +2,22 @@ import type { FilesAPI } from '@/lib/api/types';
|
|||||||
import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits';
|
import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits';
|
||||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
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<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
|
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
|
||||||
formatMessage(useI18nStore.getState().dictionary, key, params);
|
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 =
|
export type ContextFileOpenValidationResult =
|
||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; reason: ContextFileOpenFailureReason };
|
| { ok: false; reason: ContextFileOpenFailureReason };
|
||||||
|
|
||||||
|
export type ContextFileOpenOptions = {
|
||||||
|
directory?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
||||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
const normalized = message.toLowerCase();
|
const normalized = message.toLowerCase();
|
||||||
@@ -30,16 +35,27 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
|||||||
return 'unreadable';
|
return 'unreadable';
|
||||||
};
|
};
|
||||||
|
|
||||||
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
|
const readFileContent = async (
|
||||||
|
files: FilesAPI,
|
||||||
|
path: string,
|
||||||
|
options?: ContextFileOpenOptions,
|
||||||
|
): Promise<string> => {
|
||||||
if (files.readFile) {
|
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 ?? '';
|
return result.content ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({ path, optional: 'true' });
|
const params = new URLSearchParams({ path, optional: 'true' });
|
||||||
|
if (options?.directory) {
|
||||||
|
params.set('directory', options.directory);
|
||||||
|
}
|
||||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
|
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
|
||||||
// Avoid conditional requests (304 + empty body).
|
// Avoid conditional requests (304 + empty body).
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
|
headers: options?.directory ? { 'x-opencode-directory': options.directory } : undefined,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
@@ -49,9 +65,25 @@ const readFileContent = async (files: FilesAPI, path: string): Promise<string> =
|
|||||||
return response.text();
|
return response.text();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateContextFileOpen = async (files: FilesAPI, path: string): Promise<ContextFileOpenValidationResult> => {
|
/**
|
||||||
|
* 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<ContextFileOpenValidationResult> => {
|
||||||
|
if (isBinaryFile(path) || isPdfFile(path) || isImageFile(path)) {
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES);
|
||||||
if (lineCount > MAX_OPEN_FILE_LINES) {
|
if (lineCount > MAX_OPEN_FILE_LINES) {
|
||||||
return { ok: false, reason: 'too-large' };
|
return { ok: false, reason: 'too-large' };
|
||||||
@@ -73,5 +105,9 @@ export const getContextFileOpenFailureMessage = (reason: ContextFileOpenFailureR
|
|||||||
return t('contextFileOpen.failure.missing');
|
return t('contextFileOpen.failure.missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reason === 'binary') {
|
||||||
|
return t('filesView.editor.cannotPreviewBinary');
|
||||||
|
}
|
||||||
|
|
||||||
return t('contextFileOpen.failure.unreadable');
|
return t('contextFileOpen.failure.unreadable');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export type DesktopSettings = {
|
|||||||
renamedGroups?: Record<string, string>; // groupId -> custom label
|
renamedGroups?: Record<string, string>; // groupId -> custom label
|
||||||
}>; // Per-provider custom model groups configuration
|
}>; // Per-provider custom model groups configuration
|
||||||
autoDeleteEnabled?: boolean;
|
autoDeleteEnabled?: boolean;
|
||||||
|
autoSaveEnabled?: boolean;
|
||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
sessionRetentionAction?: 'archive' | 'delete';
|
sessionRetentionAction?: 'archive' | 'delete';
|
||||||
tunnelProvider?: string;
|
tunnelProvider?: string;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1883,6 +1883,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt Navigator',
|
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt Navigator',
|
||||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
'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.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.wideChatLayoutAria': 'Wide chat layout',
|
||||||
'settings.openchamber.visual.field.wideChatLayout': 'Wide Chat Layout',
|
'settings.openchamber.visual.field.wideChatLayout': 'Wide Chat Layout',
|
||||||
'settings.openchamber.visual.field.codeBlockLineWrapAria': 'Wrap code block lines',
|
'settings.openchamber.visual.field.codeBlockLineWrapAria': 'Wrap code block lines',
|
||||||
|
|||||||
@@ -1247,6 +1247,8 @@ export const dict = {
|
|||||||
'filesView.editor.showControlsAria': 'Show editor controls',
|
'filesView.editor.showControlsAria': 'Show editor controls',
|
||||||
'filesView.editor.controlsTitle': 'Editor controls',
|
'filesView.editor.controlsTitle': 'Editor controls',
|
||||||
'filesView.editor.pickFileFromTree': 'Pick a file from the tree.',
|
'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.loading': 'Loading...',
|
||||||
'filesView.state.openingFileAtChange': 'Opening file at change...',
|
'filesView.state.openingFileAtChange': 'Opening file at change...',
|
||||||
'filesView.tree.search.placeholder': 'Search files...',
|
'filesView.tree.search.placeholder': 'Search files...',
|
||||||
|
|||||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts",
|
"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.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.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.wideChatLayoutAria": "Diseño de chat ancho",
|
||||||
"settings.openchamber.visual.field.wideChatLayout": "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",
|
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Acciones en línea del asistente",
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.editor.showControlsAria": "Mostrar controles del editor",
|
"filesView.editor.showControlsAria": "Mostrar controles del editor",
|
||||||
"filesView.editor.controlsTitle": "Controles del editor",
|
"filesView.editor.controlsTitle": "Controles del editor",
|
||||||
"filesView.editor.pickFileFromTree": "Selecciona un archivo del árbol.",
|
"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.loading": "Cargando...",
|
||||||
"filesView.state.openingFileAtChange": "Abriendo archivo en cambio...",
|
"filesView.state.openingFileAtChange": "Abriendo archivo en cambio...",
|
||||||
"filesView.tree.search.placeholder": "Buscar archivos...",
|
"filesView.tree.search.placeholder": "Buscar archivos...",
|
||||||
|
|||||||
@@ -1755,6 +1755,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Navigateur de prompts',
|
'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.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.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.wideChatLayoutAria': 'Large disposition de discussion',
|
||||||
'settings.openchamber.visual.field.wideChatLayout': 'Disposition de discussion large',
|
'settings.openchamber.visual.field.wideChatLayout': 'Disposition de discussion large',
|
||||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'Actions intégrées de l\'assistant',
|
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'Actions intégrées de l\'assistant',
|
||||||
|
|||||||
@@ -1075,6 +1075,8 @@ export const dict = {
|
|||||||
'filesView.editor.showControlsAria': 'Afficher les contrôles de l\'éditeur',
|
'filesView.editor.showControlsAria': 'Afficher les contrôles de l\'éditeur',
|
||||||
'filesView.editor.controlsTitle': 'Contrôles de l\'éditeur',
|
'filesView.editor.controlsTitle': 'Contrôles de l\'éditeur',
|
||||||
'filesView.editor.pickFileFromTree': 'Choisissez un fichier dans l\'arborescence.',
|
'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.loading': 'Chargement...',
|
||||||
'filesView.state.openingFileAtChange': 'Ouverture du fichier lors du changement...',
|
'filesView.state.openingFileAtChange': 'Ouverture du fichier lors du changement...',
|
||||||
'filesView.tree.search.placeholder': 'Rechercher des fichiers...',
|
'filesView.tree.search.placeholder': 'Rechercher des fichiers...',
|
||||||
|
|||||||
@@ -1883,6 +1883,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.visual.field.promptNavigatorEnabled': 'プロンプトナビゲーター',
|
'settings.openchamber.visual.field.promptNavigatorEnabled': 'プロンプトナビゲーター',
|
||||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'エディターツールバーを常に表示',
|
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'エディターツールバーを常に表示',
|
||||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'エディターツールバーを常に表示(ファイルタブの下にドッキング)',
|
'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.wideChatLayoutAria': 'ワイドチャットレイアウト',
|
||||||
'settings.openchamber.visual.field.wideChatLayout': 'ワイドチャットレイアウト',
|
'settings.openchamber.visual.field.wideChatLayout': 'ワイドチャットレイアウト',
|
||||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'インラインアシスタントアクション',
|
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'インラインアシスタントアクション',
|
||||||
|
|||||||
@@ -1243,6 +1243,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.showControlsAria': 'エディターコントロールを表示',
|
'filesView.editor.showControlsAria': 'エディターコントロールを表示',
|
||||||
'filesView.editor.controlsTitle': 'エディターコントロール',
|
'filesView.editor.controlsTitle': 'エディターコントロール',
|
||||||
'filesView.editor.pickFileFromTree': 'ツリーからファイルを選択してください。',
|
'filesView.editor.pickFileFromTree': 'ツリーからファイルを選択してください。',
|
||||||
|
'filesView.editor.cannotPreviewBinary': 'バイナリファイルはプレビューできません',
|
||||||
|
'filesView.editor.binaryFileDescription': 'このファイルはバイナリのため、OpenChamberでは編集できません。別のアプリで開くにはダウンロードしてください。',
|
||||||
'filesView.state.loading': '読み込み中...',
|
'filesView.state.loading': '読み込み中...',
|
||||||
'filesView.state.openingFileAtChange': '変更箇所のファイルを開いています...',
|
'filesView.state.openingFileAtChange': '変更箇所のファイルを開いています...',
|
||||||
'filesView.tree.search.placeholder': 'ファイルを検索...',
|
'filesView.tree.search.placeholder': 'ファイルを検索...',
|
||||||
|
|||||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.visual.field.promptNavigatorEnabled': '프롬프트 탐색기',
|
'settings.openchamber.visual.field.promptNavigatorEnabled': '프롬프트 탐색기',
|
||||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
'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.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.wideChatLayoutAria': '넓은 채팅 레이아웃',
|
||||||
'settings.openchamber.visual.field.wideChatLayout': '넓은 채팅 레이아웃',
|
'settings.openchamber.visual.field.wideChatLayout': '넓은 채팅 레이아웃',
|
||||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '인라인 어시스턴트 작업',
|
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '인라인 어시스턴트 작업',
|
||||||
|
|||||||
@@ -1250,6 +1250,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.showControlsAria': '편집기 컨트롤 표시',
|
'filesView.editor.showControlsAria': '편집기 컨트롤 표시',
|
||||||
'filesView.editor.controlsTitle': '편집기 컨트롤',
|
'filesView.editor.controlsTitle': '편집기 컨트롤',
|
||||||
'filesView.editor.pickFileFromTree': '트리에서 파일을 선택하세요.',
|
'filesView.editor.pickFileFromTree': '트리에서 파일을 선택하세요.',
|
||||||
|
'filesView.editor.cannotPreviewBinary': '바이너리 파일을 미리볼 수 없음',
|
||||||
|
'filesView.editor.binaryFileDescription': '이 파일은 바이너리이므로 OpenChamber에서 편집할 수 없습니다. 다른 앱으로 열려면 다운로드하세요.',
|
||||||
'filesView.state.loading': '로드 중…',
|
'filesView.state.loading': '로드 중…',
|
||||||
'filesView.state.openingFileAtChange': '변경 위치에서 파일 여는 중…',
|
'filesView.state.openingFileAtChange': '변경 위치에서 파일 여는 중…',
|
||||||
'filesView.tree.search.placeholder': '파일 검색…',
|
'filesView.tree.search.placeholder': '파일 검색…',
|
||||||
|
|||||||
@@ -1090,6 +1090,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Nawigator promptów',
|
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Nawigator promptów',
|
||||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
'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.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.terminalFontSize': 'Rozmiar czcionki terminala',
|
||||||
'settings.openchamber.visual.field.terminalShell': 'Powłoka terminala',
|
'settings.openchamber.visual.field.terminalShell': 'Powłoka terminala',
|
||||||
'settings.openchamber.visual.field.terminalShellAria': 'Wybierz powłokę terminala',
|
'settings.openchamber.visual.field.terminalShellAria': 'Wybierz powłokę terminala',
|
||||||
|
|||||||
@@ -1728,6 +1728,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.openFilesAria': 'Otwarte pliki',
|
'filesView.editor.openFilesAria': 'Otwarte pliki',
|
||||||
'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej',
|
'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej',
|
||||||
'filesView.editor.pickFileFromTree': 'Wybierz plik z drzewa.',
|
'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.refreshApps': 'Odśwież aplikacje',
|
||||||
'filesView.editor.saveAria': 'Zapisz ({shortcut})',
|
'filesView.editor.saveAria': 'Zapisz ({shortcut})',
|
||||||
'filesView.editor.saveFile': 'Zapisz plik',
|
'filesView.editor.saveFile': 'Zapisz plik',
|
||||||
|
|||||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts",
|
"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.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.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.wideChatLayoutAria": "Layout de chat amplo",
|
||||||
"settings.openchamber.visual.field.wideChatLayout": "Layout de chat amplo",
|
"settings.openchamber.visual.field.wideChatLayout": "Layout de chat amplo",
|
||||||
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Ações inline do assistente",
|
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Ações inline do assistente",
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.editor.showControlsAria": "Mostrar controles do editor",
|
"filesView.editor.showControlsAria": "Mostrar controles do editor",
|
||||||
"filesView.editor.controlsTitle": "Controles do editor",
|
"filesView.editor.controlsTitle": "Controles do editor",
|
||||||
"filesView.editor.pickFileFromTree": "Selecione um arquivo na árvore.",
|
"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.loading": "Carregando...",
|
||||||
"filesView.state.openingFileAtChange": "Abrindo arquivo na alteração...",
|
"filesView.state.openingFileAtChange": "Abrindo arquivo na alteração...",
|
||||||
"filesView.tree.search.placeholder": "Pesquisar arquivos...",
|
"filesView.tree.search.placeholder": "Pesquisar arquivos...",
|
||||||
|
|||||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.visual.field.promptNavigatorEnabled": "Навігатор промптів",
|
"settings.openchamber.visual.field.promptNavigatorEnabled": "Навігатор промптів",
|
||||||
"settings.openchamber.visual.field.expandedEditorToolbarAria": "Завжди показувати панель інструментів редактора",
|
"settings.openchamber.visual.field.expandedEditorToolbarAria": "Завжди показувати панель інструментів редактора",
|
||||||
"settings.openchamber.visual.field.expandedEditorToolbar": "Завжди показувати панель інструментів редактора (закріплена під вкладками)",
|
"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.wideChatLayoutAria": "Широкий макет чату",
|
||||||
"settings.openchamber.visual.field.wideChatLayout": "Широкий макет чату",
|
"settings.openchamber.visual.field.wideChatLayout": "Широкий макет чату",
|
||||||
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Вбудовані дії асистента",
|
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Вбудовані дії асистента",
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"filesView.editor.showControlsAria": "Показати елементи керування редактора",
|
"filesView.editor.showControlsAria": "Показати елементи керування редактора",
|
||||||
"filesView.editor.controlsTitle": "Елементи керування редактора",
|
"filesView.editor.controlsTitle": "Елементи керування редактора",
|
||||||
"filesView.editor.pickFileFromTree": "Вибрати файл із дерева.",
|
"filesView.editor.pickFileFromTree": "Вибрати файл із дерева.",
|
||||||
|
"filesView.editor.cannotPreviewBinary": "Неможливо попередньо переглянути бінарний файл",
|
||||||
|
"filesView.editor.binaryFileDescription": "Цей файл є бінарним і його не можна редагувати в OpenChamber. Завантажте його, щоб відкрити в іншій програмі.",
|
||||||
"filesView.state.loading": "Завантаження...",
|
"filesView.state.loading": "Завантаження...",
|
||||||
"filesView.state.openingFileAtChange": "Відкриття файлу на зміні...",
|
"filesView.state.openingFileAtChange": "Відкриття файлу на зміні...",
|
||||||
"filesView.tree.search.placeholder": "Пошук файлів...",
|
"filesView.tree.search.placeholder": "Пошук файлів...",
|
||||||
|
|||||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.visual.field.promptNavigatorEnabled': '提示词导航',
|
'settings.openchamber.visual.field.promptNavigatorEnabled': '提示词导航',
|
||||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
'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.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.wideChatLayoutAria': '宽聊天布局',
|
||||||
'settings.openchamber.visual.field.wideChatLayout': '宽聊天布局',
|
'settings.openchamber.visual.field.wideChatLayout': '宽聊天布局',
|
||||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '内联助手操作',
|
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '内联助手操作',
|
||||||
|
|||||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.showControlsAria': '显示编辑器控制项',
|
'filesView.editor.showControlsAria': '显示编辑器控制项',
|
||||||
'filesView.editor.controlsTitle': '编辑器控制项',
|
'filesView.editor.controlsTitle': '编辑器控制项',
|
||||||
'filesView.editor.pickFileFromTree': '请从文件树中选择一个文件。',
|
'filesView.editor.pickFileFromTree': '请从文件树中选择一个文件。',
|
||||||
|
'filesView.editor.cannotPreviewBinary': '无法预览二进制文件',
|
||||||
|
'filesView.editor.binaryFileDescription': '此文件为二进制文件,无法在 OpenChamber 中编辑。请下载后使用其他应用打开。',
|
||||||
'filesView.state.loading': '加载中...',
|
'filesView.state.loading': '加载中...',
|
||||||
'filesView.state.openingFileAtChange': '正在打开变更处的文件...',
|
'filesView.state.openingFileAtChange': '正在打开变更处的文件...',
|
||||||
'filesView.tree.search.placeholder': '搜索文件...',
|
'filesView.tree.search.placeholder': '搜索文件...',
|
||||||
|
|||||||
@@ -1756,6 +1756,9 @@
|
|||||||
'settings.openchamber.visual.field.promptNavigatorEnabled': '提示詞導覽',
|
'settings.openchamber.visual.field.promptNavigatorEnabled': '提示詞導覽',
|
||||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
'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.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.wideChatLayoutAria': '寬聊天佈局',
|
||||||
'settings.openchamber.visual.field.wideChatLayout': '寬聊天佈局',
|
'settings.openchamber.visual.field.wideChatLayout': '寬聊天佈局',
|
||||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '行內助理操作',
|
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '行內助理操作',
|
||||||
|
|||||||
@@ -1224,6 +1224,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'filesView.editor.showControlsAria': '顯示編輯器控制項',
|
'filesView.editor.showControlsAria': '顯示編輯器控制項',
|
||||||
'filesView.editor.controlsTitle': '編輯器控制項',
|
'filesView.editor.controlsTitle': '編輯器控制項',
|
||||||
'filesView.editor.pickFileFromTree': '請從檔案樹中選擇一個檔案。',
|
'filesView.editor.pickFileFromTree': '請從檔案樹中選擇一個檔案。',
|
||||||
|
'filesView.editor.cannotPreviewBinary': '無法預覽二進位檔案',
|
||||||
|
'filesView.editor.binaryFileDescription': '此檔案為二進位檔案,無法在 OpenChamber 中編輯。請下載後使用其他應用程式開啟。',
|
||||||
'filesView.state.loading': '載入中...',
|
'filesView.state.loading': '載入中...',
|
||||||
'filesView.state.openingFileAtChange': '正在開啟變更處的檔案...',
|
'filesView.state.openingFileAtChange': '正在開啟變更處的檔案...',
|
||||||
'filesView.tree.search.placeholder': '搜尋檔案...',
|
'filesView.tree.search.placeholder': '搜尋檔案...',
|
||||||
|
|||||||
@@ -497,4 +497,74 @@ describe('updateDesktopSettings', () => {
|
|||||||
expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true);
|
expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true);
|
||||||
expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('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<Partial<SettingsPayload>> = [];
|
||||||
|
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<Partial<SettingsPayload>> = [];
|
||||||
|
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<Partial<SettingsPayload>> = [];
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
|
|||||||
sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget,
|
sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget,
|
||||||
collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
|
collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
|
||||||
autoDeleteEnabled: defaults.autoDeleteEnabled,
|
autoDeleteEnabled: defaults.autoDeleteEnabled,
|
||||||
|
autoSaveEnabled: defaults.autoSaveEnabled,
|
||||||
autoDeleteAfterDays: defaults.autoDeleteAfterDays,
|
autoDeleteAfterDays: defaults.autoDeleteAfterDays,
|
||||||
sessionRetentionAction: defaults.sessionRetentionAction,
|
sessionRetentionAction: defaults.sessionRetentionAction,
|
||||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||||
@@ -637,6 +638,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) {
|
if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) {
|
||||||
store.setAutoDeleteEnabled(settings.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)) {
|
if (typeof settings.autoDeleteAfterDays === 'number' && Number.isFinite(settings.autoDeleteAfterDays)) {
|
||||||
const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays));
|
const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays));
|
||||||
if (normalized !== store.autoDeleteAfterDays) {
|
if (normalized !== store.autoDeleteAfterDays) {
|
||||||
@@ -1095,6 +1099,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
||||||
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
||||||
}
|
}
|
||||||
|
if (typeof candidate.autoSaveEnabled === 'boolean') {
|
||||||
|
result.autoSaveEnabled = candidate.autoSaveEnabled;
|
||||||
|
}
|
||||||
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
||||||
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
|
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
|
||||||
}
|
}
|
||||||
@@ -1737,6 +1744,12 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
||||||
|| settings.draftStartersScheduleTaskAdded !== 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);
|
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
|
||||||
try {
|
try {
|
||||||
persistToLocalStorage(settings);
|
persistToLocalStorage(settings);
|
||||||
@@ -1745,6 +1758,9 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
await waitForHydration();
|
await waitForHydration();
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
|
if (shouldSeedAutoSaveEnabled) {
|
||||||
|
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
|
||||||
|
}
|
||||||
if (settings.draftStarters === undefined) {
|
if (settings.draftStarters === undefined) {
|
||||||
useUIStore.setState({ globalDraftStarters: null });
|
useUIStore.setState({ globalDraftStarters: null });
|
||||||
}
|
}
|
||||||
@@ -1753,12 +1769,19 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('applyDesktopUiPreferences failed:', error);
|
console.warn('applyDesktopUiPreferences failed:', error);
|
||||||
}
|
}
|
||||||
|
const migrationPatch: Partial<DesktopSettings> = {};
|
||||||
if (shouldPersistCraftGoalMigration) {
|
if (shouldPersistCraftGoalMigration) {
|
||||||
await updateDesktopSettings({
|
if (authoritativeSettings.draftStarters) {
|
||||||
...(authoritativeSettings.draftStarters ? { draftStarters: authoritativeSettings.draftStarters } : {}),
|
migrationPatch.draftStarters = authoritativeSettings.draftStarters;
|
||||||
draftStartersCraftGoalAdded: true,
|
}
|
||||||
draftStartersScheduleTaskAdded: true,
|
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;
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
|||||||
// Only the mobile composer applies this offset (ChatInput gates on isMobile).
|
// Only the mobile composer applies this offset (ChatInput gates on isMobile).
|
||||||
isAvailable: (ctx) => ctx.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',
|
id: 'appearance.expanded-editor-toolbar',
|
||||||
page: 'general',
|
page: 'general',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -705,6 +705,84 @@ export function isPdfFile(filePath: string): boolean {
|
|||||||
return ext === 'pdf';
|
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 {
|
export function getImageMimeType(filePath: string): string {
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||||
const mimeMap: Record<string, string> = {
|
const mimeMap: Record<string, string> = {
|
||||||
|
|||||||
@@ -621,6 +621,8 @@ interface UIStore {
|
|||||||
activityRenderMode: ActivityRenderMode;
|
activityRenderMode: ActivityRenderMode;
|
||||||
showDeletionDialog: boolean;
|
showDeletionDialog: boolean;
|
||||||
autoDeleteEnabled: boolean;
|
autoDeleteEnabled: boolean;
|
||||||
|
/** Global file-editor autosave. Default true for backward compatibility. */
|
||||||
|
autoSaveEnabled: boolean;
|
||||||
autoDeleteAfterDays: number;
|
autoDeleteAfterDays: number;
|
||||||
sessionRetentionAction: SessionRetentionAction;
|
sessionRetentionAction: SessionRetentionAction;
|
||||||
autoDeleteLastRunAt: number | null;
|
autoDeleteLastRunAt: number | null;
|
||||||
@@ -784,6 +786,7 @@ interface UIStore {
|
|||||||
setActivityRenderMode: (value: ActivityRenderMode) => void;
|
setActivityRenderMode: (value: ActivityRenderMode) => void;
|
||||||
setShowDeletionDialog: (value: boolean) => void;
|
setShowDeletionDialog: (value: boolean) => void;
|
||||||
setAutoDeleteEnabled: (value: boolean) => void;
|
setAutoDeleteEnabled: (value: boolean) => void;
|
||||||
|
setAutoSaveEnabled: (value: boolean) => void;
|
||||||
setAutoDeleteAfterDays: (days: number) => void;
|
setAutoDeleteAfterDays: (days: number) => void;
|
||||||
setSessionRetentionAction: (value: SessionRetentionAction) => void;
|
setSessionRetentionAction: (value: SessionRetentionAction) => void;
|
||||||
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
|
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
|
||||||
@@ -941,6 +944,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
activityRenderMode: 'summary',
|
activityRenderMode: 'summary',
|
||||||
showDeletionDialog: true,
|
showDeletionDialog: true,
|
||||||
autoDeleteEnabled: false,
|
autoDeleteEnabled: false,
|
||||||
|
autoSaveEnabled: true,
|
||||||
autoDeleteAfterDays: 30,
|
autoDeleteAfterDays: 30,
|
||||||
sessionRetentionAction: 'archive',
|
sessionRetentionAction: 'archive',
|
||||||
autoDeleteLastRunAt: null,
|
autoDeleteLastRunAt: null,
|
||||||
@@ -1682,6 +1686,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ autoDeleteEnabled: value });
|
set({ autoDeleteEnabled: value });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setAutoSaveEnabled: (value) => {
|
||||||
|
set({ autoSaveEnabled: value });
|
||||||
|
},
|
||||||
|
|
||||||
setAutoDeleteAfterDays: (days) => {
|
setAutoDeleteAfterDays: (days) => {
|
||||||
const clampedDays = Math.max(1, Math.min(365, days));
|
const clampedDays = Math.max(1, Math.min(365, days));
|
||||||
set({ autoDeleteAfterDays: clampedDays });
|
set({ autoDeleteAfterDays: clampedDays });
|
||||||
@@ -2254,13 +2262,32 @@ export const useUIStore = create<UIStore>()(
|
|||||||
{
|
{
|
||||||
name: 'ui-store',
|
name: 'ui-store',
|
||||||
storage: createDeferredSafeJSONStorage(),
|
storage: createDeferredSafeJSONStorage(),
|
||||||
version: 12,
|
version: 13,
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
if (!persistedState || typeof persistedState !== 'object') {
|
if (!persistedState || typeof persistedState !== 'object') {
|
||||||
return persistedState;
|
return persistedState;
|
||||||
}
|
}
|
||||||
const state = persistedState as Record<string, unknown>;
|
const state = persistedState as Record<string, unknown>;
|
||||||
|
|
||||||
|
// 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).
|
// v11 -> v12: drop legacy window-controls "auto" (always meant right).
|
||||||
if (version < 12) {
|
if (version < 12) {
|
||||||
if (state.desktopWindowControlsPosition === 'auto' || state.desktopWindowControlsPosition == null) {
|
if (state.desktopWindowControlsPosition === 'auto' || state.desktopWindowControlsPosition == null) {
|
||||||
@@ -2360,6 +2387,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
|
|
||||||
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
|
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
|
||||||
|
|
||||||
|
if (typeof state.autoSaveEnabled !== 'boolean') {
|
||||||
|
state.autoSaveEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
state.contextRailOrder = Array.isArray(state.contextRailOrder)
|
state.contextRailOrder = Array.isArray(state.contextRailOrder)
|
||||||
? (state.contextRailOrder as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '')
|
? (state.contextRailOrder as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '')
|
||||||
: [];
|
: [];
|
||||||
@@ -2396,6 +2427,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
activityRenderMode: state.activityRenderMode,
|
activityRenderMode: state.activityRenderMode,
|
||||||
showDeletionDialog: state.showDeletionDialog,
|
showDeletionDialog: state.showDeletionDialog,
|
||||||
autoDeleteEnabled: state.autoDeleteEnabled,
|
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||||
|
autoSaveEnabled: state.autoSaveEnabled,
|
||||||
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||||
sessionRetentionAction: state.sessionRetentionAction,
|
sessionRetentionAction: state.sessionRetentionAction,
|
||||||
autoDeleteLastRunAt: state.autoDeleteLastRunAt,
|
autoDeleteLastRunAt: state.autoDeleteLastRunAt,
|
||||||
|
|||||||
Reference in New Issue
Block a user