From a17c0f66a27d4ec551aea2f8b6fc489799ba6cad Mon Sep 17 00:00:00 2001 From: Timo Drick Date: Sat, 28 Mar 2026 23:52:04 +0100 Subject: [PATCH 1/4] Added support for directory picker in web target. --- filekit-core/build.gradle.kts | 4 + .../github/vinceglb/filekit/PlatformFile.kt | 40 +++++ .../vinceglb/filekit/PlatformFile.js.kt | 100 ------------ .../filekit/PlatformFileSerializer.js.kt | 25 --- .../vinceglb/filekit/PlatformFile.nonWeb.kt | 40 ----- .../vinceglb/filekit/PlatformFile.wasmJs.kt | 102 ------------ .../github/vinceglb/filekit/FileHandleFile.kt | 38 +++++ .../vinceglb/filekit/PlatformFile.web.kt | 147 +++++++++++++++++- .../filekit/PlatformFileSerializer.wasmJs.kt | 0 .../filekit/dialogs/compose/FileKitCompose.kt | 15 ++ .../dialogs/compose/FileKitCompose.nonWeb.kt | 15 -- .../dialogs/compose/FileKitCompose.web.kt | 42 ++++- filekit-dialogs/build.gradle.kts | 2 +- .../vinceglb/filekit/dialogs/FileKit.kt | 12 ++ .../vinceglb/filekit/dialogs/FileKit.js.kt | 33 ++-- .../dialogs/FileKitDialogSettings.js.kt | 14 -- .../filekit/dialogs/FileKit.nonWeb.kt | 12 -- .../filekit/dialogs/FileKit.wasmJs.kt | 30 ++-- .../FileKitFileSaverWithoutBytesException.kt | 7 - .../dialogs/FileKitModeMaxItemsTest.wasmJs.kt | 2 +- .../vinceglb/filekit/dialogs/FileKit.web.kt | 103 ++++++++++++ .../dialogs/FileKitDialogSettings.wasmJs.kt | 0 .../FileKitFileSaverWithoutBytesException.kt | 0 .../directorypicker/DirectoryPickerScreen.kt | 23 +-- .../components/FileDetailsMetadata.web.kt | 46 +++++- 25 files changed, 491 insertions(+), 361 deletions(-) delete mode 100644 filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt delete mode 100644 filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.js.kt delete mode 100644 filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt create mode 100644 filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt rename filekit-core/src/{wasmJsMain => webMain}/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt (100%) delete mode 100644 filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.js.kt delete mode 100644 filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt create mode 100644 filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt rename filekit-dialogs/src/{wasmJsMain => webMain}/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.wasmJs.kt (100%) rename filekit-dialogs/src/{jsMain => webMain}/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt (100%) diff --git a/filekit-core/build.gradle.kts b/filekit-core/build.gradle.kts index f610ac1c..827f777c 100644 --- a/filekit-core/build.gradle.kts +++ b/filekit-core/build.gradle.kts @@ -35,6 +35,10 @@ kotlin { implementation(libs.kotlinx.browser) } + webMain.dependencies { + implementation(libs.kotlinx.browser) + } + jvmMain.dependencies { implementation(libs.jna.platform) } diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt index 02e1e770..28c838a8 100644 --- a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt @@ -67,6 +67,46 @@ public expect suspend fun PlatformFile.readString(): String */ public expect fun PlatformFile.mimeType(): MimeType? +/** + * Returns the path string of this file. + */ +public expect val PlatformFile.path: String + +/** + * Returns the parent of this file, or null if it does not have a parent. + * + * @return The parent [PlatformFile], or null. + */ +public expect fun PlatformFile.parent(): PlatformFile? + +/** + * Checks if this file is a regular file. + * + * @return `true` if it is a regular file, `false` otherwise. + */ +public expect fun PlatformFile.isRegularFile(): Boolean + +/** + * Checks if this file is a directory. + * + * @return `true` if it is a directory, `false` otherwise. + */ +public expect fun PlatformFile.isDirectory(): Boolean + +/** + * Lists the files in this directory and passes them to the given block. + * + * @param block A callback function that receives a list of [PlatformFile]s. + */ +public expect inline fun PlatformFile.list(block: (List) -> Unit) + +/** + * Lists the files in this directory. + * + * @return A list of [PlatformFile]s in this directory. + */ +public expect fun PlatformFile.list(): List + /** * Starts accessing a security-scoped resource. * diff --git a/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt b/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt deleted file mode 100644 index ce8db354..00000000 --- a/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.js.kt +++ /dev/null @@ -1,100 +0,0 @@ -package io.github.vinceglb.filekit - -import io.github.vinceglb.filekit.exceptions.FileKitException -import io.github.vinceglb.filekit.mimeType.MimeType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import org.khronos.webgl.ArrayBuffer -import org.khronos.webgl.Uint8Array -import org.khronos.webgl.get -import org.w3c.files.Blob -import org.w3c.files.File -import org.w3c.files.FilePropertyBag -import org.w3c.files.FileReader -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine -import kotlin.time.ExperimentalTime -import kotlin.time.Instant - -/** - * Represents a file on the Web (JS) platform. - * - * @property file The underlying W3C [File] object. - */ -@Serializable(with = PlatformFileSerializer::class) -public actual data class PlatformFile( - val file: File, -) { - public actual override fun toString(): String = name - - public actual companion object -} - -public actual val PlatformFile.name: String - get() = file.name - -public actual val PlatformFile.extension: String - get() = name.substringAfterLast(".", "") - -public actual val PlatformFile.nameWithoutExtension: String - get() = name.substringBeforeLast(".", name) - -public actual fun PlatformFile.size(): Long = - file.size.toLong() - -public actual suspend fun PlatformFile.readBytes(): ByteArray = withContext(Dispatchers.Main) { - suspendCoroutine { continuation -> - val reader = FileReader() - reader.onload = { event -> - try { - // Read the file as an ArrayBuffer - val arrayBuffer = event - .target - ?.unsafeCast() - ?.result - ?.unsafeCast() - ?: throw FileKitException("Could not read file") - - // Convert the ArrayBuffer to a ByteArray - val bytes = Uint8Array(arrayBuffer) - - // Copy the bytes into a ByteArray - val byteArray = ByteArray(bytes.length) - for (i in 0 until bytes.length) { - byteArray[i] = bytes[i] - } - - // Return the ByteArray - continuation.resume(byteArray) - } catch (e: Exception) { - continuation.resumeWithException(e) - } - } - - // Read the file as an ArrayBuffer - reader.readAsArrayBuffer(file) - } -} - -public actual fun PlatformFile.mimeType(): MimeType? = - takeIf { file.type.isNotBlank() } - ?.let { MimeType.parse(file.type) } - -@OptIn(ExperimentalTime::class, ExperimentalWasmJsInterop::class) -public actual fun PlatformFile.lastModified(): Instant { - val ts = file.unsafeCast() - return Instant.fromEpochMilliseconds(ts.lastModified.toLong()) -} - -@OptIn(ExperimentalWasmJsInterop::class) -private open external class File( - fileBits: JsArray, // BufferSource|Blob|String - fileName: String, - options: FilePropertyBag = definedExternally, -) : Blob, - JsAny { - val name: String - val lastModified: JsNumber -} diff --git a/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.js.kt b/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.js.kt deleted file mode 100644 index 6348a48b..00000000 --- a/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.js.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.github.vinceglb.filekit - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -private const val UNSUPPORTED_MESSAGE = "PlatformFile serialization is not supported on JS targets" - -public actual object PlatformFileSerializer : KSerializer { - actual override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( - serialName = "io.github.vinceglb.filekit.PlatformFile", - kind = PrimitiveKind.STRING, - ) - - actual override fun deserialize(decoder: Decoder): PlatformFile { - error(UNSUPPORTED_MESSAGE) - } - - actual override fun serialize(encoder: Encoder, value: PlatformFile) { - error(UNSUPPORTED_MESSAGE) - } -} diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt index 077408d3..4574b095 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt @@ -48,18 +48,6 @@ public expect fun PlatformFile(base: PlatformFile, child: String): PlatformFile */ public expect fun PlatformFile.toKotlinxIoPath(): Path -/** - * Returns the path string of this file. - */ -public expect val PlatformFile.path: String - -/** - * Returns the parent of this file, or null if it does not have a parent. - * - * @return The parent [PlatformFile], or null. - */ -public expect fun PlatformFile.parent(): PlatformFile? - /** * Returns the absolute path string of this file. * @@ -89,20 +77,6 @@ public expect fun PlatformFile.source(): RawSource */ public expect fun PlatformFile.sink(append: Boolean = false): RawSink -/** - * Checks if this file is a regular file. - * - * @return `true` if it is a regular file, `false` otherwise. - */ -public expect fun PlatformFile.isRegularFile(): Boolean - -/** - * Checks if this file is a directory. - * - * @return `true` if it is a directory, `false` otherwise. - */ -public expect fun PlatformFile.isDirectory(): Boolean - /** * Checks if this file path is absolute. * @@ -233,20 +207,6 @@ internal expect suspend fun PlatformFile.prepareDestinationForWrite(source: Plat public fun PlatformFile.createDirectories(mustCreate: Boolean = false): Unit = SystemFileSystem.createDirectories(toKotlinxIoPath(), mustCreate) -/** - * Lists the files in this directory and passes them to the given block. - * - * @param block A callback function that receives a list of [PlatformFile]s. - */ -public expect inline fun PlatformFile.list(block: (List) -> Unit) - -/** - * Lists the files in this directory. - * - * @return A list of [PlatformFile]s in this directory. - */ -public expect fun PlatformFile.list(): List - /** * Atomically moves this file to the destination. * diff --git a/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt b/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt deleted file mode 100644 index 24944398..00000000 --- a/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFile.wasmJs.kt +++ /dev/null @@ -1,102 +0,0 @@ -package io.github.vinceglb.filekit - -import io.github.vinceglb.filekit.exceptions.FileKitException -import io.github.vinceglb.filekit.mimeType.MimeType -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import org.khronos.webgl.ArrayBuffer -import org.khronos.webgl.Uint8Array -import org.khronos.webgl.get -import org.w3c.files.Blob -import org.w3c.files.File -import org.w3c.files.FilePropertyBag -import org.w3c.files.FileReader -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine -import kotlin.time.ExperimentalTime -import kotlin.time.Instant - -/** - * Represents a file on the Web (Wasm) platform. - * - * @property file The underlying W3C [File] object. - */ -@Serializable(with = PlatformFileSerializer::class) -public actual data class PlatformFile( - val file: File, -) { - public actual override fun toString(): String = name - - public actual companion object -} - -public actual val PlatformFile.name: String - get() = file.name - -public actual val PlatformFile.extension: String - get() = name.substringAfterLast(".", "") - -public actual val PlatformFile.nameWithoutExtension: String - get() = name.substringBeforeLast(".", name) - -@OptIn(ExperimentalWasmJsInterop::class) -public actual fun PlatformFile.size(): Long = - file.size.toDouble().toLong() - -@OptIn(ExperimentalWasmJsInterop::class) -public actual suspend fun PlatformFile.readBytes(): ByteArray = withContext(Dispatchers.Main) { - suspendCoroutine { continuation -> - val reader = FileReader() - reader.onload = { event -> - try { - // Read the file as an ArrayBuffer - val arrayBuffer = event - .target - ?.unsafeCast() - ?.result - ?.unsafeCast() - ?: throw FileKitException("Could not read file") - - // Convert the ArrayBuffer to a ByteArray - val bytes = Uint8Array(arrayBuffer) - - // Copy the bytes into a ByteArray - val byteArray = ByteArray(bytes.length) - for (i in 0 until bytes.length) { - byteArray[i] = bytes[i] - } - - // Return the ByteArray - continuation.resume(byteArray) - } catch (e: Exception) { - continuation.resumeWithException(e) - } - } - - // Read the file as an ArrayBuffer - reader.readAsArrayBuffer(file) - } -} - -public actual fun PlatformFile.mimeType(): MimeType? = - takeIf { file.type.isNotBlank() } - ?.let { MimeType.parse(file.type) } - -@OptIn(ExperimentalTime::class, ExperimentalWasmJsInterop::class) -public actual fun PlatformFile.lastModified(): Instant { - val ts = file.unsafeCast() - return Instant.fromEpochMilliseconds(ts.lastModified.toDouble().toLong()) -} - -@OptIn(ExperimentalWasmJsInterop::class) -private open external class File( - fileBits: JsArray, // BufferSource|Blob|String - fileName: String, - options: FilePropertyBag = definedExternally, -) : Blob, - JsAny { - val name: String - val lastModified: JsNumber -} diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt new file mode 100644 index 00000000..3a92ea43 --- /dev/null +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt @@ -0,0 +1,38 @@ +package io.github.vinceglb.filekit + +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.toDouble +import kotlin.time.Instant + +/** + * Implementation of WebFileHandle + * Using just the W3C File object + */ +@OptIn(ExperimentalWasmJsInterop::class) +public class FileHandleFile( + public val file: File, + public val parent: WebFileHandle? = null, +) : WebFileHandle { + override val name: String + get() = file.name + override val type: String + get() = file.type + override val size: Long + get() = file.size.toDouble().toLong() + override val path: String + get() = file.webkitRelativePath ?: "" + public override val isDirectory: Boolean = false + public override val isRegularFile: Boolean = true + override val lastModified: Instant + get() = Instant.fromEpochMilliseconds(file.lastModified.toDouble().toLong()) + + override fun getFile(): File = file + + override fun getParent(): PlatformFile? = + parent?.let { PlatformFile(fh = it) } + + override fun list(): List = + emptyList() +} + +public fun PlatformFile(file: File): PlatformFile = PlatformFile(fh = FileHandleFile(file)) diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt index 51de3ee8..d5b0696f 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt @@ -1,8 +1,151 @@ package io.github.vinceglb.filekit -public actual suspend fun PlatformFile.readString(): String = - readBytes().decodeToString() +import io.github.vinceglb.filekit.exceptions.FileKitException +import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.get +import org.w3c.files.Blob +import org.w3c.files.FilePropertyBag +import org.w3c.files.FileReader +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.JsArray +import kotlin.js.JsNumber +import kotlin.js.definedExternally +import kotlin.js.unsafeCast +import kotlin.time.Instant + +public interface WebFileHandle { + public val name: String + public val type: String + public val size: Long + public val isDirectory: Boolean + public val isRegularFile: Boolean + public val lastModified: Instant + public val path: String + + public fun getFile(): File + + public fun getParent(): PlatformFile? + + public fun list(): List +} + +/** + * Represents a file on the Web platform. + * @property fh An implementation of the WasmFileHandle. + */ +@Serializable(with = PlatformFileSerializer::class) +public actual data class PlatformFile( + val fh: WebFileHandle, +) { + @OptIn(ExperimentalWasmJsInterop::class) + public val file: org.w3c.files.File + get() = fh.getFile().unsafeCast() + + public actual override fun toString(): String = name + + public actual companion object +} + +public actual val PlatformFile.name: String + get() = fh.name + +public actual val PlatformFile.extension: String + get() = name.substringAfterLast(".", "") + +public actual val PlatformFile.nameWithoutExtension: String + get() = name.substringBeforeLast(".", name) + +public actual fun PlatformFile.size(): Long = + fh.size + +public actual val PlatformFile.path: String + get() = fh.path + +public actual fun PlatformFile.mimeType(): MimeType? = + takeIf { fh.type.isNotBlank() } + ?.let { MimeType.parse(fh.type) } + +public actual fun PlatformFile.lastModified(): Instant = + fh.lastModified + +public actual fun PlatformFile.parent(): PlatformFile? = + fh.getParent() + +public actual fun PlatformFile.isRegularFile(): Boolean = + fh.isRegularFile + +public actual fun PlatformFile.isDirectory(): Boolean = + fh.isDirectory + +public actual inline fun PlatformFile.list(block: (List) -> Unit) { + block(fh.list()) +} + +public actual fun PlatformFile.list(): List = + fh.list() public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true public actual fun PlatformFile.stopAccessingSecurityScopedResource() {} + +@OptIn(ExperimentalWasmJsInterop::class) +public actual suspend fun PlatformFile.readBytes(): ByteArray = withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + val reader = FileReader() + reader.onload = { event -> + try { + // Read the file as an ArrayBuffer + val arrayBuffer = event + .target + ?.unsafeCast() + ?.result + ?.unsafeCast() + ?: throw FileKitException("Could not read file") + + // Convert the ArrayBuffer to a ByteArray + val bytes = Uint8Array(arrayBuffer) + + // Copy the bytes into a ByteArray + val byteArray = ByteArray(bytes.length) + for (i in 0 until bytes.length) { + byteArray[i] = bytes[i] + } + + // Return the ByteArray + continuation.resume(byteArray) + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + + // Read the file as an ArrayBuffer + reader.readAsArrayBuffer(fh.getFile()) + } +} + +public actual suspend fun PlatformFile.readString(): String = + readBytes().decodeToString() + +/** + * + */ +@OptIn(ExperimentalWasmJsInterop::class) +public open external class File( + fileBits: JsArray, // BufferSource|Blob|String + fileName: String, + options: FilePropertyBag = definedExternally, +) : Blob, + JsAny { + public val name: String + public val lastModified: JsNumber + public val webkitRelativePath: String? +} diff --git a/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt similarity index 100% rename from filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt rename to filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index 9792b678..ffe57080 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -71,3 +71,18 @@ internal expect fun rememberPlatformFilePickerLau dialogSettings: FileKitDialogSettings, onResult: (ConsumedResult) -> Unit, ): PickerResultLauncher + +/** + * Creates and remembers a [PickerResultLauncher] for picking a directory. + * + * @param directory The initial directory. Supported on desktop platforms. + * @param dialogSettings Platform-specific settings for the dialog. + * @param onResult Callback invoked with the picked directory, or null if cancelled. + * @return A [PickerResultLauncher] that can be used to launch the picker. + */ +@Composable +public expect fun rememberDirectoryPickerLauncher( + directory: PlatformFile? = null, + dialogSettings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), + onResult: (PlatformFile?) -> Unit, +): PickerResultLauncher diff --git a/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt b/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt index 659ef2d6..f6a59823 100644 --- a/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt +++ b/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt @@ -4,21 +4,6 @@ import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings -/** - * Creates and remembers a [PickerResultLauncher] for picking a directory. - * - * @param directory The initial directory. Supported on desktop platforms. - * @param dialogSettings Platform-specific settings for the dialog. - * @param onResult Callback invoked with the picked directory, or null if cancelled. - * @return A [PickerResultLauncher] that can be used to launch the picker. - */ -@Composable -public expect fun rememberDirectoryPickerLauncher( - directory: PlatformFile? = null, - dialogSettings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), - onResult: (PlatformFile?) -> Unit, -): PickerResultLauncher - /** * Creates and remembers a [SaverResultLauncher] for saving a file. * diff --git a/filekit-dialogs-compose/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.web.kt b/filekit-dialogs-compose/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.web.kt index a33eb5d6..f618b2cf 100644 --- a/filekit-dialogs-compose/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.web.kt +++ b/filekit-dialogs-compose/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.web.kt @@ -8,8 +8,48 @@ import androidx.compose.runtime.rememberUpdatedState import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings -import io.github.vinceglb.filekit.download +import io.github.vinceglb.filekit.dialogs.openDirectoryPicker import kotlinx.coroutines.launch @Composable internal actual fun InitFileKit() {} + +/** + * Creates and remembers a [PickerResultLauncher] for picking a directory. + * + * @param directory The initial directory. Supported on desktop platforms. + * @param dialogSettings Platform-specific settings for the dialog. + * @param onResult Callback invoked with the picked directory, or null if cancelled. + * @return A [PickerResultLauncher] that can be used to launch the picker. + */ +@Composable +public actual fun rememberDirectoryPickerLauncher( + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, + onResult: (PlatformFile?) -> Unit, +): PickerResultLauncher { + // Init FileKit + InitFileKit() + + // Coroutine + val coroutineScope = rememberCoroutineScope() + + // Updated state + val currentDirectory by rememberUpdatedState(directory) + val currentOnResult by rememberUpdatedState(onResult) + + // FileKit launcher + val returnedLauncher = remember { + PickerResultLauncher { + coroutineScope.launch { + val result = FileKit.openDirectoryPicker( + directory = currentDirectory, + dialogSettings = dialogSettings, + ) + currentOnResult(result) + } + } + } + + return returnedLauncher +} diff --git a/filekit-dialogs/build.gradle.kts b/filekit-dialogs/build.gradle.kts index 8f2b0c89..6db6669f 100644 --- a/filekit-dialogs/build.gradle.kts +++ b/filekit-dialogs/build.gradle.kts @@ -31,7 +31,7 @@ kotlin { implementation(libs.dbus.java.transport.native.unixsocket) } - wasmJsMain.dependencies { + webMain.dependencies { implementation(libs.kotlinx.browser) } } diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt index 5ff74393..b386c721 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt @@ -55,3 +55,15 @@ internal expect suspend fun FileKit.platformOpenFilePicker( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, ): Flow>> + +/** + * Opens a directory picker dialog. + * + * @param directory The initial directory. Supported on desktop platforms. + * @param dialogSettings Platform-specific settings for the dialog. + * @return The picked directory as a [PlatformFile], or null if cancelled. + */ +public expect suspend fun FileKit.openDirectoryPicker( + directory: PlatformFile? = null, + dialogSettings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), +): PlatformFile? diff --git a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt b/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt index ff28a83d..4e6f713a 100644 --- a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt +++ b/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt @@ -1,24 +1,24 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.File import io.github.vinceglb.filekit.PlatformFile import kotlinx.browser.document import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext -import org.w3c.dom.HTMLInputElement +import org.w3c.dom.HTMLElement import org.w3c.dom.asList +import org.w3c.files.FileList import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine -internal actual suspend fun FileKit.platformOpenFilePicker( +@OptIn(ExperimentalWasmJsInterop::class) +internal actual suspend fun platformOpenFilePickerWeb( type: FileKitType, - mode: PickerMode, - directory: PlatformFile?, - dialogSettings: FileKitDialogSettings, -): Flow>> { - val files: List? = withContext(Dispatchers.Default) { + multipleMode: Boolean, // select multiple files + directoryMode: Boolean, // select a directory +): List? { + val files = withContext(Dispatchers.Default) { suspendCoroutine { continuation -> // Create input element val input = document.createElement("input") as HTMLInputElement @@ -46,7 +46,8 @@ internal actual suspend fun FileKit.platformOpenFilePicker( } // Set the multiple attribute - multiple = mode is PickerMode.Multiple + multiple = multipleMode + webkitdirectory = directoryMode // max is not supported for file inputs } @@ -59,6 +60,7 @@ internal actual suspend fun FileKit.platformOpenFilePicker( ?.unsafeCast() ?.files ?.asList() + ?.map { it.unsafeCast() } // Return the result val result = files?.map { PlatformFile(it) } @@ -80,5 +82,14 @@ internal actual suspend fun FileKit.platformOpenFilePicker( } } - return files.toPickerStateFlow() + return files +} + +public abstract external class HTMLInputElement : HTMLElement { + public open var accept: String + public open val files: FileList? + public open var multiple: Boolean + public open var webkitdirectory: Boolean + public open var type: String + public open var value: String } diff --git a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.js.kt b/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.js.kt deleted file mode 100644 index 83e1ddeb..00000000 --- a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.js.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.vinceglb.filekit.dialogs - -/** - * JS implementation of [FileKitDialogSettings]. - * Currently, there are no specific settings for JS file dialogs. - */ -public actual class FileKitDialogSettings { - public actual companion object { - /** - * Creates a default instance of [FileKitDialogSettings]. - */ - public actual fun createDefault(): FileKitDialogSettings = FileKitDialogSettings() - } -} diff --git a/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt b/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt index a4e3c52f..c11dfe63 100644 --- a/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt +++ b/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt @@ -3,18 +3,6 @@ package io.github.vinceglb.filekit.dialogs import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile -/** - * Opens a directory picker dialog. - * - * @param directory The initial directory. Supported on desktop platforms. - * @param dialogSettings Platform-specific settings for the dialog. - * @return The picked directory as a [PlatformFile], or null if cancelled. - */ -public expect suspend fun FileKit.openDirectoryPicker( - directory: PlatformFile? = null, - dialogSettings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), -): PlatformFile? - /** * Opens a file saver dialog. * diff --git a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt b/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt index 2d479594..4e6f713a 100644 --- a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt +++ b/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt @@ -1,24 +1,23 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.File import io.github.vinceglb.filekit.PlatformFile import kotlinx.browser.document import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext -import org.w3c.dom.HTMLInputElement +import org.w3c.dom.HTMLElement import org.w3c.dom.asList +import org.w3c.files.FileList import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine @OptIn(ExperimentalWasmJsInterop::class) -internal actual suspend fun FileKit.platformOpenFilePicker( +internal actual suspend fun platformOpenFilePickerWeb( type: FileKitType, - mode: PickerMode, - directory: PlatformFile?, - dialogSettings: FileKitDialogSettings, -): Flow>> { + multipleMode: Boolean, // select multiple files + directoryMode: Boolean, // select a directory +): List? { val files = withContext(Dispatchers.Default) { suspendCoroutine { continuation -> // Create input element @@ -47,7 +46,8 @@ internal actual suspend fun FileKit.platformOpenFilePicker( } // Set the multiple attribute - multiple = mode is PickerMode.Multiple + multiple = multipleMode + webkitdirectory = directoryMode // max is not supported for file inputs } @@ -60,6 +60,7 @@ internal actual suspend fun FileKit.platformOpenFilePicker( ?.unsafeCast() ?.files ?.asList() + ?.map { it.unsafeCast() } // Return the result val result = files?.map { PlatformFile(it) } @@ -81,5 +82,14 @@ internal actual suspend fun FileKit.platformOpenFilePicker( } } - return files.toPickerStateFlow() + return files +} + +public abstract external class HTMLInputElement : HTMLElement { + public open var accept: String + public open val files: FileList? + public open var multiple: Boolean + public open var webkitdirectory: Boolean + public open var type: String + public open var value: String } diff --git a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt b/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt deleted file mode 100644 index a0aa1b1c..00000000 --- a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.vinceglb.filekit.dialogs - -public class FileKitFileSaverWithoutBytesException : - IllegalArgumentException( - "Bytes must not be null on Web platform. Use isSaveFileWithoutBytesSupported() " + - "to check if the platform supports saving files without bytes.", - ) diff --git a/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt b/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt index e1288ace..86dda697 100644 --- a/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt +++ b/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt @@ -9,5 +9,5 @@ import org.w3c.files.FilePropertyBag internal actual fun createTestPlatformFile(name: String): PlatformFile { val jsArray = name.encodeToByteArray().toJsArray() val file = File(jsArray, name, FilePropertyBag(type = "text/plain")) - return PlatformFile(file) + return PlatformFile(file.unsafeCast()) } diff --git a/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt new file mode 100644 index 00000000..3dfb18c8 --- /dev/null +++ b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt @@ -0,0 +1,103 @@ +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.File +import io.github.vinceglb.filekit.FileHandleFile +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.WebFileHandle +import io.github.vinceglb.filekit.path +import kotlinx.coroutines.flow.Flow +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.time.Instant + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual suspend fun FileKit.platformOpenFilePicker( + type: FileKitType, + mode: PickerMode, + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, +): Flow>> = + platformOpenFilePickerWeb( + type = type, + multipleMode = mode is PickerMode.Multiple, + directoryMode = false, + ).toPickerStateFlow() + +public actual suspend fun FileKit.openDirectoryPicker( + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, +): PlatformFile? { + val fileList = platformOpenFilePickerWeb( + type = FileKitType.File(), + multipleMode = true, + directoryMode = true, + ) + val rootDirectory = FileHandleVirtualDirectory( + name = "/", + path = "", + lastModified = Instant.fromEpochMilliseconds(0), + parent = null, + list = mutableListOf(), + ) + fileList?.forEach { file -> + val pathOnlyPart = file.path.substringBeforeLast(delimiter = '/', missingDelimiterValue = "") // Exclude the file name + val directory = rootDirectory.findOrCreateRelativeDirectory(pathOnlyPart) + val file = FileHandleFile(file = file.fh.getFile(), parent = directory) + directory.list.add(PlatformFile(file)) + } + return PlatformFile(rootDirectory) +} + +private fun FileHandleVirtualDirectory.findOrCreateRelativeDirectory(path: String): FileHandleVirtualDirectory { + val self = this + return if (path.contains('/')) { + val childDirectory = path.substringBefore('/') + val child = findOrCreateRelativeDirectory(childDirectory) + child.findOrCreateRelativeDirectory(path.substringAfter('/')) + } else { // not a children so we just check if this directory exists + val dirName = path + list.map { it.fh }.filterIsInstance().find { item -> + item.name == dirName + } ?: FileHandleVirtualDirectory( + name = dirName, + path = "${self.path}/$dirName", + lastModified = Instant.fromEpochMilliseconds(0), + parent = self, + list = mutableListOf(), + ).also { newDir -> + list.add(PlatformFile(newDir)) + } + } +} + +internal expect suspend fun platformOpenFilePickerWeb( + type: FileKitType, + multipleMode: Boolean, // select multiple files + directoryMode: Boolean, // select a directory +): List? + +/** + * Virtual directory + */ +public class FileHandleVirtualDirectory( + public override val name: String, + public override val path: String, + override val lastModified: Instant, + public val parent: FileHandleVirtualDirectory?, + public val list: MutableList, +) : WebFileHandle { + override val type: String = "" + override val size: Long = 0 + override val isDirectory: Boolean = true + override val isRegularFile: Boolean = false + + override fun getFile(): File { + TODO("Not supported!") + } + + override fun getParent(): PlatformFile? = + parent?.let { PlatformFile(it) } + + override fun list(): List = + list +} diff --git a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.wasmJs.kt b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.wasmJs.kt similarity index 100% rename from filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.wasmJs.kt rename to filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.wasmJs.kt diff --git a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt similarity index 100% rename from filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt rename to filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt index 61bd4ea4..204552c6 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt @@ -21,11 +21,11 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.sample.shared.ui.components.AppDottedBorderCard import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerResultsCard import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerSelectionButton -import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerSupportCard import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerTopBar import io.github.vinceglb.filekit.sample.shared.ui.components.AppScreenHeader import io.github.vinceglb.filekit.sample.shared.ui.components.AppScreenHeaderButtonState @@ -74,13 +74,8 @@ private fun DirectoryPickerScreen( startDirectory = directory } } - val isSupported = directoryLauncher.isSupported - val primaryButtonText = if (isSupported) "Pick Directory" else "Directory Unavailable" fun openDirectoryPicker() { - if (!isSupported) { - return - } buttonState = AppScreenHeaderButtonState.Loading directoryLauncher.launch() } @@ -105,8 +100,8 @@ private fun DirectoryPickerScreen( title = "Directory Picker", subtitle = "Select folders with the native picker on desktop and mobile", documentationUrl = "https://filekit.mintlify.app/dialogs/directory-picker", - primaryButtonText = primaryButtonText, - primaryButtonEnabled = isSupported, + primaryButtonText = "Pick Directory", + primaryButtonEnabled = true, primaryButtonState = buttonState, onPrimaryButtonClick = ::openDirectoryPicker, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), @@ -116,23 +111,13 @@ private fun DirectoryPickerScreen( item { DirectoryPickerSettingsCard( startDirectoryName = startDirectory?.name, - isSupported = isSupported, + isSupported = true, onPickStartDirectory = startDirectoryLauncher::launch, onClearStartDirectory = { startDirectory = null }, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), ) } - if (!isSupported) { - item { - AppPickerSupportCard( - text = "Directory picker is available on Android, iOS, and desktop targets.", - icon = LucideIcons.Folder, - modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), - ) - } - } - item { AppPickerResultsCard( files = pickedDirectories, diff --git a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filedetails/components/FileDetailsMetadata.web.kt b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filedetails/components/FileDetailsMetadata.web.kt index 28a7ef13..e76545c6 100644 --- a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filedetails/components/FileDetailsMetadata.web.kt +++ b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filedetails/components/FileDetailsMetadata.web.kt @@ -2,11 +2,15 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filedetails.componen import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.extension +import io.github.vinceglb.filekit.isRegularFile import io.github.vinceglb.filekit.lastModified import io.github.vinceglb.filekit.mimeType import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.nameWithoutExtension +import io.github.vinceglb.filekit.parent +import io.github.vinceglb.filekit.path import io.github.vinceglb.filekit.sample.shared.util.formatBytes +import io.github.vinceglb.filekit.sample.shared.util.isDirectory import io.github.vinceglb.filekit.size internal actual fun PlatformFile.toMetadataItems(): List = listOf( @@ -17,21 +21,61 @@ internal actual fun PlatformFile.toMetadataItems(): List = lis FileMetadataItem( label = "Name without Extension", value = this.nameWithoutExtension, + hidden = true, ), FileMetadataItem( label = "Extension", value = this.extension, + hidden = true, ), FileMetadataItem( label = "Size", - value = this.size().formatBytes(), + value = "${this.size().formatBytes()} - (${this.size()} bytes)", ), FileMetadataItem( label = "Mime Type", value = this.mimeType().toString(), ), + FileMetadataItem( + label = "Parent", + value = this.runCatching { parent()?.name }.getOrNull() ?: "N/A", + ), + /*FileMetadataItem( + label = "Created At", + value = this.createdAt().toString(), + hidden = true, + ),*/ FileMetadataItem( label = "Updated At", value = this.lastModified().toString(), ), + FileMetadataItem( + label = "Path", + value = this.path, + ), + /*FileMetadataItem( + label = "Absolute Path", + value = this.absolutePath(), + hidden = true, + ),*/ + FileMetadataItem( + label = "Is a Directory", + value = this.isDirectory().toString(), + hidden = true, + ), + /*FileMetadataItem( + label = "Is absolute", + value = this.isAbsolute().toString(), + hidden = true, + ),*/ + FileMetadataItem( + label = "Is a Regular File", + value = this.isRegularFile().toString(), + hidden = true, + ), + /*FileMetadataItem( + label = "Exists", + value = this.exists().toString(), + hidden = true, + ),*/ ) From d8aaaf44cef536653327caf674f6374a244604d6 Mon Sep 17 00:00:00 2001 From: Timo Drick Date: Sun, 29 Mar 2026 14:40:19 +0200 Subject: [PATCH 2/4] Renamed W3C objects so it is more clear that it is not the imported one from org.w3c Added backward compatibility constructor in PlatformFile --- .../github/vinceglb/filekit/FileHandleFile.kt | 6 ++--- .../vinceglb/filekit/PlatformFile.web.kt | 23 +++++++++++++------ .../vinceglb/filekit/dialogs/FileKit.js.kt | 4 ++-- .../filekit/dialogs/FileKit.wasmJs.kt | 11 +++++---- .../dialogs/FileKitModeMaxItemsTest.wasmJs.kt | 3 ++- .../vinceglb/filekit/dialogs/FileKit.web.kt | 14 +++++------ 6 files changed, 36 insertions(+), 25 deletions(-) diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt index 3a92ea43..0ff887a9 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt @@ -10,7 +10,7 @@ import kotlin.time.Instant */ @OptIn(ExperimentalWasmJsInterop::class) public class FileHandleFile( - public val file: File, + public val file: FileExt, public val parent: WebFileHandle? = null, ) : WebFileHandle { override val name: String @@ -26,7 +26,7 @@ public class FileHandleFile( override val lastModified: Instant get() = Instant.fromEpochMilliseconds(file.lastModified.toDouble().toLong()) - override fun getFile(): File = file + override fun getFile(): FileExt = file override fun getParent(): PlatformFile? = parent?.let { PlatformFile(fh = it) } @@ -35,4 +35,4 @@ public class FileHandleFile( emptyList() } -public fun PlatformFile(file: File): PlatformFile = PlatformFile(fh = FileHandleFile(file)) +public fun PlatformFile(file: FileExt): PlatformFile = PlatformFile(fh = FileHandleFile(file)) diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt index d5b0696f..6e0f5310 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt @@ -9,6 +9,8 @@ import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.Uint8Array import org.khronos.webgl.get import org.w3c.files.Blob +import org.w3c.files.BlobPropertyBag +import org.w3c.files.File import org.w3c.files.FilePropertyBag import org.w3c.files.FileReader import kotlin.coroutines.resume @@ -17,7 +19,9 @@ import kotlin.coroutines.suspendCoroutine import kotlin.js.ExperimentalWasmJsInterop import kotlin.js.JsAny import kotlin.js.JsArray +import kotlin.js.JsName import kotlin.js.JsNumber +import kotlin.js.Promise import kotlin.js.definedExternally import kotlin.js.unsafeCast import kotlin.time.Instant @@ -31,7 +35,7 @@ public interface WebFileHandle { public val lastModified: Instant public val path: String - public fun getFile(): File + public fun getFile(): FileExt public fun getParent(): PlatformFile? @@ -44,11 +48,15 @@ public interface WebFileHandle { */ @Serializable(with = PlatformFileSerializer::class) public actual data class PlatformFile( - val fh: WebFileHandle, + internal val fh: WebFileHandle, ) { + @Deprecated("Please do not use this anymore to create an instance.") @OptIn(ExperimentalWasmJsInterop::class) - public val file: org.w3c.files.File - get() = fh.getFile().unsafeCast() + public constructor(file: File) : this(FileHandleFile(file.unsafeCast())) + + @OptIn(ExperimentalWasmJsInterop::class) + public val file: File + get() = fh.getFile().unsafeCast() public actual override fun toString(): String = name @@ -87,7 +95,7 @@ public actual fun PlatformFile.isDirectory(): Boolean = fh.isDirectory public actual inline fun PlatformFile.list(block: (List) -> Unit) { - block(fh.list()) + block(list()) } public actual fun PlatformFile.list(): List = @@ -128,7 +136,7 @@ public actual suspend fun PlatformFile.readBytes(): ByteArray = withContext(Disp } // Read the file as an ArrayBuffer - reader.readAsArrayBuffer(fh.getFile()) + reader.readAsArrayBuffer(fh.getFile().unsafeCast()) } } @@ -139,7 +147,8 @@ public actual suspend fun PlatformFile.readString(): String = * */ @OptIn(ExperimentalWasmJsInterop::class) -public open external class File( +@JsName("File") +public open external class FileExt( fileBits: JsArray, // BufferSource|Blob|String fileName: String, options: FilePropertyBag = definedExternally, diff --git a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt b/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt index 4e6f713a..f5c07414 100644 --- a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt +++ b/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt @@ -1,6 +1,6 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.File +import io.github.vinceglb.filekit.FileExt import io.github.vinceglb.filekit.PlatformFile import kotlinx.browser.document import kotlinx.coroutines.Dispatchers @@ -60,7 +60,7 @@ internal actual suspend fun platformOpenFilePickerWeb( ?.unsafeCast() ?.files ?.asList() - ?.map { it.unsafeCast() } + ?.map { it.unsafeCast() } // Return the result val result = files?.map { PlatformFile(it) } diff --git a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt b/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt index 4e6f713a..7e221c90 100644 --- a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt +++ b/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt @@ -1,6 +1,6 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.File +import io.github.vinceglb.filekit.FileExt import io.github.vinceglb.filekit.PlatformFile import kotlinx.browser.document import kotlinx.coroutines.Dispatchers @@ -21,7 +21,7 @@ internal actual suspend fun platformOpenFilePickerWeb( val files = withContext(Dispatchers.Default) { suspendCoroutine { continuation -> // Create input element - val input = document.createElement("input") as HTMLInputElement + val input = document.createElement("input") as HTMLInputElementExt // Visually hide the element input.style.display = "none" @@ -57,10 +57,10 @@ internal actual suspend fun platformOpenFilePickerWeb( try { // Get the selected files val files = event.target - ?.unsafeCast() + ?.unsafeCast() ?.files ?.asList() - ?.map { it.unsafeCast() } + ?.map { it.unsafeCast() } // Return the result val result = files?.map { PlatformFile(it) } @@ -85,7 +85,8 @@ internal actual suspend fun platformOpenFilePickerWeb( return files } -public abstract external class HTMLInputElement : HTMLElement { +@JsName("HTMLInputElement") +public abstract external class HTMLInputElementExt : HTMLElement { public open var accept: String public open val files: FileList? public open var multiple: Boolean diff --git a/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt b/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt index 86dda697..f505e2ef 100644 --- a/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt +++ b/filekit-dialogs/src/wasmJsTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.wasmJs.kt @@ -1,5 +1,6 @@ package io.github.vinceglb.filekit.dialogs +import io.github.vinceglb.filekit.FileExt import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.utils.toJsArray import org.w3c.files.File @@ -9,5 +10,5 @@ import org.w3c.files.FilePropertyBag internal actual fun createTestPlatformFile(name: String): PlatformFile { val jsArray = name.encodeToByteArray().toJsArray() val file = File(jsArray, name, FilePropertyBag(type = "text/plain")) - return PlatformFile(file.unsafeCast()) + return PlatformFile(file.unsafeCast()) } diff --git a/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt index 3dfb18c8..da68f0cd 100644 --- a/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt +++ b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt @@ -1,6 +1,6 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.File +import io.github.vinceglb.filekit.FileExt import io.github.vinceglb.filekit.FileHandleFile import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile @@ -79,19 +79,19 @@ internal expect suspend fun platformOpenFilePickerWeb( /** * Virtual directory */ -public class FileHandleVirtualDirectory( - public override val name: String, - public override val path: String, +internal class FileHandleVirtualDirectory( + override val name: String, + override val path: String, override val lastModified: Instant, - public val parent: FileHandleVirtualDirectory?, - public val list: MutableList, + val parent: FileHandleVirtualDirectory?, + val list: MutableList, ) : WebFileHandle { override val type: String = "" override val size: Long = 0 override val isDirectory: Boolean = true override val isRegularFile: Boolean = false - override fun getFile(): File { + override fun getFile(): FileExt { TODO("Not supported!") } From 33e7d5a16a99c4914f672115e078d7722faa91a5 Mon Sep 17 00:00:00 2001 From: Timo Drick Date: Mon, 30 Mar 2026 11:49:28 +0200 Subject: [PATCH 3/4] Made the WebFileHandle internal --- .../github/vinceglb/filekit/FileHandleFile.kt | 6 ++--- .../vinceglb/filekit/PlatformFile.web.kt | 12 +++++----- .../vinceglb/filekit/dialogs/FileKit.js.kt | 6 ++--- .../filekit/dialogs/FileKit.wasmJs.kt | 5 +++-- .../vinceglb/filekit/dialogs/FileKit.web.kt | 22 +++++++++---------- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt index 0ff887a9..ea8dd2d1 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileHandleFile.kt @@ -28,10 +28,10 @@ public class FileHandleFile( override fun getFile(): FileExt = file - override fun getParent(): PlatformFile? = - parent?.let { PlatformFile(fh = it) } + override fun getParent(): WebFileHandle? = + parent - override fun list(): List = + override fun list(): List = emptyList() } diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt index 6e0f5310..81c15dfb 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt @@ -9,7 +9,6 @@ import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.Uint8Array import org.khronos.webgl.get import org.w3c.files.Blob -import org.w3c.files.BlobPropertyBag import org.w3c.files.File import org.w3c.files.FilePropertyBag import org.w3c.files.FileReader @@ -21,7 +20,6 @@ import kotlin.js.JsAny import kotlin.js.JsArray import kotlin.js.JsName import kotlin.js.JsNumber -import kotlin.js.Promise import kotlin.js.definedExternally import kotlin.js.unsafeCast import kotlin.time.Instant @@ -37,11 +35,13 @@ public interface WebFileHandle { public fun getFile(): FileExt - public fun getParent(): PlatformFile? + public fun getParent(): WebFileHandle? - public fun list(): List + public fun list(): List } +public fun WebFileHandle.toPlatformFile(): PlatformFile = PlatformFile(this) + /** * Represents a file on the Web platform. * @property fh An implementation of the WasmFileHandle. @@ -86,7 +86,7 @@ public actual fun PlatformFile.lastModified(): Instant = fh.lastModified public actual fun PlatformFile.parent(): PlatformFile? = - fh.getParent() + fh.getParent()?.toPlatformFile() public actual fun PlatformFile.isRegularFile(): Boolean = fh.isRegularFile @@ -99,7 +99,7 @@ public actual inline fun PlatformFile.list(block: (List) -> Unit) } public actual fun PlatformFile.list(): List = - fh.list() + fh.list().map { it.toPlatformFile() } public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true diff --git a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt b/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt index f5c07414..a7f69abb 100644 --- a/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt +++ b/filekit-dialogs/src/jsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.js.kt @@ -1,7 +1,7 @@ package io.github.vinceglb.filekit.dialogs import io.github.vinceglb.filekit.FileExt -import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.FileHandleFile import kotlinx.browser.document import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -17,7 +17,7 @@ internal actual suspend fun platformOpenFilePickerWeb( type: FileKitType, multipleMode: Boolean, // select multiple files directoryMode: Boolean, // select a directory -): List? { +): List? { val files = withContext(Dispatchers.Default) { suspendCoroutine { continuation -> // Create input element @@ -63,7 +63,7 @@ internal actual suspend fun platformOpenFilePickerWeb( ?.map { it.unsafeCast() } // Return the result - val result = files?.map { PlatformFile(it) } + val result = files?.map { FileHandleFile(it) } continuation.resume(result) } catch (e: Throwable) { continuation.resumeWithException(e) diff --git a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt b/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt index 7e221c90..4e8152ab 100644 --- a/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt +++ b/filekit-dialogs/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.wasmJs.kt @@ -1,6 +1,7 @@ package io.github.vinceglb.filekit.dialogs import io.github.vinceglb.filekit.FileExt +import io.github.vinceglb.filekit.FileHandleFile import io.github.vinceglb.filekit.PlatformFile import kotlinx.browser.document import kotlinx.coroutines.Dispatchers @@ -17,7 +18,7 @@ internal actual suspend fun platformOpenFilePickerWeb( type: FileKitType, multipleMode: Boolean, // select multiple files directoryMode: Boolean, // select a directory -): List? { +): List? { val files = withContext(Dispatchers.Default) { suspendCoroutine { continuation -> // Create input element @@ -63,7 +64,7 @@ internal actual suspend fun platformOpenFilePickerWeb( ?.map { it.unsafeCast() } // Return the result - val result = files?.map { PlatformFile(it) } + val result = files?.map { FileHandleFile(it) } continuation.resume(result) } catch (e: Throwable) { continuation.resumeWithException(e) diff --git a/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt index da68f0cd..353882da 100644 --- a/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt +++ b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.web.kt @@ -5,7 +5,7 @@ import io.github.vinceglb.filekit.FileHandleFile import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.WebFileHandle -import io.github.vinceglb.filekit.path +import io.github.vinceglb.filekit.toPlatformFile import kotlinx.coroutines.flow.Flow import kotlin.js.ExperimentalWasmJsInterop import kotlin.time.Instant @@ -21,7 +21,7 @@ internal actual suspend fun FileKit.platformOpenFilePicker( type = type, multipleMode = mode is PickerMode.Multiple, directoryMode = false, - ).toPickerStateFlow() + )?.map { it.toPlatformFile() }.toPickerStateFlow() public actual suspend fun FileKit.openDirectoryPicker( directory: PlatformFile?, @@ -42,8 +42,8 @@ public actual suspend fun FileKit.openDirectoryPicker( fileList?.forEach { file -> val pathOnlyPart = file.path.substringBeforeLast(delimiter = '/', missingDelimiterValue = "") // Exclude the file name val directory = rootDirectory.findOrCreateRelativeDirectory(pathOnlyPart) - val file = FileHandleFile(file = file.fh.getFile(), parent = directory) - directory.list.add(PlatformFile(file)) + val file = FileHandleFile(file = file.getFile(), parent = directory) + directory.list.add(file) } return PlatformFile(rootDirectory) } @@ -56,7 +56,7 @@ private fun FileHandleVirtualDirectory.findOrCreateRelativeDirectory(path: Strin child.findOrCreateRelativeDirectory(path.substringAfter('/')) } else { // not a children so we just check if this directory exists val dirName = path - list.map { it.fh }.filterIsInstance().find { item -> + list.filterIsInstance().find { item -> item.name == dirName } ?: FileHandleVirtualDirectory( name = dirName, @@ -65,7 +65,7 @@ private fun FileHandleVirtualDirectory.findOrCreateRelativeDirectory(path: Strin parent = self, list = mutableListOf(), ).also { newDir -> - list.add(PlatformFile(newDir)) + list.add(newDir) } } } @@ -74,7 +74,7 @@ internal expect suspend fun platformOpenFilePickerWeb( type: FileKitType, multipleMode: Boolean, // select multiple files directoryMode: Boolean, // select a directory -): List? +): List? /** * Virtual directory @@ -84,7 +84,7 @@ internal class FileHandleVirtualDirectory( override val path: String, override val lastModified: Instant, val parent: FileHandleVirtualDirectory?, - val list: MutableList, + val list: MutableList, ) : WebFileHandle { override val type: String = "" override val size: Long = 0 @@ -95,9 +95,9 @@ internal class FileHandleVirtualDirectory( TODO("Not supported!") } - override fun getParent(): PlatformFile? = - parent?.let { PlatformFile(it) } + override fun getParent(): WebFileHandle? = + parent - override fun list(): List = + override fun list(): List = list } From bb16402a48effe469c8485b7bcb29704361ee838 Mon Sep 17 00:00:00 2001 From: Timo Drick Date: Mon, 30 Mar 2026 17:18:33 +0200 Subject: [PATCH 4/4] Fixed problem with unsafeCast. Not sure why the assemble task was complaining. --- .../kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt index 81c15dfb..2ee2ccd1 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt @@ -52,11 +52,11 @@ public actual data class PlatformFile( ) { @Deprecated("Please do not use this anymore to create an instance.") @OptIn(ExperimentalWasmJsInterop::class) - public constructor(file: File) : this(FileHandleFile(file.unsafeCast())) + public constructor(file: File) : this(FileHandleFile(file.unsafeCast())) @OptIn(ExperimentalWasmJsInterop::class) public val file: File - get() = fh.getFile().unsafeCast() + get() = fh.getFile().unsafeCast() public actual override fun toString(): String = name @@ -136,7 +136,7 @@ public actual suspend fun PlatformFile.readBytes(): ByteArray = withContext(Disp } // Read the file as an ArrayBuffer - reader.readAsArrayBuffer(fh.getFile().unsafeCast()) + reader.readAsArrayBuffer(fh.getFile().unsafeCast()) } }