esc

Externally Scriptable Editor

git clone git://mccd.space/esc

commit 80f74a421b2da7299dca7e9e43244097a4296f05
parent d25eaacabce71e577e9c80c3673ab138cb746b45
Author: Marc Coquand <marc@coquand.email>
Date:   Fri, 27 Feb 2026 11:17:21 +0100

Add multi buffer edits

Diffstat:
Meditor.c | 213++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Meditor.h | 28++++++++++++++++++++++------
Mfuse_ipc.c | 412+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mmain.c | 7++++---
Mrenderer.c | 16++++++++++++++++
5 files changed, 548 insertions(+), 128 deletions(-)
diff --git a/editor.c b/editor.c
@@ -6,6 +6,10 @@
 #include <stdlib.h>
 #include <string.h>
 
+/* Forward declarations for file slot helpers (defined after lifecycle code) */
+static void slots_on_insert(Editor *ed, int pos, int len);
+static void slots_on_delete(Editor *ed, int pos, int len);
+
 /* ---------- undo helpers (called with lock held) ----------------------- */
 
 static void push_undo_delta(Editor *ed, UndoDelta d) {
@@ -54,6 +58,7 @@ static void delete_range_raw(Editor *ed, int start, int end) {
 			ed->ranges[new_count++] = r;
 	}
 	ed->ranges_count = new_count;
+	slots_on_delete(ed, start, len);
 	strbuf_delete(&ed->text, start, len);
 	ed->cursor_idx = start;
 	ed->selection_anchor = start;
@@ -128,7 +133,10 @@ Editor *editor_create(int char_width, float line_height) {
 	ed->ranges = NULL;
 	ed->ranges_count = 0;
 	ed->ranges_capacity = 0;
-	ed->filename = NULL;
+
+	ed->files = NULL;
+	ed->files_count = 0;
+	ed->files_cap = 0;
 
 	ed->undo_stack = malloc(UNDO_MAX * sizeof(UndoDelta));
 	ed->undo_len = 0;
@@ -141,8 +149,9 @@ Editor *editor_create(int char_width, float line_height) {
 }
 
 void editor_destroy(Editor *ed) {
-	if (ed->filename)
-		free(ed->filename);
+	for (int i = 0; i < ed->files_count; i++)
+		free(ed->files[i].path);
+	free(ed->files);
 	strbuf_free(&ed->text);
 	if (ed->mutex)
 		SDL_DestroyMutex(ed->mutex);
@@ -172,45 +181,176 @@ void editor_clear_ranges(Editor *ed) {
 	editor_unlock(ed);
 }
 
-bool editor_load_file(Editor *ed, const char *filename) {
+/* ---------- file slot helpers ------------------------------------------ */
+
+static void files_grow(Editor *ed) {
+	if (ed->files_count >= ed->files_cap) {
+		int new_cap = ed->files_cap == 0 ? 4 : ed->files_cap * 2;
+		ed->files = realloc(ed->files, new_cap * sizeof(FileSlot));
+		ed->files_cap = new_cap;
+	}
+}
+
+/* Shift all slot boundaries after a text insertion at pos. */
+static void slots_on_insert(Editor *ed, int pos, int len) {
+	for (int i = 0; i < ed->files_count; i++) {
+		FileSlot *s = &ed->files[i];
+		if (s->buf_start <= pos && pos <= s->buf_end) {
+			/* Cursor is inside this slot */
+			s->buf_end += len;
+			if (s->has_range)
+				s->range_end += len;
+			s->dirty = true;
+		} else if (s->buf_start > pos) {
+			s->buf_start += len;
+			s->buf_end   += len;
+		}
+	}
+}
+
+/* Shift all slot boundaries after a text deletion of [pos, pos+len). */
+static void slots_on_delete(Editor *ed, int pos, int len) {
+	int del_end = pos + len;
+	for (int i = 0; i < ed->files_count; i++) {
+		FileSlot *s = &ed->files[i];
+		if (s->buf_end <= pos) {
+			/* slot entirely before deletion: no change */
+		} else if (s->buf_start >= del_end) {
+			/* slot entirely after deletion */
+			s->buf_start -= len;
+			s->buf_end   -= len;
+		} else {
+			/* overlapping */
+			int overlap_start = s->buf_start > pos ? s->buf_start : pos;
+			int overlap_end   = s->buf_end < del_end ? s->buf_end : del_end;
+			int removed = overlap_end - overlap_start;
+			s->buf_end -= removed;
+			if (s->has_range)
+				s->range_end -= removed;
+			if (s->buf_start > pos)
+				s->buf_start = pos;
+			s->dirty = true;
+		}
+	}
+}
+
+int editor_file_slot_at(Editor *ed, int byte_pos) {
+	for (int i = 0; i < ed->files_count; i++) {
+		if (byte_pos >= ed->files[i].buf_start &&
+		    byte_pos <= ed->files[i].buf_end)
+			return i;
+	}
+	return ed->files_count - 1;
+}
+
+bool editor_open_file(Editor *ed, const char *filename) {
 	editor_lock(ed);
 
-	if (!strbuf_read_file(&ed->text, filename)) {
+	/* Read file into a temporary strbuf */
+	StrBuf tmp;
+	strbuf_init(&tmp, 1024);
+	if (!strbuf_read_file(&tmp, filename)) {
+		strbuf_free(&tmp);
 		editor_unlock(ed);
 		return false;
 	}
-	if (ed->filename)
-		free(ed->filename);
-	ed->filename = strdup(filename);
-	ed->cursor_idx = 0;
-	ed->selection_anchor = 0;
-	ed->ranges_count = 0;
 
-	for (int i = 0; i < ed->undo_len; i++)
-		free(ed->undo_stack[i].reinsert);
-	ed->undo_len = 0;
-	for (int i = 0; i < ed->redo_len; i++)
-		free(ed->redo_stack[i].reinsert);
-	ed->redo_len = 0;
+	int insert_pos = (int)ed->text.len;
+	/* Append a newline separator between slots (except for the first) */
+	if (ed->files_count > 0 && insert_pos > 0 &&
+	    ed->text.data[insert_pos - 1] != '\n') {
+		strbuf_insert(&ed->text, insert_pos, "\n", 1);
+		slots_on_insert(ed, insert_pos, 1);
+		insert_pos++;
+	}
 
+	files_grow(ed);
+	FileSlot *s = &ed->files[ed->files_count];
+	s->path        = filename ? strdup(filename) : NULL;
+	s->buf_start   = insert_pos;
+	s->dirty       = false;
+	s->has_range   = false;
+	s->range_start = 0;
+	s->range_end   = 0;
+
+	strbuf_insert(&ed->text, insert_pos, tmp.data, tmp.len);
+	s->buf_end = insert_pos + (int)tmp.len;
+	ed->files_count++;
+
+	strbuf_free(&tmp);
 	editor_unlock(ed);
 	return true;
 }
 
-bool editor_write_file(Editor *ed, const char *filename) {
+bool editor_add_file_slot(Editor *ed, const char *path, const char *data, int len) {
+	editor_lock(ed);
+
+	int insert_pos = (int)ed->text.len;
+	if (ed->files_count > 0 && insert_pos > 0 &&
+	    ed->text.data[insert_pos - 1] != '\n') {
+		strbuf_insert(&ed->text, insert_pos, "\n", 1);
+		slots_on_insert(ed, insert_pos, 1);
+		insert_pos++;
+	}
+
+	files_grow(ed);
+	FileSlot *s = &ed->files[ed->files_count];
+	s->path        = path ? strdup(path) : NULL;
+	s->buf_start   = insert_pos;
+	s->dirty       = false;
+	s->has_range   = false;
+	s->range_start = 0;
+	s->range_end   = 0;
+
+	if (data && len > 0)
+		strbuf_insert(&ed->text, insert_pos, data, len);
+	s->buf_end = insert_pos + (len > 0 ? len : 0);
+	ed->files_count++;
+
+	editor_unlock(ed);
+	return true;
+}
 
+void editor_set_file_slot_range(Editor *ed, int idx, int start, int end) {
 	editor_lock(ed);
-	const char *to_write;
-	if (filename)
-		to_write = filename;
-	else if (ed->filename) {
-		filename = ed->filename;
-	} else {
-		return false;
+	if (idx >= 0 && idx < ed->files_count) {
+		ed->files[idx].has_range   = true;
+		ed->files[idx].range_start = start;
+		ed->files[idx].range_end   = end;
 	}
-	bool res = strbuf_write_file(&ed->text, filename);
 	editor_unlock(ed);
-	return res;
+}
+
+bool editor_write_file(Editor *ed, const char *filename) {
+	editor_lock(ed);
+
+	bool any = false;
+	if (filename) {
+		/* Write full buffer to explicit path (legacy / single-file) */
+		bool res = strbuf_write_file(&ed->text, filename);
+		editor_unlock(ed);
+		return res;
+	}
+
+	/* Save all dirty slots */
+	for (int i = 0; i < ed->files_count; i++) {
+		FileSlot *s = &ed->files[i];
+		if (!s->dirty || !s->path)
+			continue;
+		int slot_len = s->buf_end - s->buf_start;
+		/* Write only the slot's bytes */
+		StrBuf tmp;
+		strbuf_init(&tmp, slot_len + 1);
+		strbuf_insert(&tmp, 0, ed->text.data + s->buf_start, slot_len);
+		if (strbuf_write_file(&tmp, s->path)) {
+			s->dirty = false;
+			any = true;
+		}
+		strbuf_free(&tmp);
+	}
+
+	editor_unlock(ed);
+	return any;
 }
 static int utf8_char_to_byte_idx(const char *text, int char_idx) {
 
@@ -406,6 +546,9 @@ void editor_insert_text(Editor *ed, const char *text, bool replace) {
 		}
 	}
 
+	// Shift file slot boundaries
+	slots_on_insert(ed, ed->cursor_idx, (int)input_len);
+
 	editor_unlock(ed);
 
 	strbuf_insert(&ed->text, ed->cursor_idx, text, input_len);
@@ -456,6 +599,13 @@ void editor_delete_back(Editor *ed) {
 	if (ed->cursor_idx != ed->selection_anchor) {
 		editor_delete_range(ed, ed->selection_anchor, ed->cursor_idx);
 	} else if (ed->cursor_idx > 0) {
+		/* Hard boundary: cannot backspace across a file slot boundary */
+		for (int i = 1; i < ed->files_count; i++) {
+			if (ed->cursor_idx == ed->files[i].buf_start) {
+				editor_unlock(ed);
+				return;
+			}
+		}
 		int prev = ed->cursor_idx;
 		do {
 			prev--;
@@ -471,7 +621,14 @@ void editor_delete_forward(Editor *ed) {
 	editor_lock(ed);
 	if (ed->cursor_idx != ed->selection_anchor) {
 		editor_delete_range(ed, ed->selection_anchor, ed->cursor_idx);
-	} else if (ed->cursor_idx < ed->text.len) {
+	} else if (ed->cursor_idx < (int)ed->text.len) {
+		/* Hard boundary: cannot delete-forward across a file slot boundary */
+		for (int i = 0; i < ed->files_count - 1; i++) {
+			if (ed->cursor_idx == ed->files[i].buf_end) {
+				editor_unlock(ed);
+				return;
+			}
+		}
 		const char *ptr = ed->text.data + ed->cursor_idx;
 		const char *next = ptr;
 		SDL_StepUTF8(&next, NULL);
diff --git a/editor.h b/editor.h
@@ -19,7 +19,7 @@ typedef struct {
 typedef enum { RANGE_REPLACEMENT, RANGE_FORMAT } EditorRangeType;
 
 typedef struct {
-	int visual_cols; 
+	int visual_cols;
 } RangeReplacement;
 
 typedef struct {
@@ -38,16 +38,27 @@ typedef struct {
 } EditorRange;
 
 typedef struct {
+	char *path;       // Heap-allocated file path (NULL if unnamed)
+	int  buf_start;   // Byte offset in text where this slot starts
+	int  buf_end;     // Byte offset in text where this slot ends (exclusive)
+	bool dirty;       // Unsaved changes in this slot
+	bool has_range;   // Whether an external range has been set
+	int  range_start; // Fixed: original-file byte where visible region starts
+	int  range_end;   // Dynamic: original-file byte where visible region ends
+} FileSlot;
+
+typedef struct {
 	StrBuf text;
 	int cursor_idx;
 	int selection_anchor; // Where the selection started
 
-	char *filename;
 	EditorRange *ranges;
 	size_t ranges_count;
 	size_t ranges_capacity;
 
-	bool dirty;
+	FileSlot *files;
+	int       files_count;
+	int       files_cap;
 
 	UndoDelta *undo_stack;
 	int        undo_len;
@@ -74,17 +85,22 @@ void editor_delete_back(Editor *ed);
 void editor_delete_forward(Editor *ed);
 void editor_clear_selection(Editor *ed);
 void editor_delete_range(Editor *ed, int start, int end);
-bool editor_load_file(Editor *ed, const char *filename);
+bool editor_open_file(Editor *ed, const char *filename);
 bool editor_write_file(Editor *ed, const char *filename);
 void editor_replace_body(Editor *ed, const char *data, size_t len);
 void editor_undo(Editor *ed);
 void editor_redo(Editor *ed);
 void editor_add_formatting_range(Editor *ed, int start, int end, RangeFormat format);
-void editor_add_replace_range(Editor *ed, int start, int end, int visual_cols); 
-EditorRange* editor_get_replacement_at(Editor *ed, int byte_idx); 
+void editor_add_replace_range(Editor *ed, int start, int end, int visual_cols);
+EditorRange* editor_get_replacement_at(Editor *ed, int byte_idx);
 RangeFormat editor_get_format_at(Editor *ed, int byte_idx);
 void editor_parse_ansi_codes(Editor *ed);
 
+// File slot management
+bool editor_add_file_slot(Editor *ed, const char *path, const char *data, int len);
+void editor_set_file_slot_range(Editor *ed, int idx, int start, int end);
+int  editor_file_slot_at(Editor *ed, int byte_pos);
+
 // Cursor Movement
 void editor_cursor_left(Editor *ed);
 void editor_cursor_right(Editor *ed);
diff --git a/fuse_ipc.c b/fuse_ipc.c
@@ -91,46 +91,101 @@ static void wake_render(void) {
 
 /* ---- path helpers ---------------------------------------------------- */
 
+/*
+ * Parse /buffer/N/sub paths.
+ * Returns slot index (>=0) and sets *sub to the sub-file name.
+ * Returns -2 for /buffer itself.
+ * Returns -3 for /buffer/N (slot directory).
+ * Returns -1 if not a /buffer/... path.
+ */
+static int parse_buffer_path(const char *path, const char **sub) {
+	if (strcmp(path, "/buffer") == 0) {
+		if (sub) *sub = NULL;
+		return -2;
+	}
+	if (strncmp(path, "/buffer/", 8) != 0)
+		return -1;
+	const char *rest = path + 8;
+	/* rest should be N or N/sub */
+	char *slash = strchr(rest, '/');
+	int idx = atoi(rest);
+	if (idx < 0)
+		return -1;
+	/* Validate that 'rest' is really a number */
+	const char *p = rest;
+	if (*p == '-') p++;
+	if (*p < '0' || *p > '9')
+		return -1;
+	if (!slash) {
+		if (sub) *sub = NULL;
+		return -3; /* slot directory */
+	}
+	if (sub) *sub = slash + 1;
+	return idx;
+}
+
 static int is_dir(const char *path) {
-	return strcmp(path, "/") == 0 || strcmp(path, "/buffer") == 0 ||
-	       strcmp(path, "/buffer/0") == 0;
+	if (strcmp(path, "/") == 0 || strcmp(path, "/buffer") == 0)
+		return 1;
+	const char *sub = NULL;
+	int r = parse_buffer_path(path, &sub);
+	return r == -3; /* /buffer/N with no sub-file */
 }
 
 static int is_symlink(const char *path) { return strcmp(path, "/cwd") == 0; }
 
 static int is_file(const char *path) {
-	return strcmp(path, "/cursor") == 0 ||
-	       strcmp(path, "/buffer/0/body") == 0 ||
-	       strcmp(path, "/buffer/0/path") == 0 ||
-	       strcmp(path, "/buffer/0/ranges") == 0;
+	if (strcmp(path, "/cursor") == 0)
+		return 1;
+	if (strcmp(path, "/buffer/count") == 0)
+		return 1;
+	const char *sub = NULL;
+	int idx = parse_buffer_path(path, &sub);
+	if (idx < 0 || !sub)
+		return 0;
+	return strcmp(sub, "body") == 0 || strcmp(sub, "path") == 0 ||
+	       strcmp(sub, "ranges") == 0 || strcmp(sub, "range") == 0;
 }
 
 static int is_writable(const char *path) {
-	return strcmp(path, "/cursor") == 0 ||
-	       strcmp(path, "/buffer/0/body") == 0;
+	if (strcmp(path, "/cursor") == 0)
+		return 1;
+	const char *sub = NULL;
+	int idx = parse_buffer_path(path, &sub);
+	if (idx < 0 || !sub)
+		return 0;
+	return strcmp(sub, "body") == 0 || strcmp(sub, "path") == 0 ||
+	       strcmp(sub, "range") == 0;
 }
 
 /* ---- ranges serialisation -------------------------------------------- */
 
-/* Build a malloc'd text representation of ed->ranges.
- * Caller must hold editor lock. */
-static char *build_ranges(Editor *ed, size_t *out_len) {
+/*
+ * Build a malloc'd text representation of ed->ranges filtered to
+ * [slot_start, slot_end).  Pass slot_start=0, slot_end=INT_MAX for all.
+ * Offsets in output are relative to slot_start.
+ * Caller must hold editor lock.
+ */
+static char *build_ranges_filtered(Editor *ed, int slot_start, int slot_end,
+				   size_t *out_len) {
 	size_t total = 0;
 	char tmp[128];
 
 	for (size_t i = 0; i < ed->ranges_count; i++) {
 		EditorRange *r = &ed->ranges[i];
+		if (r->end_byte <= slot_start || r->start_byte >= slot_end)
+			continue;
+		int s = r->start_byte - slot_start;
+		int e = r->end_byte - slot_start;
 		if (r->type == RANGE_FORMAT)
-			total +=
-			    snprintf(tmp, sizeof(tmp), "format %d %d %d %d\n",
-				     r->start_byte, r->end_byte,
-				     r->data.format.bold ? 1 : 0,
-				     r->data.format.italic ? 1 : 0);
+			total += snprintf(tmp, sizeof(tmp),
+					  "format %d %d %d %d\n", s, e,
+					  r->data.format.bold ? 1 : 0,
+					  r->data.format.italic ? 1 : 0);
 		else
-			total +=
-			    snprintf(tmp, sizeof(tmp), "replacement %d %d %d\n",
-				     r->start_byte, r->end_byte,
-				     r->data.replacement.visual_cols);
+			total += snprintf(tmp, sizeof(tmp),
+					  "replacement %d %d %d\n", s, e,
+					  r->data.replacement.visual_cols);
 	}
 
 	char *buf = malloc(total + 1);
@@ -140,14 +195,17 @@ static char *build_ranges(Editor *ed, size_t *out_len) {
 	size_t pos = 0;
 	for (size_t i = 0; i < ed->ranges_count; i++) {
 		EditorRange *r = &ed->ranges[i];
+		if (r->end_byte <= slot_start || r->start_byte >= slot_end)
+			continue;
+		int s = r->start_byte - slot_start;
+		int e = r->end_byte - slot_start;
 		if (r->type == RANGE_FORMAT)
-			pos += sprintf(buf + pos, "format %d %d %d %d\n",
-				       r->start_byte, r->end_byte,
+			pos += sprintf(buf + pos, "format %d %d %d %d\n", s, e,
 				       r->data.format.bold ? 1 : 0,
 				       r->data.format.italic ? 1 : 0);
 		else
 			pos += sprintf(buf + pos, "replacement %d %d %d\n",
-				       r->start_byte, r->end_byte,
+				       s, e,
 				       r->data.replacement.visual_cols);
 	}
 	buf[pos] = '\0';
@@ -155,6 +213,10 @@ static char *build_ranges(Editor *ed, size_t *out_len) {
 	return buf;
 }
 
+static char *build_ranges(Editor *ed, size_t *out_len) {
+	return build_ranges_filtered(ed, 0, (int)ed->text.len + 1, out_len);
+}
+
 /* ---- FUSE callbacks -------------------------------------------------- */
 
 static int op_getattr(const char *path, struct stat *st) {
@@ -168,6 +230,15 @@ static int op_getattr(const char *path, struct stat *st) {
 		return 0;
 	}
 
+	if (is_symlink(path)) {
+		char tmp[64];
+		st->st_mode = S_IFLNK | 0777;
+		st->st_nlink = 1;
+		st->st_size =
+		    snprintf(tmp, sizeof(tmp), "/proc/%d/cwd", (int)getpid());
+		return 0;
+	}
+
 	if (is_file(path)) {
 		st->st_mode = S_IFREG | 0644;
 		st->st_nlink = 1;
@@ -176,34 +247,48 @@ static int op_getattr(const char *path, struct stat *st) {
 
 		if (strcmp(path, "/cursor") == 0) {
 			char tmp[64];
+			st->st_size = snprintf(tmp, sizeof(tmp), "%d %d\n",
+					       ed->cursor_idx,
+					       ed->selection_anchor);
+		} else if (strcmp(path, "/buffer/count") == 0) {
+			char tmp[32];
 			st->st_size =
-			    snprintf(tmp, sizeof(tmp), "%d\n%d", ed->cursor_idx,
-				     ed->selection_anchor);
-		} else if (strcmp(path, "/buffer/0/body") == 0) {
-			st->st_size = (off_t)ed->text.len;
-		} else if (strcmp(path, "/buffer/0/path") == 0) {
-			st->st_size =
-			    ed->filename ? (off_t)strlen(ed->filename) : 0;
-		} else if (strcmp(path, "/buffer/0/ranges") == 0) {
-			size_t sz;
-			char *tmp = build_ranges(ed, &sz);
-			st->st_size = (off_t)sz;
-			free(tmp);
+			    snprintf(tmp, sizeof(tmp), "%d\n", ed->files_count);
+		} else {
+			const char *sub = NULL;
+			int idx = parse_buffer_path(path, &sub);
+			if (idx >= 0 && idx < ed->files_count && sub) {
+				FileSlot *s = &ed->files[idx];
+				if (strcmp(sub, "body") == 0) {
+					st->st_size =
+					    (off_t)(s->buf_end - s->buf_start);
+				} else if (strcmp(sub, "path") == 0) {
+					st->st_size = s->path
+					                  ? (off_t)strlen(s->path)
+					                  : 0;
+				} else if (strcmp(sub, "ranges") == 0) {
+					size_t sz;
+					char *tmp = build_ranges_filtered(
+					    ed, s->buf_start, s->buf_end, &sz);
+					st->st_size = (off_t)sz;
+					free(tmp);
+				} else if (strcmp(sub, "range") == 0) {
+					if (!s->has_range) {
+						editor_unlock(ed);
+						return -ENOENT;
+					}
+					char tmp[64];
+					st->st_size = snprintf(
+					    tmp, sizeof(tmp), "%d %d\n",
+					    s->range_start, s->range_end);
+				}
+			}
 		}
 
 		editor_unlock(ed);
 		return 0;
 	}
 
-	if (is_symlink(path)) {
-		char tmp[64];
-		st->st_mode = S_IFLNK | 0777;
-		st->st_nlink = 1;
-		st->st_size =
-		    snprintf(tmp, sizeof(tmp), "/proc/%d/cwd", (int)getpid());
-		return 0;
-	}
-
 	/* In-flight or committed temp files */
 	TempFile *tf = find_temp(ctx, path);
 	if (tf) {
@@ -220,6 +305,8 @@ static int op_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
 		      off_t offset, struct fuse_file_info *fi) {
 	(void)offset;
 	(void)fi;
+	FuseCtx *ctx = get_ctx();
+	Editor *ed = ctx->ed;
 
 	filler(buf, ".", NULL, 0);
 	filler(buf, "..", NULL, 0);
@@ -231,13 +318,36 @@ static int op_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
 		return 0;
 	}
 	if (strcmp(path, "/buffer") == 0) {
-		filler(buf, "0", NULL, 0);
+		filler(buf, "count", NULL, 0);
+		editor_lock(ed);
+		int n = ed->files_count;
+		editor_unlock(ed);
+		char tmp[32];
+		for (int i = 0; i < n; i++) {
+			snprintf(tmp, sizeof(tmp), "%d", i);
+			filler(buf, tmp, NULL, 0);
+		}
 		return 0;
 	}
-	if (strcmp(path, "/buffer/0") == 0) {
+	/* /buffer/N */
+	const char *sub = NULL;
+	int idx = parse_buffer_path(path, &sub);
+	if (idx == -3) {
 		filler(buf, "body", NULL, 0);
 		filler(buf, "path", NULL, 0);
 		filler(buf, "ranges", NULL, 0);
+		/* range only shown when has_range */
+		editor_lock(ed);
+		int slot_idx = -parse_buffer_path(path, NULL) - 3;
+		/* re-parse to get numeric index */
+		const char *rest = path + 8;
+		int sidx = atoi(rest);
+		bool hr = (sidx >= 0 && sidx < ed->files_count &&
+			   ed->files[sidx].has_range);
+		editor_unlock(ed);
+		if (hr)
+			filler(buf, "range", NULL, 0);
+		(void)slot_idx;
 		return 0;
 	}
 	return -ENOENT;
@@ -280,7 +390,7 @@ static int op_open(const char *path, struct fuse_file_info *fi) {
 
 /*
  * op_create — called when a new file is created (e.g. sed -i's temp file).
- * We only allow creation inside /buffer/0/ and reject known permanent names.
+ * Allow creation inside any /buffer/N/ prefix.
  */
 static int op_create(const char *path, mode_t mode, struct fuse_file_info *fi) {
 	(void)mode;
@@ -289,7 +399,10 @@ static int op_create(const char *path, mode_t mode, struct fuse_file_info *fi) {
 	if (is_file(path) || is_dir(path))
 		return -EEXIST;
 
-	if (strncmp(path, "/buffer/0/", 10) != 0)
+	/* Must be under /buffer/N/ */
+	const char *sub = NULL;
+	int idx = parse_buffer_path(path, &sub);
+	if (idx < 0 || !sub)
 		return -EACCES;
 
 	TempFile *tf = find_temp(ctx, path);
@@ -329,52 +442,97 @@ static int op_read(const char *path, char *buf, size_t size, off_t offset,
 		return (int)to_copy;
 	}
 
-	if (strcmp(path, "/buffer/0/body") == 0) {
+	if (strcmp(path, "/buffer/count") == 0) {
 		editor_lock(ed);
-		if (offset >= (off_t)ed->text.len) {
-			editor_unlock(ed);
-			return 0;
-		}
-		size_t to_copy = ed->text.len - (size_t)offset;
-		if (to_copy > size)
-			to_copy = size;
-		memcpy(buf, ed->text.data + offset, to_copy);
+		char tmp[32];
+		int n = snprintf(tmp, sizeof(tmp), "%d\n", ed->files_count);
 		editor_unlock(ed);
+		if (offset >= (off_t)n)
+			return 0;
+		size_t to_copy = (size_t)(n - offset);
+		if (to_copy > size) to_copy = size;
+		memcpy(buf, tmp + offset, to_copy);
 		return (int)to_copy;
 	}
 
-	if (strcmp(path, "/buffer/0/path") == 0) {
+	/* /buffer/N/... */
+	const char *sub = NULL;
+	int idx = parse_buffer_path(path, &sub);
+	if (idx >= 0 && sub) {
 		editor_lock(ed);
-		size_t flen = ed->filename ? strlen(ed->filename) : 0;
-		if (offset >= (off_t)flen) {
+		if (idx >= ed->files_count) {
 			editor_unlock(ed);
-			return 0;
+			return -ENOENT;
+		}
+		FileSlot *s = &ed->files[idx];
+
+		if (strcmp(sub, "body") == 0) {
+			int slot_len = s->buf_end - s->buf_start;
+			if (offset >= (off_t)slot_len) {
+				editor_unlock(ed);
+				return 0;
+			}
+			size_t to_copy = (size_t)(slot_len - offset);
+			if (to_copy > size) to_copy = size;
+			memcpy(buf, ed->text.data + s->buf_start + offset, to_copy);
+			editor_unlock(ed);
+			return (int)to_copy;
 		}
-		size_t to_copy = flen - (size_t)offset;
-		if (to_copy > size)
-			to_copy = size;
-		memcpy(buf, ed->filename + offset, to_copy);
-		editor_unlock(ed);
-		return (int)to_copy;
-	}
 
-	if (strcmp(path, "/buffer/0/ranges") == 0) {
-		editor_lock(ed);
-		size_t rlen;
-		char *rbuf = build_ranges(ed, &rlen);
-		editor_unlock(ed);
+		if (strcmp(sub, "path") == 0) {
+			size_t flen = s->path ? strlen(s->path) : 0;
+			if (offset >= (off_t)flen) {
+				editor_unlock(ed);
+				return 0;
+			}
+			size_t to_copy = flen - (size_t)offset;
+			if (to_copy > size) to_copy = size;
+			memcpy(buf, s->path + offset, to_copy);
+			editor_unlock(ed);
+			return (int)to_copy;
+		}
 
-		if (!rbuf)
-			return -ENOMEM;
-		if (offset >= (off_t)rlen) {
+		if (strcmp(sub, "ranges") == 0) {
+			size_t rlen;
+			char *rbuf = build_ranges_filtered(ed, s->buf_start,
+							   s->buf_end, &rlen);
+			editor_unlock(ed);
+			if (!rbuf) return -ENOMEM;
+			if (offset >= (off_t)rlen) { free(rbuf); return 0; }
+			size_t to_copy = rlen - (size_t)offset;
+			if (to_copy > size) to_copy = size;
+			memcpy(buf, rbuf + offset, to_copy);
 			free(rbuf);
-			return 0;
+			return (int)to_copy;
 		}
-		size_t to_copy = rlen - (size_t)offset;
-		if (to_copy > size)
-			to_copy = size;
-		memcpy(buf, rbuf + offset, to_copy);
-		free(rbuf);
+
+		if (strcmp(sub, "range") == 0) {
+			if (!s->has_range) {
+				editor_unlock(ed);
+				return -ENOENT;
+			}
+			char tmp[64];
+			int n = snprintf(tmp, sizeof(tmp), "%d %d\n",
+					 s->range_start, s->range_end);
+			editor_unlock(ed);
+			if (offset >= (off_t)n) return 0;
+			size_t to_copy = (size_t)(n - offset);
+			if (to_copy > size) to_copy = size;
+			memcpy(buf, tmp + offset, to_copy);
+			return (int)to_copy;
+		}
+
+		editor_unlock(ed);
+		return -ENOENT;
+	}
+
+	/* temp files */
+	TempFile *tf = find_temp(ctx, path);
+	if (tf) {
+		if (offset >= (off_t)tf->len) return 0;
+		size_t to_copy = tf->len - (size_t)offset;
+		if (to_copy > size) to_copy = size;
+		memcpy(buf, tf->data + offset, to_copy);
 		return (int)to_copy;
 	}
 
@@ -428,13 +586,55 @@ static int op_ftruncate(const char *path, off_t size,
 	return 0;
 }
 
-/* Apply content to the editor and wake the render loop. Caller holds no lock.
- */
+/* Apply content to the editor body (slot 0, legacy) and wake render loop. */
 static void apply_body(Editor *ed, const char *data, size_t len) {
 	editor_replace_body(ed, data, len);
 	wake_render();
 }
 
+/* Apply content to a specific slot by index. Creates slot if needed. */
+static void apply_slot_body(Editor *ed, int idx, const char *data, size_t len) {
+	editor_lock(ed);
+	if (idx == ed->files_count) {
+		/* Create a new slot */
+		editor_unlock(ed);
+		editor_add_file_slot(ed, NULL, data, (int)len);
+	} else if (idx < ed->files_count) {
+		/* Replace existing slot content */
+		FileSlot *s = &ed->files[idx];
+		int old_len = s->buf_end - s->buf_start;
+		/* Delete old content */
+		strbuf_delete(&ed->text, s->buf_start, old_len);
+		/* Adjust all slot boundaries as if a delete happened */
+		for (int i = 0; i < ed->files_count; i++) {
+			if (i == idx) continue;
+			if (ed->files[i].buf_start >= s->buf_end)
+				ed->files[i].buf_start -= old_len;
+			if (ed->files[i].buf_end > s->buf_start)
+				ed->files[i].buf_end -= old_len;
+		}
+		s->buf_end = s->buf_start;
+		/* Insert new content */
+		if (data && len > 0) {
+			strbuf_insert(&ed->text, s->buf_start, data, len);
+			/* Adjust boundaries after insert */
+			s->buf_end = s->buf_start + (int)len;
+			for (int i = 0; i < ed->files_count; i++) {
+				if (i == idx) continue;
+				if (ed->files[i].buf_start >= s->buf_start)
+					ed->files[i].buf_start += (int)len;
+				if (ed->files[i].buf_end > s->buf_start)
+					ed->files[i].buf_end += (int)len;
+			}
+		}
+		s->dirty = true;
+		editor_unlock(ed);
+	} else {
+		editor_unlock(ed);
+	}
+	wake_render();
+}
+
 static int op_release(const char *path, struct fuse_file_info *fi) {
 	FuseCtx *ctx = get_ctx();
 	Editor *ed = ctx->ed;
@@ -459,10 +659,7 @@ static int op_release(const char *path, struct fuse_file_info *fi) {
 		return 0;
 	}
 
-	if (strcmp(path, "/buffer/0/body") == 0) {
-		apply_body(ed, wb->data, wb->len);
-
-	} else if (strcmp(path, "/cursor") == 0 && wb->data && wb->len > 0) {
+	if (strcmp(path, "/cursor") == 0 && wb->data && wb->len > 0) {
 		char *tmp = malloc(wb->len + 1);
 		int cidx = 0, sidx = 0;
 		if (tmp) {
@@ -472,11 +669,42 @@ static int op_release(const char *path, struct fuse_file_info *fi) {
 			free(tmp);
 		}
 		editor_lock(ed);
-		// XXX HANDLE OVERFLOW
 		ed->cursor_idx = cidx;
 		ed->selection_anchor = sidx;
 		editor_unlock(ed);
 		wake_render();
+	} else {
+		const char *sub = NULL;
+		int idx = parse_buffer_path(path, &sub);
+		if (idx >= 0 && sub) {
+			if (strcmp(sub, "body") == 0) {
+				apply_slot_body(ed, idx, wb->data, wb->len);
+			} else if (strcmp(sub, "path") == 0 && wb->data) {
+				char *p = malloc(wb->len + 1);
+				if (p) {
+					memcpy(p, wb->data, wb->len);
+					p[wb->len] = '\0';
+					editor_lock(ed);
+					if (idx < ed->files_count) {
+						free(ed->files[idx].path);
+						ed->files[idx].path = p;
+					} else {
+						free(p);
+					}
+					editor_unlock(ed);
+				}
+			} else if (strcmp(sub, "range") == 0 && wb->data) {
+				char *tmp = malloc(wb->len + 1);
+				if (tmp) {
+					memcpy(tmp, wb->data, wb->len);
+					tmp[wb->len] = '\0';
+					int rs = 0, re = 0;
+					sscanf(tmp, "%d %d", &rs, &re);
+					free(tmp);
+					editor_set_file_slot_range(ed, idx, rs, re);
+				}
+			}
+		}
 	}
 
 	free(wb->data);
@@ -492,8 +720,10 @@ static int op_rename(const char *from, const char *to) {
 	if (!tf)
 		return -ENOENT;
 
-	if (strcmp(to, "/buffer/0/body") == 0)
-		apply_body(ed, tf->data, tf->len);
+	const char *sub = NULL;
+	int idx = parse_buffer_path(to, &sub);
+	if (idx >= 0 && sub && strcmp(sub, "body") == 0)
+		apply_slot_body(ed, idx, tf->data, tf->len);
 
 	free_temp(tf);
 	return 0;
diff --git a/main.c b/main.c
@@ -418,8 +418,8 @@ int main(int argc, char *argv[]) {
 	float line_height = (float)TTF_GetFontHeight(font) * 1.5;
 	float cursor_height = (float)TTF_GetFontHeight(font);
 	Editor *ed = editor_create(char_width, line_height);
-	if (argc > 1) {
-		editor_load_file(ed, argv[1]);
+	for (int i = 1; i < argc; i++) {
+		editor_open_file(ed, argv[i]);
 		editor_parse_ansi_codes(ed);
 	}
 	FuseIPC *ipc = fuse_ipc_start(ed);
@@ -435,7 +435,8 @@ int main(int argc, char *argv[]) {
 
 	SelectionState sel = { .last_click_byte = -1, .stack_len = 0 };
 #ifdef HAVE_TREESITTER
-	sel.ts = ed->filename ? ts_state_create(ed->filename) : NULL;
+	sel.ts = (ed->files_count > 0 && ed->files[0].path)
+	             ? ts_state_create(ed->files[0].path) : NULL;
 #endif
 
 	SDL_StartTextInput(window);
diff --git a/renderer.c b/renderer.c
@@ -265,12 +265,28 @@ void render_ctx_init(RenderCtx *ctx, SDL_Renderer *renderer, TTF_Font *font,
 	ctx->margin = 20.0f;
 }
 
+static void render_separators(RenderCtx *ctx, const Editor *ed)
+{
+	for (int i = 0; i < ed->files_count - 1; i++) {
+		int boundary_byte = ed->files[i].buf_end;
+		VisualPos vp = editor_byte_to_visual_pos(ed, boundary_byte);
+		float y = ctx->margin + vp.row * ctx->line_height
+			  - ctx->scroll_y + ctx->line_height / 2.0f;
+		if (y >= 0 && y < (float)ctx->render_h) {
+			SDL_SetRenderDrawColor(ctx->renderer, 80, 80, 80, 255);
+			SDL_RenderLine(ctx->renderer, 0, y,
+				       (float)ctx->render_w, y);
+		}
+	}
+}
+
 void render_editor(RenderCtx *ctx, const Editor *ed)
 {
 	SDL_GetRenderOutputSize(ctx->renderer, &ctx->render_w, &ctx->render_h);
 	SDL_SetRenderDrawColor(ctx->renderer, 255, 255, 255, 255);
 	SDL_RenderClear(ctx->renderer);
 	render_selection(ctx, ed);
+	render_separators(ctx, ed);
 	render_text(ctx, ed);
 	render_cursor(ctx, ed);
 	SDL_RenderPresent(ctx->renderer);