Build a Markdown Editor with Compose Desktop: File Access | Tutorial #5
Video: Build a Markdown Editor with Compose Desktop: File Access | Tutorial #5 by Taught by Celeste AI - AI Coding Coach
Stack: Kotlin 2.1, Compose Multiplatform 1.7.3, Material 3, kotlinx.serialization, JVM 17+. Six small Kotlin files. A real desktop app you can ship.
The earlier lessons in this series got Compose Desktop on screen — windows, state, layout, navigation. This is the lesson where the app starts touching the disk. Open a file. Edit it. Save it back. Remember what you opened last time. Standard text-editor behaviour, all in about 160 lines of Kotlin.
We use three new pieces:
File.readText()/File.writeText()for the actual I/O.JFileChooser(from Swing, included with the JVM) for the native open/save dialogs.kotlinx.serializationto persist a small JSON config file in the user's home directory.
Compose Desktop runs on the JVM, so the entire Java standard library and Swing are still right there. We use what fits.
What we are building
A minimal markdown editor:
- A toolbar with Open and Save icon buttons, the current filename, and a
*modified indicator. - A monospace
OutlinedTextFieldfilling the rest of the window — the editor pane. - A persistent config at
~/.markdown-editor.jsonstoring the last-opened file and the last 5 recent files. Closes and reopens with state intact.
The project layout
demo-app/
├── build.gradle.kts
├── settings.gradle.kts
└── src/main/kotlin/
├── Main.kt # @Composable App + window entry
├── FileManager.kt # I/O singleton (readText, writeText, JFileChooser, JSON config)
├── AppState.kt # AppConfig (persisted) + EditorState (transient)
├── TopBar.kt # toolbar Composable
├── EditorPane.kt # text-editor Composable
└── Colors.kt # the Material 3 palette
Six files. Each has one responsibility. By Lesson 5 the project has outgrown a one-file demo, and splitting into modules pays back immediately when you want to extend the app.
build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.serialization") version "2.1.0"
id("org.jetbrains.compose") version "1.7.3"
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
Two new entries vs Lesson 4:
kotlin("plugin.serialization")— the Gradle plugin that powers@Serializable.kotlinx-serialization-json— the JSON encoder/decoder.
Plus compose.materialIconsExtended for the FolderOpen and Save icons we render in the toolbar.
AppState.kt: two state types
@Serializable
data class AppConfig(
val recentFiles: List<String> = emptyList(),
val lastOpenedFile: String = ""
)
data class EditorState(
val filePath: String = "",
val content: String = "",
val modified: Boolean = false
)
Two distinct kinds of state. AppConfig is what we persist — recent files, last opened. EditorState is what we don't persist — the in-memory buffer, current file path, dirty flag. Persisting the buffer would be wrong; if you crash with unsaved changes, you should lose them, not silently restore garbage.
@Serializable is the magic annotation. The kotlinx-serialization plugin generates serialise/deserialise code for the data class at compile time. No runtime reflection, no Gson-style boilerplate — just declare the data shape and use it.
FileManager.kt: the I/O singleton
object FileManager {
private val json = Json { prettyPrint = true }
private val configFile = File(
System.getProperty("user.home"), ".markdown-editor.json"
)
fun readFile(path: String): String = File(path).readText()
fun writeFile(path: String, content: String) = File(path).writeText(content)
fun loadConfig(): AppConfig {
return if (configFile.exists()) {
json.decodeFromString<AppConfig>(configFile.readText())
} else {
AppConfig()
}
}
fun saveConfig(config: AppConfig) {
configFile.writeText(json.encodeToString(config))
}
fun pickFileToOpen(): String? {
val chooser = JFileChooser(File("files"))
chooser.fileFilter = FileNameExtensionFilter("Markdown", "md", "txt")
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile.absolutePath
} else null
}
fun pickFileToSave(): String? {
val chooser = JFileChooser(File("files"))
chooser.fileFilter = FileNameExtensionFilter("Markdown", "md", "txt")
return if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile.absolutePath
} else null
}
}
object FileManager declares a Kotlin singleton. There is exactly one FileManager for the lifetime of the app; you call its functions like static methods (FileManager.readFile(path)).
Five methods.
readFile(path) and writeFile(path, content) are one-liners over File.readText() and File.writeText(). They handle the entire file in memory — fine for markdown editors, wrong for gigabyte logs (use streaming for those).
loadConfig() and saveConfig() use kotlinx.serialization's Json class to convert between AppConfig and a JSON string. Json { prettyPrint = true } configures readable formatting. decodeFromString<AppConfig>(...) parses the JSON; the generic type tells the compiler which class to deserialise into.
pickFileToOpen() and pickFileToSave() wrap JFileChooser — Swing's file dialog, included in every JVM. It opens the OS-native file picker on macOS and Windows; on Linux it draws its own. FileNameExtensionFilter("Markdown", "md", "txt") filters the dropdown. The starting directory File("files") is relative to the project root — change to File(System.getProperty("user.home")) for a more user-friendly default.
The pattern of using JFileChooser from inside a Compose Desktop app is fine. Compose runs on the JVM and the same EDT (Event Dispatch Thread) Swing uses, so dialogs work without any plumbing.
Colors.kt: the palette
val Purple80 = Color(0xFFCFBCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Purple40 = Color(0xFF6650A4)
val PurpleGrey40 = Color(0xFF625B71)
val DarkBackground = Color(0xFF1C1B1F)
val DarkSurface = Color(0xFF2B2930)
Six named Color values, all 0xAARRGGBB hex. 0xFF is full alpha. The palette is the Material 3 default dark scheme, which other Composables reference by name — Purple80 for icon tints, DarkBackground for the window fill, etc.
Centralising colours in one file means a theme rework is one edit.
TopBar.kt: the toolbar
@Composable
fun TopBar(fileName: String, modified: Boolean, onOpen: () -> Unit, onSave: () -> Unit) {
Surface(color = DarkSurface) {
Row(
modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onOpen) {
Icon(Icons.Default.FolderOpen, "Open", tint = Purple80)
}
IconButton(onClick = onSave) {
Icon(Icons.Default.Save, "Save", tint = Purple80)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (fileName.isEmpty()) "Untitled" else fileName,
fontSize = 18.sp,
color = PurpleGrey80
)
if (modified) {
Text(" *", fontSize = 18.sp, color = Purple40)
}
}
}
}
A pure-rendering Composable. Inputs are fileName, modified, plus two callbacks. The Composable doesn't own any state — it asks the parent for everything and reports clicks back via the callbacks.
IconButton plus Icon(Icons.Default.FolderOpen, ...) renders a Material icon. The Icons.Default.* namespace comes from compose.materialIconsExtended. Using icons instead of text labels keeps the toolbar compact.
The if (modified) { Text(" *", ...) } is the dirty-state indicator — exactly the pattern every text editor uses.
EditorPane.kt: the editing surface
@Composable
fun EditorPane(content: String, onContentChange: (String) -> Unit) {
OutlinedTextField(
value = content,
onValueChange = onContentChange,
modifier = Modifier.fillMaxSize().padding(16.dp),
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 16.sp,
color = PurpleGrey80
),
placeholder = { Text("Start typing markdown...", color = PurpleGrey40) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Purple40,
unfocusedBorderColor = DarkSurface,
cursorColor = Purple80
)
)
}
OutlinedTextField is Material 3's text input. It supports multi-line editing out of the box; with Modifier.fillMaxSize() it expands to fill its parent.
textStyle switches the font to monospace at 16sp — the right look for code-or-markdown editing.
OutlinedTextFieldDefaults.colors(...) overrides the border and cursor colours to match the dark theme. Without this, the field gets default light-theme accents that clash.
Like TopBar, this Composable is pure: it takes content and onContentChange, never owns state.
Main.kt: wiring it together
@Composable
fun App() {
val editor = remember { mutableStateOf(EditorState()) }
val config = remember { mutableStateOf(FileManager.loadConfig()) }
fun onOpen() {
val path = FileManager.pickFileToOpen() ?: return
val text = FileManager.readFile(path)
editor.value = EditorState(filePath = path, content = text)
config.value = config.value.copy(lastOpenedFile = path)
FileManager.saveConfig(config.value)
}
fun onSave() {
val path = editor.value.filePath.ifEmpty {
FileManager.pickFileToSave() ?: return
}
FileManager.writeFile(path, editor.value.content)
editor.value = editor.value.copy(filePath = path, modified = false)
val recent = (listOf(path) + config.value.recentFiles).distinct().take(5)
config.value = config.value.copy(recentFiles = recent, lastOpenedFile = path)
FileManager.saveConfig(config.value)
}
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize(), color = DarkBackground) {
Column(modifier = Modifier.fillMaxSize()) {
TopBar(
fileName = File(editor.value.filePath).name.ifEmpty { "" },
modified = editor.value.modified,
onOpen = ::onOpen,
onSave = ::onSave
)
EditorPane(
content = editor.value.content,
onContentChange = { text ->
editor.value = editor.value.copy(content = text, modified = true)
}
)
}
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Markdown Editor") {
App()
}
}
App() owns the two pieces of state — editor (the buffer) and config (the persisted settings). Both via remember { mutableStateOf(...) }, which is the bread-and-butter Compose state pattern.
onOpen and onSave are defined as local functions inside App() so they can read and write the surrounding state without callbacks.
Open flow:
1. Show the file picker (pickFileToOpen returns null on cancel; ?: return bails).
2. Read the chosen file's text.
3. Reset the editor state — fresh EditorState with the new path and content; modified = false because we just loaded from disk.
4. Update the config with the new "last opened" path and persist it to JSON.
Save flow:
1. If we already have a path (we opened or saved before), use it. Otherwise show the save dialog.
2. Write the buffer to disk.
3. Update the editor's filePath (in case it just got picked) and clear the modified flag.
4. Prepend the path to the recent-files list, dedupe with .distinct(), cap to 5 with .take(5).
5. Persist.
The (listOf(path) + config.value.recentFiles).distinct().take(5) chain is the standard "recently used" pattern — most-recent first, no duplicates, bounded length.
Editing flow: the editor pane's onContentChange closure copies the editor state with the new content and modified = true. Compose recomposes; the * indicator appears in the title bar.
Running it
./gradlew run
A native desktop window opens with the dark theme, an empty editor, and "Untitled" in the toolbar. Click the folder icon. The OS file picker appears. Pick any .md or .txt file. The content fills the editor. The toolbar shows the file's name. Edit something — the * modified indicator appears. Click the disk icon. The file is saved; the indicator disappears.
Quit. Run ./gradlew run again. The config persists, but the editor state doesn't — that's the right behaviour for a text editor. The user gets a fresh buffer; the recent-files list is preserved for the future "Open Recent" menu we'll add in a later lesson.
Look at ~/.markdown-editor.json:
{
"recentFiles": [
"/Users/you/notes.md",
"/Users/you/todo.md"
],
"lastOpenedFile": "/Users/you/notes.md"
}
Pretty-printed JSON, edited by hand if you ever want to. That's the value of Json { prettyPrint = true }.
Build a native binary
./gradlew packageDmg # macOS
./gradlew packageMsi # Windows
./gradlew packageDeb # Linux
compose.desktop.application.nativeDistributions.targetFormats in the Gradle file lists which formats to produce. The build pulls a JRE in and packages everything as a native installer — users don't need Java pre-installed.
What's missing (and what's coming)
This is a minimal editor. A serious version would add:
- An "Open Recent" menu populated from
config.recentFiles. - Confirm-on-close when there are unsaved changes — Compose Desktop has window event hooks for this.
- Auto-save with a debounce so users never lose work.
- Undo/redo — Compose's
OutlinedTextFielddoesn't ship this; you would maintain a buffer history. - Markdown preview in a side pane — render the markdown to HTML and display it.
- Find/replace — a small floating window with regex matching.
Each is one or two new files in the same architecture. The pieces stay small; the whole composes.
Common mistakes
Forgetting the serialization Gradle plugin. Without kotlin("plugin.serialization") in the plugins block, @Serializable annotations are ignored and decodeFromString fails at runtime.
Mutating the data class instead of copying. editor.value.modified = true doesn't work — data classes have read-only properties by default. Use editor.value = editor.value.copy(modified = true).
Storing the editor buffer in the persisted config. Persistence is for settings, not for in-memory editor state. If you persist the buffer, you have to handle "the file changed on disk while my session was offline" and that ladder of complexity gets long fast. Don't.
Reading enormous files with readText(). It loads the whole file into memory. For text editors operating on documents, this is fine. For log viewers or anything streaming, use File.bufferedReader().forEachLine.
Picking files from a hardcoded directory. JFileChooser(File("files")) starts in a path relative to the working directory — fine for development, surprising in a packaged app. Use System.getProperty("user.home") or the OS's documents folder.
Recap
Six small Kotlin files. FileManager (singleton with readText/writeText, JFileChooser dialogs, JSON config). AppState (one persisted, one transient). TopBar and EditorPane (pure render Composables). Colors (centralised palette). Main (the wiring — local onOpen and onSave functions over remember-backed state, Material 3 dark theme, Compose Desktop window).
You have a working markdown editor. Less than 200 lines. From here, every "feature" is a small addition to one of these six files.
What's next
Lesson 6: Custom themes. The hardcoded dark palette becomes a real theme system — light/dark toggle, accent colour, persisted via the same AppConfig we built today. The shape of the app stays the same; the colour pipeline gets richer.
See you in the next lesson.