diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/file_pane.zig | 7 | ||||
| -rw-r--r-- | src/nested.zig | 98 | ||||
| -rw-r--r-- | src/pardes.zig | 57 | ||||
| -rw-r--r-- | src/tutor.txt | 3 |
4 files changed, 112 insertions, 53 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig index abf37448..fd0ab2b7 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -36,7 +36,8 @@ pub const State = struct { /// goes through setContent, which bumps this; a pipe completion accepted /// against another revision would overwrite intervening work. revision: u32 = 0, - /// Revision last handed to Save. Equal means the screen matches disk. + /// Revision last known to match disk, after Save or an external reload. + /// Equal means the screen matches disk. saved_revision: u32 = 0, /// Non-null for a generated output buffer rather than an on-disk file. output: ?output_pane.Output = null, @@ -485,6 +486,10 @@ pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void { const new = p.gpa.dupe(u8, bytes) catch return; pushUndo(p, pane); setContent(p, f, new); + // These bytes came from the watched path, so the new on-screen revision + // is already saved. Undoing back to displaced local work bumps revision + // again and makes that restored edit dirty, as it should. + f.saved_revision = f.revision; // restoreSnap only consumes cursor/selection from this synthetic snapshot. restoreSnap(pane, f, .{ .content = undefined, diff --git a/src/nested.zig b/src/nested.zig index 16cea6a1..eb01b2e0 100644 --- a/src/nested.zig +++ b/src/nested.zig @@ -25,9 +25,9 @@ //! null and a pardes inside a pardes opens a second session, as before. //! //! macOS also has a third executable in the family: the app bundle. Its binary -//! is the same build as `bin/pardes` installed a second time, at a path that -//! shares nothing below the install prefix, so identity is compared at that -//! prefix — see samePardesExecutable. +//! is named `pardes`, like the tty frontend, wherever the bundle is installed. +//! Executable identity therefore comes from the family name rather than its +//! path — see samePardesExecutable. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; @@ -115,44 +115,22 @@ fn stripDeleted(link: []const u8) []const u8 { return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link; } -/// The install prefix a program directory belongs to. `bin/pardes` and -/// `pardes.app/Contents/MacOS/pardes` are one build installed twice and share -/// no directory at all, so comparing dirnames says they are strangers; both -/// reduce to the prefix, and so does everything else — a directory that is -/// neither wrapper is its own prefix, which leaves the same-directory rule -/// below exactly as strict as it was. -fn installPrefix(dir: []const u8) []const u8 { - const macos_dir = "/Contents/MacOS"; - if (std.mem.endsWith(u8, dir, macos_dir)) { - const app = dir[0 .. dir.len - macos_dir.len]; - if (std.mem.endsWith(u8, app, ".app")) return std.fs.path.dirname(app) orelse app; - } - const bin = "/bin"; - if (std.mem.endsWith(u8, dir, bin)) return dir[0 .. dir.len - bin.len]; - return dir; -} - /// The tty, SDL and macOS builds are sibling frontends of the same program. /// Their installed names differ only by `-gui` (and, for cross builds, share /// the same `-os-arch` tail), or not at all when one of them is the app bundle -/// — so any of them must recognise any other as an outer pardes. Requiring the -/// same install prefix retains the executable-identity check: an unrelated -/// ancestor merely named `pardes` is not enough. +/// — so any of them must recognise any other as an outer pardes. Paths are +/// deliberately ignored: the GUI may be installed system-wide while the tty +/// frontend is installed in the user's bin directory. fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool { const a = stripDeleted(a_raw); const b = stripDeleted(b_raw); - if (std.mem.eql(u8, a, b)) return true; - const a_dir = installPrefix(std.fs.path.dirname(a) orelse return false); - const b_dir = installPrefix(std.fs.path.dirname(b) orelse return false); - if (!std.mem.eql(u8, a_dir, b_dir)) return false; - return sameFamily(std.fs.path.basename(a), std.fs.path.basename(b)); } /// What is left of a family name after the frontend part: `` for `pardes` and /// `pardes-gui`, `-linux-aarch64` for the cross-built spellings of both. Null -/// when the name is not in the family at all — `not-pardes` and `pardesfoo` -/// are other programs. +/// when the name is not in the family at all — `not-pardes`, `pardesfoo`, and +/// helper binaries such as `pardes-snap` are other programs. fn familyTail(name: []const u8) ?[]const u8 { const rest = if (std.mem.startsWith(u8, name, "pardes-gui")) name["pardes-gui".len..] @@ -160,23 +138,30 @@ fn familyTail(name: []const u8) ?[]const u8 { name["pardes".len..] else return null; - // `pardesfoo` shares a prefix and nothing else. A tail is a tail or empty. - if (rest.len != 0 and rest[0] != '-') return null; + if (rest.len == 0) return rest; + // Build names have exactly `-os-arch` after the frontend. Validating both + // fields keeps sibling installs flexible without mistaking pardes-snap, + // pardes-perf, and the other helper executables for editor frontends. + if (rest[0] != '-') return null; + var fields = std.mem.splitScalar(u8, rest[1..], '-'); + const os = fields.next() orelse return null; + const arch = fields.next() orelse return null; + if (fields.next() != null) return null; + if (std.meta.stringToEnum(std.Target.Os.Tag, os) == null) return null; + if (std.meta.stringToEnum(std.Target.Cpu.Arch, arch) == null) return null; return rest; } -/// Two family names for the same build, given that they already share an -/// install prefix. The tails have to agree — a linux binary and an x86_64 one -/// in the same directory are two builds — unless one of them has no tail at +/// Two executable names in the same family. The tails have to agree — a linux +/// binary and an x86_64 one are two builds — unless one of them has no tail at /// all, which is the untagged name the default build and, unavoidably, the app /// bundle both produce: CFBundleExecutable is a fixed string, so the bundled /// copy of `pardes-macos-aarch64` is called `pardes` and nothing in the name -/// records what it was. Loosening it that far is safe because the prefix -/// already had to match, and a foreign-arch ancestor cannot be running here. +/// records what it was. A foreign-arch ancestor cannot be running here. fn sameFamily(a: []const u8, b: []const u8) bool { - if (std.mem.eql(u8, a, b)) return true; const a_tail = familyTail(a) orelse return false; const b_tail = familyTail(b) orelse return false; + if (std.mem.eql(u8, a, b)) return true; return a_tail.len == 0 or b_tail.len == 0 or std.mem.eql(u8, a_tail, b_tail); } @@ -262,9 +247,19 @@ pub fn outer() ?libc.pid_t { if (comptime !supported) return null; var self_buf: [4096]u8 = undefined; const self_exe = exeOf(libc.getpid(), &self_buf) orelse return null; + // A process harness may deliberately launch a fresh top-level pardes from + // inside another one. Its pid is a process-tree boundary, not an opt-out + // for the new session itself: pane shells below the child still detect it. + // This is what lets the snapshot harness exercise nested launches while + // the harness happens to be running in a real pardes pane. + const boundary = if (libc.getenv("PARDES_NESTED_BOUNDARY_PID")) |raw| + std.fmt.parseInt(libc.pid_t, std.mem.span(raw), 10) catch 0 + else + 0; var pid = libc.getppid(); var hops: usize = 0; while (pid > 1 and hops < 64) : (hops += 1) { + if (pid == boundary) return null; var buf: [4096]u8 = undefined; if (exeOf(pid, &buf)) |exe| if (samePardesExecutable(exe, self_exe)) return pid; pid = parentOf(pid) orelse return null; @@ -543,9 +538,11 @@ test "tty and GUI sibling executables recognise each other" { "/work/zig-out/bin/pardes-linux-aarch64", "/work/zig-out/bin/pardes-gui-linux-aarch64 (deleted)", )); - try std.testing.expect(!samePardesExecutable( - "/usr/bin/pardes", - "/work/zig-out/bin/pardes-gui", + // Installation paths do not define the family. This is the ordinary + // system-GUI/user-TTY pairing and the reason this comparison uses names. + try std.testing.expect(samePardesExecutable( + "/home/who/.local/bin/pardes", + "/usr/bin/pardes-gui", )); try std.testing.expect(!samePardesExecutable( "/work/zig-out/bin/pardes-linux-aarch64", @@ -555,9 +552,17 @@ test "tty and GUI sibling executables recognise each other" { "/work/zig-out/bin/not-pardes", "/work/zig-out/bin/not-pardes-gui", )); + try std.testing.expect(!samePardesExecutable( + "/one/bin/not-pardes", + "/two/bin/not-pardes", + )); + try std.testing.expect(!samePardesExecutable( + "/work/zig-out/bin/pardes-snap", + "/usr/bin/pardes", + )); } -test "the app bundle is the same build as the binary installed beside it" { +test "the app bundle is in the same executable family" { // What `pardes foo.zig` typed into the bundle's own shell has to resolve: // the ancestor is zig-out/pardes.app/..., this process is zig-out/bin/..., // and nothing below zig-out is shared. @@ -577,15 +582,12 @@ test "the app bundle is the same build as the binary installed beside it" { "/work/zig-out/pardes.app/Contents/MacOS/pardes", "/work/zig-out/bin/pardes-macos-aarch64", )); - // A different install is still a different program, however alike the - // paths look — this is the whole point of comparing anything at all. - try std.testing.expect(!samePardesExecutable( + // Installation location does not matter here either. + try std.testing.expect(samePardesExecutable( "/work/zig-out/pardes.app/Contents/MacOS/pardes", "/opt/zig-out/bin/pardes", )); - // The wrapper is only transparent when it IS the wrapper: `Contents/MacOS` - // under something that is not a bundle keeps its own directory. - try std.testing.expect(!samePardesExecutable( + try std.testing.expect(samePardesExecutable( "/work/zig-out/pardes/Contents/MacOS/pardes", "/work/zig-out/bin/pardes", )); diff --git a/src/pardes.zig b/src/pardes.zig index 3639af0f..6eb8a740 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -137,6 +137,10 @@ test { const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; +// Kept separate from the path so a click still expands to the exact filename. +// It belongs to the live, read-only prefix rather than the editable command +// tail: saving removes it without rewriting anything the user typed there. +const dirty_marker = " *"; // Version-1 dumps originally persisted only the whole rendered tag. These were // the two canonical tails before New joined every pane; the compatibility // parser recognizes them as defaults while new dumps carry an explicit tail. @@ -767,6 +771,37 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice()); } +test "an unsaved file marker sits between its path and builtins until Save" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.hxOpenFileContent("before\n"); + + const clean = try p.tagText(p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, clean, "/hxcase.txt *") == null); + + file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); + const dirty = try p.tagText(p.scratch.allocator(), pane); + const marker_at = std.mem.indexOf(u8, dirty, "/hxcase.txt *") orelse return error.MissingDirtyMarker; + const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; + try std.testing.expect(marker_at < save_at); + + try std.testing.expect(p.executeBuiltinLine(0, "Save")); + const saved = try p.tagText(p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, saved, "/hxcase.txt *") == null); + + file_pane.changed(p, 0, "external\n"); + const reloaded = try p.tagText(p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, reloaded, "/hxcase.txt *") == null); + + // A generated output is file-shaped but has nothing it can save. + pane.file.?.output = .{ .from = .search }; + file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); + const output = try p.tagText(p.scratch.allocator(), pane); + try std.testing.expect(std.mem.indexOf(u8, output, "/hxcase.txt *") == null); +} + test "unknown Exec from an image writes to a terminal in the image directory" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{}); @@ -5668,7 +5703,11 @@ pub const Pardes = struct { .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path }, ); if (pane.image) |*state| return image_pane.tagPrefix(arena, state); - if (pane.file) |f| return arena.dupe(u8, f.path); + if (pane.file) |f| { + if (output_pane.fileTraits(f.output).saves and f.revision != f.saved_revision) + return std.fmt.allocPrint(arena, "{s}{s}", .{ f.path, dirty_marker }); + return arena.dupe(u8, f.path); + } return arena.dupe(u8, pane.cwdSlice()); } @@ -12308,7 +12347,7 @@ pub const Pardes = struct { .generic; const current = p.tagPrefix(pane) catch return; if (std.mem.startsWith(u8, src.tag, current)) - return p.restoreTailAt(pane, src.tag, current, tail_class); + return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); if (src.kind != .image) return; const saved = src.image.?; if (pane.image) |*state| if (image_pane.legacySavedPrefix(state, src.tag)) |legacy| @@ -12322,7 +12361,8 @@ pub const Pardes = struct { /// retaining genuinely edited bytes. fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { const pfx = p.tagPrefix(pane) catch return; - p.restoreTailAt(pane, saved_tag, pfx, tailClass(pane)); + const class = tailClass(pane); + p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); } const TailClass = enum { generic, savable_file }; @@ -12332,6 +12372,17 @@ pub const Pardes = struct { return .generic; } + /// A dirty marker may be present in the rendered compatibility tag of an + /// untouched file. It is live prefix chrome, not a custom command tail; + /// consume it while recovering old dumps so it disappears after Save. + fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { + if (class != .savable_file or !std.mem.startsWith(u8, saved_tag, live)) return live; + const marked_len = live.len + dirty_marker.len; + if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker)) + return saved_tag[0..marked_len]; + return live; + } + fn restoreTailAt( _: *Pardes, pane: *Pane, diff --git a/src/tutor.txt b/src/tutor.txt index 2fb89ea0..5a8335d3 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -129,7 +129,8 @@ THE TAG: each pane has a one-line tag: its directory or file path + builtins. (The mode is the box at the tag's left end, not a word in the tag.) Every pane shows "New" for an empty temporary file in its column. - File panes show "Save New Del" by default; Save writes the current file + File panes show "Save New Del" by default; an unsaved edit puts `*` after + the filename. Save writes the current file to disk, Del closes the window. Clicking a tag edits it in insert mode: type straight in, Enter looks / Tab executes body focuses that same tag in NORMAL mode, parked on the FIRST WORD of |
