Skip to content

Commit 1401017

Browse files
committed
Rename: Skip extension confirm on case-only changes
- Add `extensionsDifferIgnoringCase` helper in `filename-validation.ts`; both `validateExtensionChange` and the rename save flow's "ask" gate now route through it. - `photo.JPG` → `photo.jpg` no longer triggers the ExtensionChangeDialog or the red border (when policy is `no`). - Two unit tests pin the behavior, including a regression guard that real ext changes still error under `no`.
1 parent 398bf7a commit 1401017

5 files changed

Lines changed: 39 additions & 19 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,9 +29,10 @@ 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 `getExtension(originalName) !== getExtension(newName)`,
33-
return `{ type: 'extension-ask' }`. Caller shows ExtensionChangeDialog. If user clicks "Keep", retry with
34-
`skipExtensionCheck=true`.
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
34+
ExtensionChangeDialog. If user clicks "Keep", retry with `skipExtensionCheck=true`. Case-only changes like
35+
`photo.JPG``photo.jpg` skip the dialog entirely.
3536

3637
2. **Backend validity check**: Call `checkRenameValidity(parentPath, originalName, trimmedName)`. Returns:
3738
- `{ valid: false, error }` → return `{ type: 'error' }`
@@ -115,7 +116,8 @@ trimmed value.
115116
- **Cancel triggers**: Escape, click elsewhere, Tab, drag start, scroll >200px cumulative, sort/hidden toggle all
116117
discard rename. File watcher events during editing don't cancel (backend will catch issues on save).
117118
- **Extension validation gotcha**: If setting is "no", changing extension shows red border during editing. If setting is
118-
"ask", no red border (waits for save to show dialog). If setting is "yes", never validates extension.
119+
"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.
119121
- **Same-name edge case**: If `trimmedName === originalName`, treat as cancel (no-op). Don't emit file watcher event or
120122
refresh pane. Avoids spurious refresh on whitespace-only edits.
121123
- **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: 11 additions & 11 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 { getExtension } from '$lib/utils/filename-validation'
7+
import { extensionsDifferIgnoringCase, getExtension } from '$lib/utils/filename-validation'
88

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

52-
// Check extension change
53-
if (!skipExtensionCheck) {
54-
const oldExt = getExtension(target.originalName)
55-
const newExt = getExtension(trimmedName)
56-
if (oldExt !== newExt && extensionPolicy === 'ask') {
57-
return {
58-
type: 'extension-ask',
59-
oldExtension: oldExt.replace(/^\./, ''),
60-
newExtension: newExt.replace(/^\./, ''),
61-
}
52+
// Check extension change (case-only changes are silently allowed)
53+
if (
54+
!skipExtensionCheck &&
55+
extensionPolicy === 'ask' &&
56+
extensionsDifferIgnoringCase(target.originalName, trimmedName)
57+
) {
58+
return {
59+
type: 'extension-ask',
60+
oldExtension: getExtension(target.originalName).replace(/^\./, ''),
61+
newExtension: getExtension(trimmedName).replace(/^\./, ''),
6262
}
6363
}
6464

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ 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.
6568

6669
## confirm-dialog.ts
6770

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ describe('validateExtensionChange', () => {
197197
it('allows when setting is ask (dialog handles it)', () => {
198198
expect(validateExtensionChange('file.txt', 'file.md', 'ask').severity).toBe('ok')
199199
})
200+
201+
it('allows case-only extension change when setting is no', () => {
202+
expect(validateExtensionChange('photo.JPG', 'photo.jpg', 'no').severity).toBe('ok')
203+
})
204+
205+
it('still errors on real extension change when setting is no', () => {
206+
expect(validateExtensionChange('file.txt', 'file.md', 'no').severity).toBe('error')
207+
})
200208
})
201209

202210
describe('validateConflict', () => {

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ export function getExtension(filename: string): string {
7676
return filename.substring(lastDot)
7777
}
7878

79+
/**
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.
83+
*/
84+
export function extensionsDifferIgnoringCase(oldName: string, newName: string): boolean {
85+
return getExtension(oldName).toLowerCase() !== getExtension(newName.trim()).toLowerCase()
86+
}
87+
7988
/** Validates extension change against the user's preference. */
8089
export function validateExtensionChange(
8190
oldName: string,
@@ -84,12 +93,10 @@ export function validateExtensionChange(
8493
): ValidationResult {
8594
if (allowExtensionChanges === 'yes') return OK_RESULT
8695

87-
const oldExt = getExtension(oldName)
88-
const newExt = getExtension(newName.trim())
89-
90-
if (oldExt === newExt) return OK_RESULT
96+
if (!extensionsDifferIgnoringCase(oldName, newName)) return OK_RESULT
9197

9298
if (allowExtensionChanges === 'no') {
99+
const oldExt = getExtension(oldName)
93100
return { severity: 'error', message: `Changing the file extension isn't allowed (was "${oldExt}")` }
94101
}
95102
// 'ask' — no error, the dialog will handle it on save

0 commit comments

Comments
 (0)