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..ea8dd2d1 --- /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: FileExt, + 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(): FileExt = file + + override fun getParent(): WebFileHandle? = + parent + + override fun list(): List = + emptyList() +} + +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 51de3ee8..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 @@ -1,8 +1,160 @@ 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.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.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.JsArray +import kotlin.js.JsName +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(): FileExt + + public fun getParent(): WebFileHandle? + + 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. + */ +@Serializable(with = PlatformFileSerializer::class) +public actual data class PlatformFile( + internal val fh: WebFileHandle, +) { + @Deprecated("Please do not use this anymore to create an instance.") + @OptIn(ExperimentalWasmJsInterop::class) + 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 + + 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()?.toPlatformFile() + +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(list()) +} + +public actual fun PlatformFile.list(): List = + fh.list().map { it.toPlatformFile() } 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().unsafeCast()) + } +} + +public actual suspend fun PlatformFile.readString(): String = + readBytes().decodeToString() + +/** + * + */ +@OptIn(ExperimentalWasmJsInterop::class) +@JsName("File") +public open external class FileExt( + 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..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,24 +1,24 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.FileKit -import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.FileExt +import io.github.vinceglb.filekit.FileHandleFile 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,9 +60,10 @@ internal actual suspend fun FileKit.platformOpenFilePicker( ?.unsafeCast() ?.files ?.asList() + ?.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) @@ -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..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,28 +1,28 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.FileKit +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 -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 - val input = document.createElement("input") as HTMLInputElement + val input = document.createElement("input") as HTMLInputElementExt // Visually hide the element input.style.display = "none" @@ -47,7 +47,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 } @@ -57,12 +58,13 @@ internal actual suspend fun FileKit.platformOpenFilePicker( try { // Get the selected files val files = event.target - ?.unsafeCast() + ?.unsafeCast() ?.files ?.asList() + ?.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) @@ -81,5 +83,15 @@ internal actual suspend fun FileKit.platformOpenFilePicker( } } - return files.toPickerStateFlow() + return files +} + +@JsName("HTMLInputElement") +public abstract external class HTMLInputElementExt : 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..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) + 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..353882da --- /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.FileExt +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.toPlatformFile +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, + )?.map { it.toPlatformFile() }.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.getFile(), parent = directory) + directory.list.add(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.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(newDir) + } + } +} + +internal expect suspend fun platformOpenFilePickerWeb( + type: FileKitType, + multipleMode: Boolean, // select multiple files + directoryMode: Boolean, // select a directory +): List? + +/** + * Virtual directory + */ +internal class FileHandleVirtualDirectory( + override val name: String, + override val path: String, + override val lastModified: Instant, + 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(): FileExt { + TODO("Not supported!") + } + + override fun getParent(): WebFileHandle? = + parent + + 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, + ),*/ )