@@ -900,54 +900,79 @@ fun launchDesktopWindow() = application {
900900 Button (
901901 enabled = ! busy,
902902 onClick = {
903- // Native AWT Open dialog on the EDT (the Compose Desktop UI thread); it is modal, so
904- // it returns the selection synchronously. `window` is the FrameWindowScope's AWT frame.
903+ // S4d-229: native AWT Open dialog with MULTI-SELECT on the EDT (modal → returns the
904+ // selection synchronously) . `window` is the FrameWindowScope's AWT frame.
905905 val dialog = FileDialog (window, " Open image" , FileDialog .LOAD ).apply {
906+ isMultipleMode = true
906907 setFilenameFilter { _, fileName ->
907908 fileName.substringAfterLast(' .' , " " ).lowercase() in IMAGE_EXTENSIONS
908909 }
909910 isVisible = true
910911 }
911- val dir = dialog.directory
912- val name = dialog.file
913- if (dir != null && name != null ) {
914- val selected = File (dir, name)
912+ // S4d-229: take ALL selected supported images via the existing pure filter (cancel →
913+ // empty `files`). A single selection is just a one-element list, so single-select still works.
914+ val files = DesktopSaveDecision .supportedImageFiles(dialog.files.toList(), IMAGE_EXTENSIONS )
915+ if (files.isNotEmpty()) {
915916 // Read + render off the UI thread, then write Compose state back on the UI dispatcher.
916917 scope.launch {
917918 busy = true
918- status = " Rendering ${selected.name} …"
919- var picked: LastImage ? = null
920- var savedFile: File ? = null
921- val next = withContext(Dispatchers .IO ) {
922- try {
923- val bytes = selected.readBytes()
924- picked = LastImage (bytes, selected.path)
925- // S4d-217: write the real save to the user output dir (not the build/ default).
926- val fmt = userConfigRepo.userPreferences.first().outputFormat
927- val out = DesktopSaveDecision .resolveUniqueOutputFile(outputDir, fmt)
928- val o = DesktopWatermarkFlow .runSaveFlow(
929- repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = selected.path,
930- outputFile = out ,
931- )
932- // S4d-157: remember the real saved output for the share-substitute buttons.
933- savedFile = File (o.outputPath)
934- " Saved: ${o.outputPath} \n ${o.format} , ${o.width} x${o.height} , ${o.outputByteCount} B\n " +
935- " input: ${o.inputLabel} (${o.inputByteCount} B)\n config: ${o.configAfterEdit} "
936- } catch (t: Throwable ) {
937- " Failed: ${t.message} "
919+ status = " Rendering ${files.size} image(s)…"
920+ // Remember the LAST successful image/output (for reuse + the share-substitute buttons).
921+ var lastPicked: LastImage ? = null
922+ var lastSaved: File ? = null
923+ // S4d-229: mirror the S4d-228 drop batch exactly — the whole batch span is in
924+ // try/finally so `busy` ALWAYS resets, and the withContext body is in try/catch so a
925+ // setup-level failure (e.g. userConfigRepo.userPreferences.first()) surfaces as a
926+ // "Failed: …" status instead of leaving the UI stuck busy.
927+ try {
928+ val next = withContext(Dispatchers .IO ) {
929+ try {
930+ // S4d-217: read the output format ONCE per batch; saves go to the user dir.
931+ val fmt = userConfigRepo.userPreferences.first().outputFormat
932+ var successCount = 0
933+ var failCount = 0
934+ var firstFailure: String? = null
935+ // STRICTLY SEQUENTIAL: resolveUniqueOutputFile is existence-check-only and
936+ // does NOT create the file, and runSaveFlow writes synchronously before
937+ // returning, so resolving the NEXT path only after the previous save has
938+ // written yields collision-free names. Read one file's bytes per iteration
939+ // (never all up front). A per-file failure never aborts the batch.
940+ for (file in files) {
941+ try {
942+ val bytes = file.readBytes()
943+ val out = DesktopSaveDecision .resolveUniqueOutputFile(outputDir, fmt)
944+ val o = DesktopWatermarkFlow .runSaveFlow(
945+ repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = file.path,
946+ outputFile = out ,
947+ )
948+ lastPicked = LastImage (bytes, file.path)
949+ lastSaved = File (o.outputPath)
950+ successCount++
951+ } catch (t: Throwable ) {
952+ failCount++
953+ if (firstFailure == null ) firstFailure = " ${file.name} : ${t.message} "
954+ }
955+ }
956+ buildString {
957+ append(" Saved $successCount /${files.size} images to ${outputDir.path} " )
958+ if (failCount > 0 ) append(" · $failCount failed: $firstFailure " )
959+ }
960+ } catch (t: Throwable ) {
961+ " Failed: ${t.message} "
962+ }
938963 }
964+ lastPicked?.let { lastImage = it }
965+ lastSaved?.let { lastSavedFile = it }
966+ // Auto-refresh the preview AT MOST ONCE after the batch, only when ≥1 save
967+ // succeeded (over the last successful image). refreshPreview writes ONLY the temp
968+ // preview file (never lastSavedFile, so share-substitute stays real-save-bound).
969+ status = if (lastSaved != null ) " $next · ${refreshPreview()} " else next
970+ } finally {
971+ busy = false
939972 }
940- picked?.let { lastImage = it }
941- savedFile?.let { lastSavedFile = it }
942- // S4d-198-r1: a successful "Open image…" is an explicit source change →
943- // auto-refresh the preview over the just-loaded image (lastImage is set above).
944- // savedFile != null ⟺ the real save succeeded; refreshPreview writes ONLY the
945- // temp preview file (never lastSavedFile, so share-substitute stays real-save-bound).
946- status = if (savedFile != null ) " $next · ${refreshPreview()} " else next
947- busy = false
948973 }
949974 }
950- // Cancelled (null file/directory ) → leave the status unchanged and do no work .
975+ // Cancelled / no supported selection (empty `files` ) → no work, status unchanged (no-op) .
951976 },
952977 ) {
953978 Text (" Open image…" )
0 commit comments