From 0a51420206ceceb914d12185962e335e57d14e04 Mon Sep 17 00:00:00 2001 From: Niels Billen Date: Tue, 9 Jun 2026 14:20:58 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20duplicate=20file=20extensi?= =?UTF-8?q?on=20in=20macOS=20save=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, NSSavePanel automatically appends the extension taken from allowedFileTypes to the entered file name. The save dialog also baked the extension into nameFieldStringValue via buildFileSaverSuggestedName, so the panel appended it a second time and produced names like "name.ext.ext". Windows is unaffected because its native dialog only appends a default extension when the name has none. Mirror the Windows behaviour on both macOS code paths (native macosMain and the JVM JNA picker): set the bare suggestedName as the name field and let allowedFileTypes supply the extension. A new shared buildFileSaverAllowedFileTypes helper orders the file types with the default extension first, so NSSavePanel appends the default one. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vinceglb/filekit/dialogs/FileSaverName.kt | 12 ++++++++++ .../filekit/dialogs/FileSaverNameTest.kt | 24 +++++++++++++++++++ .../dialogs/platform/mac/MacOSFilePicker.kt | 13 ++++------ .../vinceglb/filekit/dialogs/FileKit.macos.kt | 15 ++++-------- 4 files changed, 46 insertions(+), 18 deletions(-) diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverName.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverName.kt index 09f54bbd..ccc19484 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverName.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverName.kt @@ -20,3 +20,15 @@ internal fun buildFileSaverSuggestedName( else -> "$suggestedName.$normalizedExtension" } } + +internal fun buildFileSaverAllowedFileTypes( + defaultExtension: String?, + allowedExtensions: Set?, +): List? { + val normalizedDefault = normalizeFileSaverExtension(defaultExtension) + val normalizedAllowed = normalizeFileSaverExtensions(allowedExtensions).orEmpty() + return buildList { + normalizedDefault?.let { add(it) } + normalizedAllowed.forEach { if (it != normalizedDefault) add(it) } + }.takeIf { it.isNotEmpty() } +} diff --git a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverNameTest.kt b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverNameTest.kt index 08ef6def..c67d4505 100644 --- a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverNameTest.kt +++ b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileSaverNameTest.kt @@ -55,4 +55,28 @@ class FileSaverNameTest { assertEquals("document.pdf", buildFileSaverSuggestedName("document", ".pdf")) assertEquals("document.pdf", buildFileSaverSuggestedName("document", " .pdf ")) } + + @Test + fun buildFileSaverAllowedFileTypes_whenNoExtensions_returnsNull() { + assertNull(buildFileSaverAllowedFileTypes(null, null)) + assertNull(buildFileSaverAllowedFileTypes("", setOf("", "."))) + } + + @Test + fun buildFileSaverAllowedFileTypes_whenOnlyDefaultExtension_returnsSingleNormalizedType() { + assertEquals(listOf("pdf"), buildFileSaverAllowedFileTypes(".pdf", null)) + } + + @Test + fun buildFileSaverAllowedFileTypes_whenNoDefault_returnsNormalizedAllowedExtensions() { + assertEquals(listOf("md", "txt"), buildFileSaverAllowedFileTypes(null, setOf("md", "txt"))) + } + + @Test + fun buildFileSaverAllowedFileTypes_putsDefaultExtensionFirstAndDeduplicates() { + assertEquals( + listOf("pdf", "md", "txt"), + buildFileSaverAllowedFileTypes("pdf", setOf("md", "pdf", "txt")), + ) + } } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt index eee483e4..2fd58cf0 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt @@ -3,7 +3,7 @@ package io.github.vinceglb.filekit.dialogs.platform.mac import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMacOSSettings -import io.github.vinceglb.filekit.dialogs.buildFileSaverSuggestedName +import io.github.vinceglb.filekit.dialogs.buildFileSaverAllowedFileTypes import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.Foundation import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.ID @@ -73,18 +73,15 @@ internal class MacOSFilePicker : PlatformFilePicker { Foundation.invoke(savePanel, "setDirectoryURL:", Foundation.nsURL(it.path)) } + // Set the file name without extension, NSSavePanel appends it from allowedFileTypes Foundation.invoke( savePanel, "setNameFieldStringValue:", - Foundation.nsString( - buildFileSaverSuggestedName( - suggestedName = suggestedName, - extension = defaultExtension, - ), - ), + Foundation.nsString(suggestedName), ) - val fileTypes = allowedExtensions ?: defaultExtension?.let { setOf(it) } + // Default extension first so it is the one appended + val fileTypes = buildFileSaverAllowedFileTypes(defaultExtension, allowedExtensions) savePanel.setAllowedFileTypes(fileTypes) Foundation.invoke( diff --git a/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt b/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt index 851f2acf..7523fa8e 100644 --- a/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt +++ b/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt @@ -74,21 +74,16 @@ internal actual suspend fun FileKit.platformOpenFileSaver( ): PlatformFile? { // Create an NSSavePanel val nsSavePanel = NSSavePanel() - val normalizedDefaultExtension = normalizeFileSaverExtension(defaultExtension) - val normalizedAllowedExtensions = normalizeFileSaverExtensions(allowedExtensions) // Set the initial directory directory?.let { nsSavePanel.directoryURL = NSURL.fileURLWithPath(it.path) } - // Set the file name - nsSavePanel.nameFieldStringValue = buildFileSaverSuggestedName( - suggestedName = suggestedName, - extension = normalizedDefaultExtension, - ) + // Set the file name without extension, NSSavePanel appends it from allowedFileTypes + nsSavePanel.nameFieldStringValue = suggestedName - // Set the file extension filters - val fileTypes = normalizedAllowedExtensions ?: normalizedDefaultExtension?.let { setOf(it) } - fileTypes?.let { nsSavePanel.allowedFileTypes = it.toList() } + // Set the file extension filters, default extension first so it is the one appended + buildFileSaverAllowedFileTypes(defaultExtension, allowedExtensions) + ?.let { nsSavePanel.allowedFileTypes = it } // Accept the creation of directories nsSavePanel.canCreateDirectories = dialogSettings.canCreateDirectories