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:
@@ -637,6 +637,7 @@ export interface SettingsPayload {
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoSaveEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
sessionRetentionAction?: 'archive' | 'delete';
|
||||
followUpBehavior?: 'steer' | 'queue';
|
||||
|
||||
@@ -31,6 +31,7 @@ type AppearanceSlice = {
|
||||
summaryLength: number;
|
||||
maxLastMessageLength: number;
|
||||
autoDeleteEnabled: boolean;
|
||||
autoSaveEnabled: boolean;
|
||||
autoDeleteAfterDays: number;
|
||||
sessionRetentionAction: 'archive' | 'delete';
|
||||
fontSize: number;
|
||||
@@ -78,6 +79,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
summaryLength: useUIStore.getState().summaryLength,
|
||||
maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
|
||||
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
||||
autoSaveEnabled: useUIStore.getState().autoSaveEnabled,
|
||||
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
||||
sessionRetentionAction: useUIStore.getState().sessionRetentionAction,
|
||||
fontSize: useUIStore.getState().fontSize,
|
||||
@@ -117,6 +119,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
summaryLength: state.summaryLength,
|
||||
maxLastMessageLength: state.maxLastMessageLength,
|
||||
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||
autoSaveEnabled: state.autoSaveEnabled,
|
||||
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||
sessionRetentionAction: state.sessionRetentionAction,
|
||||
fontSize: state.fontSize,
|
||||
@@ -196,6 +199,9 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) {
|
||||
diff.autoDeleteEnabled = current.autoDeleteEnabled;
|
||||
}
|
||||
if (current.autoSaveEnabled !== previous.autoSaveEnabled) {
|
||||
diff.autoSaveEnabled = current.autoSaveEnabled;
|
||||
}
|
||||
if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
|
||||
diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
|
||||
}
|
||||
|
||||
@@ -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 { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { isBinaryFile, isImageFile, isPdfFile, looksLikeBinaryText } from '@/lib/toolHelpers';
|
||||
|
||||
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
|
||||
formatMessage(useI18nStore.getState().dictionary, key, params);
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable';
|
||||
export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable' | 'binary';
|
||||
|
||||
export type ContextFileOpenValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: ContextFileOpenFailureReason };
|
||||
|
||||
export type ContextFileOpenOptions = {
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
const normalized = message.toLowerCase();
|
||||
@@ -30,16 +35,27 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
||||
return 'unreadable';
|
||||
};
|
||||
|
||||
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
|
||||
const readFileContent = async (
|
||||
files: FilesAPI,
|
||||
path: string,
|
||||
options?: ContextFileOpenOptions,
|
||||
): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, { optional: true });
|
||||
const result = await files.readFile(path, {
|
||||
optional: true,
|
||||
directory: options?.directory,
|
||||
});
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ path, optional: 'true' });
|
||||
if (options?.directory) {
|
||||
params.set('directory', options.directory);
|
||||
}
|
||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
headers: options?.directory ? { 'x-opencode-directory': options.directory } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
|
||||
@@ -49,9 +65,25 @@ const readFileContent = async (files: FilesAPI, path: string): Promise<string> =
|
||||
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 {
|
||||
const content = await readFileContent(files, path);
|
||||
const content = await readFileContent(files, path, options);
|
||||
if (looksLikeBinaryText(content)) {
|
||||
return { ok: false, reason: 'binary' };
|
||||
}
|
||||
const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES);
|
||||
if (lineCount > MAX_OPEN_FILE_LINES) {
|
||||
return { ok: false, reason: 'too-large' };
|
||||
@@ -73,5 +105,9 @@ export const getContextFileOpenFailureMessage = (reason: ContextFileOpenFailureR
|
||||
return t('contextFileOpen.failure.missing');
|
||||
}
|
||||
|
||||
if (reason === 'binary') {
|
||||
return t('filesView.editor.cannotPreviewBinary');
|
||||
}
|
||||
|
||||
return t('contextFileOpen.failure.unreadable');
|
||||
};
|
||||
|
||||
@@ -107,6 +107,7 @@ export type DesktopSettings = {
|
||||
renamedGroups?: Record<string, string>; // groupId -> custom label
|
||||
}>; // Per-provider custom model groups configuration
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoSaveEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
sessionRetentionAction?: 'archive' | 'delete';
|
||||
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.expandedEditorToolbarAria': 'Always show editor toolbar',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledAria': 'Auto-save files',
|
||||
'settings.openchamber.visual.field.autoSaveEnabled': 'Auto-save files',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Automatically save file edits after you stop typing. Disable to require manual save.',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': 'Wide chat layout',
|
||||
'settings.openchamber.visual.field.wideChatLayout': 'Wide Chat Layout',
|
||||
'settings.openchamber.visual.field.codeBlockLineWrapAria': 'Wrap code block lines',
|
||||
|
||||
@@ -1247,6 +1247,8 @@ export const dict = {
|
||||
'filesView.editor.showControlsAria': 'Show editor controls',
|
||||
'filesView.editor.controlsTitle': 'Editor controls',
|
||||
'filesView.editor.pickFileFromTree': 'Pick a file from the tree.',
|
||||
'filesView.editor.cannotPreviewBinary': 'Cannot preview binary file',
|
||||
'filesView.editor.binaryFileDescription': 'This file is binary and cannot be edited in OpenChamber. Download it to open with another app.',
|
||||
'filesView.state.loading': 'Loading...',
|
||||
'filesView.state.openingFileAtChange': 'Opening file at change...',
|
||||
'filesView.tree.search.placeholder': 'Search files...',
|
||||
|
||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts",
|
||||
"settings.openchamber.visual.field.expandedEditorToolbarAria": "Mostrar siempre la barra de herramientas del editor",
|
||||
"settings.openchamber.visual.field.expandedEditorToolbar": "Mostrar siempre la barra de herramientas del editor (anclada bajo las pestañas)",
|
||||
"settings.openchamber.visual.field.autoSaveEnabledAria": "Guardado automático de archivos",
|
||||
"settings.openchamber.visual.field.autoSaveEnabled": "Guardado automático de archivos",
|
||||
"settings.openchamber.visual.field.autoSaveEnabledInfo": "Guarda automáticamente las ediciones del archivo después de dejar de escribir. Desactívalo para exigir un guardado manual.",
|
||||
"settings.openchamber.visual.field.wideChatLayoutAria": "Diseño de chat ancho",
|
||||
"settings.openchamber.visual.field.wideChatLayout": "Diseño de chat ancho",
|
||||
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Acciones en línea del asistente",
|
||||
|
||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.showControlsAria": "Mostrar controles del editor",
|
||||
"filesView.editor.controlsTitle": "Controles del editor",
|
||||
"filesView.editor.pickFileFromTree": "Selecciona un archivo del árbol.",
|
||||
"filesView.editor.cannotPreviewBinary": "No se puede previsualizar el archivo binario",
|
||||
"filesView.editor.binaryFileDescription": "Este archivo es binario y no se puede editar en OpenChamber. Descárgalo para abrirlo con otra aplicación.",
|
||||
"filesView.state.loading": "Cargando...",
|
||||
"filesView.state.openingFileAtChange": "Abriendo archivo en cambio...",
|
||||
"filesView.tree.search.placeholder": "Buscar archivos...",
|
||||
|
||||
@@ -1755,6 +1755,9 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Navigateur de prompts',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Toujours afficher la barre d’outils de l’éditeur',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'Toujours afficher la barre d’outils de l’éditeur (ancrée sous les onglets de fichiers)',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledAria': 'Enregistrement automatique des fichiers',
|
||||
'settings.openchamber.visual.field.autoSaveEnabled': 'Enregistrement automatique des fichiers',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Enregistre automatiquement les modifications après l’arrêt de la saisie. Désactivez pour exiger un enregistrement manuel.',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': 'Large disposition de discussion',
|
||||
'settings.openchamber.visual.field.wideChatLayout': 'Disposition de discussion large',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'Actions intégrées de l\'assistant',
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const dict = {
|
||||
'filesView.editor.showControlsAria': 'Afficher les contrôles de l\'éditeur',
|
||||
'filesView.editor.controlsTitle': 'Contrôles de l\'éditeur',
|
||||
'filesView.editor.pickFileFromTree': 'Choisissez un fichier dans l\'arborescence.',
|
||||
'filesView.editor.cannotPreviewBinary': 'Impossible de prévisualiser le fichier binaire',
|
||||
'filesView.editor.binaryFileDescription': 'Ce fichier est binaire et ne peut pas être modifié dans OpenChamber. Téléchargez-le pour l’ouvrir avec une autre application.',
|
||||
'filesView.state.loading': 'Chargement...',
|
||||
'filesView.state.openingFileAtChange': 'Ouverture du fichier lors du changement...',
|
||||
'filesView.tree.search.placeholder': 'Rechercher des fichiers...',
|
||||
|
||||
@@ -1883,6 +1883,9 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.promptNavigatorEnabled': 'プロンプトナビゲーター',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'エディターツールバーを常に表示',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'エディターツールバーを常に表示(ファイルタブの下にドッキング)',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledAria': 'ファイルの自動保存',
|
||||
'settings.openchamber.visual.field.autoSaveEnabled': 'ファイルの自動保存',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledInfo': '入力を止めた後にファイルの編集内容を自動保存します。無効にすると手動保存が必要になります。',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': 'ワイドチャットレイアウト',
|
||||
'settings.openchamber.visual.field.wideChatLayout': 'ワイドチャットレイアウト',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'インラインアシスタントアクション',
|
||||
|
||||
@@ -1243,6 +1243,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.showControlsAria': 'エディターコントロールを表示',
|
||||
'filesView.editor.controlsTitle': 'エディターコントロール',
|
||||
'filesView.editor.pickFileFromTree': 'ツリーからファイルを選択してください。',
|
||||
'filesView.editor.cannotPreviewBinary': 'バイナリファイルはプレビューできません',
|
||||
'filesView.editor.binaryFileDescription': 'このファイルはバイナリのため、OpenChamberでは編集できません。別のアプリで開くにはダウンロードしてください。',
|
||||
'filesView.state.loading': '読み込み中...',
|
||||
'filesView.state.openingFileAtChange': '変更箇所のファイルを開いています...',
|
||||
'filesView.tree.search.placeholder': 'ファイルを検索...',
|
||||
|
||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.promptNavigatorEnabled': '프롬프트 탐색기',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledAria': '파일 자동 저장',
|
||||
'settings.openchamber.visual.field.autoSaveEnabled': '파일 자동 저장',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledInfo': '입력을 멈춘 후 파일 편집 내용을 자동으로 저장합니다. 끄면 수동으로 저장해야 합니다.',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': '넓은 채팅 레이아웃',
|
||||
'settings.openchamber.visual.field.wideChatLayout': '넓은 채팅 레이아웃',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '인라인 어시스턴트 작업',
|
||||
|
||||
@@ -1250,6 +1250,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.showControlsAria': '편집기 컨트롤 표시',
|
||||
'filesView.editor.controlsTitle': '편집기 컨트롤',
|
||||
'filesView.editor.pickFileFromTree': '트리에서 파일을 선택하세요.',
|
||||
'filesView.editor.cannotPreviewBinary': '바이너리 파일을 미리볼 수 없음',
|
||||
'filesView.editor.binaryFileDescription': '이 파일은 바이너리이므로 OpenChamber에서 편집할 수 없습니다. 다른 앱으로 열려면 다운로드하세요.',
|
||||
'filesView.state.loading': '로드 중…',
|
||||
'filesView.state.openingFileAtChange': '변경 위치에서 파일 여는 중…',
|
||||
'filesView.tree.search.placeholder': '파일 검색…',
|
||||
|
||||
@@ -1090,6 +1090,9 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Nawigator promptów',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledAria': 'Autozapis plików',
|
||||
'settings.openchamber.visual.field.autoSaveEnabled': 'Autozapis plików',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Automatycznie zapisuje edycje pliku po zatrzymaniu pisania. Wyłącz, aby wymagać ręcznego zapisu.',
|
||||
'settings.openchamber.visual.field.terminalFontSize': 'Rozmiar czcionki terminala',
|
||||
'settings.openchamber.visual.field.terminalShell': 'Powłoka terminala',
|
||||
'settings.openchamber.visual.field.terminalShellAria': 'Wybierz powłokę terminala',
|
||||
|
||||
@@ -1728,6 +1728,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.openFilesAria': 'Otwarte pliki',
|
||||
'filesView.editor.openInDesktopApp': 'Otwórz w aplikacji desktopowej',
|
||||
'filesView.editor.pickFileFromTree': 'Wybierz plik z drzewa.',
|
||||
'filesView.editor.cannotPreviewBinary': 'Nie można podglądać pliku binarnego',
|
||||
'filesView.editor.binaryFileDescription': 'Ten plik jest binarny i nie można go edytować w OpenChamber. Pobierz go, aby otworzyć w innej aplikacji.',
|
||||
'filesView.editor.refreshApps': 'Odśwież aplikacje',
|
||||
'filesView.editor.saveAria': 'Zapisz ({shortcut})',
|
||||
'filesView.editor.saveFile': 'Zapisz plik',
|
||||
|
||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.promptNavigatorEnabled": "Navegador de prompts",
|
||||
"settings.openchamber.visual.field.expandedEditorToolbarAria": "Mostrar sempre a barra de ferramentas do editor",
|
||||
"settings.openchamber.visual.field.expandedEditorToolbar": "Mostrar sempre a barra de ferramentas do editor (ancorada sob as abas)",
|
||||
"settings.openchamber.visual.field.autoSaveEnabledAria": "Salvamento automático de arquivos",
|
||||
"settings.openchamber.visual.field.autoSaveEnabled": "Salvamento automático de arquivos",
|
||||
"settings.openchamber.visual.field.autoSaveEnabledInfo": "Salva automaticamente as edições do arquivo depois que você parar de digitar. Desative para exigir salvamento manual.",
|
||||
"settings.openchamber.visual.field.wideChatLayoutAria": "Layout de chat amplo",
|
||||
"settings.openchamber.visual.field.wideChatLayout": "Layout de chat amplo",
|
||||
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Ações inline do assistente",
|
||||
|
||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.showControlsAria": "Mostrar controles do editor",
|
||||
"filesView.editor.controlsTitle": "Controles do editor",
|
||||
"filesView.editor.pickFileFromTree": "Selecione um arquivo na árvore.",
|
||||
"filesView.editor.cannotPreviewBinary": "Não é possível pré-visualizar o arquivo binário",
|
||||
"filesView.editor.binaryFileDescription": "Este arquivo é binário e não pode ser editado no OpenChamber. Baixe-o para abrir em outro aplicativo.",
|
||||
"filesView.state.loading": "Carregando...",
|
||||
"filesView.state.openingFileAtChange": "Abrindo arquivo na alteração...",
|
||||
"filesView.tree.search.placeholder": "Pesquisar arquivos...",
|
||||
|
||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
||||
"settings.openchamber.visual.field.promptNavigatorEnabled": "Навігатор промптів",
|
||||
"settings.openchamber.visual.field.expandedEditorToolbarAria": "Завжди показувати панель інструментів редактора",
|
||||
"settings.openchamber.visual.field.expandedEditorToolbar": "Завжди показувати панель інструментів редактора (закріплена під вкладками)",
|
||||
"settings.openchamber.visual.field.autoSaveEnabledAria": "Автозбереження файлів",
|
||||
"settings.openchamber.visual.field.autoSaveEnabled": "Автозбереження файлів",
|
||||
"settings.openchamber.visual.field.autoSaveEnabledInfo": "Автоматично зберігати зміни у файлі після того, як ви припините друкувати. Вимкніть, щоб зберігати лише вручну.",
|
||||
"settings.openchamber.visual.field.wideChatLayoutAria": "Широкий макет чату",
|
||||
"settings.openchamber.visual.field.wideChatLayout": "Широкий макет чату",
|
||||
"settings.openchamber.visual.field.showSplitAssistantMessageActionsAria": "Вбудовані дії асистента",
|
||||
|
||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"filesView.editor.showControlsAria": "Показати елементи керування редактора",
|
||||
"filesView.editor.controlsTitle": "Елементи керування редактора",
|
||||
"filesView.editor.pickFileFromTree": "Вибрати файл із дерева.",
|
||||
"filesView.editor.cannotPreviewBinary": "Неможливо попередньо переглянути бінарний файл",
|
||||
"filesView.editor.binaryFileDescription": "Цей файл є бінарним і його не можна редагувати в OpenChamber. Завантажте його, щоб відкрити в іншій програмі.",
|
||||
"filesView.state.loading": "Завантаження...",
|
||||
"filesView.state.openingFileAtChange": "Відкриття файлу на зміні...",
|
||||
"filesView.tree.search.placeholder": "Пошук файлів...",
|
||||
|
||||
@@ -1850,6 +1850,9 @@ export const settingsDict = {
|
||||
'settings.openchamber.visual.field.promptNavigatorEnabled': '提示词导航',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledAria': '自动保存文件',
|
||||
'settings.openchamber.visual.field.autoSaveEnabled': '自动保存文件',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledInfo': '停止输入后自动保存文件编辑内容。关闭后需手动保存。',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': '宽聊天布局',
|
||||
'settings.openchamber.visual.field.wideChatLayout': '宽聊天布局',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '内联助手操作',
|
||||
|
||||
@@ -1213,6 +1213,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.showControlsAria': '显示编辑器控制项',
|
||||
'filesView.editor.controlsTitle': '编辑器控制项',
|
||||
'filesView.editor.pickFileFromTree': '请从文件树中选择一个文件。',
|
||||
'filesView.editor.cannotPreviewBinary': '无法预览二进制文件',
|
||||
'filesView.editor.binaryFileDescription': '此文件为二进制文件,无法在 OpenChamber 中编辑。请下载后使用其他应用打开。',
|
||||
'filesView.state.loading': '加载中...',
|
||||
'filesView.state.openingFileAtChange': '正在打开变更处的文件...',
|
||||
'filesView.tree.search.placeholder': '搜索文件...',
|
||||
|
||||
@@ -1756,6 +1756,9 @@
|
||||
'settings.openchamber.visual.field.promptNavigatorEnabled': '提示詞導覽',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
|
||||
'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledAria': '自動儲存檔案',
|
||||
'settings.openchamber.visual.field.autoSaveEnabled': '自動儲存檔案',
|
||||
'settings.openchamber.visual.field.autoSaveEnabledInfo': '停止輸入後自動儲存檔案編輯內容。關閉後需手動儲存。',
|
||||
'settings.openchamber.visual.field.wideChatLayoutAria': '寬聊天佈局',
|
||||
'settings.openchamber.visual.field.wideChatLayout': '寬聊天佈局',
|
||||
'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': '行內助理操作',
|
||||
|
||||
@@ -1224,6 +1224,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'filesView.editor.showControlsAria': '顯示編輯器控制項',
|
||||
'filesView.editor.controlsTitle': '編輯器控制項',
|
||||
'filesView.editor.pickFileFromTree': '請從檔案樹中選擇一個檔案。',
|
||||
'filesView.editor.cannotPreviewBinary': '無法預覽二進位檔案',
|
||||
'filesView.editor.binaryFileDescription': '此檔案為二進位檔案,無法在 OpenChamber 中編輯。請下載後使用其他應用程式開啟。',
|
||||
'filesView.state.loading': '載入中...',
|
||||
'filesView.state.openingFileAtChange': '正在開啟變更處的檔案...',
|
||||
'filesView.tree.search.placeholder': '搜尋檔案...',
|
||||
|
||||
@@ -497,4 +497,74 @@ describe('updateDesktopSettings', () => {
|
||||
expect(saveCalls.some((changes) => changes.terminalShell === 'zsh')).toBe(true);
|
||||
expect(saveCalls.some((changes) => changes.terminalLoginShells?.includes('zsh'))).toBe(true);
|
||||
});
|
||||
|
||||
test('applies persisted autoSaveEnabled from server settings', async () => {
|
||||
getWindow();
|
||||
invalidateSettingsCache();
|
||||
useUIStore.getState().setAutoSaveEnabled(true);
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: { autoSaveEnabled: false, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true },
|
||||
source: 'web',
|
||||
}));
|
||||
|
||||
await syncDesktopSettings();
|
||||
|
||||
expect(useUIStore.getState().autoSaveEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('autosaves autoSaveEnabled changes to shared settings', async () => {
|
||||
getWindow();
|
||||
useUIStore.getState().setAutoSaveEnabled(true);
|
||||
const saveCalls: Array<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,
|
||||
collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
|
||||
autoDeleteEnabled: defaults.autoDeleteEnabled,
|
||||
autoSaveEnabled: defaults.autoSaveEnabled,
|
||||
autoDeleteAfterDays: defaults.autoDeleteAfterDays,
|
||||
sessionRetentionAction: defaults.sessionRetentionAction,
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
@@ -637,6 +638,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.autoDeleteEnabled === 'boolean' && settings.autoDeleteEnabled !== store.autoDeleteEnabled) {
|
||||
store.setAutoDeleteEnabled(settings.autoDeleteEnabled);
|
||||
}
|
||||
if (typeof settings.autoSaveEnabled === 'boolean' && settings.autoSaveEnabled !== store.autoSaveEnabled) {
|
||||
store.setAutoSaveEnabled(settings.autoSaveEnabled);
|
||||
}
|
||||
if (typeof settings.autoDeleteAfterDays === 'number' && Number.isFinite(settings.autoDeleteAfterDays)) {
|
||||
const normalized = Math.max(1, Math.min(365, settings.autoDeleteAfterDays));
|
||||
if (normalized !== store.autoDeleteAfterDays) {
|
||||
@@ -1095,6 +1099,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
||||
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
||||
}
|
||||
if (typeof candidate.autoSaveEnabled === 'boolean') {
|
||||
result.autoSaveEnabled = candidate.autoSaveEnabled;
|
||||
}
|
||||
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
||||
result.autoDeleteAfterDays = candidate.autoDeleteAfterDays;
|
||||
}
|
||||
@@ -1737,6 +1744,12 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
||||
|| settings.draftStartersScheduleTaskAdded !== true;
|
||||
// `autoSaveEnabled` is new to the settings backend. Until the server has a
|
||||
// value, materialize would invent the client default (true) and overwrite a
|
||||
// deliberate legacy "off" preference migrated from
|
||||
// `openchamber:files:auto-save-enabled`. Prefer the hydrated store value and
|
||||
// seed the backend once so later omitted→default authority is correct.
|
||||
const shouldSeedAutoSaveEnabled = typeof settings.autoSaveEnabled !== 'boolean';
|
||||
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
|
||||
try {
|
||||
persistToLocalStorage(settings);
|
||||
@@ -1745,6 +1758,9 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (shouldSeedAutoSaveEnabled) {
|
||||
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
|
||||
}
|
||||
if (settings.draftStarters === undefined) {
|
||||
useUIStore.setState({ globalDraftStarters: null });
|
||||
}
|
||||
@@ -1753,12 +1769,19 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
} catch (error) {
|
||||
console.warn('applyDesktopUiPreferences failed:', error);
|
||||
}
|
||||
const migrationPatch: Partial<DesktopSettings> = {};
|
||||
if (shouldPersistCraftGoalMigration) {
|
||||
await updateDesktopSettings({
|
||||
...(authoritativeSettings.draftStarters ? { draftStarters: authoritativeSettings.draftStarters } : {}),
|
||||
draftStartersCraftGoalAdded: true,
|
||||
draftStartersScheduleTaskAdded: true,
|
||||
});
|
||||
if (authoritativeSettings.draftStarters) {
|
||||
migrationPatch.draftStarters = authoritativeSettings.draftStarters;
|
||||
}
|
||||
migrationPatch.draftStartersCraftGoalAdded = true;
|
||||
migrationPatch.draftStartersScheduleTaskAdded = true;
|
||||
}
|
||||
if (shouldSeedAutoSaveEnabled) {
|
||||
migrationPatch.autoSaveEnabled = authoritativeSettings.autoSaveEnabled;
|
||||
}
|
||||
if (Object.keys(migrationPatch).length > 0) {
|
||||
await updateDesktopSettings(migrationPatch);
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
// Only the mobile composer applies this offset (ChatInput gates on isMobile).
|
||||
isAvailable: (ctx) => ctx.isMobile,
|
||||
},
|
||||
{
|
||||
id: 'appearance.auto-save-enabled',
|
||||
page: 'general',
|
||||
titleKey: 'settings.openchamber.visual.field.autoSaveEnabled',
|
||||
descriptionKey: 'settings.openchamber.visual.field.autoSaveEnabledInfo',
|
||||
keywords: ['editor', 'autosave', 'auto-save', 'files', 'save'],
|
||||
},
|
||||
{
|
||||
id: 'appearance.expanded-editor-toolbar',
|
||||
page: 'general',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
export function isSvgFile(filePath: string): boolean {
|
||||
return filePath.toLowerCase().endsWith('.svg');
|
||||
}
|
||||
|
||||
/** Known non-text extensions that must not be opened or saved as UTF-8 text. */
|
||||
const BINARY_FILE_EXTENSIONS = new Set([
|
||||
// Documents / office
|
||||
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
|
||||
// Archives / packages
|
||||
'zip', 'rar', '7z', 'gz', 'tgz', 'tar', 'bz2', 'xz', 'jar', 'war', 'apk', 'dmg', 'iso',
|
||||
'deb', 'rpm', 'msi',
|
||||
// Images (svg is text and is excluded via isSvgFile)
|
||||
...IMAGE_EXTENSIONS.filter((ext) => ext !== 'svg'),
|
||||
// Audio / video
|
||||
'mp3', 'mp4', 'm4a', 'aac', 'flac', 'ogg', 'wav', 'wma', 'avi', 'mov', 'mkv', 'webm', 'wmv',
|
||||
// Fonts
|
||||
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
||||
// Native / bytecode
|
||||
'exe', 'dll', 'so', 'dylib', 'bin', 'class', 'o', 'a', 'lib', 'wasm', 'node',
|
||||
// Databases / locks / misc binary
|
||||
'sqlite', 'sqlite3', 'db', 'dat', 'parquet', 'feather', 'pickle', 'pyc', 'pyo', 'lockb',
|
||||
]);
|
||||
|
||||
export function getFileExtension(filePath: string): string {
|
||||
const base = filePath.split(/[/\\]/).pop() ?? filePath;
|
||||
const dot = base.lastIndexOf('.');
|
||||
if (dot <= 0 || dot === base.length - 1) {
|
||||
return '';
|
||||
}
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/** True for known binary extensions (including images/PDF). SVG is not binary. */
|
||||
export function isBinaryFile(filePath: string): boolean {
|
||||
if (isSvgFile(filePath)) {
|
||||
return false;
|
||||
}
|
||||
const ext = getFileExtension(filePath);
|
||||
return BINARY_FILE_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic for UTF-8 text that is actually binary (or was lossily decoded).
|
||||
* Used as defense-in-depth when extension checks miss a binary file.
|
||||
*/
|
||||
export function looksLikeBinaryText(content: string): boolean {
|
||||
if (!content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sample = content.length > 8192 ? content.slice(0, 8192) : content;
|
||||
if (sample.includes('\0')) {
|
||||
return true;
|
||||
}
|
||||
if (sample.startsWith('%PDF')) {
|
||||
return true;
|
||||
}
|
||||
// ZIP-based formats (docx/xlsx/pptx/jar/apk…) and raw ZIP.
|
||||
if (sample.startsWith('PK\u0003\u0004') || sample.startsWith('PK\u0005\u0006') || sample.startsWith('PK\u0007\u0008')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let suspicious = 0;
|
||||
for (let index = 0; index < sample.length; index += 1) {
|
||||
const code = sample.charCodeAt(index);
|
||||
if (code === 0xFFFD) {
|
||||
suspicious += 1;
|
||||
continue;
|
||||
}
|
||||
// C0 controls excluding common whitespace (TAB/LF/VT/FF/CR).
|
||||
if (code < 9 || (code > 13 && code < 32) || code === 127) {
|
||||
suspicious += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sample.length > 0 && suspicious / sample.length > 0.1;
|
||||
}
|
||||
|
||||
export function getImageMimeType(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
|
||||
Reference in New Issue
Block a user