Skip to content

Commit 74e7b0c

Browse files
committed
File viewer: red warning banner for binary files (F3-confused users)
- Banner at the top of the viewer when the file looks binary (extension-based classifier in `lib/file-viewer/binary-warning.ts`): images and documents get the literal phrases `image` / `document`; other recognized binary categories (archive, video, audio, executable, font, etc.) get the uppercased extension (`ZIP`, `MP4`, `EXE`). Text/source/SVG/unknown extensions never trigger the banner — better to under-warn than over-warn - Copy nudges the user toward ⇧Space (Quick Look) for visual preview or Enter / double-click to open the file in the associated app - Two dismissal paths: **Close** hides the banner for this viewer instance only; **Never show this warning again** flips `fileViewer.suppressBinaryWarning` (new boolean in Settings > Advanced, default `false`) and hides it forever. Mirrors the Space-key hint toast pattern - Read once at mount via `getSetting('fileViewer.suppressBinaryWarning')` — live setting changes don't affect already-open viewer instances (per-instance UX) - 40 Vitest cases covering the classifier: images, documents, binary categories, edge cases (no extension, dotfiles, multi-dot, case-insensitive, trailing dot, empty string) - Docs: `lib/file-viewer/CLAUDE.md` Key files section updated
1 parent 6778494 commit 74e7b0c

6 files changed

Lines changed: 372 additions & 0 deletions

File tree

apps/desktop/src/lib/file-viewer/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ Opens files in a read-only viewer with instant load for any file size, virtual s
55
## Key files
66

77
- `open-viewer.ts`: `openFileViewer(filePath)` creates new `WebviewWindow` with unique label
8+
- `binary-warning.ts`: pure `categorizeForViewerWarning(fileName)` helper that classifies a file as `image` / `document`
9+
/ `<EXT-uppercased>` (or "don't warn" for text/source/unknown). The viewer route renders a red banner at the top
10+
whenever the helper says `shouldWarn`. Suppressible per-instance via the banner's **Close** button or forever via
11+
**Never show this warning again** (flips `fileViewer.suppressBinaryWarning` in Settings > Advanced).
812
- Route: `apps/desktop/src/routes/viewer/+page.svelte`: viewer UI with virtual scrolling, search bar, status bar
913

1014
## User interaction
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
import { categorizeForViewerWarning } from './binary-warning'
4+
5+
describe('categorizeForViewerWarning', () => {
6+
describe('image extensions → "image"', () => {
7+
it.each(['photo.jpg', 'snap.JPEG', 'logo.png', 'anim.gif', 'pic.webp', 'shot.heic', 'icon.ico', 'art.avif'])(
8+
'%s → image',
9+
(name) => {
10+
expect(categorizeForViewerWarning(name)).toEqual({ shouldWarn: true, label: 'image' })
11+
},
12+
)
13+
})
14+
15+
describe('document extensions → "document"', () => {
16+
it.each(['report.pdf', 'contract.docx', 'budget.xlsx', 'slides.pptx', 'notes.pages', 'data.numbers', 'novel.epub'])(
17+
'%s → document',
18+
(name) => {
19+
expect(categorizeForViewerWarning(name)).toEqual({ shouldWarn: true, label: 'document' })
20+
},
21+
)
22+
})
23+
24+
describe('other binary extensions → uppercased extension', () => {
25+
it('installer.exe → EXE', () => {
26+
expect(categorizeForViewerWarning('installer.exe')).toEqual({ shouldWarn: true, label: 'EXE' })
27+
})
28+
it('archive.zip → ZIP', () => {
29+
expect(categorizeForViewerWarning('archive.zip')).toEqual({ shouldWarn: true, label: 'ZIP' })
30+
})
31+
it('video.mp4 → MP4', () => {
32+
expect(categorizeForViewerWarning('video.mp4')).toEqual({ shouldWarn: true, label: 'MP4' })
33+
})
34+
it('sound.mp3 → MP3', () => {
35+
expect(categorizeForViewerWarning('sound.mp3')).toEqual({ shouldWarn: true, label: 'MP3' })
36+
})
37+
it('font.woff2 → WOFF2', () => {
38+
expect(categorizeForViewerWarning('font.woff2')).toEqual({ shouldWarn: true, label: 'WOFF2' })
39+
})
40+
})
41+
42+
describe('text-like or unknown extensions do NOT warn', () => {
43+
// Plain text and source code: showing raw bytes is the point.
44+
it.each([
45+
'README.md',
46+
'notes.txt',
47+
'config.json',
48+
'data.csv',
49+
'app.ts',
50+
'main.rs',
51+
'script.py',
52+
'styles.css',
53+
'index.html',
54+
'icon.svg', // text-based XML
55+
'log.log',
56+
'Cargo.toml',
57+
'Dockerfile.yaml',
58+
])('%s → no warning', (name) => {
59+
expect(categorizeForViewerWarning(name)).toEqual({ shouldWarn: false, label: '' })
60+
})
61+
62+
// Unknown extension we don't classify: better to under-warn than over-warn.
63+
it('random.xyz → no warning', () => {
64+
expect(categorizeForViewerWarning('random.xyz')).toEqual({ shouldWarn: false, label: '' })
65+
})
66+
})
67+
68+
describe('edge cases', () => {
69+
it('files with no extension never warn (Makefile, README, etc.)', () => {
70+
expect(categorizeForViewerWarning('Makefile')).toEqual({ shouldWarn: false, label: '' })
71+
expect(categorizeForViewerWarning('README')).toEqual({ shouldWarn: false, label: '' })
72+
})
73+
74+
it('hidden files with no real extension never warn (.bashrc, .gitignore)', () => {
75+
// ".bashrc" has the dot at index 0; we treat that as "no extension".
76+
expect(categorizeForViewerWarning('.bashrc')).toEqual({ shouldWarn: false, label: '' })
77+
expect(categorizeForViewerWarning('.gitignore')).toEqual({ shouldWarn: false, label: '' })
78+
})
79+
80+
it('trailing dot is treated as no extension', () => {
81+
expect(categorizeForViewerWarning('name.')).toEqual({ shouldWarn: false, label: '' })
82+
})
83+
84+
it('empty string → no warning, no crash', () => {
85+
expect(categorizeForViewerWarning('')).toEqual({ shouldWarn: false, label: '' })
86+
})
87+
88+
it('extension is matched case-insensitively', () => {
89+
expect(categorizeForViewerWarning('PHOTO.JPG')).toEqual({ shouldWarn: true, label: 'image' })
90+
expect(categorizeForViewerWarning('Setup.EXE')).toEqual({ shouldWarn: true, label: 'EXE' })
91+
})
92+
93+
it('multi-dot filenames use the last segment', () => {
94+
expect(categorizeForViewerWarning('archive.tar.gz')).toEqual({ shouldWarn: true, label: 'GZ' })
95+
expect(categorizeForViewerWarning('photo.thumbnail.png')).toEqual({ shouldWarn: true, label: 'image' })
96+
})
97+
})
98+
})
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Classifies a file by extension to decide whether the file viewer should
3+
* show the "this is the raw view" banner.
4+
*
5+
* The file viewer renders bytes (lossy UTF-8). For images, PDFs, archives,
6+
* media etc. that's almost never what the user wanted — they probably hit
7+
* F3 expecting a Finder-style preview. The banner explains the difference
8+
* and nudges them toward ⇧Space (Quick Look) or Enter (open in associated
9+
* app).
10+
*
11+
* Approach: explicit allow-lists per category. Anything not on a list (no
12+
* extension, source code, configs, logs, CSV, markdown, SVG, etc.) does
13+
* NOT trigger the banner. This is conservative — better to under-warn than
14+
* over-warn on legitimate text files.
15+
*/
16+
17+
const IMAGE_EXTS = new Set([
18+
'jpg',
19+
'jpeg',
20+
'png',
21+
'gif',
22+
'webp',
23+
'heic',
24+
'heif',
25+
'bmp',
26+
'tiff',
27+
'tif',
28+
'ico',
29+
'icns',
30+
'avif',
31+
'raw',
32+
'cr2',
33+
'nef',
34+
'dng',
35+
'arw',
36+
])
37+
38+
const DOCUMENT_EXTS = new Set([
39+
'pdf',
40+
'doc',
41+
'docx',
42+
'xls',
43+
'xlsx',
44+
'ppt',
45+
'pptx',
46+
'pages',
47+
'numbers',
48+
'key',
49+
'odt',
50+
'ods',
51+
'odp',
52+
'epub',
53+
'mobi',
54+
])
55+
56+
const OTHER_BINARY_EXTS = new Set([
57+
// video
58+
'mp4',
59+
'mov',
60+
'avi',
61+
'mkv',
62+
'webm',
63+
'm4v',
64+
'mpg',
65+
'mpeg',
66+
'flv',
67+
'wmv',
68+
'3gp',
69+
// audio
70+
'mp3',
71+
'wav',
72+
'm4a',
73+
'aac',
74+
'ogg',
75+
'flac',
76+
'opus',
77+
'wma',
78+
// archive
79+
'zip',
80+
'tar',
81+
'gz',
82+
'tgz',
83+
'bz2',
84+
'tbz',
85+
'7z',
86+
'rar',
87+
'dmg',
88+
'iso',
89+
'xz',
90+
'lz',
91+
'lzma',
92+
'jar',
93+
'war',
94+
// executable / binary
95+
'exe',
96+
'app',
97+
'msi',
98+
'pkg',
99+
'deb',
100+
'rpm',
101+
'dll',
102+
'so',
103+
'dylib',
104+
'bin',
105+
'dat',
106+
'o',
107+
'a',
108+
'lib',
109+
'class',
110+
'pyc',
111+
'pyo',
112+
// fonts
113+
'ttf',
114+
'otf',
115+
'woff',
116+
'woff2',
117+
'eot',
118+
])
119+
120+
/**
121+
* The category we surface in the banner copy. `''` means "don't warn" — the
122+
* file looks like something the raw view can plausibly show (text, source
123+
* code, no extension, etc.). For the rare in-between case (SVG, JSON, CSV),
124+
* the answer is also "don't warn" because the raw bytes ARE useful there.
125+
*/
126+
export interface ViewerWarning {
127+
shouldWarn: boolean
128+
/** Phrase that fits "view the actual <label> instead" — for example, "image", "document", "EXE", "ZIP". */
129+
label: string
130+
}
131+
132+
function getExtension(fileName: string): string {
133+
// No `lastIndexOf('.')`-blind: "foo" (no dot), ".bashrc" (leading dot only),
134+
// "name." (trailing dot) all return `''`.
135+
const dot = fileName.lastIndexOf('.')
136+
if (dot < 1 || dot === fileName.length - 1) return ''
137+
return fileName.slice(dot + 1).toLowerCase()
138+
}
139+
140+
export function categorizeForViewerWarning(fileName: string): ViewerWarning {
141+
const ext = getExtension(fileName)
142+
if (!ext) return { shouldWarn: false, label: '' }
143+
if (IMAGE_EXTS.has(ext)) return { shouldWarn: true, label: 'image' }
144+
if (DOCUMENT_EXTS.has(ext)) return { shouldWarn: true, label: 'document' }
145+
if (OTHER_BINARY_EXTS.has(ext)) return { shouldWarn: true, label: ext.toUpperCase() }
146+
return { shouldWarn: false, label: '' }
147+
}

apps/desktop/src/lib/settings/settings-registry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,18 @@ export const settingsRegistry: SettingDefinition[] = [
814814
step: 1,
815815
},
816816
},
817+
{
818+
id: 'fileViewer.suppressBinaryWarning',
819+
section: ['Advanced'],
820+
label: 'Suppress the raw-view warning for binary files',
821+
description:
822+
"F3 opens Cmdr's file viewer, which shows raw bytes (with lossy UTF-8 for non-text content). When you open an image, PDF, archive, or other binary file, the viewer shows a red banner explaining that ⇧Space (Quick Look) or Enter (open in the associated app) is probably what you wanted. Turn this on (or click 'Never show this warning again' in the banner) to suppress the warning for good.",
823+
keywords: ['viewer', 'binary', 'image', 'pdf', 'raw', 'warning', 'banner', 'f3', 'quick', 'look'],
824+
type: 'boolean',
825+
default: false,
826+
component: 'switch',
827+
showInAdvanced: true,
828+
},
817829
{
818830
id: 'fileExplorer.suppressQuickLookHint',
819831
section: ['Advanced'],

apps/desktop/src/lib/settings/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export interface SettingsValues {
184184

185185
// Viewer
186186
'viewer.wordWrap': boolean
187+
'fileViewer.suppressBinaryWarning': boolean
187188

188189
// AI
189190
'ai.provider': AiProvider

0 commit comments

Comments
 (0)