Skip to content

Commit 55592ba

Browse files
committed
Rename: Skip warning for equivalent file extensions
- Adds `EQUIVALENT_EXTENSION_GROUPS` in `filename-validation.ts` so that switching between aliases of the same format no longer triggers the extension-change confirmation. Groups: jpg/jpeg/jpe/jfif, tif/tiff, htm/html, yml/yaml, mpg/mpeg, mid/midi, aif/aiff, qt/mov, md/markdown/txt. - Renames `extensionsDifferIgnoringCase` -> `extensionsDifferMeaningfully` since the function now also ignores known aliases, not just letter case. Updates the two call sites and the relevant `CLAUDE.md` docs. - Adds unit tests covering each group, case+alias combos, no-extension edges, dotfiles, multi-dot names, and cross-group changes that should still warn. Also adjusts three pre-existing tests that used `.txt` <-> `.md` as their "real change" example, since that pair is now intentionally equivalent.
1 parent e8656bb commit 55592ba

5 files changed

Lines changed: 151 additions & 21 deletions

File tree

apps/desktop/src/lib/file-explorer/rename/CLAUDE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ Operates on cursor item only; selection is preserved and irrelevant.
2929

3030
Implemented in `rename-operations.ts::executeRenameSave()`:
3131

32-
1. **Extension check**: If `extensionPolicy === 'ask'` and the extensions differ in more than letter case
33-
(`extensionsDifferIgnoringCase()` from `filename-validation.ts`), return `{ type: 'extension-ask' }`. Caller shows
32+
1. **Extension check**: If `extensionPolicy === 'ask'` and the extensions differ meaningfully
33+
(`extensionsDifferMeaningfully()` from `filename-validation.ts`), return `{ type: 'extension-ask' }`. Caller shows
3434
ExtensionChangeDialog. If user clicks "Keep", retry with `skipExtensionCheck=true`. Case-only changes like
35-
`photo.JPG``photo.jpg` skip the dialog entirely.
35+
`photo.JPG``photo.jpg` and known-equivalent changes like `.jpeg``.jpg` or `.md``.txt` skip the dialog
36+
entirely.
3637

3738
2. **Backend validity check**: Call `checkRenameValidity(parentPath, originalName, trimmedName)`. Returns:
3839
- `{ valid: false, error }` → return `{ type: 'error' }`
@@ -117,7 +118,8 @@ trimmed value.
117118
discard rename. File watcher events during editing don't cancel (backend will catch issues on save).
118119
- **Extension validation gotcha**: If setting is "no", changing extension shows red border during editing. If setting is
119120
"ask", no red border (waits for save to show dialog). If setting is "yes", never validates extension. Case-only
120-
extension changes (e.g. `.JPG``.jpg`) are treated as no change in all modes.
121+
extension changes (e.g. `.JPG``.jpg`) and known-equivalent changes (e.g. `.jpeg``.jpg`, `.md``.txt`) are
122+
treated as no change in all modes.
121123
- **Same-name edge case**: If `trimmedName === originalName`, treat as cancel (no-op). Don't emit file watcher event or
122124
refresh pane. Avoids spurious refresh on whitespace-only edits.
123125
- **Click-to-rename interference**: Double-click on name area must open file/folder (normal behavior), not activate

apps/desktop/src/lib/file-explorer/rename/rename-operations.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* except for the actual backend calls which are awaited.
55
*/
66

7-
import { extensionsDifferIgnoringCase, getExtension } from '$lib/utils/filename-validation'
7+
import { extensionsDifferMeaningfully, getExtension } from '$lib/utils/filename-validation'
88

99
export interface ConflictFileInfo {
1010
name: string
@@ -49,11 +49,11 @@ export async function executeRenameSave(
4949
return { type: 'noop' }
5050
}
5151

52-
// Check extension change (case-only changes are silently allowed)
52+
// Check extension change (case-only and known-equivalent changes are silently allowed)
5353
if (
5454
!skipExtensionCheck &&
5555
extensionPolicy === 'ask' &&
56-
extensionsDifferIgnoringCase(target.originalName, trimmedName)
56+
extensionsDifferMeaningfully(target.originalName, trimmedName)
5757
) {
5858
return {
5959
type: 'extension-ask',

apps/desktop/src/lib/utils/CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ interface ValidationResult {
6262
(e.g. `.gitignore``''`). Implemented as `lastIndexOf('.') <= 0`.
6363
- Extension change behavior is controlled by the `allowExtensionChanges` user setting (`yes`/`no`/`ask`). `'ask'`
6464
returns `ok` at validation time — the save dialog handles it separately.
65-
- `extensionsDifferIgnoringCase(oldName, newName)` is the shared helper that decides whether an extension change is
66-
meaningful. Case-only changes (e.g. `.JPG``.jpg`) are treated as no change so users aren't pestered to confirm a
67-
metadata tweak. Used by both `validateExtensionChange` and the rename save flow's "ask" gate.
65+
- `extensionsDifferMeaningfully(oldName, newName)` is the shared helper that decides whether an extension change is
66+
worth a confirmation. It returns false for case-only changes (e.g. `.JPG``.jpg`) and for changes between known
67+
equivalents (e.g. `.jpeg``.jpg`, `.md``.txt`), so users aren't pestered to confirm a metadata tweak. The
68+
equivalence groups live in `EQUIVALENT_EXTENSION_GROUPS` in the same file — extend that constant to add more aliases.
69+
Used by both `validateExtensionChange` and the rename save flow's "ask" gate.
6870

6971
## confirm-dialog.ts
7072

apps/desktop/src/lib/utils/filename-validation.test.ts

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
validateConflict,
1010
validateFilename,
1111
getExtension,
12+
extensionsDifferMeaningfully,
1213
} from './filename-validation'
1314

1415
describe('validateDisallowedChars', () => {
@@ -180,13 +181,99 @@ describe('getExtension', () => {
180181
})
181182
})
182183

184+
describe('extensionsDifferMeaningfully', () => {
185+
it('detects a real extension change', () => {
186+
expect(extensionsDifferMeaningfully('file.txt', 'file.json')).toBe(true)
187+
})
188+
189+
it('ignores case-only changes', () => {
190+
expect(extensionsDifferMeaningfully('photo.JPG', 'photo.jpg')).toBe(false)
191+
expect(extensionsDifferMeaningfully('archive.TAR.GZ', 'archive.TAR.gz')).toBe(false)
192+
})
193+
194+
it('ignores no-op changes', () => {
195+
expect(extensionsDifferMeaningfully('file.txt', 'renamed.txt')).toBe(false)
196+
})
197+
198+
it('ignores changes within the JPEG group', () => {
199+
expect(extensionsDifferMeaningfully('photo.jpeg', 'photo.jpg')).toBe(false)
200+
expect(extensionsDifferMeaningfully('photo.jpg', 'photo.jpe')).toBe(false)
201+
expect(extensionsDifferMeaningfully('photo.jfif', 'photo.JPEG')).toBe(false)
202+
})
203+
204+
it('ignores changes within the TIFF group', () => {
205+
expect(extensionsDifferMeaningfully('scan.tif', 'scan.tiff')).toBe(false)
206+
})
207+
208+
it('ignores changes within the HTML group', () => {
209+
expect(extensionsDifferMeaningfully('page.htm', 'page.html')).toBe(false)
210+
})
211+
212+
it('ignores changes within the YAML group', () => {
213+
expect(extensionsDifferMeaningfully('config.yml', 'config.yaml')).toBe(false)
214+
})
215+
216+
it('ignores changes within the MPEG group', () => {
217+
expect(extensionsDifferMeaningfully('clip.mpg', 'clip.mpeg')).toBe(false)
218+
})
219+
220+
it('ignores changes within the MIDI group', () => {
221+
expect(extensionsDifferMeaningfully('song.mid', 'song.midi')).toBe(false)
222+
})
223+
224+
it('ignores changes within the AIFF group', () => {
225+
expect(extensionsDifferMeaningfully('sound.aif', 'sound.aiff')).toBe(false)
226+
})
227+
228+
it('ignores changes within the QuickTime group', () => {
229+
expect(extensionsDifferMeaningfully('movie.qt', 'movie.mov')).toBe(false)
230+
})
231+
232+
it('ignores changes within the markdown/text group', () => {
233+
expect(extensionsDifferMeaningfully('notes.md', 'notes.txt')).toBe(false)
234+
expect(extensionsDifferMeaningfully('notes.markdown', 'notes.md')).toBe(false)
235+
expect(extensionsDifferMeaningfully('notes.TXT', 'notes.Markdown')).toBe(false)
236+
})
237+
238+
it('flags changes that cross groups', () => {
239+
expect(extensionsDifferMeaningfully('photo.jpg', 'photo.tif')).toBe(true)
240+
expect(extensionsDifferMeaningfully('notes.md', 'notes.html')).toBe(true)
241+
})
242+
243+
it('flags adding an extension to a name that had none', () => {
244+
expect(extensionsDifferMeaningfully('Makefile', 'Makefile.txt')).toBe(true)
245+
})
246+
247+
it('flags removing an extension', () => {
248+
expect(extensionsDifferMeaningfully('readme.txt', 'readme')).toBe(true)
249+
})
250+
251+
it('treats a no-extension to no-extension rename as no change', () => {
252+
expect(extensionsDifferMeaningfully('Makefile', 'Dockerfile')).toBe(false)
253+
})
254+
255+
it('trims the new name before comparing', () => {
256+
expect(extensionsDifferMeaningfully('photo.jpg', ' photo.jpeg ')).toBe(false)
257+
})
258+
259+
it('treats a dotfile without extension as no extension', () => {
260+
// getExtension('.gitignore') === '', so renaming to .gitkeep is also no extension
261+
expect(extensionsDifferMeaningfully('.gitignore', '.gitkeep')).toBe(false)
262+
})
263+
264+
it('uses only the last extension on multi-dot names', () => {
265+
expect(extensionsDifferMeaningfully('archive.tar.gz', 'archive.tar.bz2')).toBe(true)
266+
expect(extensionsDifferMeaningfully('photo.backup.jpeg', 'photo.backup.jpg')).toBe(false)
267+
})
268+
})
269+
183270
describe('validateExtensionChange', () => {
184271
it('allows when setting is yes', () => {
185-
expect(validateExtensionChange('file.txt', 'file.md', 'yes').severity).toBe('ok')
272+
expect(validateExtensionChange('file.txt', 'file.json', 'yes').severity).toBe('ok')
186273
})
187274

188275
it('errors when setting is no and ext changed', () => {
189-
const result = validateExtensionChange('file.txt', 'file.md', 'no')
276+
const result = validateExtensionChange('file.txt', 'file.json', 'no')
190277
expect(result.severity).toBe('error')
191278
})
192279

@@ -195,15 +282,20 @@ describe('validateExtensionChange', () => {
195282
})
196283

197284
it('allows when setting is ask (dialog handles it)', () => {
198-
expect(validateExtensionChange('file.txt', 'file.md', 'ask').severity).toBe('ok')
285+
expect(validateExtensionChange('file.txt', 'file.json', 'ask').severity).toBe('ok')
199286
})
200287

201288
it('allows case-only extension change when setting is no', () => {
202289
expect(validateExtensionChange('photo.JPG', 'photo.jpg', 'no').severity).toBe('ok')
203290
})
204291

205292
it('still errors on real extension change when setting is no', () => {
206-
expect(validateExtensionChange('file.txt', 'file.md', 'no').severity).toBe('error')
293+
expect(validateExtensionChange('file.txt', 'file.json', 'no').severity).toBe('error')
294+
})
295+
296+
it('allows equivalent-extension changes when setting is no', () => {
297+
expect(validateExtensionChange('photo.jpeg', 'photo.jpg', 'no').severity).toBe('ok')
298+
expect(validateExtensionChange('notes.md', 'notes.txt', 'no').severity).toBe('ok')
207299
})
208300
})
209301

@@ -260,7 +352,7 @@ describe('validateFilename', () => {
260352
})
261353

262354
it('validates extension change with no setting', () => {
263-
const result = validateFilename('file.md', 'file.txt', parentPath, siblings, 'no')
355+
const result = validateFilename('file.json', 'file.txt', parentPath, siblings, 'no')
264356
expect(result.severity).toBe('error')
265357
})
266358
})

apps/desktop/src/lib/utils/filename-validation.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,46 @@ export function getExtension(filename: string): string {
7777
}
7878

7979
/**
80-
* True if the extensions differ in more than just letter case.
81-
* Case-only extension changes (e.g. `photo.JPG` → `photo.jpg`) are treated as no change,
82-
* so users aren't pestered to confirm something that's effectively a metadata tweak.
80+
* Extensions treated as equivalent — switching between any two in a group skips the rename warning.
81+
* Each group lists spellings of the same underlying format, lowercase, no leading dot.
82+
*
83+
* The `md`/`markdown`/`txt` group is included by deliberate choice: Markdown is plain text and
84+
* the routing tradeoff (different "Open with" defaults) is accepted as a feature, not a bug.
8385
*/
84-
export function extensionsDifferIgnoringCase(oldName: string, newName: string): boolean {
85-
return getExtension(oldName).toLowerCase() !== getExtension(newName.trim()).toLowerCase()
86+
const EQUIVALENT_EXTENSION_GROUPS: readonly (readonly string[])[] = [
87+
['jpg', 'jpeg', 'jpe', 'jfif'],
88+
['tif', 'tiff'],
89+
['htm', 'html'],
90+
['yml', 'yaml'],
91+
['mpg', 'mpeg'],
92+
['mid', 'midi'],
93+
['aif', 'aiff'],
94+
['qt', 'mov'],
95+
['md', 'markdown', 'txt'],
96+
]
97+
98+
/** Precomputed `ext -> groupIndex` map for O(1) equivalence lookup. */
99+
const EXTENSION_TO_GROUP: ReadonlyMap<string, number> = new Map(
100+
EQUIVALENT_EXTENSION_GROUPS.flatMap((group, i) => group.map((ext) => [ext, i] as const)),
101+
)
102+
103+
function normalizedExt(filename: string): string {
104+
const ext = getExtension(filename).toLowerCase()
105+
return ext.startsWith('.') ? ext.slice(1) : ext
106+
}
107+
108+
/**
109+
* True if the extension change is meaningful enough to warrant a confirmation.
110+
* Returns false for case-only changes (`photo.JPG` → `photo.jpg`) and for changes
111+
* between known equivalents (`photo.jpeg` → `photo.jpg`, `notes.md` → `notes.txt`),
112+
* so users aren't pestered to confirm what's effectively a metadata tweak.
113+
*/
114+
export function extensionsDifferMeaningfully(oldName: string, newName: string): boolean {
115+
const oldExt = normalizedExt(oldName)
116+
const newExt = normalizedExt(newName.trim())
117+
if (oldExt === newExt) return false
118+
const oldGroup = EXTENSION_TO_GROUP.get(oldExt)
119+
return oldGroup === undefined || oldGroup !== EXTENSION_TO_GROUP.get(newExt)
86120
}
87121

88122
/** Validates extension change against the user's preference. */
@@ -93,7 +127,7 @@ export function validateExtensionChange(
93127
): ValidationResult {
94128
if (allowExtensionChanges === 'yes') return OK_RESULT
95129

96-
if (!extensionsDifferIgnoringCase(oldName, newName)) return OK_RESULT
130+
if (!extensionsDifferMeaningfully(oldName, newName)) return OK_RESULT
97131

98132
if (allowExtensionChanges === 'no') {
99133
const oldExt = getExtension(oldName)

0 commit comments

Comments
 (0)