diff options
| -rw-r--r-- | next-steps.txt | 2 | ||||
| -rw-r--r-- | src/file_pane.zig | 3 | ||||
| -rw-r--r-- | src/gui/gui.zig | 10 | ||||
| -rw-r--r-- | src/pardes.zig | 59 | ||||
| -rw-r--r-- | src/tty/tty.zig | 157 | ||||
| -rw-r--r-- | test/snapshots/filewatch.golden | 93 | ||||
| -rw-r--r-- | test/snapshots/filewatch.snap | 48 |
7 files changed, 367 insertions, 5 deletions
diff --git a/next-steps.txt b/next-steps.txt index a068041c..b421f7a4 100644 --- a/next-steps.txt +++ b/next-steps.txt @@ -5,7 +5,7 @@ + Add a new builtin that will list all builtins and their respective shortcuts if they have any (note that shortcuts may be spc+... or just keyboard or mouse chords/movements) + Scrolling on a touchpad moves horizontally accidentally way too much, add some heuristics to prioritize vertical scrolling and make horizontal scrolling work only when the user is a bit more explicit about it - for example by measuring if there was recent high-enough vertical movement, etc. + Add a new builtin to list the themes and create an output window to select those themes; each theme now must live on its own .zig file, when moving with n/N on out ThemeSel output window it will select that theme so it's a interactive way to select the themes. We're being clever now with how these output windows will work, this feature depends on the new semantics for output windows but basically now the ThemeSel output panes will set the Command flag that will basically tell that when doing n/N movement on the output pane instead of Look-ing it will Execute the line, so the ThemeSel output pane will be like: (Theme acme, Theme a, Theme b) seperated by lines so the n/N will automatically consider selecting the Theme + argument (this is not output pane specific, it should be generalized to work on every pane kind), and will Exec it. Add new themes that will be automatically exported from helix and zed, a new binary will execute that will generate .zig files, this binary will be added as a execute step on the build.zig so when building it will regenarate those .zig that will be then included on the runtime as themes. -- Use zig's 0.16 new threaded io and the std functions that the build system uses to listen for file changes, it needs to use something from the os so it's not a busy wait. It will only track files on open panes. when a file is updated, doing Undo will return to the state that it was before so if a file automatically updates the user won't lose the unsaved state as it was before and the editor won't need to handle with merging the unsaved changes and the new update. ++ Use zig's 0.16 new threaded io and the std functions that the build system uses to listen for file changes, it needs to use something from the os so it's not a busy wait. It will only track files on open panes. when a file is updated, doing Undo will return to the state that it was before so if a file automatically updates the user won't lose the unsaved state as it was before and the editor won't need to handle with merging the unsaved changes and the new update. + There's some bug when rendering from a ssh session - the screen gets dirty with old chars when scrolling. - There's some bad performance when dealing with specially large files or long lines; write a perf harness for this and optimize the performance. + The Look and Exec should be regular semantic entities, i.e. their only special feature should be the keys/shortcuts they're assigned so we could execute Look main.zig and it would call the regular look on it, but also we could Look some string like @`ls -la` (you can change the syntax, but this also depends on that syntax change so it's centralized and easy to change later). This basically will allow you to make some big refactorings and cut a lot of code (hopefully) and also allow some funny things like @`Look .` (basically allowing nested things like this for no good reason other than, it's elegant and it's fun!) diff --git a/src/file_pane.zig b/src/file_pane.zig index 68fea40f..739d7b54 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -33,6 +33,9 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { pane.file = .{ .path = path_copy, .content = content, .scroll = scroll }; pane.cur_pinned = true; pane.cur_row = @intCast(scroll); + // watches follow pane lifetime: this is the only place a real file is read + // off disk, and deinitPane is the only place one goes away + p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); return pane; } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index d8086981..998573b5 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2137,6 +2137,11 @@ fn drainEffects( _ = c.SDL_SetClipboardText(z.ptr); }, .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), + // ponytail: the SDL shell does not watch files. Everything the core + // needs is already here (the effect and the file_changed event) — what + // is missing is the ~50 lines of inotify plumbing in tty.zig, and a + // shell that never delivers the event simply never reloads. + .watch => {}, .quit => {}, }; } @@ -2174,8 +2179,9 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { }, // look on a URL → a new tab .open_link => |url| openLinkWeb(gpa, url.slice()), - // nothing to spawn/write/resize/save/dump into — all no-ops - .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .quit => {}, + // nothing to spawn/write/resize/save/dump into, and no filesystem to + // watch — all no-ops + .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .watch, .quit => {}, }; } diff --git a/src/pardes.zig b/src/pardes.zig index 93ea1016..c1f8df44 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -555,6 +555,11 @@ pub const Event = union(enum) { /// stale answer (the pane was closed, or a newer query superseded it) and /// is dropped. lsp_resp: struct { id: u32, rows: []const u8 }, + /// a file the shell was asked to watch changed on disk; `bytes` are its + /// new contents, borrowed for this call exactly like `output`. A shell + /// with no filesystem (the browser) or no watcher simply never sends one — + /// nothing in the core waits for it. + file_changed: struct { pane: u8, bytes: []const u8 }, paste: []const u8, pinch: f32, touch_scroll: f32, @@ -582,6 +587,12 @@ pub const Effect = union(enum) { /// core (like save_file) and must SNAPSHOT them before the worker starts — /// the core keeps editing while this is in flight. lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, + /// start (`on`) or stop watching this pane's file on disk. Starting, the + /// shell reads the path off the core exactly like save_file does; stopping + /// carries nothing, because by the time an `off` is drained the pane is + /// already freed — the shell remembers what it watches per pane id. + /// Only real files ask for this: an output buffer has no file behind it. + watch: struct { pane: u8, on: bool }, quit, fn Buf(comptime n: usize) type { @@ -1151,6 +1162,14 @@ pub const Pardes = struct { if (iv.grid.len > 0) p.gpa.free(iv.grid); } if (pane.file) |*f| { + // a watched file is going away: tell the shell to drop it. The id + // comes from the slot, which every caller still has pointing at + // `pane` (they null it right after) — deinitPane has never needed + // one before and threading it through seven call sites for this is + // worse than the scan. + if (f.output == null) for (p.panes, 0..) |slot, i| { + if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = false } }); + }; p.gpa.free(f.path); p.gpa.free(f.content); if (f.highlights.len > 0) p.gpa.free(f.highlights); @@ -1283,6 +1302,7 @@ pub const Pardes = struct { }, .eof => |e| p.removePane(e.pane), .lsp_resp => |r| p.lspResponse(r.id, r.rows), + .file_changed => |fc| p.fileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| p.handleMouse(m), .paste => |bytes| { @@ -4271,6 +4291,38 @@ pub const Pardes = struct { term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return); } + /// Something outside pardes rewrote a file we have open. Commit the buffer + /// we are holding — unsaved edits and all — onto the undo stack, then swap + /// in the bytes from disk. + /// + /// THAT IS THE WHOLE DESIGN, not an implementation detail. An external + /// update is pushed exactly like an edit the user typed, so `u` walks back + /// to the buffer as it was a keystroke ago and unsaved work is never lost. + /// It is also why the feature is nearly free: pardes never has to merge its + /// buffer against the new file, never has to ask "reload? keep? diff?", and + /// there is no third state between "what is on disk" and "what I typed" — + /// the undo stack, which already exists, IS the history of both. + fn fileChanged(p: *Pardes, id: u8, bytes: []const u8) void { + const pane = p.panes[id] orelse return; + if (pane.file == null) return; + const f = &pane.file.?; + // the disk arrived at what we already hold: an undo step that changes + // nothing is worse than no undo step + if (std.mem.eql(u8, f.content, bytes)) return; + const new = p.gpa.dupe(u8, bytes) catch return; + p.pushUndo(pane); + file_pane.setContent(p, f, new); + // keep the cursor where it was, clamped onto the new text: restoreSnap + // reads only the cursor and selection out of the snapshot it is handed, + // so handing it the pane's own is exactly "clamp what you have". + file_pane.restoreSnap(pane, f, .{ + .content = undefined, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }); + } + fn handleMouse(p: *Pardes, m: Mouse) void { const mcol = @min(m.col, p.screen_w -| 1); const mrow = @min(m.row, p.screen_h -| 1); @@ -5506,6 +5558,13 @@ pub const Pardes = struct { pane.cur_row = @intCast(src.scroll); pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); + // a restored file pane is watched like an opened one. Note + // its content is the DUMP's, which may already differ from + // disk; the shell stamps the watch from what it has, so the + // first external write reconciles the pane to disk and the + // dump's version lands one `u` away — which is the same + // bargain as any other external update. + if (out == null) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); p.restoreTail(pane, src.tag); }, .image => { diff --git a/src/tty/tty.zig b/src/tty/tty.zig index b4bd4175..e2fe10e8 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -6,6 +6,7 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; +const linux = std.os.linux; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); @@ -36,10 +37,32 @@ pub const Command = struct { paste: []const u8, /// a language query finished on a worker; rows are gpa-owned lsp_done: struct { id: u32, rows: []u8 }, + /// something happened in a watched directory (see watchFiles) + files_changed, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); +/// The files on open panes, watched through ONE inotify instance. +/// +/// `wd` is the descriptor for the file's CONTAINING DIRECTORY, not for the +/// file: almost nothing rewrites a file in place. An editor, `git checkout`, a +/// formatter — they write a temp file and rename it over the target, which +/// swaps the inode, and a watch on the file itself keeps reporting on the old +/// unlinked one forever. The directory sees the new inode arrive (MOVED_TO / +/// CREATE). std.Build.Watch marks directories for exactly this reason; it uses +/// fanotify because it watches thousands of them across mount points and needs +/// FID-keyed dedup, whereas sixteen panes' worth of inotify is a third of the +/// code, hands us no privilege question, and has no kernel version floor. +/// +/// `hash` is what we last saw ON DISK. A reload only fires when it moves, so +/// pardes's own Save — which restamps the hash from the bytes it just wrote — +/// never looks like an external change and never touches the undo stack. +/// A hash rather than mtime+size because we have to read the file to hand the +/// core its bytes anyway, so the exact answer costs nothing extra and there is +/// no clock granularity to reason about. +const Watch = struct { wd: c_int, hash: u64 }; + /// One language query, owned by the worker that runs it. Everything the /// backend may read is copied in here before the worker starts: the core goes /// on editing the moment the effect is drained, so a borrowed slice would be a @@ -127,6 +150,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var gens: [pardes.MAX_PANES]u32 = @splat(0); // the single in-flight language query (see the .lsp effect) var lsp_task: ?std.Io.Future(anyerror!void) = null; + // One inotify instance for every watched pane, opened here — before any + // thread exists — so the pre-loop effect drain below can already mark the + // file a positional path argument opened. -1 off linux: watchPane goes + // quiet and the core simply never gets a file_changed event. + var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1; + var watches: [pardes.MAX_PANES]?Watch = @splat(null); + var watch_task: ?std.Io.Future(anyerror!void) = null; var loop: Loop = .init(io, &tty, &vx); defer { // reap the reader tasks (cancel interrupts a blocked read) before @@ -144,6 +174,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { t.cancel(io) catch {}; lsp_task = null; } + // same contract as the pty readers: cancel unblocks the watcher's + // read, and only then is the fd safe to close + if (watch_task) |*t| { + t.cancel(io) catch {}; + watch_task = null; + } + if (inotify_fd >= 0) { + _ = libc.close(inotify_fd); + inotify_fd = -1; + } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), .paste => |b| gpa.free(@constCast(b)), @@ -154,7 +194,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, false); + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, false); try loop.start(); defer loop.stop(); @@ -207,7 +247,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| { pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, gens[id], &loop }); }; + // ...and the one file watcher. Started here rather than lazily on the + // first watched pane because the fd already exists and an unwatched + // inotify instance just parks in read(2) — one thread for the process, + // however many panes come and go. + if (inotify_fd >= 0) watch_task = io.concurrent(watchFiles, .{ io, inotify_fd, &loop }) catch null; + var check_files = false; var pending: ?@TypeOf(Command.value) = .tick; while (!core.quit) { var event = if (pending) |ev| blk: { @@ -279,6 +325,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, + // Coalesced on purpose: a burst of writes (a formatter, a + // build, a `git checkout`) collapses into ONE pass below, so + // it cannot queue a reload — or an undo entry — per write. + .files_changed => check_files = true, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); gpa.free(d.rows); @@ -297,7 +347,29 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { tz_event.end(); if (stop) break; - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, true); + // A watched directory moved. The wake says only THAT something + // happened, so re-read every watched pane and hand the core the ones + // whose bytes actually changed — that comparison is what keeps our own + // Save, and any write that lands on identical content, out of the undo + // stack. Reading here rather than on the watcher thread because the + // core is the only thing that knows which pane a path belongs to, and + // opening a file pane already reads synchronously on this thread. + if (check_files) { + check_files = false; + for (&watches, 0..) |*slot, id| { + if (slot.* == null) continue; + const pane = core.panes[id] orelse continue; + const f = pane.file orelse continue; + const bytes = look.readFile(gpa, f.path) catch continue; + defer gpa.free(bytes); + const h = std.hash.Wyhash.hash(0, bytes); + if (h == slot.*.?.hash) continue; + slot.*.?.hash = h; + core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); + } + } + + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; the live @@ -316,6 +388,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { slot.* = null; gens[pid] +%= 1; }; + // the replay core's pane ids mean new things, and the dying core's + // `watch off` effects go into a queue nobody drains — drop the lot + // here. The new core emits its own `on`s as it builds its panes. + for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0); kitty_handles = @splat(null); core.deinit(); core = nc; @@ -401,6 +477,8 @@ fn drainEffects( vx: *vaxis.Vaxis, tty: *vaxis.Tty, lsp_task: *?std.Io.Future(anyerror!void), + inotify_fd: c_int, + watches: *[pardes.MAX_PANES]?Watch, threads_ok: bool, ) void { while (core.nextEffect()) |effect| switch (effect) { @@ -454,6 +532,9 @@ fn drainEffects( if (fd < 0) continue; writeFd(fd, f.content); _ = libc.close(fd); + // our own write is about to come back as a watch event: restamp + // from the bytes we just put there so it reads as "no change" + if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); }, .write_dump => { const out = core.dump_out orelse continue; @@ -517,6 +598,17 @@ fn drainEffects( continue; }; }, + .watch => |w| { + // starting, the path and the on-disk bytes are read off the core + // (same split as save_file); stopping, the pane is already gone + var path: ?[]const u8 = null; + var hash: u64 = 0; + if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| { + path = f.path; + hash = std.hash.Wyhash.hash(0, f.content); + }; + watchPane(inotify_fd, watches, w.pane, path, hash); + }, .quit => {}, }; } @@ -545,6 +637,67 @@ fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { return; } +/// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any +/// other OS this returns silently, the core never receives a file_changed +/// event, and the feature is simply off — a state the core has to tolerate +/// regardless, since the browser shell has no filesystem at all. +/// ponytail: darwin wants the FSEvents half of std.Build.Watch here. +fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void { + if (comptime builtin.os.tag != .linux) return; + if (fd < 0) return; + if (watches[id]) |old| { + // inotify hands out ONE descriptor per directory, so two panes on + // files in the same directory share it: drop the mark only when the + // last of them lets go, or closing one blinds the other. + var shared = false; + for (watches, 0..) |other, i| { + const o = other orelse continue; + if (i != id and o.wd == old.wd) shared = true; + } + if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); + watches[id] = null; + } + const p = path orelse return; + const dir = std.fs.path.dirname(p) orelse "."; + var dbuf: [4096:0]u8 = undefined; + if (dir.len >= dbuf.len) return; + @memcpy(dbuf[0..dir.len], dir); + dbuf[dir.len] = 0; + // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one + // per write(2), which is most of the debounce for free. MOVED_TO and + // CREATE catch the rename-over and the delete-then-recreate that are how + // files are actually replaced (see Watch). + const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; + const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask); + if (wd < 0) return; + watches[id] = .{ .wd = wd, .hash = hash }; +} + +/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the +/// events: the loop re-reads every watched pane anyway, so the only thing an +/// event carries that we need is THAT something happened, and parsing would +/// mean sharing the watch table with the thread that mutates it. Same shape as +/// readPty — block off the loop, hand the loop an event, keep the core a state +/// machine that never blocks. Going through std.Io.File rather than a raw +/// read(2) is what lets the teardown `cancel` interrupt it. +/// +/// ponytail: churn in a watched directory that never touches the watched file +/// still costs a wake and a re-read per event. The ceiling is one directory +/// per open file pane; filter by basename here if it ever shows up in a +/// profile. +fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { + const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; + var read_buf: [4096]u8 = undefined; + var reader = file.readerStreaming(io, &read_buf); + while (true) { + var buf: [4096]u8 = undefined; + var vec = [_][]u8{&buf}; + const n = reader.interface.readVec(&vec) catch break; + if (n == 0) break; + loop.postEvent(.files_changed) catch break; + } +} + /// Consume SIGWINCH synchronously (it is blocked in every thread) and post /// the new size as a winsize event from normal thread context — the one place /// vaxis's Io-backed queue is safe to touch on a resize. diff --git a/test/snapshots/filewatch.golden b/test/snapshots/filewatch.golden new file mode 100644 index 00000000..64c43c85 --- /dev/null +++ b/test/snapshots/filewatch.golden @@ -0,0 +1,93 @@ +== snap edited grid=150x30 cursor=15,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/filewatch/cwd/w.txt Save De NOR /tmp/pardes-snap/filewatch/cwd Del NOR /tmp/pardes-snap/filewatch/cwd Del +| 1 liMYEDITne 1 +| 2 line 2 w.txt w.txt +| 3 line 3 +| 4 line 4 +| 5 line 5 +| 6 line 6 w.txt +| 7 line 7 +| 8 line 8 +| 9 line 9 +| 10 line 10 +| 11 line 11 +| 12 line 12 +| 13 +| +| NOR /tmp/pardes-snap/filewatch/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap reloaded grid=150x30 cursor=54,5 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/filewatch/cwd/w.txt Save De TTY /tmp/pardes-snap/filewatch/cwd Del NOR /tmp/pardes-snap/filewatch/cwd Del +| 1 EXTERNAL $ ls +| 2 w.txt w.txt +| $ echo EXTERN''AL > w.txt +| $ +| +| +| +| +| +| +| +| +| +| +| NOR /tmp/pardes-snap/filewatch/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| +== snap undone grid=150x30 cursor=15,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/filewatch/cwd/w.txt Save De TTY /tmp/pardes-snap/filewatch/cwd Del NOR /tmp/pardes-snap/filewatch/cwd Del +| 1 liMYEDITne 1 $ ls +| 2 line 2 w.txt w.txt +| 3 line 3 $ echo EXTERN''AL > w.txt +| 4 line 4 $ +| 5 line 5 +| 6 line 6 +| 7 line 7 +| 8 line 8 +| 9 line 9 +| 10 line 10 +| 11 line 11 +| 12 line 12 +| 13 +| +| NOR /tmp/pardes-snap/filewatch/cwd Del +| +| w.txt +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/filewatch.snap b/test/snapshots/filewatch.snap new file mode 100644 index 00000000..1ac8d081 --- /dev/null +++ b/test/snapshots/filewatch.snap @@ -0,0 +1,48 @@ +# external file updates: a write from outside pardes lands in the open pane, +# and Undo brings the unsaved edit back. That is the whole contract — the +# update is committed like any other edit, so nothing has to be merged. +lines w.txt 12 +start 30 150 -n 3 +wait 8000 w.txt +stable 700 20000 +# open w.txt as a file pane: type the name into the shell, right-click it +press left 5 8 +release left 5 8 +stable 400 5000 +key i +text w.txt +key esc +settle 100 +press right 6 8 +release right 6 8 +wait 10000 line 3 +stable 700 15000 +# an UNSAVED edit on line 1 +press left 10 3 +release left 10 3 +stable 400 5000 +key i +text MYEDIT +key esc +settle 100 +stable 400 5000 +snap edited +# overwrite the file from a shell in another column. The marker is split so +# the ECHOED command line never matches the wait — only the reloaded pane does +press left 60 5 +release left 60 5 +stable 400 5000 +key c-b +stable 600 8000 +text echo EXTERN''AL > w.txt +key enter +wait 10000 EXTERNAL +stable 700 10000 +snap reloaded +# back in the file pane: one undo and the unsaved edit is there again +press left 10 3 +release left 10 3 +stable 400 5000 +key u +stable 400 5000 +snap undone |
