diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 61c62251..8dc69544 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -830,7 +830,7 @@ pub fn command_mode(cx: &mut Context) {
                 ["w"] | ["write"] => {
                     // TODO: non-blocking via save() command
                     let id = editor.view().doc;
-                    let doc = &mut editor.document(id).unwrap();
+                    let doc = &mut editor.documents[id];
                     smol::block_on(doc.save());
                 }
 
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 1103d6f5..1f4bf6bd 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -415,14 +415,15 @@ impl EditorView {
 
         if let Some(path) = doc.relative_path() {
             let path = path.to_string_lossy();
+
+            let title = format!("{}{}", path, if doc.modified() { "[+]" } else { "" });
             surface.set_stringn(
                 viewport.x + 6,
                 viewport.y,
-                path,
+                title,
                 viewport.width.saturating_sub(6) as usize,
                 text_color,
             );
-            // TODO: append [+] if modified
         }
 
         surface.set_string(
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 6e73104a..94684362 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -31,6 +31,7 @@ pub struct Document {
     // /// Corresponding language scope name. Usually `source.<lang>`.
     pub(crate) language: Option<Arc<LanguageConfiguration>>,
 
+    modified: bool,
     /// Pending changes since last history commit.
     changes: ChangeSet,
     /// State at last commit. Used for calculating reverts.
@@ -75,6 +76,7 @@ impl Document {
             restore_cursor: false,
             syntax: None,
             language: None,
+            modified: false,
             changes,
             old_state,
             diagnostics: Vec::new(),
@@ -111,7 +113,7 @@ impl Document {
 
     // TODO: do we need some way of ensuring two save operations on the same doc can't run at once?
     // or is that handled by the OS/async layer
-    pub fn save(&self) -> impl Future<Output = Result<(), anyhow::Error>> {
+    pub fn save(&mut self) -> impl Future<Output = Result<(), anyhow::Error>> {
         // we clone and move text + path into the future so that we asynchronously save the current
         // state without blocking any further edits.
 
@@ -120,10 +122,12 @@ impl Document {
         let identifier = self.identifier();
 
         // TODO: mark changes up to now as saved
-        // TODO: mark dirty false
 
         let language_server = self.language_server.clone();
 
+        // reset the modified flag
+        self.modified = false;
+
         async move {
             use smol::{fs::File, prelude::*};
             let mut file = File::create(path).await?;
@@ -224,6 +228,10 @@ impl Document {
 
         let success = self._apply(&transaction);
 
+        self.modified = true;
+        // TODO: be smarter about modified by keeping track of saved version instead. That way if
+        // current version == version then it's not modified.
+
         if !transaction.changes().is_empty() {
             // Compose this transaction with the previous one
             take_with(&mut self.changes, |changes| {
@@ -279,6 +287,11 @@ impl Document {
         self.id
     }
 
+    #[inline]
+    pub fn modified(&self) -> bool {
+        self.modified
+    }
+
     #[inline]
     pub fn mode(&self) -> Mode {
         self.mode